都2020年了你还不理解volatile关键字?

先给出结论

  • volatile可以保证不同线程对一个变量的可见性,即一个线程修改了被voaltile修饰的变量后,这新值对其他线程来说是立即可见的;
  • volatile可以禁止指令重排序,保证了有序性;
  • volatile不能保证原子性,更加不能保证线程安全,要想保证原子性和线程安全还是乖乖的使用锁吧!

具体的原理且看我慢慢分析。

一、Java内存模型

JMM即Java Memory Model,它将JVM内存抽象为主内存工作内存。主内存是所有的线程共享的内存区域,包括方法区和堆内存;工作内存是线程私有的,包括程序计数器、JVM桟和本地方法桟。Java规定:

  • 所有的变量都存储在主内存中(虚拟机内存的一部分),对于所有线程都是共享的

  • 每个线程都有自己的工作内存,工作内存中保存的是该线程使用到的变量副本(该副本就是主内存中该变量的一份拷贝),线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

  • 线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成

屏蔽硬件和操作系统内存读取差异,以达到各个平台下都能达到一致的内存访问效果的产物。并且通过JMM使得程序员不必接触底层的CPU缓存、寄存器、硬件内存、CPU指令优化等。JMM的作用体现在以下几个方面:

(1)原子性

​ 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。并且Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

(2)可见性

 对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

(3)有序性

​ 在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行(as-if-serial语义),却会影响到多线程并发执行的正确性。

在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

二、volatile实现的原理

1、volatile保证可见性的原理
(1)Lock前缀指令

volatile可见性的实现原理是基于lock前缀指令实现的。

public class Test  {
    private static volatile int a = 1;
    
    public static void test() {
            a = 2;   
    }

    public static void main(String [] args) {
        test();
    }
}

上面的代码,我们添加hsdis插件到JRE的lib目录后,可以对上述代码进行反汇编打印出来的汇编指令如下:

  0x000000011a5ddf25: callq  0x000000010cb439f0  ;   {runtime_call}
  0x000000011a5ddf2a: vzeroupper
  0x000000011a5ddf2d: movl   $0x5,0x270(%r15)
  0x000000011a5ddf38: lock addl $0x0,(%rsp)
  0x000000011a5ddf3d: cmpl   $0x0,-0xd4ec2e7(%rip)        # 0x000000010d0f1c60

核心就是lock指令。可以说,lock指令就是CPU实现volatile可见性的秘密。通过查IA-32架构软件开发者手册,内容如下:

8.1.4 Effects of a LOCK Operation on Internal Processor Caches

For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation,even if the area of memory being locked is cached in the processor.

For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus.

Instead, it will modify the memory location internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. Thisoperation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area.

翻译过来即就是:

对于Intel486和Pentium处理器,即使正在锁定的内存区域已缓存在处理器中,在LOCK操作期间始终会在总线上发出LOCK#(以lock为前缀的指令)信号。

对于P6和更新的处理器家族,如果在执行LOCK操作的处理器中缓存了在LOCK操作期间锁定的内存区域作为回写内存,并且完全包含在缓存行中,则处理器可能不会声明 总线上的LOCK#信号。

取而代之的是,它将在内部修改内存位置,并允许其缓存一致性机制来确保该操作是原子执行的。 该操作称为“缓存锁定”。 缓存一致性机制会自动阻止已缓存同一内存区域的两个或更多处理器同时修改该区域中的数据。

in the Pentium and P6 family processors, if through snooping one processor detects that another processor intends to write to a memory location that it currently has cached in shared state, the snooping processor will invalidate its cache line forcing it to perform a cache line fill the next time it accesses the same memory location.

翻译过来就是:

在Pentium和P6 系列处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

上述引用总结为volatile的两条实现原则:

  • (1)对缓存行加锁内容的修改会导致修改后的内容马上写回主内存
  • (2)一个处理器的缓存回写到主存会使其他缓存了该共享变量的缓存失效
(2)缓存一致性协议

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,这是CPU底层做的事儿,但是这里我们还是需要了解一下原理的。

为了保证缓存的一致性,每个处理器通过在嗅探在总线上传播的数据来检查自己缓存的数据是否过期了,当处理器发现自己缓存行中的数据过期了,就会键当前处理器缓存行中的数据设置成无效状态,当处理器对这个数据进行修改操作的时候会重新从主内存中加载这个数据到缓存里

(3)缓存一致性的简单实现方式

缓存将具有三个额外的位:
V(可用) | D(脏位,表示高速缓存中的数据与内存中的数据不同) | S(共享)

读未命中:
CPU_A本地缓存读未命中,会广播到监听总线上,其他所有CPU监听处理器会检查,如果缓存了该地址,并且缓存处于“D(脏位)”,将状态改成有效,同时发送副本到请求节点。

写未命中:
CPU_A尝试更新本地缓存,但是更新并不在主存中。其他所有CPU监听处理器
可确保将其他高速缓存中的所有副本都设置为“无效”。

以上是主要的场景,具体实现有 MESI 协议等。

2、volatile实现禁止指令重排序的原理
(1)什么是指令重排序?为什么要指令重排序?

现代CPU在执行一条指令的分为5个阶段:取指令-->指令译码-->执行指令-->内存访问-->数据写回

为了提高指令执行的吞吐量,现代CPU都支持多级指令流水线(一般就是五级指令流水线),就是可以在一个一个时钟周期内同时对5个指令执行不同阶段的操作,本质上,流水线方式不能缩短一条指令执行的时间,但它提高了CPU的吞吐率。

因此,在流水线模式下,为了提高CPU的指令执行效率以及CPU的吞吐率,就会对指令重新排序,让指令能够尽量不间断的送给CPU处理,当然重排序的前提是重排序前后执行的结果不变。指令重排序分为编译器级别的和处理器级别的,但是他们实现重排序的目的都是为了提高程序的效率。

  • 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  • 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序,处理器的重排序又被称为乱序执行(out-of-order execution,OOE)技术
(2)as-if-serial语义

as-if-serial语义的意思是:无论如何重排序,单线程内程序的执行结果不能被改变。编译器、runtime和处理器都必须遵循as-if-serial语义。为了遵守这条语义,编译器和处理器不会对存在数据依赖关系的操纵进行重排序,反之,就会被重排序。

(3)指令重排序带来的问题

指令重排序的本质是好的,但是在某些时候会导致一些错误。请看下面这段代码,这是美团技术团队博客上给出的一段代码,它描述了由于指令重排序带来的问题。

public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                b = 1;
                y = a;
            });
            one.start();
            other.start();
            one.join();
            other.join();
            if (x == 0 && y == 0) {
                System.err.println("第" + i + "次(" + x + "," + y + ")");
                break;
            }
        }
    }
}

执行结果:

当执行到第649625次的时候发生了错误,为什么说是错误,正常情况下这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。也就是说,(0,0)这个结果是如果是按正常顺序执行的话是不可能出现的,但是现在出现了就证明了一件事儿,CPU确实存在对指令的重排序。

(4)volatile禁止指令重排序
  • 为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。

  • Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序

  • JMM 会针对编译器制定 volatile 重排序规则表,如下:

其中“NO”表示禁止指令重排序,为了实现这一语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

  • LoadLoad:对于这样的语句Load1:LoadLoad:Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

  • StoreStore:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

  • LoadStore:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

  • StoreLoad:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。(开销最大,对于大多数处理器是万能屏障,它兼具其他三种屏障的功能)

其实前面的lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  • (1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成

  • (2)它会强制将对缓存的修改操作立即写入主存

  • (3)如果是写操作,它会导致其他CPU中对应的缓存行无效

留言区

还能输入500个字符