记忆模型的动机
请考虑以下示例:
public class Example {
public int a, b, c, d;
public void doIt() {
a = b + 1;
c = d + 1;
}
}
如果使用此类是单线程应用程序,那么可观察的行为将完全如你所期望的那样。例如:
public class SingleThreaded {
public static void main(String[] args) {
Example eg = new Example();
System.out.println(eg.a + ", " + eg.c);
eg.doIt();
System.out.println(eg.a + ", " + eg.c);
}
}
将输出:
0, 0
1, 1
就主线程而言,main()
方法和 doIt()
方法中的语句将按照它们在源代码中写入的顺序执行。这是 Java 语言规范(JLS)的明确要求。
现在考虑在多线程应用程序中使用的相同类。
public class MultiThreaded {
public static void main(String[] args) {
final Example eg = new Example();
new Thread(new Runnable() {
public void run() {
while (true) {
eg.doIt();
}
}
}).start();
while (true) {
System.out.println(eg.a + ", " + eg.c);
}
}
}
这会打印什么?
事实上,根据 JLS,无法预测这将打印:
- 你可能会看到几行
0, 0
开始。 - 然后你可能会看到像
N, N
或N, N + 1
这样的行。 - 你可能会看到像
N + 1, N
这样的行。 - 从理论上讲,你甚至可以看到
0, 0
线永远持续 1 。
1 - 实际上,println
语句的存在可能导致一些偶然的同步和内存缓存刷新。这可能隐藏了一些会导致上述行为的影响。
那我们怎么解释这些呢?
重新安排作业
意外结果的一种可能解释是,JIT 编译器已经改变了 doIt()
方法中赋值的顺序。JLS 要求语句似乎从当前线程**的角度按顺序执行。在这种情况下,doIt()
方法的代码中没有任何内容可以观察到这两个语句的(假设的)重新排序的效果。这意味着允许 JIT 编译器执行此操作。
为什么会这样做?
在典型的现代硬件上,使用指令流水线执行机器指令,该指令流水线允许指令序列处于不同的阶段。指令执行的某些阶段比其他阶段要长,并且内存操作往往需要更长的时间。智能编译器可以通过对指令进行排序来最大化重叠量来优化流水线的指令吞吐量。这可能导致无序执行部分语句。JLS 允许这样做,只要不影响当前线程的计算结果。
内存缓存的影响
第二种可能的解释是内存缓存的效果。在传统的计算机体系结构中,每个处理器都有一小组寄存器和更大的内存。访问寄存器比访问主存储器要快得多。在现代架构中,存在比寄存器慢的内存缓存,但比主内存更快。
编译器将通过尝试将变量的副本保存在寄存器或内存高速缓存中来利用此功能。如果一个变量并不需要刷新到主内存,或者没有需要从内存读取的,也有不这样做显著的性能优势。如果 JLS 不要求内存操作对另一个线程可见,则 Java JIT 编译器可能不会添加强制主内存读写的读屏障和写屏障指令。再一次,这样做的性能优势是显着的。
适当的同步
到目前为止,我们已经看到 JLS 允许 JIT 编译器生成代码,通过重新排序或避免内存操作来使单线程代码更快。但是当其他线程可以观察主内存中(共享)变量的状态时会发生什么?
答案是,其他线程可能会根据 Java 语句的代码顺序观察看似不可能的变量状态。解决方案是使用适当的同步。三种主要方法是:
- 使用原始互斥体和
synchronized
构造。 - 使用
volatile
变量。 - 使用更高级别的并发支持; 例如
java.util.concurrent
包中的类。
但即便如此,重要的是要了解需要同步的位置,以及你可以依赖的效果。这就是 Java Memory Model 的用武之地。
记忆模型
Java 内存模型是 JLS 的一部分,它指定了一个线程可以保证看到另一个线程对内存写入的影响的条件。内存模型具有相当程度的正式严谨性,并且(因此)需要详细和仔细阅读才能理解。但基本原则是某些构造在一个线程写入变量和另一个线程后续读取同一变量之间创建先发生关系。如果存在之前发生关系,则 JIT 编译器必须生成代码,以确保读操作看到写入写入的值。
有了这个,就可以推断出 Java 程序中的内存一致性,并决定这对于所有执行平台是否可预测和一致。