而(true)则使cpu无法读取共享变量的最新值



我有这样的java代码:

class FlagChangeThread implements Runnable {
private boolean flag = false;
public boolean isFlag() {return flag;}
public void run() {
try {Thread.sleep(300);} catch (Exception e) {}
// change value of flag to true
flag=true;
System.out.println("FlagChangeThread flag="+flag);
}
}
public class WhileLoop {
public static void main(String[] args) {
FlagChangeThread fct = new FlagChangeThread();
new Thread(fct).start();
while (true){
// but fct.isFlag() always get false
boolean flag = fct.isFlag();
if(flag){
System.out.println("WhileLoop flag="+flag);
break;
}
}
}
}

当我运行这段代码时,整个程序只打印在下面的消息,并永远被卡住:

FlagChangeThread flag=true

但是当我在主线程的while循环中添加一些睡眠时间时,就像这样:

class FlagChangeThread implements Runnable {
private boolean flag = false;
public boolean isFlag() {return flag;}
public void run() {
try {Thread.sleep(300);} catch (Exception e) {}
// change value of flag to true
flag=true;
System.out.println("FlagChangeThread ="+flag);
}
}
public class WhileLoop {
public static void main(String[] args) throws InterruptedException {
FlagChangeThread fct = new FlagChangeThread();
new Thread(fct).start();
while (true){
Thread.sleep(1);
boolean flag = fct.isFlag();
if(flag){
System.out.println("WhileLoop flag="+flag);
break;
}
}
}
}

再次运行,整个程序打印在下面的消息并正常退出:

FlagChangeThread =true
WhileLoop flag=true

我知道声明flag variable为volatile也解决了这个问题,因为当flag被更改时,它将被写回主内存,并使flag variale的其他cpu的缓存线无效。

但是,我有这样一个困惑:

  1. 为什么主线程while循环中的fct.isFlag()在没有睡眠的情况下无法获得最新值?

  2. 在标志被更改为true之后,甚至认为它现在在线程的工作内存中,但在未来的某个时刻,它最终会被写回主内存。为什么主线程不能通过调用fct.isFlag()读取这个更新的值?它不是每次调用fct.isFlag()时都从主内存中获取标志值并复制到主线程的工作内存中吗?

有人能帮我吗?

原因是邪恶的硬币。

这里相关的规范是Java内存模型(JMM)。

JMM有以下方面:

任何线程都可以自由地创建变量的本地缓存副本,也可以不创建,并且可以根据其突发奇想和月相(如果愿意)引用该副本或不引用该副本

换句话说,线程翻转硬币来决定该做什么。这是邪恶的,因为它不会以大约50/50的比例翻转头部/尾部。假设它会捣乱:它在一个小时左右的时间里效果很好,然后当你明天早上再次拿起工作时,它突然开始失败,你不知道发生了什么。

因此,在您的一些调用中,您正在查看的布尔字段正在获取缓存副本。

换句话说:

如果多个线程使用同一字段,则除非建立HB/HA,否则应用程序的行为是未定义的。

它以这种奇怪的方式工作的原因是速度:任何其他定义都意味着JVM运行代码的速度必须慢几个数量级。

解决方案是建立HB/HA:发生在之前/发生在之后的关系。

HB/HA的工作原理是这样的:如果两行代码之间存在HB/HA关系,那么就不可能从Happens After行观察到在运行Happens before行之前的状态。换言之,如果字段在HB行之前具有值"5",在"HB"行之后具有值"7",则HA行不可能观测到5。它可以观察到7,或者之后发生的一些更新。

该规范列出了建立HB/HA:的一系列内容

  • volatile字段的任何访问。您现在可以尝试:将该字段设为volatile,它会"修复"它
  • synchronized(x)块的退出与在另一个线程中进入synchronized(theSameX)块相比是HB(当然,如果进入该块实际上发生在之后)
  • 相对于您启动的线程的run()中的第一行,t.start()方法是HB
  • 在一个线程中,在任何其他线程之前运行的任何代码行都是HB(这是一种琐碎的情况)

JVM中的一些东西使用这些东西。

