what volatile?

在多线程情况下,代码中的某个变量的值可能出现类似“脏读”的情况(数据版本不一致),为什么呢?

首先要从计算机的内存模型和JVM定义的抽象的Java内存模式(JMM,即Java Memory Model)

计算机内存模型

由于cpu发展迅猛,cpu指令速度远超内存的存取速度,而处理器运算与计算机存储设备的运算速度有几个数量级的差距,所以计算机引进一层读写速度尽可能接近处理器运算速度的高速缓存(Cache),作为主内存与CPU之间的缓冲。

缓存对需要使用到的数据进行类似备份的操作,运算结束再从缓存同步回内存。但问题显而易见,在多核多线程并发处理时,它们各自维护一个Cache,又需要共享整一个主内存。

JMM模型

以线程角度描述,每个线程都有私有的工作内存,而所有的共享变量(实例变量和类变量,不包含局部变量)是存放在主内存的。工作内存保留了被本线程使用的变量的工作副本(线程对变量的操作都必须在工作内存中完成,不能直接操作主内存中的变量)

机制的概念大概讲完,那么多线程并发的情况下也是会出现可见性问题。

解决方案

  • 加锁(synchronized)

底层流程:

  1. 线程获得锁,清空工作内存
  2. 从主内存拷贝最新的共享变量值作为工作内存副本
  3. 执行完操作后将副本值刷新回主内存
  4. 释放锁

因为只有一把锁,每次只能一个线程获取锁,所以不会出问题

  • volatile修饰共享变量

底层细节:线程从主内存拷贝副本到工作内存,如果操作了数据并刷回主内存,其他已经读取变量的线程的副本数据就会失效,其他线程需要重新去主内存拷贝新副本。

简单地说,就是保证对所有共享变量的线程可见

再讲回计算机层面,Cache也将会导致数据不一致的问题,而CPU直接怎么共享?

Cache与Main Memory之间要遵循一些协议,读写操作需要根据协议要求进行操作。

这里主题是volatile关键字,所以要了解一下Intel的MESI协议(缓存一致性协议)

禁止指令重排(由内存屏障实现)

指令重排

为了提高效率,将性能发挥到极致,编译器和处理器通常会对代码执行顺序编译成的指令进行优化、重排序。

JMM对底层尽量减少了约束,使其更好的发挥性能。因此,编译器和处理器常会对指令重排序。

重排序一般涵盖三种:

  • 编译器优化的重排序:编译器层面的,在不改变单线程程序语义的条件下,重新安排语句的执行顺序。
  • 指令并行重排序:处理器层面的,多核处理器下可采用并行技术,多条指令重叠执行。如果没有数据依赖的情况下,处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统的重排序:CPU通过使用Cache和读写缓冲区,这样加载和存储看上去就是可能乱序执行的。

还要提到一个必要的概念,as-if-serial

重排序之后,保证单线程下的执行结果不能被改变。

编译器,runtime(内存管理)和处理器都遵循as-if-serial

volatile实现禁止指令重排(内存屏障-Memory barrier)

编译器会在生成指令时在合适的位置插入内存屏障指令来禁止特定类型(读/写)的处理器重排序。

硬件层面的内存屏障分两种

  • 读屏障 Load Barrier
  • 写屏障 Store Barrier

那么volatile在JMM中针对编译器制定的重排序规则是怎么样的呢?

  • volatile写是会在前面和后面分别插入内存屏障,靠前的内存屏障保证禁止前面的写操作和它重排序(StoreStore屏障),靠后的内存屏障保证禁止后面的读与它重排序(StoreLoad屏障)。
  • volatile读是会在后面插入两个内存屏障,一个禁止后面的读操作重排序(LoadLoad屏障),一个禁止后面的写操作重排序(LoadStore屏障)。

注:StoreStore屏障表示第一个操作是Store(写),第二个操作也是Store,两者中间设置的屏障称之为StoreStore屏障,其他的StoreLoad屏障等等同上。其中StoreLoad屏障的开销最大,因为它具备其他三种内存屏障的功能

禁止指令重排的好处

以单例模式中的对象创建为例,有以下步骤:

  • 分配空间
  • 调用构造器
  • 初始化实例
  • 返回地址

发生指令重排,那可能在对象初始化完成前就先将地址赋值完成了(此时对象内的属性还没完全初始化),那么分配内存空间后直接返回的地址,在线程A判断instance != null了,实例化之前先将instance指向了那块内存,线程B直接拿来使用,就拿到了不完整的对象,存在空指针的隐患。

所以要双重检测机制

public class Singleton {
    //保证可见性、不执行指令重排序
    private volatile static Singleton instance = null;

    private Singleton(){}

    public static Singleton getInstance() {
        if (instance != null) {//第一重检查
            synchronized (Singleton.class) {
                if (instance != null)//再次检查
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

volatile的happens-before

volatile域规则:对于一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。这个体现在volatile的可见性上。

volatile不保证原子性

因为volatile主要限制的是读写操作,那么Java中只对基本类型变量赋值和读取时原子操作,任何需要操作多次(读取不同变量或者写入多次)就属于多次原子操作

比如自增i++,过程是先读取i的值,对i加一,在写入i,这种非原子性操作的情况下容易出现问题。

  • 线程A读取了i=1,先记录i+1的数值=2(还没赋值给缓存的i,i依旧=1),A阻塞了,切换成线程B
  • B读取了i=1,完成了i=i+1,并刷回主存
  • 嗅探A的i=1发现不同,则所以重新从主存读取i=2,但是因为i+1数值已经计算过了=2,所以赋值给i还是2。原子性只保证在读取。

所以在需要对属性进行操作时尽量不使用volatile,仅仅只有set或者get的情况适合volatile

可以看看编译后的指令代码,其中Load到Store的组合是不安全的,这里volatile只保证Load的安全:

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier
posted @ 2020-09-05 15:57  Faink  阅读(69)  评论(0)    收藏  举报