Happens - before 原则
happens-before 原则用来阐述操作之间的内存可见性,在 JMM 中,如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作之间要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
该原则有以下几种情况:
-
程序顺序规则
一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
-
监视器锁规则
对一个锁的解锁,happens-before 与随后对这个锁的加锁。
-
volatile 变量规则
对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
-
传递性
如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
注意:
两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行。happens-before 仅仅要求前一个操作的执行结果对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
数据依赖性
但是这种重排序也不是为所欲为的,它需要遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
那么什么是数据依赖性呢?很简单,举个例子:
int a = 0;
a = 1; //A
a = 2; //B
System.out.println(a);
显然,如果 A 与 B 因为指令重排序导致操作颠倒,那么输出结果肯定变了,这里操作 A 与 B 就存在数据依赖性,这种情况下,编译器和处理器就不会重排序这两者的之间的操作。常见的存在数据依赖性的操作有:读后写、写后写、写后读。
As-if-serial 语义
该语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变,编译器和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器和处理器给我们创造了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
volatile 的内存语义
volatile 变量自身具有以下特性:
-
可见性
对一个 volatile 变量的读,总能看到对这个 volatile 变量最后的写入。
-
原子性
对任意单个 volatile 变量的读写具有原子性,但类似于 volatile++ 这种符合操作不具有原子性。
volatile 变量所具备的可见性同样依托 happens-before 原则,但是,如果用内存模型来解释就更加通俗易懂:
当写一个 volatile 变量的时候,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
由于 volatile 仅仅保证了对单个 volatile 变量的读写具有原子性和可见性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大,但在可伸缩性和执行性能上,volatile 更有优势。
锁的内存语义
锁是 Java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发生消息。
当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
final 域的内存语义
不可变对象天生就是线程安全的。
但是需要注意 final 引用不能从构造函数内溢出。例子如下:
public class FinalDemo {
final int anInt;
static FinalDemo finalDemo;
public FinalDemo() {
this.anInt = 1; //写final域
finalDemo = this; //this引用在此溢出
}
public static void writer() {
new FinalDemo();
}
public static void reader() {
if (finalDemo != null) {
int temp = finalDemo.anInt;
System.out.println(temp);
}
}
}
在构造函数返回前,被构造的对象的引用不能为其他线程所见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都能保证看到 final 域正确初始化之后的值。
错误的 DCL 写法的问题
public class SingleTon {
private static SingleTon singleTon;
public static SingleTon getInstance() {
if (singleTon == null) {
synchronized (SingleTon.class) {
if (singleTon == null) {
singleTon = new SingleTon(); //问题所在
}
}
}
return singleTon;
}
}
singleTon = new SingleTon() 可以分解如下伪代码:
memory = allocate(); //1.分配对象的内存空间
ctorInstance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址
... //4.访问对象
我们说过,编译器和处理器会对指令序列进行重排序,但是必须建立在不能改变程序结果的条件下。
很显然,操作 2 和 3 可能会被重排序。
时间 | 线程 A | 线程 B |
---|---|---|
T 1 | A 1:分配对象内存空间 | |
T 2 | A3:设置 instance 指向内存空间 | |
T 3 | B1:判断 instance 是否为空 | |
T 4 | B2:由于不为空,线程 B 将访问 instance 引用的对象 | |
T 5 | A2:初始化对象 | |
T 6 | A3:访问 instance 引用的对象 |
可以看出,由于操作 2 和 3 的重排序,会导致线程 B 访问到一个还未初始化的对象。
有两种思路来解决这个问题:
- 不允许操作 2 和 3 重排序
- 允许操作 2 和 3 重排序,但不允许其他线程 “ 看到 ” 这个重排序。
基于 volatile 的解决方案
volatile 禁止指令重排序。所以只需把 singleTon 声明为 volatile 即可。
基于类初始化的解决方案
JVM 在类的初始化阶段,会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另外一种线程安全的初始化方案:静态内部类。
public class StaticInstance {
public StaticInstance getInstance() {
return ClassHolder.instance;
}
public static class ClassHolder {
static StaticInstance instance = new StaticInstance();
}
}