volatile & synchronized

#Concurrency #JAVA #JVM

volatile 关键字是 Java 并发编程中的一个基础且至关重要的概念。它就像是 Java 虚拟机 ([[JVM]]) 和底层硬件之间立下的一份“契约”,专门用于解决多线程环境下共享变量的可见性(Visibility)和有序性(Ordering)问题


一、 volatile 的含义及在 Java 中的作用

1. 单词的意思(Literal Meaning)

在 Java 中,volatile 关键字用来标记一个 Java 变量是 “存储在主内存中"(“being stored in main memory”)的。它指示 JVM,这个变量是共享且不稳定的

2. 它在 Java 中干什么(核心机制)

volatile 的核心作用是强制读写操作直接与主内存进行交互,从而解决了由于 CPU 缓存和指令重排序带来的并发问题。

A. 解决可见性问题(强制内存访问)

由于现代 CPU 为了性能,会将变量副本存储在 CPU 寄存器或缓存中。如果变量未被声明为 volatile,线程在操作共享变量时,对主内存的读写时间是不确定的。

  • 读取操作:volatile 变量的每一次读取都将直接从计算机的主内存中读取,而不是从 CPU 寄存器中读取。
  • 写入操作:volatile 变量的每一次写入都将立即被写入到主内存中,而不仅仅是写入到 CPU 寄存器或写缓冲区中。

通过这种方式,volatile 确保了当一个线程更新变量值时,处理器会立即刷新这些更新,保证其他线程随后读取时能看到这个最新值。

B. 解决有序性问题(禁止指令重排序)

编译器、运行时环境或处理器可能会对指令进行重排序以优化性能。volatile 变量是与运行时和处理器进行通信,以避免重排序任何涉及该 volatile 变量的指令

  • 它通过插入内存屏障 (Memory Barrier) 来实现这一目标。内存屏障就像一道墙,禁止某些类型的处理器重排序。

二、 volatile 保证了什么?(核心承诺)

volatile 关键字提供了 Java 内存模型 ([[Java/Concurrency/JMM|JMM]]) 规定的两大核心保证:

1. 内存可见性(Visibility Guarantee)

这是 volatile 最主要也是最基础的保证。

  • 一致性保证: volatile 保证了所有线程看到的共享变量的值是一致的。当对 volatile 字段进行更新后,该字段的共享值会立即更新,其他线程不会获取到不一致的值。
  • 完整可见性保证(Full Volatile Visibility Guarantee): volatile 的可见性保证甚至延伸到了变量本身之外。
    • 如果线程 A 写入一个 volatile 变量,随后线程 B 读取了它,那么在 A 写入该 volatile 变量之前对所有其他变量所做的更改,对 B 来说都是可见的
    • 类似地,当线程 A 读取 volatile 变量时,所有对线程 A 可见的变量都会被强制重新从主内存读取

2. 有序性与 Happens-Before 关系

volatile 关键字提供了 Java 内存模型中的 Happens-Before 保证

  • Volatile 变量规则: 对一个 volatile 变量的写操作 Happens-Before 后面对这个 volatile 变量的读操作
  • 重排序约束:
    • 写入屏障: 发生在 volatile 变量写入之前对其他变量的读写,不能被重排到 volatile 写入之后发生。
    • 读取屏障: 发生在 volatile 变量读取之后对其他变量的读写,不能被重排到 volatile 读取之前发生。

3. 对 64 位值操作的原子性保障

[[Java/Concurrency/JMM|JMM]] 对 64 位值(如 longdouble)的访问原子性有所放宽,因为在某些 32 位平台上,读写 64 位值可能需要两次内存事务,导致操作非原子。

  • volatile 作为一种逃生出口(escape hatch),可以用来强制确保 64 位值的访问原子性
  • 如果代码可能在 32 位平台上运行,开发者必须使用 volatile 关键字来保障 64 位操作的可移植性。

三、 什么时候使用 volatile?(适用场景)

volatile 是一个轻量级的同步机制,适用于只需要保证可见性和有序性,但不需要互斥的场景。

  1. 单写多读场景 (State Flag):当只有一个线程会修改(写入)变量的值,而其他所有线程只负责读取该变量时,volatile 是足够的。
class Server {
    // 状态标志:只有一个线程会把它设为 true
    private volatile boolean isRunning = true;

    public void shutdown() {
        this.isRunning = false; // 单写操作
    }

    public void doWork() {
        while (isRunning) { // 多读操作
            // ... 执行任务
        }
        System.out.println("Server has been shut down.");
    }
}
  1. 值不依赖于旧值: 即使有多个线程写入 volatile 变量,只要写入的新值不依赖于其旧值(例如,设置一个布尔状态标记,而不是计数器递增),volatile 也是足够的。
  2. 替代重量级锁: 当应用程序需要确保数据的可见性方面,但同时又希望避免 synchronized 方法和块带来的性能开销时,volatile 很有用。
  3. “搭便车"技巧 (Piggybacking):利用 volatile 带来的 happens-before 顺序性,将普通变量的写入放在 volatile 变量写入之前,可以使普通变量的可见性“搭上” volatile 变量的便车。

