在 Java 多线程开发中,synchronizedvolatile 是解决并发问题的两大核心工具,但它们的能力边界和适用场景有本质区别。本文将结合可见性、有序性、原子性三大问题,详细拆解两者的底层原理、核心差异与最佳实践。


一、核心结论

  • synchronized:可同时解决原子性、可见性、有序性三大问题,通过“独占执行+内存同步+锁规则”实现并发安全,但存在锁竞争开销。
  • volatile:可解决可见性、有序性问题,但不保证原子性,是无锁的轻量级方案,仅适用于单变量读写场景。

二、synchronized 解决三大问题的底层原理

1. 解决原子性:临界区“独占执行”

问题根源

多线程下,非原子操作(如 i++)会被拆分为「读值→修改→写回」三步,若线程切换发生在步骤间,会导致最终结果不一致(如 10000 次自增后结果小于 10000)。

解决方式

synchronized 包裹的「临界区代码」同一时间仅允许一个线程执行,其他线程需等待锁释放后才能进入。这让临界区操作成为“不可拆分”的整体,天然具备原子性。

代码示例
// 共享变量
private int count = 0;
// 锁对象(推荐使用专用锁,避免this/类对象的隐式锁冲突)
private final Object lock = new Object();
/**
  自增方法:synchronized保证原子性
  不加synchronized:并发执行后count < 10000(原子性丢失)
  加synchronized:并发执行后count = 10000(原子性保证)
 */
public void increment() {
    synchronized (lock) {
        count++; // 临界区:独占执行,不可拆分
    }
}

2. 解决可见性:强制内存同步(工作内存 ↔ 主内存)

问题根源

线程会将共享变量缓存到「工作内存」,修改后若未及时刷新到「主内存」,其他线程读取的仍是旧值,导致“可见性丢失”(如线程1修改了变量,线程2无法感知)。

解决方式(JMM 内存语义)
  • 加锁时:清空当前线程工作内存,强制从主内存重新读取共享变量的最新值;
  • 解锁时:将当前线程工作内存中修改的共享变量,强制刷新到主内存。

这两步操作确保了“加锁线程的修改”对“后续加锁线程”实时可见。

代码示例
// 共享开关变量
private boolean run = true;
private final Object lock = new Object();
/**
  线程1执行:修改开关变量
  解锁时,run=false 会被强制刷入主内存
 */
public void stopLoop() {
    synchronized (lock) {
        run = false;
    }
}
/**
  线程2执行:循环读取开关变量
  加锁时,强制从主内存读取最新的run值
 */
public void loop() {
    while (true) {
        synchronized (lock) {
            if (!run) {
                break; // 能及时感知到run的修改,退出循环
            }
        }
    }
}

3. 解决有序性:锁规则限制重排序

问题根源

JVM 为优化性能会对指令重排序(如“对象赋值”与“对象初始化”顺序调换),单线程下重排序不影响结果,但多线程下会导致执行顺序混乱(如双重检查锁的空指针问题)。

解决方式(JMM 锁规则)

synchronized 依托“锁规则”保证有序性,核心逻辑:

  1. 持有相同锁的同步块,必须按代码书写顺序串行执行;
  2. 禁止「临界区内外」的指令重排序,但允许临界区内部指令重排序(单线程执行,不影响结果);
  3. 本质:所有持有相同锁的线程,看到的临界区执行顺序与代码逻辑完全一致。
代码示例(解决双重检查锁的有序性问题)
// 单例对象(需加volatile阻止初始化重排序,否则DCL仍有风险)
private static volatile Singleton instance;
/**
  双重检查锁(DCL):兼顾性能与并发安全
  synchronized 保证临界区内外指令不重排序
 */
public static Singleton getInstance() {
    // 第一次检查:无锁,提高性能(避免每次获取实例都加锁)
    if (instance == null) {
        // 加锁:限制串行执行,保证有序性
        synchronized (Singleton.class) {
            // 第二次检查:防止多线程并发创建多个实例
            if (instance == null) {
                instance = new Singleton(); // 临界区:禁止与外部代码重排序
            }
        }
    }
    return instance;
}

// 注:instance 必须加 volatile,阻止「对象赋值」与「初始化」的重排序,否则仍可能获取到未初始化的对象。具体原因见https://blog.csdn.net/weixin_68315058/article/details/158742384?sharetype=blogdetail&sharerId=158742384&sharerefer=PC&sharesource=weixin_68315058&spm=1011.2480.3001.8118


三、volatile 核心作用:可见性与有序性

1. 可见性:保证所有线程看到的变量值一致

问题根源

Java 内存模型(JMM)中,线程会将共享变量缓存到本地内存(工作内存),修改后若未及时刷新到主内存,其他线程读取的仍是旧值,导致“可见性丢失”。

