Java内存模型与volatile不得不说的秘密

    java内存模型(Java Memory Model,JMM)是java虚拟机规范定义的,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上都能达到内存访问的一致性。

    说到java内存模型,首先要说一下计算硬件方面的知识。

CPU与主内存

    我们知道cpu和内存是计算机组成里扮演着重要的角色,它们之前会频繁交互,随着CPU发展越来越快。内存的读写速度远远落后于CPU的处理速度。所以CPU厂商在CPU上加了高级缓存,来解决对内存的访问差异,如下图:

                Java内存模型与volatile不得不说的秘密_第1张图片
    一般高速缓存有三级L1,L2,L3,CPU不直接与内存发生交互,CPU先从L1中寻找数据,L1中没有就去L2寻找数据,L2没有就去L3寻找数据,最后才是去主存寻找。

    多核CPU呢?,每个CPU上又有高速缓存,CPU与内存的交互就变成了下面这个样子:
Java内存模型与volatile不得不说的秘密_第2张图片

这样就会引发一个问题:缓存不一致

为什么会出现这个问题呢?

CPU需要修改某个数据,是先去Cache中找,如果Cache中没有找到,会去内存中找,然后把数据复制到Cache中,下次就不需要再去内存中寻找了,然后进行修改操作。而修改操作的过程是这样的:在Cache里面修改数据,然后再把数据刷新到主内存。其他CPU需要读取数据,也是先去Cache中去寻找,如果找到了就不会去内存找了。

所以当两个CPU的Cache同时都拥有某个数据,其中一个CPU修改了数据,另外一个CPU是无感知的,并不知道这个数据已经不是最新的了,它要读取数据还是从自己的Cache中读取,这样就导致了“缓存不一致”。

其实对于这样的描述并不是十分准确,因为计算、读取等操作都是在CPU的寄存器中进行的,这样的描述是为了让问题变得更简单,相信学过计算机体系的人应该非常清楚整个流程,在这里就简单的描述下。

解决这个问题的方法有很多,比如:

  • 总线加锁(此方法性能较低,现在已经不会再使用)
  • MESI协议
Java内存模型

    java内存模型和CPU缓存模型类似,是基于CPU缓存模型来建立的,java线程的内存模型是标准化的,屏蔽掉了各种计算机对内存访问的不一致性。

Java内存模型与volatile不得不说的秘密_第3张图片

  • 主内存:共享区域,java内存模型规定所有的变量都存放在主内存中,这里主内存是java虚拟机内存的一部分,而java虚拟机内存又是物理机主内存的一部分。
  • 工作空间:线程独有的空间,保存的是主内存中变量的副本,只有当前线程才能访问自己的工作空间,不同线程之前不能相互访问其工作空间。
Java内存特点
  • 原子性(synchronized、Lock接口、Atomic类型)
  • 可见性(volatile、synchronized、final)
  • 可见性(有序性主要涉及了指令重排序现象和工作内存与主内存同步延迟现象)
JMM线程原子操作
  • read(读取):从主内存中读取数据。
  • load(载入):将主内存读取到的数据写入到工作空间。
  • use(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好的值重新赋值到工作内存中。
  • store(存储):将工作内存中的数据写入到主内存中。
  • write(写入):将store过去的变量值赋值给主内存中的变量。
  • lock(锁定):将主内存变量加锁,表示为线程独占状态。
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量。
    除lock、unlock、write是作用于内存,其余都是作用于工作空间,结构如图:
    结合程序分析:
public class JmmTest {

    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        Thread b = new Thread(() -> {
            System.out.println("开始执行线程B.");
            while (flag) {

            }
            System.out.println("结束执行线程B.");
        }, "B");
        
        Thread a = new Thread(() -> {
            System.out.println("开始执行线程A.");
            flag = false;
            System.out.println("结束执行线程A.");
        }, "A");
        b.start();
        TimeUnit.SECONDS.sleep(3L);
        a.start();
    }
}

Java内存模型与volatile不得不说的秘密_第4张图片
    以上代码会出现一个问题,那就是死循环问题,首先从主内存中读取数据,将读取到的数据装载到工作空间中,当线程A拿到变量并将变量值赋值true后,返写到工作空间中,但工作空间中的值并不会被及时及时刷新到主内存中,当刷新到主内存中线程B也不会及时读取,所以就有了死循环现象。

volatile关键字
    volatile关键字其特点是:可见性、禁止指定重排序、原子性(这里的原子性需要排除i++操作,i++操作是读且写,并不是一个原子操作)。以上代码如何解决呢?其实很简单,在变量上加上volatile关键字即可。private static volatile boolean flag = true;这里省略代码直接看图:
Java内存模型与volatile不得不说的秘密_第5张图片
    首先将代码反编译成汇编语言可以看到lock指令
在这里插入图片描述
lock指令的作用:

  • 锁定当前内存区域的缓存并回写到主存中
  • 写回内存的操作会让其它CPU里缓存了该内存地址的数据无效(MESI缓存一致性协议)
  • 会将当前处理器缓存行的数据立即回写到主存中

    那么当知道lock指令后就不难理解,首先从主内存中读取数据,将读取到的数据装载到工作空间中,当线程A拿到变量并将变量值赋值true后,返写到工作空间中,这时会通过store和write操作立即把数据刷新到主存中,在数据从cpu反写到主存中需要经过总线,当另一个cpu中的线程B通过总线嗅探机制监听到有相同变量的数据反写到主存时,会将自身相同变量的值置为无效,无效后会重新读取主存中的值。
这样也会出现问题,线程A反写数据经过总线后并没有写入到主存中,这时线程B工作空间的值已经失效并重新读取主存中的值时依然是true。其实在回写时会在write操作上锁(lock)知道直到完全写入主存才会释放锁(unlock),这期间其它线程是不能访问主存的。

你可能感兴趣的:(JAVA)