JVM解剖公园1:锁粗化和循环

问题

我们知道,Hotspot会对锁进行粗化,通过合并相邻的锁块,来减少锁开销,比如:

synchronized (obj) {
  // statements 1
}
synchronized (obj) {
  // statements 2
}

优化为:

synchronized (obj) {
  // statements 1
  // statements 2
}

那么现在的问题是,Hotspot是否会对循环内的锁进行优化呢?

for (...) {
  synchronized (obj) {
    // something
  }
}

它会优化成这个吗?

synchronized (this) {
  for (...) {
     // something
  }
}

从理论上讲是可以的,就像循环判断外提这样的手法。毕竟,在循环内使用锁,会严重影响性能。

实验

实际如何,我们来找下证据。
我们用JMH(Java微基准测试框架)来测试,它不仅可用于构建基准测试,还对代码中热点部分进行分析。

译者注:JMH中文文档
让我们从一个简单的基准测试开始:

@Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 1, jvmArgsPrepend = {"-XX:-UseBiasedLocking"})
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class LockRoach {
    int x;
    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public void test() {
        for (int c = 0; c < 1000; c++) {
            synchronized (this) {
                x += 0x42;
            }
        }
    }
}

这里有一些重要的技巧:

  1. 通过-XX:-UseBiasedLocking禁用偏向锁可以降低初始化时间,因为偏向锁不会立即启动,而是在虚拟机启动5秒后才启动偏向锁模式。
  2. 禁用@Benchmark方法内联展开,在反汇编中更容易看清楚。
  3. 加一个魔数0x42,好快速定位反汇编代码。

译者注:Java偏向锁
在i7 4790K, Linux x86_64, JDK EA 9b156上运行如下:

Benchmark            Mode  Cnt      Score    Error  Units
LockRoach.test       avgt    5   5331.617 ± 19.051  ns/op
Mode:avgt,平均耗时

从上面看不出什么,我们需要深入研究下。在JMH命令行添加 -prof perfasm,它会显示的代码的热点区。运行发现最热点的指令是lock cmpxchg。用-prof perfasm:mergeMargin=1000将这些热点区域合并转为图片,可以更直观的看到结果。
结果如下,locking/unlocking像瀑布一样,堆积了大量的循环代码:

 ↗  0x00007f455cc708c1: lea    0x20(%rsp),%rbx
 │          < blah-blah-blah, monitor enter >     ; <--- coarsened!
 │  0x00007f455cc70918: mov    (%rsp),%r10        ; load $this
 │  0x00007f455cc7091c: mov    0xc(%r10),%r11d    ; load $this.x
 │  0x00007f455cc70920: mov    %r11d,%r10d        ; ...hm...
 │  0x00007f455cc70923: add    $0x42,%r10d        ; ...hmmm...
 │  0x00007f455cc70927: mov    (%rsp),%r8         ; ...hmmmmm!...
 │  0x00007f455cc7092b: mov    %r10d,0xc(%r8)     ; LOL Hotspot, redundant store, killed two lines below
 │  0x00007f455cc7092f: add    $0x108,%r11d       ; add 0x108 = 0x42 * 4 <-- unrolled by 4
 │  0x00007f455cc70936: mov    %r11d,0xc(%r8)     ; store $this.x back
 │          < blah-blah-blah, monitor exit >      ; <--- coarsened!
 │  0x00007f455cc709c6: add    $0x4,%ebp          ; c += 4   <--- unrolled by 4
 │  0x00007f455cc709c9: cmp    $0x3e5,%ebp        ; c < 1000?
 ╰  0x00007f455cc709cf: jl     0x00007f455cc708c1

呵呵。循环似乎展开了4次,然后在这4次迭代中锁粗化了!如果是循环展开而导致的,我们可以量化下,尝试减少展开-XX:LoopUnrollLimit=1:

译者注:循环展开

//循环展开4次
for (int c = 0; c < 1000; c+=5) {
    synchronized (this) {
        x += 0x42;
        x += 0x42;
        x += 0x42;
        x += 0x42;
        x += 0x42;
    }
}
//循环展开1次
for (int c = 0; c < 1000; c+=2) {
    synchronized (this) {
        x += 0x42;
        x += 0x42;
    }
}

Benchmark Mode Cnt Score Error Units

Default

LockRoach.test avgt 5 5331.617 ± 19.051 ns/op

-XX:LoopUnrollLimit=1

LockRoach.test avgt 5 20 679.043 ± 3.133 ns/op

哇,4倍性能差距!没错,热点指令的lock cmpxchg源自锁,4倍粗化意味着4倍的吞吐量。非常酷,我们可以宣布成功了吗?还没有,必须验证的确是禁用循环展开的结果。[perfasm文件](https://shipilev.net/jvm-anatomy-park/1-lock-coarsening-for-loops/noUnroll.perfasm) 似乎有一个步骤表明它具有类似的热循环。

↗ 0x00007f964d0893d2: lea 0x20(%rsp),%rbx
│ < blah-blah-blah, monitor enter >
│ 0x00007f964d089429: mov (%rsp),%r10 ; load $this
│ 0x00007f964d08942d: addl $0x42,0xc(%r10) ; $this.x += 0x42
│ < blah-blah-blah, monitor exit >
│ 0x00007f964d0894be: inc %ebp ; c++
│ 0x00007f964d0894c0: cmp $0x3e8,%ebp ; c < 1000?
╰ 0x00007f964d0894c6: jl 0x00007f964d0893d2 ;
```
啊,好吧,一切都结束了。

结论

虽然锁粗化对整个循环不起作用,但中间部分却存在一个优化的手法:先循环展开,再锁粗化展开部分。这样可以获得性能的提升,并能限制粗化范围,避免过度粗化大的循环。

Comments
Write a Comment