目录
1.原子性:保证指令不会受到线程上下文切换的影响。
2.可见性: 保证指令不会受 CPU缓存的影响
3.有序性: 保证指令不会受 CPU 指令并行优化的影响 (编译优化带来的有序性问题)
1、Java内存模型
JMM即Java Memory Model,它从java层面定义了主存(线程共享)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
导致可见性的原因是缓存,导致有序性的原因是编译优化,所以解决可见性、有序性问题的最直接办法就是按需禁用缓存以及编译优化。而Java内存模型规范了JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
1) volatile、synchronized 和 final 三个关键字,以及
2)六项 Happens-Before 规则。
JMM在后续文章中会有详细讲解!
2、可见性
例子:
public static boolean run = true;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(run) {
}
}, "t1");
t1.start();
sleep(1);
run = false;
}
上述代码中当我们主线程把run修改为false之后,t1线程并不会停止!
原因分析:
1)初始状态:t线程刚开始会从主内存中读取run的值。
2)然后,随着程序的运行,由于t1线程频繁的从主内存中读取run值,JIT编译器会将run的值缓存至自己的高速缓存中,减少对主存的访问。
3)1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值
解决办法:
使用volatile
关键字!
作用:它可以用来修饰成员变量和静态变量(放在主存线程共享的变量),它可以避免线程从自己的工作内存中查找读取变量的值,而是每次都从主内存中去读取,线程操作volatile
修饰的变量都是直接操作主存的。
注意:
1)我们之前学的synchronized
也可以保证代码块内变量的可见性。但是它是一个重量级操作,性能比较低。
2)volatile
只能保证可见性,不能保证原子性:比如下面虽然i获取的都是最新值,但并不能避免指令交错的问题
// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
3) synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性
4)System.out.println()
方法使用了synchronized
同步代码块,可以保证原子性和可见性,它是PrintStream类的方法。
3、有序性
1)指令重排序
指令重排序:在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合的方式来实现指令级并行。
// 可以重排的例子
int a = 10;
int b = 20;
System.out.println( a + b );
// 不能重排的例子
int a = 10;
int b = a - 5;
单线程指令重排序不会影响结果,但是多线程则会影响。
2)多线程下指令重排问题
int num = 0;
// volatile 修饰的变量,可以禁用指令重排 volatile boolean ready = false; 可以防止变量之前的代码被重排序
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
}
else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
可能出现结果为0的情况,原因就是下面代码可能发生了指令重排序:
ready = true; // 前
num = 2; // 后
3)解决办法
在ready处加volatile
,可以避免指令重排序。原因是volatile 修饰的变量可以防止其之前的代码重排序。
voliatile在JDK1.5之后才能生效。
4、volatile
原理
volatile
的底层原理是内存屏障,Memory Barrier(Memory Fence)
写屏障:对 volatile 变量的写指令后会加入写屏障
读屏障:对 volatile 变量的读指令前会加入读屏障
1)volatile
保证可见性
-
写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {
num = 2; //也会把最新的值同步到主内存中
ready = true; // ready 是被 volatile 修饰的,赋值带写屏障
// 写屏障
}
- 读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r) {
// 读屏障
// ready是被 volatile 修饰的,读取值带读屏障
if(ready) {
r.r1 = num + num; //num读取的也是主内存中的值
} else {
r.r1 = 1;
}
}
2)volatile
保证有序性
-
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是被 volatile 修饰的,赋值带写屏障
// 写屏障,num = 2就不会排到这个写屏障之后
}
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
// 读屏障
// ready 是被 volatile 修饰的,读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
注意:volatile
不能解决指令交错!
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去。 而有序性的保证也只是保证了本线程内相关代码不被重排序。比如:
3)double-checked locking问题
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
上述典型的懒汉式实例化对象!
会发生指令重排,问题的关键在于new操作发生了指令重排。
注意:synchronized
保护的代码块内是可以发生代码重排序的,如果共享变量完全被synchronized
保护,是不存在有序性问题的。
解决方法:在instance前加volatile。
5.happens-before规则
1)管程中锁的规则
对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
2)volatile变量规则
对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
3)线程 start() 规则
Thread对象的start()方法先行发生于此线程的每一个动作
主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
线程 start 前对变量的写,对该线程开始后对该变量的读可见
Thread B = new Thread(()->{
// 主线程调用 B.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
4)线程 join() 规则 (线程结束后)
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
换句话说,就是线程结束前,会把共享变量同步到主内存中。
Thread B = new Thread(()->{
// 此处对共享变量 var 修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66
5)线程中断规则
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x); //10
break;
}
}
},"t2");
t2.start();
new Thread(()->{
sleep(1);
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x); //10
}
6)默认值
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
7)传递性
如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C
如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42”。
8)程序的顺序性规则
在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
在单个线程中虽然存在指令重排序,但是其结果不受影响,可以认为其是串行的。
9)对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。