跳到主要内容

02、Java JUC源码分析 - 从硬件来看volatile实现原理

1. 先谈volatile的作用

volatile关键字的主要作用是保证内存可见性和禁止指令重排序。
后续围绕这两个作用分别进行底层实现原理的剖析。

2. 我们先聊一下CPU级别的东西

术语 英文单词 术语描述
内存屏障 memory barriers 是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行 cache line CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令。
原子操作 atomic operations 不可中断的一个或一系列操作
缓存行填充 cache line fill 当处理器识别到一个从内存中读取操作数是可缓存的,处理读取整个告诉缓存行到适当的缓存(L1,L2,L3或所有)
缓存命中 cache hit 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取

2.1. 现代CPU架构图(core i7为例):

 

2.2. CPU缓存

CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:

一次主内存的访问通常在几十到几百个时钟周期
一次L1高速缓存的读写只需要1~2个时钟周期
一次L2高速缓存的读写也只需要数十个时钟周期
这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。

基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。

按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

  • 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存
  • 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半
  • 三级缓存:简称L3 Cache,部分高端CPU才有(我们使用的大多都有)
    每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。

2.2.1. CPU缓存带来的问题(针对多个CPU)

当系统运行时,CPU执行计算的过程如下

1、 程序以及数据被加载到主内存;
2、 指令和数据被加载到CPU缓存;
3、 CPU执行指令,把结果写到高速缓存;
4、 高速缓存中的数据写回主内存;

试想一种情况:

  • 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
  • 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
  • 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
  • 核3访问该字节,由于核0并未将数据写回主存,数据不同步

为了解决这个问题,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。

3. volatile是如何保证内存可见性的?

我们以懒汉式单例为例,来观察一下加了volatile关键字,CPU层面会怎么做?

3.1 案例

/**
 * 懒汉式单例(多加了一个volatile关键字)
 * 缺点:线程不安全。
 * 优点:节省了资源
 *
 * @author Saint
 */
public class LazySingleton {
   
     
    private static volatile LazySingleton singleton;

    private LazySingleton() {
   
     
    }

    public static LazySingleton getSingleton() {
   
     
        if (singleton == null) {
   
     
            singleton = new LazySingleton();
        }
        return singleton;
    }

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

这段代码中特殊的地方是,我将实例变量singleton加上了volatile修饰。我们先来看看字节码指令
字节码指令如下:
 
从字节码来看,没任何特别的。字节码指令,比如上图的getstatic、ifnonnull、new等,最终对应到操作系统的层面,都是转换为一条一条指令去执行,我们使用的PC机、应用服务器的CPU架构通常都是IA-32架构的,这种架构采用的指令集是CISC(复杂指令集),而汇编语言则是这种指令集的助记符。

既然字节码层面看不到什么端倪,我们深入汇编指令看看。

3.1.1打印汇编指令的方法

1、 访问hsdis工具路径可直接下载hsdis工具,下载完毕之后解压,将hsdis-amd64.dll与hsdis-amd64.lib两个文件放在%JAVA_HOME%\jre\bin\server路径下即可,如下图:;
  2、 然后跑main函数,跑main函数之前,加入如下虚拟机参数:;

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getSingleton

1、 注意2.中的*LazySingleton.getSingleton为main方法中调用的方法;
2、 运行main函数即可,代码生成的汇编指令(关键部分)为:;

CompilerOracle: compileonly *LazySingleton.getSingleton
Loaded disassembler from E:\SoftWareWork\JDK\JDK8\jdk1.8.0_231\jre\bin\server\hsdis-amd64.dll
Decoding compiled method 0x0000000003706c90:
Code:
...........
  0x0000000003706ee7: call    36461a0h          ; OopMap{
   
     [32]=Oop off=236}
                                                ;*invokespecial <init>
                                                ; - cn.com.saint.singleton.LazySingleton::getSingleton@10 (line 19)
                                                ;   {
   
     optimized virtual_call}
  0x0000000003706eec: mov     rax,76bbfc0e8h    ;   {
   
     oop(a 'java/lang/Class' = 'cn/com/saint/singleton/LazySingleton')}
  0x0000000003706ef6: mov     rsi,qword ptr [rsp+20h]
  0x0000000003706efb: mov     r10,rsi
  0x0000000003706efe: shr     r10,3h
  0x0000000003706f02: mov     dword ptr [rax+68h],r10d
  0x0000000003706f06: shr     rax,9h
  0x0000000003706f0a: mov     rsi,0f3a9000h
  0x0000000003706f14: mov     byte ptr [rax+rsi],0h
  // 注意此处的lock指令
  0x0000000003706f18: lock add dword ptr [rsp],0h  ;*putstatic singleton
                                                ; - cn.com.saint.singleton.LazySingleton::getSingleton@13 (line 19)

