多线程双剑客:synchronized 与 volatile 的相爱相杀
写在前面:多线程编程就像在厨房里同时做饭,每个人都觉得自己是最优秀的厨师,但如果不协调好,厨房里会一片混乱。今天我们要聊的就是厨房里的"门禁卡"(synchronized)和"公告栏"(volatile)。
一、synchronized:厨房的门禁卡系统
1.1 这把锁到底锁了什么?
很多人以为 synchronized 只是加个锁,但它的本质是对象监视器(Monitor) 。想象一下,每个 Java 对象都在脑子里有个门禁卡系统:
- 保证原子性:同一时间只有一个线程能进入(厨房门一次只能进一个人)
- 保证可见性:解锁时把修改同步到主内存(做好的菜必须放回冰箱)
- 保证有序性:防止指令重排(做饭步骤不能乱套)
java
synchronized(obj) {
// 只有拿到 obj 门禁卡的线程才能进来
// 其他人都在外面排队等待(BLOCKED 状态)
}
1.2 可重入性:你不会把自己锁在门外
这是 synchronized 最人性化的设计——可重入锁。
场景重现:
java
public synchronized void cookDinner() {
prepareIngredients(); // 自己调用自己的另一个同步方法
}
public synchronized void prepareIngredients() {
// 切菜、调料...
}
神奇之处:同一个线程拿到锁后,可以重复进入同一把锁保护的方法,不会把自己锁死在外面。
原理揭秘:锁内部有个计数器
- 第一次拿锁:计数器 = 1
- 重复进入:计数器 +1
- 退出方法:计数器 -1
- 计数器归零:真正释放锁
1.3 方法锁 vs 代码块锁:粒度决定性能
同步方法(简单粗暴版)
java
public synchronized void method() {
// 整个方法都被锁住,像把整个厨房锁起来
}
- 实例方法:锁 this(当前对象)
- 静态方法:锁 类对象(如 Kitchen.class)
- 缺点:粒度太大,效率低
同步代码块(精准手术版)
java
synchronized(具体锁对象) {
// 只锁关键代码,像只锁住灶台
}
- 锁对象自由指定:this、任意对象、类对象都行
- 锁粒度更小:只保护需要同步的代码
- 性能更好:其他线程可以在非同步区域并发执行
一句话总结:方法锁是"大锅饭",代码块锁是"精准打击"。
二、volatile:餐厅的公告栏
2.1 volatile 的两个超能力
超能力一:保证可见性
java
volatile boolean isOpen = false;
// 厨师改了这个值,服务员立刻就能看到
- 写操作:直接刷回主内存(写在公告栏上)
- 读操作:直接从主内存读取(看公告栏,不看记事本)
超能力二:禁止指令重排
编译器和 CPU 为了优化,会打乱执行顺序。volatile 会加内存屏障,确保:
- 写之前的操作必须先完成
- 读之后的操作必须后执行
经典应用:单例模式的双重校验锁(DCL)
java
private volatile static Singleton instance;
// 没有 volatile,可能出现对象引用不为空但对象未初始化的尴尬
2.2 致命陷阱:volatile 不是万能锁!
这是最容易踩坑的地方。volatile 只能保护单次读/写,无法保护复合操作。
血泪案例:
java
volatile int count = 0;
// 线程1和线程2同时执行 count++
public void increment() {
count++; // 看似一行,实际是三步:
// 1. 读 count
// 2. count + 1
// 3. 写回 count
}
问题所在:volatile 只保证每一步的可见性,但无法保证这三步不被打断。结果就是:count 会少算,线程安全问题依然存在。
解决方案:
- 单次读写:volatile 足够
- 复合操作(i++、判断后修改):必须用 synchronized 或 Lock
三、面试必杀技:三道题搞定 synchronized
面试题 1:synchronized 底层是怎么实现的?
标准答案:synchronized 基于对象监视器(Monitor) 实现,每个 Java 对象都有一个 Monitor,包含:
- 拥有锁的线程
- 等待队列(WaitSet)
- 阻塞队列(EntryList)
- 计数器(实现可重入)
执行流程:
- 线程尝试获取 Monitor
- 成功则计数器 +1,执行代码
- 同一线程再次进入,计数器继续 +1
- 执行完毕,计数器 -1,为 0 时释放锁
- 未获取到锁的线程进入 BLOCKED 状态
面试题 2:什么是可重入性?为什么不会死锁?
核心:同一个线程可以重复获取同一把锁,不会死锁。
原理:锁内部有一个计数器,每次进入 +1,每次退出 -1,归零才真正释放锁。所以同一线程反复进入不会阻塞自己。
面试题 3:同步方法和同步代码块有什么区别?
| 维度 | 同步方法 | 同步代码块 |
|---|---|---|
| 锁对象 | 实例方法锁 this,静态方法锁 class 对象 | 可以自定义任意对象 |
| 锁粒度 | 锁住整个方法,粒度大 | 只锁关键代码,粒度小 |
| 性能 | 相对较差 | 更优 |
| 灵活性 | 简单但不灵活 | 灵活,可精确控制同步范围 |
四、实战建议:什么时候用什么?
使用 synchronized 的场景:
- 需要保证原子性的复合操作(count++、转账等)
- 需要精确控制同步范围
- 需要可重入特性
使用 volatile 的场景:
- 单次读/写操作(状态标记、开关标志)
- 需要保证可见性但不需要原子性
- 单例模式的双重校验锁
禁止使用 volatile 的场景:
- 复合操作(i++、判断后修改)
- 需要保证原子性的场景
五、总结:一图胜千言
plaintext
synchronized = 门禁卡系统
├── 原子性:一次只进一人
├── 可见性:改完同步给所有人
├── 有序性:步骤不能乱
└── 可重入:自己可以重复进出
volatile = 公告栏
├── 可见性:写完立刻广播
├── 禁止重排:操作顺序固定
└── 原子性:❌ 不保证!
写在最后
多线程编程没有标准答案,只有最适合场景的选择。synchronized 和 volatile 各有千秋,关键是你是否真正理解了它们背后的原理。
掌握这两个基础后,我们就可以进一步探索锁升级、CAS、AQS 等进阶话题。路漫漫其修远兮,与君共勉!