V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
svt
V2EX  ›  程序员

关于指令重排序有个问题不明白,求大佬指点

  •  
  •   svt · 2021-01-18 21:18:54 +08:00 · 3340 次点击
    这是一个创建于 1406 天前的主题,其中的信息可能已经有所发展或是发生改变。

    《 JAVA 并发编程的艺术》一书中说指令重排序可能会改变多线程程序的执行结果。举的例子如下:

    class test { private int a = 0; private boolean flag = false;

    public void change() {
        a = 1;
        flag = true;
    }
    
    public void sysout() {
        while (flag) {
            System.out.println(a);
        }
    }
    

    }

    书中说以上程序在多线程时,输出的结果不一定是 1,有可能是 0 ; 但是我自己尝试了多次也未复现出这种情况,因此非常疑问这个情况真的可以复现出来吗?这个指令重排序到底说的真的假的呀?如果有复现出来的同学可以发个程序执行结果的图看下嘛?

    第 1 条附言  ·  2021-01-19 11:50:54 +08:00

    以上程序,因为change方法和sysout方法不在一个线程里,因此JMM不认为他们具有数据依赖性,在重排序的时候可能会对他们进行重排序而导致执行的顺序不是我们看到的顺序。 例如重排序结果情况1:change方法的第一行到了change方法的第二行后面,就可能会导致输出不是1; 重排序情况2:sysout方法的第一行和第二行虽然存在控制依赖性,但是因为CPU和编译器会使用‘猜测执行’来运算,如先执行sysout的第二行代码,然后直接读取a的值拿过来运算好放入重排序缓存中(Reorder Buffer ,ROB),之后等待sysout的第一行代码执行为真时,就直接再把a的值拿出来使用。我改了下代码,改成如下:(v2粘贴代码好像不太好使,感兴趣的可以自己复制下看看)

    //程序执行入口

    public class SimpleHappenBefore { public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 1000000; i++) { test test = new test(); Thread threadA = new Thread(() -> { test.change(); }); Thread threadB = new Thread(() -> { test.sysout(); }); threadA.start(); threadB.start();

            threadA.join();
            threadB.join();
        }
    }
    

    }

    class test {

    private int a = 0;
    
    private boolean flag = false;
    
    public void change() {
        a = 1;
        flag = true;
    }
    
    public void sysout() {
        while (flag) {
            int tmp = a * a;
            if (tmp == 0) {
                System.out.println("---------------");
            }
        }
    }
    

    }

    25 条回复    2021-01-23 12:08:39 +08:00
    lcdtyph
        1
    lcdtyph  
       2021-01-18 21:44:11 +08:00
    你需要在弱内存一致性的平台上(如 arm )来复现这个现象
    lewis89
        2
    lewis89  
       2021-01-18 22:24:52 +08:00
    说实在话 这个东西验证很困难,涉及到很多技术细节

    1. 你要确认 class 文件的字节码是否存在重排序,最好去翻一下字节码,一般这种简单的代码,
    我觉得可能字节码层面不会发生重排序

    2. 你要用这些代码 反复运行 让 JVM 生成 JIT 后的汇编指令,看下 JIT 后的汇编代码是否存在重排序

    3. CPU 层面还有分支预测跟指令冒险 这个更加不好验证

    4. 另外这里多线程的话 还要涉及到内存可见性的问题的,因为 CPU 的 L1 L2 L3 同步的模型很复杂,
    现在 X86 都是 TSO 模型,具体到 Intel 下面还有 MESI 的模型,另外 AMD zen2 架构流行后,
    现在又开始流行 NUMA 了,总之多线程同步下面的技术细节太多。


    有兴趣的话 你可以研究我的这篇博文,是关于内存可见性的研究,里面对 JVM 的 JIT 后的代码 用 GDB 打断点验证过了
    https://www.cnblogs.com/richard-winters/p/14237940.html

    关于重排序跟 memory barrier 这块,你只要记住 多线程同步的时候 一定要用 Java 的锁或者原子变量就行了,
    因为这两个都会用 x86 的同步原语 lock sfence mfence 等指令,如果不想搞清楚底层同步的原语,最好的办法就是记住 Java 的 happen before 原则就行了,这样多线程编程的时候就不会被底层的技术细节给搞蒙 B 了。
    lewis89
        3
    lewis89  
       2021-01-18 22:33:17 +08:00   ❤️ 1
    另外书上讲的不一定是发生了指令重排序,也有可能是内存可见性的问题,

    sysout() 线程可能根本就没观测到 change() 线程里面 对变量 a 的修改,因为 a 既没有 volatile ( x86 的 lock 原语),可能线程 sysout() 取到 a 的值 只是它在寄存器里面之前的拷贝,或者是线程 sysout() CPU 的 L1 的缓存,
    而且 sysout() 跟 change() 线程 都没有用到 同步的原语
    lewis89
        4
    lewis89  
       2021-01-18 23:05:03 +08:00
    @lewis89 #3 关于这些验证的猜想,都得像寻宝一样,设置各种运行条件,参考各种资料,然后到汇编代码层面去验证才能还原出结果来,对于初学者,我不建议大家这么去研究,一来这样研究的价值跟意义不大,应用层面的编程你只要遵循 Java 提供的抽象模型 Happen before 即可,二来如果你计算机体系结构基础不是很扎实的话,很难弄明白底层干了什么,当然如果你是反汇编 搞破解出生的,这些问题就能有办法研究清楚
    hobochen
        5
    hobochen  
       2021-01-18 23:07:33 +08:00
    JSR-133 能救你
    sagaxu
        6
    sagaxu  
       2021-01-18 23:11:56 +08:00
    楼上都说的很全了,构造并发错误比写出没有并发问题的代码要困难的多,首先要深刻理解并发模型,然后要熟悉不同的处理器架构,选择合理的处理器架构(X86 经常不行),再避开一些会影响同步的测试代码,然后还需要一点运气。

    System.out.println 的内部实现自带一个锁,影响了并发语义,它是最容易犯的验证并发的错误。
    fuse
        7
    fuse  
       2021-01-18 23:15:28 +08:00 via iPhone
    讲的都是啥,重排序这种词都是自己没懂,其他地方抄来的

    跟汇编根本没关系

    这个问题是 smp 情况下,cpu 对内存的写是异步的,一个 cpu 读到 flag 等于 true 时,a=1 这个写操作不一定就同步过来到这个 cpu 上,可能看到的还是 0

    为了保障逻辑正确,需要加同步等待指令
    SingeeKing
        8
    SingeeKing  
       2021-01-18 23:26:47 +08:00 via iPhone
    之前我亲测过( Intel Mac Oracle JDK 8 ),在 400 多万次的时候出来过一次
    mind3x
        9
    mind3x  
       2021-01-18 23:46:42 +08:00
    @lewis89 写得很详细。补充一下,javac 生成的字节码是完全阳春的,没有任何优化,也不会有任何重排序。
    lewis89
        10
    lewis89  
       2021-01-19 00:00:09 +08:00
    @fuse #7

    重排序是完全可能发生的,因为从编译器到 CPU 都是保证单线程 as-if-serial 语义,
    多线程的时候只能用到处理的同步原语才能解决

    int a = 2;
    int c = 1 + a;
    float b = 3f / 2f;

    像这种代码 第三条代码完全可能发生重排序,因为浮点运算会用到 FPU 而 CPU 完全是空闲出来的,
    而且从 CPU 优化的角度来看,第一条指令跟第二条有依赖,所以不会重排,但是 第三条指令跟 第一条 and 第二条指令不存在任何依赖,所以将 float b= 3f/2f 提前拿出来 交给 FPU 计算 得出结果是完全合理的,而且并不会影响程序的正确性,当然这一切都是在单线程的情况下,如果你多线程需要同步,那么必然要用 x86 lock mfence 这些原语来禁止 重排序
    lewis89
        11
    lewis89  
       2021-01-19 00:01:05 +08:00
    @mind3x #9 这个我就不了解了,从我了解的资料来看,包括我看 GCC 的编译后的汇编,都存在重排序优化的情况,所以我对 Java 字节码 也是一种猜测。
    lewis89
        12
    lewis89  
       2021-01-19 00:04:56 +08:00
    @sagaxu #6 system.out.println 这个我也研究过了 println 方法里面的 synchronize 关键字 在底层就用到了 同步的原语
    YouLMAO
        13
    YouLMAO  
       2021-01-19 00:45:32 +08:00 via Android
    c 也是会
    agagega
        14
    agagega  
       2021-01-19 01:15:13 +08:00 via iPhone
    如果说是编译器对指令做的重排序优化的话,很简单:把一条除法语句放在一个不相关的加法后面,大多数平台上这个除法都会被调到前面去。
    nuk
        15
    nuk  
       2021-01-19 02:05:46 +08:00
    和运行的 cpu 架构有关。。java 不保证顺序一致性
    lewis89
        16
    lewis89  
       2021-01-19 06:22:58 +08:00
    @nuk #15 几乎所有语言都只是 保证单线程自身的 as-if-serial 语义,因为到 CPU 层面 你也不知道 CPU 干了什么优化,CPU 也是保证单线程的 as-if-serial,除非你强制使用 内存栅栏去同步,这样 CPU 就会 invalid 掉 它的重排序
    mxalbert1996
        17
    mxalbert1996  
       2021-01-19 09:16:01 +08:00 via Android
    @lewis89 重排序是 JIT 的事,完全没有必要在编译成字节码的时候再做一次重排序。
    lewis89
        18
    lewis89  
       2021-01-19 09:21:23 +08:00
    @mxalbert1996 #17 只是猜测,我也没验证过,我只是针对其它编译型语言 做出的猜测判断
    minato
        19
    minato  
       2021-01-19 11:05:00 +08:00
    这为啥会扯到指令重排?这不是 JMM 的常规可见性问题吗?
    svt
        20
    svt  
    OP
       2021-01-19 11:51:06 +08:00
    @lewis89 非常感谢您的发言


    @minato
    @fuse 你们两位说的是可见性的问题,不过我这个主要是为了探讨指令重排序,当然以上程序也有可能是因为可见性导致出现的输出 0 。我补充了重排序可能出现的情况。您可以看下。
    Still4
        21
    Still4  
       2021-01-19 13:05:09 +08:00
    你这个例子没问题,指令重排会保证最终执行结果跟预期一样,但是不保证顺序,也就是说 change 方法执行完以后,a 一定=1,flag 一定=true,但是谁先谁后不一定

    这样一来,多线程环境下,监听线程读取到 flag 变成 true 的时候,a 的值有可能=0 有可能=1,不能按编码顺序去理解
    icyalala
        22
    icyalala  
       2021-01-19 13:38:33 +08:00
    单说指令的话:
    一个是编译器在编译时,指令可能会被重新排序,
    一个是 CPU 在 Out of Order 执行指令时,也可能会被重排。

    要说 Memory Model 那涉及到的东西就更多了。
    zhch602
        23
    zhch602  
       2021-01-19 18:57:17 +08:00
    @mxalbert1996 重排序并不只是 JIT 的事,CPU 指令流水线执行指令的时候也会指令重排的
    fuse
        24
    fuse  
       2021-01-23 11:51:44 +08:00 via iPhone
    @lewis89 总结起来不就是异步嘛
    lewis89
        25
    lewis89  
       2021-01-23 12:08:39 +08:00
    @fuse #24 但是不能影响单线程的串行化语义
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5169 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 48ms · UTC 01:23 · PVG 09:23 · LAX 17:23 · JFK 20:23
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.