深入理解 Java 并发中的 synchronized 与 volatile

深入理解 Java 并发中的 synchronized 与 volatile

在 Java 并发编程中,synchronizedvolatile 是两个非常基础但又非常容易混淆的关键字。很多人在刚开始学习并发时,会简单地认为它们都是“保证线程安全”的工具,但实际上二者解决的问题并不完全一样。

准确地说,volatile 主要解决的是可见性有序性问题,而 synchronized 解决的是更完整的线程安全问题,包括原子性、可见性和有序性。理解这两个关键字的核心,不是死记硬背它们的语法,而是要先理解 Java 并发中到底会出现什么问题。


并发问题的本质

在单线程程序中,代码通常按照我们写下的顺序执行,变量被修改之后,后面的代码马上就能看到这个修改。但在多线程环境下,情况就复杂得多。

现代计算机为了提升性能,并不是每次读写变量都直接访问主内存。CPU 有自己的缓存,线程执行时也可能把变量值保存到寄存器或工作内存中。这样带来的问题是:一个线程修改了共享变量,另一个线程不一定能马上看到。

例如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Demo {
private static boolean running = true;

public static void main(String[] args) throws Exception {
new Thread(() -> {
while (running) {
}
System.out.println("线程结束");
}).start();

Thread.sleep(1000);
running = false;
}
}

按照直觉,主线程一秒后把 running 改成 false,子线程应该退出循环。但实际运行中,子线程有可能一直不退出。原因是子线程可能一直读取自己缓存中的 running = true,而没有感知到主线程已经把它改成了 false

这就是所谓的可见性问题

除了可见性,并发中还有一个非常重要的问题叫原子性。例如:

1
count++;

这行代码看起来只有一步,但底层其实可以拆成三步:

1
2
3
1. 读取 count 的值
2. 对 count 加 1
3. 把结果写回 count

如果多个线程同时执行这三步,就可能发生数据覆盖。比如两个线程都读到了 count = 0,然后都计算出 1,最后都写回 1。虽然执行了两次加一,但结果却是 1,而不是 2

还有一个问题叫有序性。编译器、JVM 和 CPU 为了优化性能,可能会对指令进行重排序。只要不影响单线程下的执行结果,这种重排序就是允许的。但在多线程环境下,重排序可能会导致其他线程观察到不符合预期的状态。

所以 Java 并发安全通常围绕三个问题展开:可见性、原子性、有序性。理解了这三点,再看 volatilesynchronized 就清楚很多了。


volatile:解决“别人能不能看见”的问题

volatile 是 Java 提供的一种轻量级同步机制,它只能修饰变量,不能修饰方法或代码块。

1
private volatile boolean running = true;

当一个变量被 volatile 修饰后,它主要具备两个能力:第一,保证这个变量的修改对其他线程可见;第二,禁止与这个变量相关的指令重排序。

继续看前面的例子,如果我们给 running 加上 volatile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Demo {
private static volatile boolean running = true;

public static void main(String[] args) throws Exception {
new Thread(() -> {
while (running) {
}
System.out.println("线程结束");
}).start();

Thread.sleep(1000);
running = false;
}
}

这时主线程把 running 改成 false 后,子线程再次读取 running 时,就可以看到最新值,于是循环结束。

可以先用一个简单的模型来理解 volatile

1
2
volatile 写:把修改后的值刷新出去
volatile 读:读取最新的值

更准确地说,volatile 的底层依赖的是内存屏障。内存屏障可以限制编译器和 CPU 对指令的重排序,同时保证某些写操作对其他线程可见。

不过要注意,volatile 的可见性不只是作用于它自己。Java 内存模型中有一条重要规则:

对一个 volatile 变量的写操作,happens-before 后续对这个 volatile 变量的读操作。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Demo {
private int num = 0;
private volatile boolean ready = false;

public void writer() {
num = 100;
ready = true;
}

public void reader() {
if (ready) {
System.out.println(num);
}
}
}

线程 A 执行:

1
2
num = 100;
ready = true;

线程 B 执行:

1
2
3
if (ready) {
System.out.println(num);
}

如果线程 B 读到了 ready == true,那么它也能看到线程 A 在写 ready = true 之前对普通变量 num 的修改。因此这里打印出的 num 应该是 100

这就是 volatile 很重要的一个特性:它不仅保证 volatile 变量本身可见,还能保证写 volatile 变量之前的普通写操作,对后续读到该 volatile 变量的线程可见。


volatile 为什么不能保证 count++ 安全

虽然 volatile 很有用,但它不是万能的。它不能保证复合操作的原子性。

