![](/img/trans.png)
[英]About reordering: Why this code throws RuntimeException despite using the volatile?
[英]Is this understanding correct for these code about java volatile and reordering?
根据这个重新排序规则
如果我有这样的代码
volatile int a = 0;
boolean b = false;
foo1(){ a= 10; b = true;}
foo2(){if(b) {assert a==10;}}
让线程 A 运行 foo1 和线程 b 运行 foo2,因为 a= 10 是一个 volatile 存储而 b = true 是一个普通存储,那么这两个语句可能会被重新排序,这意味着在线程 B 中可能有 b = true 而a!=10? 那是对的吗?
添加:
感谢您的回答!
我刚刚开始学习 Java 多线程,并且经常被关键字 volatile 所困扰。
许多教程都在讨论 volatile 字段的可见性,就像“在对它的写操作完成后,所有读者(特别是其他线程)都可以看到 volatile 字段”。 我怀疑其他线程(或 CPU)如何看不到已完成的字段写入?
据我了解,完成写入意味着您已成功将文件写回缓存,并且根据 MESI,如果该文件已被他们缓存,则所有其他线程都应具有无效缓存行。 一个例外(因为我对硬核不是很熟悉,所以这只是一个猜测)是结果可能会写回寄存器而不是缓存,我不知道在这种情况下是否有一些协议来保持一致性或 volatile 使它不写入在 java 中注册。
在某些看起来像“隐形”的情况下会发生示例:
A=0,B=0;
thread1{A=1; B=2;}
thread2{if(B==2) {A may be 0 here}}
假设编译器没有对它重新排序,我们在thread2中看到的原因是存储缓冲区,我认为存储缓冲区中的写入操作并不意味着写入完成。 由于存储缓冲区和使队列无效的策略,这使得对变量 A 的写入看起来不可见,但实际上写入操作尚未完成,而线程 2 读取 A。即使我们使字段 B 易失,而我们将字段 B 上的写入操作设置为带有内存屏障的存储缓冲区,线程 2 可以读取带有 0 的 b 值并完成。 对我来说, volatile 看起来不是关于它声明的字段的可见性,而是更像是一个边缘,以确保所有写入发生在 ThreadA 中的 volatile 字段写入对 volatile 字段读取之后的所有操作可见(易失性读取在 ThreadA 中的 volatile 字段写入完成后发生)在另一个 ThreadB 中。
顺便说一句,由于我不是母语人士,我看到可能使用我的母语的教程(还有一些英文教程)说 volatile 会指示 JVM 线程从主内存中读取 volatile 变量的值,并且不在本地缓存它,我不认为这是真的。 我对吗?
不管怎样,谢谢你的回答,因为不是母语人士,我希望我表达清楚。
我很确定断言可以触发。 我认为易失性负载只是获取操作( https://preshing.com/20120913/acquire-and-release-semantics/)wrt 。 非易失性变量,所以没有什么能阻止加载-加载重新排序。
两个volatile
操作不能相互重新排序,但是可以在一个方向上使用非原子操作重新排序,并且您选择了没有保证的方向。
(注意,我不是 Java 专家;可能但不太可能volatile
具有一些需要更昂贵实现的语义。)
更具体的推理是,如果断言在转换为某些特定体系结构的 asm 时可以触发,则必须允许 Java 内存模型触发。
Java volatile
是(AFAIK)等价于 C++ std::atomic
和默认的memory_order_seq_cst
。 因此foo2
罐JIT编译为ARM64与一个普通的负载b
和用于获取LDAR负载a
。
ldar
不能在较晚的加载/存储中重新排序,但可以与较早的时间进行重新排序。 (除了stlr
发布存储;ARM64 专门设计用于使 C++ std::atomic<>
with memory_order_seq_cst
/ Java volatile
对ldar
和stlr
,不必立即在 seq_cst 存储上刷新存储缓冲区,仅在看到 LDAR 时,所以该设计提供了仍然恢复 C++ 指定的顺序一致性所需的最少排序(我假设是 Java)。
在许多其他 ISA 上,顺序一致性存储确实需要等待存储缓冲区自行耗尽,因此它们实际上是有序的。 后来的非原子负载。 再次在许多 ISA 上,获取或 SC 负载是通过正常负载完成的,然后是一个屏障,该屏障阻止负载在任一方向穿过它, 否则它们将无法工作。 这就是为什么具有挥发性负载a
给acquire负载指令只是做一个获取操作的关键是了解如何在实践中发生的编译。
(在 x86 asm 中,所有加载都是获取加载,所有存储都是释放存储。不过不是顺序释放;x86 的内存模型是程序顺序 + 带有存储转发的存储缓冲区,这允许 StoreLoad 重新排序,因此 Java volatile
存储需要特殊的 asm .
因此,断言不能在 x86 上触发,除非通过对 assignments 的编译/JIT 时间重新排序。 这是测试无锁代码之所以困难的一个很好的例子:失败的测试可以证明存在问题,但在某些硬件/软件组合上的测试无法证明正确性。)
除了 Peter Cordes 的出色回答之外,就 JMM 而言,b 上存在数据竞争,因为 b 的写入和 b 的读取之间的边缘之前没有发生,因为它是一个普通变量。 只有在边缘存在之前发生这种情况,然后才能保证如果 b=1 的负载也看到 a=1 的负载。
你需要让 b 变得不稳定,而不是让 a 变得不稳定。
int a=0;
volatile int b=0;
thread1(){
a=1
b=1
}
thread2(){
if(b==1) assert a==1;
}
因此,如果线程 2 看到 b=1,那么在发生之前的顺序(易失性变量规则)中,此读取将在写入 b=1 之前进行排序。 并且由于 a=1 和 b=1 是有序发生在顺序之前(程序顺序规则),并且 b 的读取和 a 的读取在发生在顺序之前(再次程序顺序规则)中进行排序,那么由于发生在关系之前,在 a=1 的写入和 a 的读取之间有一个发生在边缘之前; 这需要看到值 1。
您指的是使用围栏的 JMM 的可能实现。 虽然它提供了一些关于引擎盖下发生的事情的见解,但从围栏的角度思考同样具有破坏性,因为它们不是一个合适的心理模型。 请参阅以下计数器示例:
https://shipilev.net/blog/2016/close-encounters-of-jmm-kind/#myth-barriers-are-sane
是的,断言可能会失败。
volatile int a = 0;
boolean b = false;
foo1(){ a= 10; b = true;}
foo2(){if(b) {assert a==10;}}
JMM 保证对volatile
字段的写入发生在从它们读取之前。 在您的示例中,任何线程 a 在a = 10
之前所做的都将发生在读取 a 之后线程 b 所做的任何事情之前(同时执行assert a == 10
)。 由于b = true
在线程 a 的a = 10
之后执行(对于单个线程, happens-before
总是成立),因此不能保证会有排序保证。 但是,请考虑:
int a = 0;
volatile boolean b = false;
foo1(){ a= 10; b = true;}
foo2(){if(b) {assert a==10;}}
在这个例子中,情况是:
a = 10 ---> b = true---|
|
| (happens-before due to volatile's semantics)
|
|---> if(b) ---> assert a == 10
由于您有一个总订单,所以断言保证通过。
回答你的补充。
许多教程都在讨论 volatile 字段的可见性,就像“在对它的写操作完成后,所有读者(特别是其他线程)都可以看到 volatile 字段”。 我怀疑其他线程(或 CPU)如何看不到已完成的字段写入?
编译器可能会弄乱代码。
例如
boolean stop;
void run(){
while(!stop)println();
}
第一次优化
void run(){
boolean r1=stop;
while(!r1)println();
}
第二次优化
void run(){
boolean r1=stop;
if(!r1)return;
while(true) println();
}
所以现在很明显这个循环永远不会停止,因为实际上永远不会看到要停止的新值。 对于商店,你可以做一些类似的事情,可以无限期地推迟它。
据我了解,完成写入意味着您已成功将文件写回缓存,并且根据 MESI,如果该文件已被他们缓存,则所有其他线程都应具有无效缓存行。
正确的。 这通常称为“全局可见”或“全局执行”。
一个例外(因为我对硬核不是很熟悉,所以这只是一个猜测)是结果可能会写回寄存器而不是缓存,我不知道在这种情况下是否有一些协议来保持一致性或 volatile 使它不写入在 java 中注册。
所有现代处理器都是加载/存储架构(甚至是 uops 转换后的 X86),这意味着存在显式加载和存储指令,可以在寄存器和内存之间传输数据,而像 add/sub 这样的常规指令只能与寄存器一起使用。 所以无论如何都需要使用寄存器。 关键部分是编译器应该尊重源代码的加载/存储并限制优化。
假设编译器没有对它重新排序,我们在thread2中看到的原因是存储缓冲区,我认为存储缓冲区中的写入操作并不意味着写入完成。 由于存储缓冲区和使队列无效的策略,这使得对变量 A 的写入看起来不可见,但实际上在线程 2 读取 A 时写入操作尚未完成。
在 X86 上,存储缓冲区中的存储顺序与程序顺序一致,并将按程序顺序提交到缓存。 但是在某些架构中,存储缓冲区中的存储可以无序提交到缓存,例如由于:
写合并
允许存储在缓存行以正确状态返回时立即提交缓存,无论较早的是否仍在等待。
与 CPU 的子集共享存储缓冲区。
存储缓冲区可能是重新排序的来源; 但也可能是乱序和推测性执行的一个来源。
除了存储之外,重新排序负载也可能导致观察存储无序。 X86 上的负载不能重新排序,但在 ARM 上是允许的。 当然,JIT 也会把事情搞砸。
即使我们将字段 B 设置为 volatile,当我们将字段 B 上的写操作设置为带有内存屏障的存储缓冲区时,线程 2 可以读取带有 0 的 b 值并完成。
认识到 JMM 基于顺序一致性很重要; 因此,即使它是一个宽松的内存模型(将普通加载和存储与同步操作(如易失性加载/存储锁定/解锁)分开),如果程序没有数据竞争,它也只会产生顺序一致的执行。 对于顺序一致性,不需要遵守实时顺序。 因此,只要满足以下条件,加载/存储就完全没有问题:
内存顺序是所有加载/存储的总顺序
内存顺序与程序顺序一致
负载会在内存顺序中看到它之前的最新写入。
对我来说, volatile 看起来不是关于它声明的字段的可见性,而是更像是一个边缘,以确保所有写入发生在 ThreadA 中的 volatile 字段写入对 volatile 字段读取之后的所有操作可见(易失性读取在 ThreadA 中的 volatile 字段写入完成后发生)在另一个 ThreadB 中。
你走在正确的道路上。
例子。
int a=0
volatile int b=;
thread1(){
1:a=1
2:b=1
}
thread2(){
3:r1=b
4:r2=a
}
在这种情况下,在 1-2(程序顺序)之间的边缘之前会发生一个。 如果 r1=1,则在 2-3(易失性变量)之间的边缘之前发生,并且在 3-4(程序顺序)之间的边缘之前发生。
因为发生在关系之前是可传递的,所以在 1-4 之间的边之前发生了。 所以 r2 必须是 1。
volatile 负责以下内容:
可见性:需要确保加载/存储不会得到优化。
也就是说加载/存储是原子的。 因此,不应部分看到加载/存储。
最重要的是,它需要确保保留 1-2 和 3-4 之间的顺序。
顺便说一句,由于我不是母语人士,我看到可能使用我的母语的教程(还有一些英文教程)说 volatile 会指示 JVM 线程从主内存中读取 volatile 变量的值,并且不在本地缓存它,我不认为这是真的。
你是完全正确的。 这是一个非常普遍的误解。 缓存是事实的来源,因为它们总是连贯的。 如果每次写入都需要进入主内存,程序将变得非常缓慢。 内存只是一个溢出桶,用于存放不适合缓存的内容,并且可能与缓存完全不一致。 普通/易失性加载/存储存储在缓存中。 可以在特殊情况下(如 MMIO)或使用 SIMD 指令时绕过缓存,但这与这些示例无关。
不管怎样,谢谢你的回答,因为不是母语人士,我希望我表达清楚。
这里的大多数人都不是母语人士(我当然不是)。 你的英语足够好,你表现出很大的希望。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.