1 堆内存
运行时内存区域
G1主要管理堆内存。
对象创建
- Eden区:新创建的对象首先会尝试使用TLAB的内存,失败才会直接使用共享的堆内存分配到,所在区域为Eden区(JIT等优化使用逃逸分析,如果对象没有在方法外被使用,可能会使用栈上分配)。
- Survivor区:当Eden区满时,G1会触发Young GC。在Young GC过程中,Eden区中仍然存活的对象会被复制到Survivor区。
- Humongous区:对于大对象(超过一个Region的50%),它们会被直接分配到Humongous区,并且可能跨多个连续Region。如果H区最多存在一个对象,剩余的空间都不会被使用。只会被回收,不会被移动。
2 回收策略
2.1 分区域回收
- G1将堆内存分为多个大小相等的区域(Region),每个区域都可以是Eden区、Survivor区或者Old区。G1有专门分配大对象的Region,称为Humongous区。在G1中,大对象的判定规则是一个大对象超过了一个Region大小的50%,比如每个Region是2M,只要一个对象超过了1M,就会被放入Humongous区中。同一个巨型对象分配到连续的Humongous Region。
- 同一个区的内存不一定是物理上紧挨着的
- Region是G1垃圾回收器中内存分配和回收的最小单位,避免了整个堆或者整个代的内存粒度的回收和处理
不同生命周期的对象使用的回收算法有所差异,因此G1也使用主流的分代回收算法。新生代使用复制算法处理短生命周期的对象,老年代则使用标记-整理算法避免产生太多内存碎片。
2.2 回收算法
Young GC
触发时机
-
Eden区域空间不足:
- 当Eden区域的空间不足以容纳新的对象分配时,会触发Young GC。
- 这是最常见的触发条件,也是Young GC的主要触发原因。
-
Heap Target Utilization:
- G1有一个目标利用率(Target Utilization),即希望堆内存达到的使用率。如果年轻代的利用率超过了这个目标利用率,G1可能会提前触发Young GC以降低利用率。
-
Max Pause Time Goal:
- G1有一个最大暂停时间目标(Max Pause Time Goal),即期望的最长GC暂停时间。如果预计下一个Young GC会导致暂停时间超过这个目标,G1可能会提前触发GC以尝试达到目标暂停时间。
-
Promotion Failure:
- 如果在复制阶段无法将对象提升到老年代,因为老年代没有足够的空间,这会导致一次失败的晋升(Promotion Failure)。
- 这种情况下,G1会触发Young GC,并可能伴随一次Mixed GC(混合垃圾回收),以便清理更多空间。
回收流程
-
初始标记(Initial Marking):这是一个短暂的STW(Stop-The-World)阶段,G1会标记所有从GC Roots直接可达的对象。
-
栈中的本地变量:
- 方法执行时创建的对象引用。
- 方法参数、局部变量等。
-
方法区中的静态变量:
- 类的静态字段中引用的对象。
-
方法区中的常量:
- 类的常量池中的引用(如字符串字面量)。
-
本地方法栈中的JNI(Native)引用:
- 由本地代码(C/C++等)持有的Java对象引用。
-
线程对象中的引用:
- 每个线程可能引用的一些对象,例如线程局部变量。
-
系统类加载器和引导类加载器:
- 加载了的类本身可以作为GC roots。
-
-
根区域扫描(Root Region Scanning):在这个阶段,G1会扫描所有根区域,识别出哪些对象是存活的。
-
更新RSet(Remembered Sets Update):RSet用于记录跨Region的引用关系。在这个阶段,G1会更新RSet信息,以确保在复制存活对象时能够正确处理这些引用。
-
对象复制(Object Copying) :G1将Eden区和Survivor区中的存活对象复制到另一个干净的Survivor区。这个过程是并行执行的,以提高效率。
-
处理引用(Reference Processing) :如果启用了软引用、弱引用等,G1会在这个阶段处理它们。
-
清理(Cleanup) :清理Eden区和原始Survivor区,移除所有未被复制的对象。
-
回收统计(Collection Statistics) :G1会收集这次Young GC的统计信息,用于指导未来的垃圾回收。
回收流程整体是STW的,因为耗时比较短,不需要与应用线程并发。ParallelGCThreads控制并发最大线程数。
Mixed GC
触发条件
- 单Young GC后,部分对象晋升到到老年代,这时候老年代占用超过-XX:InitiatingHeapOccupancyPercent(默认是45%),则会启动一次Young GC,然后开始并发标记(并发标记确定老年代区域中所有当前可到达(活动)的对象),并发标记结束后开始Mixed GC。
回收流程
仅限新生代阶段(Young-only phase)
正常新生代回收(Normal young collections)
这个阶段开始时,进行几次正常的新生代垃圾回收,将对象提升到老年代
Concurrent Start
当老年代占用达到一定阈值(InitiatingHeapOccupancyPercent)时,G1 会启动并发开始的新生代回收,这不仅执行正常的新生代回收,还开始标记过程,确定所有当前可达的(存活的)老年代区域对象,以便在随后的空间回收阶段保留。
并发标记
多线程并发标记老年代中所有对象,同时与应用线程并发。具体标记算法可看三色标记+SATB。期间可以伴随多次普通Young GC。
重新标记
重新处理并发标记期间应用线程变更的引用关系。在重新标记和清理期间,G1会计算需要清理的老年代区域。此阶段是STW的。
清理
这个阶段会STW(即应用线程暂停以进行垃圾收集),决定是否会有一个空间回收阶段紧随其后。如果确定需要进行Mixed GC,那么Cleanup阶段会通过一次“Prepare Mixed”年轻代回收为Mixed GC做准备。
空间回收(Mixed GC)
这个阶段除了会回收新生代外还会回收老年代,会伴随多次Mixed GC直至内存够用。
Full GC
触发条件
只有当收集活跃对象信息(例如并发标记)期间,内存不够用时或者Mixed GC后还是不够,才会使用Full GC作为备份的手段。整个阶段是多线程处理的,但是应用线程都是暂停的。
demo
-
背景:线上应用为8C16G,使用G1垃圾回收器,每天3-4次Full GC,每次Full GC pause长达3-4秒,导致应用期间RT耗时大大超过警报阈值。GC日志中看到“humongous allocation failure”关键字。
-
目标:Full GC比较频繁,期望频率低于一周一次。
-
行动:
- 由于生成堆转储文件会STW,线上机器防止二次伤害没有配置HeapDumpBeforeFullGC和HeapDumpAfterFullGC。在一次Full GC后,马上隔离该pod,然后执行dump操作:
// pod里应用进程pid是1 jmap -dump:format=b,file=/path/to/heapdump.hprof 1 - 使用jProfiler观察大对象,重点看占用连续内存的对象,比如String或者原始类型数组,发现有个200+M的字符串,然后一直按照引用关系找到下面代码,中间件Hunter提供的代码会序列使用他们注解的方法的入参,拼成一个String。
- 由于生成堆转储文件会STW,线上机器防止二次伤害没有配置HeapDumpBeforeFullGC和HeapDumpAfterFullGC。在一次Full GC后,马上隔离该pod,然后执行dump操作:
- 业务使用该注解的地方,发现很多都是较大的对象。
- 提供改进解法,注解中加个参数控制是否要收集入参,默认需要和历史逻辑兼容(几乎没用在看Trace的时候会关注方法的入参数,尤其入参比较复杂的时候,前台都是省略展示的,所以大部分场景下都没用)
- 升级二方包后发现频率有所降低,1天1次左右,还是没法忍受。日志还是Full GC失败,等Full GC后再次dump寻找未发现大对象。改变思路,观察Full GC前接口入参和出参比较大的接口,观看代码排查,发现很多接口有以下迷之写法,传个字符串进来然后再手动反序列化,结果也要手动序列化为字符串,这不妥妥的大对象吗,而且这些接口耗时还比较长,这内存不就一致占着了吗。
6. 代码一顿瞅发现之前这么做的目的是为了针对加密加压的接口,对requestBody和responseBody做同一的处理,通过Controller的入参的class类型来匹配是否要加密和加压,其实期间还生成了一个临时大byte数组。
7. 和前端约定使用指定的content-type来标识加密和加压的接口,并使用输入输出流避免产生临时的数组
8. 由于改动比较大,接口批量上线,每个接口上线也让前端做了开关,灰度期间有问题可以切换接口。第一批选了几个关键的接口,最终上线全量后Full GC频率下降到0,至今该服务没触发Full GC。
一些思考
虽然G1提供了很多参数让开发者可以根据自己应用的情况(对象大小,产生速率以及生命周期等维度)调整参数,但是大部分都是不需要调整的,开箱即用。而且从优化漏斗考虑,JVM的调整大部分时候看上去性价比比较低,往往没有应用层调整收益高。例如这个接口的流量是不是泡沫流量,数据可以从其它已经调用的接口中组装、部分字段是否需要。