如果不需要该值,Java 是否允许优化易失性读取,同时删除同步之前发生的情况?

下面的代码示例演示了一种常见方法来演示由缺少的发生前关系导致的并发问题。

private static /*volatile*/ boolean running = true;
    
public static void main(String[] args) throws InterruptedException {
    new Thread() {
        @Override
        public void run() {
            while (running) {
                // Do nothing
            }
        }
    }.start();
    Thread.sleep(1000);
    running = false;
}

如果 是 ,则保证程序在大约一秒后终止。但是,如果不是,则程序根本不能保证终止(因为在这种情况下,不存在发生之前关系或保证变量更改的可见性),这正是我的测试中发生的情况。runningvolatilerunningvolatilerunning

根据 JLS 17.4.5,还可以通过写入和读取另一个变量来强制实施一个发生之前的关系,如下面的代码示例所示。volatilerunning2

private static boolean running = true;
private static volatile boolean running2 = true;
    
public static void main(String[] args) throws InterruptedException {
    new Thread() {
        @Override
        public void run() {
            while (running2 || running) {
                // Do nothing
            }
        }
    }.start();
    Thread.sleep(1000);
    running = false;
    running2 = false;
}

该变量在每次循环迭代中被读取,当它在大约一秒钟后被读取时,由于发生之前的关系,还可以保证变量随后被读取。因此,该程序保证在大约一秒钟后终止,这正是我的测试中发生的事情。volatilerunning2falserunningfalse

但是,当我将变量的读取放入循环内的空语句中时(如下面的代码示例所示),程序不会在我的测试中终止。running2ifwhile

private static boolean running = true;
private static volatile boolean running2 = true;
    
public static void main(String[] args) throws InterruptedException {
    new Thread() {
        @Override
        public void run() {
            while (running) {
                if (running2) {
                    // Do nothing
                }
            }
        }
    }.start();
    Thread.sleep(1000);
    running = false;
    running2 = false;
}

这里的想法是,读取 就像编译器内存屏障:编译器必须使 asm 重新读取非变量,因为读取 可能已与另一个线程中的发布操作同步。这将保证非易失性变量中新值的可见性,例如.volatilerunning2volatilerunning2running

