抽丝剥茧 volatile 关键字

 

在之前的手把手教你写单例 的文章中,我们了解到,DCL单例代码中,需要使用volatile 关键字来保证变量对所有线程可见,且禁止指令重排优化。

关键字volatiale 可以说是Java 虚拟机提供的最轻量级的同步机制。说到同步机制,可能第一想到的是,在处理多线程数据竞争问题的时候,会使用synchronized来进行同步,很少去使用volatile。 那什么场景下可以使用volatile呢? 本文旨在 弄清楚volatile 的语义到底是什么?

1 volatile 型变量的特殊规则

当一个变量被定义为volatile 之后,它将具备两种特性:

第一,保证此变量对所有线程的可见性。

第二,禁止指令重排。

1.1 可见性

       这里的“可见性” 是指当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量做不到这一点。例如线程A 修改一个普通变量的值,需要将新值写回主内存,线程B 在线程A 回写完成之后再从主内存进行读取操作,新变量值才会对B可见。也就是说,如果线程B 在A 修改没有回写主内存之前 读取了变量的值,那A的修改对B线程不可见。 

       这里我们提到了Java 的主内存,就不得不说 Java 内存模型 (JAVA Memory Model,JMM)。JMM 的主要目标是定义程序中 各个变量的访问规则,即在虚拟机中将变量存储到内存 和从内存中取出变量这样的底层细节。这里的变量并不是Java的变量,而是实例字段、静态字段和构成数组对象的元素,这些数据会被共享,存在竞争问题。

       JMM 规定了所有变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读取主内存中的变量。

     

 JMM 的主内存,仅是虚拟机内存的一部分,这样的设计 源于借鉴 物理计算机对并发问题的处理方案。

       CPU在处理任务的时候难免要和内存交互,对数据进行I/O 读写,我们了解到,CPU 的运算速度远高于内存I/O读写的速度,为了解决存储器和cpu速度的矛盾,引入了读写速度近可能接近cpu运算速度的高速缓存,将cpu运算需要的数据先复制到高速缓存中,当运算结束后再从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。

       但是高速缓存的使用又引入了另外一个问题:缓存一致性。每个处理器都有自己的高速缓存,而这些缓存又共享同一个主内存,那么同步回主内存时,以哪个高速缓存为准呢? 这个时候定义了缓存一致性协议来解决这个问题,本文不做过多的赘述。

         

这里有很强的类比性。JMM 这里所说的主内存,和java 所说的堆栈并不是同一个层次的内存划分,这两者基本上是没有关系的,如果一定要勉强对应起来,主内存主要对应于Java 堆中的对象实例数据,而工作内存则对应于栈中的部分数据。从更低层次上来说,JMM 的主内存直接对应物理机器的内存,工作内存会更优先于存储于高速缓存中,而线程则是cpu能够进行运算调度的最小单元。上面两张图可很好的对应起来。

到此,我们来继续探索一下,volatile 是如何保证变量对所有线程可见的。

DCL 单例代码如下:


public class Singleton {
    private static volatile Singleton singleton = null;  //1.volatile保证对线程的可见性并禁止指令重排优化

    private Singleton(){}

    public static Singleton getSingleton(){
        if(singleton == null){   //2.提高效率,减少synchronized加锁操作
            synchronized (Singleton.class){  //3.对象锁,阻塞
                if(singleton == null){
                    singleton = new Singleton();   //不加volatile关键字多线程情况下用户会拿到半个对象
                }
            }
        }
        return singleton;
    }

    public static void main(String[] args) {
        Singleton.getSingleton();

    }

}

上述代码有volatile 关键字和无volatile关键字,分别获取JIT 的反汇编代码如下:

(需要下载hsdis-amd64.dylib,并放在 $HAVA_HOME/jre/lib/server 目录下,且需要授权775)

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=compileonly,Singleton.getSingleton Singleton > a.txt 

对比两个代码的汇编,在79行(代码的第8行)会发现,有volatile 修饰的变量,赋值后 有一个lock addl $0x0, (%rsp) 操作(把rsp 寄存器的值➕0)。这个操作有两层含义:

1. 使得本CPU 的 缓存写入内存。这个操作相当于JMM 里面的 store 和 write操作

2. 通知别的CPU无效化其缓存。其他cpu在使用该变量的时候,需要再从主内存读。

所以,通过这个lock 指令,使得线程对变量的修改被其他CPU 立即可见。

那么lock指令是怎么实现这两层含义的呢? 

我们知道,CPU中有三类总线,其中数据总线是用来传输数据的,即cpu 在内存或者其他器件之间是通过数据总线来传输数据的。Lock 的作用其实是本cpu对数据总线加锁,阻塞了其他cpu对内存的访问,从而解决了缓存不一致的问题。

1.2 指令重排与先行发生原则

为了充分利用CPU的计算单元,CPU会对输入的代码进行乱序执行优化。CPU会在计算之后将乱序执行的结果重组,保证重组之后的结果和顺序执行的结果一致。也就是说CPU并不能保证程序中各个语句的计算顺序与输入代码中的顺序一致。

通过上文我们知道,lock addl $0x0, (%rsp) 把指令修改同步到内存时,意味着所有之前的操作都已经执行完成了,并且其他线程在使用该变量的时候,需要从内存重新读取。这样就行程了“指令重排无法越过内存屏障”的效果了。

 

2 JMM 特性

JMM 是围绕并发过程中,如何处理原子性、可见性、有序性这三个特征来建立的。

2.1 原子性

java 主内存和工作内存之间数据的交互,主要是通过8个动作来完成的。这些动作都是原子的。

  • lock(锁定):作用于主存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主存变量,把一个变量的值从主存传输到工作内存。
  • load(载入):作用于工作内存变量,把 read 来的值放入工作内存的变量副本中。
  • use(使用):作用于工作内存变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存变量,把工作内存中一个变量的值传送到主存。
  • write(写入):作用于主存变量,把 store 操作从工作内存中得到的变量的值放入主存的变量中。

如果要把一个变量从主存复制到工作内存:顺序执行 readload 操作。

如果要把变量从工作内存同步会主存:顺序执行 storewrite 操作。

我们可以认为基本数据类型的访问读写是具备原子性的。同时,更大范围的原子性保证,JMM 在lock 和 unlock的基础上,提供了字节码指令 monitorenter 和montierexit 来保证原子性,synchronized 就是通过这两个命令来保证原子性的。
 

2.2 可见性

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

不管是volatile 变量还是普通变量,线程之间数据交互的方式都是通过主内存来完成的。普通变量和volatile变量的区别在于,volatile 的特殊规则 保证了新值能立即同步到主内存,以及每次使用volatile变量前都立即从主内存读取。但是普通变量做不到这一点,笔者在学习过程中,也存有疑惑,对于普通变量而言,线程是在什么时机将工作内存中的普通变量 写回主内存的呢? 

除了volatile 之外,还有synchronized 和 final 都可以实现可见性。

2.3 有序性

Java 天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察两一个线程,所有的操作都是无序的。后半句主要是指,指令重排现象和 工作内存与主内存同步会有延迟。

 

 

以上,需要区分的是JMM 和JVM 内存模型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值