黑马 JVM —4 — 内存模型 (JMM)
java 内存模型 可见性 有序性 CAS与原子类 synchronized优化
- java 内存模型
【java 内存模型】是 Java MemoryModel(JMM)的意思。
简单的说,
JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,
对数据的可见性、有序性、和原子性的规则和保障
所谓 内存模型 , 一部分 叫 工作内存, 一部分 叫 线程内存 。(在 JMM 中)
JMM 主要 体现在 几个方面
- 原子性 – 保证 指令 不会 收到 线程 上下文 切换的 影响
- 可见性 – 保证 指令 不会 收到 CPU 缓存 的影响
- 有序性 – 保证 指令 不会 收到 CPU 指令 并行优化 的 影响
- 可见性
2.1 退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}
原因 :
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高
速缓存中,减少对主存中 run 的访问,提高效率
-
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读
取这个变量的值,结果永远是旧值
-
解决方法
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
vol
atile(可见性)保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一
个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程
注意
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。
但缺点是synchronized是属于重量级操作,性能相对更低
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?
因为println()底层 使用了 synchronized 修饰 , 即 对 当前对象 加锁
可以保证原子性与可见性,它是 PrintStream 类的方法。
- 有序性
3.1 指令重排
指令重排简单来说可以,在程序结果不受影响的前提下,可以调整指令语句执行顺序。
多线程下指令重排会影响正确性。
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
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;
} else {
r.r1 = 1;
}
}
注意:volatile 不能解决指令交错
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去。
而有序性的保证也只是保证了本线程内相关代码不被重排序
// 可以重排的例子
int a = 10;
int b = 20;
System.out.println( a + b );
// 不能重排的例子
int a = 10;
int b = a - 5;
3.2 多线程下指令重排问题
int num = 0;
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;
}
在多线程环境下,以上的代码 r1 的值有三种情况:
- 第一种:线程 2 先执行,然后线程 1 后执行,r1 的结果为 4
- 第二种:线程 1 先执行,然后线程 2 后执行,r1 的结果为 1
- 第三种:线程 2 先执行,但是发送了指令重排,num = 2 与 ready = true 这两行代码语序发生装换,
ready = true; // 前
num = 2; // 后
然后执行 ready = true 后,线程 1 运行了,那么 r1 的结果是为 0。
3.3 解决方法
volatile 修饰的变量,可以禁用指令重排
3.4 有序性理解
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序
著名的 double-checked locking (双层检查锁)模式实现单例
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}}}
return INSTANCE;
}}
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
如上面的注释内容所示,读写 volatile 变量操作(即 getstatic 操作和 putstatic 操作)时会加入内存屏障(Memory Barrier(Memory Fence)),
保证下面两点:
可见性
写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
3.5 happens-before
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,
抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
线程解锁 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();
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或
t1.join()等待它结束)
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
线程 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);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
- 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
- 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z
变量都是指成员变量或静态成员变量
- CAS与原子类
CAS 即 Compare and Swap , 它 提现的 是 一种 乐观锁 的 思想,
比如 多个线程 要 对 一个 共享 的 整形变量 执行 +1 操作
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
为什么无锁效率高?
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令
原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,
例如:AtomicInteger、AtomicBoolean等,
它们底层就是采用 CAS 技术 + volatile 来实现的。可以使用 AtomicInteger 改写之前的例子:
乐观锁与悲观锁
-
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,
我吃亏点再重试呗。 -
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁
你们都别想改,我改完了解开锁,你们才有机会。
- synchronized优化
Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。
Mark Word 平时存储这个对象的 哈希码 、 分代年龄 ,
当加锁时,这些信息就根据情况被替换为 标记位 、 线程锁记录指针 、 重量级锁指针 、 线程ID 等内容
5.1 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争||不存在竞争),
那么可以使用轻量级锁来优化。
学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。
如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来
假设有两个方法同步块,利用同一个对象加锁
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();}}
public static void method2() {
synchronized( obj ) {
// 同步块 B}}
每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
5.2 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
5.3 重量锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等
待时间长了划算) - Java 7 之后不能控制是否开启自旋功能
自旋重试成功的情况
自旋重试失败的情况
5.4 偏向锁 ( 可重入锁)
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁
来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的 hashCode 也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,
重偏向会重置对象的 Thread ID - 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
-可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁
可以参考这篇论文
假设有两个方法同步块,利用同一个对象加锁
5.5 其它优化
1. 减少上锁时间
同步代码块中尽量短
2. 减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如:
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();}}
public static void method2() {
synchronized( obj ) {
// 同步块 B}}
5.5 其它优化
- 减少上锁时间
同步代码块中尽量短
- 减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如:
ConcurrentHashMap
LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时
候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允
许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
- 锁粗化
多次循环进入同步块不如同步块内多次循环
另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,
没必要重入多次)
new StringBuffer().append(“a”).append(“b”).append(“c”)
- 锁消除
JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
- 读写分离
CopyOnWriteArrayList
ConyOnWriteSet