Java中for循环的线程安全优化有哪些?

32

我有一段代码,可以在两个线程中更改计数器。它不是线程安全的,因为我在代码中没有放置任何原子变量或锁。如果该代码仅运行一次,则会产生我预期的正确结果,但是我想运行它多次,所以将代码放入了一个for循环中。问题是只有第一个或前两个循环将生成我期望的结果。对于其余的循环,结果始终为0,这似乎是线程安全的。Java虚拟机中是否有任何内部运算符导致这种情况?

我尝试更改循环次数,第一个或前两个总是符合我的期望,但其他的不管循环多少次,结果都是0。

计数器:

private static class Counter {
    private int count;

    public void increase() {
        count++;
    }

    public void decrease() {
        count--;
    }

    public int getCount() {
        return count;
    }
}

人:

// This is just a thread to increase and decrease the counter for many times.
private static class Person extends Thread {
    private Counter c;

    public Person(Counter c) {
        this.c = c;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            c.increase();
            c.decrease();
        }
    }
}

主方法:

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 10; i++) {
        Counter c = new Counter();
        Person p1 = new Person(c);
        Person p2 = new Person(c);
        p1.start();
        p2.start();
        p1.join();
        p2.join();
        System.out.println("run "+i+": "+c.getCount());        
   }
}

输出:

run 0: 243
run 1: 12
run 2: 0
run 3: 0
run 4: 0
run 5: 0
run 6: 0
run 7: 0
run 8: 0
run 9: 0

我不知道为什么其余的结果总是0。但我猜测这与JVM的优化有关。当一些循环完成后,JVM会优化代码,省略其余的循环并始终给出0作为答案,这种说法正确吗?


3
OP告诉你,他们在前两次得到了非零结果。这很有道理,因为“增加”和“减少”不是原子操作,因此存在一个线程撤销另一个线程所做增加的可能性。例如,T1和T2都看到值为2,然后T1增加到3并保存,然后T2增加到3并保存。由T1做的增加被撤销了。 - RealSkeptic
7
我认为 JIT 的优化会将计数变量优化为本地 CPU 寄存器,这意味着在不同核心上运行的线程将看到自己的副本而不会看到其他线程的更改。将其更改为 volatile 可能很有趣。 - RealSkeptic
4
如果没有使用volatile关键字,编译器几乎可以为所欲为。你的代码中没有任何指示需要将Counter的更改可见于其他线程的内容。因此,我猜测JIT完全优化掉了这段代码。但这也不能保证是正确的行为。所以我不知道在这里有什么有用的答案。未定义的行为会导致奇怪的结果。 - Thilo
1
在您的情况下,结果是未定义的。请注意,“未定义”并不意味着“随机”。实际上,对于未定义的行为返回正确的结果是完全可以的。问题在于,返回不正确的结果也是完全可以的,因此您不能依赖任何东西。 - Jörg W Mittag
4
有多种方式可以解释这个问题。一系列非常愚蠢、琐碎的编译器优化:第一步是将increasedecrease内联;第二步是优化掉后面跟着的++以及--。可能会接着消除现在空的循环。 - Jörg W Mittag
显示剩余5条评论
3个回答

27

这出现了一个令人惊讶的转折。

可以相对确定的第一件事是,这种影响是由JIT引起的。我将代码片段合并为这个MCVE:


public class CounterJitTest
{
    private static class Counter
    {
        private int count;

        public void increase()
        {
            count++;
        }

        public void decrease()
        {
            count--;
        }

        public int getCount()
        {
            return count;
        }
    }

    private static class Person extends Thread
    {
        private Counter c;

        public Person(Counter c)
        {
            this.c = c;
        }