四、 什么时候不使用 volatile?(局限性与替代方案)

volatile 具有明显的局限性,在需要复杂同步时,不应单独依赖它。

1. 不能保证互斥性(Mutual Exclusion)

  • volatile 字段的读写不会阻塞其他线程的读写操作。
  • volatile 关键字不提供互斥(mutual exclusion),即它不能保证在任何时刻只有一个线程执行关键代码段。

2. 无法保障复合操作的原子性

  • 一旦线程需要先读取 volatile 变量的值,然后根据该值生成一个新的值(即复合操作,如 i++count = count + 1),单独的 volatile 就不够了
  • 在读取和写入新值之间的短暂时间差内,会产生竞态条件(race condition),多个线程可能读取到相同的值,导致相互覆盖,最终结果错误。
  • 替代方案: 在这种需要原子性(读取和写入是一个不可分割操作)的情况下,必须使用:
    • synchronized 块或方法
    • java.util.concurrent 包中的各种原子数据类型,如 AtomicLongAtomicReference

3. 性能考量

  • 访问 volatile 变量会将其读写到主内存,这比访问 CPU 寄存器更昂贵
  • volatile 还会阻止指令重排序,而指令重排序是常见的性能增强技术
  • 因此,应该只在真正需要强制变量可见性时才使用 volatile 变量,以避免不必要的同步开销。
class UnsafeCounter {
    // 即使使用了 volatile,也无法保证正确性
    private volatile int count = 0;

    public void increment() {
        count++; // 这不是原子操作!它包含三步:
                 // 1. 读取 count 的值
                 // 2. 将值加 1
                 // 3. 写入新值
    }

    public int getCount() {
        return count;
    }
}
特性 volatile synchronized AtomicInteger (原子类)
保证可见性
保证有序性 (禁止重排序)
保证原子性 (仅限单次读/写) (代码块) (特定操作)
互斥/阻塞 (非阻塞) (阻塞) (非阻塞,CAS)
性能开销 较低 较高 较低 (通常优于 volatile)
适用场景 状态标志、单写多读 复杂同步逻辑、临界区保护 计数器、累加等复合操作

synchronized 关键字是 Java 并发编程的基石,它提供了一种强大而直接的同步机制。它就像是为一间“共享资源室”配备的“唯一钥匙”,专门用于解决多线程环境下的原子性、可见性和有序性问题,其核心是互斥访问。


一、 synchronized 的含义及在 Java 中的作用

1. 意思

synchronized 的字面意思是“同步的”。在 Java 中,它用于确保一段代码或一个方法在同一时刻最多只有一个线程能执行它。

2. 它在 Java 中干什么(核心机制)

synchronized 的核心作用是实现线程间的互斥,它通过一个叫做监视器锁 的东西来实现。

  • 监视器锁:每个 Java 对象都可以作为一个锁,这个锁就是对象的内置锁或监视器锁。当线程试图进入一个被 synchronized 保护的代码块时,它必须先获取该代码块所关联对象的锁。
  • 锁的获取与释放
    • 获取锁:如果锁未被其他线程持有,当前线程会获取锁并进入代码块执行。
    • 阻塞等待:如果锁已被其他线程持有,当前线程将被阻塞,并进入锁的等待队列,直到锁被释放。
    • 释放锁:当线程执行完 synchronized 代码块或方法,或者在代码块中抛出异常时,锁会自动被释放。

二、 synchronized 保证了什么?(核心承诺)

synchronized 关键字提供了三大核心保证,比 volatile 更为强大。

1. 原子性

这是 synchronized 最核心的保证。

  • 互斥执行synchronized 确保被它保护的代码块(临界区)是原子的。一个线程一旦进入该代码块,在它退出之前,其他任何线程都无法进入。这从根本上杜绝了竞态条件。
  • 类比:就像一个只有一把钥匙的洗手间。一个人进去后锁上门(获取锁),在里面无论待多久,外面的人都只能排队等待(阻塞)。直到他出来并交出钥匙(释放锁),下一个人才能进去。

2. 内存可见性

