volatile

volatile

JMM

JMM的抽象:主内存和本地内存

JMM抽象示意图

JMM有以下规定:

​ 所有变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;

​ 线程不能直接读写主内存中的变量,而是只操作自己工作内存中的变量,然后再同步到主内存中;

​ 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成;

为什么会有可见性问题

  • CPU有多级缓存,导致读的数据过期;

  • 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间就多了Cache层;

  • 线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的;

  • 如果所有核心都只用一个缓存,那么就不存在内存可见性问题了;

  • 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入主存中。所以会导致有些核心取到的值是一个过期的值;

重排序与happens-before

  1. 为什么存在重排序?为什么可以提高性能?

    a = b + c
    d = e - f

    先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。

    为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。

    指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

  2. 指令重排的类型

    • 编译器优化重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
    • 指令并行重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序;
    • 内存系统重排:由于处理器使用缓存和读写缓冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差;

happens-before

  1. happens-before原则
    • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前;
    • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序;
  2. 天然的happens-before关系
    • 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作;
    • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;
    • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
    • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C;
    • start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作;
    • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回;

作用

  • 保证变量的内存可见性
  • 禁止volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”);

原理

可见性实现:

  • 修改volatile变量时会强制将修改后的值刷新的主内存中;
  • 修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值;

禁止重排序实现

JVM限制处理器的重排序 —— 内存屏障

  • 硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。

    内存屏障有两个作用:

    阻止屏障两侧的指令重排序;
    强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效;
    (注意这里的缓存主要指的是CPU缓存,如L1,L2等)

内存屏障

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:

  • 在每个volatile写操作前插入一个StoreStore屏障;
  • 在每个volatile写操作后插入一个StoreLoad屏障;
  • 在每个volatile读操作后插入一个LoadLoad屏障;
  • 在每个volatile读操作后再插入一个LoadStore屏障;

再介绍一下volatile与普通变量的重排序规则:

  1. 如果第一个操作是volatile读,那无论第二个操作是什么,都不能重排序;
  2. 如果第二个操作是volatile写,那无论第一个操作是什么,都不能重排序;
  3. 如果第一个操作是volatile写,第二个操作是volatile读,那不能重排序;

应用

1)单例模式 —— 双重锁检查

public class Singleton {

private volatile static Singleton instance;

private Singleton() {

}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

如果这里的变量声明不使用volatile关键字,则可能会发生错误的。它可能会被重排序:

instance = new Singleton();

// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址

// 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象

而一旦假设发生了这样的重排序,比如线程A执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候另一个线程B执行到了if (instance == null) ,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!

常见问题

  1. 除了在volatile中使用了内存屏障,Java还有哪里使用了内存屏障?

    Oracle的JDK中提供了Unsafe. putOrderedObject,Unsafe. putOrderedInt,Unsafe. putOrderedLong这三个方法,JDK会在执行这三个方法时插入StoreStore内存屏障,避免发生写操作重排序;

    初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序,使用了LoadLoad屏障;

  2. 原理

    • 将当前内核高速缓存行的数据立刻回写到内存;
    • 使在其他内核里缓存了该内存地址的数据无效;

    MESI协议:该缓存一致性思路:当CPU写数据时,如果发现操作的变量时共享变量,即其他线程的工作内存也存在该变量,会通过CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。当其他线程需要使用这个变量时,如内存地址失效,那么它们会在主存中重新读取该值。

Author: Jiayi Yang
Link: https://jiayiy.github.io/2020/07/13/volatile/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.