问题描述

公司某规则引擎系统,在每次发版启动会手动预热,预热完成当流量切进来之后会偶发的出现一次长达1-2秒的Young GC(流量并不大,并且LB下的每个节点都会出现该情况)

在这次长暂停之后,每一次的年轻代GC暂停时间又都恢复在20-100ms以内

2秒虽然看起来不算长吧,但规则引擎每次执行也才几毫秒,这谁能忍?而且这玩意一旦超时,出单可能也跟着超时失败!

问题分析

在分析该系统GC日志后发现,2s暂停发生在Young GC阶段,而且每次发生长暂停的Young GC都会伴随着新生代对象的晋升(Promotion)

核心JVM参数(Oracle JDK7)

-Xms10G
-Xmx10G
-XX:NewSize=4G
-XX:PermSize=1g
-XX:MaxPermSize=4g
-XX:+

可能有人会问,为什么给这么大内存?祖传代码,内存小了跑不动!

启动后第一次年轻代GC日志

2023-04-23T16:28:31.108+0800: [GC2023-04-23T16:28:31.108+0800: [ParNew2023-04-23T16:28:31.229+0800: [SoftReference, 0 refs, 0.0000950 secs]2023-04-23T16:28:31.229+0800: [WeakReference, 1156 refs, 0.0001040 secs]2023-04-23T16:28:31.229+0800: [FinalReference, 10410 refs, 0.0103720 secs]2023-04-23T16:28:31.240+0800: [PhantomReference, 286 refs, 2 refs, 0.0129420 secs]2023-04-23T16:28:31.253+0800: [JNI Weak Reference, 0.0000000 secs]
Desired survivor size 214728704 bytes, new threshold 1 (max 15)
- age 1: 315529928 bytes, 315529928 total
- age 2: 40956656 bytes, 356486584 total
- age 3: 8408040 bytes, 364894624 total
: 3544342K->374555K(3774912K), 0.1444710 secs] 3544342K->374555K(10066368K), 0.1446290 secs] [Times: user=1.46 sys=0.09, real=0.15 secs]

长暂停年轻代GC日志

2023-04-23T17:18:28.514+0800: [GC2023-04-23T17:18:28.514+0800: [ParNew2023-04-23T17:18:29.975+0800: [SoftReference, 0 refs, 0.0000660 secs]2023-04-23T17:18:29.975+0800: [WeakReference, 1224 refs, 0.0001400 secs]2023-04-23T17:18:29.975+0800: [FinalReference, 8898 refs, 0.0149670 secs]2023-04-23T17:18:29.990+0800: [PhantomReference, 600 refs, 1 refs, 0.0344300 secs]2023-04-23T17:18:30.025+0800: [JNI Weak Reference, 0.0000210 secs]
Desired survivor size 214728704 bytes, new threshold 15 (max 15)
- age 1: 79203576 bytes, 79203576 total
: 3730075K->304371K(3774912K), 1.5114000 secs] 3730075K->676858K(10066368K), 1.5114870 secs] [Times: user=6.32 sys=0.58, real=1.51 secs]

从这个长暂停的GC日志来看,是发生了晋升的,在Young GC后,有363M+的对象晋升到了老年代,这个晋升操作因该就是耗时原因(ps: 检查过safepoint原因,不存在异常)

由于日志参数中没有配置-XX:+PrintHeapAtGC参数,这里是手动计算的晋升大小:

年轻代年轻变化 - 全堆容量变化 = 晋升大小
(304371K - 3730075K) - (676858K - 3730075K) = 372487K(363M)

下一次年轻代GC日志

2023-04-23T17:23:39.749+0800: [GC2023-04-23T17:23:39.749+0800: [ParNew2023-04-23T17:23:39.774+0800: [SoftReference, 0 refs, 0.0000500 secs]2023-04-23T17:23:39.774+0800: [WeakReference, 3165 refs, 0.0002720 secs]2023-04-23T17:23:39.774+0800: [FinalReference, 3520 refs, 0.0021520 secs]2023-04-23T17:23:39.776+0800: [PhantomReference, 150 refs, 1 refs, 0.0051910 secs]2023-04-23T17:23:39.782+0800: [JNI Weak Reference, 0.0000100 secs]
Desired survivor size 214728704 bytes, new threshold 15 (max 15)
- age 1: 17076040 bytes, 17076040 total
- age 2: 40832336 bytes, 57908376 total
: 3659891K->90428K(3774912K), 0.0321300 secs] 4032378K->462914K(10066368K), 0.0322210 secs] [Times: user=0.30 sys=0.00, real=0.03 secs]

乍一看好像没什么问题,仔细想想还是发现了不对劲,为什么程序刚启动第二次gc就发生了晋升?

推测这里应该是动态年龄判定导致的,GC中晋升年龄阈值并不是固定的15,而是jvm每次gc后动态计算的

年轻代晋升机制

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄

《深入理解Java虚拟机》一书中提到,对象晋升年龄的阈值是动态判定的。

不过经查阅其他资料和验证后,发现此处和《深入理解Java虚拟机》解释的有些出入

其实就是按年龄给对象分组,取total(累加值,小于等与当前年龄的对象总大小)最大的年龄分组,如果该分组的total大于survivor的一半,就将晋升年龄阈值更新为该分组的年龄

注意:不是是超过survivor一半就晋升,超过survivor一半只会重新设置晋升阈值(threshold),在下一次GC才会使用该新阈值

3544342K->374555K(3774912K), 0.1444710 secs] 年轻代

3544342K->374555K(10066368K), 0.1446290 secs] 全堆

从上面第一次的GC日志也可以证明这个结论,在这次GC中全堆的内存变化和年轻代内存变化是相等的,所以并没有发生对象的晋升

就像上面的日志中,第一次GC只是将threshold设置为1,因为此时survivor一半为214728704 bytes,而年龄为1的对象总和有315529928 bytes,超过了Desired survivor size,所以在本次GC后将threshold设置为年龄为1的对象年龄1

这里更新了对象晋升年龄阈值为1
Desired survivor size 214728704 bytes, new threshold 1 (max 15)
- age 1: 315529928 bytes, 315529928 total
- age 2: 40956656 bytes, 356486584 total
- age 3: 8408040 bytes, 364894624 total

这里顺便解释下这个年龄分布的输出内容:

- age   1:  315529928 bytes,  315529928 total 

- age 1表示年龄为1的对象分组,315529928 bytes表示年龄为1的对象占用内存大小

315529928 total这个是一个累加值,表示小于等于当前分组年龄的对象总大小。先把对象按年龄分组,age 1的分组total为age 1总大小(前面的xxx bytes),age 2的分组total为age 1 + age 2总大小,age n的分组total为age 1 + age 2 + ... +age n的总大小,累加规则如下图所示

当total最大的分组的total值超过了survivor/2时,就会更新晋升阈值

在第二次年轻代GC“长暂停年轻代GC日志”中,由于新的晋升年龄阈值为1,所以那些经历了一次GC并存活并且现在仍然可达(reachable)的对象们就会发生晋升了

由于此次GC发生了363M的对象晋升,所以导致了长暂停

思考

JVM中这个“动态对象年龄判定”真的合理吗?

个人认为机制是好的,可以更好的适应不同程序的内存状况,但不是任何场景都适合,比如在本文中这个刚启动不就GC的场景下就会有问题

因为在程序刚启动时,大多数对象年龄都是0或者1,很容易出现年龄为1的大量存活对象;在这个“动态对象年龄判定”机制下,就会导致新的晋升阈值被设置为1,导致这些不该晋升的对象发生了晋升

比如程序在初始化,正在加载各种资源时发生了Young GC,加载逻辑还在执行中,很多新建的对象年龄在这次GC时还是可达的(reachable)

经历了这次GC后,这些对象年龄更新为1,但是由于“动态对象年龄判定”机制的影响,晋升年龄阈值更新为了“最大的对象年龄分组”的年龄,也就是这批刚经历了一次GC的对象们

在这次GC之后不久,资源初始化完成了,涉及的相关对象有很可能不可达了,但是由于刚才晋升年龄阈值被更新为了1,在下一次正常的Young GC这批年龄为1的对象会直接发生晋升,提前或者说错误的发生了晋升

解决方案

经查阅文档、资料,发现“动态年龄判定”这个机制并不能禁用,所以如果想解决这个问题,只有靠“绕过”这个计算规则了

动态年龄的判定,是根据Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半来判定的,那么根据这个机制解决也很简单

由于我们足够了解自己的系统,清楚的知道加载资源所需的大概内存,完全可以设定一个大于这些暂时可达的对象总和的数值来作为survivor的容量

比如上面的日志中,第一次GC后年龄为1的对象有315529928 Bytes(300M),Desired survivor size为(survivor size /2)214728704 bytes(204M),那么survivor就可以设置为600M以上。

不过为了稳妥,还是将survivor调到800M,这样desired survivor size就是400M左右,在第一次Young GC后,就不会因年龄为1的对象总和超过了desired survivor size而导致晋升年龄阈值的更新了,从而也就不会有提前/错误晋升而导致的GC长暂停问题

survivor不可以直接指定大小,不过可以通过-XX:SurvivorRatio这种调节比例的方式来调节survivor大小

-XX:SurvivorRatio=8

表示两个Survivor和Edgen区的比,8表示两个Survivor:Eden=2:8,即一个Survivor占新生代的1/10。

