说实话很多人学 Java 并发上来就啃 JUC 包看ReentrantLock、看ConcurrentHashMap结果一遇到线上问题还是懵。我觉得根子在于最基础的那个synchronized关键字你真的吃透了吗它不仅仅是加个锁这么简单背后牵扯到对象头、Monitor、锁升级、内存屏障甚至 JDK 版本的变化都会影响它的表现。这篇是 Thread 学习的第 6 篇咱们就把synchronized从头到尾捋一遍从用法到字节码从锁升级到排查死锁把这块硬骨头啃下来。这篇内容适合正在进阶 Java 并发、准备面试、或者写了好几年业务代码但对并发还是会用不会说的开发者。我尽量用大白话加源码细节把为什么这样设计讲清楚。看完你再去用synchronized思路会完全不一样。1. 先从最基础的用法说起synchronized 到底锁的是什么很多教程一上来就列三种用法修饰实例方法、修饰静态方法、修饰代码块。但真正要理解synchronized你不能停留在知道怎么加锁要知道它锁的对象是什么因为 Java 的锁本质上是对象头里的一个标记没有对象就没有锁。1.1 三种用法的锁对象你真的分清楚了吗第一种修饰实例方法public class Counter { private int count 0; public synchronized void increment() { count; } }这里锁的是this也就是调用这个方法的那个实例对象。两个线程如果操作同一个 Counter 实例会互斥但如果是两个不同的 Counter 实例各加各的锁完全不互斥。这是新手最容易踩的坑写完同步方法以为万事大吉结果 new 了两个对象出来测试发现计数还是乱的。第二种修饰静态方法public class Counter { private static int count 0; public static synchronized void increment() { count; } }锁的是Counter.class这个 Class 对象。Class 对象在 JVM 里是全局唯一的所以无论 new 多少个实例静态同步方法都是同一把锁天然全局互斥。第三种同步代码块public void someMethod() { synchronized (lockObject) { // 需要保护的代码 } }锁的是括号里指定的那个对象可以是this、可以是某个实例属性、也可以单独造一个专门用于加锁的对象。这种用法最灵活也是实际开发中用得最多的因为它能控制锁的粒度。注意lockObject 不能是 null。如果你写synchronized (null)编译不报错运行直接抛NullPointerException因为 JVM 加锁时要去拿这个对象的 Monitornull 连对象都没有拿什么加锁。1.2 锁对象的选择是并发设计第一个要考虑的问题我在实际项目里见过最典型的锁粒度失控就是给整个方法加synchronized方法体里有一大堆和共享资源无关的业务逻辑。比如一个支付回调接口里面有签名校验、订单查询、数据库更新、通知商家、写日志结果为了一个共享的订单状态字段把整个方法给锁了。并发量一上来操作耗时全被串行化接口 RT 直接翻了几倍。正确的思路是锁的范围尽量小锁的持有时间尽量短。只把真正操作共享资源的那几行代码放进同步块。比如public void processOrder(Order order) { // 不需要同步的校验逻辑 validate(order); // 只锁需要保护的临界区 synchronized (orderLock) { if (order.getStatus() OrderStatus.PENDING) { order.setStatus(OrderStatus.PROCESSING); orderService.save(order); } } // 不需要同步的后续逻辑 notifyUser(order); }还有一个隐蔽的坑锁字符串常量或者 Integer 缓存对象。比如private final String LOCK lock; synchronized (LOCK) { ... }字符串常量在 JVM 里会被常量池缓存两个完全没有业务关系的类如果恰好用了同一个字符串做锁它们居然会互相阻塞。同理synchronized (Integer.valueOf(1))也是危险操作因为 -128 到 127 的 Integer 是缓存的等于所有代码都在抢同一把全局锁。锁对象一定要用私有、唯一、不会被外部持有的实例。2. synchronized 的底层真相从字节码到 Monitor你光知道用法还不够面试官一问synchronized底层怎么实现的就卡壳。这一节我们直接翻字节码看看 JVM 到底干了什么。2.1 monitorenter 和 monitorexit字节码层面的加锁与解锁写一段简单的代码public class SyncDemo { private final Object lock new Object(); public void test() { synchronized (lock) { System.out.println(hello); } } }然后用javap -v SyncDemo.class反编译关键字节码长这样public void test(); descriptor: ()V flags: ACC_PUBLIC Code: stack2, locals4, args_size1 0: aload_0 1: getfield #2 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: getstatic #3 // Field System.out 10: ldc #4 // String hello 12: invokevirtual #5 // Method PrintStream.println 15: aload_1 16: monitorexit 17: goto 25 20: astore_2 21: aload_1 22: monitorexit 23: aload_2 24: athrow 25: return Exception table: from to target type 7 17 20 any看到没有核心就是monitorenter和monitorexit两条指令。JVM 规范规定执行monitorenter时必须尝试获取对象的 Monitor 所有权执行monitorexit时释放 Monitor 所有权。注意看异常表7到17之间如果抛了任何异常会跳到20行重新执行monitorexit释放锁然后再把异常抛出去。这就是synchronized能保证异常自动释放锁的根本原因不是 JVM 有什么魔法是编译器帮你生成了异常处理路径。你平时写synchronized不用像ReentrantLock那样手动finally解锁靠的就是这段隐藏的字节码。2.2 Monitor 到底是什么为什么 wait/notify 必须和 synchronized 一起用字节码层面的monitorenter只是入口真正干活的是一台叫ObjectMonitor的锁管理器。每个 Java 对象在 JVM 内部都关联一个 ObjectMonitor严格来说是重量级锁阶段才创建它的核心属性是_owner当前持有锁的线程_count重入次数_WaitSet调用 wait 后挂起的线程队列_EntryList等待获取锁的线程队列用生活类比ObjectMonitor 就像一个酒吧只有一个座位。_owner是坐在座位上的那位_count是这位点了多少杯酒重入想坐的人先到_EntryList排队被服务员请出去站着等叫号的wait在_WaitSet里。这里就能解释一个经典面试题为什么 wait 和 notify 必须在 synchronized 块里调用因为wait()的本质是释放 Monitor 所有权然后线程进入_WaitSet挂起。如果当前线程根本就没持有 Monitor它凭什么释放所有权所以 JVM 设计上就强制要求调用wait/notify之前必须先持有 Monintor这套状态机才走得通。违反会抛IllegalMonitorStateException。还有count这个字段解释了为什么synchronized是可重入的。同一线程多次进入 synchronized 代码块_count会累加退出一次减一次减到 0 才真正释放锁。比如一个同步方法调用另一个同步方法锁的都是 this不会把自己死锁。对比一下早期一些操作系统原语锁是不重入的重入直接死锁Java 这层设计对开发者友好了太多。3. 锁升级JDK 6 之后 synchronized 性能逆袭的关键在 JDK 6 之前synchronized被叫重量级锁是有道理的因为每次加锁都直接走 ObjectMonitor涉及到用户态和内核态的切换性能确实难看。但从 JDK 6 开始JVM 对 synchronized 做了一整套锁优化机制性能已经不输ReentrantLock甚至在某些场景下更好。这套机制就是锁升级。3.1 无锁到重量级锁的四级演进路径锁升级的大致路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。每一次升级都是竞争加剧了不得不用更重的手段。偏向锁JVM 发现同一线程反复获取同一把锁干脆在对象头的 Mark Word 里记录这个线程的 ID。下次这个线程再来直接判断 ID 是否匹配匹配就把锁偏向给它不用做任何 CAS 操作效率极高。这就像图书馆的固定座位老是坐同一个位置的人管理员已经记住了不用每次验票。轻量级锁一旦来了第二个线程竞争偏向锁会撤销晋级为轻量级锁。轻量级锁用 CAS 自旋的方式尝试获取锁线程不会立刻阻塞而是忙等。线程还在用户态打转没有切到内核态所以效率比重量级高。但自旋是消耗 CPU 的如果竞争太激烈自旋一直在失败JVM 就会继续升级。重量级锁大量线程竞争时没抢到锁的线程会被挂起进入_EntryList这时候才真正调用操作系统层面的互斥量涉及内核态切换。这就是老版本 synchronized 慢的根本原因。用一句话总结JVM 会尽量让锁轻只有被迫无奈才变重。3.2 对象头 Mark Word 到底长什么样要理解锁升级必须先看懂对象头。HotSpot 虚拟机里对象头分两部分其中 Mark Word 存储对象自身的运行时数据包括哈希码、GC 分代年龄、锁状态标记。64 位 JVM 下 Mark Word 有 64 个 bit不同锁状态下的布局完全不同用表格看得特别清楚锁状态锁标志位后2bitMark Word 关键内容无锁01对象哈希码、GC 分代年龄偏向锁01线程ID、偏向时间戳、epoch轻量级锁00指向栈中锁记录的指针重量级锁10指向 ObjectMonitor 的指针GC 标记11空不参与锁有意思的是无锁和偏向锁的标志位都是 01怎么区分看第一位偏向锁的 Mark Word 里有一个biased_lock位是 1 代表偏向锁0 代表无锁。JVM 初始化一个对象默认是偏向锁状态还是无锁取决于启动参数实际上在 JDK 15 之前偏向锁默认开启但由于维护成本高JDK 15 开始默认禁用偏向锁了这是另一个值得聊的坑后面细说。轻量级锁的指向栈中锁记录的指针要理解一下线程在栈帧里创建一条锁记录Lock Record用 CAS 把对象头的 Mark Word 替换成锁记录的地址。如果替换成功锁就拿到了失败说明有人抢开始自旋。退出时反向 CAS 替换回来。3.3 实操中踩过的锁升级坑偏向锁延迟 4 秒刚学锁升级的人拿例子跑 JVM 参数测偏向锁结果发现加锁之后压根没有进入偏向锁状态直接跑到轻量级锁去了。网上查资料才知道HotSpot 故意做了偏向锁延迟默认 JVM 启动后 4 秒内新建对象的偏向锁是关的。原因是 JVM 启动初期会有大量并发对象竞争激烈偏向锁在这种场景下意义不大还不如直接走轻量级锁免得反复撤销浪费性能。这个参数叫-XX:BiasedLockingStartupDelay4000默认就是 4000 毫秒。如果测试的时候想立即体验偏向锁可以在启动参数加-XX:BiasedLockingStartupDelay0另外还有一个批量重偏向的机制当同一个类的对象频繁发生偏向锁撤销且撤销次数超过阈值默认 20 次JVM 会对这个类的所有对象执行批量重偏向把里面偏向的线程 ID 更新一遍。超过 40 次-XX:BiasedLockingDecayTime25000相关的批量撤销阈值就会批量撤销整个类的偏向锁直接关闭。这些参数不常用但面试问到锁升级的边界条件能答出偏向锁撤销的批量阈值绝对是加分项。4. 锁的可见性与内存屏障synchronized 还管了哪些事很多人以为synchronized只是保证原子性排队执行而已。其实它还有一个隐形的能力就是保证临界区内共享变量的可见性。这个概念如果不理解你会在排查并发问题时走弯路。4.1 为什么有了锁其他线程还能看到旧值我先抛一个问题线程 A 在 synchronized 块里修改了一个普通变量线程 B 进入同一个对象的 synchronized 块它一定能看到线程 A 的修改吗答案是是的JMMJava 内存模型规定了 monitor lock 的 happens-before 规则。具体来说解锁操作 happens-before 后续对同一把锁的加锁操作。也就是说线程 A 解锁前的所有写操作对线程 B 获取同一把锁之后的所有读操作都是可见的。底层逻辑是内存屏障。JVM 在monitorenter之后插入一个读屏障在monitorexit之前插入一个写屏障。写屏障会把当前线程工作内存里的改动强制刷新到主内存读屏障会把主内存的最新值加载到工作内存。类似 double-checked locking 为什么这么写private volatile Singleton instance;这里volatile的写操作会插入 StoreStore StoreLoad 屏障避免指令重排导致半初始化对象被发布出去。synchronized 块内部自动有内存屏障但对象创建和逸出这一步如果是无锁的就需要 volatile 兜底。4.2 synchronized 和 volatile、ReentrantLock 的三角关系明白 synchronized 能保证可见性之后你再看volatile就清楚多了。volatile 只保证可见性和有序性不保证原子性。它适合单个共享变量的读写标记这种场景比如状态开关、枚举切换用 volatile 一个修饰符就够负重远小于加锁。ReentrantLock 是 API 层面的锁底层基于 AQSAbstractQueuedSynchronizer。和 synchronized 最大的区别是ReentrantLock 支持超时拿锁tryLock(3, TimeUnit.SECONDS)synchronized 一旦阻塞就是无限期阻塞ReentrantLock 支持公平锁构造参数传 truesynchronized 是非公平的ReentrantLock 有 Condition 条件队列可以多路 wait/notifysynchronized 是 JVM 内置异常自动释放锁ReentrantLock 需要 finally 手动释放一个实际的选型建议能用 synchronized 就用 synchronized。JDK 16 之后 synchronized 做了很多优化普通场景性能已经不吃亏而且代码更简洁、不容易写漏 unlock。ReentrantLock 只在你真的需要超时控制、可中断、公平锁、多 Condition 时才考虑。别为了显示自己会用高级 API而把一个简单的并发问题复杂化这是我见过非常多的过度设计现场。5. 线程间协作synchronized 与 wait/notify 的完整配合synchronized单独用只能控制互斥但真实业务里线程之间还要通信。生产者-消费者模型就是最典型的多线程协作场景这里需要wait/notify和synchronized一起工作。这部分虽然基础但在项目里写错的人真的不少我们直接上一个标准范式。5.1 手写一个正确的生产者-消费者模型写一个最简单可靠的实现用synchronizedwaitnotifyAllpublic class TaskQueue { private final LinkedListString queue new LinkedList(); private final int capacity 10; public synchronized void put(String task) throws InterruptedException { while (queue.size() capacity) { // 队满生产者挂起 wait(); } queue.addLast(task); // 唤醒可能等待中的消费者 notifyAll(); } public synchronized String take() throws InterruptedException { while (queue.size() 0) { // 队空消费者挂起 wait(); } String task queue.removeFirst(); notifyAll(); return task; } }这里有两个关键的细节我逐一解释很多人写错就是错在这两点。第一判断条件必须用 while 循环不能用 if。如果用 if线程被唤醒后会直接从 wait 后面继续执行不会再检查队列是否还满着。比如两个生产者同时被唤醒其中 A 先加了一个任务B 接着往下执行此时队列可能已经满了B 再 add 就会越界。用 while 是唤醒后重新判断一次这就是所谓的虚假唤醒防护。Javadoc 里明确建议在循环中调用 wait。第二必须用 notifyAll 而不是 notify。notify 只随机唤醒一个线程如果唤醒的是同类线程比如队满时唤醒的是生产者那所有消费者就永远没机会被唤醒了程序会假死。notifyAll 把该锁上所有 WaitSet 的线程都唤醒虽然会有一些线程发现条件不满足继续 wait但至少不会死锁。性能上你可以说 notify 更高效但正确性永远是第一位的先保证对再考虑优化。5.2 Lock 和 Condition 替代方案对比如果你觉得 wait/notify 的写法容易踩坑ReentrantLock提供了Condition允许更精确地定向唤醒private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); public void put(String task) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { notFull.await(); } queue.addLast(task); notEmpty.signal(); } finally { lock.unlock(); } }它可以只唤醒消费者而不惊动其他生产者从语义上更清晰。但付出的是 API 复杂度必须记得 finally 里 unlock、await 可能抛中断异常。生产环境如果已经引入了 ReentrantLock 做其他事情顺便用 Condition 很自然但如果只是为了生产者-消费者模型我仍然推荐先从 synchronized 入手写对了再说。6. 高频面试题与线上问题排查实战synchronized这块的知识点面试常考的不只是概念还有实际问题的排查能力。我把自己遇到的和朋友项目里出现过的问题整理一遍这些都是纸上学不到的经验。6.1 死锁的排查jstack 的使用方法经典的交叉锁死锁代码大家都会写两颗锁互相等待。但是在真实环境里要找到死锁点靠肉眼读代码是不靠谱的。正确流程是先通过jps找到 Java 进程 PID然后执行jstack 12345 thread_dump.txt打开 dump 文件找到类似这样的片段Thread-1: waiting to lock 0x000000076ab59bd8 (a java.lang.Object) ... Thread-0: waiting to lock 0x000000076ab59b88 (a java.lang.Object) ...两个线程互相等待对方的锁一目了然。jstack 全局搜索 Found one Java-level deadlockJVM 甚至会直接帮你标记死锁线程。我建议每个 Java 开发者在写并发代码前先学会jpsjstack这两条命令的熟练度应该和git status一样高。线上定位死锁的速度直接决定一次故障的 MTTR。6.2 锁竞争激烈导致 CPU 飙升怎么定位曾经碰到过一个现象服务 CPU 持续 100%线程池里的线程大量处于 BLOCKED 状态。用jstack抓 thread dump看到大量线程卡在同一个synchronized方法上。定位到代码后发现是有人在热路径上用了重量级锁并且临界区里有远程调用锁持有时间长达几百毫秒后面的线程全部排队。这种问题的本质不是 synchronized 不好用是锁粒度太粗 临界区做了 IO 操作。synchronized 块里绝对不要做耗时操作包括但不限于远程 RPC、数据库访问、文件写入、Thread.sleep。锁住的应该是内存里共享变量的读写这种微秒级操作。把远程调用移出锁外QPS 翻倍是常有的事。如果实在无法避免可以考虑读写分离读多写少的场景用ReentrantReadWriteLock或者StampedLock让读线程并行。但注意读写锁的写线程会饥饿StampedLock 的乐观读也不能保证一致性用之前先压测验证。6.3 同步方法能跨方法传递锁吗锁的重入边界在哪一个经典面试延展题synchronized 方法里调用另一个 synchronized 方法锁会重入但是如果递归调用了 synchronized 方法重入计数会不会出错答案是不会。JVM 的锁重入是按线程 Monitor 匹配的。同一个线程持有锁再次monitorenter同一个 Monitor只是把_count加一。递归调用深度再多只要最后对称地退出_count一定能归零。这是 JVM 层面的保障不是靠编码约束。理解这一点对排查看似死锁实际没死锁的本地调用链特别有帮助。6.4 synchronized 在 JDK 15 之后的默认行为变化你注意过吗前面提到了偏向锁的启动延迟。从 JDK 15 开始JEP 374 明确默认禁用了偏向锁原因是偏向锁的撤销逻辑在高并发时代成了维护负担。这意味着如果你还在用 JDK 15想复现无竞争时加锁零开销的偏向锁场景默认是跑不出来的除非显式加-XX:UseBiasedLocking参数。这个变化很多系统升级 JDK 后没有感知因为业务无感但做性能对比测试时如果你拿着旧 JDK 的数据和新 JDK 对比会得出新版 JDK 变慢了的错误结论。6.5 一个容易被忽略的优化锁粗化与锁消除JVM 有两个反向优化你可能听过但没深究过。锁粗化如果 JVM 发现一段代码里连续多次加锁解锁且中间没有线程切换的必要它会把锁的范围扩展到一起减少重复加锁的开销。比如 for 循环内部每次循环都 synchronized 一个局部变量JVM 可能直接把锁挪到循环外面。锁消除JVM 的逃逸分析发现锁对象不会被外部线程访问干脆把锁整个删掉。比如一个局部对象只在当前线程内使用你给它加synchronized也是白加。这两个优化说明一个重要事实JVM 比你想象的更聪明。所以在写代码时不要为了优化去手动做锁粗化把多个同步块合并成一个这反而会降低并发度。保持临界区小而正确把优化交给 JIT。7. 实战经验总结我踩过的四个 synchronized 大坑这一节我聊一些纯粹的经验之谈。有些坑看起来很低级但真到了凌晨两点排查线上线程阻塞的时候你才会发现越是基础的坑越是难发现。第一个坑锁对象被重新赋值。代码里写了一个私有锁对象但某个方法里不小心执行了lock new Object()。好了旧的锁是 A新的锁是 B后面所有同步块都用了 B但正在等 A 的线程永远等不到直接变相死锁。锁对象必须是 final 的从语法层面杜绝这个问题。第二个坑锁字符串常量池导致无关业务互相阻塞。多个服务在同一个 JVM 里如果共享同一个字符串常量的锁业务毫无关系却彼此拖慢。我前面已经强调过这里再提一遍是因为我在一个实际故障里见过这种情况。处理方式是每个模块用自己独立的锁对象最好把锁对象命名为private final Object lock new Object();干净利落。第三个坑Thread.sleep 放在 synchronized 块里。有人觉得这样能慢一点让其他线程有机会执行实际上持有锁睡觉其他线程全部干瞪眼等着。正确的做法是如果需要在锁内让出执行权用wait()它会释放锁sleep 不会释放锁。这俩一字之差线上表现是天壤之别。第四个坑synchronized 保护了错误的数据结构。共享变量是 HashMap你给所有 put 和 get 都加了 synchronized结果迭代遍历时没加锁导致ConcurrentModificationException。或者共享的是 SimpleDateFormat你在方法内部 new 了一个局部变量认为局部变量是线程安全的没错但你把对象作为锁传给了其他线程整个模型就乱了。保护什么资源就用什么资源作为锁这个对应关系必须清晰。提示如果你不确定当前锁是否能覆盖临界区所有路径先停下来画一下哪个线程操作哪个共享资源的关系图再决定锁的放置位置。慢一点但比线上故障快得多。8. 从 synchronized 到 JUC一张图理清并发工具的关系聊了这么多 synchronized最后把这个知识点放进 Java 并发工具的坐标系里方便你做技术选型时候的取舍。场景推荐工具理由单对象多个字段的原子更新synchronized简单、自动异常释放、可重入单个共享标记位/状态开关volatile零锁开销语义最轻需要超时拿锁/可中断等待ReentrantLocktryLock lockInterruptibly读多写少的缓存场景ReentrantReadWriteLock / StampedLock读写分离提升读并发高并发计数器AtomicLong / LongAdderCAS 无锁操作LongAdder 适合高争用多个 Condition 定向通知ReentrantLock Condition避免 notifyAll 全量唤起的开销简单来说并发控制的选择是一个开销从低到高、能力从弱到强的光谱。读多写少可以用 volatile 或者读写锁单变量用 CAS复合操作用 synchronized。那些一上来就推ConcurrentHashMap、推 AQS 的在简单场景下往往是杀鸡用了牛刀。再说回同步关键字本身。synchronized是 Java 并发编程的地基你后面学的ReentrantLock、CountDownLatch、Semaphore都建立在同一个内存模型和锁思维之上。把 Monintor 模型、对象头布局、锁升级路径、wait/notify 语义这几个底层概念吃透比背一百个 API 用法要重要得多。我自己在实际开发里有一个习惯面试别人时先问 synchronized 的锁对象是什么回答不上来、回避、误解的基本可以判断并发功底还停留在背 API 阶段。不是因为 synchronized 有多难而是因为它像一个仪表盘——上面显示着你对 JVM、内存模型、并发设计三个方向的真实理解程度。在你后续自己写任何并发代码的那一刻先默念一遍这句检查锁对象选对了吗临界区足够小吗锁内有没有 IO 操作异常路径会不会持锁不放这四问全部过关再提交代码心里就有底了。