5.2.8 由安全点导致长时间停顿

5.2.8 由安全点导致长时间停顿

^1
有一个比较大的承担公共计算任务的离线HBase集群,运行在JDK 8上,使用G1收集器。每天都有大量的MapReduce或Spark离线分析任务对其进行访问,同时有很多其他在线集群Replication过来的数据写入,因为集群读写压力较大,而离线分析任务对延迟又不会特别敏感,所以将-XX: MaxGCPauseMillis参数设置到了500毫秒。不过运行一段时间后发现垃圾收集的停顿经常达到3秒以上,而且实际垃圾收集器进行回收的动作就只占其中的几百毫秒,现象如以下日志所示。

1
2
[Times: user=1.51 sys=0.67, real=0.14 secs] 
2019-06-25T 12:12:43.376+0800: 3448319.277: Total time for which application threads were stopped: 2.2645818 sec

考虑到不是所有读者都了解计算机体系和操作系统原理,笔者先解释一下user、sys、real这三个时间的概念:

  • user:进程执行用户态代码所耗费的处理器时间。
  • sys:进程执行核心态代码所耗费的处理器时间。
  • real:执行动作从开始到结束耗费的时钟时间。

请注意,前面两个是处理器时间,而最后一个是时钟时间,它们的区别是处理器时间代表的是线程占用处理器一个核心的耗时计数,而时钟时间就是现实世界中的时间计数。如果是单核单线程的场景下,这两者可以认为是等价的,但如果是多核环境下,同一个时钟时间内有多少处理器核心正在工作,就会有多少倍的处理器时间被消耗和记录下来。

在垃圾收集调优时,我们主要依据real时间为目标来优化程序,因为最终用户只关心发出请求到得到响应所花费的时间,也就是响应速度,而不太关心程序到底使用了多少个线程或者处理器来完成任务。

日志显示这次垃圾收集一共花费了0.14秒,但其中用户线程却足足停顿了有2.26秒,两者差距已经远远超出了正常的TTSP(Time To Safepoint)耗时的范畴。所以先加入参数-XX: +PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1去查看安全点日志,具体如下所示:

1
2
3
4
vmop [threads: total initially_running wait_to_block] 
65968.203: ForceAsyncSafepoint [931 1 2]
[time: spin block sync cleanup vmop] page_trap_count
[2255 0 2255 11 0] 1

日志显示当前虚拟机的操作(VM Operation,VMOP)是等待所有用户线程进入到安全点,但是有两个线程特别慢,导致发生了很长时间的自旋等待。日志中的2255毫秒自旋(Spin)时间就是指由于部分线程已经走到了安全点,但还有一些特别慢的线程并没有到,所以垃圾收集线程无法开始工作,只能空转(自旋)等待。

解决问题的第一步是把这两个特别慢的线程给找出来,这个倒不困难,添加-XX: +SafepointTimeout和-XX:SafepointTimeoutDelay=2000两个参数,让虚拟机在等到线程进入安全点的时间超过2000毫秒时就认定为超时,这样就会输出导致问题的线程名称,得到的日志如下所示:

1
2
3
4
5
6
7
# SafepointSynchronize::begin: Timeout detected: 
# SafepointSynchronize::begin: Timed out while spinning to reach a safepoint.
# SafepointSynchronize::begin: Threads which did not reach the safepoint:
# "RpcServer.listener,port=24600"
#32 daemon prio=5 os_prio=0 tid=0x00007f4c14b22840 nid=0xa621 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
# SafepointSynchronize::begin: (End of list)

从错误日志中顺利得到了导致问题的线程名称为“RpcServer.listener,port=24600”。但是为什么它们会出问题呢?有什么因素可以阻止线程进入安全点?在第3章关于安全点的介绍中,我们已经知道安全点是以“是否具有让程序长时间执行的特征”为原则进行选定的,所以方法调用、循环跳转、异常跳转这些位置都可能会设置有安全点,但是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环 (Uncounted Loop),将会被放置安全点。通常情况下这个优化措施是可行的,但循环执行的时间不单单是由其次数决定,如果循环体单次执行就特别慢,那即使是可数循环也可能会耗费很多的时间。

HotSpot原本提供了-XX:+UseCountedLoopSafepoints参数去强制在可数循环中也放置安全点,不过这个参数在JDK 8下有Bug^2,有导致虚拟机崩溃的风险,所以就不得不找到RpcServer线程里面的缓慢代码来进行修改。最终查明导致这个问题是HBase中一个连接超时清理的函数,由于集群会有多个MapReduce或Spark任务进行访问,而每个任务又会同时起多个Mapper/Reducer/Executer,其每一个都会作为一个HBase的客户端,这就导致了同时连接的数量会非常多。更为关键的是,清理连接的索引值就是int类型,所以这是一个可数循环,HotSpot不会在循环中插入安全点。当垃圾收集发生时, 如果RpcServer的Listener线程刚好执行到该函数里的可数循环时,则必须等待循环全部跑完才能进入安全点,此时其他线程也必须一起等着,所以从现象上看就是长时间的停顿。找到了问题,解决起来就非常简单了,把循环索引的数据类型从int改为long即可,但如果不具备安全点和垃圾收集的知识,这种问题是很难处理的。