多线程双剑客:synchronized 与 volatile 的相爱相杀

0 阅读5分钟

多线程双剑客: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)
  • 计数器(实现可重入)

执行流程

  1. 线程尝试获取 Monitor
  2. 成功则计数器 +1,执行代码
  3. 同一线程再次进入,计数器继续 +1
  4. 执行完毕,计数器 -1,为 0 时释放锁
  5. 未获取到锁的线程进入 BLOCKED 状态

面试题 2:什么是可重入性?为什么不会死锁?

核心:同一个线程可以重复获取同一把锁,不会死锁。

原理:锁内部有一个计数器,每次进入 +1,每次退出 -1,归零才真正释放锁。所以同一线程反复进入不会阻塞自己。

面试题 3:同步方法和同步代码块有什么区别?

维度同步方法同步代码块
锁对象实例方法锁 this,静态方法锁 class 对象可以自定义任意对象
锁粒度锁住整个方法,粒度大只锁关键代码,粒度小
性能相对较差更优
灵活性简单但不灵活灵活,可精确控制同步范围

四、实战建议:什么时候用什么?

使用 synchronized 的场景:

  • 需要保证原子性的复合操作(count++、转账等)
  • 需要精确控制同步范围
  • 需要可重入特性

使用 volatile 的场景:

  • 单次读/写操作(状态标记、开关标志)
  • 需要保证可见性但不需要原子性
  • 单例模式的双重校验锁

禁止使用 volatile 的场景:

  • 复合操作(i++、判断后修改)
  • 需要保证原子性的场景

五、总结:一图胜千言

plaintext

synchronized = 门禁卡系统
├── 原子性:一次只进一人
├── 可见性:改完同步给所有人
├── 有序性:步骤不能乱
└── 可重入:自己可以重复进出

volatile = 公告栏
├── 可见性:写完立刻广播
├── 禁止重排:操作顺序固定
└── 原子性:❌ 不保证!

写在最后

多线程编程没有标准答案,只有最适合场景的选择。synchronized 和 volatile 各有千秋,关键是你是否真正理解了它们背后的原理。

掌握这两个基础后,我们就可以进一步探索锁升级、CAS、AQS 等进阶话题。路漫漫其修远兮,与君共勉!