关注【索引目录】服务号,更多精彩内容等你来探索!
查看下面的代码,并尝试猜测当它在多个线程中运行时 x 和 y 的所有可能值(方法 thread1() 将由线程 1 运行,方法 thread2() 将由线程 2 运行)。这是 Oracle Java(版本 21):
int x, y;
int r1, r2;
public void thread1() {
x = r2;
r1 = 1;
}
public void thread2() {
y = r1;
r2 = 1;
}
提示:对于 (x, y) 对,有四种可能的结果:(0, 0), (0, 1), (1, 0), (1, 1)。您可以使用 jcstress 库运行此示例,例如:https://github.com/openjdk/jcstress测试示例
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Reordering happened")
@Outcome(id = "", expect = Expect.ACCEPTABLE, desc = "Sequential execution")
@State
public class JMMReordering {
int x, y;
int r1, r2;
@Actor
public void thread1() {
x = r2;
r1 = 1;
}
@Actor
public void thread2() {
y = r1;
r2 = 1;
}
@Arbiter
public void result(II_Result r) {
r.r1 = x;
r.r2 = y;
}
}
你可能会想:
“好吧,(0,1)、(1,0) 和 (0,0) 是有道理的——它们可能是由于数据竞争而发生的。但是 (1, 1) 这个结果到底是从哪里来的呢?”
简短的回答是:Java 内存模型(JMM)——具体来说,由于重新排序。
为了实现优化,JMM 可能会指示你的 JVM(编译器,JIT 编译器)将“r2 = 1”重新排序到“y = r1”之前,或将“x = r2”重新排序到“r1 = 1”之后。这并不罕见——事实上,JMM 一直都在这样做。
您可以将 JMM 视为在后台运行的附加程序,用于确定每个用 Java 编写的程序中线程的所有可能行为。它是 JVM 的一部分。
重要的是,这些重新排序不会改变单线程程序的结果。但在多线程代码中,它们可能会导致意外的结果,例如 (1,1)。
Java 中何时发生重新排序?
您现在可能会问:
“我的 Java 程序什么时候应该进行指令重新排序?”
重新排序指令有很多种。好消息是,您通常不需要确切知道重新排序发生的位置。大多数情况下,Java 开发人员无需担心底层优化。但是,如果您正在使用多线程代码,那么了解如何避免不可预测的行为至关重要。关键在于:您的程序必须正确同步——我们将解释这意味着什么。
重新排序取决于 Java 内存模型 (JMM) 的实现方式。不同的 JVM 供应商(例如 Oracle、Amazon Corretto 等)可能会应用各自的优化措施。这些优化措施通常包括指令重新排序,以提高性能。为了更好地理解特定 JVM 版本中重新排序和其他转换的实现方式,请务必查阅该 JVM 的官方文档。
思考是什么阻止了重新排序通常比预测它何时会发生更容易。以下是阻止重新排序的因素列表(并非详尽):
1. Happens-before 边为代码中的动作带来 happens-before 关系。
如果两个操作之间存在 happens-before 边界(或内存屏障),JVM 就无法对它们进行重新排序。以下是一些关键示例:
- Volatile 变量
Volatile 变量会创建半内存屏障:代码中,在写入 volatile 变量之前的所有读写操作都不会被重新排序到写入 volatile 变量之后。在读取 volatile 变量之后的所有读写操作也不会在读取 volatile 变量之前发生。
这会创建所谓的“释放-获取”对。例如,如果上面示例中的 r1 和 r2 被声明为 volatile,那么涉及它们的指令将不会被重新排序——结果 (1, 1) 也永远不会发生。
为什么不在 x 和 y 上使用 volatile 呢?这也可以避免 (1,1)——但它无法解决 r1 和 r2 的可见性问题。不过,在这个小例子中,这并不是什么大问题。
- 循环数据依赖(循环因果关系)
Java 禁止凭空而来的值。这意味着,如果代码中的变量以循环方式相互依赖,JVM 不会凭空捏造出满足依赖关系的值。
示例 — — 此代码永远不会导致 r1 == 42 或 r2 == 42:
int x, y;
int r1, r2;
public void thread1() {
r1 = x;
if (r1 != 0)
y = 42;
}
public void thread2() {
r2 = y;
if (r2 != 0)
x = 42;
}
这里,y = 42 仅当 r1 != 0 时才会发生,这需要 x == 42,而 x == 42 又依赖于 r2 != 0,而 r2 != 0 又依赖于 y == 42。这是一个循环——循环依赖。JMM 会避免在这种情况下进行重排序,因为这会导致在 Java 的单线程执行(线程内语义)中不可能发生的结果。
2. 无限循环
如果线程陷入无限循环,则循环之后的任何操作都不会移到循环之前。
volatile int x, y;
int r1, r2;
public void thread1() {
do {
r1 = x;
} while (r1 == 0);
y = 42;
}
public void thread2() {
do {
r2 = y;
} while (r2 == 0);
x = 42;
}
这里,两个线程将永远循环,并且由于循环可能永远不会退出,因此 JVM 无法在循环之前重新排序操作。
3. 访问冲突
访问是指对变量的写入或读取。如果对同一共享变量或数组元素的两次访问中至少有一次是写入操作,则称两次访问发生冲突。如果对同一共享变量存在访问冲突,则不允许重新排序。
例如:
int x, y;
int r1, r2;
public void thread1() {
r1 = x;
if (r1 != 0)
y = 42;
}
public void thread2() {
r2 = y;
if (r2 != 0)
x = 42;
}
这里,在方法 thread1 中,对 r1 既有读取操作,也有写入操作。这是访问冲突。JVM 不会将 r1 = x 重新排序到 if 块之后,因为这会破坏逻辑。对于“thread2”和“r2”也是如此。
4. 同步动作和外部动作
某些操作永远不会重新排序:
同步操作,例如
- 读取/写入易失性变量
- 锁定和解锁监视器(例如,同步块)
- 线程启动和加入(Thread.start(),Thread.join())
- 线程的开始和结束(即使未由方法表示)
外部操作,例如
- 打印到控制台
- 读取/写入文件
- 网络操作等。
例如,以下代码将始终在“World”之前打印“Hello”:
public void thread1() {
System.out.println("Hello");
System.out.println("World");
}
打印是程序内部状态的外部操作,它是可观察的——并且必须遵守顺序。
5. 单线程逻辑(线程内语义)防止重新排序
即使没有同步,如果重新排序会改变单线程程序的行为,并且 JMM 检测到了这种情况,那么重新排序也是不允许的。
这在一定程度上简化了 Java 代码的推理:只要符合正确行为的需要,同一线程内的操作看起来会按照编写的顺序发生。
关于记忆模型
编译器、虚拟机甚至处理器经常会优化你的代码,使其在特定硬件上运行得更快。这些优化包括指令重排序、冗余同步移除(例如,当两个变量指向同一个底层值时)、推测执行(可能导致所谓的“凭空而来”的值)等等。Java 内存模型 (JMM) 决定哪些转换可以安全地应用于你的 Java 程序。
内存模型的概念并非 Java 独有。所有支持多线程的编程语言都需要一个内存模型——一组定义不同线程中的操作如何通过内存进行交互的规则。内存模型通常设计为既方便开发人员使用,又方便系统设计人员灵活使用。但是,“易于使用”和“灵活”究竟是什么意思呢?
想象一下一个多线程 Java 程序,其中不会发生任何意外行为——没有数据争用,没有重新排序,并且所有内容始终按照其在代码中出现的顺序运行。您无需担心使用 volatile、synchronized、AtomicInteger 或任何常用的并发工具。JMM 会自动应用正确的同步,您的程序将完全按照编写的顺序运行。这无疑会使编写多线程代码变得更加容易——这就是我们所说的“易于使用”。
然而,这样的内存模型存在严重的缺陷。首先,它很难正确实现。(尽管确实存在一些类似的模型)更重要的是,这种严格的顺序(称为顺序一致性)会阻碍大多数性能优化,从而显著降低程序的运行速度。
现在,考虑另一个极端:一个允许所有可能的优化(包括不安全的优化,例如凭空产生的值)的模型。在这种情况下,代码可能非常快,但诸如 volatile、synchronized 或 AtomicInteger 之类的工具可能不足以确保正确的行为。虽然性能有所提升,但代价是可预测性和正确性。
在本文中,“灵活性”指的是内存模型允许的转换次数。允许的转换次数越多,模型就越灵活(可能也更快),但开发人员也更难理解。
这就是 JMM 试图达成平衡的原因。它的目标是允许进行有用的优化,同时又不会给程序员的工作带来太大的负担。换句话说,它试图在开发人员的简单性和系统设计人员的灵活性之间取得平衡——尽可能地让每个人都满意。
JMM 担保
Java 内存模型(JMM)提供了两个主要保证(也是其核心要求):
1. 正确同步的程序将实现顺序一致性执行。
如果您的代码已正确同步(例如,使用同步块、volatile、AtomicInteger 等),它将以可预测且一致的顺序执行——就像代码中显示的那样。不会出现数据竞争,程序的行为将完全符合您的预期。
2. 明确同步错误程序的行为。
如果程序未正确同步,则可能会出现数据竞争、重新排序和其他不一致问题,但并非所有可能的转换都会被允许。JMM 仍然会限制最糟糕的行为。
第一个保证对开发者来说尤其重要,因为它告诉我们如何编写行为正确的多线程程序。
第二个保证更多地是关于内存模型如何在内部管理优化——并且不同的内存模型会有所不同。我们不会在这里讨论这些技术细节。(再次强调,有关此行为的更多详细信息,请参阅特定版本的 JVM 文档)
Java 内存模型的两个关键部分
JMM 有两个主要部分,有助于确保正确同步的程序中的顺序一致行为:
-
发生之前一致性 -
因果关系要求
让我们更仔细地看一下。
1. 先行一致性(Happens-Before Consistency)
这是理解 JMM 行为的一种简化方式。它描述了操作(例如读取和写入)必须如何相互关联才能被视为正确。
先行发生模型的关键规则
同步顺序:
同步操作(例如锁定和解锁)必须按照特定的顺序发生。例如,在同步块中,锁定必须在解锁之前发生。
这就创建了一种“先发生”的关系。JMM 保证这种关系得到保留。这些同步点被称为同步操作(完整列表请参阅 JLS §17.4.2)。
线程内一致性:
对于单线程,所有操作都将按照它们在代码中出现的顺序执行——就像在常规的单线程程序中一样。JMM
保证优化不会破坏此顺序。因此,您始终会看到基于编写代码合理的值。
先行一致性(非 volatile):
对于普通(非 volatile)变量,只要程序遵循先行一致性规则,JMM 就能确保读取操作能够读取到之前发生的写入操作。
这有助于防止一些奇怪的行为,例如读取尚未写入的值(例如“凭空而来”的值)。
如果您的代码正确同步,则不会出现此问题。
同步顺序一致性:
对于 volatile 变量,有一条特殊规则:读取操作始终会读取同一变量的最后一次写入操作(根据同步顺序)。
这与 happens-before 规则类似,但特定于 volatile 字段。
简而言之,如果程序中的每次读取都能看到在它之前发生的写入(就执行而言,不一定是源代码),则您的程序被视为先发生一致的。
但这里有一个棘手的地方,“之前发生”指的是实际执行的顺序,而不是源代码的顺序。因此,即使某些代码在后面出现,由于重新排序,它也可能提前运行。
例如:
int a = 0;
a = 2;
int b = a;
您可能期望 b 为 2,但由于重新排序,a = 2 有可能在 b = a 之后执行。因此,b 可能为 0。
即使这不是您预期的,它仍然遵循先行发生规则,因为“a”之前写入了默认值 0,而读取操作会看到该默认值为 0。
话虽如此,先行发生模型并不能阻止一些有问题的行为——尤其是凭空而来的值。这就是第二部分——因果关系要求——的用武之地。
2. 因果关系要求
因果关系是内存模型中更复杂的部分,主要与语言设计者和编译器编写者相关,而不是日常开发人员。
让我们尝试简单地解释一下。
因果关系要求是 JMM 用来判断某项执行是否合法的规则。每个程序执行都可以分解为一组操作:读取、写入、锁定、解锁等。JMM 会根据其规则检查这些操作的顺序及其是否合理。
如果执行顺序没有违反任何规则,则允许执行。
如果违反了因果关系规则,JMM 可能会重新排序操作或拒绝执行。如果无法实现有效的排序,则可能导致运行时或编译时错误(理论上)。
除非您正在构建自己的记忆模型,否则您不需要了解因果关系的每个细节。
只要记住这个简单的想法。因果关系要求可以防止非法执行,即读取操作会遇到在程序顺序中从未发生过的写入操作。
概括
JMM 提供了强有力的保证:
如果您的程序正确同步,它将以顺序一致的方式运行。
这意味着:
每次读取都会看到最后一次写入。
操作顺序是可预测且一致的。
Java 提供了许多工具来帮助确保正确的同步,例如:
同步块、
易失性变量、
来自 java.util.concurrent.atomic 的原子类等等
这些工具可帮助您的程序遵循先发生顺序,并且如果使用得当,您的程序将安全且可预测地运行。
其余部分(因果关系、重新排序、凭空而来的值)主要是 JVM 设计人员和编译器工程师关注的。
关注【索引目录】服务号,更多精彩内容等你来探索!

