并发锁三剑客
# 36.并发锁三剑客
# 目录介绍
- 1. 案例引入
- 2. ReentrantLock 工程化深挖
- 3. ReentrantReadWriteLock 源码剖析
- 4. StampedLock 乐观读革命
- 5. 三剑客性能横评
- 6. Loom 时代的 Lock 选型
- 7. 工程实战避坑
- 8. 综合回扣与设计哲学
# 1. 案例引入
# 1.1 缓存场景的"读写之痛"
某电商配置中心:1 个写线程秒级更新规则,800 个读线程毫秒级查询。一开始用 synchronized:
public class ConfigCache {
private Map<String, Rule> rules = new HashMap<>();
public synchronized Rule get(String key) { return rules.get(key); } // ← 800 个读线程互斥
public synchronized void put(String key, Rule v) { rules.put(key, v); }
}
2
3
4
5
6
压测一上:P99 延迟 480ms,CPU 仅 30%——大量线程在 BLOCKED 态空等。换成 ReentrantReadWriteLock:
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
public Rule get(String key) {
rwLock.readLock().lock();
try { return rules.get(key); }
finally { rwLock.readLock().unlock(); }
}
2
3
4
5
6
P99 降到 80ms,看似搞定——但继续加大写线程到 4 个,读线程的 P99 又飙到 220ms。再换 StampedLock 乐观读,P99 降到 12ms,吞吐再翻 5 倍。
为什么三个 Lock 性能差距这么大?什么场景该用谁? —— 这是 37 篇要回答的核心。
# 1.2 RWLock 升级死锁的真实事故
某线上事故复盘:缓存类用了 ReentrantReadWriteLock,业务希望"读到旧数据时立即升级为写锁刷新":
rwLock.readLock().lock();
try {
Rule r = cache.get(key);
if (r.isExpired()) {
rwLock.writeLock().lock(); // ❌ 读 → 写升级
try {
cache.put(key, fetch(key));
} finally { rwLock.writeLock().unlock(); }
}
} finally { rwLock.readLock().unlock(); }
2
3
4
5
6
7
8
9
10
生产环境跑着跑着所有线程卡死——jstack 一看全在 writeLock().lock()。原因 36 篇 §8.2 已揭过:读锁升级写锁会死锁——A 持读锁等写锁,要等所有读锁释放;但 B 也持读锁也在等写锁;两人无限死等。
怎么改? §3.4、§3.5、§4.5 给出三种方案。
# 1.3 我们要回答什么
37 篇是卷五第 6 篇,承接 36 篇 AQS 总框架,把 Lock API 全家桶的工程化用法一次讲透:
36.AQS (地基) → 37.三剑客 (本篇/Lock 全家桶) → 38.CAS → 39.线程池
带着 6 个问题展开:
追问 ①:ReentrantLock 比 synchronized 强在哪?什么时候必须用? → §2
追问 ②:RWLock 高低位编码的实现细节?读多场景为什么还不够快? → §3
追问 ③:StampedLock 乐观读到底"乐观"在哪里?性能凭什么暴涨? → §4
追问 ④:三剑客在不同读写比下性能曲线长什么样? → §5
追问 ⑤:Loom 时代 synchronized 真的废了吗? → §6
追问 ⑥:怎么写一行不死锁、不饿死、可监控的并发代码? → §7
2
3
4
5
6
# 2. ReentrantLock 工程化深挖
# 2.1 与 synchronized 的六维对比
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层 | JVM 字节码 + C++ monitor | Java 层 + AQS(36 篇) |
| 中断响应 | ❌ 无法中断 | ✅ lockInterruptibly() |
| 超时获取 | ❌ 无 | ✅ tryLock(time, unit) |
| 公平模式 | ❌ 仅非公平 | ✅ 构造时可选 |
| 条件队列 | 1 个(monitor) | N 个(多 Condition) |
| 释放方式 | 自动(出 synchronized 块) | 手动(必须 finally unlock) |
结论:synchronized 是"傻瓜锁"——简单但功能有限;ReentrantLock 是"瑞士军刀"——灵活但要自己拧螺丝。JDK 6 锁升级后,无竞争时 synchronized 性能持平甚至略优,但只要有以下需求之一,必须用 ReentrantLock:
✅ 需要响应中断(如长任务里支持取消)
✅ 需要超时获取(如限时尝试拿锁,失败走降级逻辑)
✅ 需要公平锁(如严格 FIFO 防饥饿)
✅ 需要多条件队列(如生产者消费者多条件区分)
✅ 需要尝试锁(tryLock() 不阻塞直接返回)
2
3
4
5
# 2.2 公平/非公平选型决策树
36 篇 §8.1 已拆解过差异——只在 initialTryLock 里多调一次 hasQueuedPredecessors()。对应到生产选型:
是否有线程长期被饿死的真实风险?
├── 是(如关键资源、SLA 敏感场景)
│ └── 公平锁 new ReentrantLock(true)
└── 否(默认 95% 业务)
└── 非公平锁 new ReentrantLock() ← 默认值,吞吐高 2-4 倍
2
3
4
5
为什么非公平默认就够:现实中线程切换、调度抖动天然带来"伪公平"——线程 A 释放锁后 B 立即抢到的概率不会太高,长期下来分布相对均匀。公平锁的代价:每次 acquire 都要查队列、入队,吞吐损失约 40%~70%。
# 2.3 lockInterruptibly 的中断契约
35 篇 §5 讲过中断三件套——这里看 AQS 怎么落地:
public final void acquireInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException(); // ① 入口检查
if (!tryAcquire(arg))
doAcquireInterruptibly(arg); // ② 等待中也响应
}
private void doAcquireInterruptibly(int arg) throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; return; }
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
throw new InterruptedException(); // ★ park 期间被中断直接抛
}
} catch (Throwable t) { cancelAcquire(node); throw t; }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
与 lock() 的关键差异:lock() 即使 park 期间被中断也只是记录标志、继续等待,等拿到锁后由 selfInterrupt() 补打;lockInterruptibly() 立即抛 InterruptedException——这是"长任务可取消"的物理基础。
// 工程范式:长任务支持中断退出
lock.lockInterruptibly(); // ★ 必须用 Interruptibly 版本
try {
while (!Thread.currentThread().isInterrupted()) {
doWork(); // 工作循环也要响应中断
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
2
3
4
5
6
7
8
9
10
11
# 2.4 tryLock 超时的正确用法
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { // ★ 限时 500ms
try {
doWork();
} finally { lock.unlock(); }
} else {
// ★ 拿不到锁的降级逻辑(如返回缓存、走 fallback)
return fallback();
}
2
3
4
5
6
7
8
两个常见错误:
// ❌ 错误 1:忘记判断返回值
lock.tryLock(500, TimeUnit.MILLISECONDS); // ← 没接收返回值,无论拿没拿到都执行后续!
doWork();
lock.unlock();
// ❌ 错误 2:try 写在外面
lock.tryLock(500, TimeUnit.MILLISECONDS);
try { doWork(); }
finally { lock.unlock(); } // ← 没拿到锁也 unlock 会抛 IllegalMonitorStateException
2
3
4
5
6
7
8
9
正确范式:返回值必须接收,try 必须在判断之内——这是死规矩。
# 2.5 Condition 多条件队列高级用法
36 篇 §7 讲过 Condition 双队列结构。这里给一个有界缓冲生产者消费者的工程级实现,对比 synchronized + wait/notifyAll 的差距:
public class BoundedBuffer<E> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // ★ 等"不满"
private final Condition notEmpty = lock.newCondition(); // ★ 等"不空"
private final Object[] items;
private int putIdx, takeIdx, count;
public BoundedBuffer(int capacity) { items = new Object[capacity]; }
public void put(E x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) notFull.await(); // 等"不满"
items[putIdx] = x;
if (++putIdx == items.length) putIdx = 0;
++count;
notEmpty.signal(); // ★ 精准唤醒消费者
} finally { lock.unlock(); }
}
@SuppressWarnings("unchecked")
public E take() throws InterruptedException {
lock.lock();
try {
while (count == 0) notEmpty.await(); // 等"不空"
E x = (E) items[takeIdx];
if (++takeIdx == items.length) takeIdx = 0;
--count;
notFull.signal(); // ★ 精准唤醒生产者
return x;
} finally { lock.unlock(); }
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
对比 synchronized 版——只能 notifyAll() 把生产者消费者全唤醒,所有人争一把锁后绝大多数又退回 wait——CPU 浪费在无效的"惊群"上。Condition 的多队列让等不同条件的线程互不打扰——这正是 ArrayBlockingQueue 的实现原型。
# 3. ReentrantReadWriteLock 源码剖析
# 3.1 高低位编码的状态布局
36 篇 §3.2、§8.2 已铺过——这里把状态机完整画清楚:
state (32 bit)
┌─────────────────────────────┬─────────────────────────────┐
│ 高 16 位 sharedCount │ 低 16 位 exclusiveCount │
│ (读锁 - 持有线程总次数) │ (写锁 - 重入次数) │
│ 最多 65535 │ 最多 65535 │
└─────────────────────────────┴─────────────────────────────┘
c >>> 16 c & 0xFFFF
2
3
4
5
6
7
关键不变量:
sharedCount > 0 && exclusiveCount > 0→ 不可能(除非同一线程持写锁后又拿读锁,即"降级中间态")sharedCount = 0 && exclusiveCount = 0→ 自由态exclusiveCount > 0→ 必然由exclusiveOwnerThread独占
线程级读锁计数:state 高 16 位是所有读线程的总次数,但每个线程自己持读锁多少次需要 ThreadLocal<HoldCounter> 跟踪——这样才能支持"读锁也可重入"。
# 3.2 读锁获取链路
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1; // ① 别人持写锁,立即失败
int r = sharedCount(c);
if (!readerShouldBlock() && // ② 是否该被阻塞(公平 vs 非公平)
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) { // ★ CAS 高 16 位 +1
if (r == 0) { // ③ 第一个读者:cache
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) { // ④ 第一个读者重入
firstReaderHoldCount++;
} else { // ⑤ 其他读者:用 ThreadLocal HoldCounter
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0) readHolds.set(rh);
rh.count++;
}
return 1;
}
return fullTryAcquireShared(current); // 回退到完整 CAS 自旋
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
精彩的"第一个读者优化":99% 场景就一个读者反复进出(如缓存),用 firstReader/firstReaderHoldCount 两个普通字段记录,避免 ThreadLocal 开销。这是 Doug Lea "为常见路径优化"的典型手法——卷一 09 篇 HashMap 的 firstNode、卷二 27 篇 Optional 缓存空对象都是同款思路。
# 3.3 写锁获取与重入
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
// ★ 关键判断:要么没人持锁,要么必须是自己持写锁
if (w == 0 || current != getExclusiveOwnerThread()) return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(c + acquires); // 重入:低 16 位 +1
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires)) // 自由态:CAS 抢
return false;
setExclusiveOwnerThread(current);
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
注意第一个 if 的精妙:c != 0 说明有人持锁,此时只有两种情况能继续——
w == 0→ 全是读者持锁 → 直接返 false(即使我自己也持读锁,也不能升写)w != 0 && current == owner→ 我自己持写锁 → 重入 +1
这一个判断同时禁止了"读升写"(§3.5)并支持了"写重入"——Doug Lea 用 5 行代码守住了整个一致性。
# 3.4 锁降级(写 → 读)的标准范式
降级合法:持写锁的线程在释放写锁前先获取读锁——这是 RWLock 唯一允许的"跨锁切换"。
public class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
rwl.readLock().unlock(); // ★ 必须先放读锁
rwl.writeLock().lock();
try {
if (!cacheValid) { // ★ 双重检查
data = ...
cacheValid = true;
}
rwl.readLock().lock(); // ★ 持写锁时拿读锁(降级中间态)
} finally {
rwl.writeLock().unlock(); // ★ 释放写锁,仅留读锁
}
}
try {
use(data);
} finally { rwl.readLock().unlock(); }
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
降级的核心目的:让该线程能继续以读模式访问,但期间禁止其他写者插入——保证"我刚写的数据自己能立刻读到"的强一致性。这是 ConcurrentHashMap 等并发容器的内部实现常用招式。
# 3.5 锁升级(读 → 写)为什么死锁
线程 A 持读锁 → 想升写 → 等所有读锁释放 → park
线程 B 持读锁 → 想升写 → 等所有读锁释放 → park
A 等 B 释放,B 等 A 释放 → 永久死锁 ⚠️
2
3
§3.3 的源码 if 直接禁止了这条路——读锁存在时(w == 0 但 sharedCount > 0),tryAcquire 直接返 false。正确做法:
// ❌ 错误:直接升级
rwLock.readLock().lock();
try {
if (needWrite) rwLock.writeLock().lock(); // 死锁!
} finally { rwLock.readLock().unlock(); }
// ✅ 正确:先释放读锁,再抢写锁
rwLock.readLock().lock();
boolean needWrite = checkNeedWrite();
rwLock.readLock().unlock(); // ★ 必须先放读锁
if (needWrite) {
rwLock.writeLock().lock();
try {
if (stillNeedWrite()) doWrite(); // ★ 写锁内重新检查(可能其他线程已写)
} finally { rwLock.writeLock().unlock(); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
§1.2 的事故就是踩了这个坑——线上无脑升级最终演变为全线程 park。最优雅的解法在 §4.5——StampedLock 提供了 tryConvertToWriteLock 原子转换。
# 3.6 RWLock 的"写饥饿"难题
非公平模式下,读锁可以无限插队——只要有读者持锁,新来的读者直接获取,写者一直在队里等。极端场景下写者可能永不被唤醒。
// AQS 内部 readerShouldBlock 实现(非公平)
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive(); // ★ 仅当队首是写者时才让读者排队
}
2
3
4
这是一个有限度的让步——队首是写者时新读者必须排队,避免完全饿死写者;但队中间还有写者时仍可能被无限插队。生产建议:高并发写场景必须用公平 RWLock 或换用 StampedLock。
# 4. StampedLock 乐观读革命
# 4.1 三种锁模式
JDK 8 引入的 StampedLock 不基于 AQS——它自己实现了一套队列,提供三种模式:
| 模式 | API | 性质 | 用途 |
|---|---|---|---|
| 写锁 | writeLock() / unlockWrite(stamp) | 独占 | 等价 RWLock 写锁 |
| 悲观读 | readLock() / unlockRead(stamp) | 共享 | 等价 RWLock 读锁 |
| 乐观读 | tryOptimisticRead() / validate(stamp) | 无锁 | 革命性创新 ⚡ |
核心创新:乐观读不上锁——直接读数据,事后用 validate(stamp) 验证读期间没有写入。无竞争的读不需要任何 CAS、不需要任何同步——这才是读多场景性能爆杀 RWLock 的根本原因。
# 4.2 stamp 戳的设计
每次成功获取(写/悲观读/乐观读)都返回一个 long 类型的 stamp——它编码了:
- 当前的"版本号"(每次写锁释放时 +1)
- 锁模式标志位
// 内部状态字段
private transient volatile long state; // long = 64 bit,不再是 int
private static final long WBIT = 1L << 7; // 写位(state 第 7 位)
private static final long RBITS = WBIT - 1L; // 读计数低 7 位
private static final long RFULL = RBITS - 1;
2
3
4
5
为什么用 long:32 位 int 不够装"版本号 + 模式 + 读计数",必须 64 位才能在一个原子变量里编码全部信息(再次回扣 36 篇 §3 "状态压缩"哲学)。
# 4.3 乐观读的标准范式
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // ① 拿乐观戳(无锁!)
double curX = x, curY = y; // ② 直接读字段
if (!sl.validate(stamp)) { // ③ 验证期间没人写
stamp = sl.readLock(); // ④ 失败:升级悲观读
try {
curX = x; curY = y;
} finally { sl.unlockRead(stamp); }
}
return Math.sqrt(curX * curX + curY * curY);
}
public void move(double dx, double dy) {
long stamp = sl.writeLock();
try { x += dx; y += dy; }
finally { sl.unlockWrite(stamp); }
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
乐观读三步曲:
tryOptimisticRead()—— 仅仅读一下当前 state(一次 volatile read),不做任何 CAS、不入队- 拷贝数据到本地变量(避免读期间字段被改)
validate(stamp)—— 再读一次 state 与原 stamp 对比,没人写过就是有效;写过了走悲观读 fallback
性能本质:无写竞争时,读者的开销 = 2 次 volatile read——这跟"自由读裸字段"几乎无差别。
# 4.4 validate 与内存屏障
public boolean validate(long stamp) {
VarHandle.acquireFence(); // ★ 关键的内存屏障
return (stamp & SBITS) == (state & SBITS);
}
2
3
4
为什么需要 acquireFence:乐观读中先读字段后 validate——必须保证字段读取不被重排到 validate 之后,否则可能验证通过但读到旧值。acquireFence 是 LoadLoad + LoadStore 屏障(卷四 31 篇 VarHandle),确保后续操作不被前移。
这是无锁编程的精髓:用最便宜的内存屏障代替昂贵的锁。这也是 StampedLock 不能基于 AQS 的原因——AQS 没暴露这种细粒度屏障 API,只能自己撸。
# 4.5 锁转换 API(tryConvertToWriteLock)
StampedLock 提供了原子化的锁模式切换——这是它解决 RWLock 升级死锁的杀手锏:
public void moveIfAtOrigin(double newX, double newY) {
long stamp = sl.readLock(); // 先持读锁
try {
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp); // ★ 原子升级
if (ws != 0L) {
stamp = ws;
x = newX; y = newY;
break;
} else {
sl.unlockRead(stamp);
stamp = sl.writeLock(); // ★ 退回先释放再抢
}
}
} finally { sl.unlock(stamp); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
tryConvertToWriteLock 的精妙:
- 当前 stamp 是写锁 → 直接返回(已经是写锁)
- 当前是读锁且只有自己持读锁 → CAS 升级为写锁,返回新 stamp
- 否则(多个读者)→ 返回 0L 失败
唯一支持锁升级的 Lock 实现——但仍要做好 fallback 处理。
# 4.6 StampedLock 的四大坑
⚠️ 坑 1:不可重入——同一个线程再次拿同种锁会死锁。RWLock 写锁可重入,StampedLock 写锁不可重入。
⚠️ 坑 2:不支持 Condition——没有 newCondition() API。需要等待/通知场景必须用 ReentrantLock。
⚠️ 坑 3:不响应中断(默认)——writeLock() / readLock() 不响应中断,要用 writeLockInterruptibly() / readLockInterruptibly()。
⚠️ 坑 4:乐观读必须配字段拷贝 + validate——直接在乐观读区间内调用其他方法、抛异常都可能读到不一致状态。乐观读区间必须是"快照式"的纯字段读取,不能有副作用。
// ❌ 错误用法:乐观读期间做复杂操作
long stamp = sl.tryOptimisticRead();
if (cache.get(key).isExpired()) { // ← 期间可能读到撕裂状态,抛 NPE
refresh();
}
sl.validate(stamp);
// ✅ 正确用法:先拷贝、再 validate、再用拷贝
long stamp = sl.tryOptimisticRead();
Snapshot snap = new Snapshot(field1, field2);
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try { snap = new Snapshot(field1, field2); }
finally { sl.unlockRead(stamp); }
}
useSnapshot(snap);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 5. 三剑客性能横评
# 5.1 测试场景设计
环境:32 核 / 64 线程 / JDK 21 / 临界区耗时 ~ 1μs
对比:synchronized / ReentrantLock / RWLock / StampedLock(乐观读)
读写比:95:5 / 50:50 / 5:95
指标:吞吐 (ops/s) + P99 延迟 (μs)
2
3
4
# 5.2 读多写少(95:5)
吞吐 (M ops/s) P99 (μs)
synchronized 2.1 420
ReentrantLock 3.8 280
RWLock 9.5 95
StampedLock(乐观) 32.0 18 ★★★ 碾压 ★★★
2
3
4
5
StampedLock 暴打的根因:乐观读路径完全无锁——95% 的读操作只做 2 次 volatile read。RWLock 即使读不互斥,每次读还是要 CAS state 高 16 位 +1。
# 5.3 读写均衡(50:50)
吞吐 (M ops/s) P99 (μs)
synchronized 2.0 450
ReentrantLock 3.2 350
RWLock 3.5 340 ← 读写各半时优势消失
StampedLock(乐观) 4.8 220
2
3
4
5
关键观察:50:50 时 RWLock 几乎没有优势——大量乐观读会因写者频繁修改而 validate 失败、退化为悲观读。StampedLock 仍领先但优势收窄。
# 5.4 写多读少(5:95)
吞吐 (M ops/s) P99 (μs)
synchronized 1.8 500
ReentrantLock 2.8 380
RWLock 2.5 420 ← 反而比 ReentrantLock 慢
StampedLock(乐观) 2.6 390 ← 乐观读 95% 失败,全走悲观
2
3
4
5
关键观察:写多场景下 RWLock 反而比 ReentrantLock 慢——原因是 RWLock 的内部状态管理比 ReentrantLock 更复杂(高低位拆分 + ThreadLocal HoldCounter),没有读多收益时这些开销纯亏。
# 5.5 横评结论与选型决策树
是否有竞争?
├── 几乎无竞争
│ └── synchronized(JDK 6 偏向锁/轻量锁红利)
│
└── 有竞争
│
读写比例如何?
├── 写为主(写 ≥ 30%)
│ └── ReentrantLock(默认非公平)
│
├── 读多写少(读 ≥ 70%)
│ │
│ 是否需要 Condition / 中断 / 重入?
│ ├── 是 → ReentrantReadWriteLock
│ └── 否 → StampedLock(乐观读 ⚡)
│
└── 写极少(读 ≥ 95%)
└── StampedLock(吞吐爆杀 RWLock 3 倍 +)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
额外维度:
- 需要多条件队列 → 必选 ReentrantLock(StampedLock 不支持)
- 需要锁可重入 → 不能用 StampedLock
- 强一致性写后读 → 必须 RWLock 锁降级范式(§3.4)
# 6. Loom 时代的 Lock 选型
# 6.1 synchronized 的 Pinning 痛点
JDK 21 虚拟线程(41 篇会展开)下,synchronized 会把虚拟线程"钉"在 carrier 线程上——其他虚拟线程无法复用该 carrier,调度优势完全丧失:
// ⚠️ 虚拟线程下的"钉死"陷阱
synchronized (lock) {
Thread.sleep(1000); // ← 虚拟线程被 pin,carrier 无法切换
}
2
3
4
根因:synchronized 是 JVM 内置 monitor,进入 monitor 的栈帧是 C++ 物理栈帧——Loom 没法把它从 carrier 上"卸下来"挂起。
# 6.2 Lock 三剑客的虚拟线程友好性
是否 Pin carrier 原因
synchronized 是 ❌ C++ monitor 物理栈帧
ReentrantLock 否 ✅ AQS 基于 LockSupport.park(支持虚拟线程友好挂起)
RWLock 否 ✅ 同上
StampedLock 否 ✅ 内部也用 LockSupport.park
2
3
4
5
Loom 设计准则:所有阻塞点都走 LockSupport.park——park 在虚拟线程上不是 OS 挂起,而是切回调度器、yield carrier。三剑客早早就埋好了这个伏笔——这是 36 篇 §8.5 提到的 "Doug Lea 早早给虚拟线程留好了门"。
# 6.3 JDK 24 的 synchronized 解 Pin
JDK 24(2025-03 发布)推进 JEP 491: Synchronize Virtual Threads without Pinning (opens new window)——重写了 monitor 实现,synchronized 不再 Pin!
JDK 21: synchronized → Pin
JDK 24: synchronized → 不再 Pin(默认开启)
2
这是否意味着 Lock 三剑客失去优势?并没有——三剑客在 Loom 下仍是首选:
- synchronized 解 Pin 只在 monitor 不抢占 native 栈帧时生效——一旦 JNI 调用还会 Pin
- ReentrantLock 仍提供 synchronized 没有的能力(中断、超时、Condition、公平)
- StampedLock 的乐观读是任何 synchronized 优化都比不上的零开销路径
41 篇会再深入 Loom 内部机制 + Pinning 完整诊断方案。
# 7. 工程实战避坑
# 7.1 lock 必须配 try-finally
// ❌ 致命错误:锁泄漏
lock.lock();
doWork(); // 抛异常 → unlock 永远不执行 → 后续所有线程死等
lock.unlock();
// ✅ 标准范式
lock.lock();
try {
doWork();
} finally {
lock.unlock();
}
2
3
4
5
6
7
8
9
10
11
12
lock() 必须在 try 之外,unlock 必须在 finally——这是死规矩。tryLock 配套是 if (lock.tryLock()) { try {...} finally {lock.unlock();} }。
# 7.2 锁顺序与死锁四要素
死锁四要素(操作系统经典):互斥、占有且等待、不可剥夺、循环等待。打破任意一条即可避免:
// ❌ 死锁经典示范
Thread1: lock(A) → lock(B)
Thread2: lock(B) → lock(A) ← 循环等待
// ✅ 全局锁顺序:永远按 hashCode 顺序加锁
void transfer(Account from, Account to, int amount) {
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
first.lock.lock();
try {
second.lock.lock();
try { /* transfer */ }
finally { second.lock.unlock(); }
} finally { first.lock.unlock(); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 7.3 锁粒度的"先粗后细"原则
开发阶段:先用粗粒度锁(一把大锁罩全场)→ 保证正确性
压测发现瓶颈 → 拆分锁(按数据分片、按读写分离)
2
典型案例:
- HashMap → Hashtable(一把大锁)→ ConcurrentHashMap(分段锁 → CAS+synchronized 节点锁)
- 数据库行锁 → 表锁 → 库锁(粒度从细到粗,并发度从高到低)
反例:上来就拆 1000 个锁——大概率某次 bug 修复时漏掉一把锁,引入幽灵 bug。先正确,再快。
# 7.4 监控埋点:getQueueLength 与 isLocked
ReentrantLock 提供丰富的监控 API(StampedLock 没有,是它的另一个短板):
ReentrantLock lock = new ReentrantLock();
lock.getQueueLength() // 当前等待队列长度
lock.hasQueuedThreads() // 是否有人在等
lock.hasQueuedThread(thread) // 指定线程是否在等
lock.isLocked() // 是否被持有
lock.isHeldByCurrentThread() // 是否当前线程持有
lock.getHoldCount() // 当前线程的重入次数
// Condition 监控
lock.hasWaiters(condition)
lock.getWaitQueueLength(condition)
2
3
4
5
6
7
8
9
10
11
12
生产埋点范式:
@Scheduled(fixedDelay = 1000)
public void monitor() {
int queueLen = lock.getQueueLength();
if (queueLen > THRESHOLD) {
log.warn("Lock contention: {} threads waiting", queueLen);
// 报警 / 触发熔断 / 降级
}
metrics.gauge("lock.queue.length", queueLen);
}
2
3
4
5
6
7
8
9
# 8. 综合回扣与设计哲学
# 8.1 案例真相揭晓
① §1.1 缓存读写之痛的真相:synchronized 把 800 个读线程串行化是性能杀手;RWLock 让读并行但写者饥饿;StampedLock 乐观读才是正解——95% 读场景下 P99 从 480ms 降到 12ms(40 倍提升)。根治:按读写比例选锁,超读多场景(≥95%)必上 StampedLock 乐观读。
② §1.2 RWLock 升级死锁的真相:读锁可被多线程持有,所以"等所有读释放"在多读者场景下永远等不完。根治:要么"先释放读锁再抢写锁"两阶段加锁,要么直接用 StampedLock.tryConvertToWriteLock 原子转换。
③ 6 大追问全部作答:
| 追问 | 答案 | 章节 |
|---|---|---|
| ① ReentrantLock 强在哪 | 中断、超时、公平、多 Condition、tryLock 五大特性 | §2 |
| ② RWLock 高低位实现 | 32 位 state 拆 16+16,第一个读者优化用 firstReader | §3.1-3.2 |
| ③ StampedLock 乐观读凭什么暴涨 | 无锁路径 = 2 次 volatile read,无 CAS、无入队 | §4.3 |
| ④ 三剑客性能曲线 | 读多 StampedLock 完胜,写多 ReentrantLock 反而最快 | §5 |
| ⑤ Loom 下 synchronized 是否废 | JDK 24 解 Pin 但仍逊于三剑客功能 | §6 |
| ⑥ 不死锁不饿死可监控的代码 | try-finally + 全局锁序 + getQueueLength 埋点 | §7 |
# 8.2 三剑客设计哲学
1. "状态压缩"是并发数据结构的命脉:RWLock 一个 int 装下读+写两种状态、StampedLock 一个 long 装下版本号+模式+读计数——这种"位编码"省下了多字段同步的不一致性。回扣 36 篇 §3 "状态压缩"哲学,也呼应卷一 09 篇 ConcurrentHashMap 的 sizeCtl 多义编码、卷一 10 篇对象头 markword。这给我们的启示:并发设计中,能用一个原子变量编码的状态绝不用两个——不是为了省内存,而是为了省"原子性"。
2. "乐观并发"是高读场景的终极答案:StampedLock 的乐观读用"读 → 拷贝 → 验证"三步走代替了"加锁 → 读 → 解锁",本质是把同步代价从必然付出改为按需付出——只有真正发生写竞争时才退化为悲观读。这种思路在数据库(MVCC、乐观锁版本号)、Git(提交版本号检测冲突)、CAS(无锁原语)中无处不在。乐观并发的前提是"冲突罕见"——读多场景成立,写多场景反成累赘(§5.4)。
3. "工具不是越多越好,而是要匹配场景":synchronized 简单可靠、ReentrantLock 功能全、RWLock 读写分离、StampedLock 极致性能——没有最好的锁,只有最合适的锁。架构师的价值不是"用最炫的工具",而是"用最贴合业务读写比的工具"。这也是为什么 Java 不淘汰 synchronized——简单场景下它的简洁和稳定无可替代。
# 8.3 速查表与下一篇预告
API 速查:
ReentrantLock ReentrantReadWriteLock
─ lock() / unlock() ─ readLock() / writeLock()
─ lockInterruptibly() ─ 写降读:先持写,再获读,再释写
─ tryLock(time, unit) ─ 不支持读升写
─ newCondition() ─ 支持 Condition (写锁)
─ isFair() / getQueueLength() ─ 高 16 位读,低 16 位写
StampedLock 选型口诀
─ tryOptimisticRead() + validate() ─ 无竞争 → synchronized
─ readLock() / unlockRead() ─ 写为主 → ReentrantLock
─ writeLock() / unlockWrite() ─ 读多 ≥ 70% → RWLock
─ tryConvertToWriteLock() ─ 读多 ≥ 95% → StampedLock 乐观读
─ ⚠️ 不可重入、无 Condition ─ 多 Condition → ReentrantLock
2
3
4
5
6
7
8
9
10
11
12
13
死规矩:
1. lock 必须在 try 外,unlock 必须在 finally
2. tryLock 必须接收返回值,try 必须在 if 内
3. RWLock 永远不要"读升写",只允许"写降读"
4. StampedLock 乐观读区间内必须先拷贝字段再 validate
5. 全局锁顺序统一(按 hashCode 或 id),消除循环等待
6. 生产环境给所有锁埋监控点(getQueueLength + 报警)
2
3
4
5
6
🎯 下一篇预告:第 38 篇《CAS、Atomic、Unsafe 与 VarHandle》——本篇站在 AQS/Lock 这一层讲"用户态阻塞同步",下一篇沉到更底层的无锁原语:CAS 怎么由 CPU 指令保证原子性、Atomic 全家桶(AtomicInteger/AtomicReference/LongAdder)的实现差异、Unsafe 那些"危险但强大"的 API、JDK 9 之后为什么用 VarHandle 取代 Unsafe,以及 LongAdder 分段累加为什么比 AtomicLong 在高并发下吞吐高 10 倍。无锁编程的物理基础一次讲透。