
任何一个写过并发代码的 Java 开发者都绕不开synchronized这个关键字。从最早的 Java 版本到今天它一直是解决多线程数据竞争、保证数据一致性的最基础手段也是面试中从初级到高级都会被反复追问的考点。很多人背下了“synchronized 是重量级锁”这句结论但真问到底层怎么实现的、实例方法和静态方法锁的到底是不是同一个东西、为什么 JDK 6 之后性能大幅提升往往就卡壳了。这篇文章不打算做成概念堆砌我会按自己实际使用和排查问题的经验把synchronized的三种写法、锁的底层原理、与volatile和ReentrantLock的选型、以及面试常考的坑逐个拆开讲。无论你是刚学 Java 基础的新手还是准备 Java 面试的开发工程师或者是线上并发问题排查到头疼的维护者这里面都有可以直接拿去用的东西。1. 三种写法和锁的粒度先搞懂 synchronized 到底在锁什么Java synchronized关键字一共有三种使用位置修饰实例方法、修饰静态方法、包裹同步代码块。很多人知道写法但没搞清楚每种写法锁的对象是什么结果代码写得热闹并发问题一点没解决。我觉得理解synchronized的第一道门槛不是背语法而是搞清楚“锁”挂在哪个对象身上。1.1 实例方法加锁锁的其实是 this看这段代码public class Counter { private int count 0; public synchronized void increment() { count; } }increment()是实例方法加上synchronized之后锁对象就是调用这个方法的那个实例也就是this。多个线程同时访问同一个Counter对象时只有一个线程能进入increment()其他线程都得在入口等着但如果访问的是两个不同的Counter实例那各自用自己的锁彼此完全不影响。我见过不少刚入门的朋友在这里踩坑他们写了一个工具类里面有个synchronized实例方法然后每次调用都new一个工具类对象一边抱怨同步没效果一边不知道问题出在自己身上。锁是挂在对象上的你每次都用新对象等于每把锁配了不同钥匙线程之间当然互不干扰。1.2 静态方法加锁锁的是 Class 对象把synchronized加到静态方法上锁对象就不再是实例而是这个类的Class对象public class Counter { private static int total 0; public static synchronized void addTotal() { total; } }Class对象在 JVM 里是全局唯一的所以不管创建多少个Counter实例所有线程访问addTotal()时用的都是同一把锁。这也是为什么很多人用静态synchronized方法来实现全局唯一的计数器或者缓存清理逻辑。搞清楚这一点你就明白了一个经典的并发陷阱一个类里同时有synchronized实例方法和synchronized静态方法它们之间是不互斥的。原因很简单一个锁在this上一个锁在Class对象上两把锁毫无关系。如果你期望它们互斥那代码逻辑就是错的。1.3 同步代码块锁的范围和对象都可以自己控制同步代码块是灵活性最高的一种写法你可以直接指定锁对象也可以把同步范围从“整个方法”缩小到“关键几行代码”public void process() { // 不需要同步的代码 String name buildName(); synchronized (lock) { // 只保护这段临界区 doSomethingSafely(name); } }锁对象可以是任意对象但我强烈建议用一个专门的、不会被外部引用的私有对象来做锁而不是用字符串字面量或者Integer缓存值。为什么用字符串字面量做锁比如synchronized (abc)JVM 里所有相同的字符串字面量可能指向同一个常量池对象不同模块如果都拿abc加锁会发生意料之外的全局互斥用Integer做锁也有类似问题因为Integer有缓存Integer.valueOf(1)拿到的可能都是同一个对象。锁的范围一旦被莫名其妙扩大性能问题就跟着来了。1.4 一个容易忽略的细节锁对象不能是 null如果把synchronized (lock)中的lock赋值为null代码不会报编译错误但运行时会直接抛出NullPointerException。这不是什么冷门知识而是我在代码 review 里看到过好几次的真实问题。锁对象本身是要被 JVM 用来记录锁状态的它必须是一个真实存在的对象。你想想连“这个锁”都不存在线程之间拿什么互斥另外还有一点synchronized是可重入的。也就是说同一个线程已经持有某个对象的锁再次进入这个对象锁保护的其他代码块是可以直接进去的不用等自己释放。这个特性保证了一个线程在同步方法里调用另一个同步方法时不会把自己卡死。底层实现里每个对象关联的监视器记录着持有锁的线程和重入次数重入一次计数加一退出一次减一减到零锁才算真正释放。2. 从字节码到对象头synchronized 的底层实现原理如果要面试中级岗位光知道三种写法是不够的面试官会顺着往下问synchronized底层到底是怎么实现的锁信息存在哪为什么现在所说的“重量级锁”不是一开始就是重量级的这一节我把底层这条线拆开讲。2.1 字节码层的 monitorenter 和 monitorexitjava synchronized在编译之后靠的是字节码指令monitorenter和monitorexit。方法级的synchronized会在方法的flags里加上ACC_SYNCHRONIZED而同步代码块则会在代码前后写入这两条指令。一条monitorenter指令含义是“尝试获取对象监视器的所有权”。如果这个对象的监视器计数为 0线程获得锁计数变成 1如果同一个线程再次进入计数继续累加也就是可重入如果监视器已经被其他线程持有当前线程就会进入阻塞等待状态。monitorexit则执行相反的操作释放所有权计数减一。比较关键的一点是monitorexit在字节码里往往会出现两次。一次是正常路径退出时执行另一次是异常路径退出时执行放在异常处理器里确保代码块抛异常时锁也能被释放。我之所以强调这个是因为有人误以为synchronized锁依赖的是“方法结束后自动释放”其实 JVM 是实实在在地在异常路径上做了释放逻辑的这一点和ReentrantLock必须手动unlock是完全不同的设计。2.2 对象头和 Mark Word锁状态就写在对象里Java 的每个对象在内存里都有一块对象头里面有个叫Mark Word的区域长度在 32 位 JVM 里是 4 字节在 64 位 JVM 里是 8 字节。Mark Word里存储的内容是动态的可能保存哈希码、分代年龄也可能保存锁状态信息。在无锁状态Mark Word存的是对象的hashCode和 GC 分代年龄一旦进入偏向锁部分位变成偏向线程 ID升级成轻量级锁后变成指向锁记录的指针到了重量级锁变成一个指向监视器对象的指针。也就是说锁的状态不是单独用一张全局表记录的而是直接编码在对象自己的头部。你每 new 一个对象这个对象天生就带着锁的“状态位”这就是synchronized能局部生效的基础。这也是为什么锁对象不能为 null一个 null 引用的对象根本不存在对象头JVM 无从记录状态自然无法完成加锁。2.3 锁升级的完整路径偏向锁到重量级锁JDK 6 之前大家吐槽synchronized是重量级锁因为每次加解锁都要依赖操作系统的互斥量线程一旦竞争锁失败就要从用户态切到内核态非常伤。JDK 6 做了大量锁优化引入了偏向锁、轻量级锁、自旋锁锁不再是“一上来就那么重”而是随着竞争激烈程度逐步升级。完整的路径是无锁状态对象刚刚创建没有线程竞争。偏向锁第一个线程访问同步块时JVM 把线程 ID 记录到Mark Word。之后这个线程再次进入不用重复竞争直接检查偏向线程 ID 是否是自己是就直接进去。这叫“偏向”意思是锁偏爱同一个线程。轻量级锁一旦有第二个线程来竞争偏向锁会被撤销锁升级为轻量级锁。竞争线程用 CAS 尝试把Mark Word替换成指向自己栈帧中锁记录的指针。轻量级锁底层靠自旋线程不会立刻挂起而是原地循环等待。重量级锁如果自旋次数过多或者竞争线程数量太大锁会升级为重量级锁线程真正进入阻塞状态靠操作系统的互斥量实现。理解了这个过程就能解释很多现象。比如有人写了个多线程程序测试synchronized性能发现单线程跑“还挺快”这就是偏向锁起的作用。又比如高并发下synchronized性能下降本质上是锁不断升级到重量级锁线程上下文切换开销变大。这里要提醒一句偏向锁在高版本 JDK 里已经不是默认推荐方案了。JDK 15 开始默认禁用偏向锁而且这个功能最终被移除原因是在现代高并发应用里偏向锁撤销带来的开销往往比它节省的还多。所以面试时别把旧文章里那套“默认为偏向锁”的理解直接抛出来最好加上“取决于 JDK 版本”这句话。2.4 一个和 wait/notify 易混淆的底层事实synchronized和Object.wait()、notify()经常放在一起用但很多人不知道为什么wait()必须放在synchronized代码块里。原因是wait()的语义是“释放当前持有的锁并等待”如果线程没有持有锁它怎么释放JVM 为此在wait()调用前强制检查线程是否拥有当前对象的监视器没有就抛IllegalMonitorStateException。另外wait()和notify()都存在对象上而不是线程上这一点也常常和synchronized锁对象绑定在一起被考察。notify()并不能具体指定唤醒哪一个线程只会从等待集合里挑一个这在实际多线程协作里是个容易忽略的细节后面面试题部分我再展开。3. 和 volatile、ReentrantLock 放在一起比一比到底选谁并发编程里大家总爱把synchronized、volatile、ReentrantLock放在一起比较。实际项目里选型不是看谁更“高级”而是看场景需要什么。比如你只是要一个 boolean 标志位控制线程停止用volatile就够了要保证几条语句的原子性那就得用synchronized或ReentrantLock。3.1 synchronized 与 volatile 的分工volatile解决的可见性和有序性问题。它保证一个线程修改变量后其他线程能立刻看到新值同时禁止指令重排。但它不解决原子性count这种“读-改-写”复合操作用volatile声明变量也照样会丢更新。所以我的选型经验比较直白如果你保护的只有一个变量而且操作是纯粹的赋值或者读取比如状态开关、配置项优先考虑volatile如果你的临界区是一段代码涉及多行操作或者变量的更新包括读、改、写多个步骤那就用synchronized。别指望用一个关键字包打天下它们解决的问题维度不同。我用一个很常见的例子说明双检锁单例模式Double-Checked Locking。这一步在多数项目里面试都会问到。public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里volatile不是可有可无的。instance new Singleton()不是原子操作它包含分配内存、初始化对象、把引用赋值给变量三步如果instance不加volatile编译器和 CPU 可能重排步骤别的线程拿到的可能是一个“分配了内存但还没完成初始化”的半成品对象。synchronized保证只有一个线程进入临界区volatile保证进入临界区之前其他线程能正确看到instance的最新状态。两个关键字是分工合作不是非此即彼。3.2 synchronized 与 ReentrantLock 的分工ReentrantLock是java.util.concurrent包提供的显式锁常见对比点大概是这些对比维度synchronizedReentrantLock锁获取释放方式自动进入、自动释放手动 lock手动 unlock锁获取中断支持不直接支持支持 lockInterruptibly等待超时不直接支持支持 tryLock(timeout)公平性非公平可构造公平锁条件变量用 wait/notify支持多个 Condition异常处理异常自动释放锁必须在 finally 中释放锁如果只是保护一段简单的临界区我会优先用synchronized因为写法简单、不会出现忘记unlock的问题。需要超时等待、可中断获取、或者多种条件队列时再上ReentrantLock。网上有些文章把ReentrantLock说得全面优于synchronized这是误导。synchronized经过 JIT 优化之后在很多场景下并不比ReentrantLock慢而且代码更不容易出错。3.3 一个常被忽略的 JIT 优化锁消除与锁粗化除了偏向锁这一类运行时优化JIT 编译器还会做锁消除和锁粗化。锁消除的意思是如果 JIT 分析出某个synchronized块只有一个线程访问或者锁对象根本不会被共享它会直接去掉加锁操作。锁粗化则是把相邻的几个同步块合并成一个大同步块减少反复加解锁的开销。这意味着什么意味着你不该用 “JDK 8 的 synchronized 很慢” 这种陈旧观念去做设计。我见过一些项目为了避免 “synchronized开销大”宁可自己写很复杂且不正确的并发控制最后线上问题一大堆。正确的做法是先保证正确性再考虑性能代码写清楚、让 JIT 和 JVM 的优化机制去帮你兜底比一开始就上各种高性能优化技巧要稳得多。4. 面试高频考点与实战排查这些坑你迟早会踩前面几节基本把Java synchronized的技术点理顺了但实际面试和线上环境里还有一个层面是“能不能把抽象概念落到具体场景里”。这一节我结合自己面试别人和被人面试的经验把高频考点和真实踩坑场景集中聊一聊。4.1 面试必问面试题可重入、wait 为什么在 Object 里、锁对象如何选面试官问java synchronized大概率会绕不开这几个问题第一个问题是“synchronized 是可重入的吗可重入是怎么实现的”。这种题考的不只是结论还有底层理解。可重入指的是同一个线程可以多次获得同一把锁底层靠监视器计数累加实现重入一次加一每退出一次减一减到零才释放。如果你只回答“是的可重入”不会扣分但也拿不到加分补上监视器计数这部分才算答到点子上。第二个问题是“synchronized 和 wait/notify 的关系为什么 wait 定义在 Object 类而不是 Thread 类”。注意这和锁对象绑定。wait()的作用是让当前持有锁的线程释放锁并等待锁是对象维度的概念每个对象都能被synchronized锁定所以 wait/notify 作为锁协作方法定义在 Object 上最自然。要是定义在线程上就难以表达“一个对象的锁可以被多个线程竞争”这层关系了。第三个问题是“锁对象怎么选能不能用 String 作为锁”。这种题没有标准答案但考察的是经验。我一般会这样回答技术上任意对象都可以但实践中不用字符串字面量或缓存对象因为可能发生隐式共享导致锁范围超出你的控制最好的方式是使用private final Object lock new Object()这样的专用锁对象既隔离又安全。第四个问题是“同步方法和同步代码块有什么区别”。区别不在性能而在于粒度控制。同步代码块可以把锁的范围收紧到最小临界区减少持锁时间提高并发度同步方法则把整个方法体纳入保护范围代码更简洁但锁范围可能过大。这个问题的衍生考点是如果锁只保护一个或两行代码没必要把整个大方法都变成synchronized。4.2 经典误用不同的锁对象导致同步失效有一次我给同事 review 一段代码他写了一个synchronized add()方法专门负责往缓存里写数据另一个方法synchronized get()负责读看起来都没问题。但排查问题时发现调用方是通过 AOP 动态代理拿到对象的代理对象和原始对象并不是同一个实例结果synchronized锁的this是代理对象和原始对象上的锁互斥不上。这类问题的排查方式一般是先确认“进入同步块的线程到底拿的是哪个对象”。我们可以用一个简单的技巧验证在同步块里打印this.getClass()或者打印锁对象的System.identityHashCode()如果不同线程打印出的 hashcode 不一致说明它们根本不在抢同一把锁。这个技巧在实际排查里比单纯看代码高效得多。还有一种误用是把synchronized加在 Spring 管理的 Service 方法上但 Service 默认是单例的方法锁在 this 上没问题如果你把 Service 配成 prototype 作用域每个调用方拿到都是新实例那同步就形同虚设。这也就是我前面强调“锁对象跟着对象实例走”的实际后果。4.3 死锁的产生与排查思路多线程锁死锁很容易发生在多个锁嵌套等待时。典型的死锁场景是两个线程各自持有一把锁然后都去等对方的锁。比如线程 A 先锁对象 X 再锁对象 Y线程 B 先锁对象 Y 再锁对象 X两个线程僵持在这里谁也不让谁。避免死锁的第一原则是锁的顺序要全局一致。代码里如果要同时拿多把锁大家都按固定的顺序去拿比如先拿 X 再拿 Y线程 B 也不会去抢 Y 等 X整个系统就避免了循环等待。这个约束看起来简单但大项目里锁分散在不同模块时要统一约定并不容易。排查死锁时不要靠猜直接用jstack把线程快照 dump 出来最稳。执行jstack pid之后如果存在死锁输出里会明确带着 “Found one Java-level deadlock” 这样的提示并且会列出线程各自持有和等待的锁。我在处理线上问题时就靠这一招快速定位到了具体代码行省了大量时间。4.4 一次性子线程锁范围过大引发的性能问题再分享一个线上排查案例某个接口在高峰期响应变慢CPU 居高不下。看监控发现该接口有一段用synchronized包裹的逻辑占用的锁几乎一直是BLOCKED状态。代码大体长这样public synchronized void doBiz() { // 大量本地计算 // 一次远程调用 // 写库操作 }这个方法把耗时的远程调用和写库操作整段包进了锁里导致所有线程都堵在这把锁上。优化方式是把锁的粒度缩小只把真正需要互斥的短小临界区包住把耗时的远程调用挪到锁外执行。改完之后接口的吞吐量立刻上来。这里我想提一个判断标准锁的范围原则上应该包含最少的共享变量操作。锁是为了保护共享资源的完整性不是为了给整个业务流程上保险。远程调用、网络 IO 这类操作时间不可控放进锁里会让锁持有时间无限拉长其他线程等得越久系统整体吞吐就越低。这是synchronized实战中最常见的性能杀手。最后说点实操体会锁这个东西写错了通常不会当场崩溃更多时候是悄悄丢一点效率或者偶尔出一个难以复现的并发问题。它不像空指针那样好查因为出错的是线程时序而不是某一行代码的返回值。所以我的习惯是写并发代码之前先问自己三个问题——这个锁锁的对象是不是大家共享的锁的范围是不是最小的多个锁之间有没有统一的获取顺序三句话想清楚再动手写能避免后面绝大多数问题。还有一个小技巧想分享给做代码 review 的人看到synchronized标记的同步方法时先看看方法体外部是否还有非同步的代码也在操作同一个共享变量。如果有哪怕同步方法内部写得再对整体线程安全性还是破的因为“漏保护”往往比“锁错对象”更隐蔽。用静态扫描工具也能发现一部分这种问题但工具只能帮忙真正要靠的还是对共享对象边界的敏感度。synchronized不是并发世界里最复杂的东西但它作为 Java 并发编程的第一道门槛值得你花时间把写法和原理都吃透。把这些基础点弄明白后面看AQS、ReentrantLock、ConcurrentHashMap这些高级实现都会轻松不少。