写了这么多年代码也面试过不少人我发现大部分人对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一直是 trueB 写回主内存的新值 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 实例方法锁的是 thispublic 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给不了。给你一张对比表方便以后选型能力synchronizedReentrantLock隐式获取/释放锁支持异常自动释放需要手动 lock/unlock可重入支持支持响应中断不支持lockInterruptibly 支持尝试获取锁带超时不支持tryLock(timeout) 支持公平锁非公平可指定公平策略多条件队列不支持只能 wait/notifyCondition 支持多队列时代感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 与 monitorexitsynchronized代码块编译后会产生两条关键指令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 内部都关联着一个监视器Monitormonitorenter就是去获取这个监视器的所有权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 的指针锁标志 10GC 标记空锁标志 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 到 127synchronized (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每次加锁之前都想清楚锁住的是哪份数据、锁对象是谁、临界区里最小需要放哪些操作。这样写出来的并发代码维护成本是真的低。
阅读完成 · 觉得有帮助?