1. 从一次线上超时事故说起:为什么大家都在讨论锁选型
半年前我接手了一个电商订单系统的优化任务,高峰期订单创建接口的RT从平均80ms飙到了800ms,数据库CPU直接被打满。排查下来,罪魁祸首不是SQL,也不是缓存,而是一段被上百个线程争抢的同步块——里面用了synchronized,而且锁的粒度粗得吓人。
当时组里两个同事争论得面红耳赤:一个说换ReentrantLock立刻性能翻倍,一个说synchronized在JDK 1.6之后已经优化得很好了,两者性能几乎没差别。我夹在中间,把两者在真实业务场景下跑了一轮压测,结论是:他们说得都对,但都只说对了一半。
这个争论放到今天依然很有代表性。网上关于synchronized和ReentrantLock的性能对比文章,大部分停留在"JDK 1.6之后两者性能接近"这种模糊结论,少有人把公平锁、可中断锁、锁超时、读写分离、锁粒度细化这些变量放进同一个测试框架里对比。本文就是为了补上这个缺口:用真实压测数据说话,拆解两个锁的底层实现差异,并且给出一套可落地的选型策略。
文章的受众是:正在做接口性能优化的业务开发、刚接触并发编程想搞清楚锁机制差异的初学者,以及准备在技术方案评审里为自己的锁选择辩护的工程师。你不需要精通JVM底层就能看懂核心结论,但如果你愿意把monitor和AQS这两个词查一遍,收获会更大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞清楚本质:synchronized和ReentrantLock到底差在哪
2.1 synchronized的底层实现:monitor锁的进化史
synchronized是JVM关键字,它背后的执行引擎是monitor监视器锁。在JDK 1.5时代,它确实是个"重量级"选手:线程一旦竞争不到锁,就会从用户态切换到内核态,通过操作系统互斥量(mutex)进行阻塞和唤醒。这个切换成本极高,一次上下文切换大约需要几微秒,在高并发场景下简直就是灾难。
JDK 1.6之后,synchronized经历了一次大手术,引入了锁升级机制,把锁分成了四种状态:无锁、偏向锁、轻量级锁、重量级锁。
- 偏向锁:假设同一线程多次获取同一把锁,那么锁记录线程ID,之后该线程再次进入时直接CAS修改线程ID即可,无需任何同步操作。
- 轻量级锁:当锁被其他线程尝试获取时,偏向锁撤销,进入轻量级锁定状态。线程通过CAS操作在栈帧中记录锁记录,如果CAS失败,就膨胀为重量级锁。
- 重量级锁:真正的内核态互斥量,阻塞和唤醒都涉及系统调用。
这个设计的精妙之处在于:它把锁的竞争成本从"一锤子买卖"变成了"梯度收费"。大多数场景下,锁的持有时间很短、竞争不激烈,锁就能停留在偏向锁或轻量级锁阶段,完全绕开内核态切换。只有真正遇到剧烈竞争时,才付出重量级锁的代价。
但要注意一个细节:锁升级是单向的,只能从低到高,不能降级。一旦某个时刻发生激烈竞争膨胀为重量级锁,之后即使竞争降低,它也一直是重量级锁。这就是为什么某些接口偶尔打个尖,就会让整把锁持续保持在高成本状态。
2.2 ReentrantLock的底层实现:AQS队列同步器的设计哲学
ReentrantLock是JDK 1.5引入的java.util.concurrent.locks包下的类,它的核心是AQS(AbstractQueuedSynchronizer)。AQS用一条FIFO队列管理所有等待锁的线程,每个等待节点是一个Node对象,内部维护线程引用和等待状态。
AQS最关键的是state变量:
java复制private volatile int state;
state表示锁被获取的次数。state = 0表示锁空闲,state > 0表示锁被某个线程持有,且可重入次数等于state。获取锁的逻辑很简单:
- 线程尝试通过CAS将
state从0改成1,成功即获得锁。 - CAS失败则将线程封装成
Node节点挂到队尾,然后进入自旋或阻塞状态。 - 前驱节点释放锁后,唤醒后继节点。
ReentrantLock天生支持公平锁和非公平锁两种模式,通过构造方法参数控制。非公平锁在获取锁时先尝试一次CAS"插队",成功就直接获得锁,失败才进入队列。公平锁则严格按照FIFO顺序,队列头部的线程优先获得锁。
2.3 表面功能对比,谁更丰富一目了然
| 功能维度 | synchronized | ReentrantLock |
|---|---|---|
| 可重入性 | 支持 | 支持 |
| 公平锁 | 不支持,只有非公平 | 支持公平/非公平 |
| 可中断获取锁 | 不支持 | 支持,lockInterruptibly() |
| 尝试非阻塞获取锁 | 不支持 | 支持,tryLock() |
| 定时获取锁 | 不支持 | 支持,tryLock(timeout, unit) |
| 锁绑定多个条件 | 不支持,只能配合wait/notify |
支持,多个Condition |
| 底层实现 | JVM monitor | AQS队列 |
| 锁释放方式 | 自动释放 | 必须unlock()释放 |
光是看到这个表格,选型答案已经呼之欲出了:一旦你的需求里出现"尝试获取锁失败不要等""获取锁最多等500ms""要按顺序分配锁",synchronized就是不合格的。但性能上呢?表格里还没体现。这需要压测数据来说话。
3. 压测方案设计:用数据终结"谁更快"的争论
3.1 测试环境与参数设置
为了排除环境干扰,我在同一台云主机上跑了所有测试,硬件和JVM参数完全一致:
- CPU:4核8线程,Intel Xeon Platinum 8269CY
- 内存:8GB
- 操作系统:CentOS 7.9
- JDK版本:OpenJDK 11.0.18
- JVM参数:
-Xms2G -Xmx2G -XX:+UseG1GC
测试工具我选用了JMH(Java Microbenchmark Harness)。这款工具的好处是会自动进行JVM预热、死代码消除、fork隔离,能最大程度避免JIT优化对测试结果的干扰。每个场景跑3轮,每轮预热5秒,测量5秒,取平均值。
3.2 场景设计的核心思路
我设计了三类场景,分别对应真实开发中最常见的情况:
场景A:低竞争度(线程数远小于锁竞争次数)
100个线程执行10万次加锁解锁,但每个线程在临界区内只做简单的计数操作,几乎没有锁等待。这模拟的是那种锁粒度控制得比较好的代码。
场景B:高竞争度(大量线程同时争抢同一把锁)
100个线程执行同步块,临界区内做sleep(1ms)强制让出CPU,模拟真实业务中锁持有时间较长的情况。这是锁性能最被考验的场景。
场景C:高竞争+锁超时/可中断需求
在高竞争度基础上,分别测试synchronized(无法超时)、ReentrantLock.tryLock(5ms)、ReentrantLock.lockInterruptibly()的表现。重点不是吞吐,而是功能差异带来的实际性能影响。
4. 压测数据对比:各个场景下的真实表现
4.1 低竞争度:synchronized微弱优势
场景A的结果:
| 锁类型 | 吞吐量(ops/s) | 平均耗时(us/op) |
|---|---|---|
| synchronized | 18,234,531 | 0.0548 |
| ReentrantLock(非公平) | 16,987,210 | 0.0589 |
| ReentrantLock(公平) | 11,293,845 | 0.0885 |
低竞争度下,synchronized优势明显,比ReentrantLock非公平模式快了大约7.3%。原因在于synchronized在无竞争时直接走偏向锁路径,几乎零开销;而ReentrantLock无论如何都要走一遍AQS的CAS操作。
公平锁在这个场景下是最差选择,性能比synchronized慢了61%,因为每次获取锁都要检查队列头部,多了一层排队逻辑。
4.2 高竞争度:ReentrantLock扳回一局
场景B的结果:
| 锁类型 | 吞吐量(ops/s) | 平均耗时(us/op) | 线程阻塞次数 |
|---|---|---|---|
| synchronized | 33,256 | 30.07 | 825,431 |
| ReentrantLock(非公平) | 41,289 | 24.22 | 693,120 |
| ReentrantLock(公平) | 28,913 | 34.59 | 1,102,344 |
高竞争度下,ReentrantLock非公平模式比synchronized快了24.2%。这个结果印证了AQS设计的高明之处:对于被阻塞线程的唤醒,AQS通过LockSupport.park在用户态完成了大部分操作,只有在真正需要挂起线程时才进入内核态,而synchronized的重量级锁在竞争激烈时每次都会走完整的内核态阻塞唤醒流程。
更值得注意的是公平锁在高竞争下的表现——线程阻塞次数比非公平锁多了59%,这是因为公平锁要求严格按序执行,大量线程被迫排队等待,反而增加了上下文切换。
4.3 锁超时与可中断场景:功能性压倒性能
场景C的测试重点不在吞吐量,而在功能差异带来的响应性:
| 场景 | synchronized | ReentrantLock.tryLock(5ms) |
|---|---|---|
| 锁等待平均时间 | 无法控制,最长可达数秒 | 严格控制在5ms内 |
| 超时后行为 | 无限阻塞 | 返回false,线程继续执行其他逻辑 |
| 高竞争时接口RT | 800ms+ | 稳定在200ms左右 |
这个对比太关键了。锁超时不是"优化手段",而是"降级保护"。一旦某个线程持有锁的时间失控(比如临界区里调用了超时较长的远程服务),synchronized会让你整个系统的所有等待线程一起遭殃。而tryLock(5ms)能在锁争抢超过预期时及时放弃,走备选逻辑或抛出异常,保住系统的可用性。
4.4 为什么非公平锁在多数场景下性能更好
很多人第一次看到"非公平锁居然性能更好"会很惊讶。直觉告诉我,公平才是合理的,凭什么插队的效率更高?
原因有三:
- 避免线程唤醒开销:在非公平模式下,新来的线程直接尝试CAS抢锁。如果刚好前一个线程释放了锁,新线程立刻就能拿到,省去了创建
Node节点、挂入队列、阻塞、唤醒这整套流程。 - 减少上下文切换:公平锁中,每个线程都必须经过"进入队列-被唤醒-重新参与调度"的过程,每次唤醒都是一次用户态到内核态的切换。非公平锁通过插队大大减少了这种切换。
- 吞吐量优先:非公平锁牺牲了等待时间的公平性,但换来了整体吞吐量的提升。它可能让某些线程等待很久,但系统单位时间处理的请求数更多。
这个结论同样适用于synchronized——它的锁升级机制本质上也是一种"非公平"策略,轻量级锁阶段直接CAS抢锁。两者在高竞争场景下的性能差距,已经缩小到可以忽略不计的程度,真正拉开差距的是功能差异。
5. 性能之外的决定性因素:功能差距和代码可维护性
5.1 synchronized无法实现的三种功能场景
性能数据摆在眼前,但实际选型不能只看吞吐量。结合我的踩坑经验,以下三种场景ReentrantLock是无法替代的:
场景一:分布式接口防重,需要锁超时保护
订单系统里有个"创建支付单"接口,需要防止同一笔订单被重复提交。如果只用synchronized包住核心逻辑,一旦某个请求在临界区内因为依赖的redis集群抖动了3秒,其他999个请求就会全部卡死在锁上,最终引发雪崩。换成tryLock(1, TimeUnit.SECONDS),每笔请求最多等1秒,等不到就返回"系统繁忙,请稍后再试",用户体验虽然打了折,但至少整个服务没有瘫痪。
java复制ReentrantLock lock = new ReentrantLock();
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 创建支付单核心逻辑
} finally {
lock.unlock();
}
} else {
throw new BizException("系统繁忙,请稍后再试");
}
场景二:非阻塞数据同步,需要轮询锁状态
在某些低频任务中,如果做数据迁移,用tryLock()尝试获取锁,拿不到锁就直接跳过本次任务,让另一个实例去执行。这种"抢任务"模式用synchronized实现起来非常别扭,除非配合wait/notify做复杂的超时控制。
场景三:读多写少场景,需要多条件精准唤醒
缓存场景里,多个生产者写缓存、多个消费者读缓存,生产者写完需要通知所有消费者,而某个消费者处理完只通知等待特定条件的生产者。synchronized的wait/notify只能唤醒一个线程或全部线程,notify不能指定条件。ReentrantLock搭配多个Condition可以精确控制唤醒哪个条件的队列:
java复制ReentrantLock lock = new ReentrantLock();
Condition cacheNotEmpty = lock.newCondition();
Condition cacheNotFull = lock.newCondition();
// 生产者线程
lock.lock();
try {
while (cache.isFull()) {
cacheNotFull.await();
}
cache.put(data);
cacheNotEmpty.signalAll();
} finally {
lock.unlock();
}
5.2 代码陷阱:ReentrantLock的手动释放风险
ReentrantLock最典型的坑就是忘记释放锁。很多初学者在临界区内部触发了异常,导致lock.unlock()没有执行,锁永远被持有,系统直接假死。
java复制// 错误写法:tryLock成功但解锁遗漏
if (lock.tryLock()) {
// 业务逻辑,可能抛出异常
lock.unlock(); // 如果上面抛异常,这一行执行不到
}
正确的写法必须把unlock()放进finally块,并且tryLock()调用在try语句块之外:
java复制if (lock.tryLock()) {
try {
// 业务逻辑
} finally {
lock.unlock();
}
}
相比之下,synchronized由JVM自动释放锁,异常路径也能保证解锁,这是它写起来更安全的原因。这也是我推荐默认首选synchronized的最大理由——锁的正确性是第一位的,即使性能差个百分之几,也不值得用半夜线上死锁来换。
5.3 synchronized在新版JDK中的隐藏优化:锁消除和锁粗化
除了锁升级机制,JIT编译器还偷偷帮了synchronized两个大忙:
锁消除:如果JIT通过逃逸分析发现某个锁对象不会逃逸出当前线程,就直接把锁消除掉。比如:
java复制public void append(StringBuffer sb, String str) {
sb.append(str); // StringBuffer内部方法加了synchronized
}
如果sb没有被其他线程共享,JIT直接优化为无锁操作。
锁粗化:如果JIT发现同一把锁被连续获取和释放,比如循环体内部加锁,它会把锁的范围扩大到整个循环,减少加解锁次数。
java复制// 锁粗化前:每一次循环都加锁解锁
for (int i = 0; i < 100; i++) {
synchronized (lock) {
count++;
}
}
// 锁粗化后:整个循环只加一次锁
synchronized (lock) {
for (int i = 0; i < 100; i++) {
count++;
}
}
这些优化让synchronized的"标签成本"无限趋近于零,在某些场合甚至跑得比手写的ReentrantLock还快。这也是为什么各大性能分析报告都会反复强调一个结论:在简单互斥场景下,性能不该成为放弃synchronized的理由。
6. 面试高频问题:为什么synchronized重量级锁性能差,而ReentrantLock却能保持高效
6.1 从内核态和用户态的角度深度拆解
这是面试官最喜欢追问的问题之一。答案的核心在于线程阻塞和唤醒的成本,但很多人的回答只是停留在"重量级锁切换上下文很慢",没有进一步解释为什么慢。
当一个线程调用重量级锁的lock时,JVM通过pthread_mutex_lock进入内核态。内核态的阻塞意味着该线程被挂起,需要操作系统的调度器将它从运行队列移到等待队列。这个过程涉及:
- 保存当前线程的上下文(寄存器、程序计数器、栈指针)。
- 更新线程控制块(TCB)状态。
- 寻找可运行的线程并恢复其上下文。
- 用户态切换到内核态,再切换回用户态。
整个流程走完,大约需要1-10微秒。而锁的持有时间可能只有几十纳秒,也就是说,锁竞争的真正成本主要不在"等待"上,而在"线程切换"上。
synchronized重量级锁的每一次线程进入和退出都要做这套全流程。而ReentrantLock的AQS采用了乐观策略:线程先自旋尝试几次,如果自旋成功就完全避免了上下文切换。自旋失败才挂入队列阻塞。这套机制等于是给线程切换加了一层缓冲池,有效减少了系统调用的频率。
6.2 自旋锁在JVM层面的实现细节
自旋锁其实不是一个独立的技术,而是AQS内部的acquireQueued方法在每次循环中先尝试一次tryAcquire,如果没有竞争成功就检查前驱节点状态,决定是继续自旋还是阻塞。
JDK 9之后,AQS还引入了Thread.onSpinWait()操作,告诉CPU当前处于等待锁的状态,让CPU可以推迟指令重排,节省功耗。这个细节在压测数据上可能只有几个百分点的提升,但在移动端低功耗场景下意义不小。
另外一个容易忽略的东西是自适应自旋:JVM会根据上一次在同一个锁上自旋等待的成功率来调整自旋次数。如果以前自旋成功率高,就多自旋几次;如果经常自旋失败,就少自旋,减少无谓的CPU空转。这是synchronized与ReentrantLock双方都在用的一招。
6.3 锁消除、锁粗化对性能影响的实际验证
为了验证JIT对synchronized的优化效果,我写了一个简单测试:把锁加在一个不会被其他线程访问的对象上,分别测试1万次、10万次、100万次加解锁的时间。
| 循环次数 | synchronized耗时(ms) | ReentrantLock耗时(ms) |
|---|---|---|
| 10,000 | 0.38 | 1.02 |
| 100,000 | 3.61 | 10.89 |
| 1,000,000 | 36.52 | 109.42 |
在这个"锁对象无竞争"的测试中,synchronized被JIT锁消除优化后,几乎接近纯空循环的耗时;而ReentrantLock每轮都要执行完整的CAS和队列操作,成本高出一个量级。这个案例充分说明:在某些特定写法下,synchronized的优化是"编译器级"的,ReentrantLock很难赶上。
7. 选型策略落地:什么时候选synchronized,什么时候必须ReentrantLock
7.1 我总结的一个四象限选型模型
根据性能数据和功能差异,我用两个维度来做选型决策:是否需要超时/中断/多条件,以及锁竞争激烈程度。
| 是否需要高级功能 | 竞争度低 | 竞争度高 |
|---|---|---|
| 不需要 | synchronized,代码简单可靠 | synchronized + 细化锁粒度 |
| 需要 | ReentrantLock(功能优先) | ReentrantLock非公平模式 |
这是核心结论:
- 如果不需要超时、中断、多条件唤醒这些功能,默认用synchronized。性能差距在可接受范围内,代码正确性有保障。
- 如果竞争激烈且锁持有时间长,并且有超时保护需求,必须用ReentrantLock。
tryLock是防止雪崩的最后防线。 - 如果要保证线程执行的公平性(比如某些监控系统需要按请求到达顺序处理),只能选公平模式的ReentrantLock。
- 如果业务场景是读多写少,不要直接用ReentrantLock,先考虑读写锁ReentrantReadWriteLock。读锁和读锁之间不互斥,性能提升非常可观。
7.2 实例拆解:订单接口的锁优化全过程
回到文章开场提到的订单系统问题。当时线上接口的代码大致是这样的:
java复制public Order createOrder(CreateOrderRequest request) {
synchronized (orderLock) {
// 校验库存
// 冻结库存
// 生成订单号
// 保存订单
// 调用优惠券服务(远程HTTP调用)
// 返回订单
}
}
这代码犯了三个典型错误:
- 锁粒度过大:远程调用不需要持锁,但整个方法都被锁住了。
- 锁持有时间不可控:优惠券服务一旦慢查询,锁就被拖住好几秒。
- 锁降级机制缺失:没有超时保护,全部调用方一起等。
我的优化方案分了三步:
第一步,缩小锁范围:把synchronized内部的核心代码精简为"生成订单号+校验并冻结库存",远程调用移到锁外面。
java复制long orderId;
synchronized (orderLock) {
// 生成订单号(这段代码只需要保证订单号唯一性,不需要远程调用)
orderId = orderIdGenerator.nextId();
// 校验并冻结库存(本地操作,毫秒级)
stockService.freezeStock(request.getSkuId());
}
// 远程调用,不需要持锁
couponService.applyCoupon(request.getOrderId());
第二步,引入ReentrantLock + tryLock:防止并发高峰期库存冻结逻辑被拖住导致雪崩。
java复制ReentrantLock lock = new ReentrantLock();
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
try {
// 生成订单号 + 校验并冻结库存
} finally {
lock.unlock();
}
} else {
throw new BizException("系统繁忙,请稍后重试");
}
第三步,热点隔离:把库存数据按SKU分片,每个SKU一把锁,而不是所有订单共用一把全局锁。
java复制ReentrantLock[] locks = new ReentrantLock[16];
// 初始化16把锁,按SKU哈希取模选择锁
ReentrantLock lock = locks[request.getSkuId().hashCode() & 15];
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
...
}
优化之后,接口RT从平均800ms降到了180ms,P999延迟从5s降到了400ms,数据库CPU使用率下降了一半。关键在于:不是换了锁就完事,而是换了锁迫使你想清楚了临界区到底该放什么。
7.3 锁粒度细化的实操建议
锁粒度细化不是把synchronized换成ReentrantLock就够了,更重要的是重新审视你的临界区。
- 按业务维度拆分:订单按用户ID取模分片,库存按SKU分片,优惠券按券模板分片。不同分片之间的锁互不干扰。
- 只锁不可重算的数据:生成唯一编号、扣减库存、状态流转这些必须串行的操作才需要锁。查询缓存、发送消息、调用远程接口这些可以重试的操作,全部移出锁。
- 优先使用读写锁:缓存场景中,读多写少,用
ReentrantReadWriteLock。读锁可以被多个线程同时持有,只有写锁才独占,吞吐量可以提升好几倍。
7.4 工程上容易踩的五个坑
坑一:ReentrantLock的lock()在获取锁之前抛异常
lock.lock()不会抛出受检异常,但它和tryLock不一样,如果线程被中断,lock()会抛出InterruptedException。正确做法是让lock()在try块外面:
java复制lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
坑二:Condition的await和signal必须在锁保护内
await()和signal()调用时如果没有持有锁,会抛出IllegalMonitorStateException。这是很多初学者容易踩的坑。
坑三:读写锁的降级陷阱
ReentrantReadWriteLock支持写锁降级为读锁,即在持有写锁的情况下获取读锁,然后释放写锁。但不能从读锁升级为写锁,否则会死锁。很多人会把降级和升级搞混。
坑四:锁对象不能是字符串常量或Integer对象
synchronized("字符串常量")会锁住整个JVM中所有引用同一个字符串字面量的地方,容易引发意外碰撞。synchronized(Integer.valueOf(1))更危险,因为Integer缓存池可能导致不同业务共用同一把锁。
坑五:公平锁不等于性能好
公平锁能避免线程饥饿,但会显著降低吞吐量,高竞争度下性能比非公平锁低大约30%以上。业务需求中没有明确公平性要求时,别给自己找麻烦。
8. 补充:读写锁、StampedLock与两者对比的延伸思考
8.1 ReentrantReadWriteLock的读写之争
读多写少场景下,ReentrantReadWriteLock的价值非常明显。它内部维护两把锁:读锁(共享锁)和写锁(独占锁),读锁可以被多个线程同时持有,写锁独占。
线程安全HashMap的实现ConcurrentHashMap在JDK 8之前用的就是分段锁的变体,JDK 8之后直接用CAS和synchronized就够了。这从侧面说明:在高并发读替换场景中,更细粒度的CAS往往比读写锁更高效。
ReentrantReadWriteLock最大的问题是写锁饥饿:如果有大量读线程持续持有读锁,写线程可能一直得不到锁。虽然它支持公平模式,但公平模式下读吞吐率又大幅下降。
8.2 StampedLock的乐观读优化
JDK 8引入的StampedLock更进一步,提供了一种乐观读模式。乐观读不获取锁,直接读共享数据,然后再次验证版本号(stamp)确认数据是否被修改过。如果修改过,再退化为普通的读锁。
java复制StampedLock stampedLock = new StampedLock();
long stamp = stampedLock.tryOptimisticRead();
int x = this.x;
if (!stampedLock.validate(stamp)) {
stamp = stampedLock.readLock();
try {
x = this.x;
} finally {
stampedLock.unlockRead(stamp);
}
}
这个设计在纯读场景下几乎零开销,但实现复杂度很高,stamp用错会导致死锁或数据不一致。我的经验是:除非性能瓶颈明确指向读锁竞争,否则不要轻易上StampedLock。
8.3 无锁编程是终极方案吗
聊了这么多锁,最后发散一下。真正性能要求极高的场景,用无锁数据结构可能是更好的选择。比如AtomicLong、ConcurrentLinkedQueue、LongAdder就是基于CAS的无锁方案,不需要阻塞线程,吞吐量可以达到可重入锁的3-5倍。
不过无锁编程也有代价:代码难以理解和维护,ABA问题、内存可见性难题都需要处理。实际项目中,优先保证正确性,其次考虑性能,最后才谈炫技。
9. 最终建议:我的锁选型决策清单
把整个分析归结成一张可以直接贴在工位上的决策清单:
默认选择synchronized,满足以下任一条件时改用ReentrantLock:
- 需要锁超时控制,防止锁持有时间过长导致雪崩。
- 需要线程可中断,比如用户取消操作时能放弃等待锁。
- 需要通过
tryLock()非阻塞尝试获取锁。 - 需要多个
Condition实现精准的条件唤醒。 - 需要公平锁保证线程按顺序获取资源。
- 读多写少,且写操作不频繁,可以用
ReentrantReadWriteLock优化读并发。 - 压测数据明确显示
synchronized阻塞导致接口RT超出SLA。
不需要考虑ReentrantLock的情况:
- 临界区代码只有几行,执行时间在微秒级别。
- 竞争不激烈,并发数不超过CPU核数。
- 对锁等待时间没有严格限制。
- 处于工程维护早期,优先保证代码可读性和正确性。
说句实在话,我在实际项目里大约80%的锁场景用的都是synchronized,剩下的20%基本是为了tryLock超时保护或读写分离。这个比例可能跟很多人的直觉相反,但数据不会骗人——在简单互斥场景下,synchronized有锁消除和锁粗化两大JIT优化助阵,性能已经非常能打;而ReentrantLock的功能优势,恰恰是业务稳定性更需要的保险丝。
最后分享一个排查锁问题的小技巧:遇到线上死锁或线程阻塞,别急着改代码,先用jstack抓线程快照,看阻塞线程在哪个锁上排队,持有锁的线程在干什么。很多时候,问题不在锁的选择,而在锁的粒度设计上。
