【问题标题】:Why reading a volatile and writing to a field member is not scalable in Java?为什么在 Java 中读取 volatile 并写入字段成员是不可扩展的?
【发布时间】:2025-09-23 21:45:02
【问题描述】:

观察以下用Java编写的程序(完整的可运行版本,但程序的重要部分在sn-p下面一点):

import java.util.ArrayList;



/** A not easy to explain benchmark.
 */
class MultiVolatileJavaExperiment {

    public static void main(String[] args) {
        (new MultiVolatileJavaExperiment()).mainMethod(args);
    }

    int size = Integer.parseInt(System.getProperty("size"));
    int par = Integer.parseInt(System.getProperty("par"));

    public void mainMethod(String[] args) {
        int times = 0;
        if (args.length == 0) times = 1;
        else times = Integer.parseInt(args[0]);
        ArrayList < Long > measurements = new ArrayList < Long > ();

        for (int i = 0; i < times; i++) {
            long start = System.currentTimeMillis();
            run();
            long end = System.currentTimeMillis();

            long time = (end - start);
            System.out.println(i + ") Running time: " + time + " ms");
            measurements.add(time);
        }

        System.out.println(">>>");
        System.out.println(">>> All running times: " + measurements);
        System.out.println(">>>");
    }

    public void run() {
        int sz = size / par;
        ArrayList < Thread > threads = new ArrayList < Thread > ();

        for (int i = 0; i < par; i++) {
            threads.add(new Reader(sz));
            threads.get(i).start();
        }
        for (int i = 0; i < par; i++) {
            try {
                threads.get(i).join();
            } catch (Exception e) {}
        }
    }

    final class Foo {
        int x = 0;
    }

    final class Reader extends Thread {
        volatile Foo vfoo = new Foo();
        Foo bar = null;
        int sz;

        public Reader(int _sz) {
            sz = _sz;
        }

        public void run() {
            int i = 0;
            while (i < sz) {
                vfoo.x = 1;
                // with the following line commented
                // the scalability is almost linear
                bar = vfoo; // <- makes benchmark 2x slower for 2 processors - why?
                i++;
            }
        }
    }

}

说明:程序其实很简单。它从系统属性加载整数sizepar(使用-D 标志传递给jvm)——这些是输入长度和稍后使用的线程数。然后它解析第一个命令行参数,该参数表示程序重复多少次(我们希望确保 JIT 已完成其工作并获得更可靠的测量结果)。

每次重复都会调用run 方法。这个方法简单地启动par 线程,每个线程都会执行size / par 迭代的循环。线程主体在Reader 类中定义。循环的每次重复都会读取一个 volatile 成员 vfoo 并将 1 分配给它的公共字段。之后,再次读取vfoo 并将其分配给一个非易失性 字段bar

注意程序大部分时间是如何执行循环体的,因此线程中的run 是本次基准测试的重点:

    final class Reader extends Thread {
        volatile Foo vfoo = new Foo();
        Foo bar = null;
        int sz;

        public Reader(int _sz) {
            sz = _sz;
        }

        public void run() {
            int i = 0;
            while (i < sz) {
                vfoo.x = 1;
                // with the following line commented
                // the scalability is almost linear
                bar = vfoo; // <- makes benchmark 2x slower for 2 processors - why?
                i++;
            }
        }
    }

观察:在

上运行 java -Xmx512m -Xms512m -server -Dsize=500000000 -Dpar=1 MultiVolatileJavaExperiment 10
Ubuntu Server 10.04.3 LTS
8 core Intel(R) Xeon(R) CPU  X5355  @2.66GHz
~20GB ram
java version "1.6.0_26"
Java(TM) SE Runtime Environment (build 1.6.0_26-b03)
Java HotSpot(TM) 64-Bit Server VM (build 20.1-b02, mixed mode)

我得到以下时间:

>>> All running times: [821, 750, 1011, 750, 758, 755, 1219, 751, 751, 1012]

现在,设置-Dpar=2,我得到:

>>> All running times: [1618, 380, 1476, 1245, 1390, 1391, 1445, 1393, 1511, 1508]

显然,由于某种原因,这无法扩展 - 我预计第二个输出的速度会快一倍(尽管它似乎确实处于早期迭代之一 - 380ms)。

有趣的是,注释掉 bar = vfoo 行(这甚至不应该是一个 volatile 写入)会产生以下时间 -Dpar 设置为 1,2,4,8

