资讯动态

Java synchronized底层原理与锁优化:从用法到对象头

发布时间:2026/10/13 22:34:53 来源:尧图企业网站定制
1. 先搞清楚synchronized到底在解决什么问题做 Java 并发开发synchronized是绕不开的东西。不管你是刚入门还是已经写了两三年业务代码遇到的并发bug十有八九都和这个关键字有关多线程共享变量数据不一致、程序死锁、明明加了锁却没生效……这些坑我全踩过。synchronized直译过来是“同步”但很多人学完口诀锁方法、锁代码块、锁类就以为会了真到线上问题一查连它保护的是什么、怎么保护的都说不清。这门课要讲的不是“会用”而是把synchronized底下的机制和排查思路理一遍。它要解决的核心问题就是三个原子性、可见性、有序性。这三件事任何一个在并发场景下出了岔子你的数据就彻底没法看了。底下先从最朴素的场景说起搞清楚它到底在防什么。1.1 并发场景下的三个经典问题先看一段再常见不过的代码public class Counter { private int count 0; public void increment() { count; } }两个线程各执行一万次increment()你猜结果是多少我让 A 同学在自己笔记本上实测过最后 count 经常是 15000 多、17000 多极少次数能到 20000。问题出在哪count看着是一条语句可 JVM 执行它时实际拆成了三步读 count 的值、把这个值加一、把新值写回去。两个线程同时读到 count100然后各自加一写回等于两次加法只加了一次数据就丢了。这就是原子性被破坏了——一段需要整体执行的逻辑被强行“拆散”中间插进了别人的操作。第二个问题是可见性。Java 内存模型里每个线程有自己的工作内存变量副本存在里面不一定会立刻刷回主内存。悲观一点说线程 A 改了某个值线程 B 可能很久都看不到这个新值。极端情况下这个循环会一直转不停boolean running true; while (running) { // 做某件事 }如果另一个线程把running改成 false这个循环在主内存模型下有可能永远停不下来。因为读取操作可能一直命中线程工作内存里的旧副本。第三个问题是有序性。JVM 和 CPU 为了性能会优化指令的执行顺序只要不改变单线程语义a 1; b 2;可能真的先执行 b 再执行 a。单线程无所谓多线程共享数据时顺序一乱别的线程看到的中间状态就很诡异。synchronized恰好能同时约束这三件事锁内的代码块天然原子执行锁的获取和释放强制做内存屏障把工作内存的修改刷回主内存释放锁时也把主内存的最新值读入工作内存获取锁时锁还限制了内部指令重排让临界区代码表现出有序的“串行”效果。1.2 为什么 Java 选择内置锁这条路很多新人会问要解决并发问题不是有AtomicInteger、volatile、Lock一堆工具吗为什么还要学 synchronized答案很简单它是 JVM 内置的、最基础的互斥同步手段。volatile只能保证可见性和有序性保证不了原子性拿它做count这类复合操作救不了场AtomicInteger走的是 CAS 无锁路线适合计数这种单点更新但你要是需要把几行代码一起锁住或者操作一个复杂的共享对象结构CAS 就不好使了。ReentrantLock虽然强大但它是个 API 层面的东西用完后必须手动 unlock一忘就出大事。synchronized由 JVM 直接管理方法正常结束或抛异常都会自动释放锁这是它最大的护城河也是为什么至今没有哪个新语法能完全取代它。而且从性能上看早期的 synchronized 确实慢重量级锁频繁做内核态切换被人吐槽过很多年。但 JDK 6 以后 JVM 对内置锁做了大量优化引入偏向锁、轻量级锁、锁粗化、锁消除等机制现在 synchronized 和 ReentrantLock 在通常场景下的性能差距已经小到基本可以忽略。这也是我长期在项目里优先推荐 synchronized 的原因先用最简单的方案把正确性保住真的到了需要可中断、可超时、公平排队这些能力的时候再考虑换成显式锁。2. 三种用法和一个避坑清单synchronized用起来花样不多但“会写”和“写对”是两码事。它一共有三个落点实例方法、静态方法、代码块。很多人三句话就能背下来可一到实际选型就懵到底该锁方法还是锁代码块锁对象填什么下面把三种写法和它们背后的锁目标拆开讲。2.1 三种写法的锁目标第一种同步实例方法。public class Account { private int balance; public synchronized void transfer(int amount) { balance amount; } }这种写法的锁对象是this也就是调用这个方法的那个实例。两个线程如果操作的是同一个 Account 对象transfer 方法就会被互斥执行如果操作的是两个不同的 Account 对象锁就完全失效。这个特点我常跟团队说实例方法的锁粒度是“对象级”的天然适合保护当前对象的内部状态但不适合保护跨对象的共享资源。第二种同步静态方法。public class Stats { public static synchronized void record() { // 操作静态变量或全局资源 } }锁对象是 Class 对象比如Stats.class。因为 Class 对象在 JVM 里是全局唯一的所以静态方法锁天然就是“全局锁”所有线程不管创建了多少个实例都在这同一把锁上排队。适合保护静态变量、应用级配置这类东西。第三种同步代码块。public class SafeList { private final ListString list new ArrayList(); public void add(String item) { synchronized (list) { list.add(item); } } }锁对象可以是你指定的任意对象灵活性最高能精确控制锁的范围——只锁真正需要保护的几行代码。这也是我最推荐日常优先使用的写法后面讲锁粒度时你就会看到它有多重要。为了看效果我让 A 同学用一个简单测试验证了两把不同的“锁钥匙”用同一把钥匙开两个同步块线程会互斥用两把不同的钥匙线程各进各的门。这就像家里大门用了两把完全不同的锁A 拿 1 号钥匙B 拿 2 号钥匙都能开门互不干扰——也就不存在“锁”这个行为了。2.2 锁对象选择的关键细节这里有几个我在代码评审里反复强调的坑相当隐蔽能帮你在项目里避开七八成低级事故。第一不要用 String 字面量或者通过intern()出来的字符串当锁。JVM 的字符串常量池会复用字面量你这边锁LOCK别的地方有个库也在锁LOCK两拨毫无关系的代码就会莫名其妙互斥。A 同学就遇到过项目里某个内部组件用了balance-lock当锁结果另一个团队也用了同一个字符串字面量两个不相关的模块就此互相拖慢。改成一个独立的private static final Object LOCK new Object()就彻底解决了。第二锁对象不能是 null。synchronized (null)会在运行时直接抛NullPointerException而且代码里根本看不出来。如果你把锁对象设计成可以外部传入的一定要做判空否则线上很容易炸在莫名其妙的地方。第三不要把锁对象重新赋值。看这段代码public class Widget { private Object lock new Object(); public void reset() { lock new Object(); // 灾难现场 } public void operation() { synchronized (lock) { // ... } } }reset()一旦被调用这把锁的“钥匙”就换了。线程 A 持着旧对象的锁线程 B 获取新对象的锁两把锁完全不互斥你的临界区等于裸奔。所以锁对象最好声明为final从语法层面就堵死重新赋值的路。第四锁对象的可见性要注意。如果你在项目里用synchronized (someObject)而someObject的引用可能被别的线程修改那锁的“身份”都变了保护效果随之失效。这一点和上面那条类似但更隐蔽建议所有锁对象统一用private final持有绝不外部暴露 setter。关于用法我平时的习惯是需要保护多个方法的公共状态时用实例方法锁或者一个private final的锁对象需要保护一段短代码时用代码块锁并精确定位需要保护静态资源时用静态方法锁或SomeClass.class。千万别无脑地把整个方法都锁上锁的范围越大并发度就越低。3. 底层原理从字节码到对象头的完整链路synchronized的语法虽然简单但底层链路其实挺深。面试喜欢问出了线上问题排查也需要理解。这里我带你把这条链路走一遍从字节码指令到 JVM 的监视器再到对象头里的标记字。很多人以为 synchronized 是什么高深的内核机制其实它落地到 JVM 就是两个字节码指令monitorenter和monitorexit。理解这两个指令就等于拿到了一半答案。3.1 字节码视角monitorenter 和 monitorexit先看一个最简单的同步代码块public void demo() { synchronized (this) { System.out.println(hello); } }用javap -c反编译能看到关键字节码0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #2 // Field System.out 7: ldc #3 // String hello 9: invokevirtual #4 // Method println 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 18: aload_1 19: monitorexit 20: aload_2 21: athrow 22: return注意看这里有两个monitorexit一个是正常退出时执行的另一个藏在异常处理器里——一旦同步块中间抛异常JVM 会走异常路径在弹出异常前再执行一次monitorexit释放锁。这保证了你不用手动写 try-finallyJVM 自动帮你兜底。这就是 synchronized 和 ReentrantLock 最大的使用差异内置锁绝不产生“忘记释放”的问题。方法的同步和代码块的同步在字节码上略有不同。用javap -v看一个同步方法不会出现monitorenter/monitorexit而是在方法表里多了一个ACC_SYNCHRONIZED标志。JVM 的调用指令看到这个标志就知道在进入该方法前需要先获取监视器锁方法正常返回或异常结束时自动释放。两条路径殊途同归最终都落到 Monitor 上。3.2 对象头里的“黑盒”锁最终要去标记某个对象JVM 是怎么把“锁状态”记录下来的答案是对象头Object Header。一个普通 Java 对象在堆内存里前 8 个字节32 位或 12 个字节64 位的头部里有一块叫Mark Word的区域。它专门用来存储对象自身的运行时数据包括哈希码、GC 分代年龄以及锁状态标记。不同锁状态下Mark Word 的二进制排布是不同的锁状态Mark Word 的部分关键位说明无锁01存哈希码、分代年龄等偏向锁01存偏向线程 ID、偏向时间戳轻量级锁00存指向栈中锁记录的指针重量级锁10存指向 Monitor/ObjectMonitor 的指针GC 标记11与锁无关是 GC 回收阶段用的这个区域的复用逻辑有点像便利店的储物柜同一个柜子白天放顾客包包晚上放补货商品不同时段塞不同东西。Mark Word 就是那个储物柜锁状态一变里面装的内容就切换。你可能会问为什么锁信息要放在对象头里而不是另开个字段因为 JVM 对内存占用极度敏感任何一个普通对象如果都额外负担锁字段堆内存直接膨胀。放在 Mark Word 里等于“按需借用”不锁的时候这块空间还能保存哈希码等数据。3.3 Monitor 监视器与线程阻塞真正的“锁”本体在重量级锁状态下是一个 C 层的ObjectMonitor对象。它内部维护着_owner当前持有锁的线程_WaitSet调用了wait()后进入等待状态的线程集合_EntryList正在竞争锁、尚未获取到锁的线程集合_recursions重入计数同一个线程重复获取该锁时计数加一当一个线程执行monitorenter时JVM 会尝试把_owner设置为当前线程。如果成功说明拿到锁如果_owner已经被别的线程占了当前线程就进入_EntryList阻塞等待这个过程中涉及操作系统的线程挂起与唤醒用户态到内核态的切换成本非常高——这也是早期 synchronized“慢”的根源。重入机制这里也值得说透。同一个线程可以多次获取同一把锁因为_recursions计数会累加而不是直接失败。这和“拥有一把锁后再次打开同一扇门”是一个道理你自己持有钥匙自然不会锁住自己。有了重入机制同步方法里调用另一个同步方法才不会死锁public synchronized void outer() { inner(); // 不会死锁因为锁可重入 } public synchronized void inner() { // 同一线程再次获取同一把锁计数加一 }到这里你已经知道锁的“主线”了。但现代 JVM 显然不会傻到每次都用重量级锁所以下面这一节讲 JVM 的锁优化才是判断你 Java 并发功底的关键。4. JVM 的锁优化偏向锁、轻量级锁与重量级锁如果 synchronized 一上来就是重量级锁那性能确实没法看毕竟用户态/内核态切换一次的成本相当可观。JDK 6 以后 JVM 引入了锁升级机制让锁随着竞争激烈程度动态进化。这个进化方向是无锁 → 偏向锁 → 轻量级锁 → 重量级锁注意这个方向是单向的、不可逆的。锁的等级只能从低级升到高级不会降回来至少经典 HotSpot 实现里是这样。下面按这条线逐个展开顺便说说这些优化为什么能成立。4.1 锁膨胀的完整过程先从偏向锁说起。它的假设非常朴素大多数情况下一把锁不会被多个线程竞争更多时候是一个线程反复进入同一个同步块。既然只有一个人在用这扇门那这把锁就没必要每次都做一个完整的加锁解锁流程。JVM 的做法是第一次获取锁的线程会把自身 ID 写入 Mark Word 中的偏向位。之后这个线程再次进入同步块时只需要检查一下“偏向线程还是我”不需要做任何 CAS 或系统调用开销几乎为零。但是如果另一个线程来竞争了偏向锁就撑不住了。JVM 需要等到一个安全点Safe Point然后撤销偏向膨胀为轻量级锁。如果线程 A 持有偏向锁期间来了线程 BB 会先尝试用 CAS 把 Mark Word 换成指向自己栈帧中锁记录的指针。如果替换成功锁就归 B替换失败说明 A 还占着这块“地”轻量级锁就继续膨胀。轻量级锁的本质是用 CAS 避免重量级锁的系统调用开销。加锁时线程会在自己的栈帧里创建一个锁记录Lock Record把对象头里的 Mark Word 复制过去然后用 CAS 把这个副本地址写回对象头。如果成功当前线程获得锁如果失败说明有竞争进入自旋。自旋是轻量级锁解决短竞争的手段线程不立刻挂起而是空转几个 CPU 周期反复尝试获取锁。这就像在餐厅排队如果判断前面的人只是买单马上就走站在原地等一下比出去逛一圈再回来效率高得多。JDK 6 之后的锁是自适应自旋JVM 会根据上次自旋等待的成功率来决定这次自旋多久所以不要太担心操作系统级的挂起唤醒。但自旋也救不了长时间竞争如果竞争线程迟迟拿不到锁JVM 就会把轻量级锁膨胀为重量级锁走 Monitor 操作系统互斥量那一套让多出的线程真正阻塞挂起避免它们白白消耗 CPU。我让 A 同学测过一把被激烈争抢的锁线程总数多、持锁时间长时最终锁状态基本都会膨胀到重量级锁。这个现象很正常JVM 不是不想保持轻量级而是竞争条件不允许。如果你发现锁频繁膨胀先别急着怪 JVM大概率是锁粒度或者并发设计出了问题。4.2 锁消除与锁粗化除了锁升级JVM 还有两个编译期优化你可能没听过锁消除和锁粗化。锁消除发生在 JIT 编译阶段。比如 Java 标准库里的StringBuffer是线程安全的方法上都加了 synchronized。但如果你在单线程里创建了一个局部StringBufferJVM 通过逃逸分析能确定这个对象不会被别的线程访问此时它就会把 syncrhonized 锁直接“抹掉”因为它知道锁根本没有竞争对象。锁粗化则相反它针对的是过于频繁加锁解锁的场景。假设你在一个循环里反复进入同一个同步块for (int i 0; i 1000; i) { synchronized (lock) { doSomething(); } }JVM 可能把这些分散的锁合并成一个范围更大的锁减少反复加锁解锁的次数。这算是 JVM 在帮你做“无奈的优化”——如果每次锁范围太小反复进出反而是浪费。这两种优化都是在解释执行阶段或者 JIT 编译后自然发生的你不需要在代码层面做什么但了解它有个重要的实际价值不要因为【过度优化】而刻意把锁范围写得特别碎特别古怪。正常的业务代码该怎么写就怎么写JVM 会在运行时替你补课。当然自己主动避免无用锁肯定没坏处这一点也不矛盾。5. 面试高频synchronized 和 ReentrantLock 怎么选聊到这会儿synchronized已经讲得比较深了但懂行的人还是要问一句既然 JDK 把内置锁优化到这么强那ReentrantLock是不是就没存在价值了答案正好相反。两者定位不同ReentrantLock在三个能力上确实比 synchronized 强下面这张表是我总结的对比。维度synchronizedReentrantLock自动释放锁是退出同步块/异常时自动释放否必须显式 unlock通常配 try-finally锁获取方式阻塞式不可中断可中断、可轮询、可超时公平性默认非公平不可配置可通过构造参数选公平/非公平条件变量只能配合 Object 的 wait/notify支持多个 Condition精确唤醒指定线程灵活性块结构锁必须在同一方法块内加解锁可以跨代码块加锁解锁非块结构性能JDK 6 后优化很强常规场景与 Lock 差距极小高竞争场景表现稳定区别不在“谁更快”而在“谁更能满足特殊要求”。5.1 可中断、非块结构、公平性synchronized有个很致命的问题线程一旦进入锁等待就是不可中断的除非拿到锁或被interrupt()碰到但响应方式有限。而ReentrantLock的lockInterruptibly()方法能够在线程等待锁期间响应中断信号。在写一个需要优雅停机、需要从死锁状态里撤离的后台任务时这个能力几乎是刚需。再说tryLock 超时获取if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 拿到锁做业务 } finally { lock.unlock(); } } else { // 3 秒没拿到走降级逻辑 }这在处理不可控的第三方接口或者资源争抢时非常有用。内置锁做不到“等不了就不等了”这种操作一旦 synchronized 阻塞就真的卡死在那里。公平锁方面ReentrantLock在构造时传入true就可以让等待时间最长的线程优先获得锁减少线程“饿死”的可能。synchronized没有任何公平性配置它默认的非公平策略在极端高并发下可能让某些线程长时间拿不到锁。5.2 我的选择建议我自己的选型经验很明确分三种情况如果只是单纯的互斥保护没有任何特殊需求优先用 synchronized。代码短、不会忘释放、JVM 自动管异常路径团队里任何水平的开发都不会写错。如果需要可中断等待、超时获取、公平排队、多个条件队列这些能力果断用 ReentrantLock。这些需求 synchronized 确实满足不了硬要用 wait/notify 去模拟不但又绕又容易出 bug。如果是新项目团队全栈水准较高也可以用 ReentrantLock 统一管理复杂锁但必须在代码规范里明确写清楚“unlock 一定放 finally”。还有个小技巧如果只是读多写少的场景真正的热门锁反而是ReadWriteLock或StampedLock不过那是另一个话题了。排队原则适合业务锁读多写少适合读写锁这些都是并发工具选型的常识不要一上来就考虑锁先看粒度、频率和竞争程度。6. 实战与踩坑实录聊完理论这部分可能是最值钱的。下面三个案例都是从真实项目里提炼的有具体的错误代码和排查路线你可以直接对照自己的项目自查。6.1 字符串字面量锁引发的“伪并发”项目背景某公共服务里有个 Redis 缓存刷新组件每个用户数据刷新时需要防并发A 同学用synchronized (user- userId)来加锁。synchronized (user- userId) { // 刷新缓存 }看起来每个用户一把锁互不干涉对吧但上线后他发现完全不同的两个用户锁竟然也互相阻塞。排查过程不算复杂先是加了日志发现两个 userId 的线程卡在同一条临界区后来用 jstack 看到两个线程都在等待同一个锁对象锁对象指向一个 String。问题根源就是字符串拼接用了字面量JVM 常量池会把“看起来相同”的字符串直接复用同一个对象于是user- 1和user- 1是同一个对象没问题但user- 1和user- userId在某些 Java 版本下也可能被 intern 成同一个这里最隐蔽的是如果你调用的是(user- userId).intern()那更是灾难全 JVM 的字符串常量池都会被污染。正确的做法是用独立的锁对象或者专门创建的一个不会 intern 的字符串对象最简单的是用一个ConcurrentHashMap或LongAdder维护每个 key 对应的独立锁对象用完再清理。教训永远不要用 String 字面量 / intern 字符串做锁对象。这句话我在团队代码规范里加了醒目注释。6.2 锁粒度不当导致的性能雪崩某系统早期版本里有人把一个大方法整体加了synchronizedpublic synchronized void processOrder(Order order) { checkOrder(order); // 耗时短 callExternalService(order); // 可能几百毫秒 saveToDatabase(order); // 耗时中等 }结果就是并发一高所有订单处理都堵在callExternalService这个外部调用上。整个过程里多个线程排着队一个外部接口慢后面几十个订单全部跟着遭殃。压测的时候吞吐量直接崩掉一半以上。解决方法是缩小锁范围把锁只放在真正需要互斥的共享状态更新上public void processOrder(Order order) { checkOrder(order); callExternalService(order); synchronized (this) { saveToDatabase(order); } }核心原则就一条锁只保护“跨线程共享且需要互斥的那一小段代码”耗时操作能不放锁内就绝不放锁内。同步块里如果还有什么远程调用、IO 等待、重计算先把它们从临界区里挪出去。6.3 常见问题排查速查现象可能原因处理思路加了锁但数据还是不一致锁对象不是同一个String 字面量/被重新赋值/用了不同的类实例当锁打印锁对象 hashCode确认两处代码是否拿到同一个对象性能断崖式下降锁粒度太大或者临界区内有长时间 IO/网络调用用 jstack 抓线程栈看线程是不是大量 BLOCKED缩小临界区范围死锁多个线程获取多个锁的顺序不一致用 jstack 找“Found one Java-level deadlock”统一加锁顺序或用 tryLock 规避线程一直 WAITING调用了wait()但没人notify()/notifyAll()或条件判断不对检查等待循环、通知时机优先用并发容器或 Condition 替换手写 wait/notifysynchronized 锁生效了但系统吞吐量上不去锁竞争过于激烈多个线程反复抢同一把锁考虑读写分离、分段锁、无锁 CAS、或者 Disruptor 这类并发框架排查工具方面jstack绝对是我的第一选择。它能打印线程的完整堆栈以及锁的持有状态哪个线程持着锁、哪些线程在等这把锁一目了然。你还可以把输出保存下来用grep -A 20 java.lang.Thread.State快速定位 BLOCKED/Waiting 的状态。我经常在团队里分享这样一个排查口诀先看是不是锁对象不统一再看锁粒度是不是过大最后才考虑是不是同步机制本身选错。十次并发问题有七八次都出在前两个真正的底层 bug 反而少。还有一个小细节处理synchronized和事务的时候要特别注意。如果你在 Spring 的事务方法上加 synchronized锁是在事务提交前释放的方法返回时而不是在 commit 之后。如果两个请求同时进入同一个方法线程 A 执行完方法、释放锁但事务还没真正提交线程 B 立刻进入了方法读到的可能还是数据库里的旧数据。这种情况你就得把事务和锁的顺序好好设计一下让锁的生命周期覆盖整个提交过程——通常是把事务逻辑抽到一个单独方法里然后用同步块包住事务调用或者反过来。回看这个过程最核心的收获是不要迷信某一个 API也不要指望 JVM 优化替你解决所有问题。真正决定并发程序好坏的是你对“到底在保护什么”的理解以及那种能够缩小锁范围、减少锁竞争的设计习惯。这个细节A 同学最开始也没想明白后来线上排查调优走了一圈回来再看 synchronized整个思路都不一样了。你也一样只要带着这套理解回到项目里再遇到并发问题眼睛就会亮很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