但是我的JVM似乎没有这样做。这是编译器还是JVM错误,或者JLS是否允许这样的优化,即在不需要值时删除易失性读取?(它只是控制一个空的主体,所以程序行为不依赖于被读取的值,只依赖于创建发生在之前的关系。if

我认为JLS适用于源代码,既然是,由于优化,读取变量的效果不应该被删除。这是编译器还是JVM错误,或者是否有规范,实际上允许进行此类优化?running2volatile


答案 1

...JLS是否允许它在不需要该值时删除易失性读取?(它只控制一个空的 if body,所以程序行为不依赖于读取的值,只依赖于创建一个发生在之前。

根据17.4。 JLS的内存模型:

内存模型描述程序的可能行为。实现可以自由地生成它喜欢的任何代码,只要程序的所有结果执行都会生成可以由内存模型预测的结果。

因此,JLS实际上允许在运行时中做任何事情,只要执行的结果是“合法的”。

通过“执行结果”,JLS意味着程序执行的所有外部操作:文件和网络套接字的操作,各种系统调用(例如读取当前时间)等
。JLS的可观察行为和非终止执行就是关于这一点(或类似的东西)。

在您的示例中,唯一的外部操作是休眠 1 秒,因此您的程序可以“优化”为:

    public static void main(String[] args){
        Thread.sleep(1000);
    }

如果上面的答案是正确的,因为无限循环也是合法执行,那么,我想,你的程序可以“优化”到:

    public static void main(String[] args){
        while(true);
    }

再一次:只要运行时执行与 JLS 允许的合法执行之一相同的外部操作,就可以允许运行时执行任何操作。

为了进一步澄清问题,让我们以合法处决为例。

通用算法

一般算法在 17.4 中描述。 JLS 的内存模型。

每个线程的单独操作必须由该线程的语义控制,但每次读取看到的值由内存模型确定。

因此,我们假设每个线程中的操作都是按顺序逐个执行的。
与单线程程序的唯一区别是,对于从多个线程访问的变量,读取可能会返回“意外”值。

获取读取的所有可能值的规则是这样的:

非正式地说,如果没有发生之前排序以防止该读取,则允许读取查看写入结果。rw

换句话说,读取某些变量将返回:

  • 最后一次写入比变量的顺序happens-before
  • 或任何写入比变量,即与读取无关happens-before

请注意,该算法不允许“优化”任何内容。

法律处决 示例

现在,让我们将算法应用于我们的示例以查找合法执行。
(注意:为简单起见,我们将省略诸如操作系统意外和终止程序之类的情况)Error

主线程没有共享变量的读取,因此它的行为就像单线程程序一样。
它的作用:

new Thread(){...}.start();
Thread.sleep(1000);
running = false;
running2 = false;

第二个线程是一个具有 2 个读取的循环。
因此,我们得到一系列操作:

read(running == true)
read(running2 == ?)
read(running == true)
read(running2 == ?)
...
read(running == false)

一旦读取返回,序列就会结束。runningfalse

根据 JLS,读取可以返回哪些值?
让我们首先注意它是易失性的,这意味着读取和写入它以全局顺序发生(它称为同步顺序),并且对该顺序的所有线程都可见。
所以:running2

  • 在写入对第二个线程可见之前:running2 = false
    • running2 == true
      这是初始写入(唯一可见的写入)。

    • running == true
      对于每次阅读:running == falserunning

      • 初始写入 () 读取running = truehappens-before
      • 写入与读取无关running = falsehappens-before

      因此,每次读取都可以随机返回两个写入中的任何一个。running == ?

      Main Thread              Thread2                 
      
      [...]                    [...]                   
        ↓ (happens-before)       ↓ (happens-before)    
      running = false;         running2 == true;       
        ↓ (happens-before)       ↓ (happens-before)    
      running2 = false;        running == true | false 
      
  • 在写入对第二个线程可见之后:running2 = false
    • running2 == false
      这是最新的可见写入。
    • running == false
      因为
      =>
      =>传递 因此,当写入对第二个线程可见时,第二个线程将完成。running2 == falserunning2 = falsehappens-beforerunning2 == falserunning = falsehappens-beforerunning == false
      Main Thread              Thread2              
      
      [...]                                         
        ↓ (happens-before)                          
      running = false;                              
        ↓ (happens-before)     [...]                
      running2 = false;          ↓ (happens-before) 
        └--------------------> running2 == false;   
            (happens-before)     ↓ (happens-before) 
                               running == false;    
      
      running2 = false

总而言之,第二个线程的所有合法执行:

  1. 可以从以下序列开始:
read(running == true)
read(running2 == true)
[... repeat the fragment above ...]
  1. 结尾为:
  • 或者:当线程看到然后保证看到时,就是这种情况。
    ...
    read(running2 == false)
    read(running == false)
    
    running2 = falserunning = false
  • 或 with:当线程看不到 时,就是这种情况,但看到 .
    ...
    read(running == false)
    
    running2 = falserunning = false
  • 或永无止境。
    当线程既看不到也看不到 时,就是这种情况。running2 = falserunning = false

如果您可以“优化”一个易失性读取,并且执行的结果将与上述一些合法执行的结果相同,那么这种优化是合法的。


关于评论中提到的AdvancedJMM_15_VolatilesAreNotFences测试。

在我看来,这个测试并没有证明,如果不使用该值,编译器可以删除易失性负载/存储。

国际海事组织它表明比弱。
基本上,这是Roach Motel优化的演示:volatileUNSAFE.storeFence() + UNSAFE.loadFence()
write can be reordered before release
read can be reordered after acquire
read/write can be moved inside acquire+release blocks

AdvancedJMM_14_SynchronizedAreNotFences是不同的,因为它使用 - 没有共享变量,也没有关系。synchronized (new Object()) {}happens-before


附言:@pveentjer评论中提到

JVM的非规范部分确实谈到了可见性;因此,更改应该在某个时候对其他线程可见。

有没有人有一个链接和报价来支持这一点?
我无法在任何地方找到它,但是,正如Peter Cordes所指出的那样,知道Java(甚至只有一些JVM)不允许对易失性写入的可见性进行无限延迟,那将是非常有用的。


答案 2

这是一个JVM错误,还是JLS允许它在不需要该值时删除易失性读取?

两者都不是。

根据 JLS,此执行是有效的。

第二个线程必须在读取 后不久完成。
但是,JLS 不保证一个线程中的写入在另一个线程中可见所需的时间。
因此,您的程序执行是有效的,因为它对应于写入需要很长时间才能传播到另一个线程的情况。running2 == truerunning2 = false

顺便说一句,在我的java版本(OpenJDK 64位服务器VM(构建17.0.3 + 7-suse-1.4-x8664,混合模式))上,程序在大约1秒内完成。
这也是一个有效的执行 — 这与写入更快地传播到第二个线程的情况相对应。running2 = false

PS你提到了“内存屏障”。
对于内存屏障,通常存在一些最大时间,之后可以保证传播到其他线程。
但是JLS在内存屏障方面不起作用,不必使用它们,实际上只保证这一点

实现可以自由地生成它喜欢的任何代码,只要程序的所有结果执行都会生成可以由内存模型预测的结果。

PSS 如果你想看到JVM为你的程序生成的真正的汇编代码,你可以使用+PrintAssembly