Java 锁粗化与循环问题
1.写在前面
“JVM解剖公园”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,并没有做一致性、写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。
AlekseyShipilёv,JVM性能极客
推特@shipilev
问题、评论、建议发送到aleksey@shipilev.net
译注:锁粗化(LockCoarsening)。锁粗化是合并使用相同锁对象的相邻同步块的过程。如果编译器不能使用锁省略(LockElision)消除锁,那么可以使用锁粗化来减少开销。
2.问题
众所周知,Hotspot确实进行了锁粗化优化,可以有效合并几个相邻同步块,从而降低锁开销。能够把下面的代码
synchronized(obj){ //语句1 } synchronized(obj){ //语句2 }
转化为
synchronized(obj){ //语句1 //语句2 }
问题来了,Hotspot能否对循环进行这种优化?例如,把
for(...){ synchronized(obj){ //一些操作 } }
优化成下面这样?
synchronized(this){ for(...){ //一些操作 } }
理论上,没有什么能阻止我们这样做,甚至可以把这种优化看作只针对锁的优化,像loopunswitching一样。然而,缺点是可能把锁优化后变得过粗,线程在执行循环时会占据所有的锁。
译注:Loopunswitching是一种编译器优化技术。通过复制循环主体,在if和else语句中放一份循环体代码,实现将条件句的内部循环移到循环外部,进而提高循环的并行性。由于处理器可以快速运算矢量,因此执行速度得到提升。
3.实验
要回答这个问题,最简单的办法就是找到Hotspot优化的证据。幸运的是,有了JMH帮助这项工作变得非常简单。JMH不仅在构建基准测试时有用,并且在分析基准测试方面同样好用。让我们从一个简单的基准测试开始:
@Fork(...,jvmArgsPrepend={"-XX:-UseBiasedLocking"}) @State(Scope.Benchmark) publicclassLockRoach{ intx; @Benchmark @CompilerControl(CompilerControl.Mode.DONT_INLINE) publicvoidtest(){ for(intc=0;c<1000;c++){ synchronized(this){ x+=0x42; } } } }
(完整的源代码参见这里,请查看原文链接)
这里有一些重要的技巧:
使用-XX:-UseBiasedLocking禁用偏向锁(BiasedLock)可以避免启动时间过长。由于偏向锁不会立即启动,在初始化阶段要等待5秒钟(参见BiasedLockingStartupDelay选项)
禁用@Benchmark方法内联操作可以帮助我们从反汇编中分离相关内容
加上“魔数”0x42有助于快速从反汇编中定位加法操作
译注:偏向锁(BiasedLocking)。尽管CAS原子指令相对于重量级锁来说开销比较小,但还是存在非常可观的本地延迟,为了在无锁竞争的情况下避免取锁获过程中执行不必要的CAS原子指令提出了偏向锁技术。
论文QuicklyReacquirableLocks,作者DaveDice、MarkMoir、WilliamSchererIII。
运行环境i74790K、Linuxx86_64、JDKEA9b156:
Benchmark Mode Cnt Score Error Units
LockRoach.test avgt 5 5331.617±19.051 ns/op
从上面运行数据能分析出什么结果?什么都看不出来,对吧?我们需要调查背后到底发生了什么。这时-profperfasm配置可以派上用场,它能显示生成代码中的热点区域。用默认设置运行,能够发现最热的指令是加锁lockcmpxchg(CAS),而且只打印指令附近的代码。-profperfasm:mergeMargin=1000配置可以将这些热点区域合并保存为输出片段,乍看之下可能觉得有点恐怖。
进一步分析得出连续的跳转指令是锁定或解锁,注意循环次数最多的代码(第一列),可以看到最热的循环像下面这样:
↗0x00007f455cc708c1:lea0x20(%rsp),%rbx │<省略若干代码,进入monitor>;<---coarsened(粗化)! │0x00007f455cc70918:mov(%rsp),%r10;加载$this │0x00007f455cc7091c:mov0xc(%r10),%r11d;加载$this.x │0x00007f455cc70920:mov%r11d,%r10d;...hm... │0x00007f455cc70923:add$0x42,%r10d;...hmmm... │0x00007f455cc70927:mov(%rsp),%r8;...hmmmmm!... │0x00007f455cc7092b:mov%r10d,0xc(%r8);LOLHotspot,冗余存储,下面省略两行 │0x00007f455cc7092f:add$0x108,%r11d;加0x108=0x42*4<--展开4次 │0x00007f455cc70936:mov%r11d,0xc(%r8);把$this.x回省略若干代码,退出monitor>;<---coarsened(粗化)! │0x00007f455cc709c6:add$0x4,%ebp;c+=4<---展开4次 │0x00007f455cc709c9:cmp$0x3e5,%ebp;c<1000? ╰0x00007f455cc709cf:jl0x00007f455cc708c1
哈哈。循环似乎被展开了4次,然后这4个迭代中实现锁粗化!为了排除循环展开对锁粗化的影响,我们可以通过-XX:LoopUnrollLimit=1配置裁剪循环展开,再次量化受限后的粗化性能。
译注:Loopunrolling(循环展开),也称Loopunwinding,是一种循环转换技术。它试图以牺牲二进制大小为代价优化程序的执行速度,这种方法被称为时空折衷。转换可以由程序员手动执行,也可以由编译器优化。
BenchmarkModeCntScoreErrorUnits #Default LockRoach.testavgt55331.617±19.051ns/op #-XX:LoopUnrollLimit=1 LockRoach.testavgt520679.043±3.133ns/op
哇,性能提升了4倍!显而易见的,因为我们已经观察到最热的指令是加锁lockcmpxchg。当然,4倍后的粗化锁意味着4倍吞吐量。非常酷,我们是不是可以宣布成功,然后继续前进?还没有。我们必须验证禁用循环展开真正提供了我们想要进行比较的内容。perfasm的结果似乎表明它含有类似的热点循环,只是跨了一大步。
↗0x00007f964d0893d2:lea0x20(%rsp),%rbx │<省略若干代码,进入monitor> │0x00007f964d089429:mov(%rsp),%r10;加载$this │0x00007f964d08942d:addl$0x42,0xc(%r10);$this.x+=0x42 │<省略若干代码,退出monitor> │0x00007f964d0894be:inc%ebp;c++ │0x00007f964d0894c0:cmp$0x3e8,%ebp;c<1000? ╰0x00007f964d0894c6:jl0x00007f964d0893d2;
一切都检查OK。
4.观察结果
当锁粗化在整个循环中不起作用时,一旦中间看起来好像存在N个相邻的加锁解锁操作,另一种循环优化——循环展开会提供常规锁粗化。这将提高性能,并有助于限制粗化的范围,以避免长循环过度粗化。
总结
以上所述是小编给大家介绍的Java锁粗化与循环问题,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对毛票票网站的支持!
如果你觉得本文对你有帮助,欢迎转载,烦请注明出处,谢谢!