ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

synchronized深度剖析:从原子性到锁升级的并发进阶指南

synchronized深度剖析:从原子性到锁升级的并发进阶指南

你说synchronized是Java里最熟悉的陌生人,一点都不夸张。我在面试候选人的时候,十个人里有九个能把偏向锁、轻量级锁、重量级锁背得滚瓜烂熟,但真让他写一段解决并发问题的代码,还是会在锁对象的选择上翻车。反而是这个看起来“最简单”的保留字,踩过的坑最深。写这篇东西不是想再讲一遍入门概念,而是把我这些年在这个关键字上看到的误解、吃过的亏、验证过的底层逻辑,一次性捋清楚。

1. 并发问题为什么非它不可:原子性、可见性、有序性的一次完整拆解

1.1 真实的并发bug长什么样

先看一个最典型的例子。count++这行代码,看起来是一条语句,但到CPU执行层面其实是三步:从内存把count加载到寄存器、寄存器里加1、写回内存。两个线程各执行一万次,你以为结果会是两万,实际跑出来可能是一万五,也可能是一万八,每次都不一样。

这就是原子性被破坏。两个线程的“读-改-写”步骤交错在一起,把彼此的更新互相覆盖了。

再看可见性问题。很多人在写多线程程序的时候,会用一个布尔变量控制线程的开关:

