Volatile 关键字的作用是什么?它是如何实现的?

volatile 关键字是 Java 并发编程中的一个非常重要的概念,但常常被误解。它的核心作用是解决多线程环境下的变量可见性问题部分有序性问题

1. volatile 的作用是什么?

在多线程环境中,为了提高性能,每个线程通常会在自己的工作内存(可以理解为 CPU 寄存器或 CPU 缓存)中保留共享变量的副本。当线程修改了变量时,这个修改可能首先只存在于线程的工作内存中,而没有立即刷新到主内存。其他线程读取这个变量时,可能仍然从自己的工作内存中读取旧的副本,导致可见性问题

此外,编译器和处理器为了优化性能,可能会对指令进行重排序(Instruction Reordering),只要重排序不影响单线程内的执行结果。但在多线程环境下,这种重排序可能导致意料之外的结果,这就是有序性问题

volatile 关键字的作用就是解决这两个问题:

  • 保证可见性 (Visibility): 当一个线程修改了用 volatile 关键字修饰的变量的值时,这个新值会立即被刷新到主内存。当其他线程读取这个 volatile 变量时,会强制从主内存中读取最新的值,而不是使用工作内存中的旧副本。

  • 保证有序性 (Ordering): volatile 关键字可以防止指令重排序。更准确地说,对于一个 volatile 变量的读写,它会禁止特定类型的指令重排序:

    • 在一个 volatile 写操作之前的读写操作,不会被重排序到这个写操作之后。
    • 在一个 volatile 读操作之后的读写操作,不会被重排序到这个读操作之前。
    • 在一个 volatile 写操作之后的写操作,不会被重排序到这个写操作之前。(StoreStore barrier)
    • 在一个 volatile 读操作之前的读操作,不会被重排序到这个读操作之后。(LoadLoad barrier)
  • 不保证原子性 (Atomicity - Limited): 非常重要! volatile 只能保证对单个 volatile 变量的读/写操作是具备原子性的。但对于复合操作,比如 i++(先读 i,再加 1,再写 i),volatile 并不能保证整个复合操作的原子性。如果在多个线程同时对一个 volatile 变量进行 ++ 操作,仍然可能出现线程安全问题。对于复合操作,需要使用 synchronizedjava.util.concurrent.atomic 包中的原子类来保证原子性。

总结来说: volatile 变量提供了一种弱同步机制,确保了对变量的修改对其他线程立即可见,并防止了围绕 volatile 变量读写的特定指令重排序。

2. volatile 是如何实现的?

volatile 的实现依赖于 JVM 底层对操作系统的调用和 CPU 提供的内存屏障 (Memory Barrier / Memory Fence) 指令。

当 JVM 看到一个 volatile 变量的读或写操作时,它会插入特定的内存屏障指令。这些指令会:

  1. 强制刷新工作内存到主内存 (对于写操作): 当一个线程写入 volatile 变量时,内存屏障会确保该线程工作内存中之前所有对共享变量的修改都刷新到主内存,并且 volatile 变量本身的修改也立即刷新到主内存。
  2. 强制从主内存读取 (对于读操作): 当一个线程读取 volatile 变量时,内存屏障会使线程的工作内存中的变量副本失效(或者直接忽略工作内存中的副本),强制线程从主内存中重新读取最新的值。
  3. 禁止指令重排序 (Ordering): 内存屏障会阻止屏障前后的指令跨越屏障进行重排序。

具体的内存屏障类型及其作用:

  • LoadLoad 屏障: Load1; LoadLoad; Load2。确保 Load1 读操作的数据在使用之前,Load2 读操作及其后续操作不会发生。
  • StoreStore 屏障: Store1; StoreStore; Store2。确保 Store1 写操作对其他处理器可见(刷新到主内存)后,Store2 写操作才会对其他处理器可见。
  • LoadStore 屏障: Load1; LoadStore; Store2。确保 Load1 读操作的数据在使用之前,Store2 写操作及其后续操作不会发生。
  • StoreLoad 屏障: Store1; StoreLoad; Load2。确保 Store1 写操作对其他处理器可见后,Load2 读操作及其后续操作才会发生。这是开销最大的屏障,因为它需要等待写缓冲器(write buffer)的刷新,并且可能需要使其他处理器的缓存失效。

volatile 变量读写操作对应的内存屏障插入规则 (HotSpot JVM 的实现为例):

  • 在每个 volatile 写操作后插入 StoreStore 和 StoreLoad 屏障。

    • ...; StoreStore; volatile 写; StoreLoad; ...
    • StoreStore 屏障:确保 volatile 写之前的所有普通写都已刷新到主内存。
    • StoreLoad 屏障:是关键。它会使当前处理器的高速缓存行无效,强制从主内存读取,并禁止 volatile 写后面的任何读或写操作被重排序到 volatile 写之前。
  • 在每个 volatile 读操作前插入 LoadLoad 屏障,并在其后插入 LoadStore 屏障。

    • ...; LoadLoad; volatile 读; LoadStore; ...
    • LoadLoad 屏障:确保 volatile 读之前的任何普通读操作都已完成。
    • LoadStore 屏障:禁止 volatile 读后面的任何写操作被重排序到 volatile 读之前。

通过这些内存屏障,volatile 变量的读写操作就遵循了 happens-before 原则:一个线程对 volatile 变量的写入操作 happens-before 后续对该变量的读取操作。

底层硬件层面:

不同的 CPU 架构有不同的内存模型和内存屏障指令(例如 x86 架构上的 sfence, lfence, mfence)。JVM 会将 JMM 定义的内存屏障抽象映射到对应平台上的具体指令,确保在各种硬件上都能满足 volatile 的语义。在 x86 架构上,由于其内存模型的强一致性特性,volatile 写的实现可能只需要在写操作后加上一个 StoreLoad 屏障(或类似效果的指令,如 lock addl),而 volatile 读可能甚至不需要显式的内存屏障(因为 x86 的 load 操作本身就有很强的有序性保证),但 JMM 要求 JVM 必须遵守规范。

总结:

volatile 通过在变量访问时插入内存屏障,强制线程刷新/失效工作内存中的数据,直接与主内存同步,从而解决了可见性问题;同时,内存屏障还限制了指令重排序,解决了部分有序性问题。然而,它不适用于需要保证复合操作原子性的场景。

你可能感兴趣的:(JVM,常见问题汇总,java,spring,volatile)