• 并发bug之源(二)-有序性


    什么是有序性?

    简单来说,假设你写了下面的程序:

    java

    1. int a = 1;
    2. int b = 2;
    3. System.out.println(a);
    4. System.out.println(b);

    但经过编译器/CPU优化(指令重排序,和编程语言无关)后可能就变成了这样:

    java

    1. int b = 2;
    2. int a = 1;
    3. System.out.println(a);
    4. System.out.println(b);

    当然上面例子这种情况,就算调整了代码顺序,也没有任何影响。但实际工作过程中,这种擅自优化,并不总是没有问题的,在多线程情况下,有时候就会给我们的程序中埋下一个隐藏的bug。

    如何证明指令重排序的存在?

    我说有指令重排序就有啊,那不得拿出证据来?

    这个证明有点复杂,我先写一个程序,跑起来看结果,然后再解释:

    java

    1. public class DisOrder {
    2. private static int a, b, x, y = 0;
    3. public static void main(String[] args) throws InterruptedException {
    4. for (long i = 0; i < Long.MAX_VALUE; i++) {
    5. a = 0;b = 0;x = 0;y = 0;
    6. CountDownLatch cdl = new CountDownLatch(2);
    7. Thread t1 = new Thread(() -> {
    8. a = 1;
    9. x = b;
    10. cdl.countDown();
    11. });
    12. Thread t2 = new Thread(() -> {
    13. b = 1;
    14. y = a;
    15. cdl.countDown();
    16. });
    17. t1.start();
    18. t2.start();
    19. cdl.await();
    20. if (x == 0 && y == 0) {
    21. System.out.println("第" + i + "次循环时, (" + x + "," + y + ")");
    22. break;
    23. }
    24. }
    25. }
    26. }
    • 这个程序需要等一会,执行结果:

    我来解释下这个程序在干什么,程序中有四个成员变量 a, b, x, y ,初始都是0。

    然后执行一个无限循环,循环中启动两个线程,两个线程分别去修改 a, b, x, y 四个变量,按顺序一共有四行代码:

    none

    1. a = 1;
    2. x = b;
    3. b = 1;
    4. y = a;
    • 每次修改完成后,判断下x和y是否都为0,是则打印 x, y 并停止循环,否则重新循环,并将四个变量归零。

    OK,现在我们先来简单推理下。

    假设程序严格按照代码的顺序去执行,那么两个线程修改完成后,a, b, x, y 的值有哪些可能呢?

    我猜你懒得推理,直接说结论吧:

    结果一共有6种可能性,一种为x=0,y=1,一种为x=1,y=0,另外四种都为x=1,y=1。

    可以发现,没有任何情况的结果是x=0,y=0的。

    但是,我们从上面程序实际执行结果可以看到,循环终止了,也打印出了当第35239次循环时,x=0,y=0

    那么也就是说,必然发生了上面6种可能性以外的其他情况。大家可以再简单推理下,发生什么情况会导致 x=0,y=0 呢?

    我们发现只有上面这2种情况,会导致x=0,y=0。

    而从这两种情况可以发现,代码执行的顺序,和我们写的顺序发生了交换,第一个线程里原本是a = 1;x = b;,在这两种情况里,都变成了x = b;a = 1;,第二个线程里也是如此。

    由此可以证明,指令重排序的存在。

    如何解决有序性问题

    指令重排序是编译期/CPU为了有可能的性能提升,而进行的擅自优化。上面也说了这种优化在有些时候会带来一些问题,这就是有序性问题,那么我们该如何解决这种问题呢?

    JVM有一个原则,叫做Happens-Before原则,翻译过来就是先行发生原则。里面有8条规则:

    上图截自《深入理解Java虚拟机第三版》,除了这8条以外JVM可以随便换顺序,这8条规则没必要背,一点意义都没有。

    通过这个 Happens-Before 原则,就可以解决多线程的可见性和有序性问题。

    下面我们来看个经典的面试题

    DCL单例到底需不需要加 volatile

    DCL(Double Check Lock)双重检查锁,单例模式的一种实现方案,代码如下:

    java

    1. public class Singleton {
    2. private static Singleton instance;
    3. private Singleton() {}
    4. public static Singleton getSingleton() {
    5. if (instance == null) {
    6. synchronized (Singleton.class) {
    7. if (instance == null) {
    8. instance = new Singleton();
    9. }
    10. }
    11. }
    12. return instance;
    13. }
    14. }
    • 这段代码看起来很完美,但它是有问题的。主要在于instance = new Singleton()这句,这其实并非是一个原子操作,事实上这行代码大概做了下面 3 件事情:
    1. 分配一块内存M,成员变量赋默认值(0,0.0,false,null)
    2. 调用 Singleton 类的构造函数,在内存M上初始化成员变量
    3. 将instance变量指向分配的内存M(执行完这步 instance 就为非 null 了)

    但是由于存在指令重排序的优化,上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是1-3-2,则就有可能在 3 执行完毕、2 未执行之前,被线程二抢占了CPU,去调用 getSingleton() 方法。这时 instance 已经是非 null 了(但却没有执行第二步的初始化,此时只是完成第一步的半初始化状态),所以线程二会直接返回 instance,然后使用,然后理所当然发生错误。因为此时 instance 对象还没有执行第二步 ,没有调用构造函数初始化成员变量。

    这里给大家看下 new 一个对象,字节码长什么样,证实一下确实是上面说的这三个步骤,可不是我胡说:

    图中红色框起来的三行字节码指令,就是上面对应的三个步骤( dup 和 return 指令在这里暂时不需要关注)。

    none

    1. 0 new #2 <java/lang/Object>
    2. 4 invokespecial #1 <java/lang/Object.<init> : ()V>
    3. 7 astore_1
    • 么这个问题要怎么解决呢?其实只要将变量 instance ⽤ volatile 修饰,就可以避免这个问题了。

    java

    1. public class Singleton {
    2. /** 声明成 volatile */
    3. private volatile static Singleton instance;
    4. private Singleton() {}
    5. public static Singleton getSingleton() {
    6. if (instance == null) {
    7. synchronized (Singleton.class) {
    8. if (instance == null) {
    9. instance = new Singleton();
    10. }
    11. }
    12. }
    13. return instance;
    14. }
    15. }
    • 可另一个问题又来了,为什么加了 volatile ,就可以避免指令重排序导致的问题呢?

    volatile 如何防止指令重排序

    我们想一下,发生上面指令重排序的情况,本质上就是两行代码的顺序发生了交换。比如你站在A点,我站在B点,你我交换位置就会出现问题。那如果不想让你我交换位置,有什么办法呢?

    只要给咱俩中间加一堵墙就行了嘛,你过不来,我也过不去,就不会发生位置交换了。

    没错,其实 volatile 就是这么干的,这堵“墙”,就被称之为内存屏障

    内存屏障,其本质上是一条特殊的屏障指令,编译器/CPU当看到这条指令的时候,就绝对不会将这条指令之前的指令,和之后的指令换顺序。

    那屏障指令有哪些呢?不同的CPU,是不一样的。

    我们以英特尔CPU举例,它的屏障指令有3个:lfence、mfence、sfence。这个东西是汇编级别的,暂时不用关心这些。

    那Java里面有没有屏障指令呢?Java里也得有一种机制,来告诉JVM,不能随便换顺序啊。没错,这语句就是volatile

    JVM在看到 volatile 之后呢,就会给被 volatile 修饰的变量加屏障指令。注意这里和缓存一致性协议没有关系,缓存一致性协议是硬件级别的东西,我们现在讲的是 Java 虚拟机中的实现。

    JVM中的内存屏障一共有四种,这是JVM的规范:

    1. LoadLoad屏障
    2. StoreStore屏障
    3. LoadStore屏障
    4. StoreLoad屏障

    看着有点懵,其实很简单。

    以第一个LoadLoad屏障为例,有个一个变量X,它前面一条读命令,它后面也有一条读命令,中间有个LoadLoad屏障,那么前面的读命令和后面的读命令就不能换顺序。

    再比如第二个StoreStore屏障,有个一个变量X,它前面有一条写命令,它后面也有一条写命令,中间有个StoreStore屏障,那么前面的写命令和后面的写命令就不能换顺序。

    好了后面的两个指令就不用讲了吧。

    那么就可以看 volatile 是怎么实现的了,在JVM层面:

    对于被 volatile 修饰的变量,在发生写的前面,会加上StoreStore屏障,在后面会加上StoreLoad屏障。意味着前面的写完我才能写,我写完后面的才能读。

    对于被 volatile 修饰的变量,在发生读的后面,会加上LoadLoad屏障,和LoadStore屏障。意味着我读完后面的才能读写。

    放个图总结下:

    从这个表格最后⼀列可以看出,如果第⼆个操作为 volatile 写,不管第⼀个操作是什么,都不能重排序,这就确保了 volatile 写之前的操作不会被重排序到 volatile 写之后。

    从这个表格倒数第⼆⾏可以看出,如果第⼀个操作为 volatile 读,不管第⼆个操作是什么,都不能重排序,这确保了 volatile 读之后的操作不会被重排序到 volatile 读之前。

    这样就从JVM上保证了变量读写的有序性。

  • 相关阅读:
    html页面动态加载css,可以根据系统参数配置
    Selenium java 控制当前已经打开的 chrome浏览器窗口
    word目录怎么自动生成?用这个方法,快速自动生成
    关于GPIO你真的懂了吗?这篇文章都给你整理好了
    PTA:7-1 线性表的合并
    LRU: C++代码实现
    体验馆设计要考虑哪些需求
    游戏行业需要堡垒机吗?用哪款堡垒机好?
    【MATLAB】MATLAB App Designer中的回调函数
    [ubuntu20] hadoop3.1.2集群io测试
  • 原文地址:https://blog.csdn.net/jh035512/article/details/127956910