>>> All running times: [762, 563, 563, 563, 563, 563, 570, 566, 563, 563]
>>> All running times: [387, 287, 285, 284, 283, 281, 282, 282, 281, 282]
>>> All running times: [204, 146, 143, 142, 141, 141, 141, 141, 141, 141]
>>> All running times: [120, 78, 74, 74, 81, 75, 73, 73, 72, 71]

它可以完美扩展。

分析:首先,这里没有发生垃圾回收周期(我也添加了-verbose:gc 来检查这一点)。

我在 iMac 上得到了类似的结果。

每个线程都在写入自己的字段,属于不同线程的不同 Foo 对象实例似乎不会出现在相同的缓存行中 - 将更多成员添加到 Foo 以增加其大小不会更改测量值。每个线程对象实例都有足够多的字段来填满 L1 缓存行。所以这可能不是内存问题。

我的下一个想法是 JIT 可能会做一些奇怪的事情,因为早期的迭代通常 do 在未注释的版本中按预期进行扩展,所以我通过打印程序集来检查这一点(参见this post on how to do that)。

java -Xmx512m -Xms512m -server -XX:CompileCommand=print,*Reader.run MultiVolatileJavaExperiment -Dsize=500000000 -Dpar=1 10

我在 Reader 中得到了 Jitted 方法 run 的 2 个版本的这 2 个输出。注释(适当可扩展)版本:

[Verified Entry Point]
  0xf36c9fac: mov    %eax,-0x3000(%esp)
  0xf36c9fb3: push   %ebp
  0xf36c9fb4: sub    $0x8,%esp
  0xf36c9fba: mov    0x68(%ecx),%ebx
  0xf36c9fbd: test   %ebx,%ebx
  0xf36c9fbf: jle    0xf36c9fec
  0xf36c9fc1: xor    %ebx,%ebx
  0xf36c9fc3: nopw   0x0(%eax,%eax,1)
  0xf36c9fcc: xchg   %ax,%ax
  0xf36c9fd0: mov    0x6c(%ecx),%ebp
  0xf36c9fd3: test   %ebp,%ebp
  0xf36c9fd5: je     0xf36c9ff7
  0xf36c9fd7: movl   $0x1,0x8(%ebp)

---------------------------------------------

  0xf36c9fde: mov    0x68(%ecx),%ebp
  0xf36c9fe1: inc    %ebx               ; OopMap{ecx=Oop off=66}
                                        ;*goto
                                        ; - org.scalapool.bench.MultiVolatileJavaExperiment$Reader::run@21 (line 83)

---------------------------------------------

  0xf36c9fe2: test   %edi,0xf7725000    ;   {poll}
  0xf36c9fe8: cmp    %ebp,%ebx
  0xf36c9fea: jl     0xf36c9fd0
  0xf36c9fec: add    $0x8,%esp
  0xf36c9fef: pop    %ebp
  0xf36c9ff0: test   %eax,0xf7725000    ;   {poll_return}
  0xf36c9ff6: ret    
  0xf36c9ff7: mov    $0xfffffff6,%ecx
  0xf36c9ffc: xchg   %ax,%ax
  0xf36c9fff: call   0xf36a56a0         ; OopMap{off=100}
                                        ;*putfield x
                                        ; - org.scalapool.bench.MultiVolatileJavaExperiment$Reader::run@15 (line 79)
                                        ;   {runtime_call}
  0xf36ca004: call   0xf6f877a0         ;   {runtime_call}

未注释的bar = vfoo(不可扩展,速度较慢)版本:

[Verified Entry Point]
  0xf3771aac: mov    %eax,-0x3000(%esp)
  0xf3771ab3: push   %ebp
  0xf3771ab4: sub    $0x8,%esp
  0xf3771aba: mov    0x68(%ecx),%ebx
  0xf3771abd: test   %ebx,%ebx
  0xf3771abf: jle    0xf3771afe
  0xf3771ac1: xor    %ebx,%ebx
  0xf3771ac3: nopw   0x0(%eax,%eax,1)
  0xf3771acc: xchg   %ax,%ax
  0xf3771ad0: mov    0x6c(%ecx),%ebp
  0xf3771ad3: test   %ebp,%ebp
  0xf3771ad5: je     0xf3771b09
  0xf3771ad7: movl   $0x1,0x8(%ebp)

