同一份代码,同一台机器,同样的流量。上游接口从 12ms 涨到 40ms 时,我们那个用了一年半没动过的 synchronized 方法,QPS 从 800 掉到 200。那段代码里,除了一个 if 分支,什么都没改。
排查到凌晨,原因不是"业务变复杂了",而是那个 synchronized 块里不小心调了一个可能阻塞的远程调用。锁膨胀了,monitor 进到内核态,后面的线程排进 AQS 队列睡觉。这事儿像常识,但没看过 Mark Word,你不知道"膨胀"有多贵。
这篇不讲"如何学习 Java 并发",直接掀开锁的头皮:它到底是怎么长在你对象头上的。
一、你以为的锁,其实是一段内存
Java 里最反直觉的一点:synchronized 不是一把"锁对象",它就是被锁对象本身:
public void transfer(Account from, Account to) {
synchronized (from) {
from.debit(100);
to.credit(100);
}
}
加锁的是 from 这个对象本身,锁信息存在它的对象头里。JVM 把对象头拆成两段(64 位 HotSpot、压缩指针开启时):Mark Word(64 位,塞着哈希码、GC 年龄、以及锁状态)和 Klass Pointer(指向类元数据)。
Mark Word 是块"公共停车位":没被锁住时放 GC 年龄和哈希码;一旦有人 monitorenter,这块 64 位就被"霸占",改写成锁状态编码。所以"加锁"在 JVM 里其实是"改写对象头的一个 bit 位"。这操作快到只有几纳秒——直到它不成立。
二、四个阶段:一把锁的四张面孔
HotSpot 的锁有四个状态,Mark Word 随竞争强度单向升级(只升不降,全文最关键的一句):
- 无锁:Mark Word 记录哈希码和 GC 年龄,对象就是个普通对象。
- 偏向锁:只有一个线程访问过,JVM 在 Mark Word 里记下它,同线程再进入连 CAS 都不用。JDK 15 起默认禁用,JDK 18 彻底移除。
- 轻量级锁:有竞争了。在线程栈上开空间放锁记录,用一次 CAS 把栈上记录和 Mark Word 换过来,谁抢到谁进临界区。
- 重量级锁:线程一多,CAS 反复失败还自旋烧 CPU,JVM 干脆申请一把 OS 互斥量(monitor),把线程挂起进内核排队。
膨胀的本质是:从"用户态自旋 + CAS"退化成"内核态阻塞 + 唤醒"。前者纳秒级,后者动辄微秒到毫秒,中间差着一到两个数量级。
三、致命之处:升级是不可逆的
private final Object lock = new Object();
public void handle(Request req) {
synchronized (lock) {
cache.put(req.getKey(), req.getValue()); // 偶尔很慢
}
synchronized (lock) { // 第二次加锁
metrics.record(req);
}
}
看起来是两次独立加锁。第一次进来时没竞争,还是轻量级锁;但只要有一次并发把锁撑到重量级,这个 lock 对象余生都保持重量级。
这解释了一个经典现场:"压测好好的,跑半小时突然变慢"。膨胀只需一次,偏偏发生在最不巧的那次。而且膨胀是按对象的:同一对象上一百个方法,加锁的只有一个,膨胀的却是整个对象。第一原则因此是:让锁对象尽可能私有、尽可能短命。别用全局 lock 保护整个类的方法。
四、别再手搓 ConcurrentHashMap 的分段锁了
JDK 7 用 Segment 分段锁,16 段各一把锁,吞吐上来了,但数量写死、扩容复杂。JDK 8 推翻重写:
// JDK 8 的核心思路
final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i;
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 空桶:CAS 插入,无需加锁
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
break;
} else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 扩容中,帮着搬
else if (...) {
synchronized (f) { // 桶头加锁
// double check 后写入
}
}
}
}
三个关键设计:空桶走 CAS 不加锁,绝大多数写入不需要锁;桶头加锁,粒度从 16 段缩到单个桶;桶内链表超 8 且表长不小于 64 时转红黑树,防止哈希碰撞把查找退化成 O(n)——HashMap 被"碰撞 DoS"这事在真实世界里真发生过。
有意思的是,JDK 8 用的还是 synchronized 而不是 ReentrantLock:JVM 能对它做锁消除、锁粗化,也不会因为忘了 unlock 泄漏线程。能用语言原语就别自己造轮子。
五、AQS:所有 Lock 的骨架
理解了 AQS,前面那些锁就串起来了。ReentrantLock、Semaphore、CountDownLatch 全基于同一个 AbstractQueuedSynchronizer:
// 1. 一个 volatile int state 表示同步状态
// ReentrantLock: 0=未加锁, n=重入 n 次
// Semaphore: 剩余许可数
// CountDownLatch: 剩余计数
// 2. 一个 CLH 变体双向队列,节点是等待的线程
// 3. 三步模板
protected boolean tryAcquire(int arg) { ... } // 尝试获取
protected boolean tryRelease(int arg) { ... } // 尝试释放
protected void tryAcquireShared(int arg) { ... } // 共享模式
acquire() 的流程:先 tryAcquire,成功就完事(这就是非公平锁——锁刚释放的瞬间,排队的老哥不一定抢得到,新来的反而可能插队);失败则包装成 Node 入队,在 acquireQueued 里反复尝试,抢不到就 park 挂起,被唤醒再试。全程用 LockSupport.park/unpark,比 wait/notify 那个"必须配对"的坑少得多。
公平锁就是在第一步加了个 hasQueuedPredecessors() 判断,有人在排队你就老实去排。非公平锁不加——那一下"插队"往往能让刚释放锁的 CPU 缓存保持热。公平锁是个道德模范,不是性能冠军。大多数业务场景下,非公平锁才是正确选择。
六、线上出问题,怎么查
先分清:BLOCKED 在等 monitor(即 synchronized),WAITING 在等 Condition。排查第一步永远是 jstack:
jstack -l <pid> > /tmp/jstack.log
# 看 BLOCKED 的线程卡在哪个方法的 monitor
grep -A 20 "BLOCKED" /tmp/jstack.log | head -60
# 更直接的:jcmd 打印线程信息
jcmd <pid> Thread.print -l
# 找死锁:jstack 自己会报
Found one Java-level deadlock:
...
第二步,怀疑膨胀就用 jol 看对象头锁状态位——重量级锁是 0x02,被 hash code 覆盖就是彻底膨胀。第三步最容易漏:去代码里找"膨胀的那一刻"。膨胀需要并发,只在峰值流量下才触发。凌晨三点没人的时候,你的服务健康得跟个 baby 一样。
七、几个能立刻用的结论
1. 锁对象私有化。能用方法内的局部对象就别用成员变量,局部对象每次都是新的,天然不共享。
2. 临界区里不做可能阻塞的事。不查库、不调远程、不做文件 IO、不同步打日志。这些会把一次 CPU 级操作变成 IO 级等待,直接把你推上膨胀之路。
3. 读多写少就别用 synchronized。ReentrantReadWriteLock、StampedLock 的乐观读,或不可变对象 + ConcurrentHashMap。注意乐观读可能读到撕裂数据,你得能接受这种不一致。
final StampedLock sl = new StampedLock();
double sum() {
long stamp = sl.tryOptimisticRead(); // 先拿"版本号"
double cur = this.value;
if (!sl.validate(stamp)) { // 校验期间是否被写
stamp = sl.readLock(); // 失败就退化成读锁
try { cur = this.value; } finally { sl.unlockRead(stamp); }
}
return cur;
}
4. 学会用 jstack,不是 top。CPU 高用 top,锁问题用 jstack,别混。
5. 别迷信"无锁"。CAS 失败会自旋烧 CPU,高竞争下未必比锁好,也没有重入语义。
八、最后
回头看那次事故,我最后改的代码是:把 synchronized 块里的远程调用挪到锁外,用局部变量接住结果再进锁写回。改动很小,延迟从 40ms 回到 9ms。
这就是并发编程最让人又爱又恨的地方:你写的每一行代码都在和一个 64 位的整数位较劲,而这个位被四种状态共享着,还会单向退化。
知识分子的浪漫是"锁是操作系统提供的原语",工程师的浪漫是终于搞清楚了自己对象头里那 64 位到底写了什么。
祝你的锁,永远不要膨胀。