Java 锁机制:synchronized、Lock 与读写锁
建立 Java 锁的分类体系,理解 Monitor、ReentrantLock、读写锁、StampedLock 及其工程选型。
一、锁分类不是互斥标签
悲观/乐观、独占/共享、公平/非公平、可重入/不可重入、自旋/阻塞描述的是不同维度。一把锁可以同时具备多个特征。
例如 ReentrantLock 是悲观、独占、可重入锁,支持公平或非公平策略;ReentrantReadWriteLock.ReadLock 属于共享读锁;StampedLock 支持写锁、悲观读和乐观读,但不可重入。
二、synchronized 的使用方式
public synchronized void instanceMethod() {}
public static synchronized void staticMethod() {}
public void block() {
synchronized (lock) {
// 临界区
}
}
对应锁对象:
- 实例同步方法锁当前实例;
- 静态同步方法锁对应
Class对象; - 同步代码块锁括号中的对象。
同步代码块在字节码层面通常使用 monitorenter 和 monitorexit;同步方法使用 ACC_SYNCHRONIZED 标志,由方法调用机制进入和退出 Monitor。
三、ObjectMonitor 的主要结构
每个 Java 对象都可关联 Monitor。竞争升级到需要阻塞管理时,可以用下图理解等待线程的组织方式:
核心概念:
- Owner:当前持有 Monitor 的线程;
- EntryList:等待进入临界区的竞争线程;
- WaitSet:调用
wait()后释放 Monitor 并等待条件的线程; notify()/notifyAll():使 WaitSet 中线程有资格重新参与锁竞争,不会让其立即执行。
wait 为什么必须在 synchronized 中使用
调用线程必须先拥有对象 Monitor,才能原子地释放锁并进入该 Monitor 的等待集合。否则会抛出 IllegalMonitorStateException。
synchronized 的内存语义
对同一 Monitor 的解锁 happens-before 后续加锁。锁既提供临界区互斥,也建立共享数据的可见性和必要有序性。
四、synchronized 的历史锁优化
经典资料常描述:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
这反映了 HotSpot 历史实现中的优化思路:无竞争时尽量减少同步成本,短竞争时使用 CAS 和自旋,竞争加剧后使用 Monitor 阻塞管理。
需要注意版本差异:偏向锁在较新 JDK 中已被禁用并移除,不应把它当成所有现代 JDK 的当前执行流程。面试时应把“规范语义”与“某个 HotSpot 版本的优化实现”分开。
自旋
锁预计很快释放时,线程可以短暂自旋,避免挂起和唤醒;竞争时间过长则会浪费 CPU。现代 JVM 会根据运行情况决定部分自旋策略,业务代码不要自行模拟复杂的 JVM 锁升级逻辑。
锁消除
JIT 通过逃逸分析确认对象不会被其他线程访问时,可以消除不必要的同步。
锁粗化
连续对同一对象频繁加锁解锁时,JIT 可能扩大锁范围,减少重复同步成本。
五、ReentrantLock
ReentrantLock 基于 AQS 构建,提供与 synchronized 类似的可重入互斥语义,并增加显式控制能力。
标准写法:
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
必须在 finally 中释放,避免异常导致锁永久不释放。
主要能力
lock():普通阻塞获取;lockInterruptibly():等待时可以响应中断;tryLock():立即尝试;tryLock(timeout):超时获取;- 公平或非公平策略;
- 一把锁可创建多个
Condition。
公平与非公平
公平锁会优先考虑等待队列中更早的线程,减少插队和饥饿;非公平锁允许新线程先尝试抢锁,通常吞吐更高,因为刚运行的线程可能直接获得锁,减少调度切换。
公平不等于严格实时顺序,也不等于性能更高。
六、Condition
Condition 把一个锁拆出多个条件等待队列:
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
await() 会把当前线程加入条件队列并完全释放锁;signal() 只是把等待节点转移到 AQS 同步队列,线程仍需重新获取锁后才能从 await() 返回。
与 wait/notify 相比:
- Object 通常只有一个 WaitSet;
- ReentrantLock 可以创建多个 Condition;
- Condition 能更精确地唤醒等待某类条件的线程。
七、ReentrantReadWriteLock
读写锁的基本规则:
读锁 + 读锁:可以并发
读锁 + 写锁:互斥
写锁 + 写锁:互斥
适合读多写少,并且读临界区足够长,值得承担锁管理成本的场景。读操作极短时,普通互斥锁或不可变快照可能更简单。
state 如何表示读写状态
经典实现中,AQS 的一个 int state 被拆成两部分:
高 16 位:共享读锁计数
低 16 位:独占写锁重入次数
每个线程自己的读锁重入次数还需要额外记录,不能只靠全局读计数判断。
锁降级
允许写线程在持有写锁时先获取读锁,再释放写锁:
写锁 → 同时持有读锁 → 释放写锁 → 保留读锁
这样可以在完成写入后继续安全读取更新结果。
不应直接从读锁升级为写锁。多个读线程同时升级会彼此等待,容易死锁。通常应释放读锁、获取写锁,并重新检查条件。
八、StampedLock
StampedLock 提供:
- 独占写锁;
- 悲观读锁;
- 乐观读 stamp。
乐观读模式:
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
正确顺序是“先复制共享字段到局部变量,再校验”。校验成功后只使用局部快照;校验失败则加悲观读锁重读。
重要限制
- 不可重入,同线程重复加同一锁可能自锁;
- 不支持 Condition;
- stamp 必须与锁模式匹配;
- 乐观读不适合包含网络调用、数据库写入等不可逆副作用;
- 写频繁时校验经常失败,收益可能消失。
如果数据可以设计为不可变对象并通过 volatile 引用整体替换,通常比 StampedLock 更简单。
九、synchronized 与 ReentrantLock
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 释放 | JVM 自动释放 | 必须 finally 手动释放 |
| 可重入 | 支持 | 支持 |
| 公平策略 | 不提供显式公平选项 | 支持公平/非公平 |
| 中断等待 | 不提供对应获取 API | lockInterruptibly() |
| 超时尝试 | 不支持 | tryLock(timeout) |
| 条件队列 | 每个对象一个 Monitor 等待集合 | 一把锁可有多个 Condition |
| 实现 | JVM Monitor 与运行时优化 | AQS |
不要简单说 ReentrantLock 一定更快。现代 JVM 对 synchronized 做了大量优化,性能取决于竞争程度、临界区和功能需求。
十、锁如何选
优先 synchronized
- 普通互斥;
- 临界区结构简单;
- 不需要超时、中断、公平和多个条件队列;
- 希望异常时自动释放锁。
选择 ReentrantLock
- 需要
tryLock、超时或中断; - 需要多个 Condition;
- 需要显式公平策略;
- 需要锁状态监控接口。
选择读写锁或 StampedLock
- 读多写少,并已通过基准测试证明读并发收益明显;
- StampedLock 仅用于低写冲突、短读取、能正确重试的场景。
优先考虑无共享设计
锁不是唯一方案。不可变对象、线程封闭、分片、消息队列串行化、并发容器和原子类往往能减少锁复杂度。
十一、常见锁问题
死锁
四个必要条件:互斥、请求并持有、不可剥夺、循环等待。治理方法包括固定加锁顺序、缩短临界区、避免嵌套锁和使用超时获取。
活锁
线程不断响应彼此、状态一直变化,却没有实际进展。随机退避可以缓解部分活锁。
饥饿
某线程长期得不到 CPU 或锁。公平策略可以缓解,但会牺牲一定吞吐。
锁内慢操作
不要在锁中执行不可控远程调用、慢 SQL、文件 I/O 或大计算。它们会把外部延迟放大为所有竞争线程的等待。
十二、高频面试问题
- synchronized 锁实例方法和静态方法有什么区别?
- Monitor 的 Owner、EntryList、WaitSet 分别是什么?
- notify 后线程为什么不能立即执行?
- synchronized 与 ReentrantLock 如何选择?
- 公平锁为什么吞吐通常更低?
- Condition await/signal 的流程是什么?
- 读写锁为什么不支持直接升级?
- StampedLock 乐观读为什么必须先读再 validate?
- 偏向锁是否仍存在于所有现代 JDK?
- 如何排查 Java 死锁?
评论与讨论
回复 :
留下你的想法