-------------------------------------------------

  0xf3771ade: mov    0x6c(%ecx),%ebp
  0xf3771ae1: mov    %ebp,0x70(%ecx)
  0xf3771ae4: mov    0x68(%ecx),%edi
  0xf3771ae7: inc    %ebx
  0xf3771ae8: mov    %ecx,%eax
  0xf3771aea: shr    $0x9,%eax
  0xf3771aed: movb   $0x0,-0x3113c300(%eax)  ; OopMap{ecx=Oop off=84}
                                        ;*goto
                                        ; - org.scalapool.bench.MultiVolatileJavaExperiment$Reader::run@29 (line 83)

-----------------------------------------------

  0xf3771af4: test   %edi,0xf77ce000    ;   {poll}
  0xf3771afa: cmp    %edi,%ebx
  0xf3771afc: jl     0xf3771ad0
  0xf3771afe: add    $0x8,%esp
  0xf3771b01: pop    %ebp
  0xf3771b02: test   %eax,0xf77ce000    ;   {poll_return}
  0xf3771b08: ret    
  0xf3771b09: mov    $0xfffffff6,%ecx
  0xf3771b0e: nop    
  0xf3771b0f: call   0xf374e6a0         ; OopMap{off=116}
                                        ;*putfield x
                                        ; - org.scalapool.bench.MultiVolatileJavaExperiment$Reader::run@15 (line 79)
                                        ;   {runtime_call}
  0xf3771b14: call   0xf70307a0         ;   {runtime_call}

两个版本的区别在---------之内。我希望在程序集中找到可能导致性能问题的同步指令 - 虽然很少有额外的 shiftmovinc 指令可能会影响绝对性能数字,但我看不出它们会如何影响可伸缩性。

所以,我怀疑这是与存储到类中的字段相关的某种内存问题。另一方面,我也倾向于相信 JIT 做了一些有趣的事情,因为在一次迭代中,测量的时间的两倍,应该是。

谁能解释这里发生了什么? 请准确无误,并附上支持您声明的参考资料。

谢谢!

编辑:

这是快速(可扩展)版本的字节码:

public void run();
  LineNumberTable: 
   line 77: 0
   line 78: 2
   line 79: 10
   line 83: 18
   line 85: 24



  Code:
   Stack=2, Locals=2, Args_size=1
   0:   iconst_0
   1:   istore_1
   2:   iload_1
   3:   aload_0
   4:   getfield    #7; //Field sz:I
   7:   if_icmpge   24
   10:  aload_0
   11:  getfield    #5; //Field vfoo:Lorg/scalapool/bench/MultiVolatileJavaExperiment$Foo;
   14:  iconst_1
   15:  putfield    #8; //Field org/scalapool/bench/MultiVolatileJavaExperiment$Foo.x:I
   18:  iinc    1, 1
   21:  goto    2
   24:  return
  LineNumberTable: 
   line 77: 0
   line 78: 2
   line 79: 10
   line 83: 18
   line 85: 24

  StackMapTable: number_of_entries = 2
   frame_type = 252 /* append */
     offset_delta = 2
     locals = [ int ]
   frame_type = 21 /* same */

带有bar = vfoo 的慢速(不可扩展)版本:

public void run();
  LineNumberTable: 
   line 77: 0
   line 78: 2
   line 79: 10
   line 82: 18
   line 83: 26
   line 85: 32



  Code:
   Stack=2, Locals=2, Args_size=1
   0:   iconst_0
   1:   istore_1
   2:   iload_1
   3:   aload_0
   4:   getfield    #7; //Field sz:I
   7:   if_icmpge   32
   10:  aload_0
   11:  getfield    #5; //Field vfoo:Lorg/scalapool/bench/MultiVolatileJavaExperiment$Foo;
   14:  iconst_1
   15:  putfield    #8; //Field org/scalapool/bench/MultiVolatileJavaExperiment$Foo.x:I
   18:  aload_0
   19:  aload_0
   20:  getfield    #5; //Field vfoo:Lorg/scalapool/bench/MultiVolatileJavaExperiment$Foo;
   23:  putfield    #6; //Field bar:Lorg/scalapool/bench/MultiVolatileJavaExperiment$Foo;
   26:  iinc    1, 1
   29:  goto    2
   32:  return
  LineNumberTable: 
   line 77: 0
   line 78: 2
   line 79: 10
   line 82: 18
   line 83: 26
   line 85: 32

  StackMapTable: number_of_entries = 2
   frame_type = 252 /* append */
     offset_delta = 2
     locals = [ int ]
   frame_type = 29 /* same */

