写了这么多年代码,也面试过不少人,我发现大部分人对synchronized的理解其实停留在"给方法加个锁就不会有并发问题"这个层面。它确实能挡住并发问题,但如果你不清楚它挡的到底是什么,迟早会在某个诡异的内存可见性 bug 面前栽跟头。本文不打算只讲 API 用法,而是把synchronized从表面语法到底层实现按照我自己的理解完整梳理一遍:先搞懂它在解决什么问题,再逐层拆解三种写法背后的锁对象差异,然后是异常场景下的锁行为、字节码层面的执行逻辑,最后落到 JVM 对象头和锁升级路径上。文章会比较长,但你跟着走一遍,以后写并发代码心里会踏实很多。
1. 先治"学了三年 Java 还不会加锁"的病:synchronized 到底在管什么
很多教程一上来就说synchronized是"互斥锁",保证同一时刻只有一个线程执行某段代码。这话没错,但不完整。synchronized其实管了两件事:互斥(Mutual Exclusion)和内存可见性(Memory Visibility)。只记住互斥、忽略可见性的人,十个里有八个会在线上遇到说不清道不明的"值怎么没更新"问题。
1.1 synchronized 管的不是代码,是"数据的访问权限"
Java 内存模型(JMM)里有个核心概念:每个线程有自己的工作内存(可以简单理解为 CPU 高速缓存的抽象),线程操作变量时不是直接读写主内存,而是先拷贝一份到自己的工作内存,操作完再刷回主内存。问题就出在这里——两个线程同时读同一个变量,各自缓存了一份副本,一个线程改了,另一个线程未必能马上看到。
线程 A执行:
boolean running = true; while (running) { // 忙循环 }线程 B执行:
running = false;如果没有额外的同步手段,线程 A 的这个 while 循环可能永远停不下来,因为 A 的工作内存里那份running一直是 true,B 写回主内存的新值 A 根本不知道。这就是典型的可见性问题。
synchronized解决这件事的方式是:进入临界区(synchronized 保护的代码块)时,从主内存重新读取所需变量;退出临界区时,把工作内存中修改过的变量强制刷新到主内存。注意,这是和互斥同样重要的能力,很多人只盯着"排队执行",把"刷新可见性"这半个功能丢了。
1.2 一个让我印象深刻的线上问题:只锁写、不锁读
有一回帮一个团队排查接口偶发返回旧数据的问题,代码逻辑大概是:
private Map orderCache = new HashMap<>(); public void updateCache(Map newOrders) { synchronized (this) { this.orderCache = newOrders; } } public Map getCache() { return this.orderCache; // 没有 synchronized }写那边加了锁,读这边觉得"读操作又不会改数据,加锁干嘛"。结果就是:线程 A 在持锁更新orderCache,线程 B 同时在读,读到的可能是旧引用,也可能因为指令重排读到还没完全构造好的新对象。
这个案例告诉我们一件事:synchronized的互斥和可见性是成对出现的。你在写操作加了锁,读操作也必须走同一把锁,才能保证"读到的永远是最新值"。只锁一半,本质上等于没锁。
2. 三种语法糖,锁住的其实是三件不一样的东西
synchronized可以加在三个位置:实例方法、静态方法、代码块。语法都很简单,真正的坑是在"锁的对象是谁"上。锁对象一旦搞错,锁就白加了——你以为是全局串行,实际各锁各的,互不相干。
2.1 实例方法锁的是 this
public class Counter { private int count = 0; public synchronized void increment() { count++; } }等价于:
public void increment() { synchronized (this) { count++; } }this是当前实例对象。两个线程操作同一个Counter 实例时,竞争的是同一把锁,互斥有效;但如果两个线程各自 new 了一个 Counter,那它们锁的就不是同一个东西,increment 之间完全没有竞争关系。
这个设计天然适合"每个实例维护自己状态"的场景,比如每笔订单一个对象、每个连接一个会话。但如果你把某个实例方法加了synchronized,又希望所有实例共享互斥,那必须用静态方法或者静态锁对象,不能指望 this 锁跨实例生效。
2.2 静态方法锁的是 Class 对象
public class Counter { private static int total = 0; public static synchronized void addTotal(int value) { total += value; } }等价于:
public static void addTotal(int value) { synchronized (Counter.class) { total += value; } }每个类在 JVM 里只有一个Class对象(这里不展开类加载器细节,正常场景下即可),所以静态方法上的锁天然是"全局唯一"的。多个线程不管 new 了多少实例,进这个方法都得排同一把锁。
这里有个很常见的设计误区:有人为了"并发安全"把无关紧要的工具方法也声明成static synchronized,结果不同业务线程频繁串行排队,吞吐量肉眼可见地往下掉。static synchronized影响范围比实例方法大得多,加之前务必确认:这个锁是否真的需要全局级别?临界区里做的事情值不值得让所有调用方排队?
2.3 代码块锁的是任意指定对象
public void doSomething() { synchronized (lockObject) { // 临界区 } }代码块的自由度最高,锁对象由你指定。常见的选型有三种:
- 锁
this:等价于实例方法,但可以把临界区范围缩小到只有几行代码,而不是整个方法体。 - 锁
Counter.class:等价于静态方法,适合保护静态共享数据。 - 锁一个独立的专用锁对象:
private final Object lock = new Object();,我只锁这个哨兵对象,外部拿不到引用,想"恶意锁你"都做不到。
我个人的习惯是优先使用专用锁对象。原因很简单:this是暴露给外部的,调用方只要拿到你的实例引用,就能synchronized (instance)堵住你所有用synchronized(this)保护的临界区。虽然多数时候不会有人这么干,但被坑过一次之后你就会明白,锁对象越"私有"越安全。
三种写法的对照,一句话总结就是:实例方法锁 this,静态方法锁 Class,代码块锁你指定的东西。锁的对象不同,竞争范围完全不同,写之前先问自己是"实例级别互斥"还是"全局级别互斥"。
3. 可重入和不可中断,这两条特性决定了它的行为边界
synchronized有两个非常关键的行为特性:可重入(Reentrant)和不可中断(Non-interruptible)。理解这两条,你才能说清楚"什么时候 synchronized 够用,什么时候必须换 Lock"。
3.1 可重入:为什么自己锁自己不会死锁
先看这段代码:
public class Service { public synchronized void methodA() { methodB(); } public synchronized void methodB() { // do something } }同一个线程先进入methodA获得了 this 的锁,然后在methodA里又调用了同样需要 this 锁的methodB。如果锁不可重入,这里就是自己把自己锁死了——等一把自己正持有的锁,永远等不到。
synchronized是可重入锁,同一线程可以重复获取同一把监视器锁。JVM 在底层记录了这个线程获取锁的次数(进入一次 +1,退出一次 -1),只有计数归零,锁才真正释放给其他线程。所以递归调用、相互调用、嵌套同步块,只要都是同一线程,就都不会出现死锁。
这个特性的实际价值不只是"递归不死锁",更体现在代码结构上。你可以放心地让一个持锁方法调用另一个加锁方法,不用担心锁的传递和释放问题,锁的获取是自动的,释放也是自动的,JVM 帮你把计数管好了。
3.2 不可中断:等锁的过程中,interrupt() 是无效的
synchronized的第二个核心特性是不可中断。线程进入synchronized块等待获取锁时,无法响应中断——Thread.interrupt()只会设置中断标志位,但线程会继续阻塞在那里,直到拿到锁为止。
这在某些场景下很要命。想象一个线程池任务,某个任务持锁时间很长(比如持锁调远程接口,虽然这不是好设计,但现实中太常见了),其他任务只能在锁外面干等。如果等了很久你希望把它停下来,synchronized做不到——它不会抛InterruptedException,只会在拿到锁之后才能感知中断标志。
对比一下ReentrantLock,它提供了lockInterruptibly(),可以响应中断;还提供了tryLock(timeout),等锁超过设定时间就放弃。这两点对构建"有超时、可取消"的并发流程是刚需,synchronized给不了。
给你一张对比表,方便以后选型:
| 能力 | synchronized | ReentrantLock |
|---|---|---|
| 隐式获取/释放锁 | 支持,异常自动释放 | 需要手动 lock/unlock |
| 可重入 | 支持 | 支持 |
| 响应中断 | 不支持 | lockInterruptibly 支持 |
| 尝试获取锁带超时 | 不支持 | tryLock(timeout) 支持 |
| 公平锁 | 非公平 | 可指定公平策略 |
| 多条件队列 | 不支持,只能 wait/notify | Condition 支持多队列 |
| 时代感 | JDK1.0 就有 | JDK1.5 引入,功能更全 |
但注意,功能更全不代表"更好"。我用ReentrantLock踩过最多的坑就是finally里忘记unlock(),轻则锁永远不释放,重则线程池被打满。synchronized完全没有这个负担。如果只是解决互斥和可见性问题,synchronized更安全也更省心;当需求升级为"需要等锁超时""需要中断""需要多个条件队列",我才换ReentrantLock。
4. 临界区抛异常时,锁到底会不会丢
这是一个实操中特别值得掰开揉碎的问题。很多人刚用Lock接口的时候被教育"一定要在 finally 里 unlock",于是转头对synchronized也疑神疑鬼:临界区里抛了异常,锁还释放吗?答案是:释放,而且这是synchronized对比手动锁最大的优势之一。
4.1 字节码里的 monitorexit 暴露了真实逻辑
把下面这段简单代码编译后用javap -v查看字节码:
public void demo() { synchronized (this) { System.out.println("hello"); } }你会看到monitorenter出现在临界区入口,而monitorexit出现了两次。第一次是正常执行路径:临界区代码走完,正常退出时执行monitorexit释放锁。第二次藏在异常表(exception table)里:只要临界区里的代码抛了异常,不管是什么异常、发生在哪一行,都会转入异常处理器,在真正抛出异常之前先执行monitorexit释放锁。
这就解释了"为什么 synchronized 不会因为异常而丢锁"。JVM 在编译阶段就把异常处理路径上的释放锁动作生成好了,不需要你在代码里写 finally,也不需要手动释放。对开发者来说,锁的持有和释放完全不可见,异常发生时 JVM 帮你兜底。
4.2 但请注意:锁释放不等于数据安全
锁不会丢,可这不代表你可以放心在临界区里乱抛异常。我见过不少代码是这个画风:
synchronized (accountLock) { balance -= amount; if (balance < 0) { throw new IllegalStateException("余额不足"); } // 记录流水 }锁倒是释放了,但余额已经被扣掉了。这种"操作做了一半就抛异常"的情况,锁释放只是免于死锁,业务数据的正确性需要你自己保证。所以临界区里要么做完整校验再修改数据,要么在异常场景里补齐补偿逻辑。synchronized管的是线程安全,不是事务一致性,这两件事千万别混为一谈。
还有一个实际建议:虽然synchronized自动释放很省心,但你依然要控制临界区的大小。锁的范围越大,持有时间越长,其他线程等待的概率和时长就越大。把临界区里的耗时操作(远程调用、数据库访问、大文件读写)想办法挪出去,只在真正需要保护的数据修改处加锁,这在并发场景下的收益往往比选什么锁更大。
5. javap、对象头与锁升级:把底层原理拆给你看
前面讲的都是"表面行为",接下来是我最想展开的部分——synchronized在 JVM 里到底怎么跑。这块内容理解了,你才能真正读懂"为什么 synchronized 并不像老一辈说的那么慢",也才能理解锁升级的整个演化逻辑。
5.1 字节码层面:monitorenter 与 monitorexit
synchronized代码块编译后会产生两条关键指令:monitorenter和monitorexit。用javap -c实际看一眼:
5: monitorenter 6: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream; 9: ldc #13 // String hello ... 15: aload_1 16: monitorexit 17: goto 25 20: astore_2 21: aload_1 22: monitorexit 23: aload_2 24: athrow可以看到正常路径一个monitorexit,异常路径还有一个monitorexit,和前面说的异常表正好对应。每个对象在 JVM 内部都关联着一个监视器(Monitor),monitorenter就是去获取这个监视器的所有权,monitorexit就是释放所有权。
这里有一个细节:synchronized修饰的方法,字节码里并没有monitorenter和monitorexit,而是在方法的access_flags里多了一个ACC_SYNCHRONIZED标志。JVM 根据这个标志判断:方法调用时需要获取方法所属对象的监视器。理解这一点有助于你明白,方法级和方法块级的 synchronized 底层其实是同一套机制,只是入口标识不同。
5.2 对象头与 Mark Word:锁状态存在哪
每个 Java 对象在堆内存里都有一个对象头(Object Header),其中一部分叫Mark Word,它存储了对象的运行时数据:哈希码、GC 分代年龄、锁状态标志等。
锁状态不同,Mark Word 里存的内容也不一样。简化一下,大致是这种映射:
| 锁状态 | Mark Word 存储内容 |
|---|---|
| 无锁 | 对象哈希码、分代年龄、偏向标志 0、锁标志 01 |
| 偏向锁 | 持有偏向锁的线程 ID、epoch、偏向标志 1、锁标志 01 |
| 轻量级锁 | 指向线程栈中 Lock Record 的指针,锁标志 00 |
| 重量级锁 | 指向 ObjectMonitor 的指针,锁标志 10 |
| GC 标记 | 空,锁标志 11(实际是 GC 相关状态) |
锁标志位是最后两位(在 64 位 JVM 上结合偏向标志位一起看)。JVM 判断一个对象当前处于什么锁状态,就是读这一段位信息。
5.3 锁升级的完整路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
很多人第一次听说"锁升级"时觉得这是四个独立的锁,其实它们是同一个对象在不同的竞争压力下逐步"膨胀"的过程。
无锁(01):没有任何线程竞争,或者只有一个线程偶尔访问。此时 Mark Word 里存的是哈希码和分代年龄。如果你在无竞争状态下执行一个synchronized块,JVM 会优先考虑"反正就一个线程,加锁完全是心理安慰,没必要真的上锁",于是尝试走向偏向锁。
偏向锁(101):如果第一个线程进来时发现对象的偏向标志是 1,就把自己的线程 ID 写进 Mark Word,之后这个线程再次进入同步块时,只需要比对线程 ID,一致就直接放行,不需要任何 CAS 操作,开销几乎为零。这就是"偏向"的含义:锁偏向于第一个获得它的线程。
这里必须说明一个时代背景:偏向锁在 JDK 8 等老版本里是默认开启且有明显收益的;但从 JDK 15 开始默认关闭,后续版本逐步移除了相关实现。原因是它增加了很多底层复杂度,而且在大量现代应用里,锁竞争一旦真正发生,偏向锁带来的收益极低。所以你现在学锁升级,把它当作理解 JVM 锁设计思想的一条历史路径即可,不用纠结自己的 JDK 上有没有开偏向锁。
轻量级锁(00):当第二个线程真的来竞争同一把锁时,偏向锁会被撤销。JVM 会让竞争线程在自己的栈帧中分配一个Lock Record,然后通过 CAS 尝试把对象头 Mark Word 替换成指向这个 Lock Record 的指针。如果 CAS 成功,说明抢锁成功,此时处于轻量级锁状态;如果失败,说明锁已经被别的线程持有,当前线程会短暂自旋等待(Spin)。自旋就是让线程空转一小段时间反复尝试 CAS,而不是立刻挂起,目的是赌一把"持有锁的线程很快就释放了"。
轻量级锁的底层逻辑其实是一种乐观策略:大多数锁被持有的时间都极短,用自旋等待比直接把线程挂起更划算。JDK 6 之后引入了适应性自旋,JVM 会根据上次自旋等待的成功率动态调整自旋次数,让这个"短暂空转"的时间更智能。
重量级锁(10):如果自旋等待后还是拿不到锁,或者 CAS 反复失败、竞争线程太多,轻量级锁就膨胀为重量级锁。重量级锁的 Mark Word 指向一个ObjectMonitor对象,在 JVM 内部是一个 C++ 实现的监视器结构。它内部维护着 owner 线程、等待队列、递归计数器等。最关键的是:重量级锁的阻塞和唤醒,最终是依赖操作系统层面的互斥量(Mutex)和条件变量来实现的,线程拿不到锁会被真正挂起,从用户态切换到内核态,这个切换成本很高——这就是"重量"两个字的来源。
锁升级的完整路径可以理解为:一个锁从"几乎没有成本"的偏向状态,到"乐观自旋"的轻量级状态,再到"真实阻塞"的重量级状态,代价逐步上升,触发条件都是"竞争越来越激烈"。JVM 这样设计的初衷,就是想让 synchronized 在竞争不激烈时足够快,在竞争激烈时才付出大的代价。
5.4 顺带一提:锁消除与锁粗化
除了锁升级,JVM 还有两个 JIT 编译期的优化值得知道。一是锁消除:如果 JIT 通过逃逸分析发现某个锁对象只会被当前线程访问、根本不会逃逸到其他线程,那它会把锁直接去掉。比如局部变量new Object()拿来加锁,只要不传出方法,JVM 觉得没必要真加锁,直接把monitorenter优化掉。二是锁粗化:如果连续的几个同步块用的是同一把锁,JIT 可能把它们合并成一个更大的同步块,减少反复获取/释放锁的次数。
这两个优化意味着:你不必为了"性能"去纠结每一处 synchronized 是不是多锁了,JVM 在解释执行和 JIT 编译时会帮你做一层把关。但注意,锁消除依赖逃逸分析,锁粗化只对同一把锁的连续同步块有效,真正重要的仍然是你的临界区是不是过大、锁对象是否合理。
6. 实测自增、锁粒度与几个让我浪费过半天的坑
原理讲了这么多,最后还是落到实操。这一节分享我自己的测试数据和几个印象深刻的坑,都是真实开发里容易踩中的地方。
6.1 一个简单的性能感知实验
为了对锁的开销有直观感知,我写过一个小测试:10 个线程,每个线程对同一个计数变量自增 100 万次,分别用synchronized方法、ReentrantLock、AtomicLong实现,看总耗时量级。
不同机器、不同 JDK、不同测试姿势结果都不一样,但趋势是稳定的:
AtomicLong最快(无锁 CAS),大约是最后一名耗时的三分之一左右。synchronized和ReentrantLock差距很小,很多时候synchronized甚至不输,因为 JVM 的锁优化让它在低竞争下足够轻量。- 如果把循环里的加锁操作拆细,比如每次自增加一次锁,锁获取释放的固定成本占比会上升,synchronized 的优势会缩小;但 JIT 的锁粗化又可能把这部分成本抹掉,所以微基准测试要特别小心,结论很容易被 JIT 阶段影响。
我的结论很明确:性能不是你选锁的理由。选择synchronized还是ReentrantLock,核心看功能需求(是否需要超时、中断、多条件),而不是"哪个快"。在绝大多数业务场景里,锁的性能差异远小于业务逻辑本身的消耗。
6.2 锁对象选择的三个坑
第一坑:synchronized一个字符串字面量。比如synchronized ("lock"),字符串字面量会进入常量池,而且 JVM 对字符串有重用机制,不同代码里的"lock"可能指向同一个 String 对象。这会导致你本来只想锁一段代码,结果把所有用了这个字符串字面量的同步块全部串行化了。更危险的是,如果某个你都没想到的第三方库内部也用了同样的字符串常量加锁,两边就会莫名其妙地互相阻塞。我在项目里严禁用字符串字面量加锁。
第二坑:锁Integer这类包装类。因为包装类有小整数缓存(比如 -128 到 127),synchronized (Integer.valueOf(1))会让所有用同一个数字做锁的地方共享同一把锁。加上很多框架会对基本类型做自动装箱,你以为是局部锁,实际是全局锁,排查起来特别费劲。
第三坑:把锁对象声明为public。之前提到过,锁对象暴露给外部,外部代码就可以主动竞争你的锁,造成"人为死锁"或"锁饥饿"。用private final Object lock = new Object(),从源头杜绝这种幺蛾子。
6.3 锁粒度与死锁的日常预防
锁粒度的话题前面零散提过,把一个完整原则放在这里:临界区要尽量小,但也不能无限小。太小了,比如只给i++这一行加锁而把前置条件判断放在锁外,会引入"判断-执行"不一致的竞态;太大了,比如整个方法体加锁而方法里有网络调用,又会让其他线程长时间等待。最好的粒度是:把对共享数据的"检查"和"修改"作为一个原子操作整体放进临界区,其余计算搬出去。
死锁方面,最经典的两个预防手段是:
- 锁顺序一致:多个线程需要同时获取多把锁时,必须按同样的顺序获取。比如线程 A 先拿锁 1 再拿锁 2,线程 B 也必须先拿锁 1 再拿锁 2,否则互相持有对方等待的锁就是死锁。
- 能不嵌套就不嵌套:如果能在设计上避免一个临界区里再拿另一把锁,就不要嵌套。真需要嵌套,考虑用
ReentrantLock的tryLock加超时,至少让死锁变成可恢复的超时失败。
另外别忘了,synchronized块里调用wait()会让当前线程释放所有监视器锁并进入等待队列,这是它配合线程协作的关键机制。wait和notify必须在 synchronized 块内部调用,否则抛IllegalMonitorStateException。这个异常在初学者代码里出现频率非常高,它就是在提醒你:你都没有持有这把锁,凭什么让线程等待或唤醒?理解了"wait 释放锁"这层语义,生产者和消费者模型的实现才能捋得顺。
写到这里,基本把 synchronized 从入门到原理的这条路完整走了一遍。最后分享一点我在实际工作中的体会:默认用synchronized,只有在确实需要超时控制、中断响应或多个条件队列时才换ReentrantLock;每次加锁之前都想清楚锁住的是哪份数据、锁对象是谁、临界区里最小需要放哪些操作。这样写出来的并发代码,维护成本是真的低。