volatile 保证可见性,synchronized 同样保证,而且更强。

  • Happens-Before 保证:[[Java/Concurrency/JMM|JMM]] 规定,对一个锁的解锁操作 [[Java/Concurrency/JMM#^444be5|Happens-Before]] 后面对同一个锁的加锁操作
  • 强制刷新:这意味着,当线程 A 释放锁时,[[Java/Concurrency/JMM|JMM]] 会强制将该线程工作内存中的所有共享变量的最新值刷新到主内存。而当线程 B 随后获取同一个锁时,[[Java/Concurrency/JMM|JMM]] 会强制将该线程的工作内存置为无效,使其必须从主内存中重新读取所有共享变量的值。
  • 效果:这确保了前一个线程对共享变量的修改,对后一个获取锁的线程是立即可见的。

3. 有序性

volatile 通过内存屏障禁止指令重排,synchronized 通过其“原子性”隐式地保证了有序性。

  • 隐式保证:由于 synchronized 块内的代码与块外的代码是互斥执行的,一个线程在 synchronized 块内观察到的状态,一定是另一个线程在之前或之后同步块内操作的结果。[[Java/Concurrency/JMM|JMM]] 可以在保证最终结果正确性的前提下,对 synchronized 块内的代码进行重排序,但它绝不会允许块内的代码“[[Runtime Data Area#Escape|逃逸]]”到块外,从而破坏整体的同步语义。

4. 可重入性

这是 synchronized 一个非常重要的特性。

  • 定义:一个已经获取到锁的线程,可以再次进入由它自己持有的锁所保护的任何代码块,而不会造成死锁。
  • 机制:JVM 会为每个锁关联一个持有计数器和一个持有线程。当一个线程获取锁时,计数器加一。当同一线程再次获取时,计数器再次加一。当线程退出同步块时,计数器减一。直到计数器为 0 时,锁才被真正释放。
  • 意义:可重入性极大地简化了代码编写,避免了因在同步方法中调用另一个同步方法而导致的自我死锁。

三、 什么时候使用 synchronized?(适用场景)

synchronized 是一个重量级的同步工具,适用于需要强一致性保证的场景。

  1. 保护临界区:当多个线程需要访问和修改共享资源(如共享的数据结构、文件、设备等)时,必须使用 synchronized 来保护访问这些资源的代码块。
  2. 保证复合操作的原子性:对于像 check-then-act(检查再操作,如单例模式的懒加载)或 read-modify-write(读取再修改,如 i++)这样的复合操作,volatile 无能为力,synchronized 是最直接的解决方案。
  3. 实现简单的线程安全类:在创建自定义的线程安全容器或工具类时,synchronized 是最基础、最可靠的实现手段。

三种使用方式

public class MySynchronizedClass {

    // 1. 修饰实例方法,锁是当前实例对象 (this)
    public synchronized void instanceMethod() {
        // 临界区代码
        System.out.println("Instance method. Lock on: " + this);
    }

    // 2. 修饰静态方法,锁是当前类的 Class 对象 (MySynchronizedClass.class)
    public static synchronized void staticMethod() {
        // 临界区代码
        System.out.println("Static method. Lock on: " + MySynchronizedClass.class);
    }

    private final Object lockObject = new Object(); // 专用锁对象

    public void blockMethod() {
        // 3. 修饰代码块,锁是指定的对象 (lockObject)
        synchronized (lockObject) {
            // 临界区代码
            System.out.println("Code block. Lock on: " + lockObject);
        }
    }
}

四、 什么时候不使用 synchronized?(局限性与替代方案)

synchronized 虽然强大,但其“重量级”的特性也带来了局限。

1. 性能开销

  • 线程阻塞与上下文切换:当锁竞争激烈时,无法获取锁的线程会被阻塞,涉及从用户态到内核态的切换,这是一个非常昂贵的操作
  • 性能瓶颈:在高并发场景下,synchronized 容易成为系统的性能瓶颈。

2. 灵活性不足

  • 不可中断:一个线程在等待获取 synchronized 锁时,不能被中断。
  • 无法设置超时:无法设置一个线程等待锁的最长时间。
  • 非公平锁synchronized 是非公平的,即等待时间长的线程不一定能优先获取锁。

3. 替代方案

为了克服 synchronized 的局限性,Java 提供了更灵活、更高效的并发工具。

  • volatile:当只需要保证可见性和有序性,而不需要互斥访问时,volatile 是更轻量级的选择。
  • java.util.concurrent.atomic:对于单个变量的原子性复合操作(如计数器),AtomicInteger 等原子类基于 CAS(Compare-And-Swap)操作,通常比 synchronized 性能更好。
  • java.util.concurrent.locks.Lock 接口:提供了更高级的锁功能,如可中断的锁获取(lockInterruptibly())、可超时的锁获取(tryLock())和公平锁(ReentrantLock 的构造函数参数)。它为复杂并发场景提供了更精细的控制。

核心要点总结(一图流)

特性 synchronized java.util.concurrent.locks.Lock
保证原子性
保证可见性
保证有序性
互斥/阻塞 (阻塞) (阻塞)
可重入 (如 ReentrantLock)
可中断 (lockInterruptibly())
可超时 (tryLock())
公平性 (仅非公平) (可选公平/非公平)
使用方式 关键字 (JVM 实现,自动释放) 接口 (API 调用,需手动 unlock())
性能开销 较高 (JDK 6 后大幅优化) 通常较低,但依赖具体实现