我对此进行的试验越多,在我看来,这与 volatile 完全无关——它与写入对象字段有关。我的直觉是,这在某种程度上是一个内存争用问题——缓存和错误共享,尽管根本没有明确的同步。

编辑 2:

有趣的是,像这样改变程序:

final class Holder {
    public Foo bar = null;
}

final class Reader extends Thread {
    volatile Foo vfoo = new Foo();
    Holder holder = null;
    int sz;

    public Reader(int _sz) {
        sz = _sz;
    }

    public void run() {
        int i = 0;
        holder = new Holder();
        while (i < sz) {
            vfoo.x = 1;
            holder.bar = vfoo;
            i++;
        }
    }
}

解决了缩放问题。显然,上面的Holder 对象是在线程启动后创建的,并且可能分配在不同的内存段中,然后同时对其进行修改,而不是修改线程对象中的字段bar,即以某种方式在不同线程实例之间的内存中“关闭”。

【问题讨论】:

  • bar = vfoo 很慢,因为它是易失性读取。使用非易失性 vfoo 的时间是什么时候(而不是取消任务)?
  • 1) vfoo.x = 1 也是一个 volatile 读取,但它并不慢,而且扩展性很好。 2) 当vfoo 是非易失性时,JIT 会优化循环,除非您在vfoo.x = 1 之前的循环内添加if (bar != null) 检查以抵消其影响。如果您这样做 - 使 vfoo 成为非易失性并添加此检查,同样的可伸缩性问题仍然存在。
  • @axel22:我不明白vfoo.x = 1 是如何进行易失性读取的,您只是在写入非易失性字段。 bar = vfoo 改为读取 volatile 引用。
  • 另请注意,还有另一个区别:0xf36c9ffc: xchg %ax,%ax0xf3771b0e: nop。在 x86 中,XCHG 表示 LOCK
  • @ninjalj: 1) vfoo.x = 1 首先对位置vfoo 进行易失性读取,然后对位置vfoo[x] 进行写入。我觉得效果应该和Foo tmp = vfoo; tmp.x = 1一样。 2)我对x86程序集不太了解,但我认为XCHG应该以LOCK为前缀被锁定(原子)。你能给我指一个教程或参考吗?

标签: java assembly concurrency jvm


【解决方案1】:

这就是我认为正在发生的事情(请记住,我不熟悉 HotSpot):

0xf36c9fd0: mov    0x6c(%ecx),%ebp    ; vfoo
0xf36c9fd3: test   %ebp,%ebp          ; vfoo is null?
0xf36c9fd5: je     0xf36c9ff7         ;   throw NullPointerException (I guess)
0xf36c9fd7: movl   $0x1,0x8(%ebp)     ; vfoo.x = 1
0xf36c9fde: mov    0x68(%ecx),%ebp    ; sz
0xf36c9fe1: inc    %ebx               ; i++
0xf36c9fe2: test   %edi,0xf7725000    ; safepoint on end of loop
0xf36c9fe8: cmp    %ebp,%ebx          ; i < sz?
0xf36c9fea: jl     0xf36c9fd0


0xf3771ad0: mov    0x6c(%ecx),%ebp          ; vfoo
0xf3771ad3: test   %ebp,%ebp                ; vfoo is null?
0xf3771ad5: je     0xf3771b09               ;   throw NullPointerException (I guess)
0xf3771ad7: movl   $0x1,0x8(%ebp)           ; vfoo.x = 1
0xf3771ade: mov    0x6c(%ecx),%ebp          ; \
0xf3771ae1: mov    %ebp,0x70(%ecx)          ; / bar = vfoo
0xf3771ae4: mov    0x68(%ecx),%edi          ; sz
0xf3771ae7: inc    %ebx                     ; i++
0xf3771ae8: mov    %ecx,%eax                ; 
0xf3771aea: shr    $0x9,%eax                ; ??? \ Probably replaced later
0xf3771aed: movb   $0x0,-0x3113c300(%eax)   ; ??? / by some barrier code?
0xf3771af4: test   %edi,0xf77ce000          ; safepoint
0xf3771afa: cmp    %edi,%ebx                ; i < sz ?
0xf3771afc: jl     0xf3771ad0               ;