计算方式为:

变形一下,Eden 的大小计算公式为:

这里用一张堆叠柱状图来详细的解释 SurvivorRatio 不同数值下 Eden/Survivor 的空间比例:

好了,现在直接通过比例,强行给 Survivor 调大

-XX:SurvivorRatio=3

调整之后,Survivor 总占比为 40%,大小为 1717829632 Bytes,单个 S0/S1的一半也有 10% - 429457408 Bytes,远超 age=1 的分组总大小 315529928 Bytes。

这样一来, Young GC 后复制到 Survivor 的对象(最大年龄分组)占总比例的大小就不会到 50% 了,也就不会把 MaxTenuringThreshold 更新为 1 ,自然就解决了这个“乱晋升”的问题**

改完收工,再次发版手动预热后,再也没有切量后长暂停的问题了,Young GC稳定在 30-100ms,成功解决!

扩展

为什么晋升300M比年轻代回收3G还要慢这么多倍

根据复制算法的特性,复制算法的时间消耗主要取决于存活对象的大小,而不是总空间的大小

比如上面4G的年轻代(实际只有Eden+S0可用),GC时只需要从GC ROOTS开始遍历对象图,将可达的对象复制至S1即可,并不需要遍历整个年轻代

复制算法的详细介绍可以参考我的另一篇《垃圾回收算法实现之 - 复制算法(完整可运行C语言代码)》

在上面那次长暂停GC日志中,发生了363M的晋升,300M左右的回收,对比第一次GC基本可以得出,花费的1.5S基本上都是在晋升操作

为什么晋升操作这么耗时?

晋升毕竟涉及跨代复制啊(其实都年轻代和老年代都是heap,在复制这件事上本质上没什么区别,都是memcpy而已,只是需要额外处理的逻辑更多了)

,所需处理的逻辑会更复杂,比如指针的更新等操作,更耗时也是可以理解吗嘛,

本地代码模拟

这里也附上一段可以在本地模拟问题的代码,Oracle JDK7下可直接运行测试

//jdk7.。

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; public class PromotionTest {
public static void main(String[] args) throws IOException {
//模拟初始化资源场景
List<Object> dataList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
dataList.add(new InnerObject());
}
//模拟流量进入场景
for (int i = 0; i < 73; i++) {
if(i == 72){
System.out.println("Execute young gc...Adjust promotion threshold to 1");
}
new InnerObject();
}
System.out.println("Execute full gc...dataList has been promoted to cms old space");
//这里注意dataList中的对象在这次Full GC后会进入老年代
System.gc();
}
public static byte[] createData(){
int dataSize = 1024*1024*4;//4m
byte[] data = new byte[dataSize];
for (int j = 0; j < dataSize; j++) {
data[j] = 1;
}
return data;
}
static class InnerObject{
private Object data; public InnerObject() {
this.data = createData();
}
}
}

jvm options

-server -Xmn400M -XX:SurvivorRatio=9 -Xms1000M -Xmx1000M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintHeapAtGC -XX:+PrintReferenceGC -XX:+PrintGCApplicationStoppedTime -XX:+UseConcMarkSweepGC

注意,文中垃圾回收相关的机制解释,都是基于 HotSpot JVM,Parallel New + CMS Old 。

参考

作者:京东保险 蒋信

来源:京东云开发者社区 转载请注明来源

