1. synchronized 到底锁住了什么:从一次滑铁卢面试说起
有位读者朋友去年面试某大厂,被问到这么一个问题:“synchronized 修饰在方法上,锁的是当前对象;修饰在静态方法上,锁的是 Class 对象。那如果两个线程一个调用同步实例方法、一个调用同步静态方法,它们会互斥吗?”
他当时脱口而出:“会吧,都是 synchronized。”
结果当然是挂了。面完他回来问我,我跟他解释完,他第一反应是:“原来 synchronized 锁的不是方法,也不是代码块,而是对象。我之前一直以为锁的是代码。”
这个认知误区其实非常普遍。很多 Java 开发者从入门开始就天天写 synchronized,知道它能保证线程安全,但真被问到“锁的到底是什么”“为什么能保证线程安全”“锁升级的细节是什么”时,往往就只能答个大概。这篇文章不是教科书式的复述,而是把我这些年在使用、排查、优化 synchronized 过程中积累的理解,包括踩过的坑、验证过的手段,系统地整理一遍。你会搞清楚三件事:synchronized 的特性到底怎么理解,三种用法背后的锁对象分别是谁,以及偏向锁、轻量级锁、重量级锁这套升级机制的来龙去脉。
无论是准备面试,还是写多线程代码时想弄明白“为什么这样写安全、那样写不安全”,这篇文章应该都能帮到你。我会尽量用大白话拆解,但涉及底层的地方不会回避细节,因为那些细节才是区分“会用”和“懂”的分水岭。
2. 三条核心特性:原子性、可见性、有序性,外加一个可重入
2.1 原子性:锁住的是“代码片段”的完整执行
synchronized 的第一条特性是原子性。通俗点说,被 synchronized 包裹的代码段,在同一时刻只能有一个线程执行,其他线程想进来就得在门外等着,直到持有锁的线程执行完毕并释放锁。
但这里有一个很容易忽略的点:synchronized 保证的原子性,是“加了锁的这段代码”的原子性,而不是“方法里所有操作”的原子性。举个例子:
public class Counter { private int count = 0; public synchronized void increment() { count++; } } public class Counter2 { private int count = 0; public void increment() { // 这里的 count++ 并不是原子操作 synchronized (this) { count++; } } }上面的两种写法,效果是一样的,锁住的都是当前对象,保证 count++ 这段临界区同一时刻只有一个线程执行。但如果你在 increment() 方法里既有 count++,又有其他不需要同步的操作,那 synchronized 只保证临界区内的操作不被并发干扰,临界区外的操作该并发还是并发。
曾经有人问我:“synchronized 是不是能保证方法内的所有语句都原子执行?”答案是:能保证它们作为一个整体不会被其他持有同一把锁的线程插入执行,但如果你在同步代码块里调用了另一个没有加锁的方法,那个方法在执行时仍然可能被其他线程并发访问。所以设计临界区的时候,一定要把需要保护的操作都放进锁的范围内,别只锁一半。
2.2 可见性:锁的释放与获取之间,存在一条内存屏障
可见性这个概念,很多初学者理解得比较浅,就觉得“多个线程同时访问一个变量,用 synchronized 锁住就不会出问题”。这里面的机制值得认真说清楚。
Java 内存模型(JMM)规定,每个线程有自己的工作内存(相当于 CPU 缓存),线程对共享变量的读写,先操作工作内存,再同步到主内存。如果一个线程修改了变量但还没来得及写回主内存,另一个线程读到的就是旧值,这就出现了可见性问题。
synchronized 之所以能解决可见性,是因为它内部有一条隐含的规则:当一个线程释放锁时,会把工作内存中的共享变量刷新到主内存;当另一个线程获取同一把锁时,会重新从主内存加载共享变量到工作内存。
换成大白话就是:线程 A 在 synchronized 块里改了 count 的值,退出块时这个值被强制刷回主内存;线程 B 进入同一个锁保护的块时,强制从主内存重新读一遍 count,读到的就是 A 改过的最新值。
这就像两个同事共用一个保险柜。A 打开保险柜存入新文件,锁上柜门;B 打开同一个保险柜,看到的就是 A 存入的最新文件,不会看到旧版本。但如果 B 开的是另一个保险柜(也就是不同的锁对象),那他看到的可能就是旧文件,这就是为什么锁对象必须是同一个对象才能保证互斥和可见。
2.3 有序性:synchronized 如何对抗指令重排序
指令重排序是编译器和 CPU 为了优化性能而做的调整,单线程下没问题,多线程下就可能出岔子。JMM 规定了一套 happens-before 规则,其中与 synchronized 相关的核心就是:一个锁的释放操作 happens-before 于后续对这个锁的获取操作。
这个规则意味着,线程 A 在释放锁之前做的所有写操作,对于随后获取同一把锁的线程 B 来说,都是可见的,而且在 B 看来这些操作发生的顺序就是 A 代码里的顺序。即使编译器和 CPU 在 A 内部做了重排序,在锁边界上也会插入内存屏障,禁止相关指令跨越锁的边界重排序。
很多人知道 volatile 能禁止重排序,却不知道 synchronized 也有类似的效果。区别在于,volatile 是对单个变量的读写保证有序性,synchronized 是对整个临界区的操作,在锁的边界上生效。
2.4 可重入性:同一个线程可以重复拿到同一把锁
可重入性是我在面试中经常考查的一个点,也是写代码时容易忽略的一个特性。简单说,同一个线程在已经持有某把锁的情况下,可以再次进入所有由该锁保护的代码块,不会把自己锁死。
public class ReentrantDemo { public synchronized void outer() { System.out.println("outer"); inner(); // 这里能进入吗? } public synchronized void inner() { System.out.println("inner"); } }上面的代码完全没问题,outer() 和 inner() 都是 synchronized 方法,锁对象都是 this。线程执行 outer() 时获取了 this 的锁,然后调用 inner(),发现需要 this 的锁,但自己已经持有了,于是直接放行。这就是可重入。
可重入的底层实现是:每个锁对象关联一个计数器和一个持有线程。当持有线程再次获取锁时,计数器加一;退出同步块时计数器减一,减到零才真正释放锁。这个机制保证了递归调用、方法链式调用等场景下不会出现死锁。
可重入性的作用在继承场景下更明显。如果父类有一个 synchronized 方法,子类重写它并在方法里调用了父类的 synchronized 方法,如果锁不可重入,这里就会死锁。正是因为可重入,这种写法才能正常工作。
3. 三种用法剖析:实例方法、静态方法、代码块的锁对象分别是谁
3.1 同步实例方法:锁的是 this,不是方法
synchronized 修饰实例方法时,锁对象是调用这个方法的实例对象,也就是 this。来看看这行代码:
public class Account { private int balance; public synchronized void withdraw(int amount) { if (balance >= amount) { balance -= amount; } } public synchronized int getBalance() { return balance; } }这里两个方法锁的都是同一个 Account 实例。如果线程 A 在调用 withdraw(),线程 B 想调用同一个对象上的 getBalance(),就必须等 A 释放锁。这保证了 balance 的读写不会交叉执行。
但要注意:如果是两个不同的 Account 实例,它们的锁互不干扰。线程 A 操作 account1.withdraw(),线程 B 操作 account2.getBalance(),它们可以同时执行,因为锁对象不同。这在业务上通常是合理的(不同账户的余额本来就该独立操作),但如果你的需求是所有账户共享某个资源(比如一个全局的总余额),那锁 this 就不够了,得用 Class 锁或者一个共享的静态锁对象。
3.2 同步静态方法:锁的是 Class 对象,全局唯一
synchronized 修饰静态方法时,锁对象是这个类对应的 Class 对象。Class 对象在 JVM 中每个类只有一份,所以所有通过该类的静态同步方法进行的并发控制,是全局性的。
public class ConfigManager { private static Map<String, String> config = new HashMap<>(); public static synchronized void updateConfig(String key, String value) { config.put(key, value); } public static synchronized String getConfig(String key) { return config.get(key); } }无论创建多少个 ConfigManager 实例,updateConfig() 和 getConfig() 用的锁都是 ConfigManager.class,所以它们之间是互斥的。这里有个很微妙的点:同一个类的同步实例方法(锁 this)和同步静态方法(锁 Class 对象)之间,并不互斥。
回到文章开头那个面试题:一个线程执行实例同步方法,另一个线程执行静态同步方法,它们会不会互斥?答案是:不会。因为一把锁是实例对象,另一把锁是 Class 对象,锁根本不同,大门都不是同一扇,自然谈不上互斥。
这是一个容易掉进去的坑。比如你在一个 Service 类里既有同步实例方法又有同步静态方法,都访问同一个共享数据,如果误以为它们会互斥,那就埋下了并发隐患。
3.3 同步代码块:锁对象由你指定,最灵活的姿势
同步代码块能让你精确控制锁对象和锁的范围。锁对象可以是一个实例、一个 Class,也可以是一个专门用于同步的常量对象。
public class SafeList<T> { private final List<T> list = new ArrayList<>(); private final Object lock = new Object(); public void add(T item) { synchronized (lock) { list.add(item); } } public T get(int index) { synchronized (lock) { return list.get(index); } } }用专门的 lock 对象而不是 this,好处有两个。第一,避免外部代码通过 synchronized(instance) 跟你竞争同一把锁,减少不必要的阻塞;第二,如果你的类里有多组需要分别加锁的共享资源,可以用不同的锁对象,降低锁的粒度,提高并发度。
代码块还可以指定锁 Class 对象,比如 synchronized (ConfigManager.class),效果和同步静态方法一样。这在你只需要锁某个局部操作、不想把整个方法都用 synchronized 修饰时非常有用。
选择锁对象时,有一个原则必须遵守:所有需要互斥的线程,必须使用同一个锁对象。比如两个线程一个用 synchronized(lockA)、一个用 synchronized(lockB),哪怕它们操作的是同一份数据,也不会互斥,反而会破坏线程安全。
3.4 三种用法的实际选择:什么场景该用哪一种
日常开发中,我给的建议是这样:
- 方法体很短,整个方法都需要保护,直接修饰方法最省事,代码也简洁;
- 方法体较长,但只有一小段涉及共享变量,用同步代码块缩小锁范围,提高并发度;
- 多个方法共享同一个资源,但调用源不同(实例/静态),统一用同步代码块指定同一个锁对象,避免锁对象不一致;
- 需要用多个锁分别保护多组资源,使用显式锁对象,每组资源配一把锁。
实际经验是:锁的粒度越小,性能越好;锁的粒度越精确,代码越安全。不要嫌多写几行代码,代码块 + 私有锁对象是综合最稳的写法。
4. 锁机制底层:偏向锁到重量级锁的四级升级之路
4.1 从对象头说起:Mark Word 里藏着的秘密
synchronized 的锁信息存放在 Java 对象头的 Mark Word 里。在 64 位 JVM 中,对象头由 Mark Word(8 字节)和 Klass Pointer(8 字节,默认开启压缩指针时是 4 字节)组成。Mark Word 的默认存储结构里包含 hashCode、分代年龄、偏向锁标志位(biased_lock)和锁标志位(lock bits)。
锁标志位只有 2 个比特,却能表达四种状态:01 表示无锁或偏向锁(用 biased_lock 位区分),00 表示轻量级锁,10 表示重量级锁,11 表示 GC 标记。
这很关键:Java 中的锁不是一上来就是重量级的,而是从无锁状态开始,根据竞争情况逐步升级。这个设计理念很朴素——大多数情况下,锁往往不会被多个线程同时竞争,没必要一开始就上最重的锁。
4.2 偏向锁:只有一把钥匙,谁拿到就是谁的
偏向锁的设计动机是:很多 synchronized 代码在绝大多数情况下只被一个线程执行,不存在竞争。那就不如让这个线程“偏向”于这把锁,下次再来时不需要任何 CAS 操作,直接进入。
当一个线程第一次访问某同步块并获取锁时,JVM 会在对象头的 Mark Word 里记录这个线程的 ID(Thread ID),并设置偏向锁标志位。之后这个线程再次进入同一个同步块时,只需检查 Thread ID 是否匹配,匹配就直接执行,无需任何原子操作,性能极高。
但这有个前提:只有当一个线程反复进出同步块,偏向锁才能发挥最大价值。一旦出现第二个线程尝试获取锁,偏向锁就开始“不安定”了。
偏向锁的撤销是延迟的。如果线程 B 来竞争,JVM 先要让持有偏向锁的线程到达一个安全点(safe point),然后暂停它,判断偏向线程是否还活着以及是否还在使用锁。如果偏向线程已经退出同步块,就把锁状态重置为无锁状态(biased_lock=0,lock=01),然后允许竞争;如果偏向线程还在执行同步块,就升级为轻量级锁,让两个线程公平竞争。
JVM 提供了几个偏向锁开关,便于调试和优化:
-XX:+UseBiasedLocking:启用偏向锁(JDK 8 默认开启)-XX:BiasedLockingStartupDelay=0:JVM 启动后立即启用偏向锁(默认有 4 秒延迟)-XX:-UseBiasedLocking:禁用偏向锁
值得注意的是,JDK 15 开始默认禁用了偏向锁,JDK 18 之后直接移除了偏向锁。原因很实际:偏向锁在竞争存在时,撤销成本反而高于收益,而且现代偏向锁的优化空间被其他机制(如 JIT 优化)覆盖了。这提醒我们,锁优化策略是跟着 JVM 版本走的,不要死记硬背一套。
4.3 轻量级锁:CAS 抢锁,用自旋换阻塞
当第二个线程开始竞争同一把锁时,偏向锁撤销后,锁状态升级为轻量级锁。为什么叫“轻量级”?因为它的加锁和解锁不需要操作系统介入,不涉及线程阻塞和唤醒,而是基于 CAS(Compare And Swap)。
获取轻量级锁的过程是这样的:
- 线程在栈帧中创建锁记录(Lock Record)空间,用于存放锁对象的 Mark Word 副本;
- 线程使用 CAS 尝试将对象头中的 Mark Word 替换为指向锁记录的指针;
- 如果替换成功,说明获取锁成功,记录锁状态为轻量级锁;
- 如果替换失败,说明有其他线程持有了锁,当前线程进入自旋(spin),不断重试 CAS;
- 自旋到一定次数(自适应自旋会动态调整次数)还拿不到锁,就膨胀为重量级锁。
这里有个理念要理解:轻量级锁适合“线程交替执行同步块、竞争时间很短”的场景。CAS 操作比线程阻塞和唤醒的开销小得多。但如果多个线程同时竞争,且持有锁的时间很长,自旋就会白白浪费 CPU,得不偿失,此时就应升级为重量级锁。
4.4 重量级锁:依赖操作系统,进入阻塞队列
重量级锁是锁升级的最终形态。获取不到锁的线程会进入阻塞状态,不占用 CPU,等待操作系统唤醒。这个机制依赖底层操作系统的互斥量(Mutex Lock)来实现。
这里涉及用户态和内核态的切换。阻塞和唤醒一个线程,需要操作系统介入,一次切换的成本可能在几十微秒到上百微秒,比 CAS 自旋重试高出几个数量级。所以重量级锁的代价高,但在高竞争场景下,把 CPU 让给真正在干活的线程是合理的选择。
锁升级到重量级锁后,不会再回退到轻量级锁或偏向锁。这是一个单行道。JVM 里用 inflate 方法膨胀锁,一旦升级,整个对象头的 Mark Word 就记录为指向 Monitor 对象(管程)的指针,通过 Monitor 来管理阻塞队列。
Moniter 对象里维护了几个关键数据结构:
- Owner:当前持有锁的线程;
- EntryList:等待获取锁的线程队列;
- WaitSet:调用了 wait() 方法的线程队列。
这一套结构对应着 synchronized + wait/notify 的协作模型,也是理解对象监视器(Monitor)的关键。
4.5 锁升级全过程:一次典型的线程竞争模拟
为了把这四级状态串起来,我模拟一个典型过程。假设有一个计数器对象 counter,状态初始为无锁。
线程 T1 第一次进入 synchronized(counter) 代码块。此时 counter 的锁状态为无锁,JVM 将 T1 的线程 ID 写入 Mark Word,锁状态变为偏向锁,T1 每次进入都畅通无阻。
线程 T2 出现了,它也尝试进入同一个同步块。偏向锁发现 Thread ID 对不上,撤销偏向,锁升级为轻量级锁。T1 和 T2 用 CAS 开始抢锁,抢不到的线程自旋等待重试。
如果 T1 持有锁的时间很长,T2 自旋多次仍拿不到锁,自适应自旋机制认为竞争激烈,于是锁膨胀为重量级锁。T2 进入阻塞队列,等待操作系统唤醒。此后 T1 释放锁时,会从 EntryList 中唤醒一个等待线程。
整个过程可以概括为:无锁 → 偏向锁 → 轻量级锁(自旋)→ 重量级锁(阻塞)。锁升级是为了在不同竞争强度下选择最合适的锁策略:竞争少用偏向,竞争短用自旋,竞争激烈用阻塞,尽量做到性能和资源占用的平衡。
5. 反直觉场景与真实排错:锁对象选错、锁粒度失控、锁死循环
5.1 String 作为锁对象:一个能让你排查到怀疑人生的坑
来一个我自己实际踩过的坑。在一段业务代码里,我想以订单号作为锁粒度,让同一个订单的请求串行处理,不同订单可以并行。代码写出来是这样:
private void processOrder(String orderId) { synchronized (orderId) { // 处理订单 } }看起来没毛病,但一压测就发现,不同订单的请求也被阻塞了。查了很久才发现,问题出在字符串常量池上。Java 里的字符串字面量和 intern() 出来的字符串会被缓存到常量池。如果两个不同的订单号在代码里都能匹配到同一个常量池字符串,或者你的 orderId 恰好是通过.intern()获取的,那它们对应的就是同一个 String 对象,锁自然就撞上了。
更隐蔽的问题还有一个:如果 orderId 为 null,synchronized(null) 会在运行时抛 NullPointerException。别笑,我见过生产环境因为这个直接挂掉的。
正确做法是用String.intern()也没那么可靠,因为常量池管理策略在不同版本 JVM 上有差异。更稳妥的方案是使用ConcurrentHashMap维护一个订单号到锁对象的映射:
private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>(); private void processOrder(String orderId) { Object lock = lockMap.computeIfAbsent(orderId, k -> new Object()); synchronized (lock) { // 处理订单 } }用完后记得清理 lockMap 中的条目,否则会有内存泄漏风险。这个方案能精确控制锁粒度,而且不会踩常量池的坑。
5.2 包装类作为锁对象:Integer 的缓存陷阱
跟 String 类似的坑还有包装类。比如用 Integer 当锁对象:
private Integer count = 0; public void increment() { synchronized (count) { count++; } }这个写法有几层问题。第一,Integer 的值在 [-128, 127] 范围内会走缓存,也就是说,所有值为 1 的 Integer 都是同一个对象,锁粒度被放大了。第二,count++实际上创建了一个新的 Integer 对象,原来的锁对象已经变了。你锁住的对象和业务数据对象不是同一个,不同线程可能锁着不同的对象进入临界区,线程安全完全失控。
我之前用一段压测代码验证过:两个线程分别对两个不同的 Integer 对象(值相同,比如都为 1)加锁,结果它们互斥了。因为这两个 Integer 对象实际是同一个缓存对象。这就是为什么用基本类型包装类当锁对象,一定要格外谨慎。
用锁对象的基本原则是:锁对象必须是稳定的、不会被重新赋值的,最好是 private final 的独立对象。
5.3 锁粒度失控:锁范围太大导致系统吞吐量骤降
还有一次性能排查,同事报接口响应变慢,从 50ms 涨到 2 秒。查代码发现,他们在一个方法上加了 synchronized,但这个方法内部会调用远程 HTTP 服务,耗时要几百毫秒。结果所有请求在 synchronized 门口排队,吞吐量直线下降。
这类问题的本质是锁范围太大,把不需要同步的操作也锁住了。正确做法是把锁的粒度缩小,只锁住操作共享资源的代码段。
// 错误的写法 public synchronized List<String> fetchAndProcess() { // 远程调用,耗时 500ms List<String> data = remoteService.fetch(); // 本地处理,只这段需要锁 List<String> result = process(data); return result; } // 正确的写法 public List<String> fetchAndProcess() { List<String> data = remoteService.fetch(); synchronized (this) { // 只对本地处理加锁 List<String> result = process(data); return result; } }微服务架构下,类似这种大锁问题很常见。远程调用(数据库操作、HTTP 调用、Redis 操作)都不应该放在 synchronized 块里。锁只保护临界资源,不保护 IO 操作,这条原则值得贴在工位上。
5.4 synchronized 里的死循环:锁不释放的现场还原
最后一个排错案例。有个定时任务,每隔 5 秒启动一个线程去处理一批数据,逻辑里用了 synchronized。某天开始,任务直接不执行了,连日志都没有。导出线程快照发现,某个线程一直处于RUNNABLE状态,并且持有锁,卡在一个 while 循环里出不来。
原因是一个异常引发死循环,导致同步块永远执行不完,锁也就永远不释放。其他等待锁的线程全部阻塞,任务队列越积越多。
这个案例给我们一个启发:synchronized 不像 ReentrantLock 那样支持 tryLock 超时中断,它一旦进入,只能等执行完或者抛异常退出。如果在同步块里用了可能导致死循环的代码,比如 while(!stop)、没有超时的远程调用,整个系统的并发能力都会被拖垮。
所以使用 synchronized 时,临界区内的代码应该尽量简短、确定、无阻塞。如果业务逻辑复杂,可能需要考虑用 ReentrantLock 或者并发工具类(如 CountDownLatch、Semaphore)来实现更灵活的控制。
6. 锁优化与替代方案:锁消除、锁粗化,以及为什么说 synchronized 不该背性能的锅
6.1 锁消除:JIT 编译器替你识破了一个“多余的锁”
JIT 编译器会在运行期分析,如果发现一个 synchronized 代码块在代码路径上不可能出现竞争,就会直接移除这把锁。这叫锁消除。
最常见的就是局部变量场景:
public void append(String a, String b) { StringBuffer sb = new StringBuffer(); sb.append(a); sb.append(b); }StringBuffer 的 append 方法都是 synchronized 的,但这里的 sb 是局部变量,不可能被其他线程访问。JIT 通过逃逸分析确认 sb 不会逃逸出当前方法,于是把所有 append 方法的同步逻辑优化掉,等价于用 StringBuilder 执行。
这个例子的启发是:不要因为性能而拒绝使用 synchronized。在现代 JVM 的优化能力下,很多看起来“重”的锁,实际执行时可能已经被优化没了。JDK 的-XX:+DoEscapeAnalysis和-XX:+EliminateLocks默认都是开启的。
6.2 锁粗化:把连续多次加锁合并成一次
与锁消除相对的,是锁粗化。当 JIT 检测到同一个线程在短时间内反复对同一个对象加锁、释放锁,它会把这些加锁操作合并成一个更大的同步块,减少加锁解锁的次数。
比如:
public void concat(String[] parts) { StringBuffer sb = new StringBuffer(); for (String part : parts) { sb.append(part); // 每次 append 都加锁解锁 } }如果 parts 数组有 10 个元素,这段代码理论上要加锁解锁 10 次。JIT 检测到这是同一个对象上的连续操作,就会自动把锁粗化,把这 10 次 append 合并到一个锁范围内,只加锁解锁一次。
这告诉我们,写代码时不需要过度纠结“一个方法里是否多次使用同一个锁”,JIT 有这个能力帮你优化。当然,锁粗化是有条件的,它要求这些加锁操作在时间上是连续且紧密的,中间没有长耗时的操作。如果你的代码加锁、解锁之间隔了远程调用,那 JIT 是不会帮你粗化的。
6.3 为什么说 synchronized 不该背性能的锅
很多人一谈到 synchronized 就觉得性能差,宁可不用。但从 JDK 6 引入锁升级机制后,synchronized 的默认表现已经非常好了:无竞争时是偏向锁,低竞争时是轻量级锁,高竞争时才升级为重量级锁。加上 JIT 的锁消除、锁粗化,多数场景下 synchronized 的性能已经不输 ReentrantLock。
真正让性能变差的,不是 synchronized 本身,而是它被用错了地方:锁了过大的范围、用了不合适的锁对象、在锁内做了 IO 操作。这些本质上是使用问题,不是锁机制的问题。
所以我一直建议:默认情况下优先使用 synchronized,而不是一上来就 ReentrantLock。synchronized 代码简洁、自动释放锁、可读性高。只有当需要可中断获取锁、超时获取锁、公平锁、多个条件队列这些 synchronized 无法支持的能力时,才考虑 ReentrantLock。别再让 synchronized 背性能的锅了,它现在的表现,绝大多数场景下都够用。
6.4 一个进阶建议:理解 happens-before,别死记锁规则
最后想多说一句。很多人学 synchronized,喜欢背“锁能保证原子性、可见性、有序性”这三条规则。但真正遇到并发 bug 时,靠背规则往往无法定位问题。更实用的做法是理解 happens-before 关系,尤其是锁相关的规则:解锁 happens-before 后续的加锁。
这条规则能推导出很多结论。比如,线程 A 在 synchronized 块里写入的数据,只要线程 B 随后进入同一个锁保护的代码块,就一定能看到。这个推论不依赖你记住“可见性”这个概念,而是从规则里直接推导出来。如果你习惯于用 happens-before 的视角去看并发问题,遇到各种并发容器、锁、原子类时,会更容易建立起整体认知,而不是把每个并发工具都当成一个孤立的 API 去背。
这个习惯我是在排查一类很隐蔽的并发 bug 时养成的:一个线程改了配置,另一个线程立刻去读,代码里加了 synchronized,但实际上用的是不同的锁对象。如果仅从“锁保证可见性”的角度思考,很容易觉得“我加了锁啊”。但用 happens-before 看一下,锁对象不同,解锁和加锁之间就没有 happens-before 关系,问题就清晰了。
我自己的经验是,遇到并发问题,先画出锁对象和线程之间的对应关系,再画 happens-before 边,基本能快速定位出是锁对象不一致、锁粒度太大,还是临界区代码本身有逻辑缺陷。synchronized 本身不算复杂,复杂的是对锁模型的理解。把这套机制想透了,以后无论是看 JUC 包里的源码,还是排查生产环境的线程安全问题,思路都会顺很多。