我认为上面的代码代表障碍的原因是,当采用 NullPointerException 时,可伸缩版本有一个 XCHG,它充当屏障,而不可伸缩版本在那里有一个 NOP。

理由是在vfoo 的初始加载和加入线程之间需要先发生排序。在 volatile 情况下,障碍将在循环内部,因此它不需要在其他地方。我不明白为什么XCHG 不在循环内使用。也许运行时检测 MFENCE 支持?

【讨论】:

  • 显然,shr/movb 指令对正是屏障代码——它设置了垃圾收集器使用的卡脏字节。
【解决方案2】:

让我们尝试让 JVM 表现得更加“一致”。 JIT 编译器真的抛弃了测试运行的比较;所以让我们disable the JIT compiler 使用-Djava.compiler=NONE。这肯定会影响性能,但有助于消除 JIT 编译器优化的模糊性和影响。

垃圾收集引入了自己的一套复杂性。让我们通过使用-XX:+UseSerialGC 来使用serial garbage collector。让我们也禁用显式垃圾收集并打开一些日志记录以查看何时执行垃圾收集:-verbose:gc -XX:+DisableExplicitGC。最后,让我们使用-Xmx128m -Xms128m 分配足够的堆。

现在我们可以使用以下命令运行测试:

java -XX:+UseSerialGC -verbose:gc -XX:+DisableExplicitGC -Djava.compiler=NONE -Xmx128m -Xms128m -server -Dsize=50000000 -Dpar=1 MultiVolatileJavaExperiment 10

多次运行测试显示结果非常一致(我在 Ubuntu 10.04.3 LTS 上使用 Oracle Java 1.6.0_24-b07 和 Intel(R) Core(TM)2 Duo CPU P8700 @ 2.53GHz) ,平均大约 2050 毫秒。如果我注释掉 bar = vfoo 行,我的平均时间始终是大约 1280 毫秒。使用-Dpar=2 运行测试,bar = vfoo 的平均运行时间约为 1350 毫秒,评论的时间约为 1005 毫秒。

+=========+======+=========+
| Threads | With | Without |
+=========+======+=========+
|    1    | 2050 |  1280   |
+---------+------+---------+
|    2    | 1350 |  1005   |
+=========+======+=========+

现在让我们看一下代码,看看是否能找出多线程效率低下的任何原因。在Reader.run() 中,适当地使用this 限定变量将有助于明确哪些变量是本地变量:

int i = 0;
while (i < this.sz) {
    this.vfoo.x = 1;
    this.bar = this.vfoo;
    i++;
}

首先要注意的是while 循环包含四个通过this 引用的变量。这意味着代码正在访问类的运行时常量池并执行类型检查(通过getfield 字节码指令)。让我们更改代码以尝试消除对运行时常量池的访问,看看是否有任何好处。

final int mysz = this.sz;
int i = 0;
while (i < mysz) {
    this.vfoo.x = 1;
    this.bar = this.vfoo;
    i++;
}

在这里,我们使用本地mysz 变量来访问循环大小,并且只访问szthis 一次,用于初始化。用两个线程运行测试,平均大约 1295 毫秒;一个小好处,但仍然是一个。

查看while 循环,我们真的需要引用this.vfoo 两次吗?这两个易失性读取创建了虚拟机(以及底层硬件)需要管理的两个同步边缘。假设我们确实想要在 while 循环的开始有一个同步边,我们不需要两个,我们可以使用以下内容:

final int mysz = this.sz;
Foo myvfoo = null;
int i = 0;
while (i < mysz) {
    myvfoo = this.vfoo;
    myvfoo.x = 1;
    this.bar = myvfoo;
    i++;
}

这平均约为 1122 毫秒;还在好转。那this.bar 参考呢?由于我们正在谈论多线程,假设while 循环中的计算是我们希望从中获得多线程好处的,this.bar 是我们将结果传达给其他人的方式。在while 循环完成之前,我们真的不想设置this.bar

final int mysz = this.sz;
Foo myvfoo = null;
Foo mybar = null;
int i = 0;
while (i < mysz) {
    myvfoo = this.vfoo;
    myvfoo.x = 1;
    mybar = myvfoo;
    i++;
}
this.bar = mybar;

这给了我们大约 857 毫秒的平均时间。 while 循环中还有最后的 this.vfoo 引用。再次假设while 循环是我们希望多线程受益的地方,让我们将this.vfoo 移出while 循环。

