Java · synchronized 锁升级与对象布局

无锁 → 偏向 → 轻量级(自旋/CAS) → 重量级(monitor) — 锁的全部状态都在对象头的 Mark Word 里

对象内存布局 (64 位 JVM · 默认压缩指针) Mark Word (8 字节) hashcode · 分代年龄 · 持锁线程ID 锁标志位 — 锁的全部状态都在这 Klass Pointer (4 字节) 指向类元数据 (压缩后 4B) 开启了压缩指针 klass+字段引用都省 实例数据 instance data 字段按类型宽度排列 (JVM 会重排以最小化填充) 对齐填充 padding 补齐到 8 字节整数倍 所以对象最小 16B "锁升级"不是另造锁对象 — 就是这 64 位 Mark Word 内容的改写 (JOL 可直接打印验证) Mark Word 五种状态 无锁 001 hashcode + 分代年龄 (没人碰过锁) 偏向 101 记录线程 ID (同一线程反复进, 零成本) JDK 15 起默认废弃 轻量级 00 指向线程栈里 Lock Record 的指针 CAS 抢锁, 抢不到自旋 重量级 10 指向堆外 ObjectMonitor (互斥量) 阻塞 = 用户态↔内核态切换 GC 标记 11 标记期专用 (与本主题无关) 升级路径 (竞争激烈度驱动) 偏向锁: CAS 记下线程 ID 同一线程再进只比对 ID — 零 CAS 零屏障 (15+ 默认关闭: 撤销要 safepoint, 不划算) 轻量级锁: 栈上 Lock Record + CAS 第二线程来抢 → 撤销偏向; 自旋等持有者释放 (赌"马上就好", 不切内核) 重量级锁: ObjectMonitor (OS 互斥量) 自旋超限/多核竞争 → park 线程, 上下文切换, 不耗 CPU 但延迟高 只能升不能降; 偏向锁的批量撤销/重偏向需全局 safepoint 这就是 JDK 15 废弃偏向锁的原因: 复杂度 > 收益 (现代应用普遍多线程) synchronized vs ReentrantLock — 以及 JIT 的隐形助攻 synchronized: JVM 内置 · 异常自动释放 · JIT 锁消除(逃逸分析发现无竞争直接删锁) / 锁粗化(循环内锁合并) ReentrantLock: tryLock(超时) / 可中断 / 公平锁 / 多条件变量 Condition — 需要这些能力才值得手写 try/finally 公共骨架 AQS: state(CAS) + CLH 等待队列 — ReentrantLock / Semaphore / CountDownLatch 全是它的实现 现代结论: 无特殊需求首选 synchronized — JIT 与锁升级已让它足够快且不会忘记 unlock Legend 对象头/Mark Word 偏向(已废弃) 轻量级/推荐 重量级/开销

锁住在对象头里

  • • Mark Word 64bit 复用: hash/年龄/线程ID/锁指针
  • • 轻量级指针指向栈, 重量级指向 ObjectMonitor
  • • JOL 一行命令亲眼看状态变化

升级 = 付出更多换更强

  • • 偏向: 零成本但只适合单线程
  • • 轻量级: CAS+自旋, 赌等待很短
  • • 重量级: 内核互斥, 不耗 CPU 但切换贵

工程结论

  • • 默认 synchronized, 特殊能力才用 Lock
  • • 锁消除/粗化: JIT 替你省了很多无谓的锁
  • • 真正的性能来自锁粒度设计, 不是换锁

💡 一句话理解

synchronized 的聪明之处是"按竞争激烈程度付费": 没人抢时近乎免费(Mark Word 记个标记), 竞争短暂时 CAS+自旋硬扛(轻量级), 打持久战才动用 OS 互斥量(重量级)。这条升级路径的全部状态都写在对象头 Mark Word 里 — 理解了它, "synchronized 到底慢不慢"这个争论你就有标准答案: 无竞争时几乎免费, 高竞争时真正要优化的是锁粒度, 而不是换个锁。

🧠 必知必会 必考 & 必会

