Java 锁机制(终极工程版):别把锁当安全感,把它当成本与治理对象
并发里最危险的不是没加锁,而是:
加了锁,却不知道它会让系统慢到哪里、卡在哪里、崩在哪里。所以本文不背概念,而围绕三个工程问题展开:
锁解决什么问题?代价是什么?如何取舍?
并补齐两件常被忽略的能力:可观测性与可恢复性。
锁的本质是一种“资源排队机制”
你可以把锁理解成系统里的“收费站”:
-
解决的问题:避免车辆撞车(共享数据一致性)
-
付出的代价:排队等待(吞吐下降、延迟上升)
-
工程目标:收费站要少、要短、要可控(最小化临界区、减少竞争、可降级)
一句话总结:
锁不是为了让代码正确,而是为了让系统在并发下“可控地正确”。
1. 锁解决什么问题:共享可变状态的一致性
并发 bug 的根源从来不是“多线程很难”,而是两件事叠加:
-
共享(Shared):多个线程访问同一份数据
-
可变(Mutable):这份数据会被修改
最经典的例子:库存扣减/抢票超卖。
为什么会超卖?因为扣库存不是一步:
扣库存 = 读(stock) → 判断(stock>0) → 写(stock-1)
这是一个“复合操作”,天然不是原子性的。
所以锁的意义就是把这段复合操作包成一个整体:
把临界区串行化:同一时刻只有一个线程能做读改写。
2. 锁的代价:性能、延迟、可维护性三杀
2.1 性能成本:阻塞/唤醒 + 上下文切换
线程阻塞与唤醒需要 OS 介入,CPU 状态切换也会造成 cache 失效。
很多时候临界区只有几行代码,但阻塞/唤醒的代价可能更大——这就是自旋锁出现的原因。
2.2 延迟成本:长尾比平均更致命
锁竞争越激烈,排队越长,P99 延迟会急剧恶化。线上很多事故不是平均变慢,而是少量请求卡死导致雪崩。
2.3 可维护性成本:排障难、治理难
锁引发的问题往往不是“报错”,而是“卡住”。卡住意味着:
-
没异常
-
CPU 不高
-
线程池却满了
-
RT 爆炸
这类问题如果缺乏可观测性,排查会非常痛苦。
所以我一直强调:
锁不只是代码结构,它是一个需要治理的系统组件。
3. 乐观锁 vs 悲观锁:本质是“冲突概率假设”
别把乐观/悲观当哲学,它们只是两种成本模型:
3.1 悲观锁(synchronized / Lock)
-
假设:一定会冲突
-
策略:先加锁再操作
-
成本:排队阻塞(线程等待)
适合:
-
写多
-
冲突高
-
强一致
典型:库存、余额、订单状态。
3.2 乐观锁(CAS / Atomic)
-
假设:大概率不冲突
-
策略:提交时 CAS 校验
-
成本:冲突时自旋重试(耗 CPU)
适合:
-
读多写少
-
冲突低
-
可重试
典型:计数器、统计指标。
悲观锁:用等待换正确
乐观锁:用重试换正确
4. 自旋锁 vs 阻塞锁:用 CPU 换延迟
自旋锁本质是:
我先不睡,盯着锁看一会儿,万一马上释放就赚了。
适用边界很明确:
-
临界区很短:自旋划算
-
临界区很长(IO/RPC/DB):自旋浪费 CPU
工程建议:
锁内出现 IO/RPC/DB,就不要幻想自旋能救你。
5. JVM 锁升级:无锁→偏向→轻量→重量(只记工程含义)
很多文章讲对象头、Mark Word,我建议你在工程上只记一句话:
synchronized 并不等于重量级锁,JVM 会根据竞争程度自动升级。
-
偏向锁:几乎单线程
-
轻量级锁:少量竞争,CAS 自旋
-
重量级锁:竞争激烈,阻塞等待
所以不要一上来就“恐惧 synchronized”,真正让系统慢的永远是:
锁竞争 + 临界区过大
6. 代码示例:库存扣减(含超时降级 + 可观测性)
这是更接近生产的写法:既保证正确性,又能自我保护。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class StockService {
private int stock = 10;
// 非公平锁:吞吐更高(秒杀更常用)
private final Lock lock = new ReentrantLock(false);
public boolean buy(String userId) throws InterruptedException {
long start = System.currentTimeMillis();
// 可恢复性:超时失败,避免线程堆积
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) {
System.out.printf("buy fail(user=%s): lock timeout%n", userId);
return false;
}
try {
// 临界区最小化:只保护共享变量读改写
if (stock <= 0) return false;
stock--;
return true;
} finally {
lock.unlock();
long cost = System.currentTimeMillis() - start;
// 可观测性:记录锁持有耗时(线上排障很关键)
if (cost > 20) {
System.out.printf("buy slow(user=%s): lockCost=%dms%n", userId, cost);
}
}
}
}
这段代码体现了我认为锁必须具备的两种能力:
-
可恢复性:tryLock 超时,失败快速返回
-
可观测性:记录锁耗时,定位慢在哪里
7. synchronized vs ReentrantLock:场景化对比(不是背答案)
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 可重入 | 支持 | 支持 |
| 可中断 | 不支持(等待锁不可中断) | 支持 lockInterruptibly |
| 超时 | 不支持 | 支持 tryLock(timeout) |
| 公平性 | 非公平 | 可公平/非公平 |
| 可观测性 | 主要靠 jstack | 可查询队列/持锁状态 |
| 使用成本 | 语法简单,自动释放 | 必须手动 unlock(易忘) |
怎么选?给你一个工程口诀:
-
临界区短、逻辑简单、无需超时 →
synchronized -
高并发竞争、需要超时/中断/公平、要治理 →
ReentrantLock
我个人更偏向的观点:
synchronized更像“语言级安全带”,ReentrantLock更像“工程级控制系统”。
8. 死锁:锁机制里最贵的坑(如何避免 + 如何排查)
8.1 死锁的典型模式:锁顺序不一致
线程 A:锁 A → 锁 B
线程 B:锁 B → 锁 A
两个线程互相等待,永远卡死。
8.2 工程化避免死锁的三板斧
-
统一加锁顺序(制度化,不靠自觉)
-
避免锁嵌套(能拆就拆)
-
超时兜底:
tryLock(timeout),拿不到就降级/回滚
这就是“可恢复性”的价值:
系统宁可失败,也不要卡死。
8.3 排查死锁:别猜,直接看线程栈
-
jstack pid | grep -A 20 deadlock -
Arthas:
thread -b(查看阻塞线程)
排查关键点:
-
谁持有锁?
-
谁在等待锁?
-
等了多久?
-
锁内在做什么?(最常见:锁内 IO/RPC)
9. 锁的正确姿势:三少两要(我的个人总结)
三少
-
少锁代码:临界区最小化(只锁共享变量读改写)
-
少锁时间:锁内禁止 IO/RPC/DB
-
少锁竞争:拆锁、分段锁、读写分离
两要
-
要可观测性:日志/监控能定位锁等待与持锁耗时
-
要可恢复性:超时、降级、失败快速返回
10. 锁选型决策树(建议收藏)
你可以按这个顺序做选择:
1)是否必须强一致?
-
否 → 优先无锁/最终一致(队列、异步、缓存)
-
是 → 继续
2)冲突概率高吗?
-
低 → CAS/Atomic/乐观锁
-
高 → 悲观锁
3)锁失败能不能接受?
-
能 → tryLock(timeout) + 降级
-
不能 → 阻塞锁(但要监控)
4)读多写少吗?
-
是 → 读写锁(ReentrantReadWriteLock)
-
否 → 互斥锁
结尾:2 条实战建议(可直接落地)
建议 1:锁粒度优化(临界区最小化 + 分段锁)
-
把锁内代码缩到“共享变量读改写”
-
大对象锁拆小(按用户/商品/订单维度)
-
热点 key 用分段锁降低竞争
收益: 吞吐上升,长尾下降,死锁概率下降。
建议 2:读写分离 + 原子类替代互斥锁
-
计数/统计:优先
LongAdder -
读多写少:
ReentrantReadWriteLock -
状态更新:CAS + 版本号解决 ABA
收益: 并发度提升,减少线程阻塞。
更多推荐




所有评论(0)