关于Volatile你需要知道的一切

684 阅读5分钟

前言

在没有必要同步的情况下,编译器、运行时或处理器可能会应用各种优化。尽管这些优化通常是有益的,但有时它们会导致一些微妙的问题。 缓存和重新排序是在并发上下文中可能会让我们感到惊讶的优化之一。 Java 和 JVM 提供了许多方法来控制内存顺序,volatile 关键字就是其中之一。

首先,我们将从一些有关底层计算机体系结构如何工作的背景知识开始,然后我们将熟悉 Java 中的内存顺序。

共享多处理器架构

处理器负责执行程序指令。因此,他们需要从 RAM 中检索程序指令和所需数据。

由于 CPU 每秒可以执行许多指令,因此从 RAM 中获取数据对它们来说并不理想。为了改善这种情况,处理器正在使用乱序执行、分支预测、推测执行,当然还有缓存等技巧。

这是以下内存层次结构发挥作用的地方:

image.png

随着不同内核执行更多指令并处理更多数据,它们会用更多相关数据和指令填充缓存。这将以引入缓存一致性挑战为代价提高整体性能。

简而言之,当一个线程更新缓存值时,我们应该三思而后行。

何时使用 volatile

我们借用一个示例:

public class TaskRunner {

    private static int number;
    private static boolean ready;

    private static class Reader extends Thread {

        @Override
        public void run() {
            while (!ready) {
                Thread.yield();
            }

            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new Reader().start();
        number = 42;
        ready = true;
    }
}

TaskRunner 类维护两个简单的变量。在其 main 方法中,它会创建另一个线程,只要 ready 变量为 false,它就会循环。当变量变为真时,线程将简单地打印数字变量。

许多人可能希望这个程序在短暂的延迟后简单地打印 42;然而,实际上,延迟可能要长得多。它甚至可能永远挂起或打印为零。

这些异常的原因是缺乏适当的内存可见性和重新排序。让我们看看哪里出了问题。

内存可见性

在这个简单的示例中,我们有两个应用程序线程:主线程和读取线程。让我们想象一个场景,操作系统在两个不同的 CPU 内核上调度这些线程,其中:

  • 主线程在其核心缓存中有 ready 和 number 变量的副本。
  • 读者线程也以其副本。
  • 主线程更新缓存的值。

在大多数现代处理器上,写入请求不会在发出后立即应用。事实上,处理器倾向于将这些写入排队到一个特殊的写入缓冲区中。一段时间后,他们会立即将这些写入应用到主内存。

也就是说, 当主线程更新 number 和 ready 变量时,无法保证读者线程会看到什么。换句话说,读者线程可能会立即看到更新后的值,但有一些延迟,或者根本看不到。

这种内存可见性可能会导致依赖可见性的程序出现变量不一致的问题。

重新排序

更糟糕的是,读取线程可能会看到这些写入的顺序与实际程序顺序不同。例如,由于我们首先更新了数字变量:

public static void main(String[] args) { 
    new Reader().start();
    number = 42; 
    ready = true; 
}

我们可能希望读者线程打印 42。但实际上可能会看到0作为打印值。

重新排序是一种用于提高性能的优化技术。有趣的是,不同的组件可能会应用此优化:

  • 处理器可能会以不同于程序顺序的顺序刷新其写入缓冲区。
  • 处理器可以应用乱序执行技术。
  • JIT 编译器可以通过重新排序进行优化。

volatile内存顺序

为了确保对变量的更新可预测地传播到其他线程,我们应该将 volatile 修饰符应用于这些变量:

public class TaskRunner {

    private volatile static int number;
    private volatile static boolean ready;

    // same as before
}

通过这种方式,我们告诉处理器,不会重新排序任何涉及 volatile 变量的指令。此外,处理器明白他们应该立即刷新对这些变量的任何更新。

volatile 和线程同步

对于多线程应用程序,我们需要确保一些规则以实现一致的行为:

  • 互斥——一次只有一个线程执行临界区
  • 可见性——一个线程对共享数据所做的更改对其他线程可见,以保持数据一致性

同步方法和块以应用程序性能为代价提供上述两个属性。

volatile 是一个非常有用的关键字,因为它可以在不提供互斥的情况下帮助确保数据更改的可见性。因此,它在我们可以让多个线程并行执行一段代码但需要确保可见性属性的地方很有用。

Happens-Before 顺序

volatile 变量的内存可见性影响超出了 volatile 变量本身。

为了使事情更具体,我们假设线程 A 写入一个 volatile 变量,然后线程 B 读取同一个 volatile 变量。

在这种情况下,在写入 volatile 变量之前对 A 可见的值将在读取 volatile 变量后对 B 可见:

从技术上讲,对volatile字段的任何写入都发生在同一字段的每次后续读取之前。这是Java内存模型的volatile变量规则.

image.png

搭便车

由于 happens-before 内存排序的优势,有时我们可以利用另一个 volatile 变量的可见性属性。例如,在我们的特定示例中,我们只需要将就绪变量标记为易变的:

public class TaskRunner {

    private static int number; // not volatile
    private volatile static boolean ready;

    // same as before
}

读取就绪变量后,任何在写入就绪变量之前的内容都对任何内容可见。因此,number 变量搭载在 ready 变量强制执行的内存可见性上。简而言之,即使它不是易变变量,它也表现出易变行为。

使用这些语义,我们可以仅将类中的几个变量定义为 volatile 并优化可见性保证。