Synchronized 这个词凡是写过并发代码的 Java 开发者基本都绕不开。但说实话大部分人对它的理解停留在加个锁就能保证安全这一层。前阵子帮一个团队排查线上接口偶发变慢的问题压测一上来某个方法的并发能力直接掉了一半翻了半天代码发现他们把一段只需要局部加锁的逻辑整个方法都挂上了 synchronized。类似这种看起来会了、实际用错的情况我见过太多次了。这篇文章不打算把 synchronized 往教科书方向讲而是从我真实遇到的并发问题出发把下面几件事说透它到底在保护什么、不同写法锁的到底是什么、JVM 底层是怎么处理锁的、哪些用法会让程序更糟以及在实际项目里应该怎么选型。不管是刚接触并发的新人还是已经写了几年 Java 但没系统梳理过锁机制的人应该都能从里面找到有用的点。1. 从一次“假死”找原因synchronized到底在保护什么1.1 一个能复现的数据竞争现场先说一个最小但非常典型的例子。假设你写了一个计数器class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }单线程下这段代码没有任何问题。但当你用 100 个线程去同时调用 increment每个线程执行 1000 次满心期待结果是 100000实际跑出来却经常是九万八、九万九而且每次数字都不一样。这不是 JVM 有 bug而是count根本不是一步操作。它在字节码层面至少拆成了三步读 count 的值、把值加一、把新值写回去。假如线程 A 读到了 count 10还没写回去线程 B 也读到了 count 10两个线程都把它改成 11 再写回那这次加操作就等于丢失了一次更新。这就是数据竞争data race也是 synchronized 这类同步机制要解决的核心问题之一。把 increment 方法加上 synchronized 之后public synchronized void increment() { count; }结果会稳定变成 100000。因为同一时刻只能有一个线程进入这个方法读、加、写三步被硬生生圈成了一个不可分割的临界区。1.2 synchronized带来的三个承诺很多资料会把 synchronized 的作用概括成加锁这没错但只说对了一小半。从 Java 内存模型JMM的角度看它其实承诺了三件事互斥同一时刻同一个锁对象只允许一个线程进入临界区其他线程必须等待。这是最直观的一条。可见性线程在释放锁之前对共享变量的修改会被随后获取同一把锁的线程看到。换句话说A 线程释放锁时会把工作内存中的变量强制同步回主内存B 线程获取锁后会从主内存重新读取这些变量。有序性临界区内的代码不会因为编译器和 CPU 的重排序而让另一个线程看到执行到一半但从逻辑上看顺序颠倒的结果。锁的 acquire 和 release 指令像两道栅栏把重排序挡在临界区边界之内。第二和第三条经常被忽略。很多人以为 synchronized 只是排队其实它还解决了内存可见性和指令重排两个更深层的坑。这也是为什么有时候你明明加了锁却总觉得代码看起来应该没问题但就是诡异——很可能你只利用了互斥没有意识到锁对内存行为的约束。1.3 为什么光靠volatile救不了计数器那如果我把 count 声明成 volatile是不是就不用 synchronized 了不行。volatile 确实能保证可见性——一个线程改完 count另一个线程立刻能看到最新值。但它不能保证互斥更不提供读改写的原子性。还是拿count来说即使 count 是 volatile并发环境下一个线程读到的值也可能在写回前被另一个线程覆盖丢失更新的问题依然存在。volatile 适合的是一个线程写、多个线程读的场景或者像状态开关running、initialized这种先检查、再赋值的简单用途。一旦涉及读旧值、改新值、再写回这种复合操作必须靠 synchronized 或 atomic 类来兜底。理解了这个区别后面再看选型部分就顺了synchronized 解决的是复合操作 多线程同时改的问题volatile 解决的是修改结果对其他线程立即可见的问题。2. synchronized的四种写法别把语法背错位置synchronized 有两种放置位置和一种显式块写法很多人会在锁的到底是什么对象上翻车。先记住一个核心原则synchronized 锁的一定是一个对象而不是代码本身。代码只是附属于某个对象的临界区。搞清楚这个对象是谁才算真正会用。2.1 synchronized实例方法锁的是“当前对象”public class Account { public synchronized void deposit(int amount) { balance amount; } }这种写法等价于public void deposit(int amount) { synchronized (this) { balance amount; } }它锁的是调用这个方法的 Account 实例也就是 this。由此会引出一个很容易被忽略的结论如果两个线程操作的是同一个 Account 实例那么天然互斥但如果它们操作的是两个不同的 Account 实例这把锁就形同虚设。很多把 synchronized 写在内网服务上的新人碰到每个请求进来 new 一个对象的写法时会发现加锁完全没效果——因为锁对象每次都是新的根本没有竞争同一个 monitor。2.2 synchronized静态方法锁的是Class对象public class Account { public static synchronized void transfer() { // ... } }静态方法锁的不是 this而是 Account.class。换句话说它锁的是这个类在 JVM 里的 Class 对象所有通过这个类调用静态方法的线程共享同一把锁。这带来一个有趣的细节静态 synchronized 方法和实例 synchronized 方法锁的是两个不同的对象因此它们的临界区可以同时被两个线程进入一个是 Class 对象一个是实例对象互不干扰。如果你以为加了 synchronized 就全局安全这里很容易产生误判。2.3 synchronized代码块锁哪个对象由你决定public void updateBalance() { // 这里的代码不需要加锁 synchronized (lock) { // 这里的代码需要加锁 } }代码块是灵活性最高的写法也是实际项目里用得最多的。你可以选择锁 this、锁一个专门的私有 final 对象、锁某个共享的外部对象甚至可以锁一个类的 Class 对象。我建议的实践是如果临界区只需要保护类内的一部分状态就别直接锁 this而是弄一个专用的锁对象。private final Object lock new Object(); public void update() { synchronized (lock) { // 只锁这个专用对象不会影响同一个类其他 synchronized 代码块 } }这样做的好处是锁的 identity 清晰不会和其他代码块意外共享。为什么锁 identity 重要因为 synchronized 判断是不是同一把锁用的是对象身份引用不是 equals 逻辑。你 new 出来的两个逻辑上相等的对象对锁系统来说完全是两码事。2.4 四种写法的锁范围对照平时在代码评审里我经常用下面这张表让人快速对齐锁的边界写法锁对象临界区范围常见问题synchronized 实例方法当前实例 this进入该方法的线程不同实例之间不互斥synchronized 静态方法类的 Class 对象全部静态调用者和实例方法锁不同步synchronized(this) 块当前实例 this代码块内的逻辑别人如果也用 this 锁会被一起堵住synchronized(obj) 块指定的 obj代码块内的逻辑锁对象选错没锁记住这张表的核心结论静态方法锁 Class实例方法锁 this代码块锁谁取决于你传谁。写 synchronized 之前先在心里回答我锁的是哪个对象、哪些线程会竞争这个对象再落代码能避掉一大半坑。另外补充一个小细节synchronized 不能用在构造函数上。Java 语法里构造函数加 synchronized 是编译错误因为构造函数本身就是多线程环境里 publish 对象 的过程JVM 有自己的一套发布保证。如果你确实需要在构造过程中保护某些状态应该把相关内容拆到初始化方法或代码块里用显式锁对象。3. 锁是怎么“记账”的monitor、对象头和锁升级3.1 每个对象天生自带一把“锁”monitor在 JVM 的视角里每一个 Java 对象都可以作为锁因为对象头里有一部分信息叫 Mark Word里面记录了和锁相关的状态。synchronized 的底层依赖 monitor管程机制。每个对象都关联着一个 monitor一个线程要进入 synchronized 代码块必须先拿到这个 monitor退出临界区时必须释放 monitor。如果两个线程同时尝试获取同一个 monitor只有一个能成功另一个会阻塞等待。听到阻塞别慌现代 JVM 为了减少线程真正被挂起带来的上下文切换开销设计了一套渐进式锁升级策略。它不会一上来就直接用最重量级的操作系统线程锁而是能轻则轻谁抢到了就算谁的。3.2 偏向锁、轻量级锁、重量级锁JVM在偷懒JVM 的锁并不是铁板一块而是一条偷懒路线偏向锁。这是最开始的状态。如果同一个线程反复进入同一个同步代码块JVM 会在对象头里记下这个线程的 ID之后这个线程再来时连 CAS 都不用做直接放行。这就是偏爱的含义——相当于门卫认出你是熟人连证件都不看了。不过要注意JDK 15 之后官方已经默认关闭了偏向锁因为它带来的维护成本逐渐超过收益所以如果你用的是新版本 JDK这一层可能已经不存在了。轻量级锁。一旦有第二个线程来竞争偏向锁会撤销升级为轻量级锁。轻量级锁不阻塞线程而是通过 CAS 自旋等待试图把对象头里的 Mark Word 修改为当前线程持有的锁记录。如果竞争的线程很少、等待时间很短自旋几次就能拿到锁性能非常好。但如果自旋超过一定次数还拿不到说明竞争已经很激烈再空转只是浪费 CPU。重量级锁。自旋失败后锁升级为重量级锁线程真正进入阻塞状态交给操作系统 mutex 去调度。这个状态下线程需要被挂起和唤醒涉及内核态切换成本最高。这也是为什么并发激烈时加了 synchronized 的接口性能会断崖式下降——不是锁本身多慢而是大量线程在重量级锁上排队产生大量上下文切换。这张表可以帮你直观理解三个阶段锁状态解决什么问题开销什么时候触发偏向锁同一个线程重复加锁几乎为零无线程竞争轻量级锁少量线程短时竞争较低CAS 自旋出现少量竞争重量级锁大量线程长时间竞争高线程挂起唤醒自旋失败或竞争激烈这里顺带说一句synchronized 早期一直被人吐槽性能差主要是站在 JDK 5 之前的老黄历上看问题。现代 JVM 对锁做了大量优化普通场景下 synchronized 和 ReentrantLock 的性能差距已经非常小完全不用一提到锁就下意识认为它很慢。真正让你程序变慢的往往是锁粒度、锁对象设计这类更宏观的问题。3.3 可重入性为什么 synchronized 不会把自己锁死synchronized 是可重入的。同一线程已经拿到某把锁之后如果再次进入同一把锁保护的代码块不会被自己挡住。举个例子public synchronized void outer() { System.out.println(outer); inner(); } public synchronized void inner() { System.out.println(inner); }两个方法都是实例同步方法锁的都是 this。外层方法拿到 this 的 monitor 后调用 inner 时需要再次获取 this 的 monitor。如果不可重入这里就是死锁因为可重入JVM 只需要给 monitor 的持有计数加一线程依然能顺利进入退出 inner 时计数减一退出 outer 时再减一直到计数归零才真正释放锁。可重入不仅让同步方法可以互相调用还保证了像是同一个对象上的多个 synchronized 块嵌套时不会把自己锁死。理解了这一点你以后再看到锁自己的说法就知道为什么不可能——除非两个不同的锁以相反顺序嵌套那才是真正会死锁的场景。4. 我替你们踩过的坑这些用法会让程序更糟4.1 锁在“错误的对象”上看似加了锁实际等于没锁之前看到一个很典型的错误。有人在方法里这么写public void process() { Object lock new Object(); synchronized (lock) { // 同步代码 } }每次调用 process 都会 new 一个 Object这个锁对象对每个线程来说都是全新的。线程 A 拿着自己的锁线程 B 拿着自己的锁彼此之间根本不是同一把锁同步自然失效。正确的做法是把锁对象定义为类的成员变量而且最好声明为 finalprivate final Object lock new Object();为什么强调 final因为如果锁对象可以被重新赋值某次赋值之后后面的线程锁的是新对象前面的线程锁的是旧对象锁的 identity 又分裂了。一旦锁对象发生变化你之前基于同一个 monitor 做的所有互斥假设都会崩塌。还有一个容易被忽略的锁对象陷阱不要把 Boolean、Integer、Long 这类包装类型当锁。它们的某些值在 JVM 里是缓存的比如Integer.valueOf(1)整个 JVM 只有同一个对象锁它等于锁一个全局共享对象影响范围远超你的预期而那些超出缓存范围的值每次装箱可能产生不同的对象锁了等于没锁。锁对象最好就是普通 Object干净、可控、无副作用。4.2 锁字符串或包装类型整个JVM都跟着遭殃synchronized (LOCK) { // do something }这段代码看起来很正常实际非常危险。Java 里字符串字面量会被驻留intern到常量池所有引用相同字面量的地方拿到的都是同一个对象。也就是说你可能只是想保护自己类里的资源结果整个 JVM 里所有写成LOCK的代码段都共享了同一把锁。更糟的是第三方库如果也用了同样的字符串作为锁两边就会无缘无故地互相阻塞甚至产生你完全无法定位的死锁。包装类型同理。Boolean.TRUE是 JVM 里代表 true 的唯一实例你用它做锁全 JVM 的其他线程只要也锁它全都被堵在一起。这种锁的可见范围远远超出了类的边界排查问题时非常隐蔽。正确的姿势永远是锁一个只属于当前类、不对外暴露的私有对象。4.3 synchronized方法里做IO或远程调用一次慢请求拖垮全站这是一个性能大坑也是最容易让 synchronized 背黑锅的写法。public synchronized void syncData() { String result httpClient.post(url, data); // 网络请求可能要几百毫秒 db.save(result); }加锁的本意可能只是保护 db.save 这个写入操作但 synchronized 把整个网络调用也圈进了临界区。一个线程发起远程请求时其他所有想进入这个方法的线程都在干等。如果下游接口偶尔超时 5 秒你等于让整条业务链路都跟着卡 5 秒。正确的做法是缩小锁的粒度把网络 IO 放到临界区外面public void syncData() { String result httpClient.post(url, data); // 不需要加锁 synchronized (this) { db.save(result); // 只需要同步这一步 } }原则很简单临界区越小并发度越高。凡是跟共享资源无关的计算、IO、RPC都不要放进 synchronized 里面。4.4 双重检查锁为什么必须配volatile单例模式里有一种很经典的写法叫双重检查锁Double-Checked Lockingclass Singleton { private static volatile Singleton instance; static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }外层判断为了减少进入 synchronized 的频率内层判断为了在竞争下保证单例。这个写法本身没问题但前提是 instance 必须声明成 volatile。原因出在new Singleton()这行代码上它不是一个原子操作大致分为分配内存、调用构造器、把引用赋值给变量三步。编译器和 CPU 有可能把最后两步重新排序让引用赋值先于构造器执行发生。此时另一个线程如果恰好在外部读到 instance ! null拿到的就是一个还没构造完成的对象。volatile 在这里的作用是禁止这行代码的重排序保证对象发布之后所有字段一定已经初始化完毕。这个坑当年坑过无数 Java 开发者直到 JDK 5 的 volatile 语义被强化之后才算真正有解。所以你也可以看到synchronized 管的是互斥和可见性但有的场景还需要 volatile 来补上安全发布这一环。4.5 死锁的预防别让锁的“顺序”打架两个线程互相持有对方想要的锁并且谁都不肯先释放就会形成死锁。一个经典场景// 线程A synchronized (lockA) { synchronized (lockB) { ... } } // 线程B synchronized (lockB) { synchronized (lockA) { ... } }如果 A 先拿到 lockA、B 先拿到 lockB然后 A 等 lockB、B 等 lockA两边就永远等下去。synchronized 不具备超时机制也没有尝试获取失败就放弃的语义一旦死锁只能重启进程。预防方法从工程上看主要有两条保证多个锁的获取顺序全局一致。比如都按 lockA - lockB 的顺序去拿就不会出现循环等待。拆分锁的粒度。如果两个锁其实在保护不同的资源本身就没有必要嵌套持有重新设计临界区让每个锁只保护它真正对应的状态。如果实在无法保证顺序或者你想让线程能在等待锁时超时重试那就得考虑用ReentrantLock的tryLock。这是 synchronized 的能力边界后面选型部分会细讲。5. 选型synchronized、volatile、Lock什么时候用哪个5.1 三种同步手段的能力边界很多初学者一遇到并发就想着加个 synchronized但真到生产环境你会发现还有 volatile、Atomic 类、ReentrantLock、ConcurrentHashMap 等一堆工具。它们各有边界选错了不是玄学性能问题就是逻辑正确性问题。我习惯用一张能力表来做判断能力synchronizedvolatileReentrantLock原子性复合操作保证不保证保证可见性保证保证保证有序性/禁止重排保证保证特定规则保证锁超时不支持不适用支持 tryLock中断等待不支持不适用支持 lockInterruptibly公平锁不支持不适用支持锁的释放自动退出块即释放不适用必须手动 unlock条件变量 Condition用 wait/notify较弱不适用原生支持更灵活从这里可以得出几条实用规律如果只需要保证一个线程写、多个线程读的可见性优先用 volatile它最轻。如果需要保护一小段复合操作而且不需要超时和公平策略优先用 synchronized。它写法简单、不容易忘释放锁JVM 对它的优化也比想象中到位。如果需要等待可中断、锁超时、公平调度或者同一把锁要跨多个方法手动释放再考虑 ReentrantLock。这里必须强调一个经常被忽视的点ReentrantLock 需要手动 unlock而且必须在 finally 里执行。一旦你写了 new Lock() 然后忘了 unlock或者执行路径里抛了异常没走到 unlock你就获得了一个永远释放不掉的锁比 synchronized 自动释放的行为危险得多。能用 synchronized 解决的问题没必要为了显得高级去换 Lock。5.2 我常用的组合方式和决策参考实际项目里我不太会只依赖一种机制更多是组合使用读多写少的共享状态用 volatile 声明状态变量配合发布不可变对象。精确控制的临界区用 private final Object 做锁对象 synchronized block。单个资源的高并发计数用 AtomicInteger、LongAdder性能远好于 synchronized。多个资源之间的复合操作先看能不能合并状态合并不了再考虑 ReentrantLock 或事务性方案。容器类并发访问优先 ConcurrentHashMap、CopyOnWriteArrayList 等并发容器而不是给 HashMap 整个包 synchronized。这个决策顺序背后有个朴素的逻辑先看能不能用无锁方案volatile 并发容器再看能不能用小粒度临界区synchronized block最后才考虑 Lock。锁是最后手段不是第一选择。5.3 对性能的真实认知偏向锁和锁消除不是银弹网上有很多文章强调 JVM 的偏向锁、锁消除、锁粗化优化仿佛加了 synchronized 之后就不用关心性能了。我的看法是这些优化确实有效但它们是锦上添花不是雪中送炭。锁消除的典型场景是某个锁对象只在一个线程内部使用JIT 发现它不可能被其他线程看到就会把 acquire/release 整个移除。这时候 synchronized 确实是零开销。但这是编译器在确认没有竞争的前提下做的优化如果你的锁对象被多个线程共享锁消除根本不会触发。锁粗化的典型场景是循环里反复进入小的 synchronized 块JIT 会把它们合并成一个更大的临界区。这对循环很短、每次加锁成本高的情况有帮助。但如果你把粗化的临界区理解成反正 JVM 会帮我扩大锁的范围所以我可以随便写那就反了。代码的可维护性和可预测性还是要靠人自己保证。所以我的结论是synchronized 在现代 JVM 里性能不差但它能解决的问题边界是固定的。不要把性能希望全部寄托在 JVM 的优化上也别因为焦虑性能去盲目替换成 Lock 或各种花哨的并发数据结构。先把锁对象、锁粒度、临界区范围设计对再谈性能。最后再分享一个我自己的实践习惯每次写完 synchronized 相关代码我都会在心里过三个问题——我锁的是哪个对象这个对象会被哪些线程访问这个锁的粒度是不是已经达到最小如果三个问题都能立刻答出来代码基本可以放心如果哪个问题要想半天那多半就是将来出问题的地方。并发编程里最贵的不是写锁的语法而是搞错锁对象之后那几个小时的排查时间。