☰
synchronized锁原理与优化全解析:从对象头到锁升级实战
2026/10/16 4:56:36 网站建设 项目流程

聊到 Java 并发,synchronized 基本是绕不开的第一站。前阵子给团队做代码评审,发现不少人在用 synchronized,但一问“它到底锁的是谁、底层怎么实现的、为什么有时性能好有时性能差”,回答就含糊了。这个关键字看似简单,真要吃透却涉及对象头、Monitor、锁升级、编译优化一整条链路。这篇东西我不打算写成官方文档摘抄,就按我这些年排查并发问题、调优性能的实际经验,把 synchronized 的用法、原理、优化手段和实战案例一次讲透。

1. synchronized 到底在锁什么:三种用法与锁对象辨析

1.1 三种典型用法的锁对象是不同对象

synchronized 有三种典型用法,很多人混淆的根源就是没搞清它们各自锁的是什么:

// 用法一:修饰实例方法,锁的是当前实例 this public synchronized void deductStock(int num) { if (stock >= num) { stock -= num; } } // 用法二:修饰静态方法,锁的是当前类的 Class 对象 public static synchronized void refreshConfig() { configVersion++; } // 用法三:同步代码块,锁的是括号中传入的对象 public void shipOrder(Order order) { synchronized (order) { order.setStatus(Status.SHIPPED); } }

实例方法加锁,锁的是调用该方法的那个实例对象。也就是说:同一个实例的 synchronized 方法在多线程环境下互斥,但不同实例之间完全不受影响。静态方法加锁,锁的是 Class 对象——注意不是“类所在的实例”,而是类本身在 JVM 中的 Class 对象。这意味着一个类的所有静态同步方法都共用同一把锁,无论来了多少实例。

代码块最灵活,锁对象由你自己指定。你可以锁任意对象,也可以精细控制临界区范围,这也是日常开发里用得最多的形式。

1.2 锁对象选错:三类高发翻车场景

选锁对象有几个高频踩坑点,我一个个说。

第一个坑:使用 this 锁代码块,但实例被创建了多份。假设你的服务里用线程池并发处理请求,每个请求都会 new 一个业务对象,那么 synchronized(this) 实际上是各锁各的,完全没互斥效果。正确的做法是锁一个共享对象,比如把实例本身设计成单例,或者显式锁一个全局共享的 final Object 常量。

第二个坑:用字符串常量做锁。下面的代码看起来没问题:

class GateKeeper { private static final String LOCK = "common-gate-lock-2024"; public void pass() { synchronized (LOCK) { // 业务逻辑 } } }

字符串常量会被 JVM 加入常量池,不同类里相同内容的字符串字面量可能指向同一个对象。如果项目里恰好有两个模块用同一个字符串内容做锁,即使它们毫无业务关系,也会互相阻塞。这个坑往往只在压力测试时才暴露,非常隐蔽。解决办法是 new Object() 创建一个专属锁对象,保证没人在别处能拿到同一把锁。

第三个坑:自动装箱对象做锁。Integer、Long 这些包装类在 -128 到 127 之间有缓存实例,synchronized (Integer.valueOf(1)) 锁的根本不是你以为的“每个值一把锁”,而是所有用 1 这个值的地方都共用那个缓存对象。轻则误伤其它调用方,重则连锁阻塞。锁对象永远用普通 Object 或者明确的业务对象,别用包装类。

1.3 锁粒度:锁对象和代码块的大小决定性能上限

锁对象定了之后,锁的范围同样要精心控制。synchronized 方法往往锁的代码范围过大,实际操作里我推荐优先写代码块,把锁的范围收到最小。看一个典型的计数器场景:

// 做法一:整个方法加锁,操作简单时性能损失很小 public synchronized void addCount() { count++; } // 做法二:只锁必要代码,但判断逻辑如果涉及共享状态仍要注意 public void addCount() { // 这里如果读取了共享变量,要考虑可见性 synchronized (this) { count++; } }

当同步逻辑很简单时,两种做法性能差异不大;但一旦方法里有 IO、网络调用、日志输出、复杂计算,锁方法会让所有无关操作都串行化,吞吐量直接掉一个量级。我的经验是:凡是加了 synchronized 的方法,都把锁内的代码量控制在“只修改共享状态”的最小区间,别的操作全部移出锁外。

2. 底层原理:对象头、Mark Word 与 Monitor 机制

2.1 对象在内存里到底长什么样

synchronized 之所以表面上只是“加个关键字”,是因为锁信息就存放在 Java 对象的对象头里。在 HotSpot 这类主流 JVM 实现中,一个普通对象在堆内存里分为三部分:对象头、实例数据、对齐填充。对象头又包含两部分:Mark Word 和 Klass Pointer。

64 位 JVM 默认开启指针压缩时,Mark Word 占 8 字节,Klass Pointer 占 4 字节,对象头合计 12 字节。Mark Word 是核心,它是一块动态复用的内存区域,同一个 8 字节在不同状态下存的内容完全不同:

