通过上一节中的介绍,我们已经了解导致可见性的原因是缓存,有序性问题是编译优化造成。直接禁用缓存和编译器优化就可以解决这些让人苦恼的问题了,但性能也是肉眼可见的降低,这也是无法接受的。Java内存模型规范了JVM如何提供按需禁用缓存和编译优化方法。这些方法包括 volatile、synchronized 和 final三个关键字,以及六项 Happens-Before 规则。
volatile
volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略。
(类成员变量、静态成员变量)被volatile修饰后,那么就具备两层含义(缓存一致性协议 MESI 协议):
1.保证了不同线程对这个变量进行操作的可见性(可见性)
2.禁止指令重排(有序性)
例如对于volatile int x = 0,对x的读写,不再通过cpu缓存,必须通过内存进行读写。
Happens-Before 规则
Happens-Before定义:前面一个操作的结果对后续操作是可见的。
Happens-Before约束了编译器的优化行为,允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
1.程序顺序性规则
这条规则表示在一个线程中,按照程序的顺序,前面的操作结果对于后续操作都是可见的。
public class Volatile {
private int i = 0;
private int j = 1;
private volatile boolean flag = false;
public void write() {
i = 2;
j = 3;
flag = true;
}
public void read() {
if (flag) {
System.out.println("i=" + i);
System.out.println("j=" + j);
}
}
public static void main(String[] args) {
Volatile v = new Volatile();
Thread write = new Thread(() -> {
v.write();
}, "thead-write");
Thread read = new Thread(() -> {
try {
write.join();
v.read();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thead-read");
read.start();
write.start();
}
}
按照程序的顺序,前面的操作结果对于后续操作都是可见的, i = 2; j = 3;在flag之前,程序前面对某个变量的修改一定是对后续操作可见的。
2.volatile变量规则
这条规则是指对一个 volatile 变量的写操作, Happens-Before (前面一个操作的结果对后续操作是可见的)于后续对这个 volatile 变量的读操作。
JMM对volatile的内存屏障插入策略
为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
- 在每个volatile写操作的前面插入StoreStore屏障
- 在每个volatile写操纵的后面插入StoreLoad屏障
- 在每个volatile读操纵的的后面插入LoadLoad屏障
- 在每个volatile读操作的后面插入LoadStore屏障
上图中的StoreStore屏障可以保障在volatile写之前,其前面的所有普通写操作已经对所有处理器可见,这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主存中。
StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。如果volatile写之后直接return,编译器无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。为了保证能正确实现volatile的内存语义,JMM采取了保守策略:在每个volatile写的后面,或者说在每个volatile读的前面插入一个StoreLoad屏障。
3. 传递性
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
这条规则比较好理解,A先于B发生,B先于C发生,那么A一定先于C发生。 C 能看到A的操作后的变化。类似于数学里面的推导。
4. 管程中锁的规则
对一个锁的解锁操作早于另一个线程对该锁的加锁操作。
在理解这条规则之前,先看下什么是"管程"。
在操作系统中,管程的定义如下: 管程是由一组数据以及定义在这组数据之上的对该组数据操作的操作组成的软件模块,称之为管程。 基本特性: 1. 局部于管程的数据只能被局部于管程内的过程所访问。 2. 一个进程只有通过调用管程内的过程才能进入管程访问共享数据 3. 每次仅允许一个进程在管程中执行某个内部过程。 注意:由于管程是一个语言的成分,所以管程的互斥访问完全由编译程序在编译时自动添加,无需程序员关注。
管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
synchronized我们或多或少都接触了解过,理解管程后,这条规则含义其实就是当线程A执行完syn()方法后,x=1,此时线程B看到的x值为1。这个也非常容易理解,毕竟synchronized可以保证原子性、可见性、唯一性。
private int x = 0;
private final Object LOCK = new Object();
public void syn() {
// 加锁
synchronized (LOCK) {
if (x < 1) {
x = 1;
}
}
// 释放锁
}
5. 线程 start() 规则
线程A在启动线程B之前的所有操作结果对线程B都是可见的。
我们通过main线程调用子线程thread-0,修改变量tmp值。发现thread-0线程调用start启动线程后tmp值打印出来的也为20。
private static int tmp = 0;
public static void main(String[] args) {
Thread thread = new Thread(() -> System.out.println(tmp),"thread-0");
tmp = 20;
thread.start();
}
6. 线程 join() 规则
如果线程 A 执行操作ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens- before 线程A Join 之后的所有操作
这个也比较简单,可以参考下join方法。
以上就是JMM解决可见性和有序性的规则介绍。