提示:

  • 通常,使用java.util.concurrent包中的内容
  • 尽量避免与来自不同线程的同一字段交互
  • 考虑数据库、消息队列或其他对线程间通信规则不那么挑剔的系统
  • 如果写入其他线程应该读取的字段,则必须考虑HB/HA
  • 这些都不能保证您可以编写损坏的代码,但这些代码今天、明天、下周和生产机器上都通过了所有测试,但在下个月向大客户进行重要演示时失败了。因此,这里有龙:如果你搞砸了,你可能不知道,直到bug给你带来的成本失控。因此,除非你真的,真的,真正地知道自己在做什么,否则要避免这些事情

您的代码无法正常工作,因为您违反了Java内存模型(JMM)。您的代码的问题是,在写入"flag"和读取"flag)之间缺少一个发生前边缘,因此您的代码正遭受数据竞争。当发生数据竞赛时,您可能会得到意想不到的行为。幸运的是,它比C++的数据竞赛定义得更好,因为在C++中,它可能会导致未定义的行为。

编译器是打破这个例子的典型组件。它可以将您的代码转换为:

if(!flag) return;
while(true){
...
}

如果循环中的标志没有更改,那么在循环中检查标志就没有意义。这种优化称为循环不变代码运动或提升。如果你想让标志字段变得不稳定,那么在写入和读取之间的边缘存在之前就会发生,编译器无法应用优化读取。相反,它需要从"共享内存"中读取标志(包括从一致的CPU缓存中读取)。

请不要认为volatile强制刷新主内存并从主内存写入。主内存只是一个溢出桶,用于存储CPU缓存中不适合的内容。现代CPU上的缓存总是连贯的。如果对于每个易失性读/写,您都需要访问主内存,那么并发程序将变得非常慢。在大多数情况下,如果不存在读/写未命中,并且不需要与其他CPU或主存储器的缓存一致性流量,则可以本地解决易失性读/写问题。为了保持加载和存储之间的顺序,需要进行的主要"刷新"是加载需要等待存储缓冲区中的存储耗尽;但这是在存储命中缓存之前。甚至这里的"刷新"也是一个不合适的术语,因为存储缓冲区已经以尽可能快的速度排入缓存。

也不要相信volatile阻止在CPU中使用寄存器;现代处理器都是加载-存储体系结构,这意味着有单独的加载/存储指令从存储器加载/存储到寄存器中,而像ALU执行的那些指令这样的大多数普通指令只能处理寄存器,不具有访问存储器的能力。即使是X86,它从外部如果是一个寄存器-内存架构,经过uops转换后就变成了一个加载-存储架构。所以寄存器总是被使用;关键部分是寄存器需要与缓存同步的频率。

除此之外,JMM并没有根据寄存器和对主内存的刷新进行定义,因此它不是一个合适的心理模型。

@rzwitserloot的答案几乎涵盖了所有内容。(他所说的正确性……是正确的。)

sleep()println()调用更改行为的原因是它们对内存缓存刷新行为有未记录的(偶然的)影响。

println的情况下,输出流堆栈的当前实现涉及对内部同步方法的调用。这显然足以使您的标志的值更改对第二个线程可见。

sleep的情况下,调用会将当前线程的状态保存到内存中,以便执行可以切换到不同的线程。

但在任何一种情况下,您修改的代码都是";工作";因为未记录行为。这种行为可能在不同的Java版本之间、不同的硬件或操作系统平台之间发生变化,等等

为什么主线程while循环中的fct.isFlag()不能在没有睡眠的情况下获得最新值?

我相信这里发生的事情是,如果没有Thread.sleep(1),你就会有一个紧密的循环(相关定义)。这意味着主线程没有得到最新的值,因为它正在使用缓存的值(正如您所说的,也可以通过使标志值可变来修复)。

当添加Thread.sleep()时,由于这会使线程脱离Runnable状态,因此紧密循环将被打破。当线程移出Runnable状态时,它将移出CPU。当从Thread.sleep()恢复时,CPU缓存的值会从内存中重新加载,这会给出最新的标志值。

最新更新