monitor 是什么
每个对象关联一个 ObjectMonitor(堆外 C++ 结构): owner 线程、EntryList(锁竞争阻塞队列)、WaitSet(wait 的线程)。字节码层面 = monitorenter/monitorexit + wait/notify 直接操作它。
synchronized (obj) { ... }   // monitorenter/monitorexit
obj.wait(); obj.notify();     // 直接操作 WaitSet/EntryList
// 关键: owner + EntryList + WaitSet 是堆外 C++ 结构
为什么废弃偏向锁
偏向的撤销与重偏向要求全局 safepoint(STW); 现代应用天生多线程, "单线程反复进锁"的场景越来越少 — 维护成本盖过收益, JDK 15 默认禁用(JEP 374)。
java -XX:+PrintFlagsFinal -version | grep -i Bias
# JDK 8: UseBiasedLocking=true; JDK 15+ 默认禁用(JEP 374)
# 关键: 偏向撤销要全局 safepoint, 成本盖过收益
自旋的赌注
轻量级自旋赌"持锁者马上释放": 赢了省一次上下文切换(μs 级), 输了白烧 CPU。自适应自旋按历史成功率动态调整次数 — 上次自旋成功, 这次多转一会。
synchronized (lock) { i++; }  // 临界区极短: 自旋几 μs 就能赢
// 关键: 自适应自旋按历史成功率定次数, 上次赢这次多转
锁消除与锁粗化
锁消除: JIT 逃逸分析证明对象不逃逸(如方法内局部的 StringBuffer), synchronized 直接删掉; 锁粗化: 循环里反复锁同一对象, 合并成循环外一把大锁。两者都自动发生 — 所以"局部 StringBuffer 加锁很慢"是过时认知。
synchronized (sb) { sb.append(x); }  // 局部 sb 不逃逸 → JIT 删锁
for (...) { synchronized (o) { ... } }   // 粗化: 合并成循环外一把
wait/notify 三铁律
①必须持有该对象锁才可 wait/notify(否则 IllegalMonitorStateException); ②wait 释放锁、sleep 不释放; ③被唤醒后要重新竞争锁, 且条件检查必须用 while(防虚假唤醒与竞态)。
synchronized (lock) {            // ① 不持锁 → IllegalMonitorStateException
    while (!ready) lock.wait();      // ③ while 重检; ② wait 已释放锁
}
AQS 一句话
volatile int state + CAS 改 state + 获取失败的线程进 CLH 双向队列挂起 — 一个"同步器框架"。ReentrantLock(重入=state 累加)/Semaphore(许可)/CountDownLatch(计数) 都是换 state 语义的插件。
// AQS = volatile state + CAS + CLH 队列挂起等待线程
acquire(1);  // ReentrantLock: state 累加 = 重入计数
// Semaphore / CountDownLatch 只是换 state 语义的插件
可重入
synchronized 与 ReentrantLock 都可重入: 同线程可重复获取自己持有的锁(state 计数), 防止嵌套调用自我死锁 — 这是默认且必要的行为。
synchronized void a() { b(); }  // 同线程嵌套: 直接进, 不死锁
synchronized void b() { ... }   // 关键: state 计数, 出一次减一次

🏭 生产实战 real world

场景 1 · 锁粒度: 从"整表锁"到"分段锁"

风控规则计算, 100ms 一把大锁 → 全局串行; 按 userId 分段后并发恢复:

// 差: 全局一把锁, 无关用户互相排队
synchronized (rulesCache) { compute(user); }

// 好: 分段锁 — 不同段并行, 同段才互斥
private static final Object[] LOCKS = new Object[64];
Object lock = LOCKS[Math.floorMod(userId.hashCode(), LOCKS.length)];
synchronized (lock) { compute(user); }
// 更省心: ConcurrentHashMap<K,V>.computeIfAbsent() 内部按桶加锁, 通常不用手写

场景 2 · jstack 抓死锁

jstack <pid> | grep -A 20 "Found one Java-level deadlock"
# 输出直接给出互相等待的线程与代码行 → 调整加锁顺序(全局排序拿锁)修复
# 预防: 团队规约"多把锁必须按固定顺序获取"; 带超时的 tryLock 打破循环等待