final int mysz = this.sz;
final Foo myvfoo = this.vfoo;
Foo mybar = null;
int i = 0;
while (i < mysz) {
    myvfoo.x = 1;
    mybar = myvfoo;
    i++;
}
final Foo vfoocheck = this.vfoo;
if (vfoocheck != myvfoo) {
    System.out.println("vfoo changed from " + myvfoo + " to " + vfoocheck);
}
this.bar = mybar;

现在我们平均大约 502 毫秒;单线程测试平均大约 900 毫秒。

那么这告诉我们什么?通过从while 循环外推非局部变量引用,在单线程和双线程测试中都有显着的性能优势。 MultiVolatileJavaExperiment 的原始版本测量了 50,000,000 次访问 非本地 变量的成本,而最终版本测量了 50,000,000 次访问 本地 变量的成本。通过使用局部变量,您可以提高 Java 虚拟机和底层硬件更有效地管理线程缓存的可能性。

最后,让我们正常运行测试(注意,使用 500,000,000 循环大小而不是 50,000,000):

java -Xmx128m -Xms128m -server -Dsize=500000000 -Dpar=2 MultiVolatileJavaExperiment 10

原始版本平均约为 1100 毫秒,修改后的版本平均约为 10 毫秒。

【讨论】:

    【解决方案3】:

    您实际上并没有写入 volatile 字段,因此可以在每个线程中缓存 volatile 字段。

    使用 volatile 会阻止一些编译器优化,在微基准测试中,您可以看到较大的相对差异。

    在上面的示例中,注释掉的版本更长,因为它已展开循环以在一个实际循环中放置两次迭代。这几乎可以使性能翻倍。

    使用 volatile 时,您可以看到没有展开循环。

    顺便说一句:您可以删除示例中的大量代码以使其更易于阅读。 ;)

    【讨论】:

    • 谢谢,我已经提取了一点代码。但是:1)在每个线程中缓存 volatile 字段(在寄存器中)应该是可扩展性的资产,2)通过删除 volatile,问题仍然存在,如上述问题后的评论中所述,3)评论(可扩展)版本更短,而不是更长,4) 虽然不同的长度会影响性能(它们确实 - 在 1 个线程的情况下约为 50%),但我不明白它会如何影响可伸缩性。
    • 更短的代码更便于回答问题的人阅读。
    • @PeterLawrey:我没有看到循环展开。
    • 它不在代码中,CPU可以用预测的代码填充管道。如果您使用 volatile,它会停止管道。
    【解决方案4】:

    编辑:这个答案经不起测试。

    我现在无法对此进行测试(这台机器中没有多核 CPU),但这里有一个理论:Foo 实例可能不在相同的缓存行中,但 Reader 实例可能在。

    这意味着速度下降可以通过写入bar 来解释,而不是读取foo,因为写入bar 会使其他内核的缓存行无效并导致缓存之间的大量复制。注释掉对bar 的写入(这是循环中对Reader 字段的唯一写入)停止减速,这与这个解释一致。

    编辑:根据this article,对象的内存布局使得bar 引用将是Reader 对象布局中的最后一个字段。这意味着它很可能与堆上的下一个对象位于同一缓存行中。由于我不确定在堆上分配新对象的顺序,我在下面的评论中建议用引用填充两种“热”对象类型,这将有效地分离对象(至少,我希望它会,但这取决于相同类型的字段在内存中的排序方式)。

    【讨论】:

    • 我会支持这个理论,但我最初认为Reader,作为Thread,有很多字段。不过,为了检查这一点,我刚刚添加了 16 个 32 位整数字段。测量值保持完全相同。因此,假设对象实例占用连续的内存区域,这不应该是原因。这是一篇关于 JVM 上的内存布局的博客文章支持这个假设:codeinstructions.com/2008/12/java-objects-memory-structure.html
    • 您可以尝试用一些空引用填充 Foo 和 Reader(根据您的系统计算 4 或 8 个字节)吗?我有一个非常基于假设的古怪理论,但它可能会奏效。
    • 我用Object aX;X 填充了这两个类,范围从0f(总共16 个参考)。结果没有改变。
    【解决方案5】:

    简短:显然,由于 GC 的卡标记,答案是虚假共享。

    这个问题给出了更广泛的解释:

    Array allocation and access on the Java Virtual Machine and memory contention

    【讨论】: