JVM内存模型
写在最前
- 这部分的内容会在后续的JUC详细讲解
Java内存模型
- 很多人将
Java内存结构
和Java内存模型
傻傻分不清,Java内存模型
是Java Memory Model
(JMM)的意思 - 简单地说,JMM定义了一套在多线程读写共享数据时(成员变量、数组),对数据的可见性、有序性和原子性的规则和保障
原子性
- 原子性在前面的文章也提过,现在来简单回顾一下
- 两个线程对初始值为0的静态变量,一个做自增,一个做自减,各做5000次,那么最终结果是0吗?
1 | public class JMM01 { |
问题分析
- 以上的结果可能是正数、负数、零。为什么呢?
- 因为Java中对静态变量的自增、自减操作并不是原子操作
1 | public class JMM02 { |
- 编译后的字节码文件
1 | Code: |
- 对于i++而言(注意i为静态常量),实际上会产生如下字节码指令
1 | getstatic #2 // 获取静态常量 i 的值 |
- 而对于i–而言,也是类似的操作
1 | getstatic #2 // 获取静态常量 i 的值 |
- 在多线程环境下,这些指令可能会被CPU交错的执行,就会导致我们看到的结果出现问题
- Java的内存模型如下,完成静态变量的自增、自减需要在主存与线程内存中进行数据交换
- 出现负数的情况:
1 | // 假设i的初始值为0 |
- 出现正数的情况:
1 | // 假设i的初始值为0 |
解决方法
- 使用synchronized(同步关键字),语法如下
1 | synchronized(obj) { |
- 解决上面的问题,在
i++
和i--
操作处加锁
1 | public class JMM03 { |
- 可以把obj想象成一间房间(撤硕),线程t1、线程t2想象成两个人
- 当线程t1执行到
synchronized(obj)
时,就好比t1进入了撤硕,并反手锁住了门,在门内执行i++
操作 - 此时如果t2也运行到了
synchronized(obj)
,它发现门被锁住了,只能在门外等待 - 当t1执行完synchronized块内的代码,此时才会解开门上的锁,从撤硕出来,t2线程此时才可以进入撤硕,并反手锁住门,执行它的
i--
操作
- 最后,也建议将synchronized加锁的范围设置的大一些,刚刚的代码中,仅在
i++
操作上加锁,锁住了4条虚拟机指令,但是外层循环了5W次,那就要加锁解锁5W次,这样是比较耗时的,那么此时我们就可以直接在for循环上加锁,这样就只用解锁一次
1 | Thread t1 = new Thread(() -> { |
可见性
- 可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改的结果。在单线程环境下,修改变量的值和读取变量的值都是在同一个线程内进行的,所以不存在可见性问题。但是在多线程环境下,由于每个线程都有自己的缓存,所以可能出现一个线程修改了共享变量的值,但是其他线程还是看到原来的旧值的情况。
退不出的循环
- 先来看一个现象,main线程对run变量的修改,对于t线程不可见,导致t线程无法停止
1 | public class JMM04 { |
- 那这是为什么呢?我们来分析一下
(提示:联想一下上篇文章的JIT优化) - 初始状态:t线程刚开始就从主存读取到了run的值到工作内存
- 因为t线程要频繁的从主存中读取run的值,JIT编译器会将run的值缓存至自己工作内存的高速缓存中,减少对主存中run的访问,提高效率
- 1秒过后,main线程修改了run值,并同步至主存,但是t现在已经是从自己工作内存的高速缓存中读取的run,结果永远是true
- 初始状态:t线程刚开始就从主存读取到了run的值到工作内存
解决方法
- 可见性问题的方法是通过使用
volatile
关键字来声明共享变量。在使用了volatile
关键字声明的共享变量上进行读写操作时,JVM会保证所有线程都能够看到该变量的最新值,从而解决可见性问题。
1 | public class JMM04 { |
- 此时程序运行1秒后就会停下来了
- 如果在前面示例的死循环中,加入一条输出指令
System.out.println()
会发现,即使不加volatile修饰符,线程t也正确看到run变量的修改了,这是为什么呢?- 因为System.out.println()语句具有同步锁的效果,它会强制刷新CPU缓存,从而强制线程从主内存中读取变量的值。这与volatile的作用相似,可以保证线程获取到最新的变量值。
1
2
3
4
5
6public void println(int x){
synchronized(this) {
print(x);
newLine( );
}
}
有序性
诡异的结果
- 先来看一段代码
1 | int num = 0; |
-
Person是一个对象,有一个属性age用来保存结果,那么上面的代码会有几种可能?
- 线程1先执行,此时
ready = false
,进入else分支,结果是1
- 线程2先执行,
num = 2
,ready = true
,线程1执行是,ready = true
,执行if分支,同时num = 2
,结果是4
- 线程2先执行,
num = 2
,还没来得及执行ready = true
,此时线程1执行,ready = false
,进入else分支,结果是1
- 线程1先执行,此时
-
但是其实还有一种可能,结果是0
- 这种情况下:线程2先执行
ready = true
,切回到线程1,进入if分支,相加为0,再切回线程2执行num = 2
- 这种情况下:线程2先执行
-
这种现象叫:
指令重排
- 指令重排是指在编译器或者JIT编译器优化过程中,为了提高程序的性能而重新排列指令的执行顺序,以便在运行时更加高效地执行。
- 指令重排并不会改变程序的语义,但它可能会改变程序的执行顺序,从而导致程序出现错误或异常。
- 指令重排需要通过大量测试才能发现,借助java并发压测工具
jcstress
- 在JVM中,指令重排主要有以下三种类型:
- 编译器重排:编译器在生成目标代码时对指令进行重排,以提高代码的性能。
- 运行时重排:JIT编译器在运行时对字节码进行优化,对指令进行重排,以提高程序的性能。
- 处理器重排:现代处理器具有乱序执行的能力,可以根据需要重新排列指令的执行顺序,以提高指令的执行效率。
- 指令重排的好处是可以提高程序的性能,但也有风险。如果重排不当,可能会导致程序出现错误或异常。为了避免这种情况,JVM提供了一些机制,例如volatile关键字、synchronized关键字、final关键字等,以保证程序的正确性。
-
运行如下maven命令
1 | mvn archetype:generate -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=com.demo.jmm -DartifactId=com.demo.jmm.my-test-project -Dversion=1.0-SNAPSHOT |
- 修改生成的测试方法
1 |
|
- 执行maven clean install,生成jar包
- 使用java -jar命令启动测试,结果如下
1 | *** INTERESTING tests |
- 可以看到,出现结果为0的次数有4118次,虽然次数相对较少,但毕竟还是出现了
解决方法
- 使用volatile修饰的变量,可以禁用指令重排
1 |
|
- 结果如下
1 | *** INTERESTING tests |
有序性理解
- JVM会在不影响正确性的前提下,调整语句的执行顺序,来看一下下面的代码
1 | static int i; |
- 可以看到,不管是先执行i还是先执行j,对最终的结果都不会产生影响,所以上面两条语句的执行顺序可以任意的排列组合
- 这种特性被称之为
指令重排
,多线程下的指令重排会影响正确性,例如著名的double-checked-locking
模式实现单例
1 | public final class Singleton { |
- 以上的实现的特点是
- 懒惰实例化
- 首次使用getInstance()才使用synchronized加锁,后续使用时无需加锁
- 但是在多线程环境下,上面的代码是有问题的,
INSTANCE = new Singleton();
对应的字节码如下
1 | 0: new #2 // class cn/itcast/jvm/t4/Singleton |
- 其中
4
和7
两步的顺序不是固定的,也许jvm会优化为:先将引用地址赋给INSTANCE变量后,再执行构造方法,如果两个线程t1、t2按如下时间序列执行- 时间1
t1
线程执行到INSTANCE = new Singleton();
- 时间2
t1
线程分配空间,为Singleton对象生成了引用地址(0 处) - 时间3
t1
线程将引用地址赋值给 INSTANCE,这时INSTANCE != null
(7 处) - 时间4
t2
线程进入getInstance()
方法,发现INSTANCE != null
(synchronized块外),直接返回 INSTANCE - 时间5
t1
线程执行Singleton的构造方法(4 处)
- 时间1
- 此时t1还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到的将是一个未完成初始化的单例
- 对INSTANCE使用volatile修饰即可,可以禁用指令重排
happens-before
- happens-before规定了哪些写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。抛开以下happens-before规则,JMM不能保证一个线程对共享变量的
写
,对于其他线程对该共享变量的读
是可见
的
- 线程解锁m之前对变量的写,对于接下来对m加锁的其他线程对该变量的读可见
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15static int x;
static Object m = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (m) {
x = 10;
}
}, "t1").start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
}, "t2").start();
}- 在线程t2中,当获取了对象m的锁之后,线程可以读取到线程t1对变量x的写入结果。
- 线程对volatile变量的写,对接下来其他线程对该变量的读可见
1
2
3
4
5
6
7
8
9
10volatile static int x;
public static void main(String[] args) {
new Thread(() -> {
x = 10;
}, "t1").start();
new Thread(() -> {
System.out.println(x);
}, "t2").start();
}- 在线程t2中,当读取变量x的值时,可以看到线程t1对变量x的最新写入结果,而不会读取到变量x的旧值。
- 线程start前对变量的写,对该线程开始后对该变量的读可见
1
2
3
4
5
6
7
8static int x;
public static void main(String[] args) {
x = 10;
new Thread(() -> {
System.out.println(x);
}, "t2").start();
} - 线程结束前对变量的写,对其他线程得知它结束后的读可见
1
2
3
4
5
6
7
8
9
10static int x;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
}- 当主线程中读取变量x的值时,可以看到线程t1对变量x的写入结果。
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26static 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);
}- 以上代码创建了两个线程t1和t2。线程t2在一个无限循环中不断检查自身的中断状态,如果发现自己被打断则打印变量x的值并跳出循环,线程t1会在1秒后修改变量x的值并打断线程t2。
- 线程t1在打断线程t2之前对变量x的写操作对于其他线程得知线程t2被打断后的读操作可见。在本例中,线程t1在修改变量x的值并打断线程t2之前会先睡眠1秒,因此线程t2的循环会在线程t1修改变量x的值之后才会被打断。此时,线程t2中对变量x的读操作就能看到线程t1对变量x的修改。
CAS与原子类
- CAS即
Compare And Swap
,它体现的是一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行+1
操作:
1 | // 需要不断尝试 |
- 获取共享变量时,为了保证该变量的可见性需要使用volatile修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核CPU的场景下
- 因为没有使用synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
- CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令,下面是直接使用Unsafe对象进行线程安全保护的一个例子
1 | import sun.misc.Unsafe; |
乐观锁与悲观锁
- CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就散改了也没关系,我吃亏点再重试呗
- synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量,我上了锁你们都别想改,我改完了再解开锁,你们才有机会来
原子操作类
- JUC(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用CAS技术+volatile来实现的
- 可以使用AtomicInteger改写之前的例子
1 | public class JMM07 { |
synchronized优化
- Java HotSpot虚拟机中,每个对象都有对象头(包括class指针和Mark Word),Mark Word平时存储这个对象的
哈希码
、分代年龄
,当加锁时,这些信息就根据情况被替换为标记位
、线程锁记录指针
、重量级锁指针
、线程ID
等内容
轻量级锁
- 如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么就可以使用轻量级锁来优化,就好比
- 学生A(线程A)用课本占座,上了半节课就出门了(CPU时间到了),回来一看,发现课本还在,说明没有竞争,继续上他的课
- 如果此时其他学生B(线程B)来了,会告知学生A(线程A)有并发访问,线程A随即升级为重量级锁,进入重量级锁的流程
- 而重量级锁就不是用课本占座那么简单了,在学生A走之前,把座位用铁栅栏围了起来
- 假设有两个方法同步块,利用同一个对象加锁
1
2
3
4
5
6
7
8
9
10
11static Object obj = new Object();
public static void method1(){
synchronized(obj) {
// 同步块 A
method2();
}
public static void method2(){
synchronized(obj) {
// 同步块 B
}
} - 每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
线程 1 | 对象 Mark Word | 线程 2 |
---|---|---|
访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 A | 00(轻量锁)线程 1 锁记录地址 | - |
访问同步块 B,把 Mark 复制到线程 1 的锁记录 | 00(轻量锁)线程 1 锁记录地址 | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 00(轻量锁)线程 1 锁记录地址 | - |
失败(发现是自己的锁) | 00(轻量锁)线程 1 锁记录地址 | - |
锁重入 | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 B | 00(轻量锁)线程 1 锁记录地址 | - |
同步块 B 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
同步块 A 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
成功(解锁) | 00(轻量锁)线程 1 锁记录地址 | - |
- | 01(无锁) | - |
- | 01(无锁) | 访问同步块 A,把 Mark 复制到线程 2 的锁记录 |
- | 01(无锁) | CAS 修改 Mark 为线程 2 锁记录地址 |
- | 00(轻量锁)线程 2锁记录地址 | 成功(加锁) |
- | … | … |
锁膨胀
- 如果在尝试加轻量级锁的过程中,CAS操作无法完成,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时就需要进行锁膨胀,将轻量级锁变为重量级锁
1 | public static void method1(){ |
线程 1 | 对象 Mark Word | 线程 2 |
---|---|---|
访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 访问同步块,把 Mark 复制到线程 2 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为线程 2 锁记录地址 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 失败(发现别人已经占了锁) |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为重量锁 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞中 |
执行完毕 | 10(重量锁)重量锁指针 | 阻塞中 |
失败(解锁) | 10(重量锁)重量锁指针 | 阻塞中 |
释放重量锁,唤起阻塞线程竞争 | 01(无锁) | 阻塞中 |
- | 10(重量锁) | 竞争重量锁 |
- | 10(重量锁) | 成功(加锁) |
- | … | … |
重量锁
- 重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
- Java 7 之后不能控制是否开启自旋功能
- 自旋重试成功的情况
线程 1 (cpu 1 上) | 对象 Mark | 线程 2 (cpu 2 上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行完毕 | 10(重量锁)重量锁指针 | 自旋重试 |
成功(解锁) | 01(无锁) | 自旋重试 |
- | 10(重量锁)重量锁指针 | 成功(加锁) |
- | 10(重量锁)重量锁指针 | 执行同步块 |
- | … | … |
- 自旋重试失败的情况
线程 1(cpu 1 上) | 对象 Mark | 线程 2(cpu 2 上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
- | … | … |
偏向锁
- 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的 hashCode 也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,
- 重偏向会重置对象的 Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁
- 假设有两个方法同步块,利用同一个对象加锁
1 | static Object obj = new Object(); |
线程 1 | 对象 Mark |
---|---|
访问同步块 A,检查 Mark 中是否有线程 ID | 101(无锁可偏向) |
尝试加偏向锁 | 101(无锁可偏向)对象 hashCode |
成功 | 101(无锁可偏向)线程ID |
执行同步块 A | 101(无锁可偏向)线程ID |
访问同步块 B,检查 Mark 中是否有线程 ID | 101(无锁可偏向)线程ID |
是自己的线程 ID,锁是自己的,无需做更多操作 | 101(无锁可偏向)线程ID |
执行同步块 B | 101(无锁可偏向)线程ID |
执行完毕 | 101(无锁可偏向)对象 hashCode |
其他优化
-
减少上锁时间
- 同步代码块中尽量短
-
减少锁的粒度
- 将一个锁拆分为多个锁提高并发度,例如
- ConcurrentHashMap
- LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
- LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
- 将一个锁拆分为多个锁提高并发度,例如
-
锁粗化
- 多次循环进入同步块不如同步块内多次循环
- 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
1
new StringBuffer().append("a").append("b").append("c");
-
锁消除
- JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
-
读写分离
- CopyOnWriteArrayList
- ConyOnWriteSet
评论