欢迎光临
我们一直在努力

synchronized 锁升级不是越快越好:对象头里那两位状态位,我曾误读过一次


title: "synchronized 锁升级不是越快越好:对象头里那两位状态位,我曾误读过一次"
category: Java
tags: ["synchronized", "锁升级", "对象头", "JVM", "源码分析"]


三年前做支付对账系统,QA 报了一个诡异问题:生产环境偶发同一笔订单被两个线程同时处理,但日志里明明都进了 synchronized 块。我们第一反应是锁对象是不是同一个,排查下来果然是——两个不同实例的 Integer 被 == 比较通过了。那次排查让我把 synchronized 的对象头、锁状态、升级链路重新啃了一遍,发现很多文章把“偏向锁 → 轻量级锁 → 重量级锁”这条线讲得太顺滑,好像锁升级就是越快越好。真实场景里,锁状态位 Mark Word 的两位 bits, combined with 线程 ID、epoch、monitor 指针,才是判断线程安全的唯一依据。

一、问题现场:Integer 缓存池让 synchronized 形同虚设

当时业务代码大概长这样:

public class OrderProcessor {
public void reconcile(Integer orderId) {
synchronized (orderId) { // 危险:锁的是 Integer 对象
// 查询、比对、更新状态
}
}
}

测试环境并发量小,问题被 Integer 缓存池(-128 ~ 127)掩盖了:小 orderId 都是同一个对象,synchronized 看起来有效。生产环境订单号动辄几百万,两个线程拿到不同 Integer 实例,锁自然互不相干。这个 bug 的直接原因是锁对象选错,但修复过程中我们发现团队很多人对 synchronized 的“锁升级”存在误解:以为一旦升级成重量级锁就一定能保证互斥,却忽略了锁对象本身必须保持一致。

我当时的修复方案不是简单换成 orderId.toString().intern()——那会把字符串常量池变成新的全局锁热点,而是引入了 ConcurrentHashMap 做分段锁:

private final ConcurrentHashMap<Long, Object> locks = new ConcurrentHashMap<>();

public void reconcile(Long orderId) {
Object lock = locks.computeIfAbsent(orderId, k -> new Object());
synchronized (lock) {
try {
doReconcile(orderId);
} finally {
locks.remove(orderId, lock); // 处理完释放,避免 map 无限膨胀
}
}
}

这个改动上线后再也没出现并发对账。但更重要的是,它让我意识到 synchronized 的语义安全分为两层:锁对象是否相同,以及 JVM 内部锁状态是否正确。

二、对象头与 Mark Word:两位 bits 决定锁的“重量级”

在 HotSpot 64 位 JVM 中,对象头分为 mark word 和 klass pointer。普通对象的开头是 mark word,长度取决于是否开启指针压缩(UseCompressedOops)。关闭压缩时是 8 字节,开启时也是 8 字节(klass pointer 被压缩到 4 字节)。mark word 的最后 3 位是锁状态标志:

| 锁状态 | 标志位 (2 bits) | 偏向锁标记 (1 bit) |
| 无锁 | 01 | 0 |
| 偏向锁 | 01 | 1 |
| 轻量级锁 | 00 | – |
| 重量级锁 | 10 | – |
| GC 标记 | 11 | – |

我用 jol-core(Java Object Layout)在 OpenJDK 17 下打印了一个普通对象的对象头:

import org.openjdk.jol.info.ClassLayout;

public class ObjectHeaderDemo {
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}

输出里可以看到 mark word 的十六进制值。刚创建时通常是 0x0000000000000001(关闭偏向锁后的无锁状态,最后三位是 001);进入 synchronized 后变成类似 0x00007f… 的轻量级锁记录地址,最后两位变成 00。如果发生竞争,会膨胀成重量级锁,最后两位变成 10,mark word 里存储的是 ObjectMonitor 的指针。

注意:从 JDK 15 开始,JVM 默认关闭偏向锁(JEP 374),JDK 18 正式移除偏向锁(JEP 407)。所以在现代 JDK 17/21 下,synchronized 新对象一上来就是“无锁(可偏向关闭)”,第一次加锁直接是轻量级锁;只有竞争才会膨胀。很多老文章还在强调“偏向锁优化”如何如何,如果你用 JDK 17+,那部分内容已经过时了。

三、锁升级的源码链路:不是“升级”,是“膨胀”

锁升级在 HotSpot 源码里主要走 ObjectSynchronizer::fast_enter 和 slow_enter。我们以 InterpreterRuntime::monitorenter 为入口:

// hotspot/share/runtime/interpreterRuntime.cpp
IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
Handle h_obj(thread, elem->obj());
ObjectSynchronizer::enter(h_obj, elem->lock(), CHECK);
IRT_END

进入 ObjectSynchronizer::enter 后,会尝试 fast_enter:

// hotspot/share/runtime/synchronizer.cpp
void ObjectSynchronizer::enter(Handle obj, BasicLock* lock, TRAPS) {
if (UseBiasedLocking && !SafepointSynchronize::is_at_safepoint()) {
if (obj->mark()->has_bias_pattern()) {
bool success = BiasedLocking::revoke_and_rebias(obj, false, THREAD);
if (success) return;
}
}
markWord mark = obj->mark();
if (mark->is_neutral()) { // 无锁状态
lock->set_displaced_header(mark);
if (mark == obj()->cas_set_mark(lock->displaced_header(), mark)) {
return; // 成功获得轻量级锁
}
} else if (…) {
// 已持有轻量级锁,重入
}
// 失败则膨胀为重量级锁
ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);
}

关键逻辑是:先 CAS 把 mark word 改成指向当前线程栈中的 Lock Record;成功就是轻量级锁;失败说明有竞争,走 inflate 创建或关联 ObjectMonitor,进入重量级锁。

膨胀的源码在 ObjectSynchronizer::inflate:

ObjectMonitor* ObjectSynchronizer::inflate(Thread* self, oop object) {
for (;;) {
markWord mark = object->mark();
if (mark->has_monitor()) { // 已经是重量级锁
return mark->monitor();
}
if (mark->is_neutral()) { // 无锁,直接放 monitor
ObjectMonitor* monitor = new ObjectMonitor(object);
markWord cmp = object->cas_set_mark(markWord::encode(monitor), mark);
if (cmp == mark) return monitor;
delete monitor;
}
// 自旋或让出 CPU,等待 mark word 稳定
}
}

这段代码说明:膨胀不是一个单向“升级”,而是发现竞争后创建 ObjectMonitor 并把 mark word 替换为 monitor 指针。一旦膨胀,后续同一线程再进入 synchronized 都会走 monitor 的 _count 重入计数,开销明显变大。

四、一次真实的性能回退:锁粗化把轻量级锁磨成重量级锁

我们另一个服务做库存扣减,为了“减少加锁次数”,把一段代码刻意用锁粗化包起来:

synchronized (inventory) {
for (Sku sku : skuList) {
// 查 DB、算库存、更新缓存
Thread.sleep(10); // 模拟调用外部接口
}
}

初衷是减少 synchronized 进出次数,结果 CPU 反而飙升。用 jstack 一看,大量线程 BLOCKED 在 monitor,锁已经膨胀成重量级锁。原因很简单:锁内持有时间长,其他线程持续竞争,JVM 判断“轻量级锁自旋没意义”,直接膨胀。

修复方式不是取消 synchronized,而是把锁粒度拆细,并把 IO 移出临界区:

List<StockChange> changes = new ArrayList<>();
for (Sku sku : skuList) {
changes.add(calculate(sku)); // IO/计算在锁外做
}

synchronized (inventory) {
for (StockChange c : changes) {
inventory.apply(c); // 只保留最小原子更新
}
}

上线后锁状态维持在轻量级锁,吞吐量提升了约 35%。这个案例让我形成一条判断标准:synchronized 性能好坏不取决于“有没有升级”,而取决于临界区是否短、竞争是否少。把 IO 和大循环塞进 synchronized,就是把轻量级锁往重量级锁推。

五、个人观点:synchronized 仍然是首选,但要学会看对象头

很多团队一谈到并发就非 ReentrantLock 不可,觉得 synchronized 太“黑盒”。我的看法是:在 JDK 17+、竞争不激烈、临界区短的场景里,synchronized 经过 JIT 锁消除、锁粗化等优化后,性能往往和 ReentrantLock 持平甚至更好,而且代码可读性更高。但它有两个硬性前提:

  • 锁对象必须是稳定且唯一的引用。String.intern()、Integer 缓存池、手动 new 出来的对象,都可能让锁失效。
  • 临界区必须尽量短且不包含 IO。否则锁膨胀不可避免,此时与其在 synchronized 上硬扛,不如换 ReentrantLock + Condition 做更细的分段或读写分离。
  • 我通常的取舍是:
    – 计数器、单例初始化、简单状态变更:直接用 synchronized。
    – 需要超时、公平锁、读写分离:用 ReentrantLock / StampedLock。
    – 高并发且临界区有 IO:考虑分段锁或无锁结构(LongAdder、ConcurrentHashMap)。

    六、总结与思考题

    synchronized 的“锁升级”本质上是 JVM 根据竞争情况在对象头 mark word 里做状态切换。JDK 17+ 已经不存在偏向锁,第一次加锁就是轻量级锁,竞争激烈时膨胀为重量级锁。理解了这一点,排查并发 bug 就不会只盯着“锁升级慢不慢”,而是先看锁对象是否一致、临界区是否干净。

    思考题:
    1. 为什么 synchronized (new Object()) 完全无法起到互斥作用?
    2. 在 JDK 17 默认参数下,用 synchronized 对一个对象反复加锁(单线程),mark word 会经历哪些状态变化?
    3. 如果你发现某个 synchronized 块频繁膨胀为重量级锁,除了换 ReentrantLock,还有哪些优化方向?

    赞(0)
    未经允许不得转载:171主机测评 » synchronized 锁升级不是越快越好:对象头里那两位状态位,我曾误读过一次
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址