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

2021-01-18 21:18:54 +08:00
 svt

《 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 ; 但是我自己尝试了多次也未复现出这种情况,因此非常疑问这个情况真的可以复现出来吗?这个指令重排序到底说的真的假的呀?如果有复现出来的同学可以发个程序执行结果的图看下嘛?

3367 次点击
所在节点    程序员
25 条回复
lcdtyph
2021-01-18 21:44:11 +08:00
你需要在弱内存一致性的平台上(如 arm )来复现这个现象
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
2021-01-18 22:33:17 +08:00
另外书上讲的不一定是发生了指令重排序,也有可能是内存可见性的问题,

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

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

跟汇编根本没关系

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

为了保障逻辑正确,需要加同步等待指令
SingeeKing
2021-01-18 23:26:47 +08:00
之前我亲测过( Intel Mac Oracle JDK 8 ),在 400 多万次的时候出来过一次
mind3x
2021-01-18 23:46:42 +08:00
@lewis89 写得很详细。补充一下,javac 生成的字节码是完全阳春的,没有任何优化,也不会有任何重排序。
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
2021-01-19 00:01:05 +08:00
@mind3x #9 这个我就不了解了,从我了解的资料来看,包括我看 GCC 的编译后的汇编,都存在重排序优化的情况,所以我对 Java 字节码 也是一种猜测。
lewis89
2021-01-19 00:04:56 +08:00
@sagaxu #6 system.out.println 这个我也研究过了 println 方法里面的 synchronize 关键字 在底层就用到了 同步的原语
YouLMAO
2021-01-19 00:45:32 +08:00
c 也是会
agagega
2021-01-19 01:15:13 +08:00
如果说是编译器对指令做的重排序优化的话,很简单:把一条除法语句放在一个不相关的加法后面,大多数平台上这个除法都会被调到前面去。
nuk
2021-01-19 02:05:46 +08:00
和运行的 cpu 架构有关。。java 不保证顺序一致性
lewis89
2021-01-19 06:22:58 +08:00
@nuk #15 几乎所有语言都只是 保证单线程自身的 as-if-serial 语义,因为到 CPU 层面 你也不知道 CPU 干了什么优化,CPU 也是保证单线程的 as-if-serial,除非你强制使用 内存栅栏去同步,这样 CPU 就会 invalid 掉 它的重排序
mxalbert1996
2021-01-19 09:16:01 +08:00
@lewis89 重排序是 JIT 的事,完全没有必要在编译成字节码的时候再做一次重排序。
lewis89
2021-01-19 09:21:23 +08:00
@mxalbert1996 #17 只是猜测,我也没验证过,我只是针对其它编译型语言 做出的猜测判断
minato
2021-01-19 11:05:00 +08:00
这为啥会扯到指令重排?这不是 JMM 的常规可见性问题吗?
svt
2021-01-19 11:51:06 +08:00
@lewis89 非常感谢您的发言


@minato
@fuse 你们两位说的是可见性的问题,不过我这个主要是为了探讨指令重排序,当然以上程序也有可能是因为可见性导致出现的输出 0 。我补充了重排序可能出现的情况。您可以看下。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/746080

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX