无法理解 Java 规范中的 volatile 示例

Can't understand example of volatile in Java specification

我大致了解了 volatile 在 Java 中的含义。但读书 Java SE Specification 8.3.1.4 我无法理解该特定示例下方的文字。

class Test {
    static volatile int i = 0, j = 0;
    static void one() { i++; j++; }
    static void two() {
        System.out.println("i=" + i + " j=" + j);
    }
}

This allows method one and method two to be executed concurrently, but guarantees that accesses to the shared values for i and j occur exactly as many times, and in exactly the same order, as they appear to occur during execution of the program text by each thread. Therefore, the shared value for j is never greater than that for i, because each update to i must be reflected in the shared value for i before the update to j occurs. It is possible, however, that any given invocation of method two might observe a value for j that is much greater than the value observed for i, because method one might be executed many times between the moment when method two fetches the value of i and the moment when method two fetches the value of j.

怎么样

j never greater than i

,但同时

any given invocation of method two might observe a value for j that is much greater than the value observed for i

??

看起来很矛盾。

在 运行 示例程序之后,我得到 j 大于 i。为什么要使用 volatile 呢?它在没有 volatile 的情况下给出几乎相同的结果(i 也可以大于 j,规范中的先前示例之一)。为什么这个例子在这里作为替代 synchronized?

一切都取决于您如何使用它。 Java 中的 volatile 关键字用作 Java 编译器和线程的指示符,它们不会缓存此变量的值并始终从主内存中读取它。因此,如果您想通过实现共享任何读写操作是原子的变量,例如在 int 或 boolean 变量中读取和写入,然后您可以将它们声明为 volatile 变量。

从 Java 5 开始,随着自动装箱、枚举、泛型和变量参数等重大变化,Java 在 Java 内存模型 (JMM) 中引入了一些变化,这保证了可见性从一个线程到另一个线程所做的更改也作为 "happens-before" 解决了一个线程中发生的内存写入问题可以 "leak through" 并被另一个线程看到。

Java volatile 关键字不能与方法或 class 一起使用,它只能与变量一起使用。 Java volatile 关键字还保证可见性和顺序,在 Java 5 之后写入任何 volatile 变量发生在任何读入 volatile 变量之前。顺便说一句,使用 volatile 关键字还可以防止编译器或 JVM 对代码进行重新排序或将它们从同步障碍中移开。

Java

中有关 Volatile 关键字的要点
  1. Java中的volatile关键字仅适用于变量,使用volatile关键字与class和方法是非法的。

  2. Java 中的 volatile 关键字保证 volatile 变量的值总是从主内存中读取,而不是从线程的本地缓存中读取。

  3. 在 Java 中,对于使用 Java volatile 关键字声明的所有变量(包括 long 和 double 变量),读写都是原子的。

  4. 在变量上使用 Java 中的 volatile 关键字可降低内存一致性错误的风险,因为对 Java 中的易失性变量的任何写入都会与后续变量建立先行关系读取同一个变量。

在任何时候,j 不大于 i

这与方法二观察到的不同,因为它在不同的时间访问变量ij。先访问i,稍晚访问j

这不是同步版本的直接替代方案,因为行为不同。与不使用 volatile 的一个区别是,如果不使用 volatile,则始终可以打印 0 值。增量永远不需要可见。

该示例演示了可变访问的顺序。一个需要这样做的例子可能是这样的:

volatile boolean flag = false;
volatile int value;

// Thread 1
if(!flag) {
    value = ...;
    flag = true;
}

// Thread 2
if(flag) {
    System.out.println(value);
    flag = false;
}

并且线程 2 读取线程 1 设置的值而不是旧值。

How is j never greater than i?

假设您只执行了一次 one()。在执行此方法期间,i 总是在 j 之前递增,因为递增操作一个接一个地发生。 如果您同时执行 one(),每个单独的方法调用将等待执行队列中的其他方法完成将它们的值写入 i 或 j,具体取决于当前正在执行的方法试图递增哪个变量。因此,对 i 的所有写入都一个接一个地发生,对 j 的所有写入一个接一个地发生。并且由于在方法主体内部 i 在 j 之前递增,因此在给定时刻,j 永远不会大于 i。

any given invocation of method two might observe a value for j that is much greater than the value observed for i, how?

如果调用 two() 时方法 one() 正在后台执行,则在读取 i 和读取 j 之间的时间段内,方法一个可以执行多次。因此,当 i 的值被读取时,它可能是在时间 t=0 时对 one() 的某些调用的结果,而当 j 的值被读取时,它可能是稍后发生的 one() 调用的结果,例如在 t=10。因此,在 println 语句的这种情况下,j 可以大于 i

Why use volatile in lieu of synchronized?

我不会列出为什么任何人都应该使用 volatile 而不是 synchronized 块的所有原因。但请记住,volatile 仅保证对该特定字段的原子访问,并不能确保未标记为 synchronized 的代码块的原子执行。在这个例子中,对 i 和 j 的访问是同步的,但是整个操作 {i++;j++} 不是同步的,因此它 apparently (我使用 apparently 因为它不完全相同但是看起来很相似)给出与不使用 volatile 关键字相同的结果。

How is

j never greater than i

,但同时

any given invocation of method two might observe a value for j that is much >>greater than the value observed for i

??

第一个语句在程序执行的任何给定时刻始终为真,第二个语句可能对任何给定间隔为真] 在程序的执行中。

当一个 volatile 变量被写入时,在它必须对其他线程可见之前写入它和所有内容(至少在 Java 5+ 中。版本的解释并没有太大变化不过在那之前 Java)。因此,i 的增量必须 j 递增时可见,这意味着 j 永远不会出现大于 i 到其他线程。

但是,ij 的读取不能保证在程序执行的单个时刻发生。 ij 的读取可能看起来彼此非常靠近执行 two() 的线程,但实际上在读取之间可能已经经过了任意数量的时间。例如,two() 可能在 i = 5j = 5 时读取 i,但随后在其他线程执行时读取 "frozen",更改 i 的值和 j 分别为 2019。当 two() 恢复时,它会从停止的地方开始读取 j,现在的值为 19two() 不会重新读取 i 因为就它而言,执行没有中断,所以不需要进行额外的工作。

Why use volatile then?

虽然 volatilesynchronized 都提供可见性保证,但精确的语义略有不同。 volatile 保证对变量所做的更改将立即对所有线程可见,而 synchronized 保证在其块中所做的更改将对所有线程可见 只要它们同步同样的锁synchronized 还提供了 volatile 没有的额外原子性保证。

Why is this example here as an alternative to synchronized?

只有当 one() 由单个线程执行时,

volatile 才是 synchronized 的可行替代方案,这里就是这种情况。在这种情况下,只有一个线程会写入 ij,因此不需要 synchronized 提供的原子性保证。如果 one() 由多个线程执行,volatile 将不起作用,因为构成增量的读取-添加-存储操作必须以原子方式发生,而 volatile 不能保证这一点。

我觉得这个例子的重点是强调在使用volatile时需要注意并保证顺序;该行为可能违反直觉,示例证明了这一点。

我同意那里的措辞有点晦涩,可以为多种情况提供更明确和清楚的例子,但没有矛盾。

共享值是同一时刻的值。如果两个线程同时读取 i 和 j 的值,则永远不会观察到 j 的值大于 i。 volatile 保证在代码中保持读取和更新的顺序。

然而,在示例中,print + i+ j 是两个不同的操作,间隔任意时间;因此,可以观察到 j 比 i 大,因为它可以在读取 i 之后和读取 j 之前更新任意次数。

使用 volatile 的意义在于,当您以正确的顺序同时更新和访问 volatile 变量时,您可以做出原则上没有 volatile 不可能的假设。

在上面的示例中,two() 中的访问顺序不允许断定哪个变量更大或相等。

但是,考虑一下,如果示例更改为 System.out.println("j=" + j + " i=" + i);

在这里,您可以自信地断言 j 的打印值永远不会大于 i 的打印值。如果没有 volatile,这个假设 不会 成立,原因有二。

首先,更新 i++ 和 j++ 可以由编译器和硬件以任意顺序执行,实际上可以执行为 j++;i++。如果您从其他线程访问 j 和 i 在 j++ 之后但在 i++ 之前,您可以观察到 j=1i=0,而不管访问顺序如何。 volatile 保证不会发生这种情况,它将按照源代码中写入的顺序执行操作。

其次,volatile 保证另一个线程将看到另一个线程更改的最新值,只要它在上次更新后的稍后时间点访问它。没有 volatile,就无法对观察值进行假设。理论上,该值可以永远为另一个线程保留为零。该程序可能会从过去的更新中打印两个零,零和任意数字等;其他线程中的观察值可能小于更新线程在更新后看到的当前值。 volatile 保证您将在第一个线程更新后在第二个线程中看到该值。

虽然第二个保证看起来是第一个(顺序保证)的结果,但实际上它们是正交的。

关于synchronized,它允许执行一系列非原子操作,如i++;j++作为原子操作,例如如果一个线程执行同步 i++;j++ 而另一个线程执行同步 System.out.println("i=" + i + " j=" + j);,第一个线程可能不会执行递增序列,而第二个线程打印结果将是正确的。

但这是有代价的。首先,synhronized 本身有性能损失。其次,更重要的是,并非总是需要这样的行为,阻塞的线程会浪费时间,从而降低系统吞吐量(例如,您可以在 System.out 期间执行如此多的 i++;j++;)。

此程序确实保证方法 two() 观察 j >= i-1(不考虑溢出)。

如果没有 volatilei,j 的观测值可能到处都是。

声明

the shared value for j is never greater than that for i

是非常不正式的,因为它表示"at the same time",这在JMM中不是定义的概念。


JMM的核心原理是关于"sequential consistency"。 JMM的驱动力是

JLS#17 - If a program is correctly synchronized, then all executions of the program will appear to be sequentially consistent

在下面的程序中

void f()
{
    int x=0, y=0;
    x++;
    print( x>y );
    y++
}

x>y 将始终被观察为 true。如果我们遵循动作的顺序,它必须是。否则,我们真的没有办法推理任何(命令式)代码。即"sequential consistency"。

"Sequential consistency" 是一个 observed 属性,它不必与 "real" 动作一致(无论那是什么意思)。 x>y 完全有可能在 x 实际递增(或根本递增)之前被 JVM 评估为 true。只要 JVM 可以保证观察到的顺序一致性,它就可以尽可能地优化实际执行,例如乱序执行代码。

但这是针对单线程的。如果多个线程是 reading/writing 个共享变量,那么这种优化当然会完全破坏顺序一致性。我们不能通过考虑来自多个线程的交错操作来推断程序行为(同一线程中的操作遵循线程内序列)。

如果要保证任何多线程代码的线程间顺序一致性,就必须放弃为单线程开发的优化技术。这将对大多数程序造成严重的性能损失。而且这也是不必要的——线程之间的数据交换是相当罕见的。

因此,创建特殊指令只是为了建立线程间顺序一致性需要时。易失性读取和写入就是这样的操作。所有易失性读取和写入都遵循线程间顺序一致性。在这种情况下,它保证 j >= i-1 in two().

我想提出这是一个错误,示例应该在 i:

之前打印 j
static void two() {
    System.out.println("j=" + j + " i=" + i);
}

第一个示例的新颖之处在于,由于更新重新排序,j 可以大于 i ,即使首先观察到

最后一个例子现在对解释进行了一些小的编辑(括号中的编辑和评论),现在非常有意义:

This allows method one and method two to be executed concurrently, but guarantees that accesses to the shared values for i and j occur exactly as many times, and in exactly the same order, as they appear to occur during execution of the program text by each thread. Therefore, the shared value for j is never [observed to be] greater than that for i, because each update to i must be reflected in the shared value for i before the update to j occurs. It is possible, however, that any given invocation of method two might observe a value for [i] that is much greater than the value observed for [j], because method one might be executed many times between the moment when method two fetches the value of [j] and the moment when method two fetches the value of [i].

这里的关键点是当使用volatile时,第二次更新永远不会在第一次更新之前观察到。最后一句关于两次读取之间的差距完全是括号,并且ij被交换以符合错误的例子。