数据竞争
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
上述代码中,定义了两个共享变量 a 和 b,以及两个方法。在单线程分别调用方法一和方法二后,r1 和 r2 的值可能是(1,0) 或者是(0,2)。如果是在多线程环境下,两个方法分别跑在两个线程上,假设 Java 虚拟机在执行了任一方法的第一条赋值语句之后便切换线程,那么最终 r1 和 r2 的结果可能是(0,0)。
除了上述三种情况外,还有可能出现另一种 r1 和 r2 值的情况(1,2)。出现这种情况的原因有三个:即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序。后两种涉及到具体的体系架构,下面我们只分析编译器重排序是怎么回事。
首先声明,即时编译器需要保证程序能够遵守 as-if-serial 属性。也就是说,在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果与顺序执行的结果保持一致。
另外,如果两个操作之间存在数据依赖,那么即时编译器不能调整他们的顺序,否则将会造成程序语义的改变。
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
int c = b + 1;
if (r2 == 2) {
int r3 = r2 + 1;
}
}
上述代码中,扩展了方法一。新增代码会先使用变量 b 的值,然后再使用局部变量 r2 的值。此时,编译器有两种选择。
一,在一开始就将 a 加载中某一寄存器中,并且在接下来 b 的赋值操作以及使用 b 的代码中避免使用该寄存器。二:在真正使用 r2 时才将 a 加载至寄存器中。这样的话在使用 b 的时候不用霸占一个寄存器,减少了接触栈空间的情况。
int a=0, b=0;
public void method1() {
for (..) {
int r2 = a;
b = 1;
a = r2 + 1;
}
}
上述代码是把方法一中的代码放入循环体中,并且新增一行代码:使用 r2 并且更新 a。由于对 b 的赋值是循环无关的,即时编译器很有可能将其移出循环之前,而对 r2 的赋值语句还停留在循环之中。
通过上述两段举例分析,我们得出结论:即时编译器的优化可能将原本字段访问的执行顺序打乱。在单线程环境下,由于 as-if-serial 的保证,我们无需担心顺序执行不可能发生情况。例如(r1,r2)的值为(1,2)。
但是在多线程情况下,这种数据竞争的情况是可能发生的。而且,Java 语言规范将其归咎于应用程序没有做出恰当的同步操作。
Java 内存模型与 happens-before 关系
为了避免数据竞争的干扰,Java 5 引入了明确定义的 Java 内存模型。其中有个最重要的概念便是:happens-before 关系。happens-before 关系是用来描述两个操作的内存可见性的。如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 是可见的,也就是说 X 操作先于 Y 操作执行。
在同一个线程中,字节码的先后顺序也暗含了 happens-before 关系:控制流靠前的字节码 happens-before 靠后的字节码。但是,如果后者没有观测前者的运行结果,也就是后者没有数据依赖于前者,那么它们的执行顺序就可以能被颠倒。
下面举例线程间的 happens-before 关系:
1:解锁操作 happens-before 对同一把锁枷锁操作。
2:volatile 字段的写操作 happens-before 对同一字段的读操作。
3:线程的启动操作 happens-before 该线程的第一个操作。
4:线程的最后一个操作 happens-before 该线程的终止事件。
5:线程对其他线程的中断操作 happens-before 被中断线程受到中断信号。
6:构造器中的最后一个操作 happens-before 析构器的第一个操作。
happens-before 关系具有传递性。如果 X happens-before Y,Y happens-before Z,那么 X happens-before Y。
文章开头提到 r1 和 r2 的值可能是(1,2),那么如何避免这种结果呢?那就是将 a 或者 b 设置为 volatile 字段。
比如:b 设置为 volatile 字段。假设 r1 可以观测到 b 的赋值结果 1。这样的话,b 的赋值操作要先于 r1 的赋值操作执行。根据 volatile 字段的 happens-before 关系,我们知道 b 的赋值操作 happens-before r1 的赋值操作。然后,再根据同一个线程中字节码暗含 happens-before 关系,以及 happens-before 关系的传递性,可以得出 r2 的赋值操作 happens-before a 的赋值操作。这样的话,就不会出现 r1 和 r2 的值是(1,2)这种情况了。
由此观之,解决数据竞争问题的关键在于构造一个跨线程的 happens-before 关系。
Java 内存模型的底层实现
理解了上述 Java 内存模型的概念后,我们总结下它的底层实现。Java 内存模型是通过内存屏障来进制重排序的。
对于即时编译器,它会对每一个 happens-before 关系向正在编译的目标方法中插入相应的读读,读写,写读以及写写屏障。
这些内存屏障会限制即时编译器的重排序操作。以 volatile 字段的访问为例,所插入的内存屏障不允许 volatile 字段写操作之前的内存访问重排序在其之后,也不允许 volatile 字段读操作之后的内存访问被重排序至其之前。
之后,即时编译器将根据具体的底层体系架构,将这些内存屏障替换成 CPU 指令。
对于 volatile 字段的内存屏障转化而来的指令,可以简单地理解为强制刷新处理器的写缓存。写缓存是处理器用来加速内存存储效率的一项技术。在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步到主内存之中。强指刷新写缓存,将使得当前线程写入 volatile 字段的值(以及写缓存中已有的其他内存修改),同步到主内存之中。
内存写操作同时会无效化其他处理器所持有的,指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该 volatile 字段的最新值。
锁,volatile 字段,final 字段
锁操作同样具备 happens-before 关系。具体指:解锁操作 happens-before 对同一把锁加锁操作。实际上,解锁时,Java 虚拟机同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
volatile 字段可以看成一种轻量级,不保证原子性的同步,其性能往往优于锁同步。然而频繁的访问 volatile 字段也会因为不断的强制刷新缓存而严重影响程序的性能。所以,理想情况下对 volatile 字段应该多度少写,并且只有一个线程进行写操作。volatile 字段另一个特性是无法被即时编译器分配到寄存器里。也就是说,volatile 字段的每次访问均需直接存内存中读写。
final 实例字段设计新建对象发布问题。当一个对象包含 final 字段时,其他线程只能读 final 字段。所以,即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布重排序至 final 字段的写操作之前。
总结
本文创作灵感来源于 极客时间 郑雨迪老师的《深入拆解 Java 虚拟机》课程,通过课后反思以及借鉴各位学友的发言总结,现整理出自己的知识架构,以便日后温故知新,查漏补缺。
网友评论