volatile 可见性实现(JMM 内存语义)
  • 写操作:当线程写入 volatile 变量时,JMM 会强制将本地内存中修改的变量值刷新到主内存。
  • 读操作:当线程读取 volatile 变量时,JMM 会将本地内存中该变量的缓存置为无效,强制从主内存重新读取最新值。
代码示例:volatile 解决可见性问题
public class VolatileExample {
    // 未加 volatile:线程A可能永远看不到stop的变化,陷入死循环
    // private static boolean stop = false;
    // 加 volatile:保证可见性
    private static volatile boolean stop = false;
    public static void main(String[] args) {
        // 线程A:循环检查stop变量
        new Thread("Thread A") {
            @Override
            public void run() {
                while (!stop) {
                    // 空循环
                }
                System.out.println("3: " + Thread.currentThread().getName() + " 停止了");
            }
        }.start();
        // 主线程:1秒后修改stop变量
        try {
            TimeUnit.SECONDS.sleep(1);
            System.out.println("1: 主线程等待一秒...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("2: 将stop变量设置为true");
        stop = true;
    }
}

执行结果:未加 volatile 时,线程A会无限循环;加 volatile 后,线程A能立即感知到 stop 的变化并退出循环。


2. 有序性:通过 happens-before 规则禁止重排序

volatile 有序性实现

volatile 依托 JMM 的 happens-before 规则和内存屏障指令,严格限制指令重排序,保证执行顺序的可预测性:

  • happens-before 规则:对一个 volatile 变量的写操作,happens-before 于任意后续对该变量的读操作。
  • 内存屏障:JVM 会在 volatile 读写操作前后插入内存屏障,禁止特定类型的处理器重排序,核心规则如下:
是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 YES YES NO
volatile读 NO NO NO
volatile写 YES NO NO
代码示例:volatile 保证有序性
class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    // 线程A执行:先修改a,再修改volatile变量flag
    public void writer() {
        a = 1;          // 1. 线程A修改共享变量a
        flag = true;    // 2. 线程A修改volatile变量flag
    }
    // 线程B执行:先读取volatile变量flag,再读取a
    public void reader() {
        if (flag) {     // 3. 线程B读取volatile变量flag
            int i = a;  // 4. 线程B读取共享变量a
            // i 一定等于1,因为happens-before规则保证了有序性
        }
    }
}

根据 happens-before 规则:1 happens-before 2,2 happens-before 3,3 happens-before 4,因此 1 happens-before 4,线程B读取的a一定是线程A修改后的值。


3. 关键局限:volatile 不保证原子性

volatile 仅能保证单次读写的可见性和有序性,但无法保证复合操作的原子性,这是它与 synchronized 最核心的区别。

反例:volatile 无法保证 i++ 的原子性
public class VolatileAtomicExample {
    private static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++; // 非原子操作:读-改-写三步
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 预期结果:20000,实际结果通常小于20000
        System.out.println("count = " + count);
    }
}

原因:count++ 是“读取count→修改count→写回count”的复合操作,volatile 仅能保证每次读取和写入的可见性,但无法保证这三步操作的原子性,线程切换仍会导致结果丢失。


四、synchronized vs volatile 核心对比

特性 synchronized volatile
原子性 完全保证(临界区独占执行) 不保证(仅保证单次读写可见性)
可见性 加解锁时强制内存同步 写操作刷新主内存,读操作从主内存读取
有序性 禁止临界区内外重排序;块内可重排 内存屏障严格限制重排序(无例外)
性能 稍差(存在锁竞争/上下文切换开销) 极好(无锁,仅内存屏障微小开销)
适用场景 多步操作(i++、业务逻辑、DCL) 单变量读写(开关变量、状态标记)

五、总结与最佳实践

1. 核心总结

  • synchronized:全能型方案,通过锁机制同时解决原子性、可见性、有序性,但性能开销较大。
  • volatile:轻量级方案,通过内存屏障解决可见性和有序性,但不保证原子性,仅适用于单变量场景。
  • 两者互补:简单变量读写用 volatile 提升性能,多步操作用 synchronized 保证原子性,禁止混合同步方式。

2. 最佳实践

  • 开关变量、状态标记:优先用 volatile(如 volatile boolean stop)。
  • 复合操作(i++、业务逻辑):必须用 synchronized 或原子类(如 AtomicInteger)保证原子性。
  • 双重检查锁(DCL):需同时使用 synchronized 和 volatile,synchronized 保证临界区安全,volatile 禁止对象初始化重排序。
  • 避免“伪同步”:不要依赖 System.out.println() 等自带锁的方法解决可见性,需显式使用 synchronized 或 volatile。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