给祖传系统做了点 GC调优,暂停时间降低了 90%的更多相关文章

  1. JVM系列(四)之GC调优

    JVM内存参数调优 为什么要GC调优? 或者说的更确切一些,对于基于Java的服务,是否有必要优化GC?应该说,对于所有的基于Java的服务,并不总是需要进行GC优化,但当你的系统时常报了内存溢出或者 ...

  2. 一文了解JDK12 13 14 GC调优秘籍-附PDF下载

    目录 简介 那些好用的VM参数 G1的变化 配置FlightRecorder RAM参数 JDK13中的ZGC RTM支持 总结 简介 想了解JDK12,13,14中的GC调优秘籍吗?想知道这三个版本 ...

  3. Java GC 专家系列3:GC调优实践

    本篇是”GC专家系列“的第三篇.在第一篇理解Java垃圾回收中我们学习了几种不同的GC算法的处理过程,GC的工作方式,新生代与老年代的区别.所以,你应该已经了解了JDK 7中的5种GC类型,以及每种G ...

  4. GC参考手册 —— GC 调优(基础篇)

    GC调优(Tuning Garbage Collection)和其他性能调优是同样的原理.初学者可能会被 200 多个 GC参数弄得一头雾水, 然后随便调整几个来试试结果,又或者修改几行代码来测试.其 ...

  5. GC参考手册 —— GC 调优(工具篇)

    JVM 在程序执行的过程中, 提供了GC行为的原生数据.那么, 我们就可以利用这些原生数据来生成各种报告.原生数据(raw data) 包括: 各个内存池的当前使用情况, 各个内存池的总容量, 每次G ...

  6. GC调优入门笔记

    想给项目代码做做调优但有许多疑惑,比如有哪些参数要调.怎么调.使用什么工具.调优的效果如何定量测量等.发现Oracle的这份资料不错,简洁直接,回答了我的许多问题,给了许多很实用的大方向上的指导.将其 ...

  7. gc调优我们到底在调整什么

    java开发一般都会涉及到jvm调优其中gc调优是个重点项.那gc调优调整的究竟是什么呢准确来说是业务.下面围绕这个话题展开 起因 为什么说是业务呢得从cc++开始说起如果说是用c/c++做开发运行的 ...

  8. 深入JVM系列(二)之GC机制、收集器与GC调优

    一.回想JVM内存分配 须要了解很多其它内存模式与内存分配的,请看 深入JVM系列(一)之内存模型与内存分配 1.1.内存分配: 1.对象优先在EDEN分配 2.大对象直接进入老年代  3.长期存活的 ...

  9. 6. GC 调优(工具篇) - GC參考手冊

    进行GC性能调优时, 须要明白了解, 当前的GC行为对系统和用户有多大的影响. 有多种监控GC的工具和方法, 本章将逐一介绍经常使用的工具. 您应该已经阅读了前面的章节: 垃圾收集简单介绍 - GC參 ...

  10. 深入JVM系列(二)之GC机制、收集器与GC调优(转)

    一.回顾JVM内存分配   需要了解更多内存模式与内存分配的,请看 深入JVM系列(一)之内存模型与内存分配 1.1.内存分配: 1.对象优先在EDEN分配2.大对象直接进入老年代 3.长期存活的对象 ...

随机推荐

  1. JOIN 关联表中 ON、WHERE 后面跟条件的区别

    SQL中join连接查询时条件放在on后与where后的区别 数据库在通过连接两张或多张表来返回记录时,都会生成一张中间的临时表,然后再将这张临时表返回给用户. 在使用left jion时,on和wh ...

  2. Go 并发编程 - Goroutine 基础 (一)

    基础概念 进程与线程 进程是一次程序在操作系统执行的过程,需要消耗一定的CPU.时间.内存.IO等.每个进程都拥有着独立的内存空间和系统资源.进程之间的内存是不共享的.通常需要使用 IPC 机制进行数 ...

  3. 深入了解商品详情API接口的使用方法与数据获取

    ​ 作为程序员,了解和熟悉如何调用API接口获取淘宝商品数据是非常重要的.在现今的电商环境中,准确.及时地获取商品详情信息对于开发者和商家来说至关重要.本文将以程序员的视角,详细介绍如何调用API接口 ...

  4. 国内镜像安装Python解释器及扩展包

    一.下载Python解释器 1.下载地址 官网(下载速度很慢):Welcome to Python.org 淘宝镜像(推荐):CNPM Binaries Mirror (npmmirror.com) ...

  5. Spring Boot 中使用 Poi-tl 渲染数据并生成 Word 文档

    本文 Demo 已收录到 demo-for-all-in-java 项目中,欢迎大家 star 支持!后续将持续更新! 前言 产品经理急冲冲地走了过来.「现在需要将按这些数据生成一个 Word 报告文 ...

  6. Linux中python更换pip源

    Linux中python更换pip源 执行以下命令,更换为阿里源: echo " [global] trusted-host = mirrors.aliyun.com index-url = ...

  7. Linux平台Oracle 23c单实例 安装部署配置 快速参考

    转眼间已经2023年,再有一周就要过年了,在这里先给大家拜个早年,祝大家新的一年万事顺利. Oracle如今版本号也和年份挂钩,在前段时间的OCW上也宣布发布了beta版本的23c,因为23c是继19 ...

  8. Linux系列教程——Linux文件编辑、Linux用户管理

    @ 目录 1 Linux基本权限 1.权限基本概述 1.什么是权限? 2.为什么要有权限? 3.权限与用户之间的关系? 4.权限中的rwx分别代表什么含义? 2.权限设置示例 1.为什么要设定权限,我 ...

  9. RatingBar android 自定义 评级 星星

    资源下载地址 <!-- xml 中的使用 --> <RatingBar android:id="@+id/ratingBar" android:layout_wi ...

  10. oracle命令3 冷备份

    用户管理的备份:备份脚本要自己写:备份哪些文件要自己选:恢复时要复制那些文件自己判断:恢复需要的日志,自己找: 备份,需要备份保存关键SCN信息的文件:一次完成的备份包括:控制文件,数据文件,日志文件 ...