【JVM】- 内存模式
Java内存模型:JMM(Java Memory Model),定义了一套在多线程环境下,读写共享数据(成员变量、数组)时,对数据的可见性,有序性和原子性的规则和保障。
原子性
问题分析
【问题
】:两个线程对初始值为0的静态变量操作,一个线程做自增,一个线程做自减,各做50000次,结果是0吗?
public class Demo01 {static int i = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i++;}});Thread t2 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {--i;}});t1.start();t2.start();// 让主线程等待t1和t2两个子线程执行完毕后,再执行后续代码t1.join();t2.join();System.out.println(i);}
}
【
结果
】:上边代码输出,每次运行的结果不一样。
【原因
】:Java中对静态变量的自增自减并不是原子操作,对于i++而言:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i中
Java的内存模型如下,如果需要完成静态变量的自增、自减,需要在主内存和工作线程的内存中进行交换数据。
由于当线程是按顺序执行,所以并不会出现问题。
但是在多线程下,可能出现交错运行。线程是一个抢占式的,大家都是轮流使用CPU
解决方法
synchronized(对象) {要作为原子操作的代码
}
修正后:
public class Demo02 {static int i = 0;static Object obj = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {synchronized (obj) {for (int j = 0; j < 50000; ++j) {i++;}}});Thread t2 = new Thread(() -> {synchronized (obj) {for (int j = 0; j < 50000; ++j) {--i;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(i);}
}
这样++i和–i的四条指令都可以作为一个整体来运行。
并且:t1和t2必须锁的是同一个obj对象(相当于这两个人进入了两个不同的房间)
Monitor(监视器)
Monitor 是一种线程同步机制,可以理解为 对象锁 的内部实现。每个 Java 对象(Object
)在 JVM 内部都有一个关联的 Monitor,用于实现 synchronized
同步机制。
Monitor 的组成部分
(1) Owner(持有者)
- 作用:表示当前持有 Monitor 的线程。
- 特点:
- 当线程进入
synchronized
代码块时,会尝试获取 Monitor 的owner
权限。 - 如果
owner
为null
(即没有线程持有锁),当前线程会成为owner
。 - 如果
owner
已经被其他线程持有,当前线程会进入entryList
等待。
- 当线程进入
(2) EntryList(入口队列)
- 作用:存储 竞争锁的线程(即等待获取锁的线程)。
- 特点:
- 当线程 A 持有锁时,线程 B 尝试进入
synchronized
代码块,会进入entryList
并进入 BLOCKED 状态。 - 当线程 A 释放锁(退出
synchronized
代码块),JVM 会从entryList
中唤醒一个线程,使其竞争锁。
- 当线程 A 持有锁时,线程 B 尝试进入
(3) WaitSet(等待队列)
- 作用:存储 调用了
wait()
的线程(即主动放弃锁的线程)。 - 特点:
- 当线程 A 调用
wait()
时,它会释放锁,并进入waitSet
,状态变为 WAITING。 - 当其他线程调用
notify()
或notifyAll()
时,JVM 会从waitSet
中随机唤醒一个(或全部)线程,使其重新竞争锁。
- 当线程 A 调用
3. Monitor 的工作流程
synchronized (obj) { // 1. 尝试获取 Monitor 的 ownerwhile (!condition) {obj.wait(); // 2. 释放锁,进入 waitSet}// 3. 执行同步代码
}
obj.notify(); // 4. 唤醒 waitSet 中的线程
- 线程 A 进入
synchronized
代码块:- 检查
owner
,如果为空,线程 A 成为owner
。 - 如果
owner
已被线程 B 持有,线程 A 进入entryList
(BLOCKED 状态)。
- 检查
- 线程 A 调用
wait()
:- 释放
owner
,线程 A 进入waitSet
(WAITING 状态)。 - JVM 从
entryList
中唤醒一个线程(如线程 B),使其成为新的owner
。
- 释放
- 线程 B 调用
notify()
:- 从
waitSet
中随机唤醒一个线程(如线程 A),使其重新进入entryList
(BLOCKED 状态)。 - 线程 A 需要重新竞争锁(不会立即获得锁)。
- 从
- 线程 B 退出
synchronized
代码块:- 释放
owner
,JVM 从entryList
中选择一个线程(如线程 A),使其成为新的owner
。
- 释放
可见性
问题分析
【问题
】:main线程对于run变量的修改对t线程是不可见的,这就导致了t线程无法停止:
public class Demo03 {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线程刚开始从main线程的内存中读取了run的值到工作线程
- 因为t线程要频繁的从主内存中读取run的值,JIT编译器会将run的值缓存到自己的工作内存中的高速缓冲区中,这样就可以减少对主内存的读取。
- 主线程睡眠1s后,main线程修改了run的值,并同步到贮存,而t线程仍然是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
解决办法
volatile(易变关键字):用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值
,必须到主存中获取它的值,线程操作volatile变量都是直接操作主内存。
public class Demo03 {static volatile boolean run = true;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (run) {// ...}});t.start();Thread.sleep(1000);run = false; // 不会停下来}
}
- volatile:保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,但是不能保证原子性,只能用在
一个写线程,多个读线程
的情况。 - synchronized:既可以保证原子性,也能保证代码块变量的可见性。但是缺点是:synchronized属于重量级操作,性能相对更低。
【
补充
】:在上边的代码中,如果不加volatile,但是在for循环里加System.out.println(),t线程也能正常看到对run变量的修改。
【原因
】:System.out.println()底层使用syncronized关键字,强制要求当前的线程不要从高速缓存中获取,从主线程中获取。
有序性
问题分析——指令重排序
public class Demo04 {static int num = 0;static boolean ready = false;// 线程1:执行此方法public static void actor1(I_Result r) {if(ready) {r.r1 = num + num;}else {r.r1 = 1;}}// 线程2:执行此方法public static void actor2(I_Result r) {num = 2;ready = true;}public static void main(String[] args) throws InterruptedException {I_Result r = new I_Result();Thread t1 = new Thread(() -> {actor1(r);});Thread t2 = new Thread(() -> {actor2(r);});t1.start();t2.start();t1.join();t2.join();System.out.println(r.r1);}
}class I_Result {int r1;
}
上边的代码执行一共可能会有三种不同的输出:
- 正常执行:t2先执行完,t1后执行 ==> 输出4
- t1先执行完,t2后执行 ==> 输出1
- 指令重排序(导致ready先变成true,num还未赋值)
- t2先执行ready = true
- t1执行(此时num还未被t2修改):r.r1 = num + num = 0
- t2再执行num = 2(但是t1已经计算完毕,不会影响结果)
解决方法
如果要保证线程安全,可以:
- 使用
volatile
修饰ready
和num
,禁止指令重排序,并保证可见性:
static volatile int num = 0;static volatile boolean ready = false;
- 这样
t2
的num = 2
和ready = true
不会重排序,且t1
能立即看到修改。 - 可能的输出:
1
或4
(不会出现0
)。
- 使用
synchronized
加锁,确保原子性:
public static synchronized void actor1(I_Result r) { ... }public static synchronized void actor2(I_Result r) { ... }
- 这样
t1
和t2
不会同时执行,输出一定是1
或4
。
有序性理解
static int i, j;
// 在某个线程内执行:
i = ...; // 较为耗时的操作
j = ...;
由于这段代码先执行i还是先执行j对结果并不会有影响,所以上面代码的执行可以是先对i赋值,再对j赋值
;也可以是先对j赋值,再对i赋值
。
案例:双重检查锁
public 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加锁,后续使用无需加锁。
多线程下可能的问题
但是在多线程环境下,上边的代码是有问题的
INSTANCE = new Singleton();
这行代码在JVM中并不是原子操作,它分为三个步骤:
- 分配内存空间(malloc)
- 初始化对象(调用构造方法Sington())
- 将INSTANCE指向分配的内存地址(赋值)
但是JVM可能会对指令重排序(优化执行顺序),变成:
- 分配内存空间
- 将INSTANCE指向分配的内存地址(此时INSTANCE != null,但是对象未初始化)
- 初始化对象(调用构造方法)
如果发生这种重排序,可能导致:
- 线程 A 执行 INSTANCE = new Singleton();,但只完成了 步骤 1 和 2(INSTANCE 已不为 null,但对象未初始化)。
- 线程 B 调用 getInstance(),发现 INSTANCE != null,直接返回 未初始化完成的对象,导致错误!
解决办法
使用volatile禁止指令重排序
private static volatile Singleton INSTANCE = null;
happens-before
是JMM的核心规则,定义了 多线程环境下操作的可见性和顺序性,确保一个线程对共享变量的修改能被其他线程正确观察到。
Java 内存模型定义了 6 种 Happens-Before 规则:程序顺序、锁、volatile、线程启动、线程终止、传递性
(1) 程序顺序规则(Program Order Rule)
在同一个线程中,前面的操作 Happens-Before 后面的操作。
int x = 1; // (1)
int y = x + 1; // (2) —— (1) Happens-Before (2)
- 单线程下,代码顺序执行,
(1)
的结果对(2)
可见。
(2) 锁规则(Monitor Lock Rule)
解锁操作 Happens-Before 后续的加锁操作。
synchronized (lock) {x = 10; // (1)
} // 解锁 (1) Happens-Before 后续的加锁
synchronized (lock) {int y = x; // (2) —— 能读到 x = 10
}
- 线程 A 解锁后,线程 B 加锁时能看到 A 的修改。
(3) volatile 变量规则(Volatile Variable Rule)
volatile 变量的写操作 Happens-Before 后续的读操作。
volatile boolean flag = false;// 线程 A
flag = true; // (1) —— 写操作// 线程 B
if (flag) { // (2) —— (1) Happens-Before (2),能读到 flag = true// do something
}
volatile
保证可见性,写操作后,读操作一定能看到最新值。
(4) 线程启动规则(Thread Start Rule)
线程的
start()
方法 Happens-Before 该线程的所有操作。
int x = 0;Thread t = new Thread(() -> {System.out.println(x); // (2) —— 能读到 x = 1
});
x = 1; // (1)
t.start(); // (1) Happens-Before (2)
- 主线程修改
x = 1
后,子线程能读到这个值。
(5) 线程终止规则(Thread Termination Rule)
线程的所有操作 Happens-Before 它的终止检测(如
join()
)。
int x = 0;Thread t = new Thread(() -> {x = 1; // (1)
});
t.start();
t.join(); // (2) —— (1) Happens-Before (2)
System.out.println(x); // 输出 1
- 子线程修改
x = 1
后,主线程join()
后能读到最新值。
(6) 传递性规则(Transitivity Rule)
**如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
int x = 0;
volatile boolean flag = false;// 线程 A
x = 1; // (1)
flag = true; // (2) —— (1) Happens-Before (2)// 线程 B
if (flag) { // (3) —— (2) Happens-Before (3)System.out.println(x); // 输出 1 —— (1) Happens-Before (3)
}
- 由于
(1) → (2) → (3)
,所以(1)
对(3)
可见。
CAS与原子类
CAS
CAS:Compare and Swap,是一种乐观锁的思想。
【案例
】多个线程要对一个共享变量的整型变量执行 + 1操作:
// 需要不断尝试
while(true) {int 旧值 = 共享变量; // 旧值 = 0int 结果 = 旧值 + 1; // 结果 = 0 + 1 = 1/*这时候如果别的线程把共享变量改成了5,本线程的正确结果1就作废了,此时:compareAdnSwap:返回false,重新尝试,直到:compareAndSwap:返回true,表示本线程做修改的同时,其他线程没有干扰*/if(compareAndSwap(旧值, 结果)) {// 成功,退出循环}
}
【
注意
】:
共享变量一定要用volatile修饰,保证共享变量的可见性,当前线程拿到的共享变量必须一定要是新值。(结合CAS和volatile就可以实现无锁并发了,适用于竞争不激烈、多核CPU的场景)
- 如果竞争激烈,线程重试会频繁发生,效率会受到影响
- 因为没有使用synchronized,线程并不会陷入阻塞,这也是效率提升的因素
CAS底层依赖于Unsafe类来直接调用操作系统底层的CAS指令
乐观锁与悲观锁
CAS:最乐观的估计,不怕别的线程来修改共享变量,如果改了就重试即可。
synchronized:最悲观的估计,得防着其他线程来修改共享变量,直接给代码上锁,等执行完解开锁了,其他线程才有机会执行。
原子操作类
juc(java.util.concurrent)包下提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean…,他们的底层就是使用CAS + volatile
来实现的。
public class Demo05 {static AtomicInteger i = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i.getAndIncrement();}});Thread t2 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i.getAndDecrement();}});t1.start();t2.start();// 让主线程等待t1和t2两个子线程执行完毕后,再执行后续代码t1.join();t2.join();System.out.println(i);}
}
synchronized优化
JVM中,每个对象都有对象头(包括class指针、Mark Word)。Mark Word平时存储这个对象的哈希码、分代年龄…,当加锁时,这些信息就会被替换成标记位、线程锁记录指针、重量级指针、线程id…
轻量级锁
如果一个对象虽然有多个线程访问,但多线程访问的时间是错开的(没有竞争),那么可以用轻量级锁来优化。
这就类似于:学生A(线程A)用课本占座,短暂的离开教室了一下(时间片到)
- 回来发现课本没变(没有竞争),就会继续上课(仍然保持轻量级锁)
- 如果期间又来了一个学生B(线程B),就会告知学生A(线程A)此时有并发访问,线程A就会升级成重量级锁,进入重量级锁的流程。
每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。
static Object obj = new Object();
public static void method1() {synchronized(obj) {// 同步块Amethod2();}
}
public static void method2() {synchronized(obj) {// 同步块B}
}
线程1 | 对象Mark Word | 线程2 |
---|---|---|
访问同步块A,把MarkWord赋值到线程1的锁记录 | 01(无锁) | - |
CAS修改MarkWord为线程1锁记录 | 01(无锁) | - |
成功(加锁) | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块A | 00(轻量级锁)线程1锁记录地址 | - |
访问同步块B,把MarkWord赋值到线程1的锁记录 | 00(轻量级锁)线程1锁记录地址 | - |
CAS修改MarkWord为线程1锁记录 | 00(轻量级锁)线程1锁记录地址 | - |
失败(发现是自己的锁) | 00(轻量级锁)线程1锁记录地址 | - |
锁重入 | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块B | 00(轻量级锁)线程1锁记录地址 | - |
同步块B执行完毕 | 00(轻量级锁)线程1锁记录地址 | - |
同步块A执行完毕 | 00(轻量级锁)线程1锁记录地址 | - |
成功(解锁) | 01(无锁) | - |
- | 01(无锁) | 访问同步块A,把MarkWord赋值到线程2的锁记录 |
- | 01(无锁) | CAS修改MarkWord为线程2锁记录 |
- | 00(轻量级锁)线程1锁记录地址 | 成功(加锁) |
… | … | … |
锁膨胀
在尝试加轻量级锁的过程中,CAS操作无法成功,这时如果其他线程为这个对象加上轻量级锁(有竞争),这时就需要进行锁膨胀,将轻量级锁变为重量级锁
static Object obj = new Object();
public static void method1() {synchronized(obj) {// 同步块}
}
线程1 | 对象Mark Word | 线程2 |
---|---|---|
访问同步块,把MarkWord赋值到线程1的锁记录 | 01(无锁) | - |
CAS修改MarkWord为线程1锁记录 | 01(无锁) | - |
成功(加锁) | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | - |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | 访问同步块,把MarkWord赋值到线程2 |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | CAS修改MarkWord为线程2锁记录 |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | 失败(发现别人已经占了锁) |
执行同步块 | 00(轻量级锁)线程1锁记录地址 | CAS修改Mark为重量级锁 |
执行同步块 | 10(重量级锁)重量锁指针 | 阻塞中 |
执行完毕 | 10(重量级锁)重量锁指针 | 阻塞中 |
失败(解锁) | 10(重量级锁)重量锁指针 | 阻塞中 |
释放重量锁,唤起阻塞线程竞争 | 10(重量级锁)重量锁指针 | 阻塞中 |
- | 10(重量级锁)重量锁指针 | 竞争重量锁 |
- | 10(重量级锁)重量锁指针 | 成功(加锁) |
… | … | … |
加重量级锁是为了后边唤醒的时候,根据重量级锁的指针唤醒阻塞中的线程。
重量级锁
重量级锁竞争时,可以使用自旋来进行优化,如果当时线程自旋成功(说明此时持有锁的线程已经退出同步代码块,释放锁),此时当前线程就可以避免阻塞,直接进入运行状态。
自旋锁是自适应的
- 对象刚刚的一次自选操作成功了,那么认为这次自旋成功的可能性会高,就会多自旋几次;
- 反之,就少自旋 或 不自旋
注意,自旋会占用CPU时间,只有多核的CPU才能发挥自旋的优势。
偏向锁
只有第一次使用CAS将线程ID设置到对象的Mark Word投,之后发现这个线程ID是自己的,就表示没有竞争,不用重新CAS。
其他优化
- 较少上锁时间:同步代码块中尽量短
- 减少锁的粒度:将一个锁拆分成多个锁提高并发度
- ConcurrentHashMap:每次只锁住了一个部分,其他读取操作不会受到影响。
- LongAdder:累加工具类,分为base和cells两部分
- 没有并发争用或cells数组正在初始化时,就会使用CAS来累加到base
- 有并发争用,就会初始化cells数组,数组有多少个cell,就允许多少线程并行修改,最后将数组中每个cell累加,再加上base就是最终的值
- LinkedBlockingQueue:出队和入队使用的就是不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
- 锁粗化:StringBuffer的append方法都会调用synchronized来进行同步保护,如果不加以限制,那么下边这段代码会重复调用三次synchronized。JVM会将多次的append的加锁操作粗化为一次(因为都是一个对象加锁,没必要重入多次)
new StringBuffer().append("a").append("b").append("c");
- 锁消除:JVM会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其他线程访问到,这时就会被即时编译器忽略掉所有同步操作。
- 读写分离:CopyOnWriteArrayList、CopyOnWriteSet(读原始数组的内容;写操作会复制一份,在新数组上进行写操作)