文章目录
上一章讲解的 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性。
这一章我们进一步深入学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题。
一、Java 内存模型
JMM 即 Java Memory Model,它从 Java 层面定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。JMM 体现在一下几个方面
- 原子性:保证指令不会受线程上下文切换的影响。
- 可见性:保证指令不回受 CPU 缓存的影响(JIT 堆热点代码的缓存优化)
- 有序性:保证指令不会受 CPU 指令并行优化的影响。
之前讲的 synchronized 底层 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性。
1.1 可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
1.1.1 举个例子:退不出的循环
import static java.lang.Thread.sleep;
public class Test1 {
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
}
});
t.start();
sleep(1000);
run = false;
}
}
为什么会发生这种情况,我们分析一下:
- 初始状态下,t 线程刚开始从内存读取了 run 的值到工作内存。
- 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
- 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取了这个变量的值,结果永远是旧值。
可见性问题,这里涉及到 Java 内存模型(JMM)
JMM 描述了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取出变量这样的底层细节。
在内存模型中,所有的变量都存储在主内存中。每个线程都有自己独立的工作内存,里面保存着该线程使用到的变量的副本。
内存模型图:
JMM 线程操作内存的两条基本规定:
- 关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
- 关于线程间工作内存:不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要经过主内存来完成。
共享变量可见性实现的原理:
线程 1 对共享变量的修改要想被线程 2 及时看到,必须要经过两个步骤:
- 把工作内存 1 中更新过的共享变量刷新到主内存中
- 把内存中最新的共享变量的值更新到工作内存 2 中。
1.1.2 解决方法
- 使用
volatile
(表示易变关键字的意思),它可以用来修饰成员变量
和静态成员变量
,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。 - 使用
synchronized
关键字也有相同的效果,在Java内存模型中,synchronized规定,线程在加锁时,需要先清空工作内存 -> 在主内存中拷贝最新变量的副本到工作内存 -> 执行完代码 -> 将更改后的共享变量的值刷新到主内存中 -> 释放互斥锁
1.1.3 可见性 vs 原子性
前面例子体现的实际就是可见性,它是指保证多个线程之间一个线程对volatile
变量的修改对另一个线程可见,而不能保证原子性。volatile用在一个写线程,多个读线程的情况,比较合适。
举个例子:两个线程一个i++,一个 i–,但是volatile只保证可见性,只能看到最新之,但不能解决指令交错问题(原子性)。
// 假设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
synchronized
语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是它是属于重量级操作,性能相对较低。如果在前面实例的死循环中加入 System.out.println() 会发现即使不加volatile修饰符,线程t也能正确看到对run变量的修改了,想想是为什么?因为println方法里面有synchronized修饰。
1.1.4 模式之两阶段种植
我们之前的做法:当我们在执行线程一时,想要终止线程二,这就需要使用interrupt
方法来优雅的停止线程二。
使用volatile
关键字来实现两阶段终止模式。
public class Test1 {
public static void main(String[] args) throws InterruptedException {
// 下面是两个线程操作共享变量stop
Monitor monitor = new Monitor();
monitor.start();
Thread.sleep(3500);
monitor.stop();
}
}
class Monitor {
// private boolean stop = false; // 不会停止程序
private volatile boolean stop = false; // 会停止程序
/**
* 启动监控器线程
*/
public void start() {
Thread monitor = new Thread(() -> {
//开始不停的监控
while (true) {
if (stop) {
break;
}
}
});
monitor.start();
}
/**
* 用于停止监控器线程
*/
public void stop() {
stop = true;
}
}
1.2 有序性
由于JIT即时编译器的优化,可能会导致指令重排。CPU支持多级指令流水线,例如支持同时执行·取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
的处理器。效率很快。
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;
}
线程1执行actor1方法, 线程2执行actor2方法
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为1
- 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
- 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为4(因为 num 已经执行过了)
- 情况4:结果还有可能是0 ,线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2。
1.2.1 重排序需要遵守一定规则
指令重排序操作不会对存在数据依赖关系的操作进行重排序。比如:a=1,b=a;这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序/
指令重排序在单线程模式
下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了。
解决方法:volatile 修饰的变量,可以禁用指令重排。
注意:
- 使用synchronized并不能解决有序性问题,但是如果时该变量整个都在synchronized代码块的保护范围内,那么变量就不会被多个线程同时操作,也不用考虑有序性问题。在这种情况下相当于解决了重排序问题。
1.3 volatile原理
volatile是Java虚拟机提供的轻量级的同步机制。
- 保证可见性
- 不保证原子性
- 保证有序性
volatile的底层实现原理时 内存屏障
- 对volatile变量的写指令后会加入**写屏障,**保证写屏障之前的写操作,都能同步到主存中。
- 对vloatile变量的读指令前会加入**读屏障,**保证读屏障之后的读操作,都能读到主存的数据。
1.3.1 volatile如何保证可见性
- 写屏障:保证在该屏障之前的对共享变量的改动,都同步到主存当中。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready是被volatile修饰的 ,赋值带写屏障
// 写屏障.(在ready=true写指令之后加的,
//在该屏障之前对共享变量的改动, 都同步到主存中. 包括num)
}
- 读屏障:保证在该屏障之后,对共享变量的读取,加载的都是主存中的最新数据。
public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) { // ready, 读取的就是主存中的新值
r.r1 = num + num; // num, 读取的也是主存中的新值
} else {
r.r1 = 1;
}
}
1.3.2 volatile 是如何保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
- 读屏障会确保指令重排序时,不会讲读屏障之后的代码排在读屏障之前。
volatile不能解决指令交错(不能解决原子性)
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其他线程的读,跑到它前面去。
- 有序性的保证也只是保证了本线程内相关代码不会被重排序。
下图中的t2线程,就先读取了i=0,此时还是会出现指令交错,可以使用synchronized
来解决原子性。
1.4 double-checked locking (双重检查锁)问题
首先synchronized
可以保证它的临界区的资源是原子性、可见性、有序性
d的,有序性的前提是,在synchronzied代码块中的共享变量,不会再代码块中使用到,否正有序性不能被保证,只能使用volatile
来保证有序性。
最开始的单例模式:
// 最开始的单例模式是这样的
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
/*
多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时
判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁,
防止多线程操作共享资源,造成的安全问题
*/
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
但是上面这样的代码是不够好的,如果我们已经创建好一个实例了,其他线程使用这个实例的时候还是会竞争锁,每个线程都要竞争锁,这样会严重影响性能,只要实例创建成功之后,其他线程应该直接拿到这个实例,不应该再去竞争锁。
所以我们需要双重检查,在加锁之前,就判断实例对象是否已经创建成功。如果成功,那直接返回实例对象,否正再去加锁然后创建实例对象。
/*
首先上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁,
严重影响性能,再次判断INSTANCE==null吗, 此时肯定不为null, 然后就返回刚才创建的INSTANCE;
这样导致了很多不必要的判断;
所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,
如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行
if(instance==null)的判断, 如果已经有单例对象, 此时就不会再去同步块中获取锁了. 提高效率
*/
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
//但是上面的if(INSTANCE == null)判断代码没有在同步代码块synchronized中,
// 不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致 指令重排
以上的实现特点是:
- 懒汉式单例
- 首次使用getInstance() 才使用 synchronized 加锁,后续使用时无需加锁。
- 在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。
第一个 if 使用了 INSTANCE 变量,是在同步块之外,这样会导致synchronized无法保证指令的有序性,此时可能会导致指令重排问题。
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37 // 判断是否为空
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 将类对象的引用地址存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
// 新建一个实例
17: new #3 // class cn/itcast/n5/Singleton
// 复制了一个实例的引用
20: dup
// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "<init>":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
指令重排:首先编译器执行指令的时候会存在指令重排的情况,以便于提高指令的执行速度。就像是考试一样,肯定是先把会的题做完再做难的题目。
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用,复制了引用地址, 解锁使用
- 21 表示利用一个对象引用,调用构造方法,根据复制的引用地址调用构造方法
- 24 表示利用一个对象引用,赋值给 static INSTANCE
JVM中实例化对象内存模型流程如下:
- 分配空间给对象
- 在空间内创建对象
- 将对象赋值给
instance
引用
假如出现如下顺序错乱的情况:
线程的执行顺序为:1 -> 3 -> 2, 那么这时候会把空指针赋值给instance
(指令重排现象)
这一步INSTANCE = new Singleton();
操作不是一个原子操作,它分为两个指令,此时可能就会发生指令重排的问题。
对于这个问题,我们可以给该成员变量加上volatile
关键字。
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
读写 volatile 变量操作(即getstatic操作和putstatic操作)时会加入内存屏障
(Memory Barrier(Memory Fence)),保证下面两点:
- 可见性
写屏障
(sfence)保证在该屏障之前
的 t1 对共享变量的改动,都同步到主存当中读屏障
(lfence)保证在该屏障之后
t2 对共享变量的读取,加载的是主存中最新数据
- 有序性
写屏障
会确保指令重排序时,不会将写屏障之前
的代码排在写屏障之后读屏障
会确保指令重排序时,不会将读屏障之后
的代码排在读屏障之前
加上volatile
之后,保证了指令的有序性,不会发生指令重排,21就不会跑到24之后执行了
总结:
synchronized
既能保证原子性、可见性、有序性
,其中有序性是在该共享变量完全被synchronized
所接管(包括工作变量的读写操作),上面的例子中synchronized 外面的 if (INSTANCE == null) 中的INSTANCE读操作没有被synchronized 接管,因此无法保证INSTANCE共享变量的有序性(即不能防止指令重排)。- 对共享变量加
volatile
关键字可以保证可见性
和有序性
,但是不能保证原子性
(即不能防止指令交错)。