  0x0000000003706f1d: mov     rax,76bbfc0e8h    ;   {
   
     oop(a 'java/lang/Class' = 'cn/com/saint/singleton/LazySingleton')}
  0x0000000003706f27: mov     eax,dword ptr [rax+68h]
  0x0000000003706f2a: shl     rax,3h            ;*getstatic singleton
                                                ; - cn.com.saint.singleton.LazySingleton::getSingleton@16 (line 21)
 ..........
Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output

这么长的汇编代码,哪知道CPU在哪里做了手脚,我们定位到带lock指令的两行。
 
之所以定位到这两行是因为这里结尾写明了line 19,line 19即volatile变量singleton赋值的地方。后面的add dword ptr [rsp],0h都是正常的汇编语句,意思是将双字节的栈指针寄存器+0,这里的关键就是add前面的lock指令,后面详细分析一下lock指令的作用和为什么加上lock指令后就能保证volatile关键字的内存可见性。

3.2. Lock指令

3.2.1. Lock指令在多核处理器下做了什么?
  • 将当前处理器缓存行的数据写回到系统内存。
  • 写回系统内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

实现细节:

  • 现代操作系统,为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读取到内部缓存后进行操作,但是操作完并不知道何时写回到内存。
  • 对声明了volatile的变量进行写操作,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
  • 但是就算写回到了系统内存,如果其他处理器缓存的值还是旧值,再执行计算操作就会出问题。
  • 所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,例如MESI、snooping(嗅探)。
  • 每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态。当处理器对这个数据进行修改操作的时候,会重新从系统内存中读取数据到处理器缓存里。
3.2.2. Lock指令锁总线?

锁总线时,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存。

3.3. 缓存一致性协议

LOCK#会锁总线,实际上这不现实,因为锁总线效率太低了。因此最好能做到:使用多组缓存,但是它们的行为看起来只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。

缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于"嗅探(snooping)"协议,它的基本思想是:

1、 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存);
2、 CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效;

MESI协议是当前最主流的缓存一致性协议,在MESI协议中,每个缓存行有4个状态,可用2个bit表示,它们分别是:
 

  • 只有当缓存行处于E或者M状态时,处理器才能去写它,即处理器是独占这个缓存行的。当处理器想写某个缓存行时,如果它没有独占权,它必须先发送一条"我要独占权"的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(如果有)。只有在获得独占权后,处理器才能开始修改数据。
  • 如果有其它处理器想读取这个缓存行(马上能知道,因为一直在嗅探总线),独占或已修改的缓存行必须先回到"共享"状态。如果是已修改的缓存行,那么还要先把内容回写到内存中。

3.4. 多线程操作volatile变量

当写线程Thread-A与读Threab-B同时操作主存中的一个volatile变量i时。
1)Thread-A写了变量i,则:

  • Thread-A发出LOCK#指令
  • 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效
  • Thread-A向主存回写最新修改的i

2)Thread-B读取变量i,则:

  • Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值

由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。

3.5. volatile关键字相关的原子操作

volatile中read load use// assign store write 动作必须是连续的。并且它们是工作内存和主内存交互的几种原子操作。

