synchronized 大概是 Java 里最矛盾的关键字:教科书把它放在多线程第一章,面试官从基础问到锁升级,可真正在业务代码里能把 synchronized 用好的人却不多。要么是锁了字符串常量导致整个服务排队,要么是在两个实例上各自加锁却以为锁住了同一段逻辑,要么一到线上出现死锁,日志上只有几行线程 dump,根本不知道从哪入手。我这些年排查过的并发问题里,少说有一半都和 synchronized 的错误使用有关,而这当中绝大多数不是语法问题,而是对“锁的对象是谁、锁区间覆盖到哪、JVM 底层怎么执行”这三件事没吃透。
所以这篇文章我不打算再列一遍 API 用法,而是把 synchronized 从字节码、Monitor、对象头、锁升级、JMM 可见性一直到线上踩坑的完整链路拆开讲。无论你是刚接触并发的小白,还是写了几年 Java 想补底层原理的中级开发者,都能在这里找到可以直接落地的结论和一些“文档里不会写”的经验。读完你至少能回答清楚一个问题:我在代码里写下一个 synchronized,JVM 到底凭什么让线程安全?
1. synchronized 到底锁的是什么:三种用法背后同一套逻辑
先别急着看锁升级和 Monitor,第一步必须搞清楚 synchronized 的锁对象。这是所有使用问题的根源,也是我第一次带团队 code review 时反复强调的点:你写的每一种 synchronized 用法,都必须能在心里立刻回答出“锁在谁身上”。
1.1 修饰实例方法:你以为锁的是“方法”,其实锁的是对象
java复制public class Counter {
private int count;
public synchronized void increment() {
count++;
}
}
很多初学者以为上面的代码锁住了 increment 这个方法,于是写两个线程各自 new 一个 Counter 去调用,回来一执行发现 count 还是乱跳,立刻怀疑 synchronized 是不是失效了。
其实根本没有失效。synchronized 修饰实例方法时,锁的是调用这个方法的当前实例,也就是 this。线程 A 拿到的是 counterA 这个对象的锁,线程 B 拿的是 counterB 的锁,两把锁互不相干,相当于两个人进的是不同的房间,自然谈不上互斥。
正确做法是让多个线程共享同一个 Counter 实例:
java复制Counter counter = new Counter();
// 线程 A 和线程 B 都调用 counter.increment();
只有所有人都尝试进入“同一个房间”,锁才有意义。这个道理听起来简单,但实际项目里常见的错误是:每次请求来了都 new 一个 Service,然后方法上用 synchronized,结果压力一大,数据照样错乱。这种问题排查起来很隐蔽,因为日志里每个操作都“正常”,只是共享状态被撕碎了。
1.2 static 方法锁 Class 对象:为什么它和实例锁各不相干
java复制public class Counter {
private static int total;
public static synchronized void addTotal(int value) {
total += value;
}
public synchronized void increment() {
// 操作实例字段 count
}
}
synchronized 修饰静态方法时,锁的不是 this,而是当前类的 Class 对象,也就是 Counter.class。JVM 里面每个类只对应一个 Class 实例,所以静态同步方法的锁天然是全局唯一的,所有通过该类静态方法进入的线程都在争同一把锁。
这里有个很容易被忽略的坑:同一个类里,实例同步方法和静态同步方法用的是两把不同的锁。前者锁 this,后者锁 Counter.class。假如一个线程正在执行 addTotal,另一个线程可以同时执行 increment,两者完全不冲突。如果这两个方法操作的是同一个共享数据,那这个“不冲突”就是灾难。
我之前在别人的计费代码里见过这样一个设计:金额累加用静态 synchronized 方法,账户余额读取用实例 synchronized 方法,两个方法都操作同一个账户对象内部的数据,结果并发一上来账就对不上。修的方法很简单,统一锁同一个对象,或者把共享状态全部抽到同一个类内部,用同一把锁保护。
1.3 同步代码块:锁对象改成“裸锁”才最安全
java复制public class AccountService {
private final Object lock = new Object();
public void transfer(Account from, Account to, int amount) {
synchronized (lock) {
// 执行转账逻辑
}
}
}
同步代码块是用 synchronized(object) 手动指定锁对象,这是三种用法里粒度最灵活的一种。因为可以自己控制锁的作用范围,不需要把整个方法都圈进去,减少持锁时间,提升并发度。
但我对锁对象的选择有个硬性建议:优先使用 private final Object lock = new Object() 这种“裸锁”。为什么?因为裸锁对象没有被任何外部代码引用,外面的人根本不可能拿到这个锁去 synchronized 它,锁的边界完全封闭在类内部。很多框架会做 Spring 单例、代理增强之类的操作,如果直接 synchronized(this),万一 this 被外部某个环节 synchronized 了,两个不相干的锁就会莫名其妙互相阻塞,排查起来极其痛苦。
注意:锁对象最好声明为 final。如果锁对象本身是可变字段,一旦某次赋值把引用换了,所有等待旧对象锁的线程和新对象锁的线程就会各自为政,临界区瞬间失守。
小结一句话记住:synchronized 修饰实例方法锁 this,修饰静态方法锁 Class 对象,修饰代码块锁你指定的对象。后文所有 JVM 机制,都建立在“某个线程持有了某个对象的 Monitor”这个基础上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从字节码到 Monitor:synchronized 在 JVM 里到底执行了什么
很多讲 synchronized 的文章从语法直接跳到锁升级,中间“字节码 + Monitor”这一段被轻轻带过。但偏偏这两块才是理解锁升级的钥匙,你只有知道 JVM 是怎么识别同步块、Monitor 又是什么结构,才能明白为什么重量级锁要“膨胀”。
2.1 方法级与代码块级:字节码呈现的两条路径
先做一个最简单的实验,写一个同步代码块,然后用 javap 反编译查看:
bash复制javap -c -p SynchronizedDemo.class
字节码里会出现两个关键指令:monitorenter 和 monitorexit。
java复制public void demo();
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: getfield #2 // Field count:I
8: iconst_1
9: iadd
10: putfield #2 // Field count:I
13: aload_1
14: monitorexit
monitorenter 代表线程尝试获取某个对象的 Monitor 所有权。一个线程进入 monitorenter 时,如果目标对象的 Monitor 计数为 0,该线程直接持有它并把计数加 1;如果当前线程已经持有 Monitor,计数继续加 1,表示重入;如果 Monitor 被其他线程持有,当前线程阻塞等待。
代码块里通常会出现两个 monitorexit,一个是正常路径退出时执行的,另一个藏在异常处理器里。也就是即使临界区代码抛了 RuntimeException,JVM 也会通过异常表上的 handler 执行 monitorexit 释放锁,绝不会让锁因为异常而永远不释放。这也是为什么 synchronized 不需要你像 ReentrantLock 那样在 finally 里手动 unlock。
方法级 synchronized 的字节码稍微不同。javap -v 查看方法描述时,会看到方法的 access_flags 里多了一个 ACC_SYNCHRONIZED 标志。JVM 调用方法时检查到该标志,就知道这是一个同步方法,调用前获取锁,方法正常返回或异常返回时释放锁。本质和 monitorenter / monitorexit 相同,只是指令级别换了一种表达方式。
2.2 ObjectMonitor:重量级锁的老底
HotSpot 虚拟机里,每个对象都可以关联一个 ObjectMonitor 对象,这是 synchronized 实现重量级锁的核心数据结构。它的主要字段包括:
- _owner:记录当前持有 Monitor 的线程。
- _recursions:记录同一线程重入该 Monitor 的次数。
- _EntryList:存放因为竞争失败而进入阻塞等待状态的线程。
- _WaitSet:存放调用了 wait() 方法后进入等待状态的线程。
用 Java 世界能听懂的话说:ObjectMonitor 就是一个房间的管理员,_owner 是房间里当前的那个人,_EntryList 是门口排队的人,_WaitSet 是主动退到休息室等通知的人。所有线程进入 synchronized 代码块,本质就是竞争成为 _owner。
当一个重量级锁被释放时,JVM 会把 _owner 置空,然后从 _EntryList 里挑一个线程唤醒,让它去抢锁。这个唤醒过程依赖操作系统的线程调度,涉及用户态和内核态的切换,成本很高。这也是早期 synchronized 被称为“重量级锁”的原因:锁竞争剧烈时,大量线程在等待、唤醒里来回折腾,CPU 光处理内核态切换就忙不过来了。
2.3 可重入性的底层保障:计数器而不是“我记得你”
同一线程多次进入同一把锁不会死锁,这是 synchronized 可重入性的体现。底层靠的就是 _recursions 字段。
线程第一次获得 Monitor 时,_recursions = 1。同一线程再次进入同一个锁,_recursions++,退出一次就 _recursions--。只有当 _recursions 减到 0,锁才真正释放,其他线程才有机会成为 _owner。
如果是基于“重入 ID + 计数器”的设计,那每次重入都不需要重新走一遍完整的锁获取流程,而是直接在计数器上加一。这个机制保证了我们常见的递归场景是安全的,比如:
java复制public synchronized void outer() {
inner(); // 当前线程已经持有锁,这里会直接重入成功
}
public synchronized void inner() {
// do something
}
如果没有可重入机制,outer 调用 inner 的一瞬间就会出现“自己等自己的锁”这种死锁。有了 _recursions 计数器,这个问题从架构上就消失了。这也是 synchronized 比很多自旋锁方案更无脑安全的原因之一。
3. 从无锁到重量级:锁升级路径与对象头 Mark Word
JDK 1.6 之前,synchronized 出手就是重量级锁,性能差是公认的。后来 Doug Lea 的 ConcurrentHashMap 等并发包大量使用 CAS 和 volatile,反过来刺激 JVM 团队优化 synchronized,于是就有了偏向锁、轻量级锁这一连串升级机制。理解锁升级,必须从 Mark Word 开始。
3.1 为什么 JVM 要设计一条锁升级路径,而不是所有场景都用 Monitor
假设你的代码里有个计数器,大多数时候只有一个线程在访问,只有极偶尔情况下才出现多线程竞争。如果一上来就给这个对象挂重量级锁,每次访问都走内核态挂起唤醒的流程,那这个“线程安全”的成本比业务本身还高。
HotSpot 团队做的优化思路很简单:根据不同竞争程度动态调整锁的实现。
- 无竞争:连锁都不用,直接用 CAS 或者干脆无锁。
- 只有一个线程反复进出:偏向锁,让这个线程“记住”自己拥有锁。
- 低竞争:轻量级锁,线程通过自旋空转等待锁释放。
- 高竞争或自旋超时:膨胀为重量级锁,阻塞等待。
这套机制保证了 synchronized 在低竞争场景下能和并发包里的 Lock 打得有来有回,高竞争场景下又能退回到传统的线程阻塞方案兜底。
3.2 Mark Word:对象头里一块“超级变变变”内存区域
任何一个 Java 对象在内存布局上大致分三块:对象头、实例数据、对齐填充。对象头里最核心的部分叫 Mark Word,64 位 JVM 上通常是 64 bit,不同的锁状态会让同样 64 bit 内存拥有完全不同的含义。
| 锁状态 | Mark Word 存储内容 | 说明 |
|---|---|---|
| 无锁态 | 对象哈希码、GC 分代年龄 | 正常新对象的初始状态 |
| 偏向锁 | 偏向线程 ID、时间戳、偏向状态位 | 记录当前持有者线程 |
| 轻量级锁 | 指向线程栈中 Lock Record 的指针 | 线程通过 CAS 抢锁 |
| 重量级锁 | 指向 ObjectMonitor 的指针 | Monitor 模型,线程阻塞 |
| GC 标记 | 空,标记可达性 | GC 使用 |
很多人搞不懂为什么 Mark Word 能又存哈希又存锁信息。其实它并不是同时存下这些,而是这些状态互斥:当线程给一个对象加偏向锁时,如果对象还没计算过 hashCode,Mark Word 里的空间就会被重新编排,偏向线程 ID 顶替原本存放 identity hashcode 的位置。所以你会看到网上有人说“重写了 hashCode 的对象不能进入偏向锁,因为 hashcode 写进了 Mark Word,没有位置再写偏向锁信息”,这就是底层存储位置冲突导致的。
3.3 锁升级过程的完整时间线
我把一条典型的升级链路按时间顺序拆开:
-
对象刚创建,处于无锁态。第一个线程尝试进入 synchronized 块。
-
JVM 发现这个对象目前没有竞争,就把当前线程 ID 通过 CAS 写入 Mark Word,让它进入偏向锁状态。之后这个线程每次再进出临界区,都不再做任何同步操作,只比对一下线程 ID 是否一致,一致就直接通过,这部分开销几乎为零。
-
第二个线程也来竞争这个锁。偏向锁会先尝试撤销,需要等到持有偏向锁的线程到达全局安全点(safe point)才能操作。如果撤销成功,对象回到无锁态或者轻量级锁状态。如果持有偏向锁的线程仍在运行且不释放,那么第二个线程会把锁升级为轻量级锁。
-
轻量级锁的抢占方式是自旋。线程把自己的 Lock Record 复制到栈帧中,通过 CAS 尝试把对象头的 Mark Word 换成指向自己 Lock Record 的指针。成功就拿到锁;失败就自旋重试;自旋次数超过阈值还是拿不到,说明竞争已经剧烈,这时候 JVM 把 Mark Word 改成指向 ObjectMonitor 的指针,膨胀为重量级锁。
-
重量级锁下,没有获得锁的线程不再空转,而是被挂起,进入 ObjectMonitor 的 _EntryList 排队,线程状态会变成经典的 BLOCKED。
这套升级流程是单向的:偏向锁可以升级为轻量级锁,轻量级锁可以膨胀为重量级锁,但锁不会降级。所以一个对象一旦走上了重量级锁,后面即使没有竞争了,它也会继续保持重量级锁状态。这也是为什么线上要关注锁竞争持续时长,别让一个瞬间峰值把一个热点对象永久“打”成重量级。
3.4 JDK 15 之后偏向锁被默认关闭,学习还要不要学旧版
偏向锁的设计在 JDK 15 之后默认被关闭,官方随后也在逐步移除相关参数和代码。原因是现代应用里大部分锁对象的生命周期短,且偏向锁撤销时要等安全点,这个开销在高并发容器类场景下反而成了包袱。Java 并发包的作者和维护者都表示偏向锁带来的收益被高估了。
但这不代表你不需要学偏向锁。一方面,很多企业线上还是 JDK 8 和 JDK 11,偏向锁在这些版本里默认为开启;另一方面,面试里聊锁升级时能说出“偏向锁需要到达安全点撤销”“JDK 15 之后默认关闭”这些细节,比背一版过时答案要专业得多。如果你能在回答后面加一句话“所以现在的性能瓶颈判断不能默认偏向锁存在,还是要看实际压测”,这就能体现出你有线上经验。
4. synchronized 与 JMM:三大特性到底保证到什么程度
并发编程的三个核心问题是原子性、可见性、有序性。synchronized 不是靠单一机制同时解决的,而是分别依赖互斥执行、内存屏障和 happens-before 规则。理解这三件事,你才能真正解释一些看起来“莫名其妙”的线程安全问题。
4.1 可见性:锁的获取和释放自带“内存屏障”效果
先说结论:线程 A 在释放锁之前对共享变量的所有修改,在线程 B 获取同一把锁之后都是可见的。这是 JMM 里锁语义的核心保证。
从 JVM 的实现角度看,线程在释放 Monitor 时,会把自己工作内存中的共享变量刷新到主内存;线程在获取 Monitor 时,会重新从主内存加载共享变量到自己的工作内存。这一系列操作相当于在临界区入口和出口各加了一道内存屏障。由于任何时候只会有一个线程持有锁,它释放前写回主存的数据,必然能被下一个拿锁线程重新读取到。
这里要特别提醒一种常见误用:一个线程在 synchronized 块里修改了共享变量,另一个线程在没加锁的地方直接读这个变量,这是不安全的。锁的可见性只建立在“同一把锁 + 两侧都加锁”的前提下。你只锁写方、不锁读方,读方依然可能读到旧值,这在并发容器和一些缓存框架里经常引发隐蔽问题。
4.2 原子性:锁的是临界区,不是整个世界
synchronized 提供的原子性是“互斥执行”,也就是同一时刻只有一个线程能进入被同一把锁保护的临界区。拿经典的 count++ 来说:
code复制读取 count 当前值 -> 把当前值加 1 -> 写回 count
这三步不是原子的,但把它们全部放进 synchronized 临界区后,线程 B 必须在门外等线程 A 完整走完三步才能进来。从这个意义上说,synchronized 把一组复合操作“打包”成了肉眼上的一个原子操作。
但必须注意边界的粒度。如果一段代码里有多个共享变量,却用两把不同的锁保护,那么两个线程可以分别拿不同锁进入不同临界区,同时修改这两个变量,仍然会产生不一致。原子性保护的覆盖面是临界区内的全部操作,不是方法名,不是类名,更不是“我觉得应该安全”的范围。
4.3 有序性:靠 happens-before 规则形成“时间上的先后”
许多人把 synchronized 的有序性理解成“临界区内的代码不会被重排序”,这个说法并不严谨。JVM 并没有承诺临界区内部不会指令重排,它承诺的是:同一个锁的 unlock 操作 happens-before 后续对这个锁的 lock 操作。
通俗解释:线程 A 在锁内完成的所有写入,逻辑上先于线程 B 获得同一把锁之后看到的状态。这个 happens-before 关系再加上互斥性,会让从外部观察时临界区里的操作表现出明确的先后顺序。锁的入口和出口是重排序的边界,JVM 不会让一个线程在释放锁之后,把临界区里的某些写操作“拖延”到释放之后才执行,也不会让获取锁的线程把读取操作提前到拿锁之前,否则锁规则就被破坏了。
在线程安全的程序里,synchronized 提供的这个顺序保证了你的复合操作在多个线程之间不会穿插乱序。但如果你在锁外不加控制地读共享变量,那就属于数据竞争,JMM 不承诺任何顺序。
4.4 synchronized 与 volatile 怎么分工
volatile 只能保证可见性和禁止指令重排序,不能保证原子性。所以它适合两种场景:一是状态标志位,二是安全发布不可变对象。而 synchronized 同时具备互斥和可见性,能处理复合操作,但开销更大。
实际中常见组合是 volatile 控制开关,synchronized 保护数据更新:
java复制private volatile boolean running = true;
private final Object lock = new Object();
public void shutdown() {
running = false; // 其他线程立刻能看到
}
public void process() {
synchronized (lock) {
while (running) {
// 业务处理
}
}
}
如果我把这里的 volatile 去掉,thread 里读 running 就可能长时间读到旧值,导致 shutdown 后线程还在跑。这里 volatile 用得非常优雅:它不承担复合操作,只承担可见性,所以不会牵扯到原子性问题。这也是我在项目里最常用的组合之一。
5. 线上最容易踩的 synchronized 坑:锁不生效与死锁排查
这章不讲原理,只讲我处理过的真实事故和排查思路。看再多文档不如踩一次坑,而我能做的,是让你连坑都别踩。
5.1 在多个实例上加锁,锁形同虚设
现象:生产环境每秒几千请求,偶发数据错误。代码结构大致是这样:
java复制@Service
public class OrderService {
public synchronized void createOrder(Order order) {
// 生成订单号并落库
}
}
照理说 Spring 管理的单例 Bean,多个线程调用的都是同一个 Service 实例,synchronized 锁 this 应该有效。但问题出在另一个调用入口直接 new OrderService() 去调方法,绕过了 Spring 容器。于是有的线程锁的是 Spring 里的单例对象,有的线程锁的是临时 new 出来的对象,两把锁互不干扰,订单号瞬间重复。
排查思路:先看错误数据是不是“同一时间段内多线程并发修改同一资源”,再用 jstack 抓线程栈看 BLOCKED 状态。如果明明有 synchronized 但没看到多少线程阻塞,那第一怀疑对象就是锁对象不一致。解决办法很简单:要么全部依赖 Spring 单例,要么把锁对象改成静态常量或 Class 对象,绝不能依赖每次创建的实例。
5.2 锁 String 和锁 Integer:你以为的独立锁其实是一把全局锁
java复制private static final String LOCK_KEY = "order_lock";
public void pay(String orderId) {
synchronized (LOCK_KEY) {
// 支付逻辑
}
}
只看代码,LOCK_KEY 是私有常量,似乎没问题。但 JVM 里的字符串常量有驻留机制,运行时凡是内容等于 "order_lock" 的字面量,都可能指向同一个 String 对象。如果项目里其他模块、其他类也用了同一个字符串当锁,你们的临界区会莫名其妙地互相阻塞。
更隐蔽的是锁 Integer。Java 对 -128 到 127 范围内的 Integer 做了缓存,valueOf(100) 返回的是同一个对象。如果拿一个值为 100 的 Integer 对象当锁,所有线程只要都落到 100 这个缓存值,就会抢同一把锁。这个锁范围会被放大到完全无关的业务模块,而且极难定位。
我的处理经验是:锁对象一定要用别人拿不到、无法复用的对象,private final Object lock = new Object() 是最安全的。千万不要图省事锁字符串或锁包装类型,哪怕它们看起来像常量。
5.3 锁顺序反转导致死锁:一次完整排查链路
死锁的本质就一句话:两个线程各自持有一把锁,同时又在等对方手里的锁。最经典的例子:
java复制public void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) {
// 转账
}
}
}
线程 A 执行 transfer(a, b),线程 B 执行 transfer(b, a)。A 持有 a 等 b,B 持有 b 等 a,双方都不撒手,程序卡死。
排查死锁时我最常用的是 jstack。过程如下:
- jps 找到 Java 进程 PID。
- jstack PID > thread_dump.txt。
- 在 dump 文件里搜索 "deadlock",会看到类似输出:
code复制Found one Java-level deadlock:
"pool-1-thread-1":
waiting to lock monitor 0x... (object 0x..., a com.example.Account),
which is held by "pool-1-thread-2"
jstack 甚至会把形成锁环的对象和线程都列出来。修复方式有两个方向:一是强制所有线程按同一个顺序加锁,比如先比较 Account 的 id,永远先锁 id 小的对象;二是使用 ReentrantLock 的 tryLock 尝试获取锁,拿不到就释放已有锁重试或回滚。我倾向第二种,因为在大系统里保证“所有代码路径都按统一顺序加锁”很难靠约定维持。
5.4 wait 和 sleep 混用导致线程“假死”
wait 和 notify 必须放在 synchronized 代码块里,而且调用 wait 的线程必须持有目标对象的 Monitor,否则会抛 IllegalMonitorStateException。这些是基本用法,但我看到的高频错误不是异常,而是把 wait 和 sleep 的语义搞混。
sleep 不会释放锁,wait 会释放锁。线程在 synchronized 块里调 sleep,锁还捏在手里,其他线程进不来,如果你预期“睡一会儿之后别人能操作”,实际就会变成假死。wait 则不同,它会让线程释放 Monitor,进入 _WaitSet,直到 notify 或 notifyAll 唤醒。
还有一个经验点:wait 要用在循环里,而不是 if 里。因为线程被唤醒之后,条件不一定已经满足,可能有其他线程抢先消耗了这个条件。正确模板:
java复制synchronized (lock) {
while (!condition) {
lock.wait();
}
// 条件满足,执行业务
}
这个“while 重新判断条件”是 Java 并发编程里最容易被忽略、却最容易出生产事故的细节。
6. synchronized 的性能优化与并发工具组合思路
到了这一章,你已经知道 synchronized 底层大致怎么回事,也见过各种坑。下面聊实际项目里怎么用它,什么时候该换 ReentrantLock,什么时候该拆分锁。
6.1 缩小临界区与 JIT 锁粗化:两种相反的优化怎么共存
业务代码层面我永远建议把锁范围控制在最小。比如只需要对一个临时变量做保护,就不要 synchronized 整个方法。锁范围越大,其他线程等待时间越长,吞吐量下降越明显。
但你可能也听过 JIT 的锁粗化优化:如果 JVM 发现同一个线程连续进入多个相邻的 synchronized 块,而且锁对象相同,它会把这几把锁合并成一个大锁块,减少加锁解锁开销。比如:
java复制synchronized (lock) {
a++;
}
synchronized (lock) {
b++;
}
JIT 很可能把两个块合并成一个同步块,因为加锁解锁本身有成本,频繁进出不划算。
锁粗化是编译器做的事,缩小临界区是程序员做的事,两者并不矛盾:程序员从业务语义上减少无意义的持锁范围,JIT 从指令执行层面消除反复进出锁的开销。你不必为了让 JIT 粗化而故意写大临界区,只要别把完全无关的操作硬塞进锁里即可。
6.2 锁粒度拆分:当一把大锁扛不住的时候
如果经过压测,一个 synchronized 保护的关键路径已经成了瓶颈,先不要急着换 ReentrantLock,先检查是不是锁粒度太大。
分段锁思想来源于 ConcurrentHashMap 早期版本:一个 Map 不可能给整张表加一把锁,而是把数据分成多个段,每个段一把独立锁,写操作只锁对应段,读操作通过 volatile 读取几乎无锁。这个思路可以直接借鉴到普通业务里,比如用户维度数据,你可以按 userId 哈希分桶,每个桶维护一个独立的锁对象:
java复制private final Object[] locks = new Object[16];
private Object getLock(String key) {
int index = (key.hashCode() & 0x7fffffff) % locks.length;
return locks[index];
}
public void updateUserBalance(String userId, int amount) {
synchronized (getLock(userId)) {
// 更新用户余额
}
}
不同用户落在不同锁上,并发能力直接提升 16 倍,而代码改动量很小。这就是锁粒度拆分的工程价值。
6.3 synchronized 还是 ReentrantLock:实际选型判断
从 JDK 6 开始,synchronized 已经经过锁升级、自旋优化、锁消除等手段,性能上和 ReentrantLock 的差距在绝大多数业务场景下可以忽略不计。选型主要看功能需求,不是看性能谣言。
| 能力 | synchronized | ReentrantLock |
|---|---|---|
| 锁释放 | 自动,JVM 保证 | 必须手动 unlock,通常放 finally |
| 可重入 | 支持 | 支持 |
| 中断响应 | 不支持 | lockInterruptibly 支持 |
| 公平锁 | 非公平 | 可指定公平 |
| 超时抢锁 | 不支持 | tryLock(timeout) 支持 |
| 多个等待条件 | 一个 Monitor 只有一个 wait 队列 | 可 new 多个 Condition |
我的建议很直接:如果只是去保护一段共享数据的更新,用 synchronized 就够了。如果想实现“抢不到锁就放弃”“最多等 100 毫秒”“区分多种唤醒条件”这些高级语义,选 ReentrantLock。用 synchronized 却想实现超时获取锁,是做不到的;强行用一个线程打断另一个线程的阻塞,更是给自己挖坑。
6.4 一个实际可用的完整组合:双重检查锁 + volatile
双重检查锁(Double-Checked Locking)被提到很多次,本质原因是它把“减少锁竞争”和“保证可见性”结合得非常好:
java复制public class CacheManager {
private static volatile CacheManager instance;
private final Map<String, Object> cache = new HashMap<>();
public static CacheManager getInstance() {
if (instance == null) {
synchronized (CacheManager.class) {
if (instance == null) {
instance = new CacheManager();
}
}
}
return instance;
}
}
两个 null 判断分别解决什么问题?外层判断是为了绝大多数线程不进入同步块,直接返回实例;内层判断是为了防止两个线程同时通过外层判断,第一个创建完实例后第二个又创建一次。instance 字段用 volatile 是为了防止“对象已经赋值但还没完成构造”的重排序问题,避免让其他线程拿到一个半初始化的实例。
这个案例在工程上很通用:先读 volatile 状态做快速通道,只在状态需要更新时才走 synchronized,这种“volatile 读决定是否加锁”的思路可以用在很多懒加载、连接池初始化、配置刷新场景里。
synchronized 不解决所有并发问题,但它依然是 Java 并发里最稳妥的基础设施。我的真实感受是:并发代码最大的风险往往不是你选错了锁,而是你根本没想清楚这把锁到底锁住了谁、保护了哪段数据、范围是不是和其他锁产生了意外重叠。先把这三个问题答清楚,再讨论优化,顺序不要反。如果你手头正有一个加了 synchronized 却还在出问题的模块,不妨先停下翻代码,看看所有线程进入临界区时拿的到底是不是同一个对象的 Monitor——多数时候答案就藏在这句话里。
