JMM(Java Memory Model), 从java层面定义了主存(所有线程共享的数据)、工作内存(线程私有数据)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等,很复杂,很难搞清,所以在java层面抽象,用关键字控制底层运作。
JMM 体现在以下几个方面:
原子性 - 保证指令不会受到线程上下文切换的影响。
可见性 - 保证指令不会受 cpu 缓存的影响。
有序性 - 保证指令不会受 cpu 指令并行优化的影响。
(synchronized
能保证3个,volatile
只能保证后两个)
1. 可见性
1.1 退不出的循环 例子
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
(volatile)static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
为什么呢?分析一下:
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
![[uTools_1688533608221.png]] -
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
![[uTools_1688533690587.png]]
1.2 解决方案
volatile
(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
也可用synchronized锁。
1.3 可见性 vs 原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况:
上例从字节码理解是这样的:
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
比较一下之前我们将线程安全时举的例子:两个线程一个 i++(四条指令) 一个 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,此时它的getstatic i得到的是0。
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1(不是0,错!)
注意 synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低。
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?
—— print底层的write方法会用synchronized(this)锁住当前对象。
2. 有序性
JVM会优化,进行『指令重排』,多线程下指令重排会影响正确性。为什么要有重排指令这项优化呢?从 CPU执行指令的原理来理解一下吧。
2.1 指令重排序优化
现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段。
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。
2.2 支持流水线的处理器
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
2.3 禁止指令重排序
volatile
的第二个作用,加在一个变量后,能防止本线程内之前的代码重排序。(原理在3.介绍)
3. volatile原理
上面介绍volatile
可以保证可见性和有序性。
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
3.1 如何保证可见性
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障,前面的num也一样,存到主内存
// 写屏障
}
读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
![[uTools_1688537455525.png]]
3.2 如何保证有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。
![[uTools_1688537585305.png]]
3.3 double-checked-locking 问题
懒汉式单例,并不是一开始就创建实例,用到才创建。
一开始,调用一次getInstance 方法,就要进入synchronized代码块。实际上单例对象只有第一次需要线程保护。
所以有double-checked-locking单例模式,if判断了两次。
![[uTools_1688538015888.png]]
以上的实现特点是:
懒惰实例化。
首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁。
有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外,
但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码分析如下:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton (获得类对象)
8: dup
9: astore_0 (复制了一份类对象,存储,为了以后解锁用)
10: monitorenter (创建Monitor对象,看有没有Owner)
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; (剩下一份,赋值操作给 static INSTANCE)
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~24行。也许 jvm 会优化为:先执行 24,再执行 21 ( 变成先赋值再调用构造 )。如果两个线程 t1,t2 按如下时间序列执行:
![[uTools_1688539888825.png]]
syn能保护里面的,保护不了getInstance()方法外面的if判断。
关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值。
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。
解决:
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。
![[uTools_1688543548723.png]]
这时,t2拿到的实例一定是已经调用过实例构造方法之后的。
4. happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结。
抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读是可见的。
- synchronized()
- volatile
- 线程 start 前对变量的写,对该线程开始后对该变量的读可见。
- 线程结束前对变量的写,对其它线程得知它结束后的读可见。(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见。(通过t2.interrupted 或 t2.isInterrupted)
![[uTools_1688544175120.png]] - 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见。
- 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子。
![[uTools_1688544404101.png]]
x=20之后,加入写屏障。t2读取x,发生在读屏障后。x,y都存到了主存,传递性。
5. balking 模式习题
![[uTools_1688545171928.png]]
因为这个变仅仅能保证共享变量的可见性,不能保证原子性,有好几行,有读有写。
没有对线程做互斥操作,线程1做完doInit()后,线程2进来了,变量还是false,又调用了doInit()。
解决办法:
用同步代码块保护initialized这个变量。(理解这句话:volatile只是用于一个线程写,其他线程读。还有dcl中保证syn代码块外共享变量指令重排序)
6 线程安全 单例 习题 △△
饿汉式:类加载就会导致该单实例对象被创建。
懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建。
6 .1 (饿汉)
- 防止它有子类,子类里可能会有不适当的覆盖方法破坏原方法的单例。
- 有序列化接口,就可以通过反序列化,字节码来创建对象,破坏了单例。所以要加一个特殊方法
public Object readResolve
,此时就不会用字节码创建的对象,而是你这个方法里返回的,保证了单例。 - 不是私有,别的类都能无限创建这个对象了,就不是单例了;不能,可以暴力反射调用构造方法创建新的实例。
- 是线程安全的,静态成员变量的初始化操作在类加载阶段(cinit,JVM会保证线程安全的)。
- 可以做成懒惰的初始化方式;创建单例对象时可以支持泛型。
6 .2
![[uTools_1688546296757.png]]
- 枚举对象定义时有几个就有几个,相当于枚举类的静态成员变量。
- 没有,静态成员变量的初始化操作在类加载阶段。
- 不能,枚举类构造器私有,不能反射获取,要想获取,要设置安全管理器规则。
- 不能,防反序列化是因为枚举类的反序列化是通过 valueOf() 实现的。
- 饿汉式(静态成员变量)。
- 加一个构造方法。
6 .3
![[uTools_1688546691144.png]]
线程安全的,锁在类对象上(注意不能加到INSTANCE这静态变量,要锁住一个相对不变的量),但效率低。
6 .4
![[uTools_1688546702069.png]]
- syn同步代码块里面的指令也会重排序,new的构造方法和赋值的指令顺序会乱,线程2如果在进syn代码块之前获取对象引用时在第一个if里,有可能拿到的引用是没有调用构造方法的,不完整的对象。所以要保证syn代码块内指令的有序。
- 第二三次调用getInstance方法,已经不为空,return了,不用走syn代码块。
- 为了防止首次创建Singleton对象时,多个线程并发的问题。当第一个线程在赋值前停了,线程2也执行到syn前(因为此时还没赋值,上面的if不会进去,就不会进入阻塞队列。),这个时候线程2被syn阻塞,线程1还没放锁,这时线程1接着走完,给对象赋值,释放锁,这时线程2就能进入同步代码块,不加if的话就会往下走,还会再创建一个Singleton对象,把线程1的覆盖。
6 .5 (比较推荐线程安全单例实现)
![[uTools_1688546712441.png]]
- 静态内部类,懒汉式的创建。 类加载是懒惰的,如果只是调用getInstance(),不会触发LazyHolder的类加载操作的,所以只有用到这个类才会进行类加载,对里面的静态变量初始化。
- 安全的,还是因为里面静态成员变量的初始化操作在类加载阶段(cinit,JVM会保证线程安全的)。