下面这段代码就是典型错误:

1
2
3
4
5
6
7
8
9
10
11
class Counter {
private volatile int count = 0;

public void add() {
count++;
}

public int get() {
return count;
}
}

很多人以为 count 加了 volatilecount++ 就线程安全了。但实际上不是。

volatile 能保证每次读取 count 时尽量读到最新值,也能保证写回后的值对其他线程可见。但是 count++ 不是单次读写,而是“读、改、写”三个步骤。多个线程可能同时读到相同的旧值,然后各自计算,再覆盖写回。

假设 count = 0,两个线程同时执行 count++

1
2
3
4
5
6
7
8
线程 A 读取 count = 0
线程 B 读取 count = 0

线程 A 计算 0 + 1 = 1
线程 B 计算 0 + 1 = 1

线程 A 写回 count = 1
线程 B 写回 count = 1

最终结果是 1,而不是 2

所以 volatile 解决的是“能不能看见最新值”的问题,不解决“多个线程同时修改会不会冲突”的问题。

如果要做线程安全计数,应该使用 AtomicInteger

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.concurrent.atomic.AtomicInteger;

class Counter {
private final AtomicInteger count = new AtomicInteger(0);

public void add() {
count.incrementAndGet();
}

public int get() {
return count.get();
}
}

或者使用 synchronized 来保护整个复合操作。


volatile 的典型使用场景

volatile 最适合用于状态标记类场景。比如一个线程负责执行任务,另一个线程负责通知它停止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Worker implements Runnable {
private volatile boolean stopped = false;

public void stop() {
stopped = true;
}

@Override
public void run() {
while (!stopped) {
// 执行任务
}
System.out.println("任务停止");
}
}

这里 stopped 的操作非常简单:一个线程写,另一个线程读。它不涉及 stopped++ 这种复合操作,也不涉及多个变量之间的一致性,所以用 volatile 很合适。

volatile 也经常用于配置刷新、开关控制、状态发布等场景。例如:

1
2
3
4
5
6
7
8
9
10
11
class Config {
private volatile String mode = "default";

public void updateMode(String newMode) {
mode = newMode;
}

public String getMode() {
return mode;
}
}

只要业务逻辑是简单读写某个变量,而不是基于它当前的值进行复杂修改,就可以考虑 volatile


volatile 与双重检查锁单例

volatile 还有一个非常经典的使用场景,就是双重检查锁单例。

