1. 概述

编译器、运行时或处理器会应用各种优化。虽然这些优化通常有益,但在缺乏必要同步时,可能导致意外结果等微妙问题。

在并发上下文中,缓存和重排序这两种优化可能会让我们踩坑,尤其是代码同步没做对的时候。Java和JVM提供了多种控制内存顺序的方法,volatile字段就是其中之一。

本教程聚焦于Java中基础但常被误解的volatile字段概念。我们先从底层计算机架构讲起,然后熟悉Java的内存顺序,接着分析多处理器共享架构中的并发挑战,以及volatile如何解决这些问题。

2. 共享多处理器架构

处理器负责执行程序指令,因此必须从RAM获取指令和数据。

由于CPU每秒能执行大量指令,直接从RAM获取数据效率太低。为此,处理器使用了乱序执行分支预测推测执行和缓存等技巧。

这就形成了以下内存层次结构:

cpu

随着不同核心执行更多指令并操作更多数据,它们会用更多相关数据和指令填充自己的缓存。这虽然提升了整体性能,但也带来了缓存一致性的挑战

当一个线程更新缓存值时,我们必须三思其后果

3. 缓存一致性挑战

为深入探讨缓存一致性,我们借用《Java并发编程实战》一书中的例子:

public class TaskRunner {
    private static int number;
    private static boolean ready;

    private static class Reader extends Thread {
        @Override
        public void run() {
            while (!ready) {
                // 自旋等待
            }
            System.out.println(number);
        }
    }

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

TaskRunner类维护两个简单变量。其main方法创建一个线程,该线程在readyfalse时一直自旋。当变量变为true时,线程打印number变量。

很多人期望程序短暂延迟后打印42,但延迟可能长得多,甚至可能永久挂起或打印0

这些异常的根本原因是缺乏适当的内存可见性和重排序。下面详细分析:

3.1. 内存可见性

这个例子有两个应用线程:main线程和reader线程。假设操作系统将它们调度到两个不同CPU核心:

  • main线程在其核心缓存中有readynumber的副本
  • reader线程也有自己的副本
  • main线程更新了缓存中的值

大多数现代处理器不会立即应用写请求。处理器倾向于将写入操作排队到特殊写缓冲区,稍后一次性应用到主内存。

综上所述,main线程更新numberready时,无法保证reader线程会看到什么。换句话说:

  • reader可能立即看到更新值
  • 可能延迟看到
  • 可能永远看不到

这种内存可见性问题可能导致依赖可见性的程序出现活性问题。

3.2. 重排序

更糟糕的是,**reader线程看到的写入顺序可能与实际程序顺序不同**。例如我们先更新number

number = 42;
ready = true;

我们期望reader线程打印42,但实际上它完全可能打印0

重排序是性能优化技术,不同组件都可能应用:

  • 处理器可能以非程序顺序刷新写缓冲区
  • 处理器可能应用乱序执行
  • JIT编译器可能通过重排序优化

4. volatile内存顺序

我们可以用volatile解决缓存一致性问题。

为确保变量更新可预测地传播到其他线程,应该对这些变量应用volatile修饰符。

这样就能通知运行时和处理器:

  • 避免重排序任何涉及volatile变量的指令
  • 立即刷新对这些变量的更新
public class TaskRunner {
    private static volatile int number;
    private static volatile boolean ready;

    private static class Reader extends Thread {
        @Override
        public void run() {
            while (!ready) {
                // 自旋等待
            }
            System.out.println(number);
        }
    }

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

这样就能通知运行时和处理器避免重排序涉及volatile变量的指令,并立即刷新更新。

5. volatile与线程同步

多线程应用需要确保两个规则以保持行为一致:

  1. 互斥——一次只有一个线程执行临界区
  2. 可见性——一个线程对共享数据的更改对其他线程可见

synchronized方法和块同时提供这两种特性,但代价是性能损失。

volatile字段很有用,因为它能确保数据更改的可见性,但不提供互斥。因此,当我们允许多线程并行执行代码块,但需要保证可见性时,它就派上用场了。

6. volatile的保证

Java编译器允许重排序指令,只要不影响线程的独立执行。包含volatile修饰符的共享变量保证:

  • 运行时和处理器会按程序文本顺序执行相关指令
  • 不应用可能重排序指令的优化
  • 所有线程看到该共享变量的值是一致的
  • volatile字段的任何更新都会立即更新共享值
  • 更新后其他线程不可能获取到不一致的值

重要提示:volatile保证一致性,但缺少volatile不意味着其他线程总是获取不一致值——只是可能偶尔发生。不要期望移除TaskRunner中的volatile后程序就必然打印错误值,但这种情况确实可能发生。

7. Happens-Before顺序

volatile变量的内存可见性效果超越了变量本身。

假设线程A写入volatile变量,然后线程B读取同一变量。在这种情况下,写入volatile前对A可见的值,在读取volatile后将对B可见

happens before

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

8. 搭便车(Piggybacking)

**由于happens-before的强大特性,有时我们可以利用另一个volatile变量的可见性来"搭便车"**。例如,在我们的例子中,只需将ready标记为volatile

public class TaskRunner {
    private static int number;
    private static volatile boolean ready;

    private static class Reader extends Thread {
        @Override
        public void run() {
            while (!ready) {
                // 自旋等待
            }
            System.out.println(number);
        }
    }

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

在写入ready=true之前的任何操作,在读取ready后都是可见的。因此number变量搭上了ready强制内存可见性的便车。即使它不是volatile变量,也表现出volatile行为

利用这种语义,我们可以在类中只定义少量volatile变量来优化可见性保证。

9. 结论

本文探讨了volatile关键字的能力,以及从Java 5开始对其的改进。简单粗暴地总结:volatile是解决并发可见性问题的利器,但要注意它不保证原子性。理解其背后的内存模型和happens-before原则,才能在并发编程中少踩坑。

完整代码可在GitHub上找到。


原始标题:Guide to the Volatile Keyword in Java | Baeldung

« 上一篇: JBoss Undertow 详解