        @Override
        public void run()
        {
            for (int i = 0; i < 1000000; i++)
            {
                c.increase();
                c.decrease();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException
    {
        for (int i = 0; i < 10; i++)
        {
            Counter c = new Counter();
            Person p1 = new Person(c);
            Person p2 = new Person(c);
            p1.start();
            p2.start();
            p1.join();
            p2.join();
            System.out.println("run " + i + ": " + c.getCount());
        }
    }
}

使用以下方式运行:

java CounterJitTest

导致了问题中提到的输出结果:

run 0: 6703
run 1: 178
run 2: 1716
run 3: 0
run 4: 0
run 5: 0
run 6: 0
run 7: 0
run 8: 0
run 9: 0

通过使用-Xint(解释模式)关闭JIT,即以该模式启动它

java -Xint CounterJitTest

引起以下结果:

run 0: 38735
run 1: 53174
run 2: 86770
run 3: 27244
run 4: 61885
run 5: 1746
run 6: 32458
run 7: 52864
run 8: 75978
run 9: 22824
为了更深入地了解JIT实际上的工作原理,我在HotSpot反汇编器VM中启动了整个程序,以查看生成的汇编代码。但是,执行时间非常快,以至于我想:好吧,我只需增加for循环中的计数器即可。
for (int i = 0; i < 1000000; i++)

但是,即使将它增加到100000000,程序也立即完成了。这已经引起了怀疑。在生成反汇编后:

java -server -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:+PrintAssembly -XX:+PrintInlining CounterJitTest

我查看了increasedecrease方法的编译版本,但没有发现明显的问题。然而,run方法似乎是罪犯。最初,run方法的汇编包含了预期的代码(这里只列出最相关的部分):

Decoding compiled method 0x0000000002b32fd0:
Code:
[Entry Point]
[Constants]
  # {method} {0x00000000246d0f00} &apos;run&apos; &apos;()V&apos; in &apos;CounterJitTest$Person&apos;
  ...
[Verified Entry Point]
  ...
  0x0000000002b33198: je     0x0000000002b33338  ;*iconst_0
            ; - CounterJitTest$Person::run@0 (line 35)

  0x0000000002b3319e: mov    $0x0,%esi
  0x0000000002b331a3: jmpq   0x0000000002b332bc  ;*iload_1
            ; - CounterJitTest$Person::run@2 (line 35)

  0x0000000002b331a8: mov    0x178(%rdx),%edi   ; implicit exception: dispatches to 0x0000000002b3334f
  0x0000000002b331ae: shl    $0x3,%rdi          ;*getfield c
            ; - CounterJitTest$Person::run@9 (line 37)

  0x0000000002b331b2: cmp    (%rdi),%rax        ;*invokevirtual increase
            ; - CounterJitTest$Person::run@12 (line 37)
            ; implicit exception: dispatches to 0x0000000002b33354
  ...
  0x0000000002b33207: je     0x0000000002b33359
  0x0000000002b3320d: mov    0xc(%rdi),%ebx     ;*getfield count
            ; - CounterJitTest$Counter::increase@2 (line 9)
            ; - CounterJitTest$Person::run@12 (line 37)

  0x0000000002b33210: inc    %ebx
  0x0000000002b33212: mov    %ebx,0xc(%rdi)     ;*putfield count
            ; - CounterJitTest$Counter::increase@7 (line 9)
            ; - CounterJitTest$Person::run@12 (line 37)
  ...
  0x0000000002b3326f: mov    %ebx,0xc(%rdi)     ;*putfield count
            ; - CounterJitTest$Counter::decrease@7 (line 14)
            ; - CounterJitTest$Person::run@19 (line 38)

  ...

我得承认,我对此并不是非常“理解”,但可以看到它执行了一个 getfield c,以及一些(部分内联的?)increasedecrease方法的调用。

然而,最终编译版本的run方法是这样的:

Decoding compiled method 0x0000000002b34590:
Code:
[Entry Point]
[Constants]
  # {method} {0x00000000246d0f00} &apos;run&apos; &apos;()V&apos; in &apos;CounterJitTest$Person&apos;
  #           [sp+0x20]  (sp of caller)
  0x0000000002b346c0: mov    0x8(%rdx),%r10d
  0x0000000002b346c4: 
<writer thread='2060'/>
[Loaded java.lang.Shutdown from C:\Program Files\Java\jre1.8.0_131\lib\rt.jar]
<writer thread='5944'/>
shl    $0x3,%r10
  0x0000000002b346c8: cmp    %r10,%rax
  0x0000000002b346cb: jne    0x0000000002a65f60  ;   {runtime_call}
  0x0000000002b346d1: data32 xchg %ax,%ax
  0x0000000002b346d4: nopw   0x0(%rax,%rax,1)
  0x0000000002b346da: nopw   0x0(%rax,%rax,1)
[Verified Entry Point]
  0x0000000002b346e0: mov    %eax,-0x6000(%rsp)
  0x0000000002b346e7: push   %rbp
  0x0000000002b346e8: sub    $0x10,%rsp         ;*synchronization entry
            ; - CounterJitTest$Person::run@-1 (line 35)

  0x0000000002b346ec: cmp    0x178(%rdx),%r12d
  0x0000000002b346f3: je     0x0000000002b34701
  0x0000000002b346f5: add    $0x10,%rsp
  0x0000000002b346f9: pop    %rbp
  0x0000000002b346fa: test   %eax,-0x1a24700(%rip)        # 0x0000000001110000
            ;   {poll_return}
  0x0000000002b34700: retq   
  0x0000000002b34701: mov    %rdx,%rbp
  0x0000000002b34704: mov    $0xffffff86,%edx
  0x0000000002b34709: xchg   %ax,%ax
  0x0000000002b3470b: callq  0x0000000002a657a0  ; OopMap{rbp=Oop off=80}
            ;*aload_0
            ; - CounterJitTest$Person::run@8 (line 37)
            ;   {runtime_call}
  0x0000000002b34710: int3                      ;*aload_0
            ; - CounterJitTest$Person::run@8 (line 37)

  0x0000000002b34711: hlt    
  0x0000000002b34712: hlt    
  0x0000000002b34713: hlt    
  0x0000000002b34714: hlt    
  0x0000000002b34715: hlt    
  0x0000000002b34716: hlt    
  0x0000000002b34717: hlt    
  0x0000000002b34718: hlt    
  0x0000000002b34719: hlt    
  0x0000000002b3471a: hlt    
  0x0000000002b3471b: hlt    
  0x0000000002b3471c: hlt    
  0x0000000002b3471d: hlt    
  0x0000000002b3471e: hlt    
  0x0000000002b3471f: hlt    
[Exception Handler]
[Stub Code]
  0x0000000002b34720: jmpq   0x0000000002a8c9e0  ;   {no_reloc}
[Deopt Handler Code]
  0x0000000002b34725: callq  0x0000000002b3472a
  0x0000000002b3472a: subq   $0x5,(%rsp)
  0x0000000002b3472f: jmpq   0x0000000002a67200  ;   {runtime_call}
  0x0000000002b34734: hlt    
  0x0000000002b34735: hlt    
  0x0000000002b34736: hlt    
  0x0000000002b34737: hlt    

这是该方法的完整组装!但它实际上......什么也没做。

为了确认我的怀疑,我明确地禁用increase 方法的内联,从以下开始:

java -XX:CompileCommand=dontinline,CounterJitTest$Counter.increase CounterJitTest

输出结果再次符合预期:

run 0: 3497
run 1: -71826
run 2: -22080
run 3: -20893
run 4: -17
run 5: -87781
run 6: -11
run 7: -380
run 8: -43354
run 9: -29719

我的结论是:

JIT会内联increasedecrease方法。它们只是增加和减少相同的值。而在内联之后,JIT聪明地发现了对getTotal方法的调用序列。

c.increase();
c.decrease();

本质上是一个无操作,因此,它只做了这个:什么也没做。


13

我认为JVM正在进行优化,就像你所说的那样。

我在你的问题中添加了一些带时间的输出,清楚地显示出了优化发生的位置。

public static void main(String[] args) throws InterruptedException {

    for (int i = 0; i < 10; i++) {
        final long startTime = System.currentTimeMillis();
        Counter c = new Counter();
        Person p1 = new Person(c);
        Person p2 = new Person(c);
        p1.start();
        p2.start();
        p1.join();
        p2.join();
        final long endTime = System.currentTimeMillis();
        System.out.println(String.format("run %s: %s (%s ms)", i, c.getCount(), endTime - startTime));        
   }
}

结果:

run 0: 1107 (8 ms)
run 1: 1 (1 ms)
run 2: 0 (2 ms)
run 3: 0 (0 ms)
run 4: 0 (0 ms)
run 5: 0 (0 ms)
run 6: 0 (1 ms)
run 7: 0 (0 ms)
run 8: 0 (0 ms)
run 9: 0 (0 ms)

程序的第一次迭代需要很长时间,而在后续执行中几乎不会使用任何时间。

可以合理怀疑这种行为是由于优化造成的。

使用volatile int count

run 0: 8680 (15 ms)
run 1: 6943 (12 ms)
run 2: 446 (7 ms)
run 3: -398 (7 ms)
run 4: 431 (8 ms)
run 5: -5489 (6 ms)
run 6: 237 (7 ms)
run 7: 122 (7 ms)
run 8: -87 (7 ms)
run 9: 112 (7 ms)

8
强调一下,JVM并没有像问题标题所暗示的那样优化“线程安全”,而只是通过消除冗余操作来提高性能。因此,任何实际应用中的例子,如果不是完全过时的循环,都有可能会出现问题,无论是否进行了优化,有时甚至会更糟糕。 - Holger
2
@jpmc26 "volatile强制所有访问都进入RAM"这是一个经常听到的错误:不,它并不是,并且以这种方式思考是非常危险的。 JMM不涉及RAM等事物,而只是定义了强制执行的保证。这听起来像吹毛求疵,直到人们意识到例如x86缓存一致性协议不需要立即写回主内存的更改。 - Voo
@Voo 这里的“危险”指的是哪方面?Java内存模型对于volatile有明确的保证,并且必要的屏障被插入以真正观察可以描述为“所有东西直接进入RAM”(或者更少技术性、更理论性的说法:~“所有线程总是看到相同的值”)的行为。当然,++--不是原子操作仍然会搞砸这里的事情... - Marco13
@Marco,volatile 的原始内存模型基本上就是你所描述的。然后人们发现这是一个毫无意义的属性。内存模型不仅仅关于“所有线程总是看到相同的值”。它主要涉及可见性保证以及您可以看到和不可以看到的可能值。你简化的 volatile 定义不能用于安全地发布任何数据,它也引出了有趣的问题,比如你从哪里获取全局参考时间来确定哪个写入是“先”发生的。 - Voo
@Voo 我不敢自认对“JVM下面的层次”了解足够多,所以我无法就此进行争论。但也许这正是重点:JVM是一个抽象层,并提供了一些保证,从应用程序开发人员的角度来看,可以依赖这些保证 - 无论它们是如何实现的。虽然这个话题很有趣,但我认为这已经超出了评论的范围。我们可以将其转移到聊天中,但是在技术层面上,我几乎无法说更多(抽象或模糊?):“volatile”使线程之间的事情可见(这对我来说足够抽象或模糊,我相信这是真实的)。 - Marco13
显示剩余4条评论

6
您不能确定一个多线程的代码对一个变量进行加减操作总是会得到0作为结果。
要确保,您可以:
  • 同步访问Counter对象
  • Counter对象内部使用AtomicInteger
事实上count++count-- 代码是不线程安全的。内部等效于类似以下代码:
load count     - load count from ram to the registry
increment count - increment by 1
store count    - save from the registry to ram

如果被两个线程调用,这段代码可能会出现这种行为

    first                             second                           ram
    ----------                        --------                         ------
                                                                       count = 0
    load count
                                      load count
    (here count in registry == 0)     (here count in the second registry == 0)

    increment count       
                                      increment count

    (here count in registry == 1)     (here count in the second registry == 1)

    store count           
                                      store count
                                                                        count == 1

请注意:这段代码不是同步的,因此不能假定任何它的真实行为。

它取决于许多因素,例如:

  • 处理器数量
  • 增量和减量代码执行速度
  • 处理器类型(对于I7机器和Atom处理器的行为可能不同)
  • JVM实现(使用Open JDK或Oracle JVM可能会有不同的行为)
  • CPU负载
  • GC过程是否存在或不存在

你知道这段代码是线程不安全的。 你不能试图预测可以在其他PC或使用不同配置或在相同机器上具有相同配置的该代码中可重现的任何行为,因为你无法控制JVM之外发生了什么(其他应用程序的CPU负载)。

附加说明:微基准测试有一个副作用,与一些资源尚未加载相关。在你的代码中,由于类Counter和Person尚未加载(也要注意第一次迭代的执行时间比其他迭代要长得多),因此竞态条件可能更加频繁出现。


11
这并没有回答问题。原帖作者知道这个程序不是线程安全的,并试图证明这一点,但由于某种原因,在第三次循环中竞争条件似乎神奇地消失了。这就是原帖作者所询问的内容。 - RealSkeptic
3
说实话:目前被接受的答案并没有比这个更好多少,因为它也有点像是挥手道别式地说“是的,那是某种优化”。它并没有真正深入探讨什么样的优化以及如何解释观察到的行为。 - Marco13
3
你不能说它发生得更加罕见。你只能说在你创建的测试中,它似乎出现得更少。请查看更新后的答案,了解为什么你不能确定地说什么。 - Davide Lorenzo MARINO
3
正如你所说,@RealSkeptic,这是“魔法”,因为它不在你的控制之下。试图调查这个问题超出了可预测的范围。可以确定的是,这段代码并不安全,并且你无法确定竞态条件何时发生。唯一明智的做法是删除不安全的代码。 - Davide Lorenzo MARINO
2
@RealSkeptic说,声称不以线程安全的方式运行的代码是不可预测的,并不能回答一个试图预测不以线程安全方式运行的代码结果的问题?对我来说听起来像是一个答案。 - jpmc26
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接