在 Java 的进阶学习中,多线程环境下对 临界区代码的同步处理 是一个非常重要的内容。其中,synchronized 关键字作为实现临界资源同步最直接、最简洁的方式,扮演着关键角色
尽管 synchronized 使用简单,但它的底层实现却涉及 JVM 锁机制、对象头、Monitor、锁升级 等复杂概念。很多人仅仅停留在“加锁能保证线程安全”的层面,却不知道:
- 为什么
synchronized既能修饰方法,也能修饰代码块? - 锁到底存储在对象的哪个部分?
synchronized源码是如何实现的- 重量级锁 加锁解锁流程
- 锁升级是如何优化性能的?
本文将从 使用方式、字节码分析、对象头结构、锁升级过程、性能优化 等多个维度,深入解析 synchronized 的底层原理,帮助你在高并发场景下写出更高效的代码,本文大量参考了 B 站视频 UP 黑马程序员满老师。
临界区
临界区 是指一段可能被多个线程同时执行的代码,而这段代码访问了 共享资源(变量、集合或者文件),由于多个线程同时访问这些共享资源,可能会导致数据不一致或程序行为异常,例如下面这个非常经典的例子
两个线程同时对 count 进行累加和累减操作,最终结果应当为 0,但由于 count++ 和 count-- 操作不是原子性的而导致最后结果五花八门
这就是最常见的线程安全问题,使用 synchronzed 就能规避这个问题🙋
synchronized 的三种实现
synchronized 可以用于 方法 或 代码块 中,其主要有以下三种典型使用方式:
修饰代码块
在 synchronized 中使用同一个 锁对象 锁住临界区的代码
private static final Object LOCK = new Object();
Thread thread1 = new Thread(() -> {
synchronized (LOCK) {
for (int i = 0; i < LOOP_NUM; i++) {
count++;
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (LOCK) {
for (int i = 0; i < LOOP_NUM; i++) {
count--;
}
}
});
这是最灵活的方法,通过锁住具体的代码可以实现更细粒度的锁控制
修饰实例方法
synchronized 也可以加载实例方法上,相当于对方法整体加锁,加锁对象为 当前实例对象
class ThreadSafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized void decrement() {
count--;
}
public int getCount() {
return this.count;
}
}
ThreadSafeCounter threadSafeCounter = new ThreadSafeCounter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < LOOP_NUM; i++) {
threadSafeCounter.increment();
}
})
Thread thread2 = new Thread(() -> {
for (int i = 0; i < LOOP_NUM; i++) {
threadSafeCounter.decrement();
}
});
使用的时候创建出 实例对象,等同于 synchronized(this),当然创建出两个实例,两个实例之间的对象加锁是互不干扰的
修饰静态方法
synchronized 关键字还可以加在 静态方法 上,加锁对象为当前 class 类
private static synchronized void increment() {
for (int i = 0; i < LOOP_NUM; i++) {
count--;
}
}
例如下面两个方法,加锁的对象就是 SynchronizedLearning.class
Thread thread2 = new Thread(SynchronizedLearning::increment);
Thread thread1 = new Thread(SynchronizedLearning::decrement);
字节码分析
对字节码的分析能让我们对 java 程序更加了解,首先对 synchronized 加锁的代码块进行分析,通过 javap 工具可以查看字节码的信息
private static void lambda$main$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=3, args_size=0
0: getstatic #17 // Field LOCK:Ljava/lang/Object;
3: dup
4: astore_0
5: monitorenter
6: iconst_0
7: istore_1
8: iload_1
9: ldc #19 // int 100000
11: if_icmpge 28
14: getstatic #13 // Field count:I
17: iconst_1
18: iadd
19: putstatic #13 // Field count:I
22: iinc 1, 1
25: goto 8
28: aload_0
29: monitorexit
30: goto 38
33: astore_2
34: aload_0
35: monitorexit
36: aload_2
37: athrow
38: return
Exception table:
from to target type
6 30 33 any
33 36 33 any
上面的字节码对应了上面 thread1 的代码部分,我们主要对加锁的字节码进行重点分析
0: getstatic
用于获取 锁对象,是本类中的静态字段 LOCK,并压入操作数栈中
3: dup
dup 命令用于 复制操作数栈顶部元素(也就是 LOCK 对象)原因是接下来两个地方要用这个锁对象
monitorenter加锁操作需要使用- 如果发生异常,
monitorexit需要再次使用到
5: monitorenter
🔒 加锁指令!为什么是 monitor,后面会提到 synchronized 在不同线程竞争情况下,采用操作系统中 monitor 实现
- JVM 会尝试获取对象锁(即对 LOCK 对象加锁)
- 如果其它线程已持有锁,则会阻塞直到获取锁
从这里开始进入 synchronized 控制的代码块区域
29: monitorexit
30: goto 38
......
38: return
这里 monitorexit 是去释放锁,执行完代码块的任务,然后 38 行🔚 方法执行结束
但是可以看到在方法执行结束之前,还有部分字节码
33: astore_2
34: aload_0
35: monitorexit
36: aload_2
37: athrow
这部分就是 JVM 高明的地方,考虑到开发者都没考虑到的地方,这里要结合下面的 Exception table
Exception table:
from to target type
6 30 33 any
33 36 33 any
这张表里记录了所有可能 出现异常的代码块部分 和 处理部分
这里第一行 6 - 30 行代码是整个加锁和处理部分,如果这部分代码出现了任何异常,字节码都会跳到 33 行字节码开始执行,也就是上面部分代码
这里就做了一件事情,monitorexit 去释放 synchronized 加的锁,防止出现这部分代码一直占住锁
这部分是 JVM 给我们生成的,开发者不需要去考虑 synchronized 代码块中出现异常时 锁释放 问题,这也正是 JVM 厉害👍的地方
以上是 synchronized 针对代码块进行加锁,如果是针对 实例方法 或者 静态方法 加锁,对应的方法字节码如下
public synchronized void increment();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field count:I
5: iconst_1
6: iadd
7: putfield #2 // Field count:I
10: return
LineNumberTable:
line 89: 0
line 90: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lioc/ThreadSafeCounter;
其中 flags 中会记录 ACC_SYNCHRONIZED,标志这个方法被 synchronized 关键词修饰,如果是 静态方法(ACC_STATIC) ,JVM 就会锁住 class,如果是 实例方法,则锁住对象本身(this)
private static void lambda$main$0(ioc.ThreadSafeCounter);
descriptor: (Lioc/ThreadSafeCounter;)V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Mark Word
在 JVM 中,每个对象在堆内存中的存储结构分为 对象头、实例数据、对齐填充 三部分:
-
对象头 是 JVM 为每个对象预留的元信息区域,最重要的内容有两部分
- Mark Word,后面将会重点介绍
- Klass Pointer,是指向 元数据(Class Metadata) ,找到自己的类信息,包括字段定义、方法表、接口、父类、GC信息等等
-
实例对象,用于存储类的字段信息
-
对齐填充(padding) ,为了提高内存访问效率,JVM 要求对象大小是 8 字节的整数倍,若对象头 + 实例数据加起来不是 8 的倍数,就会 自动添加填充字节
Mark Word 是 synchronized 实现锁的关键,存储了当前锁的类型和一些对象状态,下面是 64 位 JVM 中 Mark Word 的结构,在不同锁状态下 Mark Word 存储内容也不同
|--------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|--------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:0 | lock: 01 | Normal |
|--------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock: 01 | Biased |
|--------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock: 00 | Lightweight Locked |
|--------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock: 10 | Heavyweight Locked |
|--------------------------------------------------------------------------------|--------------------|
| | lock: 11 | Marked for GC |
|--------------------------------------------------------------------------------|--------------------|
其中 lock 位表示了当前对象的不同加锁状态:
01:无锁 / 偏向锁00:轻量级锁10:重量级锁11:GC 标记
接下来结合 synchronized 锁升级的过程来详细讲述 Mark Word 中的内容
synchronized 锁升级
这里为了能让 Java 输出 Mark Word 数据,使用了 JOL 工具来查看当前对象 Mark Word 信息,但 JOL 生成对象头信息过多,不方便我们查看,这里我自己写了一个工具类将对象头中 Mark Word 信息展示出来,并转成 二进制,方便我们查看
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
在 JDK 15 之后废弃⚠️偏向锁,考虑到目前 JDK 都已经升级到 24版本,不对偏向锁做介绍
无锁状态
对象刚创建时,属于无锁状态
Object lock = new Object();
System.out.println(MarkWordUtils.getMarkWordBinary(lock));
通过 JOL 工具在控制台打印出了 lock 对象头信息
0000000000000000000000000000000000000000000000000000000000000001
如上面介绍的 lock 为 01,biased_lock 为 0,表示这个对象处于 无锁状态
轻量级锁
1)加锁场景
当两个或者多个线程 交替 执行同步代码块,注意 没有竞争!没有竞争!没有竞争!
System.out.println("锁创建时 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t\t锁状态:" + MarkWordUtils.getLock(lock));
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println("t1 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
}
});
t1.start();
t1.join(); // 阻塞等待,保证 t1 线程执行完成再执行 t2 线程
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println("t2 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
}
});
t2.start();
t2.join();
System.out.println("锁全部释放 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
在上面的代码中,t1 和 t2 虽然都是使用 lock 作为锁对象,但是两个线程并 不是同时加锁,轮流使用 lock 对象,下面是输出内容
可以看到当 t1 进入同步代码块的时候,lock 的 Mark Word 后两位变成了 00,即锁当前状态为轻量级锁
2)源码分析
这里查看 synchronized 加锁的 jdk 17 源码 来分析轻量级加锁过程,代码位置在 src/hotspot/share/runtime/synchronizer.hpp 中
void ObjectSynchronizer::enter(Handle obj, BasicLock*lock, JavaThread*current) {
// ... 前面代码省略 ...
markWord mark = obj -> mark();
assert (!mark.has_bias_pattern(),"should not see bias pattern here");
if (mark.is_neutral()) {
lock -> set_displaced_header(mark);
if (mark == obj()->cas_set_mark(markWord::from_pointer (lock), mark)){
return;
}
// ... 后续代码省略 ...
}
// ... 后续代码省略 ...
}
这里只要先看 if 部分代码即可,ObjectSynchronizer::enter 方法对应上面字节码中的 monitorenter 命令,现在逐行来看加锁过程
mark.is_neutral()用来确定 对象未被锁定(无锁状态)lock->set_displaced_header(mark):保存原始 Mark Word,lock是当前线程栈上分配的 锁记录(Lock Record) ,set_displaced_header将 mark 设置到 锁记录 中
轻量级锁记录 源码在同目录下 basicLock 中
class BasicLock {
// ... 前面代码省略 ...
private:
volatile markWord _displaced_header;
// ... 后续代码省略 ...
}
class BasicObjectLock {
// ... 前面代码省略 ...
private:
BasicLock _lock; // the lock, must be double word aligned
oop _obj; // object holds the lock;
// ... 后续代码省略 ...
};
锁记录中主要存储了两个重要信息
BasicLock _lock用于保存 锁对象的原始 Mark Wordoop _obj用于指向 加锁对象
再回到轻量级锁加锁源码中,obj()->cas_set_mark(markWord::from_pointer(lock), mark) 中 原子交换 当前对象 Mark Word 和 锁记录指针进行交换
-
from_pointer(lock)获取轻量级锁的 地址指针 -
obj()->cas_set_mark是一个 CAS(Compare-And-Sweep) 操作,简化成obj->cas(new, old),CAS 操作保证了操作的 原子性- 比较 old 值和 obj->mark 值是否相同,都是原始对象 Mark Word
- new 值是轻量级锁记录 地址指针 + 00(轻量级锁 Mark Word 标识)
- new 和 old 值进行交换,解锁的时候能根据地址解锁
-
lock 对象 Mark Word 最后两位变成
00,轻量级锁加成功
3)重入锁
当前对象已经加了轻量级锁,并且是自己再次加锁。允许 同一个线程 多次获取 同一把锁,这种锁被称为「可重入的(reentrant) 」
new Thread(() -> {
synchronized (lock) {
synchronized (lock) {
}
}
}).start();
对于已经 持有轻量级锁 的当前线程再次加锁,往栈桢中添加 对象存储 null 的锁记录,源码也很好理解
else if (mark.has_locker() &&
current->is_lock_owned((address)mark.locker())) {
assert(lock != mark.locker(), "must not re-lock the same lock");
assert(lock != (BasicLock*)obj->mark().value(), "don't relock with same BasicLock");
lock->set_displaced_header(markWord::from_pointer(NULL));
return;
}
if 条件判断当前是重入轻量级锁,添加锁记录代码为 lock->set_displaced_header(markWord::from_pointer(NULL));,往栈桢中添加存储为 NULL 锁记录
3)解锁步骤
源码中解锁方法为 ObjectSynchronizer::exit,对应字节码中的 monitorexit 命令,下面是轻量级锁的解锁部分源码
void ObjectSynchronizer::exit(oop object, BasicLock*lock, JavaThread*current) {
markWord mark = object -> mark();
markWord dhw = lock -> displaced_header();
// 判断是否为重入的锁
if (dhw.value() == 0) {
return;
}
// 轻量级锁解锁流程
if (mark == markWord::from_pointer (lock)){
if (object -> cas_set_mark(dhw, mark) == mark) {
return;
}
}
// 省略后续代码...
}
首先先获取加 锁对象的 Mark Word 和 锁记录头中存储的信息,这里对象信息已经交换,mark 存储的是 锁记录地址指针,dhw 存储原始对象 Mark Word
dhw.value() == 0 上面提到,如果是同一个线程重入的锁的锁记录头中存储的信息为 NULL,则不用执行任何操作,直接返回
当 mark == markWord::from_pointer(lock) 为 true 时,说明
- Mark Word 最后两位是
00(轻量级锁标识) - 锁被当前线程持有
markWord::from_pointer(lock) 为 false 时说明:
- 当前线程不持有轻量级锁
- 当前锁对象已经成为重量级锁
这是就会都会进入锁膨胀,并执行重量级锁解锁步骤
因此可以确认 当前线程仍持有该对象的轻量级锁,进入解锁流程,object -> cas_set_mark(dhw, mark) == mark 再把锁记录中的原始 Mark Word 通过 CAS 交换到锁对象的 Mark Word 中
- CAS 执行成功,成功解锁轻量级锁,对象重新变为
无锁状态 - CAS 解锁失败,进入重量级锁解锁流程
重量级锁
如果轻量级锁加锁失败,分成下面几种情况:
- 默认关闭轻量级锁加锁步骤
- CAS 竞争失败
- 其他线程已经占用轻量级锁
此时都会升级成为 重量级锁,进入重量级锁的加锁流程(锁膨胀),交由 内核 中的 Monitor 管理
// An async deflation can race after the inflate() call and before
// enter() can make the ObjectMonitor busy. enter() returns false if
// we have lost the race to async deflation and we simply try again.
while (true) {
ObjectMonitor* monitor = inflate(current, obj(), inflate_cause_monitor_enter);
if (monitor->enter(current)) {
return;
}
}
1)Moniter 对象
当前有多个线程进行竞争时,就会触发 锁膨胀,从轻量级锁升级为 重量级锁,重量级锁会由 Moniter 对象进行管理
System.out.println("锁创建时 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t\t锁状态:" + MarkWordUtils.getLock(lock));
Thread t1 = new Thread(() -> {
synchronized (lock) {
TimeUnit.MICROSECONDS.sleep(1);
System.out.println("t1 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println("t2 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
}
});
// 启动线程,阻塞等待,省略代码
System.out.println("锁全部释放 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
这时两个对象发生争抢,lock 对象的 Mark Word 最后两位为 10,锁状态为 重量级锁
这里锁释放了不会恢复 无锁状态,依旧会处于重量级锁状态
Monitor 对象负责管理重量级锁,主要由三部分组成:WaitSet,EntryList 和 Owner
- Owner:当前持有线程,唯一能执行同步代码块,唯一能调用
wait()方法 - EntryList:「想要锁但还没得到」的线程队列,线程状态为
BLOCKED - WaitSet:「主动放弃 Owner,等待条件」的线程(调用
wait()方法),等待notify唤醒
2)锁膨胀
锁膨胀总共分为 4 种情况
- Inflated:已经膨胀完毕
- Stack-locked:从轻量级锁开始膨胀
- Inflating:膨胀中
- Neutral:从无锁状态开始膨胀
for (;;) {
const markWord mark = object->mark();
assert(!mark.has_bias_pattern(), "invariant");
// The mark can be in one of the following states:
// * Inflated - just return
// * Stack-locked - coerce it to inflated
// * INFLATING - busy wait for conversion to complete
// * Neutral - aggressively inflate the object.
// * BIASED - Illegal. We should never see this
}
锁膨胀在一个 死循环 中,每一次都会获取对象的 Mark Word,判断当前状态,其中从无锁对象膨胀和从轻量级锁开始膨胀逻辑相似,只介绍从轻量级锁开始膨胀
- Inflated 膨胀完成
// CASE: inflated
if (mark.has_monitor()) {
ObjectMonitor* inf = mark.monitor();
markWord dmw = inf->header();
return inf;
}
如果当前已经是重量级锁,直接获取 Moniter 对象返回
- Stack-lock 从轻量级锁开始膨胀
if (mark.has_locker()) {
// 1. 创建 moniter 对象
ObjectMonitor* m = new ObjectMonitor(object);
// 2. 将当前对象头设置为全0,为膨胀中标识
markWord cmp = object->cas_set_mark(markWord::INFLATING(), mark);
// 3. CAS 失败,删除 moniter 对象
if (cmp != mark) {
delete m;
continue; // Interference -- just retry
}
// 4.1 获取原始 mark word 值
markWord dmw = mark.displaced_mark_helper();
// 4.2 将原始 Mark word 存入 moniter 中
m->set_header(dmw);
// 4.3 设置 mointer 对持锁线程
m->set_owner_from(NULL, mark.locker());
// 4.4 锁对象头设置新的 mark word
object->release_set_mark(markWord::encode(m));
_in_use_list.add(m);
return m;
}
mark.has_locker() 用来判断当前对象是否为轻量级锁,创建一个新的 moniter 对象,通过 CAS 并将当前对象 mark word 全部置为 0,标志 膨胀中 状态,关键是下面设置重量级锁的 4 个步骤
mark.displaced_mark_helper()获取原始的 mark word 的值m->set_header(dmw)把原始 mark word 值存储在moniter中m->set_owner_from(NULL, mark.locker());设置moniter的 Owner,即当前持重量级锁的线程object->release_set_mark(markWord::encode(m))将 monitor 的地址 encode 编码 +10存入锁对象 mark word 中
经过上面操作,会保证只有一个线程针对 lock 锁对象加 重量级锁 成功
- inflating 膨胀中
当前处于膨胀中的状态代码很好理解,read_stable_mark 方法等待一会
if (mark == markWord::INFLATING()) {
read_stable_mark(object);
continue;
}
所有处于膨胀中的线程都是上一步 CAS 加锁竞争失败的,会等待真正加锁的线程加锁成功,在这里 循环等待
3)误区:轻量级锁真的有自旋吗?
网上有些说法说 轻量级锁 膨胀为 重量级锁 时会去尝试自旋等待轻量级锁释放,自旋 10 次失败升级为重量级锁
甚至问 AI 有时也会得到这样的结果,还有一个合理的理由:避免升级成重量级锁的性能开销。但实际上却是如此吗?
这个说法完全是错误的。上面关于轻量级锁膨胀为重量级锁的源码已经剖析过了,当有线程发生竞争的时候,就会立刻执行锁膨胀流程,不会去自旋 10 次。而上面唯一有循环等待的过程只有当线程处于 膨胀中 时会去循环等待加锁线程成功
4)重量级锁自旋
当对象已经升级成为 重量级锁 后,其他线程再来争抢重量级锁失败后,不会直接进入 Moniter 对象中的 EntryList,也就是并不会直接进入 BLOCKED 堵塞状态,而会选择 自旋 的方式尝试等待获取 重量级锁
重量级锁自旋获取锁的逻辑在源码中为 ObjectMonitor::TrySpin 方法,其中自旋方式分为:
- 固定自旋 1000 次(jdk 1.6 后被禁用)
- 自适应自旋
这里源码过长,挑重要的部分片段来解释 自适应自旋 获取锁的逻辑,与自适应自旋相关参数
// 自旋次数上限
int ObjectMonitor::Knob_SpinLimit = 5000;
// 成功奖励
static int Knob_Bonus = 100;
// 失败惩罚
static int Knob_Penalty = 200;
// 自旋次数下限
static int Knob_Poverty = 1000;
// 预自旋次数
static int Knob_PreSpin = 10;
首先会进入 预自旋 流程,自旋 11 次
for (ctr = Knob_PreSpin + 1; --ctr >= 0;) {
// 进行自旋获取锁...
}
- 自旋成功获取锁,直接返回
- 自旋失败,进入下面 完整自旋
进入 完整自旋 逻辑,当前自旋次数由全局变量 _SpinDuration (初始 5000 次)记录
- 自旋失败,下次
-200次,最少减为0(0 表示下次完成预自旋直接进入阻塞) - 自旋成功,下次
+200次,至少加为1000(1000 值是指从 0 开始累加的话,直接从 1000 开始累加)
当自旋全部失败后,失败线程就会进入重量级锁加锁流程
6)_cxq 队列
这里的加锁流程是指当前 Owner 已经被占用,其他线程的「挣扎过程」,当失败线程执行完自旋获取锁的流程后,会进入 ObjectMonitor::EnterI 方法
- 线程使用
TATAS (Test-And-Test-And-Set)尝试 快速获取锁,即使上面 5000 次自旋失败了,线程还会努力 挣扎一次,如果成功了,直接将 Owner 设置为当前线程,直接返回
// Try the lock - TATAS
if (TryLock (current) > 0) {
// ...
return;
}
2. 再次进入 自适应自旋尝试,因为上一次失败了次数 -200,如果上次自旋次数是 5000 次,那这次会自旋 4800 次
if (TrySpin(current) > 0) {
return;
}
3. 自旋还是失败后,会讲当前线程加入 Monitor 对象中的 _cxq 栈 中
ObjectWaiter node(current);
node.TState = ObjectWaiter::TS_CXQ;
// CAS操作 将当前线程加入_cxq队列
for (;;) {
node._next = nxt = _cxq;
if (Atomic::cmpxchg(&_cxq, nxt, &node) == nxt) break;
// 如果CAS失败,重试获取锁
if (TryLock(current) > 0) return;
}
创建 ObjectWaiter 节点表示当前线程,压入栈(LIFO 后进先出),并设置一个 责任线程
if (nxt == NULL && _EntryList == NULL) {
Atomic::replace_if_null(&_Responsible, current);
}
如果没有责任线程且队列为空,设置当前线程为 责任线程。所有加入 _cxq 栈中的线程都会使用 park() 命令让自己 阻塞
- 设置责任线程定期唤醒,非责任线程一直阻塞
for (;;) {
if (TryLock(current) > 0) break;
// 责任线程使用定时park,非责任线程无限期park
if (_Responsible == current) {
current->_ParkEvent->park((jlong)recheckInterval);
recheckInterval = MIN2(recheckInterval*8, MAX_RECHECK_INTERVAL);
} else {
current->_ParkEvent->park();
}
// 唤醒后再次尝试获取锁
if (TryLock(current) > 0) break;
}
通过设置责任线程的机制,能够防止线程永久挂起,并且节省了性能的开销
7)解锁步骤
- 若当前线程为轻量级锁,但调用的是
ObjectMonitor::exit重锁解锁方法,会先加重锁,再解锁 - 若为重入锁(重量级锁重入的时候会对
Monitor中的_recursions累加),则计数-1直接返回
重量级锁正常解锁过程,首先讲 Owner 设置为 NULL,顺序执行下来会出现 4 中不同情况
- _cxq 和 EntryList 全部为空,直接返回
// 没有等待线程,直接返回
if ((intptr_t(_EntryList)|intptr_t(_cxq)) == 0) {
return;
}
2. 若 EntryList 为空,_cxq 有数据,则将 _cxq 列表全部转移到 EntryList 中
for (;;) {
assert(w != NULL, "Invariant");
ObjectWaiter* u = Atomic::cmpxchg(&_cxq, w, (ObjectWaiter*)NULL);
if (u == w) break;
w = u;
}
通过使用 Atomic::cmpxchg 保证队列转移的原子性
转换前 _cxq (LIFO 单向链表):
+---+ +---+ +---+
| C |--->| B |--->| A |---> NULL
+---+ +---+ +---+
转换步骤:
1. 断开 _cxq 链接
2. 遍历链表建立双向链接
转换后 _EntryList (FIFO 双向链表):
+---+ +---+ +---+
NULL <--- | A | <->| B | <->| C | ---> NULL
+---+ +---+ +---+
^
|
_EntryList 指向这里
3. 当前 EntryList 不为空,从队列获取 头节点,unpark() 唤醒,返回
w = _EntryList;
if (w != NULL) {
ExitEpilog(current, w);
return;
}
w 是 EntryList 的头结点,这里调用 ExitPilog 方法来唤醒 w 线程,主要有四个步骤
// 1. 设置继承者
_succ = Wakee->_thread;
// 2. 释放锁
release_clear_owner(current);
// 3. 内存屏障保证操作顺序
OrderAccess::fence();
// 4. 唤醒线程
Trigger->unpark();
OrderAccess::fence() 是一个 全内存屏障(full memory barrier) ,用于保证了 2 步骤一定先于 4 步骤执行,即肯定先释放锁再唤醒线程
最后这里用一张流程图来简单梳理 重量级锁的解锁流程
8)误区:锁释放,会从 EntryList 中随机挑选一个线程唤醒吗?
很多八股文都会说当 Owner 释放了重量级锁后,会随机从 EntryList 中挑选一个线程唤醒获得锁,这的是这样吗?
结论显然是错误的,我们上面已经剖析过源码,竞争的线程会加入 _cxq 栈中的(LIFO,先进后出),然后再将 _cxq 内容加入 EntryList 队列中(FIFO,先进先出),因此唤醒顺序一定是确认的,下面的示例代码将证明这一点
final Object lock = new Object();
new Thread(() -> {
synchronized (lock) {
try {
System.out.println("t0 加锁成功");
System.in.read(); // 阻塞 t0
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}, "t0").start();
TimeUnit.MICROSECONDS.sleep(100);
new Thread(() -> {
synchronized (lock) {
System.out.println("t1 获取锁");
}
}, "t1").start();
TimeUnit.MICROSECONDS.sleep(100);
new Thread(() -> {
synchronized (lock) {
System.out.println("t2 获取锁");
}
}, "t2").start();
TimeUnit.MICROSECONDS.sleep(100);
new Thread(() -> {
synchronized (lock) {
System.out.println("t3 获取锁");
}
}, "t3").start();
t0 线程优先获取锁,并阻塞,让 t1,t2,t3 先后加锁,最后查看获取锁的先后情况
最后执行结果说明了顺序一定是如下图所示,EntryList 是一个 先进先出 的队列,t3 最先被唤醒,并非网上说的随机挑选一个线程获取锁
9)为什么有了 EntryList 还需要 _cxq
逻辑上,我们只需要一个队列就可以管理阻塞的线程,然后唤醒。但实现上,我们把这个队列分成了两部分: _cxq 和 EntryList,主要为了避免 ABA 问题 和 惊群效应,以及 兼顾了吞度量和公平性
- ABA 问题
ABA 问题 是并发编程中的一个经典问题,主要出现在特别是在使用 CAS(Compare-And-Swap) 操作时可能发生
对于共享变量 x = a, 执行 cas(a, b) 是原子性的将 x 的值交换成 b,并且会把交换的旧值返回
- 当前 x 为 a,则交换成功,x = b
- 当前 x 被其他线程修改成了 c,则交换失败,本线程需要重试
当 CAS 操作结合 栈 时,就会引发 ABA 问题:,例如下面的示例
这个图已经基本反映了 ABA 问题 的核心逻辑:一个线程在用 CAS 操作时,只判断了「值有没有变化」,没考虑「值是否曾经被改过」
- _cxq 是 LIFO(栈)结构,插入简单,不容易出现 ABA
- 只有 JVM 自己在合适时机把 _cxq 中的线程“批量转移”到 EntryList → 所以可以集中加锁、避免高频竞 → 避免 ABA 问题
- 惊群效应
惊群效应 指的是当 一个资源释放时,所有等待的线程都被唤醒并竞争,但最终只有一个线程能成功获取锁,其他线程又得重新挂起,导致 大量无意义的上下文切换和CPU争抢
通过 _cxq + EntryList 每次只唤醒一个线程(EntryList) 减少竞争,提高吞吐量
- 兼顾吞吐量和公平性
_cxq 相当于缓存了最近竞争的线程,提高了整体的 吞吐量
EntryList 采用 FIFO 顺序可以一定的 公平性,防止线程 饿死(长时间获取不到锁)