如何演示 Java 指令重排序问题?

How to demonstrate Java instruction reordering problems?

使用Java指令重新排序代码的执行顺序在编译时或运行时由JVM更改,可能导致不相关的语句乱序执行。

编辑: [指令重新排序会产生违反直觉的结果。许多 CPU 架构可以重新排序机器指令的内存交互,即使编译器没有更改指令顺序,也会导致类似的意外结果。因此,术语内存重新排序可能比指令重新排序更合适。]

所以我的问题是:

有人可以提供一个示例 Java program/snippet,可靠地显示指令重新排序问题,这不是由其他同步问题引起的(例如 caching/visibility 或非原子r/w,就像我在 中尝试进行此类演示失败一样)

强调一下,我不是在寻找理论重新排序问题的例子。我正在寻找的是一种通过查看 运行ning 程序的不正确或意外结果来实际演示它们的方法。

除了错误行为示例,仅显示在简单程序的汇编中发生的实际重新排序也可能很好。

这展示了某些赋值的重新排序,在 1M 次迭代中通常有几行打印出来。

public class App {

    public static void main(String[] args) {

        for (int i = 0; i < 1000_000; i++) {
            final State state = new State();

            // a = 0, b = 0, c = 0

            // Write values
            new Thread(() -> {
                state.a = 1;
                // a = 1, b = 0, c = 0
                state.b = 1;
                // a = 1, b = 1, c = 0
                state.c = state.a + 1;
                // a = 1, b = 1, c = 2
            }).start();

            // Read values - this should never happen, right?
            new Thread(() -> {
                // copy in reverse order so if we see some invalid state we know this is caused by reordering and not by a race condition in reads/writes
                // we don't know if the reordered statements are the writes or reads (we will se it is writes later)
                int tmpC = state.c;
                int tmpB = state.b;
                int tmpA = state.a;

                if (tmpB == 1 && tmpA == 0) {
                    System.out.println("Hey wtf!! b == 1 && a == 0");
                }
                if (tmpC == 2 && tmpB == 0) {
                    System.out.println("Hey wtf!! c == 2 && b == 0");
                }
                if (tmpC == 2 && tmpA == 0) {
                    System.out.println("Hey wtf!! c == 2 && a == 0");
                }
            }).start();

        }
        System.out.println("done");
    }

    static class State {
        int a = 0;
        int b = 0;
        int c = 0;
    }

}

打印写入 lambda 的程序集得到这个输出(以及其他..)

                                                ; {metadata('com/example/App$$Lambda')}
  0x00007f73b51a0100: 752b                jne       7f73b51a012dh
                                                ;*invokeinterface run
                                                ; - java.lang.Thread::run@11 (line 748)

  0x00007f73b51a0102: 458b530c            mov       r10d,dword ptr [r11+0ch]
                                                ;*getfield arg
                                                ; - com.example.App$$Lambda/1831932724::run@1
                                                ; - java.lang.Thread::run@-1 (line 747)

  0x00007f73b51a0106: 43c744d41402000000  mov       dword ptr [r12+r10*8+14h],2h
                                                ;*putfield c
                                                ; - com.example.App::lambda$main[=11=]@17 (line 18)
                                                ; - com.example.App$$Lambda/1831932724::run@4
                                                ; - java.lang.Thread::run@-1 (line 747)
                                                ; implicit exception: dispatches to 0x00007f73b51a01b5
  0x00007f73b51a010f: 43c744d40c01000000  mov       dword ptr [r12+r10*8+0ch],1h
                                                ;*putfield a
                                                ; - com.example.App::lambda$main[=11=]@2 (line 14)
                                                ; - com.example.App$$Lambda/1831932724::run@4
                                                ; - java.lang.Thread::run@-1 (line 747)

  0x00007f73b51a0118: 43c744d41001000000  mov       dword ptr [r12+r10*8+10h],1h
                                                ;*synchronization entry
                                                ; - java.lang.Thread::run@-1 (line 747)

  0x00007f73b51a0121: 4883c420            add       rsp,20h
  0x00007f73b51a0125: 5d                  pop       rbp
  0x00007f73b51a0126: 8505d41eb016        test      dword ptr [7f73cbca2000h],eax
                                                ;   {poll_return}
  0x00007f73b51a012c: c3                  ret
  0x00007f73b51a012d: 4181f885f900f8      cmp       r8d,0f800f985h

我不确定为什么最后一个 mov dword ptr [r12+r10*8+10h],1h 没有用 putfield b 和第 16 行标记,但是你可以看到 b 和 c 的交换赋值(c 紧跟在 a 之后)。

编辑: 因为写入按 a、b、c 的顺序发生,而读取按相反的顺序 c、b、a 发生,除非重新排序写入(或读取),否则你永远不会看到无效状态。

由单个 cpu(或核心)执行的写入在所有处理器中以相同的顺序可见,例如参见this answer, which points to Intel System Programming Guide 第 3 卷第 8.2.2 节。

Writes by a single processor are observed in the same order by all processors.

测试

我写了一个 JUnit 5 测试来检查两个线程终止后是否发生了指令重新排序。

  • 如果没有发生指令重新排序,则测试必须通过。
  • 如果发生指令重新排序,测试必须失败。

public class InstructionReorderingTest {

    static int x, y, a, b;

    @org.junit.jupiter.api.BeforeEach
    public void init() {
        x = y = a = b = 0;
    }

    @org.junit.jupiter.api.Test
    public void test() throws InterruptedException {
        Thread threadA = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread threadB = new Thread(() -> {
            b = 1;
            y = a;
        });

        threadA.start();
        threadB.start();

        threadA.join();
        threadB.join();

        org.junit.jupiter.api.Assertions.assertFalse(x == 0 && y == 0);
    }

}

结果

我运行测试了until it fails几次。结果如下:

InstructionReorderingTest.test [*] (12s 222ms): 29144 total, 1 failed, 29143 passed.
InstructionReorderingTest.test [*] (26s 678ms): 69513 total, 1 failed, 69512 passed.
InstructionReorderingTest.test [*] (12s 161ms): 27878 total, 1 failed, 27877 passed.

说明

我们期望的结果是

  • x = 0, y = 1threadAthreadB 开始之前运行完成。
  • x = 1, y = 0threadBthreadA 开始之前运行完成。
  • x = 1, y = 1:它们的指令是交错的。

没有人能预料到x = 0, y = 0,这可能会像测试结果显示的那样发生。

The actions in each thread have no dataflow dependence on each other, and accordingly can be executed out of order. (Even if they are executed in order, the timing by which caches are flushed to main memory can make it appear, from the perspective of threadB, that the assignments in threadA occurred in the opposite order.)

Java Concurrency in Practice, Brian Goetz

对于单线程执行,重新排序根本不是问题,因为 Java 内存模型 (JMM)(保证与写入相关的任何读取操作都是完全有序的)并且不会导致意外结果。

对于并发执行,规则完全不同,事情变得更难理解(即使提供简单的示例也会引发更多问题)。但即便如此,JMM 也完全描述了所有极端情况,因此,意外结果也被禁止。一般情况下,如果所有障碍物都放置正确,则禁止。

为了更好地理解重新排序,我强烈推荐 this 包含大量示例的主题。