背景
在 Java 服务部署从物理机向公司私有云平台迁移的过程中,发现迁移后的服务质量比迁移之前有所下降。经过排查分析,发现问题的导火索可能是 JVM GC 导致的全局暂停;经过几轮对比调参,服务的 GC 单次耗时和频率都有较大程度的降低,取得了较好的效果。
所负责的项目主要是存储用户的基本信息数据,数据量在亿级。存储底层为MySQL,使用了自研的分库分表中间件;数据库实例个数在5个以上,单实例数据表行数在千万以上。
为了配合公司的云化战略,该服务需要从物理机部署迁移为私有云(docker based)部署。
服务的运行环境是 JDK 1.6,JVM 运行参数为:
-server-XX:+UseConcMarkSweepGC-XX:+UseParNewGC-XX:+DisableExplicitGC-Xms10g-Xmx10g-Xmn4g-Xss1024K-XX:PermSize=256m-XX:MaxPermSize=512m-XX:SurvivorRatio=10-XX:+ParallelRefProcEnabled-XX:+CMSParallelRemarkEnabled-XX:+UseCMSCompactAtFullCollection-XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=70-XX:CMSMaxAbortablePrecleanTime=30000-XX:SoftRefLRUPolicyMSPerMB=0
即使用的 GC 收集器为 ParNew (new) + ConcMarkSweep (CMS, old)。
在迁移过程中,发现私有云上的服务实例:
- 访问 Cache 出现超时,且超时量较明显
- 平均请求响应时间增大
- 平均响应时间会出现偶发性的突刺
线上的服务出现了问题就需要解决问题,于是开始对服务进行排查。
对比迁移前后的服务变化和环境变化:
- 迁移之前是部署到物理机上(32 core + 128 G),迁移之后是部署到私有云上(8 core + 8 G)
- 迁移之前的 JVM 参数中配置的内存数为 10 G,迁移之后调整为 7 G
通过定位 Cache 超时问题,很快发现服务会出现较长时间的全局暂停(> 100 ms),很容易联想到 GC 的 stop-the-world 上去。
GC 调优过程
Round 1 - 自适应新生代
首先需要在服务中启用 GC 日志,并去掉了显式的新生代大小设置(-Xmn)。
-Xms7g-Xmx7g-Xloggc:/path-to-gc.log-XX:+PrintGCDetails-XX:+PrintGCDateStamps
实际采集到的 GC 日志片段如下:
可以看到 JVM 自动选择的新生代大小为 156224K,新生代 GC 的暂停时长为 10-13 ms,间隔 ~ 4 s。
作为参考,调用 `jstat -gcutil` 的输出如下:
Round 2 - 增大新生代大小以增加 GC 间隔
新生代的 GC 间隔约为 4 s,尝试设置显示的新生代大小为 2G 以增加新生代 GC 的间隔,降低新生代 GC 的频率。
-server-Xms7g-Xmx7g-Xloggc:/path-to-gc.log-XX:+PrintGCDetails-XX:+PrintGCDateStamps...-Xmn2g
新的 GC 日志片段如下:
新生代 GC 的暂停时间增大为 12-17 ms,间隔为 5 s,效果不明显。老年代 GC 的暂停时间分别为 8 ms 和 698 ms,remark 阶段耗时较大,存在优化空间;第一次老年代 GC 发生于 21 个小时后。
Round 3 - 降低并发 GC 线程数以减少线程切换损失
对比迁移前后的部署环境,发现一个差异:JVM 读取到的 CPU 核数的限制是硬件核数。在物理机上,硬件核数与 JVM 运行时可用的核数一致;而私有云上 JVM 的可用核数受到 docker 的限制。而 CMS 的算法在 remark 阶段并发收集的线程数是通过 JVM 收集到的 CPU 核心数相关的。Oracle JDK 默认当 CPU 的核数小于等于 8 时,设置线程数等于核数;当 CPU 的核数大于 8 时,设置线程数为 3 + 5 * CPU / 8。这导致迁移后服务的 JVM 为 GC 并发收集线程数设置了较高的值,产生了较高的不必要的线程切换开销。
尝试设置 GC 并发线程数为可用的 CPU 核数,即 `-XX:ParallelGCThreads`:
-server-Xms7g-Xmx7g-Xmn2g-Xloggc:/path-to-gc.log-XX:+PrintGCDetails-XX:+PrintGCDateStamps...-XX:ParallelGCThreads=8
新的 GC 日志片段如下:
可以发现,新生代的 GC 的耗时降为 8-10 ms,间隔为 4 s,有一定成效。老年代 GC 的 remark 阶段耗时 729 ms,效果变差。
作为参考,`jstat -gcutil` 的输出如下:
Round 4 - full heap dump & analyze
分析 remark 阶段的耗时,发现主要是 `weak refs processing` 过程耗时较大(612 ms)。参考 [PhantomReference导致CMS GC耗时严重](https://www.jianshu.com/p/6d37afd1f072) 里的经验,推测可能是类似的问题:由于 MySQL `com.mysql.jdbc.NonRegisteringDriver` 类内部通过静态变量 `connectionPhantomRefs` 维护了一个 `com.mysql.jdbc.NonRegisteringDriver.ConnectionPhantomReference` 类型(`java.lang.ref.PhantomReference` 的子类型)的哈希表,用于 JDBC 连接的最终释放;该哈希表中元素在连接主动关闭时并不会被清理,而是通过 `java.lang.ref.PhantomReference` 的清理机制来实现连接的清理和释放。猜想这样设计是为了在用户没有主动关闭连接(未调用 `com.mysql.jdbc.Connection#close`)时,还能够清理连接。
标准库的 `java.lang.ref.PhantomReference` 被设计来进行资源的清理和关闭:当 JVM 检测到某对象只被虚引用 `java.lang.ref.PhantomReference` 而没有被别的更强引用(标准强引用、软引用 `java.lang.ref.SoftReference` 或弱引用 `java.lang.ref.SoftReference`)所指向后,会在合适时机将对应的虚引用放入一个阻塞队列。MySQL 的驱动类 `com.mysql.jdbc.Driver` 是 `com.mysql.jdbc.NonRegisteringDriver` 类的子类,在类初始化的时候会启动一个清理线程清理上述的阻塞队列,实现连接的清理和释放的逻辑。
以上是理论。为了验证是否是由于连接为清理的原因导致的`weak refs processing` 过程耗时较大,对线上服务进行了一次 full heap dump (`jmap`),并使用 [Eclipse Memory Analyzer](https://www.eclipse.org/mat/) 对 dump 的结果进行可视化分析。
通过 retained size 分析可以看出,类 `com.mysql.jdbc.NonRegisteringDriver` 导致了 812 M 的内存对象不能及时释放。
通过对象依赖分析可以看出,类 `com.mysql.jdbc.NonRegisteringDriver` 类导致大量 retained size 的罪魁祸首就是上文分析的哈希表。
Round 5 - clean connection reference map
根据以上分析,尝试在服务中启动一个线程定时清理哈希表中的 connection reference。
新的 GC 日志片段如下:
新生代 GC 的暂停时间减小为 8 ms,间隔为 8 s,效果比之前要好。
第一次老年代 GC 发生于服务器动的 37 个小时后,暂停时间分别为 11 ms 和 131 ms;第二次老年代 GC 发生于服务器动的 73 个小时后,暂停时间分别为 9 ms 和 143 ms;两次的时间间隔是 35 小时。效果明显。
以下是使用 [GCeasy - Universal GC Log Analyzer](https://www.gceasy.io) 对 GC 日志的可视化分析结构,作为参考。
清理连接之前:
清理连接之后:
以上的所有结果和分析都是在服务启动之后1-2 天的结果;作为对比,下面是服务运行半个月之后的结果:
补充说明
连接清理
由于服务使用的 [Tomcat DBCP](https://tomcat.apache.org/tomcat-7.0-doc/jdbc-pool.html) 连接池组件,会托管所使用的 JDBC 连接,所以不需要额外的 MySQL 自带的清理机制。
连接数
发现服务运行过程中,哈希表中最多会出现 30000+ 的连接的虚引用。
当前服务所使用的数据库中间件通过 [Tomcat DBCP](https://tomcat.apache.org/tomcat-7.0-doc/jdbc-pool.html) 连接池管理超过 5 个的 MySQL 数据库实例,每个 MySQL 实例 3 个连接池(一主两备)。
服务中使用的连接池的配置是:
<initialSize>16</initialSize><maxActive>16</maxActive><maxWait>4000</maxWait><minIdle>16</minIdle><maxIdle>16</maxIdle>
应该不会出现如此大量的连接。而且在当前的服务访问量下,也应该没有空闲连接以致出现连接被自动释放的情况。
此问题可以进一步排查。
升级 JDK 版本
如果条件允许,可以优先升级 JDK 到 1.8。但是由于该服务是老服务,而且调用量巨大,升级风险太大;而且 JDBC 接口跨 JDK 版本存在兼容性问题,服务所使用的分库分表中间件没有做兼容性测试和保证。
所以没有办法升级 JDK 版本。
总结
调整 GC 参数和代码逻辑之后,服务由于 GC 暂停导致的暂停时间大为减少。
由于新生代的 GC 是复制算法,耗时主要在于存活对象的大小,所以调优对新生代的效果不明显;老年代的 GC 是 CMS,通过调整并发收集线程数和修改代码逻辑定时清理 PhantomReference,发现取得了较好的效果。
调优之后的服务,基本上不出现或很少出现 Cache 访问超时,服务的平均响应时间也接近甚至好于迁移之前的表现。
欢迎大家关注“58架构师”微信公众号,定期分享云计算、AI、区块链、大数据、搜索、推荐、存储、中间件、移动、前端、运维等方面的前沿技术和实践经验。