错误写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton {
private static Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

这段代码看起来已经使用了 synchronized,而且还做了两次 instance == null 判断,似乎没有问题。但实际上,它仍然可能存在隐患。

问题出在这一行:

1
instance = new Singleton();

这行代码看起来是一步,底层大致可以拆成三步:

1
2
3
1. 分配对象内存
2. 调用构造方法初始化对象
3. 把对象地址赋值给 instance

由于指令重排序,第二步和第三步可能被调整顺序:

1
2
3
1. 分配对象内存
2. 把对象地址赋值给 instance
3. 调用构造方法初始化对象

如果线程 A 执行到第二步时,instance 已经不是 null,但对象还没有初始化完成。此时线程 B 调用 getInstance(),发现 instance != null,就直接返回了这个还没初始化完成的对象。

正确写法是给 instance 加上 volatile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton {
private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

这里 volatile 的作用不是保证 synchronized 内部的互斥,而是防止 instance = new Singleton() 发生危险的指令重排序,同时保证其他线程能看到初始化完成后的对象。


synchronized:解决“同时修改会不会乱”的问题

相比 volatilesynchronized 的能力更完整。它是 Java 的内置锁,也叫监视器锁。它既可以修饰方法,也可以修饰代码块。

synchronized 的核心思想是:

同一时刻,只允许一个线程进入被同一把锁保护的代码区域。

例如:

1
2
3
4
5
6
7
8
9
10
11
class Counter {
private int count = 0;

public synchronized void add() {
count++;
}

public synchronized int get() {
return count;
}
}

这里 add()get() 都是同步方法。对于实例方法来说,synchronized 锁住的是当前对象,也就是 this

上面的代码等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Counter {
private int count = 0;

public void add() {
synchronized (this) {
count++;
}
}

public int get() {
synchronized (this) {
return count;
}
}
}

由于同一时刻只有一个线程能拿到 this 这把锁,所以 count++ 虽然本身不是原子操作,但被放进 synchronized 代码块后,就变成了受保护的临界区。其他线程必须等当前线程执行完并释放锁,才能继续进入。

这就是 synchronized 保证原子性的方式:它不是让 count++ 这条指令本身变成原子指令,而是让这段代码在同一时间只被一个线程执行。


synchronized 保证可见性

synchronized 不仅能保证原子性,也能保证可见性。

Java 内存模型规定:

对一个锁的解锁,happens-before 后续对同一个锁的加锁。

这句话可以这样理解:

1
2
线程释放锁时,会把在同步代码块中的修改刷新出去;
线程获取锁时,会读取其他线程释放锁之前的最新修改。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Demo {
private boolean flag = false;
private final Object lock = new Object();

public void writer() {
synchronized (lock) {
flag = true;
}
}

public void reader() {
synchronized (lock) {
if (flag) {
System.out.println("看到 flag = true");
}
}
}
}

线程 A 执行 writer(),在 synchronized (lock) 中把 flag 改为 true。当线程 A 释放 lock 后,线程 B 再进入 reader() 并获取同一把 lock,就能看到线程 A 对 flag 的修改。

这里有一个非常关键的前提:必须是同一把锁

如果写操作使用的是 lockA,读操作使用的是 lockB,那么它们之间没有 happens-before 关系,也就不能保证可见性。


synchronized 锁的到底是什么

很多人说“这个方法加锁了”“这段代码加锁了”,但更准确地说,synchronized 锁住的不是方法,也不是代码,而是对象

比如:

1
2
3
4
5
6
7
private final Object lock = new Object();

public void add() {
synchronized (lock) {
count++;
}
}

这里锁住的是 lock 这个对象。

如果写成这样:

1
2
3
4
5
public void add() {
synchronized (new Object()) {
count++;
}
}

这就是错误写法。因为每次调用 add() 都会创建一个新的锁对象,不同线程拿到的根本不是同一把锁,自然无法互斥。

正确写法应该是让多个线程竞争同一个锁对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Counter {
private int count = 0;
private final Object lock = new Object();

public void add() {
synchronized (lock) {
count++;
}
}

public int get() {
synchronized (lock) {
return count;
}
}
}

实例同步方法锁的是 this

1
2
3
public synchronized void add() {
count++;
}

等价于:

1
2
3
4
5
public void add() {
synchronized (this) {
count++;
}
}

静态同步方法锁的是当前类的 Class 对象:

1
2
3
public static synchronized void add() {
count++;
}

等价于:

1
2
3
4
5
public static void add() {
synchronized (Counter.class) {
count++;
}
}

所以实例同步方法和静态同步方法锁的不是同一个对象。一个锁的是 this,一个锁的是 Counter.class,它们之间不会互斥。


synchronized 的可重入性

synchronized 是可重入锁。所谓可重入,就是同一个线程已经拿到某把锁后,可以再次进入这把锁保护的代码。

例如:

1
2
3
4
5
6
7
8
9
class Demo {
public synchronized void methodA() {
methodB();
}

public synchronized void methodB() {
System.out.println("methodB");
}
}

线程进入 methodA() 时,已经拿到了当前对象的 this 锁。随后 methodA() 内部调用 methodB(),而 methodB() 也是同步方法,也需要获取同一个 this 锁。

如果 synchronized 不支持可重入,这里就会发生死锁:线程自己等自己释放锁。但 Java 的 synchronized 是可重入的,所以同一个线程可以重复获得同一把锁,只是锁的重入次数会增加。当方法一层层返回时,锁的计数逐步减少,直到完全释放。


synchronized 的底层简单理解

从字节码层面看,synchronized 代码块通常对应两个核心指令:

1
2
monitorenter
monitorexit

例如:

1
2
3
synchronized (lock) {
count++;
}

大致可以理解为:

1
2
3
monitorenter:尝试获取 lock 对象关联的 Monitor
执行同步代码块
monitorexit:释放 Monitor

每个 Java 对象都可以作为锁对象。对象头中有一部分叫 Mark Word,其中会存储与锁相关的信息。线程进入 synchronized 代码块时,会尝试获取该对象关联的 Monitor。如果获取成功,就进入临界区;如果获取失败,就进入阻塞或等待状态。

早期很多人认为 synchronized 很重,不推荐使用。但从 JDK 1.6 之后,JVM 对 synchronized 做了大量优化,例如偏向锁、轻量级锁、锁膨胀、锁消除、锁粗化等。虽然现在很多场景也会使用 ReentrantLockAtomicInteger 等工具,但 synchronized 依然是 Java 并发中非常重要、非常常用的基础机制。


synchronized 和 volatile 的核心区别

volatilesynchronized 都能保证可见性,也都能在一定程度上保证有序性,但它们最大的区别在于:volatile 不保证复合操作的原子性,而 synchronized 可以保证临界区内代码的原子性。

volatile 更像是一种通知机制。一个线程修改了变量,其他线程能及时看到。它适合用在状态标记、开关控制、配置刷新这类简单场景。

synchronized 更像是一种保护机制。它把一段代码保护起来,保证同一时刻只有一个线程能执行。它适合用在共享资源修改、复合操作、多个变量一致性维护等场景。

比如下面这个场景,用 volatile 就很合适:

1
private volatile boolean stopped = false;

因为它只是一个停止标记,一个线程写,另一个线程读。

但是下面这个场景,就不应该只用 volatile

1
2
3
4
5
private volatile int count = 0;

public void add() {
count++;
}

因为 count++ 是复合操作,会有并发覆盖问题。这里应该使用 synchronized

1
2
3
4
5
6
7
8
9
10
11
class Counter {
private int count = 0;

public synchronized void add() {
count++;
}

public synchronized int get() {
return count;
}
}

或者使用原子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.concurrent.atomic.AtomicInteger;

class Counter {
private final AtomicInteger count = new AtomicInteger(0);

public void add() {
count.incrementAndGet();
}

public int get() {
return count.get();
}
}

用 synchronized 之后还需要 volatile 吗

大多数情况下,如果一个变量的所有读写都已经被同一把 synchronized 锁保护,那么就不需要再加 volatile

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Demo {
private boolean flag = false;
private final Object lock = new Object();

public void setFlag() {
synchronized (lock) {
flag = true;
}
}

public boolean getFlag() {
synchronized (lock) {
return flag;
}
}
}

这里 flag 不需要加 volatile,因为读和写都在同一把锁 lock 的保护下,synchronized 已经保证了可见性。

但如果写操作加锁,读操作不加锁,就可能有问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Demo {
private boolean flag = false;
private final Object lock = new Object();

public void setFlag() {
synchronized (lock) {
flag = true;
}
}

public boolean getFlag() {
return flag;
}
}

这里 getFlag() 没有加锁,所以不能保证它一定看到 setFlag() 中的修改。

解决方式有两种。第一种是读操作也加同一把锁:

1
2
3
4
5
public boolean getFlag() {
synchronized (lock) {
return flag;
}
}

第二种是把 flag 声明为 volatile

1
private volatile boolean flag = false;

不过在实际开发中,更推荐保持同步策略的一致性:要么读写都加锁,要么明确使用 volatile 做状态变量,不要混乱使用。


实际开发中如何选择

如果只是简单的状态标记、开关变量、配置引用,并且不涉及复合修改,可以使用 volatile。它语义简单,性能开销也较小。

例如:

1
private volatile boolean running = true;

如果涉及共享数据修改、count++、集合操作、先判断再修改、多个变量需要一起保持一致性,就应该使用 synchronized 或其他锁机制。

例如:

1
2
3
4
5
6
public synchronized void transfer(Account target, int money) {
if (this.balance >= money) {
this.balance -= money;
target.balance += money;
}
}

这种银行转账逻辑就不能只靠 volatile。因为这里涉及多个操作:判断余额、扣减余额、增加目标账户余额。这些操作必须作为一个整体执行,否则就可能出现数据不一致。

如果只是高并发计数,可以优先考虑 AtomicIntegerLongAdder 等并发工具类。如果需要更灵活的锁能力,比如可中断、尝试获取锁、公平锁,则可以考虑 ReentrantLock


总结

volatilesynchronized 都是 Java 并发编程中非常重要的关键字,但它们的定位不同。

volatile 是轻量级同步机制,主要解决共享变量的可见性问题,并通过内存屏障禁止相关的指令重排序。它适合用于状态标记、配置刷新、开关控制等简单场景。但它不能保证复合操作的原子性,所以 volatile int count 配合 count++ 并不是线程安全的。

synchronized 是 Java 的内置锁机制,它可以保证同一时刻只有一个线程进入同步代码块,因此可以保证临界区操作的原子性。同时,线程释放锁和获取锁之间存在 happens-before 关系,所以它也能保证可见性和有序性。它适合用于共享资源修改、复合操作、多个变量一致性维护等场景。

可以用一句话来区分:

1
2
volatile 解决的是“能不能看见”的问题;
synchronized 解决的是“能不能安全地一起改”的问题。

如果只是通知状态变化,用 volatile。如果要保护一段修改共享数据的逻辑,用 synchronized

真正理解这两个关键字,并不是记住它们的定义,而是理解它们分别解决了并发中的哪类问题。只有明确了问题是可见性、原子性,还是有序性,才能在实际开发中选择正确的并发工具。