代码之家  ›  专栏  ›  技术社区  ›  Community wiki

为什么在Java中读取volatile和写入字段成员是不可伸缩的?

  •  32
  • Community wiki  · 技术社区  · 3 年前

    观察以下用Java编写的程序(下面是完整的可运行版本,但该程序的重要部分在下面的片段中):

    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++;
                }
            }
        }
    
    }
    

    解释 :这个程序其实很简单。它加载整数 size 和 par 从系统属性(通过 -D flag)-这些是输入长度和以后要使用的线程数。然后,它解析第一个命令行参数,该参数说明重复程序的时间(我们希望确保JIT已经完成了它的工作,并有更可靠的测量结果)。

    这个 run 方法在每次重复中调用。这个方法只是简单地启动 标准杆数 线程,每个线程将使用 size / par 迭代。螺纹体在 Reader 班循环的每次重复都读取一个易失性成员 vfoo 和分配 1 到其公共领域。之后, 虚拟操作系统 被再次读取并分配给 非挥发性的 领域 bar 。

    请注意程序大部分时间是如何执行循环体的,因此 跑 在线程中是这个基准测试的焦点:

        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 (这甚至不应该是一个易失性写入),为 -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公司 增大其尺寸不会改变测量值。每个线程对象实例都有足够多的字段来填充一级缓存行。所以这可能不是内存问题。

    我的下一个想法是 JIT 可能在做一些奇怪的事情,因为早期的迭代通常 做 在未注释的版本中按预期进行缩放,因此我通过打印程序集(请参见 this post on how to do that )。

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

    我得到了Jitted方法的两个版本的这两个输出 跑 在里面 读者 。注释的(可适当扩展的)版本:

    [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}
    

    两个版本的差异在 --------- 。我希望在程序集中找到可能导致性能问题的同步指令,而很少有额外的指令 shift , mov 和 inc 指令可能会影响绝对性能数字,我看不出它们会如何影响可伸缩性。

    所以,我怀疑这是某种内存问题,与存储到类中的字段有关。另一方面,我也倾向于相信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 */
    

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

    编辑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 上面的对象是在线程启动后创建的,并且可能被分配到不同的内存段中,然后该内存段被同时修改,而不是修改字段 酒吧 在线程对象中,它在不同线程实例之间的内存中以某种方式“接近”。

    5 回复  |  直到 14 年前
        1
  •  3
  •   ninjalj    14 年前

    这就是我认为正在发生的事情(请记住,我不熟悉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 并连接线。在不稳定的情况下,屏障将在环路内部,因此不需要在其他地方。我不明白的是为什么 XCHG公司 未在循环中使用。也许是MFENCE支持的运行时检测?

        2
  •  3
  •   Go Dan    14 年前

    让我们试着让JVM的行为更加“一致”。JIT编译器实际上是在丢弃测试运行的比较;所以让我们 disable the JIT compiler 通过使用 -Djava.compiler=NONE 。这肯定会带来性能打击,但有助于消除JIT编译器优化的模糊性和影响。

    垃圾回收引入了自己的一系列复杂性。让我们使用 serial garbage collector 通过使用 -XX:+UseSerialGC 。让我们还禁用显式垃圾收集并打开一些日志记录,以查看何时执行垃圾收集: -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 平均约1350毫秒的结果 bar=vfoo 大约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 循环包含通过引用的四个变量 这 这意味着代码正在访问类的运行时常量池并执行类型检查(通过 getfield 字节码指令)。让我们更改代码,尝试消除对运行时常量池的访问,看看是否有任何好处。

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

    在这里,我们使用本地 mysz 变量以访问循环大小并仅访问 sz 通过 这 一次,用于初始化。使用两个线程运行测试,平均时间约为1295毫秒;这是一个小小的好处,但却是一个。

    看着 虽然 循环,我们真的需要参考吗 this.vfoo 两次这两个易失性读取创建了虚拟机(以及底层硬件)需要管理的两个同步边缘。假设我们确实希望在 虽然 循环,我们不需要两个,我们可以使用以下内容:

    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 参考由于我们谈论的是多线程,所以我们假设 虽然 循环是我们希望从中获得多线程的好处 这个.bar 就是我们如何将我们的结果传达给他人。我们真的不想设置 这个.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毫秒的平均时间。决赛还在 这个.vfoo 中的引用 虽然 环再次假设 虽然 循环是我们想要多线程的好处,让我们移动它 这个.vfoo 中的 虽然 环

    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毫秒。

    那么这告诉我们什么呢?通过从 虽然 循环中,在单线程和双线程测试中都有显著的性能优势。的原始版本 MultiVolatileJavaExperiment 正在衡量访问成本 非本地的 变量50000000次,而最终版本是衡量访问成本 地方的 变量50000000次。通过使用局部变量,可以增加Java虚拟机和底层硬件更有效地管理线程缓存的可能性。

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

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

    原始版本的平均时间约为1100毫秒,而修改版本的平均值约为10毫秒。

        3
  •  2
  •   Peter Lawrey    14 年前

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

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

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

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

    BTW:您可以删除示例中的许多代码,使其更易于阅读。)

        4
  •  1
  •   Medo42    14 年前

    编辑:这个答案经不起检验。

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

    这意味着经济放缓的原因可能是 bar ,而不是 foo ,因为写信给 酒吧 将使另一个核心的缓存行无效,并导致缓存之间的大量复制。对写入对象进行注释 酒吧 (这是对字段的唯一写入 读者 在循环中)停止减速,这与这种解释一致。

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

        5
  •  1
  •   axel22    9 年前

    简而言之:显然,答案是由于GC的卡片标记而导致的错误共享。

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

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