深入学习掌握JUC并发编程系列(四) -- 深入剖析Java内存模型
一、概述
- JMM(Java Memory Model):
- 一种抽象的概念,描述了一组规则或规范,通过规范定义了程序中各个变量的访问方式
- 定义了主存(线程共享数据)、工作内存(线程私有数据)的抽象概念
- 工作内存是从主内存拷贝的副本,当线程启动时,从主内存中拷贝副本到工作内存,执行相关指令操作,最后写回主内存
- 三大特性:
- 原子性:保证指令不会受到线程上下文切换的影响( 线程对共享变量的操作,不受其它线程干扰)
- 可见性:保证指令不会受到 cpu 缓存的影响(线程修改共享变量值后,另一个线程能否立刻知道)
- 有序性:保证指令不会受到 cpu 指令并行优化的影响(多线程下,指令重排)
二、可见性
- 问题:因为while(run),t线程频繁从主内存中读取run的值,JIT即时编译器为了提高效率,会将run的值缓存到t线程的工作内存中,当主线程修改了主内存中的值后,t线程从工作内存读取的仍然是旧值
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不会如预想的停下来
}
- 描述:一个线程对主内存中的数据修改后,对于另一个线程不可见
- 解决:
- 在共享变量定义时,加volatile关键字修饰(禁止线程从缓存中获取数据,只能从主内存中读取)
- 在读取数据和修改数据的代码块上加锁(synchronized会清空工作内存)
- volatile(易变的):修饰成员变量和静态成员变量,让线程必须从主内存中获取变量值
三、可见性VS原子性
- 可见性保证的是一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性
- 可见性只能保证看到最新值,不能解决指令交错问题(原子性)
- 适用情况:仅用在一个写线程,多个读线程的情况
- synchronized既可以保证代码块的原子性,又可以保证变量的可见性(缺点是重量级操作,性能低)
四、有序性
- 指令重排:JIT即时编译器会在不影响正确性的前提下,调整语句的执行顺序(单线程没问题,多线程影响正确性)
- 一条指令可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回,五个阶段
- 多级(五级)指令流水线:CPU可以在一个时钟周期内,同时运行五条指令的不同阶段
- 指令级并行:在不改变程序结果的前提下,指令的各个阶段可以重排序和组合
- 解决:volatile 修饰的变量,可以禁用指令重排
五、volatile原理(JDK1.5之后)
- volatile 的底层实现原理是内存屏障(Memory Barrier/fence) :
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
- volatile保证可见性:
- 写屏障(sfence):在写屏障之前的,对共享变量的改动(赋值操作),都同步到主内存当中
- 读屏障(lfence):在读屏障之后的,对共享变量的读取,加载的是主内存中最新数据
- volatile保证有序性:指令重排序时
- 写屏障:确保不会将写屏障之前的代码排在写屏障之后
- 读屏障:确保不会将读屏障之后的代码排在读屏障之前
- volatile无法保证原子性:不能解决指令交错问题(synchronized可以解决三种特性问题)
- 写屏障是保证之后的读能够读到最新的结果,不能保证其它线程的读跑到它前面去
- 有序性的保证只是保证了本线程内相关代码不被重排序
六、DCL懒汉式单例模式
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) {
synchronized(Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
- 单例模式:一个类只有一个实例,通过构造器私有化实现
- 饿汉式:类加载时单例对象就被创建;懒汉式:首次使用单例对象时才被创建
- DCL:double-checked-locking(双重 if 判断)
- 懒惰实例化:用到时才创建
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
- 字节码解析(INSTANCE = new Singleton();):
- 17行(new):创建对象,将对象引用入栈 // new Singleton
- 20行(dup):复制一份对象引用 // 引用地址
- 21行(invokespecial):根据引用地址,调用构造方法
- 24行(putstatic):利用对象引用,赋值给静态变量 INSTANCE
- DCL懒汉式单例问题:
- jvm可能会对指令顺序优化:先执行 24,再执行 21
- 第一个 if 判断使用的 INSTANCE 变量,是在synchronized同步块之外
- t2线程在t1线程还未完成21(调用构造方法),就读取了INSTANCE的值(未初始化的单例)
- 问题根本:JIT在创建对象的过程中,会将对象引用赋值给变量(24)和构造方法初始化(21)这两条指令顺序进行优化
- 问题解决:volatile关键字修饰INSTANCE,禁止指令重排序
七、happens-before规则
- 可见性与有序性的一套规则总结
- 规定了对共享变量的写操作对其它线程的读操作可见:
- 线程解锁(synchronized)之前对变量的写,对于接下来加锁的其它线程对该变量的读可见
- 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
- 线程 start 前对变量的写,对该线程开始后对该变量的读可见
- 线程运行结束前对变量的写,对其它线程得知它结束(join)后的读可见
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
- 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
- 可见传递性:
volatile static int x;
static int y;
new Thread(()->{
y = 10;
x = 20;
},"t1").start();
new Thread(()->{
// x=20 对 t2 可见, 同时 y=10 也对 t2 可见
System.out.println(x);
},"t2").start();
总结
- JMM:
- 可见性 - 由 JVM 缓存优化引起(一个读,多个写)
- 有序性 - 由 JVM 指令重排序优化引起
- happens-before 规则(写入对其它线程可见)
- 原理:
- CPU指令并行
- volatile(写屏障之前,读屏障之后)