Java 内存模型

Posted 皓洲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 内存模型相关的知识,希望对你有一定的参考价值。

Java 内存模型

参考视频:https://www.bilibili.com/video/BV1F64y1B7sV

参考博客:https://zhuanlan.zhihu.com/p/29881777

硬件内存模型

周所周知:CPU的处理速度和内存的读写读写速度是不在一个数量级的,所以需要CPU在内存之间加上缓存来进行提速,这就呈现了一种CPU-寄存器-缓存-主存的访问结构。

多CUP缓存产生的同步问题

当一台计算机出现了多个CPU的时候就会出现如下的问题:

  • 假如CPU A将数据D从主存读取到独占的缓存内,将D改为D1
  • 同时CPU B将数据D从主存读取到独占的缓存内,将D改为D2
  • 然后A B同时将数据D写入主存中,那么主存的值会变成D1还是D2?

针对这个多个CPU缓存之间的同步问题,科学家们制定了缓存一致性协议

CPU指令重排

关于缓存一致性协议,可以猜想的是,其内容一定是一些和数据同步相关的操作。

既然要进行数据同步,就很可能出现等待、唤醒这样的操作,这可能导致性能问题,尤其是对于CPU这种运算速度极快的组件来说,丝毫的等待都是极大的浪费。

比如CPU B想要读取数据D的时候,还要等待CPU A将D写回主存。这种行为是难以忍受的。

因此,计算机科学家们做了一些优化,怎么优化呢?整体思想就是将同步改成异步。

比如CPU B要读取数据D时,发现D正在被其他CPU进行修改,那么此时CPU B注册一个读取D的消息,自己回头去做其他的事情,其他CPU写回数据D后 响应了这个注册消息。此时CPU B发现了这个消息被相应后,再去读取D 这样就能够提升效率。

但是对于CPU B来说,程序看上去就不是顺序执行的了,有可能会出现先运行后面的指令,再回头去运行前面的指令,这一种行为就体出了一种指令重排序

Java内存模型

Java内存模型屏蔽了各种硬件和操作系统的内存访问差异,实现了让Java程序能够在各种硬件平台下都能够按照预期的方式来运行。

它的抽象如图所示:

这里说明一下:

Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。

我们可以将工作线程和本地内存具象化为Thread Stack,将主存具象为Heap

Thread Stack有两种类型的变量:

  1. 原始类型的变量,比如(int、char等)总是存储在线程栈上。
  2. 对象类型变量,引用(指针)本身存储在线程栈上,而引用指向的对象存储在堆上。

在Heap中存储对象本身,持有对象引用的线程就能够访问该对象,Heap本身不关心哪个线程正在访问对象。

我们可以这么理解:Java线程模型中Thread Stack和Heap都是对物理内存的抽象。

Thread Stack大部分都是使用寄存器和CPU缓存来实现的,而Heap中需要储存大量的对象,需要大量的容量,所以大部分是使用主存来实现的。

线程通信中可能存在的问题

  • 可见性问题

    假如本地内存A和本地内存B中存在着数据x的副本,且x值为1,当线程A将X修改为2并且将数据写入主存后,当线程B想要读取X,默认的会从本地内存B中读取,而本地内存B的x依然是等于1的。换言之,线程A刷新了主存中x的值,线程B却不知道x的值发生了改变。

  • 原子性问题

    假如线程A和线程B同时读取主存中x的值,此时x为1。当线程A和线程B对x进行自增1后,放回主存,此时主存中x值变成2,但是x经过线程A和线程B的两次自增,x的值应该变为3。

并发三要素

这些问题呢,被总结为三个要素:可见性、原子性、有序性

可见性

可见性指的是,当一个线程修改共享变量的值,其他线程需要能够立即得知这个修改

  • 解读一

    线程A修改了数据D,线程B需要督导修改后最新的D。(由刷新主存的时机引起的)

    写一个例子:线程T1,在a!=2的时候进行死循环,一秒钟后,线程T2将a的值改为2。此时如果线程T1能够读到a的值被改为2,那么会跳出死循环。可是事实上线程T1会一直进行死循环。可以证明他们不满足可见性

static int a = 1;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (a!=2);//只要a!=2就死循环
        }
    });

    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            a=2;
        }
    });

    t1.start();
    Thread.sleep(1000);
    t2.start();
}

解决方法1:使用volatile关键字。写volatile变量,主动写主存;读volatile变量,主动读主存。

解决方法1:使用synchronized关键字。synchronized块内部读写变量,隐式调用内存lock,unlock指令,主动读主存。

  • 解读二

    线程B需要读到被修改的变量D,线程A应该修改但是因为重排序导致线程A没有及时修改变量D。(由指令重排序引起的)

举个例子:下面代码执行到代码4时,变量b是否等于1?

static int a = 0;
static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            a=1;    // 代码1
            flag=true;  //代码2
        }
    });

    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            if(flag){   // 代码3
                int b = a;  // 代码4
            }
        }
    });

    t1.start();
    t2.start();
}

答案是否定的,我们在最上面提到过硬件内存模型中存在者指令重排序机制,Java内存模型中也存在指令重排序,它们的作用和约束都是一样的,第一是为了更高的执行效率,第二是在单线程中重拍后能够保证程序执行结果的正确性,就是说和顺序执行的结果是一样的。

解决方法1:使用volatile关键字。禁止volatile变量与之前的语句进行重拍,volatile这行相当于“基准线”当运行到基准线,能保证之前语句和自身的可见性。将flag加上volatile关键字就可以解决问题了。

解决方法1:使用synchronized关键字。synchronized块内部读写变量,隐式调用内存lock,unlock指令,主动读主存。使用synchronized将代码1和代码2进行包裹,这样其内部无论如何进行重排,外部只能看到最终结果,这样也保证了可见性。

原子性

原子性指的是,一个操作时不可中断的,要么全部执行,要么全部失败。

  1. 单指令原子操作
  2. 利用锁机制的原子操作,反应到上层就是synchronized关键字

有序性

上文其实我们已经详细讲解到了无论是从硬件内存模型还是Java内存模型来看,都支持指令重排这种优化操作,在单线程中虽然指令可能会被重排序,但是在单线程中内存模型能够保证执行结果的准确性,也就是说在单线程中无论指令如何重排,他最终的执行结果和顺序执行的结果时一样的,但是在多线程环境下就可能因为重排而导致一些问题。

我们在上面就提到过了由于指令重排引起的可见性问题,所以在这里不在继续讲了。

以上是关于Java 内存模型的主要内容,如果未能解决你的问题,请参考以下文章

详解Jvm内存结构

详解Jvm内存结构

java 片段将重用以前膨胀的根视图,这可以节省内存。好可怜 ......

Jvm内存模型

java内存模型的JMM简介

Java内存模型