上个月排查线上接口抖动,监控里看到某个扣减库存接口在高峰期RT突然从几十毫秒跳到三秒。一开始怀疑SQL索引失效,把explain翻来覆去地看都正常;后来抓了一把线程dump才发现,几十个线程全部卡在同一个 synchronized 代码块上。那一刻我意识到,很多人对 synchronized 的理解还停留在“给方法加个锁就安全了”,但真正决定并发上限的,是它背后那套锁策略。这篇文章就把锁策略与 synchronized 放到一起拆开讲:从策略分类、JVM锁升级、显式锁选型,到实战中的锁粒度设计和死锁排查,一次说透。
1. 锁策略的底层逻辑:为什么不能只盯着 synchronized 的“互斥”
1.1 锁的语义从来不只是“锁住”
很多人刚接触并发时,会认为锁就是保证同一时刻只有一个线程能进入代码块。这个理解没错,但太浅了。synchronized 保护的临界区,除了互斥,还承担着内存可见性和happens-before语义。也就是说,线程释放锁之前所做的全部写入,对后续获取同一把锁的线程都是可见的。这个性质在很多并发 bug 里比互斥还要重要。
锁策略真正要回答的问题,不是“要不要加锁”,而是“在多高的竞争强度下,用什么样的代价换来互斥与可见性”。JVM 里同一把锁可能从偏向锁一路升级到重量级锁,每次升级都会带来不同的性能开销;ReentrantLock 内部虽然不走这条路,但也有自己的队列与唤醒机制。锁策略就是在这些机制里做取舍:愿不愿让线程阻塞、允不允许插队、能不能重入、锁范围定多大。这些决策最终反映为系统的吞吐量、延迟和扩展性,而不是简单地在代码里加一个 synchronized 关键字。
从实际经验看,没有最好的锁,只有最合适的锁策略。一个接口的并发瓶颈可能不在算法本身,而在于锁的竞争方式。后面会拿一个真实案例展开,这里必须先建立概念。
1.2 四大锁策略十字路口:悲观/乐观、公平/非公平、可重入、锁粒度
在选锁策略时,我习惯先拆成四个维度逐一判断。
悲观锁 vs 乐观锁
synchronized 是典型的悲观锁策略:它假定冲突随时发生,所以直接给临界区上锁,让对方进不来。乐观锁的代表是 java.util.concurrent.atomic 包下的 CAS 操作、LongAdder、ConcurrentHashMap 的很多内部实现。乐观锁假设冲突很少,先执行操作,失败再重试。
实际项目里,这两个不是二选一,而是可以组合的。比如 ConcurrentHashMap 的 put 方法:它先用 Node 的 CAS 尝试插入,如果发现 hash 冲突或桶位已经被占用,再用 synchronized 锁住桶里的首节点继续操作。这正是“乐观优先,悲观兜底”的典型用法。
公平锁 vs 非公平锁
公平锁意味着线程按发起请求的顺序获得锁,先到先得;非公平锁允许新来的线程尝试插队。 synchronized 是非公平的,ReentrantLock 默认也非公平,但构造时可以传入 true 切换为公平。
很多团队谈“公平”就觉得是正义,但在并发场景下,公平往往是有代价的。公平锁需要维护严格的等待队列,线程唤醒时上下文切换更频繁,吞吐量通常会低于非公平锁。非公平锁虽然可能让某些线程“饿死”,但在绝大多数业务场景下,它换来的是更高吞吐和更低的平均延迟。如果业务没有强需求,不建议为了追求公平而牺牲性能。
可重入
synchronized 和 ReentrantLock 都是可重入锁。所谓可重入,就是一个线程已经持有锁后,可以再次获取同一把锁。比如一个 synchronized 方法调用了同类中另一个 synchronized 方法,不会把自己锁死。这个特性在使用继承、回调、递归时尤其重要。
java复制public class Demo {
public synchronized void outer() {
System.out.println("outer");
inner(); // 同一线程再次进入 synchronized 方法
}
public synchronized void inner() {
System.out.println("inner");
}
}
如果锁不可重入,outer() 调用 inner() 时就会死锁。JVM 在实现可重入时,会给每个锁关联一个持有线程和一个计数器:同一线程再次获取时计数器加一,释放时减一,直到计数归零才真正释放。这也是 synchronized 底层优雅的地方。
锁粒度
锁粒度是四个维度里最容易操作又最容易翻车的。方法级锁锁住整个方法,简单粗暴但临界区过大;代码块锁只锁共享资源的最短操作,吞吐更好但容易引入复杂逻辑。更细的粒度还包括锁分离和分段锁,比如 ConcurrentHashMap 把桶数组作为资源,LinkedBlockingQueue 用两个不同锁分别控制入队和出队,都是为了降低竞争。
注意:锁粒度不是越细越好。过度拆分锁会引入额外的同步复杂度、死锁风险和维护成本。我见过一个系统把单锁拆成 16 个锁,结果某次业务变更导致两个锁顺序不一致,线上直接死锁。粒度选择必须与业务模型匹配,而不是做并发度的数字游戏。
1.3 策略不是越多越好:锁对象的选择其实是性能和时延权衡
用 synchronized 时,锁对象怎么选往往被忽视,但它本身就是一种锁策略。synchronized 方法锁住 this,静态方法锁住 Class 对象,代码块可以显式指定任意对象。很多人直接锁 this,如果有多个独立不相关的资源需要分别保护,它们会共享一把锁,带来无谓的互相阻塞。
更隐蔽的问题出现在锁对象为 Integer、String 或 Boolean 时。Integer 有对象缓存池,-128 到 127 之间返回的是同一个 Integer 对象;如果两个线程用相同数值的 Integer 当锁,实际上用的可能是同一个锁。同样的坑在 String 字面量上更常见,字符串常量池会让看似不同的变量引用同一个底层对象。锁对象最好是独立的专用对象实例,或 static final Object,并且有清晰的生命周期。
java复制// 不推荐:使用 Integer 作为锁对象,存在缓存复用风险
private final Integer lock = 1;
public void wrong() {
synchronized (lock) {
// ...
}
}
// 推荐:使用独立的 Object 实例
private final Object lock = new Object();
public void right() {
synchronized (lock) {
// ...
}
}
锁对象的选择还涉及锁的存活时间和对象头状态。JVM 在锁升级时需要操作对象头里的 Mark Word,如果锁对象被频繁创建或回收,偏向锁的撤销和重偏向也会消耗性能。这是我的切身体会:写框架代码时,定义一个专用的 static final Object 会让锁的生命周期与类绑定,比每次 new 一个锁对象稳定得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized 的演进史:从重量级锁到锁升级的真正含义
2.1 JVM 锁升级机制:偏向锁→轻量级锁→重量级锁
早些年 synchronized 被诟病性能差,是因为早期 JDK 里它直接使用操作系统的互斥量(mutex)实现。线程进入锁要申请内核资源,阻塞和唤醒都涉及用户态与内核态的切换,开销很大。JDK 6 以后,HotSpot 对 synchronized 做了一系列优化,才有了我们今天看到的锁升级机制。
在 HotSpot 中,每个 Java 对象头部都有一个 Mark Word,存放对象哈希、GC 分代年龄以及锁状态。锁状态就保存在 Mark Word 的最后几位里。升级过程大致是:
- 无锁状态:对象没有被线程锁定。
- 偏向锁:如果只有一个线程反复进入同步块,JVM 会把线程 ID 记录到 Mark Word。后面该线程再次进入时,不再需要 CAS 操作,直接进入即可。这避免了不必要的原子操作开销。
- 轻量级锁:一旦有第二个线程参与竞争,偏向锁会被撤销。JVM 在栈帧中创建 Lock Record,通过 CAS 把 Mark Word 替换成指向 Lock Record 的指针。如果 CAS 成功,说明获得轻量级锁;失败则说明存在竞争。
- 重量级锁:如果竞争进一步加剧,轻量级锁会膨胀为重量级锁。此时线程真正进入阻塞状态,等待操作系统的 mutex。
这个升级路线意味着锁竞争程度不同,代价也不同。单线程轮询或低竞争时,偏向锁和轻量级锁都能避免内核态切换;只有高竞争时才会阻塞。
常见误解是锁只能升级不能降级。实际上,HotSpot 存在批量重偏向和批量撤销机制,锁状态并不是严格单向的,只是在大多数业务代码里,重量级锁不会自动降级。理解这一点,排查问题时就不会得出“JVM 把锁降级了所以不阻塞”这样错误的结论。
2.2 锁消除与锁粗化:编译器层面的锁策略
锁升级是 JVM 运行时层面的优化,锁消除和锁粗化则发生在 JIT 编译阶段。它们都属于锁策略的一部分。
锁消除依赖逃逸分析。如果 JVM 判断一个锁对象只在当前线程的局部作用域内使用,不存在逃逸到其他线程的可能,它就会直接把这个锁消除掉。经典例子是局部变量拼接字符串:
java复制private static String concat(String a, String b) {
// StringBuilder 是局部对象,JIT 可能消除 append 方法里的锁
return new StringBuilder(a).append(b).toString();
}
StringBuffer 的 append 是 synchronized 方法,但这里的 StringBuffer 如果被逃逸分析判定为不逃逸,JVM 就会去掉锁。这也是为什么不要迷信“用 StringBuffer 就更安全”的原因,在单线程局部场景下,编译器早就帮你把锁优化没了。
锁粗化则相反。当 JVM 发现相邻多个同步块锁的是同一对象时,可能把它们合并成一个更大的同步块。比如循环里反复加锁:
java复制for (int i = 0; i < 10000; i++) {
synchronized (lock) {
sum += i;
}
}
JVM 可能把锁粗化为整个循环。这虽然减少了加锁次数,但也意味着持锁时间变长。所以在手写代码时,仍然建议自己控制锁范围,不要依赖编译器兜底。可以用 -XX:+EliminateLocks 和 -XX:+DoEscapeAnalysis 控制相关优化,JDK 8 默认开启,但理解原理比背参数更值钱。
2.3 偏向锁虽好但也有坑:为什么要关闭偏向锁
偏向锁设计的初衷是优化“只有一个线程访问同步块”的场景。比如 ArrayList 在单线程迭代时,它的 it 相关锁如果存在,偏向锁能让后续访问几乎无开销。但偏向锁撤销的成本并不低,它需要等待一个安全点,再通过 CAS 修改 Mark Word。一旦应用存在大量多线程竞争,偏向锁不断被“勾起”再撤销,反而拖慢整体性能。
这也是为什么 JDK 15 默认抛弃了偏向锁,JDK 18 直接禁止了偏向锁。我们维护的老系统在 JDK 8 上跑,初期不管怎么调参,锁竞争总没那么理想;后来压测时用 -XX:-UseBiasedLocking 关掉偏向锁,相同流量下吞吐量反而涨了一截。原因就是业务接口几乎都在高并发下运行,偏向锁的撤销开销大于它带来的收益。
经验:如果你对线上流量有信心,确认临界区必然被多线程争抢,可以在启动参数中关闭偏向锁,减少安全点停顿和撤销开销。但如果你有很多单线程短临界区,偏向锁仍值得保留。这个选择一定基于压测,不要拍脑袋。
3. synchronized 和显式锁的选型博弈:什么场景用什么策略
3.1 ReentrantLock 与 synchronized 的策略差异
ReentrantLock 是 JDK 提供的显式锁,提供比 synchronized 更丰富的策略。两者对比大致如下:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 语法 | 关键字,自动加锁/释放 | API,需要 try-finally 手动释放 |
| 可重入 | 支持 | 支持 |
| 非公平 | 默认 | 默认 |
| 公平 | 不支持 | 构造参数支持 |
| 中断等待 | 不支持 | lockInterruptibly() 支持 |
| 超时获取 | 不支持 | tryLock(timeout) 支持 |
| 多条件变量 | 只能 wait/notify | newCondition() 多个条件队列 |
| JVM 优化 | 锁升级、锁消除、锁粗化 | 没有偏向锁,但支持锁自旋和队列优化 |
Java 6 之后,synchronized 和 ReentrantLock 在非竞争场景下的性能差距已经非常小,选择显式锁的主要理由并不是性能,而是功能策略。
如果你的业务出现以下需求,建议考虑 ReentrantLock:
- 需要等待锁可被中断,避免一个线程在锁前无限期阻塞;
- 需要设置获取锁的超时时间,避免慢任务拖垮线程;
- 需要多个条件队列,比如生产者消费者模型里区分“队列满”和“队列空”两种等待条件。
tryLock 是死锁克星。在高并发跨多个锁的操作里,可以用带超时的 tryLock 替代无限期等待:
java复制Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// 业务操作
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
这段代码虽然笨重,但至少不会永久死锁。每次获取锁都设置超时,哪怕其中一个线程获取失败,也能及时释放已经持有的锁,让系统自愈。
3.2 读多写少场景:JUC 并发容器与分段锁策略
如果说 synchronized 是“一把锁管全部”的简单策略,那么 JUC 包里的并发容器展示了更精细的锁策略组合。
ConcurrentHashMap 在 JDK 7 里采用分段锁,整个 Map 分成多个 Segment,每个 Segment 是一把可重入锁,不同段之间可以并行写;JDK 8 改为 Node 数组 + CAS + synchronized(桶首节点头)。它的读取操作在很多情况下不加锁,通过 volatile 语义保证可见性,写操作只锁对应桶,而不是锁整张表。这是“锁粒度细化”的教科书实现。
CopyOnWriteArrayList 的策略完全换了一个思路:每次写操作都复制一份底层数组,在副本上修改后发布引用。读操作永远不需要锁,因此读多写少场景下性能极佳,代价是写操作需要复制数组,内存和 GC 开销大。
ReadWriteLock 和 StampedLock 则是把锁策略进一步拆成读锁/写锁两种模式:共享读锁可以多个线程同时持有,写锁则独占。StampedLock 还支持乐观读,适合读多写少且读取数据一致性要求稍弱的场景。
当业务条件满足“读远多于写”时,我总是优先考虑这些容器,而不是抱着 synchronized 硬撑。选择本身就是一种锁策略:用合适的并发容器,比把所有访问都塞进同步块高效得多。
3.3 选型清单:从业务特征倒推锁策略
经过几个项目反复踩坑,我总结出一份锁策略选型清单。每次做并发设计前过一遍,能少走很多弯路。
一是竞争强度。低竞争时优先用 CAS 或 synchronized,高竞争时再看能否拆分锁。二是失败成本。如果临界区冲突后可以安全重试,乐观锁更合适;如果重试成本极高,直接用悲观锁。三是超时容忍度。接受失败后超时重试,就选 ReentrantLock 的 tryLock;不能容忍超时,才考虑 synchronized。四是公平性诉求。不需要严格公平,就别开公平锁。五是并发访问模式。读多写少时考虑 ReadWriteLock、CopyOnWrite 容器;写多读多时考虑 ConcurrentHashMap 配合 synchronized 局部锁。
还有一条铁律:能只锁共享数据,就不要锁整个方法;能用不可变对象,就不要上锁。有的场景把变量改成 volatile 或使用 AtomicLong 就可以解决,完全不需要重锁。
4. 实战调优与死锁排查:锁策略落在代码里的样子
4.1 一个高并发下单扣库存的锁粒度设计案例
回到开头那个扣库存接口。最初的实现非常直接:
java复制public synchronized boolean deductStock(Long skuId, int quantity) {
Stock stock = stockMapper.selectBySkuId(skuId);
if (stock.getAvailable() < quantity) {
return false;
}
stock.setAvailable(stock.getAvailable() - quantity);
stockMapper.updateById(stock);
return true;
}
这段代码看起来没毛病,但它把 所有 SKU 的扣减串行化了。假设系统压力主要在 A、B 两个热门商品上,即便它们毫无数据关系,也会因为同一个 this 锁互相等待。更糟的是,里面还包含数据库查询和更新,持锁时间被拉长,问题彻底被放大。
改进思路是锁粒度缩小到资源维度。对单个 JVM 内的库存服务,可以用 SKU ID 作为维度加锁。最简单的方式是维护一个锁池:
java复制private static final ConcurrentHashMap<Long, Object> LOCKS = new ConcurrentHashMap<>();
public boolean deductStock(Long skuId, int quantity) {
// 每个 skuId 对应一个锁对象,提高并发度
Object lock = LOCKS.computeIfAbsent(skuId, key -> new Object());
synchronized (lock) {
// 只锁当前 sku 的库存操作
Stock stock = stockMapper.selectBySkuId(skuId);
if (stock.getAvailable() < quantity) {
return false;
}
stock.setAvailable(stock.getAvailable() - quantity);
stockMapper.updateById(stock);
return true;
}
}
这样一来,不同 SKU 的操作可以并发执行,只有同一 SKU 的扣减才会排队。压测结果立竿见影:吞吐量从几百 QPS 涨到两千多,RT 高峰也明显回落。当然,这个方案在纯内存场景没问题,但涉及数据库时仍然要小心事务边界,锁的范围和数据库事务何时提交、释放都要对齐,否则会出现锁已释放但事务还未提交的窗口问题。
注意:上面锁池里的
LOCKS会随着新 SKU 加入而不断增长。生产环境要加清理策略,否则会变成另一份内存泄漏。简单做法是记录最近访问时间,定期淘汰长时间未访问的 skuId 对应的锁对象。
4.2 锁竞争居高不下:从 jstack 到火焰图的分析思路
线上最痛苦的事不是没有锁,而是不知道锁在哪里被抢。
第一步看线程 dump。jstack <pid> 后,关注 java.lang.Thread.State: BLOCKED 的线程,它们会停在 - locked <0x...>(已持有锁)或 - waiting to lock <0x...>(等待锁)。如果看到大量线程阻塞在同一个对象地址上,基本可以锁定竞争热点。
text复制"http-thread-12" #12 prio=5 os_prio=0 cpu=12.34ms elapsed=100.2s tid=0x00007f0c1c0ac800 nid=0x2b3c waiting for monitor entry [0x00007f0bfeff9000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.service.StockService.deductStock(StockService.java:45)
- waiting to lock <0x00000007b18c5a30> (a java.lang.Object)
第二步看火焰图。用 async-profiler 采集锁事件,命令大致是:
bash复制./profiler.sh -e wall -d 60 -o flamegraph <pid>
锁竞争严重时,火焰图上会有一个又宽又平的“锁塔”,一眼就能找到热点方法。-e wall 模式按时间采样,能看到线程在等待什么资源,比单纯看 CPU 火焰图更精准。
定位到热点后,常见的锁策略调整方式有:
- 缩短持锁时间:把耗时的远程调用、数据库操作移出同步块;只有在必要时刻才持锁。
- 减小锁粒度:能用代码块不用方法,能用不同锁保护不同资源就不用同一把锁。
- 乐观化:如果操作是可重试的,先尝试 CAS 或版本号,失败再退化成
synchronized。 - 读写分离:多读少写时切换为
ReadWriteLock或ConcurrentHashMap。
从我的经验来看,大部分锁竞争问题不是单靠换一种锁解决的,而是靠减少持锁时间。很多团队为了优化锁性能去换成 ReentrantLock,结果锁依然被持有了 500 毫秒,换什么都没用。
4.3 可重入与锁顺序:避免死锁的约定
死锁是锁策略设计里最隐蔽的坑。两个线程各自持有一把锁,又同时等待对方的锁,就会永久阻塞。经典场景是账户转账:
java复制synchronized (fromAccount) {
synchronized (toAccount) {
// 转账
}
}
两个线程互相转入转出时,就可能因为获取锁的顺序不一致而死锁。避免死锁最常用的约定是全局固定加锁顺序。比如统一按账户 id 升序获取锁,保证所有线程都先去锁 id 小的账户,再去锁 id 大的账户。
如果代码中已经用了多个锁,还有一个简单技巧:用 ReentrantLock 的 tryLock(1, TimeUnit.SECONDS) 替代 synchronized 锁嵌套,超时后主动释放持有锁并记录告警。这牺牲了一点可读性,但能把无法自愈的死锁变成可感知、可恢复的异常。
此外,尽量缩小同步块也能降低死锁概率。把无关操作移出锁外,把多个锁合并成一个业务实体锁,都是常见的做法。我见过一个系统在锁内调了另一个服务的接口,导致锁被跨线程持有,引发连环阻塞,这就是锁范围过大的典型。
最后,可以在开发阶段引入静态检查工具(如 SpotBugs、Error Prone)辅助识别明显的不安全模式。它们能发现一部分锁顺序问题,但核心还是代码评审时对锁策略的推敲。
最后分享一点个人体会:锁策略没有银弹,synchronized 也不是万年后退的旧东西。JVM 为了它做了大量优化,它在很多场景下依然是最简单、最不容易出错的选择。真正的核心是永远先搞清楚“并发度有多高、资源是否可拆分、能否缩短持锁时间”,再去决定用哪把锁。写出并发代码之后,记得用压测和线程 dump 验证,而不是凭直觉迷信某一种锁策略。
