重排序对多线程的影响
//重排序对多线程的影响
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if (flag) { // 3
int i = a * a; // 4
}
}
}
操作步骤1,2没有数据依赖,处理器和编译器可以对这两个操作重排序;
同理,操作3,4可以重排序;
如果有两个线程A,B分别去调用 writer() 和 reader() 两个方法,可能的执行顺序如下图所示:
操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。
为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并 行度的影响。
以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把 计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。
当操作3的条件判断为真时,就把该计算结果写入变量i中。
从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了 多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
同步程序的顺序一致性效果
//同步程序的顺序一致性效果
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() { // 获取锁
a = 1;
flag = true;
} // 释放锁
public synchronized void reader() { // 获取锁
if (flag) {
int i = a;
} // 释放锁
}
}
顺序一致性模型中,所有操作完全按程序的顺序串行执行。 而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语 义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图)。虽然线程A在临界 区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临 界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
JMM在具体实现上的基本方针为: 在不改变(正确同步的)程序执 行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。
从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
代码块同步是使用monitorenter 和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结 束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
同步方法的实现不是基于monitorenter和monitorexit指令来实现的,在运行时常量池里通过ACC_SYNCHRONIZED来区分是否是同步方法,方法执行时会检查该标志当一个方法有这个标志的时候,进入的线程首先需要获得监视器才能执行该方法方法结束或者抛异常时会释放监视器。
volatile写-读建立的happens-before关系
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if (flag) { // 3
int i = a; // 4
}
}
}
假设线程A执行writer()方法之后,线程B执行reader()方法。
A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
volatile写的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值 被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。
volatile读的内存语义
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主 内存中读取共享变量。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插内存屏障来禁止特定类型的处理器重排序。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障。