并发编程学习5—深入理解Java内存模型

JMM(Java Memory Model), 从java层面定义了主存(所有线程共享的数据)、工作内存(线程私有数据)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等,很复杂,很难搞清,所以在java层面抽象,用关键字控制底层运作。

JMM 体现在以下几个方面:
原子性 - 保证指令不会受到线程上下文切换的影响。
可见性 - 保证指令不会受 cpu 缓存的影响。
有序性 - 保证指令不会受 cpu 指令并行优化的影响。
synchronized能保证3个,volatile只能保证后两个)

1. 可见性

1.1 退不出的循环 例子

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

volatilestatic boolean run = true;

public static void main(String[] args) throws InterruptedException {
	Thread t = new Thread(()->{
	while(run){
		// ....
	}
	});
	t.start();
	sleep(1);
	run = false; // 线程t不会如预想的停下来
	}

为什么呢?分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
    在这里插入图片描述

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
    ![[uTools_1688533608221.png]]

  3. 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)

  1. 对 volatile 变量的写指令后会加入写屏障
  2. 对 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 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读是可见的。

  1. synchronized()
  2. volatile
  3. 线程 start 前对变量的写,对该线程开始后对该变量的读可见。
  4. 线程结束前对变量的写,对其它线程得知它结束后的读可见。(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
  5. 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见。(通过t2.interrupted 或 t2.isInterrupted)
    ![[uTools_1688544175120.png]]
  6. 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见。
  7. 具有传递性,如果 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 (饿汉)

在这里插入图片描述

  1. 防止它有子类,子类里可能会有不适当的覆盖方法破坏原方法的单例。
  2. 有序列化接口,就可以通过反序列化,字节码来创建对象,破坏了单例。所以要加一个特殊方法public Object readResolve,此时就不会用字节码创建的对象,而是你这个方法里返回的,保证了单例。
  3. 不是私有,别的类都能无限创建这个对象了,就不是单例了;不能,可以暴力反射调用构造方法创建新的实例。
  4. 是线程安全的,静态成员变量的初始化操作在类加载阶段(cinit,JVM会保证线程安全的)。
  5. 可以做成懒惰的初始化方式;创建单例对象时可以支持泛型。
6 .2

![[uTools_1688546296757.png]]

  1. 枚举对象定义时有几个就有几个,相当于枚举类的静态成员变量。
  2. 没有,静态成员变量的初始化操作在类加载阶段。
  3. 不能,枚举类构造器私有,不能反射获取,要想获取,要设置安全管理器规则。
  4. 不能,防反序列化是因为枚举类的反序列化是通过 valueOf() 实现的。
  5. 饿汉式(静态成员变量)。
  6. 加一个构造方法。
6 .3

![[uTools_1688546691144.png]]
线程安全的,锁在类对象上(注意不能加到INSTANCE这静态变量,要锁住一个相对不变的量),但效率低。

6 .4

![[uTools_1688546702069.png]]

  1. syn同步代码块里面的指令也会重排序,new的构造方法和赋值的指令顺序会乱,线程2如果在进syn代码块之前获取对象引用时在第一个if里,有可能拿到的引用是没有调用构造方法的,不完整的对象。所以要保证syn代码块内指令的有序
  2. 第二三次调用getInstance方法,已经不为空,return了,不用走syn代码块。
  3. 为了防止首次创建Singleton对象时,多个线程并发的问题。当第一个线程在赋值前停了,线程2也执行到syn前(因为此时还没赋值,上面的if不会进去,就不会进入阻塞队列。),这个时候线程2被syn阻塞,线程1还没放锁,这时线程1接着走完,给对象赋值,释放锁,这时线程2就能进入同步代码块,不加if的话就会往下走,还会再创建一个Singleton对象,把线程1的覆盖。
6 .5 (比较推荐线程安全单例实现)

![[uTools_1688546712441.png]]

  1. 静态内部类,懒汉式的创建。 类加载是懒惰的,如果只是调用getInstance(),不会触发LazyHolder的类加载操作的,所以只有用到这个类才会进行类加载,对里面的静态变量初始化。
  2. 安全的,还是因为里面静态成员变量的初始化操作在类加载阶段(cinit,JVM会保证线程安全的)。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值