深入理解 Java 并发中的 synchronized 与 volatile
深入理解 Java 并发中的 synchronized 与 volatile
YYT深入理解 Java 并发中的 synchronized 与 volatile
在 Java 并发编程中,synchronized 和 volatile 是两个非常基础但又非常容易混淆的关键字。很多人在刚开始学习并发时,会简单地认为它们都是“保证线程安全”的工具,但实际上二者解决的问题并不完全一样。
准确地说,volatile 主要解决的是可见性和有序性问题,而 synchronized 解决的是更完整的线程安全问题,包括原子性、可见性和有序性。理解这两个关键字的核心,不是死记硬背它们的语法,而是要先理解 Java 并发中到底会出现什么问题。
并发问题的本质
在单线程程序中,代码通常按照我们写下的顺序执行,变量被修改之后,后面的代码马上就能看到这个修改。但在多线程环境下,情况就复杂得多。
现代计算机为了提升性能,并不是每次读写变量都直接访问主内存。CPU 有自己的缓存,线程执行时也可能把变量值保存到寄存器或工作内存中。这样带来的问题是:一个线程修改了共享变量,另一个线程不一定能马上看到。
例如下面这段代码:
1 | class Demo { |
按照直觉,主线程一秒后把 running 改成 false,子线程应该退出循环。但实际运行中,子线程有可能一直不退出。原因是子线程可能一直读取自己缓存中的 running = true,而没有感知到主线程已经把它改成了 false。
这就是所谓的可见性问题。
除了可见性,并发中还有一个非常重要的问题叫原子性。例如:
1 | count++; |
这行代码看起来只有一步,但底层其实可以拆成三步:
1 | 1. 读取 count 的值 |
如果多个线程同时执行这三步,就可能发生数据覆盖。比如两个线程都读到了 count = 0,然后都计算出 1,最后都写回 1。虽然执行了两次加一,但结果却是 1,而不是 2。
还有一个问题叫有序性。编译器、JVM 和 CPU 为了优化性能,可能会对指令进行重排序。只要不影响单线程下的执行结果,这种重排序就是允许的。但在多线程环境下,重排序可能会导致其他线程观察到不符合预期的状态。
所以 Java 并发安全通常围绕三个问题展开:可见性、原子性、有序性。理解了这三点,再看 volatile 和 synchronized 就清楚很多了。
volatile:解决“别人能不能看见”的问题
volatile 是 Java 提供的一种轻量级同步机制,它只能修饰变量,不能修饰方法或代码块。
1 | private volatile boolean running = true; |
当一个变量被 volatile 修饰后,它主要具备两个能力:第一,保证这个变量的修改对其他线程可见;第二,禁止与这个变量相关的指令重排序。
继续看前面的例子,如果我们给 running 加上 volatile:
1 | class Demo { |
这时主线程把 running 改成 false 后,子线程再次读取 running 时,就可以看到最新值,于是循环结束。
可以先用一个简单的模型来理解 volatile:
1 | volatile 写:把修改后的值刷新出去 |
更准确地说,volatile 的底层依赖的是内存屏障。内存屏障可以限制编译器和 CPU 对指令的重排序,同时保证某些写操作对其他线程可见。
不过要注意,volatile 的可见性不只是作用于它自己。Java 内存模型中有一条重要规则:
对一个 volatile 变量的写操作,happens-before 后续对这个 volatile 变量的读操作。
例如:
1 | class Demo { |
线程 A 执行:
1 | num = 100; |
线程 B 执行:
1 | if (ready) { |
如果线程 B 读到了 ready == true,那么它也能看到线程 A 在写 ready = true 之前对普通变量 num 的修改。因此这里打印出的 num 应该是 100。
这就是 volatile 很重要的一个特性:它不仅保证 volatile 变量本身可见,还能保证写 volatile 变量之前的普通写操作,对后续读到该 volatile 变量的线程可见。
volatile 为什么不能保证 count++ 安全
虽然 volatile 很有用,但它不是万能的。它不能保证复合操作的原子性。
下面这段代码就是典型错误:
1 | class Counter { |
很多人以为 count 加了 volatile,count++ 就线程安全了。但实际上不是。
volatile 能保证每次读取 count 时尽量读到最新值,也能保证写回后的值对其他线程可见。但是 count++ 不是单次读写,而是“读、改、写”三个步骤。多个线程可能同时读到相同的旧值,然后各自计算,再覆盖写回。
假设 count = 0,两个线程同时执行 count++:
1 | 线程 A 读取 count = 0 |
最终结果是 1,而不是 2。
所以 volatile 解决的是“能不能看见最新值”的问题,不解决“多个线程同时修改会不会冲突”的问题。
如果要做线程安全计数,应该使用 AtomicInteger:
1 | import java.util.concurrent.atomic.AtomicInteger; |
或者使用 synchronized 来保护整个复合操作。
volatile 的典型使用场景
volatile 最适合用于状态标记类场景。比如一个线程负责执行任务,另一个线程负责通知它停止:
1 | class Worker implements Runnable { |
这里 stopped 的操作非常简单:一个线程写,另一个线程读。它不涉及 stopped++ 这种复合操作,也不涉及多个变量之间的一致性,所以用 volatile 很合适。
volatile 也经常用于配置刷新、开关控制、状态发布等场景。例如:
1 | class Config { |
只要业务逻辑是简单读写某个变量,而不是基于它当前的值进行复杂修改,就可以考虑 volatile。
volatile 与双重检查锁单例
volatile 还有一个非常经典的使用场景,就是双重检查锁单例。
错误写法如下:
1 | class Singleton { |
这段代码看起来已经使用了 synchronized,而且还做了两次 instance == null 判断,似乎没有问题。但实际上,它仍然可能存在隐患。
问题出在这一行:
1 | instance = new Singleton(); |
这行代码看起来是一步,底层大致可以拆成三步:
1 | 1. 分配对象内存 |
由于指令重排序,第二步和第三步可能被调整顺序:
1 | 1. 分配对象内存 |
如果线程 A 执行到第二步时,instance 已经不是 null,但对象还没有初始化完成。此时线程 B 调用 getInstance(),发现 instance != null,就直接返回了这个还没初始化完成的对象。
正确写法是给 instance 加上 volatile:
1 | class Singleton { |
这里 volatile 的作用不是保证 synchronized 内部的互斥,而是防止 instance = new Singleton() 发生危险的指令重排序,同时保证其他线程能看到初始化完成后的对象。
synchronized:解决“同时修改会不会乱”的问题
相比 volatile,synchronized 的能力更完整。它是 Java 的内置锁,也叫监视器锁。它既可以修饰方法,也可以修饰代码块。
synchronized 的核心思想是:
同一时刻,只允许一个线程进入被同一把锁保护的代码区域。
例如:
1 | class Counter { |
这里 add() 和 get() 都是同步方法。对于实例方法来说,synchronized 锁住的是当前对象,也就是 this。
上面的代码等价于:
1 | class Counter { |
由于同一时刻只有一个线程能拿到 this 这把锁,所以 count++ 虽然本身不是原子操作,但被放进 synchronized 代码块后,就变成了受保护的临界区。其他线程必须等当前线程执行完并释放锁,才能继续进入。
这就是 synchronized 保证原子性的方式:它不是让 count++ 这条指令本身变成原子指令,而是让这段代码在同一时间只被一个线程执行。
synchronized 保证可见性
synchronized 不仅能保证原子性,也能保证可见性。
Java 内存模型规定:
对一个锁的解锁,happens-before 后续对同一个锁的加锁。
这句话可以这样理解:
1 | 线程释放锁时,会把在同步代码块中的修改刷新出去; |
例如:
1 | class Demo { |
线程 A 执行 writer(),在 synchronized (lock) 中把 flag 改为 true。当线程 A 释放 lock 后,线程 B 再进入 reader() 并获取同一把 lock,就能看到线程 A 对 flag 的修改。
这里有一个非常关键的前提:必须是同一把锁。
如果写操作使用的是 lockA,读操作使用的是 lockB,那么它们之间没有 happens-before 关系,也就不能保证可见性。
synchronized 锁的到底是什么
很多人说“这个方法加锁了”“这段代码加锁了”,但更准确地说,synchronized 锁住的不是方法,也不是代码,而是对象。
比如:
1 | private final Object lock = new Object(); |
这里锁住的是 lock 这个对象。
如果写成这样:
1 | public void add() { |
这就是错误写法。因为每次调用 add() 都会创建一个新的锁对象,不同线程拿到的根本不是同一把锁,自然无法互斥。
正确写法应该是让多个线程竞争同一个锁对象:
1 | class Counter { |
实例同步方法锁的是 this:
1 | public synchronized void add() { |
等价于:
1 | public void add() { |
静态同步方法锁的是当前类的 Class 对象:
1 | public static synchronized void add() { |
等价于:
1 | public static void add() { |
所以实例同步方法和静态同步方法锁的不是同一个对象。一个锁的是 this,一个锁的是 Counter.class,它们之间不会互斥。
synchronized 的可重入性
synchronized 是可重入锁。所谓可重入,就是同一个线程已经拿到某把锁后,可以再次进入这把锁保护的代码。
例如:
1 | class Demo { |
线程进入 methodA() 时,已经拿到了当前对象的 this 锁。随后 methodA() 内部调用 methodB(),而 methodB() 也是同步方法,也需要获取同一个 this 锁。
如果 synchronized 不支持可重入,这里就会发生死锁:线程自己等自己释放锁。但 Java 的 synchronized 是可重入的,所以同一个线程可以重复获得同一把锁,只是锁的重入次数会增加。当方法一层层返回时,锁的计数逐步减少,直到完全释放。
synchronized 的底层简单理解
从字节码层面看,synchronized 代码块通常对应两个核心指令:
1 | monitorenter |
例如:
1 | synchronized (lock) { |
大致可以理解为:
1 | monitorenter:尝试获取 lock 对象关联的 Monitor |
每个 Java 对象都可以作为锁对象。对象头中有一部分叫 Mark Word,其中会存储与锁相关的信息。线程进入 synchronized 代码块时,会尝试获取该对象关联的 Monitor。如果获取成功,就进入临界区;如果获取失败,就进入阻塞或等待状态。
早期很多人认为 synchronized 很重,不推荐使用。但从 JDK 1.6 之后,JVM 对 synchronized 做了大量优化,例如偏向锁、轻量级锁、锁膨胀、锁消除、锁粗化等。虽然现在很多场景也会使用 ReentrantLock、AtomicInteger 等工具,但 synchronized 依然是 Java 并发中非常重要、非常常用的基础机制。
synchronized 和 volatile 的核心区别
volatile 和 synchronized 都能保证可见性,也都能在一定程度上保证有序性,但它们最大的区别在于:volatile 不保证复合操作的原子性,而 synchronized 可以保证临界区内代码的原子性。
volatile 更像是一种通知机制。一个线程修改了变量,其他线程能及时看到。它适合用在状态标记、开关控制、配置刷新这类简单场景。
synchronized 更像是一种保护机制。它把一段代码保护起来,保证同一时刻只有一个线程能执行。它适合用在共享资源修改、复合操作、多个变量一致性维护等场景。
比如下面这个场景,用 volatile 就很合适:
1 | private volatile boolean stopped = false; |
因为它只是一个停止标记,一个线程写,另一个线程读。
但是下面这个场景,就不应该只用 volatile:
1 | private volatile int count = 0; |
因为 count++ 是复合操作,会有并发覆盖问题。这里应该使用 synchronized:
1 | class Counter { |
或者使用原子类:
1 | import java.util.concurrent.atomic.AtomicInteger; |
用 synchronized 之后还需要 volatile 吗
大多数情况下,如果一个变量的所有读写都已经被同一把 synchronized 锁保护,那么就不需要再加 volatile。
例如:
1 | class Demo { |
这里 flag 不需要加 volatile,因为读和写都在同一把锁 lock 的保护下,synchronized 已经保证了可见性。
但如果写操作加锁,读操作不加锁,就可能有问题:
1 | class Demo { |
这里 getFlag() 没有加锁,所以不能保证它一定看到 setFlag() 中的修改。
解决方式有两种。第一种是读操作也加同一把锁:
1 | public boolean getFlag() { |
第二种是把 flag 声明为 volatile:
1 | private volatile boolean flag = false; |
不过在实际开发中,更推荐保持同步策略的一致性:要么读写都加锁,要么明确使用 volatile 做状态变量,不要混乱使用。
实际开发中如何选择
如果只是简单的状态标记、开关变量、配置引用,并且不涉及复合修改,可以使用 volatile。它语义简单,性能开销也较小。
例如:
1 | private volatile boolean running = true; |
如果涉及共享数据修改、count++、集合操作、先判断再修改、多个变量需要一起保持一致性,就应该使用 synchronized 或其他锁机制。
例如:
1 | public synchronized void transfer(Account target, int money) { |
这种银行转账逻辑就不能只靠 volatile。因为这里涉及多个操作:判断余额、扣减余额、增加目标账户余额。这些操作必须作为一个整体执行,否则就可能出现数据不一致。
如果只是高并发计数,可以优先考虑 AtomicInteger、LongAdder 等并发工具类。如果需要更灵活的锁能力,比如可中断、尝试获取锁、公平锁,则可以考虑 ReentrantLock。
总结
volatile 和 synchronized 都是 Java 并发编程中非常重要的关键字,但它们的定位不同。
volatile 是轻量级同步机制,主要解决共享变量的可见性问题,并通过内存屏障禁止相关的指令重排序。它适合用于状态标记、配置刷新、开关控制等简单场景。但它不能保证复合操作的原子性,所以 volatile int count 配合 count++ 并不是线程安全的。
synchronized 是 Java 的内置锁机制,它可以保证同一时刻只有一个线程进入同步代码块,因此可以保证临界区操作的原子性。同时,线程释放锁和获取锁之间存在 happens-before 关系,所以它也能保证可见性和有序性。它适合用于共享资源修改、复合操作、多个变量一致性维护等场景。
可以用一句话来区分:
1 | volatile 解决的是“能不能看见”的问题; |
如果只是通知状态变化,用 volatile。如果要保护一段修改共享数据的逻辑,用 synchronized。
真正理解这两个关键字,并不是记住它们的定义,而是理解它们分别解决了并发中的哪类问题。只有明确了问题是可见性、原子性,还是有序性,才能在实际开发中选择正确的并发工具。




