当涉及的线程之一是 main() 线程时,为什么线程间可见性不需要 volatile 关键字?
Why is volatile keyword not needed for inter-thread visibility when one of the threads involved is the main() thread?
考虑以下程序:
import java.util.concurrent.TimeUnit;
public class StopThread {
public static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Runnable task = new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopRequested) {
i++;
System.out.println(i);
}
System.out.println("Stopping the thread!!");
}
};
Thread backgroundThread = new Thread(task);
backgroundThread.start();
TimeUnit.SECONDS.sleep(5);
stopRequested = true;
}
}
- 此处
stopRequested
未声明为易失性 - 因此理想情况下,线程 backgroupdThread
不得停止 - 并无休止地执行
- 但是当 运行 在本地时 - 线程
backgroundThread
正常关闭并显示消息:“正在停止线程!!”。
main() 线程对共享变量 stopRequested
的所有更新是否对其他线程可见?即使不使用 volatile
关键字?
您还没有完全理解 Java 内存模型 (JMM) 的概念。
JMM 在 so-called Happens-Before/Happens-After 关系的基础上工作。它是这样工作的:
- 每当任何线程读取一个字段时,我们称之为 'observing'。^1
- 如果 X行(发生在之前)和Y行(发生在之后)之间存在happens-before/happens-after关系,那么JVM gua运行tees您无法从 Y 观察到 X 运行 之前的字段值。这是整个 JMM 提供给您的唯一保证。它以任何其他方式使 gua运行tees 为零:它没有说明 Y 的写入对 X 做了什么(特别是,X 也可能看到 Y 的写入,这很奇怪,因为 Y 运行之后? - 而且,当也没有 HB/HA 时,它不会生成 gua运行tees:然后 Y 可能会看到 X 之前或 X 之后的状态,任何一个都可能发生!)
- HB/HA和实际时间完全无关。如果您使用时钟确定行 B 发生在行 A 之后,则不能保证 运行 这两者之间存在 HB/HA 关系,因此,任何由 A 引起的字段写入在 B 中不一定是可观察的。类似地,如果行 B 和 A 确实具有 HB/HA 关系,则可以保证运行您无法观察到处于 A [=106 之前的状态的任何字段=] 来自 B,但是,你实际上并没有得到任何 gua运行Tee B 将在 A 之后物理上(如,根据时钟)运行s。通常它必须按顺序能够让 B 观察 A 所做的更改,但如果 B 实际上没有检查 A 写的任何内容,那么 JVM 和 CPU 就没有必要小心,这 2 个语句可以 运行 并行,或者 B 甚至可以在 A 之前 运行,HB/HA 关系该死。
- gua运行tee 不是two-way街!是的,如果B'happens after'A,那么你得到gua运行tee 你无法观察到A之前的场的状态。但反过来就不是这样了!如果 A 和 B 根本没有 HB/HA 关系,你就得不到 gua运行tees。你得到的是我喜欢称之为 evil coin.
的东西
每当没有 HB/HA 关系并且 JVM 为您读取一个字段时,JVM 就会从它的厄运袋中取出邪恶的硬币并翻转它。尾巴,你得到了 A 写入之前的状态(例如,你得到了一个本地缓存副本)。头,你得到的是同步版本。
这枚硬币是邪恶的,因为它不会以任意 50/50 的随机概率掉落 heads/tails。不,不。它会着陆,以便您的代码在今天每次 运行 以及整个星期在 CI 服务器上每次测试套件 运行 时都能正常工作。然后在它到达生产服务器后,它仍然每次都以您想要的方式着陆。但是 2 周后,就在您向巨大的潜在新客户进行演示时?
然后它决定可靠地翻转你的另一条路。
JMM 为 JVM 提供了这种能力。这看起来非常疯狂,但这样做是为了让 JVM 尽可能多地进行优化。任何进一步的 gua运行tees 都会显着降低 JVM 的实际运行速度。
因此,您一定不能让 JVM 掷硬币。每当你通过字段写入的方式在 2 个线程之间通信时建立 HB/HA(几乎所有的东西最终都是字段写入,记住这一点!)
如何建立HB/HA?很多很多方式——你可以搜索它。最明显的:
- 自然HB/HA:在同一线程中的任何'below'另一行和运行s 平凡地建立HB/HA。 IE。
x = 5; y = x;
,单线程里就这样?那x
读了明明会看到你写的
- 线程开始。
thread.start();
该线程内第一行之前的 HBs。
synchronized
。在另一个线程的同步块中的第一行代码进入一个块(在同一个 x
!)
volatile
访问类似地建立 HB/HA 尽管很难弄清楚哪一行实际上是第一行(它基本上说 em HBs 中的一个在另一个之前但是是哪一个?),所以保持记住这一点。
您的代码根本没有 HB/HA,所以 JVM 正在抛硬币。你不能在这里得出任何结论。那个stopRequested
可能马上更新,可能下周更新,可能永远不更新,也可能5秒后更新。在决定执行这 4 个选项中的哪一个之前检查月相的 JVM 是 100% 有效的 java 实现。唯一的解决办法是不要让 VM 掷硬币,所以,用一些东西建立 HB/HA。
[1] 计算机尝试并行化并应用各种极其奇怪的优化。这会影响 timing(事情需要多长时间),并且没有(简单的)方法来给你计时 gua运行tees,所以 JMM 只是 不要这样做。在其他窝ds,如果你开始超时 and/or 尝试注册 CPU 计时器(System.nanoTime
)并尝试记录事情的顺序,你可以 'see' 各种奇怪的事情,但如果您真的尝试了,请注意几乎所有记录事件的方法都会导致同步,从而使您的测试完全无效。关键是,如果你做对了,你可以并行地观察事物 运行ning 甚至完全无序。 gua运行tees 与这些无关,gua运行tees 仅与阅读领域有关。
Java 语言规范不保证此结果。
在没有同步操作(例如 volatile
写入和后续读取)的情况下,写入不会 发生在 读取之前,并且是因此不能保证可见。
即read可能看到旧值也可能看到新值; Java 内存模型允许任何一种结果。
要查看间隙有多窄,请尝试从循环中删除打印:
while (!stopRequested) {
i++;
}
执行于
openjdk version "14" 2020-03-17
OpenJDK Runtime Environment (build 14+36-1461)
OpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)
此代码不会终止。显着的区别是循环体变得不那么复杂,导致 JIT 应用额外的优化:-)
如您所见,不正确同步的程序的行为是不可预测的,并且可以在最轻微的刺激下发生变化。如果您想编写健壮的多线程代码,那么您应该根据规范证明您的代码是正确的,而不是依赖于测试。
考虑以下程序:
import java.util.concurrent.TimeUnit;
public class StopThread {
public static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Runnable task = new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopRequested) {
i++;
System.out.println(i);
}
System.out.println("Stopping the thread!!");
}
};
Thread backgroundThread = new Thread(task);
backgroundThread.start();
TimeUnit.SECONDS.sleep(5);
stopRequested = true;
}
}
- 此处
stopRequested
未声明为易失性 - 因此理想情况下,线程backgroupdThread
不得停止 - 并无休止地执行 - 但是当 运行 在本地时 - 线程
backgroundThread
正常关闭并显示消息:“正在停止线程!!”。
main() 线程对共享变量 stopRequested
的所有更新是否对其他线程可见?即使不使用 volatile
关键字?
您还没有完全理解 Java 内存模型 (JMM) 的概念。
JMM 在 so-called Happens-Before/Happens-After 关系的基础上工作。它是这样工作的:
- 每当任何线程读取一个字段时,我们称之为 'observing'。^1
- 如果 X行(发生在之前)和Y行(发生在之后)之间存在happens-before/happens-after关系,那么JVM gua运行tees您无法从 Y 观察到 X 运行 之前的字段值。这是整个 JMM 提供给您的唯一保证。它以任何其他方式使 gua运行tees 为零:它没有说明 Y 的写入对 X 做了什么(特别是,X 也可能看到 Y 的写入,这很奇怪,因为 Y 运行之后? - 而且,当也没有 HB/HA 时,它不会生成 gua运行tees:然后 Y 可能会看到 X 之前或 X 之后的状态,任何一个都可能发生!)
- HB/HA和实际时间完全无关。如果您使用时钟确定行 B 发生在行 A 之后,则不能保证 运行 这两者之间存在 HB/HA 关系,因此,任何由 A 引起的字段写入在 B 中不一定是可观察的。类似地,如果行 B 和 A 确实具有 HB/HA 关系,则可以保证运行您无法观察到处于 A [=106 之前的状态的任何字段=] 来自 B,但是,你实际上并没有得到任何 gua运行Tee B 将在 A 之后物理上(如,根据时钟)运行s。通常它必须按顺序能够让 B 观察 A 所做的更改,但如果 B 实际上没有检查 A 写的任何内容,那么 JVM 和 CPU 就没有必要小心,这 2 个语句可以 运行 并行,或者 B 甚至可以在 A 之前 运行,HB/HA 关系该死。
- gua运行tee 不是two-way街!是的,如果B'happens after'A,那么你得到gua运行tee 你无法观察到A之前的场的状态。但反过来就不是这样了!如果 A 和 B 根本没有 HB/HA 关系,你就得不到 gua运行tees。你得到的是我喜欢称之为 evil coin. 的东西
每当没有 HB/HA 关系并且 JVM 为您读取一个字段时,JVM 就会从它的厄运袋中取出邪恶的硬币并翻转它。尾巴,你得到了 A 写入之前的状态(例如,你得到了一个本地缓存副本)。头,你得到的是同步版本。
这枚硬币是邪恶的,因为它不会以任意 50/50 的随机概率掉落 heads/tails。不,不。它会着陆,以便您的代码在今天每次 运行 以及整个星期在 CI 服务器上每次测试套件 运行 时都能正常工作。然后在它到达生产服务器后,它仍然每次都以您想要的方式着陆。但是 2 周后,就在您向巨大的潜在新客户进行演示时?
然后它决定可靠地翻转你的另一条路。
JMM 为 JVM 提供了这种能力。这看起来非常疯狂,但这样做是为了让 JVM 尽可能多地进行优化。任何进一步的 gua运行tees 都会显着降低 JVM 的实际运行速度。
因此,您一定不能让 JVM 掷硬币。每当你通过字段写入的方式在 2 个线程之间通信时建立 HB/HA(几乎所有的东西最终都是字段写入,记住这一点!)
如何建立HB/HA?很多很多方式——你可以搜索它。最明显的:
- 自然HB/HA:在同一线程中的任何'below'另一行和运行s 平凡地建立HB/HA。 IE。
x = 5; y = x;
,单线程里就这样?那x
读了明明会看到你写的 - 线程开始。
thread.start();
该线程内第一行之前的 HBs。 synchronized
。在另一个线程的同步块中的第一行代码进入一个块(在同一个x
!)volatile
访问类似地建立 HB/HA 尽管很难弄清楚哪一行实际上是第一行(它基本上说 em HBs 中的一个在另一个之前但是是哪一个?),所以保持记住这一点。
您的代码根本没有 HB/HA,所以 JVM 正在抛硬币。你不能在这里得出任何结论。那个stopRequested
可能马上更新,可能下周更新,可能永远不更新,也可能5秒后更新。在决定执行这 4 个选项中的哪一个之前检查月相的 JVM 是 100% 有效的 java 实现。唯一的解决办法是不要让 VM 掷硬币,所以,用一些东西建立 HB/HA。
[1] 计算机尝试并行化并应用各种极其奇怪的优化。这会影响 timing(事情需要多长时间),并且没有(简单的)方法来给你计时 gua运行tees,所以 JMM 只是 不要这样做。在其他窝ds,如果你开始超时 and/or 尝试注册 CPU 计时器(System.nanoTime
)并尝试记录事情的顺序,你可以 'see' 各种奇怪的事情,但如果您真的尝试了,请注意几乎所有记录事件的方法都会导致同步,从而使您的测试完全无效。关键是,如果你做对了,你可以并行地观察事物 运行ning 甚至完全无序。 gua运行tees 与这些无关,gua运行tees 仅与阅读领域有关。
Java 语言规范不保证此结果。
在没有同步操作(例如 volatile
写入和后续读取)的情况下,写入不会 发生在 读取之前,并且是因此不能保证可见。
即read可能看到旧值也可能看到新值; Java 内存模型允许任何一种结果。
要查看间隙有多窄,请尝试从循环中删除打印:
while (!stopRequested) {
i++;
}
执行于
openjdk version "14" 2020-03-17
OpenJDK Runtime Environment (build 14+36-1461)
OpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)
此代码不会终止。显着的区别是循环体变得不那么复杂,导致 JIT 应用额外的优化:-)
如您所见,不正确同步的程序的行为是不可预测的,并且可以在最轻微的刺激下发生变化。如果您想编写健壮的多线程代码,那么您应该根据规范证明您的代码是正确的,而不是依赖于测试。