1、 lock:作用于主内存,把变量标识为线程独占状态;
2、 unlock:作用域主内存,解除独占状态;
3、 read:作用于主内存,把一个变量的值从主内存传输到线程的工作内存;
4、 load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中;
5、 use:作用于工作内存,把工作内存中的一个变量传给执行引擎;
6、 assign:作用与工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量;
7、 store:作用与工作内存的变量,把工作内存中的变量值传送到主内存中;
8、 write:作用于主内存的变量,把store操作传来的变量的值放入到主内存的变量中;

Volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

4. volatile是如何保证有序性的?

volatile有序性的保证就是通过禁止指令重排序来实现的。指令重排序包括编译器和处理器重排序。禁止指令重排序只要由内存屏障实现。

4.1. CPU级别的内存屏障

  • lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
  • sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。
  • mfence,即全能屏障,具备ifence和sfence的能力。、
  • Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。

4.2. JVM级别的内存屏障

 

  • StoreStore屏障:Store1—>StoreStore—>Store2 确保 Store2 以及后续 Store 指令执行前,Store1 操作的数据对其它处理器可见。
  • StoreLoad屏障(是一个全能屏障):Store1 --> StoreLoad --> Load2。确保Load2和后续的Load指令读取之前,Store1的数据对其他处理器是可见的。
  • LoadStore屏障:Load1—>LoadStore—>Store2 确保 Store2 和后续 Store 指令执行前,可以访问到 Load1 加载的数据。
  • LoadLoad屏障:Load1—>Loadload—>Load2 确保 Load2 及后续 Load 指令加载数据之前能访问到 Load1 加载的数据。

4.3. volatile中使用的内存屏障

StoreStore 写操作  StoreLoad
LoadLoad 读操作  LoadStore

 

  • 第二个操作是volatile写时,无论第一个操作是什么,都不会发生指令重排序。
  • 第一个操作是volatile读时,无论第二个操作是什么,都不会发生指令重排序。
  • 第一个操作是volatile写,第二操作是volatile读时,不会发生指令重排序。

4.4.JSR-133对volatile内存语义的增强

JSR-133之前的旧的java内存模型中,虽然不允许对volatile变量之间的操作进行重排序,但允许对volatile变量与普通变量之间进行重排序。比如内存屏障前面是一个写volatile变量的操作,内存屏障后面的操作是一个写普通变量的操作,即使这两个写操作可能会破坏volatile内存语义,但JMM是允许这两个操作进行重排序的。

JSR-133以及后面的新的java内存模型中,增强了volatile的内存语义。只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障策略禁止

5. 两道面试题

5.1 是否需要volatile关键字?

面试官:以下代码段是否需要给flag变量加volatile关键字?

public class Test2 {
   
     
    private static boolean flag;
    public static void main(String[] args) {
   
     
        new Thread( () -> {
   
     
            System.out.println("start");
            while(!flag) {
   
     

            }
            System.out.println("end");
        }).start();
        flag = true;
    }
}

答:

  • 这里不涉及任何重排序和可见性问题,只是JIT及时编译器对代码的一个优化
  • 在字节码层面,通过__asm_volatile指令来实现。
  • 我们手动加volatile也可以,但是加上之后会放弃JIT的一些编译优化,尽量不要用。

5.2. DCL中volatile关键字的作用?

要给对象加上volatile关键字:保证线程间可见,阻止指令重排序,否者就会出现对象值为0/false的问题。

对象new的过程有两步:

  • 第一步是准备阶段,分配内存,赋默认值(0/flase)
  • 第二步是初始化阶段,调用构造方法赋值给新的对象。

出现问题的场景:

  • 因为指令重排序,可能在半初始化(分配内存)之后、直接就调用store指令对对象进行赋值,最后在进行构造方法初始化。
  • 然而有可能在进行构造方法初始化之前,进来了一个线程,此时对象已经分配了内存地址并且值已经为0 ,不再是null值,所以就会出现对象值为0/false的情况。

版权声明:本文不是「DDKK.COM 弟弟快看,程序员编程资料站」的原创文章,版权归原作者所有

原文地址:https://saint.blog.csdn.net/article/details/129029852