之前的博文中说过最近在查一个问题,花费了近两个星期,问题算是有了一个小结,是时候总结一下了。
排查过程走了很多弯路,由于眼界和知识储备问题,也进入了一些思维误区,希望此问题能以后再查询此类问题时能有所警示和参考;而且很多排查方法和思路都来自于部门 leader 和 组里大神给的提示和启发,总结一下也能对这些知识有更深的理解。
这个问题出现在典型的高并发场景下,现象是某个接口会偶尔超时,查了几个 case 的日志,发现 httpClient 在请求某三方接口结束后输出一条日志时间为 A,方法返回后将请求结果解析成为 JSON 对象后,再输出的日志时间为 B, AB之间的时间差会特别大,100-700ms 不等,而 JSON 解析正常是特别快的,不应该超过 1ms。
转载随意,请注明来源地址: https://zhenbianshu.github.io ,文章持续修订。
首先考虑导致这种现象的可能:
我们都知道 JVM 在 GC 时会导致 STW,进而导致所有线程都会暂停,接下来重点排查 GC 问题。
首先我们使用 jstat 命令查看了 GC 状态,发现 fullGC 并不频繁,系统运行了两天才有 100 来次,而 minorGC 也是几秒才会进行一次,且 gcTime 一直在正常范围。
由于我们的 JVM 在启动时添加了 -XX:+PrintGCApplicationStoppedTime
参数,而这个参数的名字跟它的意义并不是完全相对的,在启用此参数时,gclog 不仅会打印出 GC 导致的 STW,其他原因导致的 STW 也会被记录到 gclog 中,于是 gclog 就可以成为一个重要的排查工具。
查看 gclog 发现确实存在异常状况,如下图:
系统 STW 有时会非常频繁地发生,且持续时间也较长,其中间隔甚至不足 1ms,也就是说一次停顿持续了几十 ms 之后,系统还没有开始运行,又会进行第二次 STW,如上图的情况,系统应该会一次 hang 住 120ms,如果次数再频繁一些,确实有可能会导致接口超时。
那么这么频繁的 STW 是由什么产生的呢,minorGC 的频率都没有这么高。
我们知道,系统在 STW 前,会等待所有线程安全点,在 安全点
里,线程的状态是确定的,其引用的 heap 也是静止的,这样,JVM 才能安心地进行 GC 等操作。至于安全点的常见位置和安全点的实现方式网卡有很多文章介绍这里不再多提了,这里重点说一下安全点日志。
安全点日志是每次 JVM 在所有线程进入安全点 STW 后输出的日志,日志记录了进入安全点用了多久,STW 了多久,安全点内的时间都是被哪个步骤消耗的,通过它我们可以分析出 JVM STW 的原因。
服务启动时,使用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintSafepointStatistics -XX:+LogVMOutput -XX:LogFile=./safepoint.log
参数,可以将安全点日志输出到 safepoint.log 中。
在安全点日志中,我发现有很多下图类似的日志输出:
从前面的 vmopt 列可以得知,进入安全点的原因是 RevokeBias
, 查资料得知,这是在释放 偏向锁
。
偏向锁是 JVM 对锁的一种优化,JVM 的开发人员统计发现绝大部分的锁都是由同一个线程获取,锁争抢的可能性很小,而系统调用一次加锁释放锁的开销相对很大,所以引入偏向锁默认锁不会被竞争,偏向锁中的 “偏向” 便指向第一个获取到锁的线程。在一个锁在被第一次获取后,JVM 并不需要用系统指令加锁,而是使用偏向锁来标志它,将对象头中 MarkWord 的偏向锁标识设置为1,将偏向线程设置为持续锁的线程 ID,这样,之后线程再次申请锁时如果发现这个锁已经 “偏向” 了当前线程,直接使用即可。而且持有偏向锁的线程不会主动释放锁,只有在出现锁竞争时,偏向锁才会释放,进而膨胀为更高级别的锁。
有利则有弊,偏向锁在单线程环境内确实能极大降低锁消耗,但在多线程高并发环境下,线程竞争频繁,而偏向锁在释放时,为了避免出现冲突,需要等到进入全局安全点才能进行,所以每次偏向锁释放会有更大的消耗。
明白了 JVM 为什么会频繁进行 STW,再修改服务启动参数,添加 -XX:-UseBiasedLocking
参数禁用偏向锁后,再观察服务运行。
发现停顿问题的频率下降了一半,但还是会出现,问题又回到原点。
这时候就需要猜想排查了。首先提出可能导致问题的点,再依次替换掉这些因素压测,看能否复现来确定问题点。
考虑到的问题点有 HttpClient、Hystrix、Log4j2
。使用固定数据替换了三方接口的返回值,删去了 Hystrix,甚至将逻辑代码都删掉,只要使用 Log4j2 输出大量日志,问题就可以复现,终于定位到了 Log4j2,原来是监守自盗啊。。
虽然定位到 Log4j2,但日志也不能不记啊,还是要查问题到底出在哪里。
首先考虑 Log4j2 代码里的锁,怀疑是由于锁冲突,导致输出 log 时被 block 住了。
查看源码,统计存在锁的地方有三处:
rollover()
方法,在检测到日志文件需要切换时会锁住进行日志文件的切分。 encodeText()
方法,日志输出需要将各种字符集的日志都转换为 byte 数组,在进行大日志输出时,需要分块进行。为了避免多线程块之间的乱序,使用 synchronized
关键字对方法加锁。 flush()
方法,同样是为了避免不同日志之间的穿插乱序,需要对此方法加锁。 具体是哪一处代码出现了问题呢,我使用 btrace
这一 Java 性能排查利器进行了排查。
使用 btrace 分别在这三个方法前后都注入代码,将方法每次的执行时间输出,然后进行压测,等待问题发生,最终找到了执行慢的方法: encodeText()
。
排查代码并未发现 encodeText 方法的异常(千锤百炼的代码,当然看不出什么问题)。
这时,组内大神建议我使用 jmc 来捕捉异常事件。 给 docker-compose 添加以下参数来启用环境的 JFR。
environment: - JFR=true - JMX_PORT=port - JMX_HOST=ip - JMX_LOGIN=user:pwd
在记录内查找停顿时间附近的异常事件,发现耗时统计内有一项长耗时引人注目,调用 RandomAccessFile.write()
方法竟然耗时 1063 ms,而这个时间段和线程 ID 则完全匹配。
查看这个耗时长的方法,它调用的是 native 方法 write()
。。。
native 方法再查下去就是系统问题了,不过我比较怀疑是 docker 的问题,会再接着排查,等有结论了再更新。
问题总是要解决的,其实也很简单:
查问题的过程确实学习到了很多知识,让我更熟悉了 Java 的生态,但我觉得更重要的是排查问题的思路,于是我总结了一个排查问题的一般步骤,当作以后排查此类问题的 checkList。
当然还有一个非常重要的点,贯穿始末,那就是 数据支持
,排查过程中一定要有数据作为支持。通过总量、百分比数据的前后对比来说服其他人相信你的理论,也就是用数据说话。
关于本文有什么疑问可以在下面留言交流,如果您觉得本文对您有帮助,欢迎关注我的微博 或 GitHub 。