  • 无锁状态:存对象的 hashCode、GC 分代年龄、锁标志位 01
  • 偏向锁状态:存持有偏向锁的线程 ID、epoch、分代年龄、偏向标志位 1 + 锁标志位 01
  • 轻量级锁状态:存指向线程栈中 Lock Record 的指针,锁标志位 00
  • 重量级锁状态:存指向 Monitor 对象的指针,锁标志位 10

理解这点你就明白了:synchronized 不是虚构的魔法,而是通过修改对象头里的状态位来记录锁信息。不同的锁状态对应不同的竞争处理策略,这就是锁升级的物理基础。

2.2 字节码视角:monitorenter 与 monitorexit

编译之后,synchronized 代码块对应两条字节码指令:monitorenter 和 monitorexit。拿一段代码块举例:

public void demo() { Object lock = new Object(); synchronized (lock) { System.out.println("in lock"); } }

javap -c 查看字节码可以看到,monitorenter 出现在进入临界区的位置,而正常退出路径有一个 monitorexit,异常退出路径还有一个 monitorexit。也就是说,无论代码是正常执行完还是抛异常,JVM 都会保证执行一次 monitorexit 释放锁,这就是 synchronized 无需手动 unlock 的原因。

monitorenter 指令背后的逻辑是:尝试获取对象的 Monitor(监视器锁)。每个 Java 对象在需要时都会关联一个 Monitor 对象,在线程进入时检查 Monitor 的 owner 字段。monitorenter 语义规定:同一线程可以多次进入同一个 Monitor,每进入一次计数加一,这就是可重入;退出一次计数减一,减到零时才真正释放锁。

2.3 Monitor 的结构与线程协作

Monitor 是锁的核心数据结构,包含三个关键字段:

  • owner:指向当前持有锁的线程
  • EntryList:竞争锁但尚未获取成功的线程队列
  • WaitSet:调用了 wait() 后进入等待状态的线程队列

整个流程可以这样理解:一个线程来竞争锁,先看 owner 是不是自己,是则重入计数加一。如果 owner 是别人,线程进入 EntryList 阻塞。当持有线程释放锁时,Monitor 会从 EntryList 里唤醒一个线程去竞争。调用 wait() 的线程会释放锁并进入 WaitSet,直到被 notify/notifyAll 唤醒,重新进入 EntryList 等待锁。

这里要注意:wait() 和 notify() 只能在持有 Monitor 的代码块里调用,否则会抛 IllegalMonitorStateException。因为它们操作的正是当前线程持有的那个 Monitor 对象。

3. 锁升级全链路:从偏向锁到重量级锁

3.1 偏向锁:为“只有一个线程访问”准备的优化

早期 JDK 里 synchronized 性能差,主要是因为每次加锁都走操作系统互斥量,开销巨大。后来 JVM 做了大量优化,第一个优化就是偏向锁。

偏向锁的核心思想:如果 synchronized 代码块始终被同一个线程访问,那就没必要每次 CAS 抢锁了。JVM 会在 Mark Word 里记录这个线程的 ID,后续该线程再次进入时,只需检查偏向线程 ID 是否是自己,是则直接进入,开销几乎为零。

偏向锁的撤销要特别留意:当另一个线程尝试获取这把锁时,偏向模式失效。如果之前持有偏向锁的线程已退出临界区,JVM 会尝试将 Mark Word 改成无锁或轻量级锁状态;如果还在临界区内,则升级为轻量级锁。

3.2 对象 hashCode 与偏向锁的冲突

这里有一个非常有意思的细节:偏向锁的 Mark Word 里存的是线程 ID、epoch 等,没有空间放 hashCode。一旦你调用了对象的 hashCode() 方法,就可能被迫撤销偏向锁,因为 Mark Word 要让位给 hashCode。

对象调用 Object.hashCode()(不是重写的 hashCode)时,会生成 identity hash code,这个值需要存在 Mark Word 里。如果一个对象已经处于偏向锁状态,再调用 hashCode() 就会导致偏向撤销,让对象回到无锁状态来存储 hash code。也就是说,如果一个类重写了 hashCode,使用普通的业务字段计算,那 Mark Word 里存的 hash code 便不再触发这个机制,偏向锁的安全性影响相对小。

有一点需要提醒:偏向锁被设计为默认开启,但它也增加了撤销成本。对于竞争激烈的场景,启动阶段加载大量类并触发批量撤销,可能带来额外负担。JDK 15 之后默认禁用了偏向锁,JDK 18 之后直接标记为废弃。如果你用的是新版本 JDK,就不用考虑偏向锁这条优化路径了;如果是 JDK 8/11 环境,还可以通过 -XX:+UseBiasedLocking 或 -XX:-UseBiasedLocking 控制是否启用。

3.3 轻量级锁:靠 CAS 和自旋撑起中等竞争

当第二个线程来竞争时,偏向锁撤销,JVM 进入轻量级锁阶段。轻量级锁的核心是 CAS(Compare And Swap):线程在自己的栈帧中创建 Lock Record,用 CAS 尝试把 Mark Word 替换成指向 Lock Record 的指针。如果替换成功,表示获取锁成功;如果失败,说明锁已被占用。

CAS 失败后,线程不会立刻进入内核态阻塞,而是自旋等待——也就是空循环反复尝试获取锁。一个线程在短时间里持锁很快释放,自旋比阻塞挂起更高效,因为阻塞涉及用户态/内核态切换,开销可能是自旋的几十倍。JDK 6 之后自旋是自适应性的:JVM 会根据前一次竞争的情况自动调整自旋时间,所以不要手动去设置自旋次数。

轻量级锁的问题在于:如果持有锁的线程执行时间很长,或者竞争线程特别多,自旋就会变成纯粹的 CPU 空转。大量线程都在自旋时,CPU 占用率直接飙高,反而拖垮整个系统。

3.4 重量级锁:进入内核态的兜底方案

当自旋也无法快速拿到锁,或同一时刻竞争线程数量过多时,轻量级锁升级为重量级锁。重量级锁依赖操作系统提供的互斥量(mutex),线程获取不到锁时会被操作系统挂起阻塞,不消耗 CPU,但线程上下文切换的开销巨大。

锁升级是单向的:对象一旦被升级为重量级锁,就保持重量级,不会自己降回轻量级或偏向锁。只有 GC 里的特殊清理过程可能清掉偏向记录,一般情况下你可以认为“锁只能越打越重”。这个特性决定了:一个被很多线程激烈竞争的锁,后续所有线程都要承受内核态切换的开销,这正是高并发场景下 synchronized 可能成为瓶颈的原因。

3.5 编译器的锁优化:逃逸分析与锁消除

除了偏向锁、轻量级锁这些运行时优化,JVM 编译期还会做两种优化:逃逸分析和锁消除。

逃逸分析判断一个对象是否“逃逸”出了当前方法。如果一个对象只在线程内部、方法内部使用,没有暴露给其他线程,那么这个对象的锁其实没有意义——不会有人跟它竞争。这时候 JIT 编译器在 JIT 编译阶段会直接“消除”这个锁。

典型的例子:

public String concat(String a, String b) { StringBuffer sb = new StringBuffer(); sb.append(a); sb.append(b); return sb.toString(); }

StringBuffer 的 append 方法本身是 synchronized 的,但 sb 这个对象只在方法内使用,不会被其他线程看到,JIT 会消除这些锁调用,从而获得接近无锁的性能。

锁消除依赖逃逸分析,而逃逸分析本身有开销,且受限于 JIT 的触发条件。在 JDK 8 及之后,参数 -XX:+DoEscapeAnalysis 默认开启。写代码时不必依赖它,合理设计锁粒度才是正途。

4. 优化实战:让 synchronized 不再拖后腿

4.1 减小锁粒度:分段思想与锁内操作瘦身

高并发场景下 synchronized 优化第一法则:减小锁粒度。怎么减小?两个方向。

第一个方向是缩小临界区代码量。把锁内的耗时操作移出来,比如网络请求、文件写入、消息发送、日志打印全部移出锁块。锁内只保留对共享变量的修改逻辑,减少持锁时间,自然降低锁竞争概率。

第二个方向是给不同的数据分不同的锁。一个典型的需求是缓存多个商品的库存,全部共用一把大锁会导致商品 A 的扣减阻塞商品 B 的扣减。优化时给不同商品分配独立的锁对象,让彼此互不干扰:

class StockCache { private final Object[] locks = new Object[64]; { for (int i = 0; i < locks.length; i++) { locks[i] = new Object(); } } private Object lockOf(String skuId) { int hash = skuId.hashCode() & 0x7fffffff; int idx = hash % locks.length; return locks[idx]; } public boolean deduct(String skuId, int num) { synchronized (lockOf(skuId)) { // 只锁单个商品相关的状态更新 } } }

这里用的是分段锁思想:把一个全局锁拆成多个独立锁,每个锁保护一部分数据。类似的做法在 JDK 并发包里也有体现,比如早期的 ConcurrentHashMap 用 Segment 分段锁,现在的版本在桶级别做并发控制,思路一脉相承。

4.2 锁粗化:反向优化的合理解释

锁粒度不是越小越好,在一些场景下,频繁加锁解锁反而比一把大锁更慢。这就是锁粗化的由来。

请看这段代码:

public void processList(List<String> items) { for (String item : items) { synchronized (this) { // 处理单个元素,实际每次加锁解锁 } } }

代码看起来把锁粒度控制到了“每个元素”,理论上很细。但实际情况是:每次循环都执行 monitorenter → 临界区 → monitorexit,频繁加锁解锁的本身开销可能超过业务逻辑。此时更好的做法是锁住整个循环体:

public void processList(List<String> items) { synchronized (this) { for (String item : items) { // 处理单个元素 } } }

JVM 的 JIT 编译器也会做类似的锁粗化优化:如果它检测到同一个线程反复进入同一个锁,会把相邻的同步块合并。不过人工写代码时仍然要主动识别这种模式,因为循环体里的业务一旦复杂,编译器未必能完全推断优化范围。

4.3 读多写少场景:volatile、不可变对象与读写锁

synchronized 的主要矛盾在于:读操作并不修改共享状态,却也必须排队拿锁。读多写少时,这把锁的代价被放大了。

三个替代思路,按优先级排序。

第一,用 volatile 代替锁。如果只是线程之间传递状态标识,比如开关、版本号、初始化标记,volatile 保证可见性和有序性,写入操作也不复杂,完全可以去掉锁。

第二,设计不可变对象。把要共享的数据封装成 final 字段,对象一旦创建就不允许修改。线程之间只传递引用,读方永远可以安全读取,彻底消灭锁需求。这里要注意:不可变对象必须保证内部所有字段都是 final,集合类要使用只读包装或深拷贝,否则只是“看起来不可变”。

第三,读写锁。JDK 里 ReentrantReadWriteLock 允许多个读线程同时进入,写线程独占;StampedLock 还支持乐观读,读操作不加锁直接读,写操作在提交前校验版本。这些机制和 synchronized 不是同一体系,但解决的是同一类问题:读多写少的共享数据并发访问。

值得说明的是,JDK 并发包里很多组件并没有盲目抛弃 synchronized。比如 LongAdder 这类高并发计数器,使用的是一个 base 值加一组 Cell 数组,更新操作分散到多个 Cell 上,降低竞争。但它的实现内部也用到了 synchronized 或 CAS 来处理冲突。要理解的是工具选择,而不是迷信某一种方案。

4.4 synchronized 与 ReentrantLock 到底怎么选

这是面试和项目里都躲不开的问题,我直接说结论:性能上,两者在 JDK 8+ 经过多年优化后,常规竞争场景差距已经很小。选型主要看功能需求。

ReentrantLock 独有的能力有:可中断获取锁(lockInterruptibly)、可定时获取锁(tryLock(timeout)),支持公平锁,以及可以绑定多个 Condition 实现精细化的等待/通知。如果你需要这些能力,比如防止死锁时的超时退出、或生产者消费者需要分组唤醒,ReentrantLock 是更合适的选择。

synchronized 的优势在于:语法简洁,不会忘释放锁,可重入计数自动管理,JIT 的偏向锁/轻量级锁优化路径对它友好。如果你的临界区逻辑简单、不需要公平性、等待条件单一,用 synchronized 完全够,而且代码更短更容易维护。

我个人的选型经验是:默认优先用 synchronized,只有当确实需要可中断、超时、公平或 Condition 时才换 ReentrantLock。纯粹为了“性能更好”而换,多半是过度设计。

5. 实战案例:限流计数、死锁排查与性能问题定位

5.1 案例一:高并发实时计数器的实现

假设需要统计某接口每秒钟的访问量,并在超过阈值时触发限流。最直观的写法是:

class RateLimiterCounter { private long currentSecond; private long count; public synchronized boolean tryAcquire(long second) { if (second != currentSecond) { currentSecond = second; count = 0; } if (count < THRESHOLD) { count++; return true; } return false; } }

这段代码功能没问题,但 count++ 这个单点更新加 synchronized,QPS 到一定量级时锁竞争会很明显。可以使用分段计数思想优化:每个线程或每个 CPU 核心维护一个独立的计数槽,读取时再汇总,这样把锁竞争大幅降低。

如果你不想自己实现分段计数,可以直接用 LongAdder。它的 add 操作通过 CAS 更新分散的 Cell,没有 CAS 时就 fallback 到基础变量,比 synchronized 计数器吞吐量高很多。这个案例说明一个道理:synchronized 不是不能用,而是要知道它适合什么场景。简单互斥场景下它就很好,热点计数场景则要考虑更专门的结构。

5.2 案例二:死锁的定位与预防

synchronized 死锁的两个典型场景:

场景一:两个线程持锁互相等待。线程 A 持有锁 L1 等待 L2,线程 B 持有锁 L2 等待 L1,双方互不相让,线程永久阻塞。

场景二:加锁顺序不一致。同一个业务逻辑里,线程 A 先锁 X 再锁 Y,线程 B 先锁 Y 再锁 X,一旦交错就死锁。

排查死锁最直接的手段是用 JDK 自带的线程转储工具。执行 jps 找到 Java 进程 PID,再执行 jstack PID,输出末尾会明确包含 Found one Java-level deadlock 字样,并列出参与死锁的线程、持有的锁、等待的锁。

预防死锁主要有三个手段。第一个是固定锁顺序:所有线程按同一顺序获取多把锁,比如先锁低序号再锁高序号,从根本上消除循环等待。第二个是缩小锁范围:嵌套锁的持有时间越短,交叉概率越低。第三个是用超时锁代替无条件的 synchronized 获取:tryLock 拿不到锁就释放已有锁,避免无限等待。

5.3 案例三:定位 synchronized 引起的性能瓶颈

线上服务出现 CPU 高、接口响应慢,怀疑和 synchronized 有关,排查思路如下。

先看线程状态。执行 jstack 导出线程堆栈,统计线程处于 BLOCKED、WAITING、RUNNABLE 的状态分布。大量线程 BLOCKED 且都阻塞在同一个 synchronized 块上,说明锁竞争非常激烈。

再看持锁时间。从堆栈里找到持锁线程在干什么,如果持锁线程本身在等待网络 IO,那其他所有竞争线程都要陪着等,这时候问题往往不在锁本身,而在锁内不该有 IO。优化方式是缩短锁内代码,把 IO 移出去。

最后看锁的升级情况。如果 JVM 参数支持,可以打印锁相关的日志或用 JFR(Java Flight Recorder)抓取锁竞争事件。不过实践中我更多是结合业务分析:高竞争锁通常对应热点共享数据,要么拆分锁,要么换更合适的并发组件。

这里分享一个我自己的调优经验:在库存扣减场景里,最初用 synchronized 大锁保护所有商品,压测 TPS 到 3000 时出现大量 BLOCKED。改成分段锁后 TPS 到了 12000,效果立竿见影。但随后发现单个热点商品仍然是瓶颈,最后直接引入基于 Redis 的 Lua 原子扣减逻辑,彻底绕开 JVM 层锁竞争。这说明锁优化是一个递进的过程:先用好 synchronized,再考虑更重的方案,千万不能在业务初期就引入复杂组件。

个人实操体会

写了这么多年并发代码,我的体会是:synchronized 不是“低级锁”的代名词,它是 JVM 原生提供的、最可靠的互斥手段。JDK 8 之后的 synchronized 经过偏向锁、轻量级锁、自适应自旋等优化,在绝大多数业务场景下都够用。真正决定性能的,是你有没有分析清楚共享资源的访问模式,有没有把锁的粒度设计到合理范围。最后再分享一个小技巧:写并发代码时,每加一个 synchronized,都问自己三个问题——锁的对象对吗?锁的范围最小吗?有没有其他线程拿同一把锁干无关的事?这三个问题想清楚,绝大部分并发隐患都能提前消除。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询