众所周知,JVM可以进行锁粗化优化,可以有效地合并多个相邻的加锁代码块,因此可以减少加锁的成本。该特性可以将下述代码:
synchronized (obj) {
// statements 1
}
synchronized (obj) {
// statements 2
}
变为:
synchronized (obj) {
// statements 1
// statements 2
}
那么Hotspot 对循环也会做这种优化么?比如:
for (...) {
synchronized (obj) {
// something
}
}
是否会优化成这样:
synchronized (this) {
for (...) {
// something
}
}
理论上来说是可以的,这有点像针对锁的循环判断外提。然而如此优化的缺点是将锁的粒度增加太多,线程在执行循环时将会长时间独占锁。
实验
我们从一个简单的例子开始:
@Fork(..., jvmArgsPrepend = {"-XX:-UseBiasedLocking"})
@State(Scope.Benchmark)
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;
}
}
}
}
这里有几个重要的技巧:通过-XX:-UseBiasedLocking关闭偏向锁,以避免长时间的预热,因为偏向锁并不是直接启动,而是完成初始化阶段后等待5秒钟才开始。(参看BiasedLockingStartupDelay选项)(译者注:如果确定应用的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。)
关闭@Benchmark方法的内联可以帮忙我们从反汇编中分离出相关代码。
每次累加一个魔数0x42可以帮助我们快速地在反汇编代码中确定增量。
执行环境 i7 4790K, Linux x86_64, JDK EA 9b156:
Benchmark Mode Cnt Score Error Units
LockRoach.test avgt 5 5331.617 ± 19.051 ns/op
我们需要深入看JVM做了什么操作。-prof perfasm这个参数非常有用,它可以展示生成代码中最热的部分。使用默认参数执行代码,发现代码中最热的指令是执行锁操作的lock cmpxchg(compare-and-sets),另外也输出了该指令附近的操作。通过设置-prof perfasm:mergeMargin=1000
来放宽输出热区代码的范围。
进一步剥离这些代码,同时注意累积最多周期的代码(第一列),我们可以看到最热的循环是:
↗ 0x00007f455cc708c1: lea 0x20(%rsp),%rbx
│ < blah-blah-blah, monitor enter > ;
│ 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
│ 0x00007f455cc70936: mov %r11d,0xc(%r8) ; store $this.x back
│ < blah-blah-blah, monitor exit > ;
│ 0x00007f455cc709c6: add $0x4,%ebp ; c += 4
│ 0x00007f455cc709c9: cmp $0x3e5,%ebp ; c < 1000?
╰ 0x00007f455cc709cf: jl 0x00007f455cc708c1
循环以4为步长展开了,然后锁通过这4次迭代粗化了!如果这是由于循环展开造成的,那么我们可以通过-XX:LoopUnrollLimit=1调低循环展开的步长:
Benchmark Mode Cnt Score Error Units
# Default
LockRoach.test avgt 5 5331.617 ± 19.051 ns/op
# -XX:LoopUnrollLimit=1
LockRoach.test avgt 5 20679.043 ± 3.133 ns/op
因为我们已经观察到最热的指令是锁操作lock cmpxchg,所以可以预期有4倍的性能提升。4倍的锁粗化意味着4倍的吞吐量。继续验证关闭循环展开后的反汇编代码。通过 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 ;
看起来一切正常。
结论
虽然 Hotspot 不会对整个循环进行锁粗化,但是使用循环展开优化为锁粗化做了准备,因为循环展开后的代码就是 N 个连续的加锁代码块。这样既获得了性能收益,又限制了粗化的粒度,避免了对循环的过度粗化。