public class VisibilityDemo { private boolean running = true; public void test() { Thread worker = new Thread(() -> { while (running) { // 模拟业务操作 } }); worker.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } running = false; System.out.println("主线程已经将running置为false"); } }

你满怀信心地以为一秒钟后worker线程就会停下来,但它可能一直跑下去。为什么?因为worker线程的CPU缓存里始终装着一个旧的running = true,主线程的修改没有及时刷到它眼里。这里缺的就是可见性保证。

还有一个是有序性。最经典的就是单例模式的双重检查锁写法,在没有正确同步的版本里,另一个线程可能看到一个“分配好引用地址但还没有完成构造”的半成品对象。这是指令重排导致的。

1.2 synchronized的药理:一个锁同时治三种病

synchronized能同时解决这三个问题,很多人不理解怎么能这么全能。其实秘密在于,它不只是一把“互斥锁”,它背后带着一套完整的内存语义。

  • 原子性:线程进入synchronized代码块前要拿到Monitor锁,没拿到的线程就进不来,临界区里的操作天然不会被交错执行。
  • 可见性:JMM(Java内存模型)规定了happens-before规则,其中有一条叫“监视器锁规则”——同一个锁的解锁操作happens-before该锁后续的加锁操作。也就是说,线程A在释放锁之前做的所有写入,线程B一旦获取同一把锁,全都能看到。
  • 有序性:临界区内部的代码虽然仍可能重排,但JVM保证了锁的加锁和解锁有明确的边界,并且不允许重排序破坏锁的语义。更关键的是锁获取时的那条内存屏障,相当于给CPU下达了“缓存失效,重新从主存读”的指令。

我当年就是没弄明白这层关系,以为synchronized只是“它不让别人进来”,直到有一次排查一个诡异的数据错乱问题,才发现加锁之后不仅竞争消失了,连那个线程读取不到新数据的毛病也一起消失了。从那时候起,我才真正把它从“互斥工具”升级到“内存语义工具”这个维度去理解。

2. 一个保留字的三种形态与底层载体:对象头、Monitor与字节码真相

2.1 语法层面的三种写法,锁的对象分别是哪个

synchronized的三种写法是面试常客,但很多人只是记住了答案,没记住为什么这么设计。

public class LockDemo { // 写法一:修饰实例方法,锁的是当前实例this public synchronized void instanceMethod() { } // 写法二:修饰静态方法,锁的是当前类的Class对象 public static synchronized void staticMethod() { } // 写法三:修饰代码块,锁的是括号里指定的对象 public void blockMethod() { synchronized (this) { } synchronized (lockObject) { } } }

这里最容易被忽略的是静态方法锁的是Class对象,和实例方法用到的this完全不是同一个东西。这意味着:一个类里同时有一个静态synchronized方法和一个实例synchronized方法,它们可以并行执行,因为一个锁是Class对象,另一个锁是具体的实例对象,互不干扰。

我就见过有人以为这两者都加了synchronized,就一定是互斥的,结果代码上线后同一个类的两个方法同时在跑,闹了个乌龙。所以用synchronized之前,第一件事永远是问自己:我用的这把锁,锁的是哪个对象?还有谁会用到同一把锁?

2.2 把String和Integer当锁,是两个特别隐蔽的坑

锁对象的选择直接决定互斥范围。我最想提醒的是两个特别容易踩的坑。

第一个坑是你用字符串字面量当锁。Java对字符串有常量池机制,只要两个字符串的内容一样,它们就是同一个对象:

public void a() { synchronized ("lock") { // 业务逻辑 } } public void b() { synchronized ("lock") { // 另一段毫不相关的业务逻辑 } }

这两个方法本来毫无关系,但因为共享了常量池里的同一个字符串对象,它们会互相阻塞。你以为是“各自锁各自的”,结果是“大家一起堵”。

第二个坑是用Integer做锁。Integer在-128到127之间会走缓存,synchronized (Integer.valueOf(1))和synchronized (Integer.valueOf(1))拿到的是缓存里同一个对象,同样会造成意外的全局互斥。更要命的是,如果你在锁里修改了这个Integer变量的值,那锁就立刻变了,其他线程拿着新值对应的锁对象还能继续进临界区,同步完全失效。

这些坑的共性是:锁对象必须是稳定的、唯一的、且明确归属于当前同步场景的。所以我现在的习惯是,每个需要同步的模块专门定义一个私有final常量作为锁对象,或者直接锁this/当前类的Class对象,从不图省事用字面量。

2.3 从字节码看穿它的本来面目

把上面任意一个用synchronized修饰的类用javap -c反编译,能看到代码块版本是这样:

monitorenter // 业务指令 monitorexit // 异常路径上还有另一个monitorexit

方法版本则不同,它没有monitorenter指令,而是在方法描述符上多了一个ACC_SYNCHRONIZED标志。

这说明什么?不管哪种写法,最终都是围绕同一件事:获取一个Monitor对象。方法锁的ACC_SYNCHRONIZED相当于虚拟机会在方法调用时自动帮你做monitorenter,方法返回时自动做monitorexit。代码块锁则需要自己写monitorenter和monitorexit,而且要处理异常路径。

还有个细节是,代码块锁里的monitorexit不止一个,JVM会把尾部正常出口和异常出口各放一次monitorexit,保证无论什么情况锁都会被释放。这一点是咋一看字节码容易忽略的,但理解了它,你就知道为什么synchronized代码块即使抛异常也不会死锁——编译器在异常表里早就把这个后路安排好了。

3. 锁升级机制到底做了什么:偏向锁、轻量级锁、重量级锁的代价权衡

3.1 JDK 1.6之前为什么慢,之后又是怎么翻身的

网上能搜到的资料都会告诉你synchronized在JDK 1.6之后有了大量优化,但很少有人讲清楚“它到底为什么慢,优化又发生在哪个环节”。

在最早的实现里,任何一次加锁都要直接走操作系统的互斥量。线程拿不到锁,就会被操作系统挂起,直到锁释放再被唤醒。这个过程涉及用户态切换到内核态,切换本身的成本可能比临界区里代码的执行成本还高。所以当年很多人宁可自己用CAS自旋写锁。

后来的优化思路其实很朴素:大多数锁在大多数时间里,根本就没有竞争。既然没有竞争,为什么还要走最昂贵的系统调用?于是JVM设计了三档升级路径:

  1. 偏向锁:同一个线程反复进入同一把锁时,只需要在对象头的Mark Word里做一个线程ID标记,后续进来直接校验标记通过,什么都不用做。
  2. 轻量级锁:一旦有另一个线程来竞争,偏向锁撤销,升级为轻量级锁。这个阶段会用到CAS去竞争锁,拿不到的线程会自旋重试,而不是立刻被挂起。
  3. 重量级锁:如果自旋失败,或者竞争者实在太多,锁就会膨胀成重量级锁,走真正的Monitor,拿不到的线程被挂起等待。

升级是单向的,只能从偏向到轻量、再到重量,不能倒回去。所以一个锁如果长期处于高竞争状态,最终一定会变成重量级锁,之后的每一次获取和释放都要用户态和内核态来回切换。

3.2 偏向锁的兴衰:为什么它反而成了被淘汰的对象

这里有个反直觉的地方。JDK 15里偏向锁被标记为废弃,JDK 20开始默认禁用。一个当年为优化而生的机制,怎么就退场了?

原因就藏在撤销偏向上。撤销偏向锁不是O(1)的本地操作,它需要到达一个安全点,让所有线程阻塞一段时间,然后JVM去检查当前持有偏向锁的线程是否还活着,再做Mark Word覆盖。如果应用本身竞争激烈,偏向锁的收益没吃到多少次,反而频繁触发安全点,导致全应用停顿。

这个演进过程特别能说明问题:性能优化不是无脑堆机制,而是要在真实负载里做权衡。现代应用里,即使原本无竞争的锁,遇到线程池复用、协程切换等场景,竞争概率也大幅上升,偏向锁那点收益已经赶不上它带来的潜在停顿风险了。所以真要追求极致性能,别迷信偏向锁,做压测才是王道。

3.3 从锁升级看同步开销的本质

搞清楚了升级路径,就不难理解一个老生常谈的问题:synchronized到底慢不慢?答案是取决于竞争程度。

如果锁无竞争或者只有轻微竞争,偏向锁和轻量级锁的开销极低,基本就是几次CAS的事。如果临界区很短,自旋期间可能就等到锁释放,整体性能依然不错。但一旦有大量线程同时阻塞等待,就会走重量级锁,这时候性能才会断崖式下降。

所以你在评估同步性能时,真正要看的不是synchronized本身,而是你的临界区平均占用时间和线程到达的密集程度。我手头一个项目曾经把一个需要做网络I/O的操作整个锁住,那时候无论用什么锁都救不了它,因为临界区在毫秒级,而请求到达率远高于这个数,锁竞争不可避免。后来把I/O移出临界区,用异步回调替代,锁占用时间降到微秒级,问题才真正解决。这个教训说明一个道理:锁的实现再优化,也救不了设计层面的错误。

4. 实操里的几个典型坑与排查思路:从锁选择到死锁定位

4.1 synchronized、ReentrantLock、读写锁、StampedLock怎么选

这些工具谁替代谁,经常被问。我给一个比较朴素的决策逻辑:

场景推荐方案理由
默认场景,临界区短且无复杂需求synchronized语法简单,不用手动解锁,JVM还能做锁粗化锁消除
需要尝试获取锁、超时控制、可中断ReentrantLock提供了tryLock和lockInterruptibly
需要公平锁ReentrantLock(true)synchronized无法保证公平性
读多写少,读写锁分离ReentrantReadWriteLock读读不互斥,提高并发度
并发读比重极大,且读操作很快StampedLock乐观读乐观读完全无锁,读性能极高

很多人上来就想用高级锁,我觉得这是没想清楚。ReentrantLock再强,它需要你自己在finally里unlock,一旦代码分支复杂,漏掉释放的后果比synchronized严重得多。所以我现在的习惯是:默认synchronized,只有在明确需求出现时才切换到JUC里的其他锁。这不是技术水平低,而是工程上的风险控制。

4.2 锁粒度和锁粗化的度到底怎么拿

网上总说“尽量缩小同步范围”,这话对,但不全对。JVM有锁粗化优化,如果它检测到连续多个同步块用的是同一把锁,且中间没有线程调度点,它会把这些同步块合并成一个更大范围的同步块。

比如下面这样的代码:

public void process() { synchronized (lock) { step1(); } synchronized (lock) { step2(); } synchronized (lock) { step3(); } }

JVM在不少情况下会直接当成一个同步块来看待。这时候你费劲拆分的三个小块,其实并没有带来额外并发度。理解了这个,你就知道锁粒度真正该关注的是:这个临界区内是否包含耗时操作。如果是,那就应该把耗时操作移出去;如果只是几个快速操作,拆不拆区别不大。

真正需要避免的是在临界区里做慢操作,比如远程调用、DB查询、大循环、刻意sleep。这些才是性能杀手,而不是同步代码块本身。

4.3 死锁:synchronized锁的修复难度更高,所以更要预防

死锁的四个条件,大部分人都知道:互斥、持有并等待、不可剥夺、循环等待。synchronized有一个不同于ReentrantLock的点——它不提供超时释放的能力。ReentrantLock还有个tryLock可以让你尝试获取一次,拿不到就放弃;synchronized一旦锁死,它就会一直等,程序就只能靠重启。

构造一个死锁很简单:

public class DeadlockDemo { private final Object lockA = new Object(); private final Object lockB = new Object(); public void method1() { synchronized (lockA) { System.out.println("持有锁A"); synchronized (lockB) { System.out.println("持有锁B"); } } } public void method2() { synchronized (lockB) { System.out.println("持有锁B"); synchronized (lockA) { System.out.println("持有锁A"); } } } }

两个线程各自拿着A等B、拿着B等A,就卡死了。排查死锁的标准套路是先把线程栈dump出来:

jstack -l 进程ID > thread_dump.txt

然后在dump文件里搜索“Found one Java-level deadlock”,能看到死锁循环的完整链路:线程一当前持有的锁、正在等待的锁、以及哪一行代码触发了等待。

但从我的视角看,最重要的不是事后排查,而是事前约定。团队里做代码评审时,我会特别盯一件事:如果一个方法里需要同时持有多把锁,所有方法里的加锁顺序必须完全相同。这是成本最低、也最有效的死锁预防手段。

4.4 虚拟线程时代,synchronized还会是主角吗

这个问题的答案我觉得很明确:会是。JDK 21正式带来了虚拟线程,虚拟线程最大的特点就是它足够轻量,可以被JVM调度来调度去。使用synchronized在虚拟线程中不再阻塞操作系统线程,而是让出调度权,成本大幅下降。这意味着重量级锁曾经最大的痛点——线程挂起唤醒的昂贵开销——被虚拟线程的机制很大程度上消解了。

所以虽然表面上synchronized这些年在语法层面没有任何变化,但运行它的那个JVM早已不是当年那个JVM了。保留字还是那个保留字,性能故事早就换了一轮。

5. 写在最后的一点个人体会

如果让我给一个结论,那就是:不要因为它看起来简单就轻视它,也不要因为它看起来古老就嫌弃它。synchronized在Java并发里的地位,相当于你做菜时的基础刀工——刀法再好的人,也绕不开它。我现在的习惯是,需要同步时先无脑用synchronized保证正确性和可读性,只有真的在压测数据里看到锁竞争成为瓶颈,才去考虑ReentrantLock或者更复杂的无锁方案。很多号称synchronized太慢而换锁的项目,换了之后性能依然没起来,原因大多是临界区设计不合理,而不是锁本身的问题。先解决设计问题,再谈锁的选型,这个顺序千万别颠倒。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进