场景 3 · JOL 亲眼看 Mark Word 变化

System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 无锁: ... 0x01 (可看到 hashcode 位)
synchronized (obj) {
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    // 轻量级: ... 0x00 + 栈 Lock Record 指针 (JDK 15+ 无偏向, 直接轻量级)
}

场景 4 · 读多写少缓存的选型与实现

三种方案覆盖 99% 场景, 按竞争强度升级:

// 1. 低频读: synchronized 一步到位 (JIT 锁优化足够)
synchronized (map) { return map.get(k); }
# 2. 读多写少: ReadWriteLock 放行并发读
rw.readLock().lock(); try { return map.get(k); } finally { rw.readLock().unlock(); }
# 3. 读极多: StampedLock 乐观读 — 读时零锁
long stamp = sl.tryOptimisticRead();        // 不加锁, 拿版本号
V v = data; if (!sl.validate(stamp)) {    // 读期间有写? 才升级悲观读
    stamp = sl.readLock(); try { v = data; } finally { sl.unlockRead(stamp); }
}

场景 5 · tryLock 超时打破死锁

转账要同时锁两个账户, 顺序不定就有死锁风险 — 有界等待+重试是标准解:

public boolean transfer(Account from, Account to, long amt)
        throws InterruptedException {
    while (true) {
        if (from.lock.tryLock(1, SECONDS)) {
            try {
                if (to.lock.tryLock(1, SECONDS)) {
                    try { from.debit(amt); to.credit(amt); return true; }
                    finally { to.lock.unlock(); }
                }
            } finally { from.lock.unlock(); }
        }
        backoff();                    // 没拿到就退避重来, 死锁不可能形成
    }
}

场景 6 · 队列替代 wait/notify

生产者消费者自己写 wait/notify 是历史课 — BlockingQueue 三行搞定且无唤醒坑:

BlockingQueue<Order> buffer = new ArrayBlockingQueue<>(500);
# 生产者: 队满自动阻塞 (背压)
buffer.put(order);
# 消费者: 队空自动等待, 无需手动 wait/notifyAll/while 重检
Order o = buffer.take(); process(o);

场景 7 · CAS 重试 + 失败退避

无锁更新带上限退避, 避免高竞争下自旋风暴:

public static <T> boolean casRetry(AtomicReference<T> ref,
        UnaryOperator<T> update, int maxRetries) {
    for (int i = 0; i < maxRetries; i++) {
        T cur = ref.get();
        T next = update.apply(cur);
        if (ref.compareAndSet(cur, next)) return true;
        Thread.onSpinWait();            // JDK9+: 提示 CPU 让出执行资源
    }
    return false;                       // 重试耗尽: 调用方走慢路径(加锁/MQ)
}

场景 8 · JVM 锁与分布式锁的边界

synchronized 只锁进程内 — 多实例部署时同 key 并发照样穿透:

// 单实例够用: JVM 锁 (快, 无外部依赖)
synchronized (userId.intern()) { grantOnce(uid); }   // 注意: intern 词汇有限才安全
# 多实例必须: 分布式锁 + 数据库唯一约束双保险
if (redisLock.tryLock("grant:" + uid, 5s)) {
    try { dao.insertUnique(uid); }         // 最终防线是唯一索引
    catch (DuplicateKeyException e) { // 锁失效也不脏 }
    finally { redisLock.unlock(); }
}

场景 9 · 锁竞争监控与告警

BLOCKED 线程数是锁健康度的直接指标:

// 常态巡检: BLOCKED 数量与等待目标
jstack <pid> | grep -c "java.lang.Thread.State: BLOCKED"
# 告警口径: BLOCKED > 核数 且持续 1 分钟 → P2 告警
# 定位: jstack 输出里 "waiting to lock <0x...>" 对应 "locked <0x...>" 的线程栈
# 也可用 arthas: thread --state BLOCKED 一屏看全

场景 10 · Condition 多条件精准唤醒

有界缓冲区"满等空、空等满"两个条件, 一个 lock 配两个 condition:

private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull  = lock.newCondition();
private final Condition notEmpty = lock.newCondition();

public void put(T x) throws InterruptedException {
    lock.lock();
    try {
        while (count == items.length) notFull.await();  // 满了等"非满"
        items[putIdx] = x; if (++putIdx == items.length) putIdx = 0;
        count++; notEmpty.signal();                    // 只叫醒等"非空"的
    } finally { lock.unlock(); }
}

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 锁 String 字面量 / Integer 缓存 — synchronized("lock"): 字符串常量池全局共享, 不同模块可能"锁到同一把"或"以为同锁实则不同"; Integer 缓存 -128~127 同理。正解: private static final Object LOCK = new Object()。
synchronized ("lock") { ... }  // 错: 常量池全局共享, 锁到同一把
private static final Object LOCK = new Object();  // 对
坑 2 · synchronized(this) 暴露锁 — 外部代码也能 synchronized(你的实例), 干扰你的并发策略。正解: 私有 final 锁对象, 把锁权收进类内部。
synchronized (this) { ... }  // 错: 外部也能锁你的实例
private final Object lock = new Object();  // 对: 锁权收进类内
坑 3 · wait 条件用 if — 虚假唤醒+唤醒后条件可能又不满足。正解: while (!condition) lock.wait();。
if (!cond) lock.wait();    // 错: 虚假唤醒+条件再变
while (!cond) lock.wait();  // 对: 唤醒后重检条件
坑 4 · 锁内做慢 IO / RPC — 持锁时间决定并发度, 锁内调外部接口等于全局串行化。正解: 锁内只做内存操作; IO 移出临界区, 用不可变快照交换。
synchronized (lock) { rpc.call(); }      // 错: 全局串行化
synchronized (lock) { snap = copy(); }   // 对: 锁内只做内存操作
坑 5 · 手写 Lock 忘 unlock — ReentrantLock 没有 JVM 自动释放, 异常路径漏 unlock = 后续全部卡死。正解: lock() 后第一行 try{ } finally{ unlock(); }, 无例外。
lock.lock();  // 错: 异常路径漏 unlock, 后续全部卡死
lock.lock(); try { ... } finally { lock.unlock(); }  // 对
坑 6 · 双重检查锁少 volatile — 与 JMM 页同源的经典: new 的分配→初始化→赋值可重排。正解: instance 加 volatile; 或改用静态内部类/枚举。
private static Singleton inst;           // 错: 重排 → 半成品
private static volatile Singleton inst;  // 对: 或静态内部类/枚举
坑 7 · 迷信"换 Lock 更快" — 无竞争时 synchronized 有锁消除/偏向遗留优化, 通常更快; Lock 的价值是 tryLock/可中断/公平/Condition 能力。正解: 先优化粒度与持锁时长, 压测数据说话。
// 错: "Lock 一定比 synchronized 快" — 无竞争时反而常更慢
lock.tryLock(1, SECONDS);  // 对: 价值在超时/可中断/公平/Condition
坑 8 · 锁对象被序列化/克隆复制 — 含 Mutex 语义的对象走序列化或 clone, 锁状态被复制出第二把, 互斥失效。正解: 锁字段 transient + 重建; 深拷贝用工厂重建而非 clone。
// 错: 锁随序列化/clone 复制出第二把 → 互斥失效
private transient Object lock;  // 对: transient + 重建, 拷贝走工厂
坑 9 · static synchronized 与实例锁的错觉 — static synchronized 锁 Class 对象, synchronized(this) 锁实例: 两者互不互斥, "加了锁还是并发"的经典原因。正解: 明确锁粒度(类级/实例级), 同一不变量只用一把。
static synchronized void a() {}  // 锁 Foo.class
synchronized void b() {}         // 锁 this — 两把锁互不互斥!
坑 10 · synchronized 不可中断 — 拿不到锁就死等, 无法响应取消。正解: 需要超时/可中断的场景用 ReentrantLock.tryLock(timeout) / lockInterruptibly。
synchronized (lock) { ... }       // 错: 死等, 无法响应取消
if (rlock.tryLock(3, SECONDS))  // 对: 超时/可中断
坑 11 · 公平锁的吞吐税 — new ReentrantLock(true) 每次唤醒都走队列, 吞吐降明显。正解: 只有严格防饿死需求(交易系统)才公平; 默认非公平 + 业务侧超时兜底。
new ReentrantLock(true);  // 错: 默认上公平, 吞吐明显降
new ReentrantLock();    // 对: 非公平 + 业务超时兜底
坑 12 · 丢失唤醒 — notify 先于 wait 执行, 通知凭空消失, 等待者睡死。正解: 状态字段 + while 重检双保险; 新代码直接用 Condition/BlockingQueue。
notify();                  // 错: 先于 wait 执行 → 等待者睡死
ready = true; notifyAll();  // 对: 状态字段 + while 重检
坑 13 · 分段锁的跨段操作 — 每段一把锁很美, 但"同时改 A 段和 B 段"的复合操作要么锁两段(死锁风险)要么撕裂。正解: 跨段操作走全局锁或重设计分区让操作单段化。
// 错: "同时改 A 段和 B 段"锁两段 → 死锁风险; 不锁 → 撕裂
// 对: 跨段操作走全局锁, 或重设计分区让操作单段化
坑 14 · ABA 在金额场景真会咬人 — 余额 100→80→100, CAS 只看值就放行, 中间的扣款被"原谅"了。正解: 加版本号(AtomicStampedReference)或直接用数据库乐观锁(version 列)。
ref.compareAndSet(100, next);  // 错: 100→80→100 也放行
AtomicStampedReference<Long> ref;   // 对: 版本号 / DB version 列
坑 15 · StampedLock 不可重入 — 同线程重复 readLock 直接死锁, 与 ReentrantLock 直觉相反。正解: StampedLock 只用在"读多写少+简单临界区"; 需要重入就用 ReentrantReadWriteLock。
// 错: StampedLock 非重入 — 同线程二次 readLock 可死锁
ReentrantReadWriteLock rw;  // 对: 需要重入的场景用它
坑 16 · StampedLock 写饥饿 — 乐观读者源源不断, 写线程可能长时间拿不到锁。正解: 写敏感场景限制乐观读比例或换 RWLock; 监控写等待时间。
long s = sl.tryOptimisticRead(); ...  // 乐观读不断 → 写饥饿
// 对: 限制乐观读比例或换 RWLock, 监控写等待时间
坑 17 · 锁内调用可覆写方法 — 持锁调 this.hook(), 子类/回调里再拿别的锁或做 IO, 死锁与持锁时间失控。正解: 模板方法里锁只包数据操作, 扩展点移出临界区。
synchronized (lock) { this.hook(); }  // 错: 子类再拿锁/做 IO
// 对: 锁只包数据操作, 扩展点移出临界区
坑 18 · Collections.synchronizedMap 当高性能用 — 每个方法一把全局锁, 迭代还要手动同步, 性能远差于 ConcurrentHashMap。正解: 并发场景直接 CHM; synchronizedMap 只用于遗留代码过渡。
Collections.synchronizedMap(new HashMap<>());  // 错: 全局锁
new ConcurrentHashMap<String,Object>();      // 对: 直接 CHM
坑 19 · 迭代并发集合时修改 — CHM 迭代弱一致不抛错但可能看不到并发修改, 语义误判成"丢数据"。正解: 迭代+修改要单原子(computeIfAbsent/replaceAll); 快照语义用 new ArrayList(map.values())。
for (String k : map.keySet()) map.remove(k);  // 错: 语义不可靠
map.keySet().removeIf(...);                    // 对: 原子方法/快照
坑 20 · 双重检查锁的另一半坑 — volatile 只保护引用赋值; 若构造里 this 逃逸(注册监听器), 即使 volatile 也可能被外部看到半成品。正解: 构造器绝不发布 this; 单例优先静态内部类/枚举。
ctx.register(this);  // 错: 构造里发布 this, volatile 也救不了
// 对: 构造器绝不发布 this; 单例优先静态内部类/枚举