欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

老大难的Java GC原理和调优,看这篇就够了

程序员文章站 2023-11-06 14:28:34
概述 本文介绍GC基础原理和理论,GC调优方法思路和方法,基于Hotspot jdk1.8,学习之后将了解如何对生产系统出现的GC问题进行排查解决 阅读时长约30分钟,内容主要如下: GC基础原理,涉及调优目标,GC事件分类、JVM内存分配策略、GC日志分析等 CMS原理及调优 G1原理及调优 GC ......

老大难的Java GC原理和调优,看这篇就够了

概述

本文介绍gc基础原理和理论,gc调优方法思路和方法,基于hotspot jdk1.8,学习之后将了解如何对生产系统出现的gc问题进行排查解决

阅读时长约30分钟,内容主要如下:

  • gc基础原理,涉及调优目标,gc事件分类、jvm内存分配策略、gc日志分析等
  • cms原理及调优
  • g1原理及调优
  • gc问题排查和解决思路

gc基础原理

1 gc调优目标

大多数情况下对 java 程序进行gc调优, 主要关注两个目标:响应速度、吞吐量

  • 响应速度(responsiveness)
    响应速度指程序或系统对一个请求的响应有多迅速。比如,用户订单查询响应时间,对响应速度要求很高的系统,较大的停顿时间是不可接受的。调优的重点是在短的时间内快速响应

  • 吞吐量(throughput)
    吞吐量关注在一个特定时间段内应用系统的最大工作量,例如每小时批处理系统能完成的任务数量,在吞吐量方面优化的系统,较长的gc停顿时间也是可以接受的,因为高吞吐量应用更关心的是如何尽可能快地完成整个任务,不考虑快速响应用户请求

gc调优中,gc导致的应用暂停时间影响系统响应速度,gc处理线程的cpu使用率影响系统吞吐量

2 gc分代收集算法

现代的垃圾收集器基本都是采用分代收集算法,其主要思想:
将java的堆内存逻辑上分成两块:新生代、老年代,针对不同存活周期、不同大小的对象采取不同的垃圾回收策略

老大难的Java GC原理和调优,看这篇就够了

  • 新生代(young generation)

新生代又叫年轻代,大多数对象在新生代中被创建,很多对象的生命周期很短。每次新生代的垃圾回收(又称young gc、minor gc、ygc)后只有少量对象存活,所以使用复制算法,只需少量的复制操作成本就可以完成回收

新生代内又分三个区:一个eden区,两个survivor区(s0、s1,又称from survivor、to survivor),大部分对象在eden区中生成。当eden区满时,还存活的对象将被复制到两个survivor区(中的一个)。当这个survivor区满时,此区的存活且不满足晋升到老年代条件的对象将被复制到另外一个survivor区。对象每经历一次复制,年龄加1,达到晋升年龄阈值后,转移到老年代

  • 老年代(old generation)

在新生代中经历了n次垃圾回收后仍然存活的对象,就会被放到老年代,该区域中对象存活率高。老年代的垃圾回收通常使用“标记-整理”算法

3 gc事件分类

根据垃圾收集回收的区域不同,垃圾收集主要通常分为young gc、old gc、full gc、mixed gc

(1) young gc

新生代内存的垃圾收集事件称为young gc(又称minor gc),当jvm无法为新对象分配在新生代内存空间时总会触发 young gc,比如 eden 区占满时。新对象分配频率越高, young gc 的频率就越高

young gc 每次都会引起全线停顿(stop-the-world),暂停所有的应用线程,停顿时间相对老年代gc的造成的停顿,几乎可以忽略不计

(2) old gc 、full gc、mixed gc

old gc,只清理老年代空间的gc事件,只有cms的并发收集是这个模式
full gc,清理整个堆的gc事件,包括新生代、老年代、元空间等

  • mixed gc,清理整个新生代以及部分老年代的gc,只有g1有这个模式

4 gc日志分析

gc日志是一个很重要的工具,它准确记录了每一次的gc的执行时间和执行结果,通过分析gc日志可以调优堆设置和gc设置,或者改进应用程序的对象分配模式,开启的jvm启动参数如下:

-verbose:gc -xx:+printgcdetails -xx:+printgcdatestamps  -xx:+printgctimestamps

常见的young gc、full gc日志含义如下:

老大难的Java GC原理和调优,看这篇就够了

老大难的Java GC原理和调优,看这篇就够了

免费的gc日志图形分析工具推荐下面2个:

  • gcviewer,下载jar包直接运行
  • ,web工具,上传gc日志在线使用

5 内存分配策略

java提供的自动内存管理,可以归结为解决了对象的内存分配和回收的问题,前面已经介绍了内存回收,下面介绍几条最普遍的内存分配策略

  • 对象优先在eden区分配
    大多数情况下,对象在先新生代eden区中分配。当eden区没有足够空间进行分配时,虚拟机将发起一次young gc

  • 大对象之间进入老年代
    jvm提供了一个对象大小阈值参数(-xx:pretenuresizethreshold,默认值为0,代表不管多大都是先在eden中分配内存),大于参数设置的阈值值的对象直接在老年代分配,这样可以避免对象在eden及两个survivor直接发生大内存复制

  • 长期存活的对象将进入老年代
    对象每经历一次垃圾回收,且没被回收掉,它的年龄就增加1,大于年龄阈值参数(-xx:maxtenuringthreshold,默认15)的对象,将晋升到老年代中

  • 空间分配担保
    当进行young gc之前,jvm需要预估:老年代是否能够容纳young gc后新生代晋升到老年代的存活对象,以确定是否需要提前触发gc回收老年代空间,基于空间分配担保策略来计算:

continuesize:老年代最大可用连续空间

老大难的Java GC原理和调优,看这篇就够了

young gc之后如果成功(young gc后晋升对象能放入老年代),则代表担保成功,不用再进行full gc,提高性能;如果失败,则会出现“promotion failed”错误,代表担保失败,需要进行full gc

  • 动态年龄判定
    新生代对象的年龄可能没达到阈值(maxtenuringthreshold参数指定)就晋升老年代,如果young gc之后,新生代存活对象达到相同年龄所有对象大小的总和大于任一survivor空间(s0 或 s1总空间)的一半,此时s0或者s1区即将容纳不了存活的新生代对象,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到maxtenuringthreshold中要求的年龄

另外,如果young gc后s0或s1区不足以容纳:未达到晋升老年代条件的新生代存活对象,会导致这些存活对象直接进入老年代,需要尽量避免

cms原理及调优

1 名词解释

可达性分析算法:用于判断对象是否存活,基本思想是通过一系列称为“gc root”的对象作为起点(常见的gc root有系统类加载器、栈中的对象、处于激活状态的线程等),基于对象引用关系,从gc roots开始向下搜索,所走过的路径称为引用链,当一个对象到gc root没有任何引用链相连,证明对象不再存活

stop the world:gc过程中分析对象引用关系,为了保证分析结果的准确性,需要通过停顿所有java执行线程,保证引用关系不再动态变化,该停顿事件称为stop the world(stw)

safepoint:代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要gc,线程可以在这个位置暂停。hotspot采用主动中断的方式,让执行线程在运行期轮询是否需要暂停的标志,若需要则中断挂起

2 cms简介

cms(concurrent mark and swee 并发-标记-清除),是一款基于并发、使用标记清除算法的垃圾回收算法,只针对老年代进行垃圾回收。cms收集器工作时,尽可能让gc线程和用户线程并发执行,以达到降低stw时间的目的

通过以下命令行参数,启用cms垃圾收集器:

-xx:+useconcmarksweepgc

值得补充的是,下面介绍到的cms gc是指老年代的gc,而full gc指的是整个堆的gc事件,包括新生代、老年代、元空间等,两者有所区分

3 新生代垃圾回收

能与cms搭配使用的新生代垃圾收集器有serial收集器和parnew收集器。这2个收集器都采用标记复制算法,都会触发stw事件,停止所有的应用线程。不同之处在于,serial是单线程执行,parnew是多线程执行

老大难的Java GC原理和调优,看这篇就够了

4 老年代垃圾回收

cms gc以获取最小停顿时间为目的,尽可能减少stw时间,可以分为7个阶段

老大难的Java GC原理和调优,看这篇就够了

  • 阶段 1: 初始标记(initial mark)

此阶段的目标是标记老年代中所有存活的对象, 包括 gc root 的直接引用, 以及由新生代中存活对象所引用的对象,触发第一次stw事件

这个过程是支持多线程的(jdk7之前单线程,jdk8之后并行,可通过参数cmsparallelinitialmarkenabled调整)

老大难的Java GC原理和调优,看这篇就够了

  • 阶段 2: 并发标记(concurrent mark)

此阶段gc线程和应用线程并发执行,遍历阶段1初始标记出来的存活对象,然后继续递归标记这些对象可达的对象

老大难的Java GC原理和调优,看这篇就够了

  • 阶段 3: 并发预清理(concurrent preclean)

此阶段gc线程和应用线程也是并发执行,因为阶段2是与应用线程并发执行,可能有些引用关系已经发生改变。
通过卡片标记(card marking),提前把老年代空间逻辑划分为相等大小的区域(card),如果引用关系发生改变,jvm会将发生改变的区域标记位“脏区”(dirty card),然后在本阶段,这些脏区会被找出来,刷新引用关系,清除“脏区”标记

老大难的Java GC原理和调优,看这篇就够了

  • 阶段 4: 并发可取消的预清理(concurrent abortable preclean)

此阶段也不停止应用线程. 本阶段尝试在 stw 的 最终标记阶段(final remark)之前尽可能地多做一些工作,以减少应用暂停时间
在该阶段不断循环处理:标记老年代的可达对象、扫描处理dirty card区域中的对象,循环的终止条件有:
1 达到循环次数
2 达到循环执行时间阈值
3 新生代内存使用率达到阈值

  • 阶段 5: 最终标记(final remark)

这是gc事件中第二次(也是最后一次)stw阶段,目标是完成老年代中所有存活对象的标记。在此阶段执行:
1 遍历新生代对象,重新标记
2 根据gc roots,重新标记
3 遍历老年代的dirty card,重新标记

  • 阶段 6: 并发清除(concurrent sweep)

此阶段与应用程序并发执行,不需要stw停顿,根据标记结果清除垃圾对象

老大难的Java GC原理和调优,看这篇就够了

  • 阶段 7: 并发重置(concurrent reset)

此阶段与应用程序并发执行,重置cms算法相关的内部数据, 为下一次gc循环做准备

5 cms常见问题

最终标记阶段停顿时间过长问题

cms的gc停顿时间约80%都在最终标记阶段(final remark),若该阶段停顿时间过长,常见原因是新生代对老年代的无效引用,在上一阶段的并发可取消预清理阶段中,执行阈值时间内未完成循环,来不及触发young gc,清理这些无效引用

通过添加参数:-xx:+cmsscavengebeforeremark。在执行最终操作之前先触发young gc,从而减少新生代对老年代的无效引用,降低最终标记阶段的停顿,但如果在上个阶段(并发可取消的预清理)已触发young gc,也会重复触发young gc

并发模式失败(concurrent mode failure) & 晋升失败(promotion failed)问题

老大难的Java GC原理和调优,看这篇就够了

并发模式失败:当cms在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,cms 垃圾回收就会退化成单线程的full gc。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收

老大难的Java GC原理和调优,看这篇就够了

晋升失败:当新生代发生垃圾回收,老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败,此时会触发单线程且带压缩动作的full gc

并发模式失败和晋升失败都会导致长时间的停顿,常见解决思路如下:

  • 降低触发cms gc的阈值,即参数-xx:cmsinitiatingoccupancyfraction的值,让cms gc尽早执行,以保证有足够的空间
  • 增加cms线程数,即参数-xx:concgcthreads,
  • 增大老年代空间
  • 让对象尽量在新生代回收,避免进入老年代

内存碎片问题

通常cms的gc过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩,常见以下场景会触发内存碎片压缩:

  • 新生代young gc出现新生代晋升担保失败(promotion failed)
  • 程序主动执行system.gc()

可通过参数cmsfullgcsbeforecompaction的值,设置多少次full gc触发一次压缩,默认值为0,代表每次进入full gc都会触发压缩,带压缩动作的算法为上面提到的单线程serial old算法,暂停时间(stw)时间非常长,需要尽可能减少压缩时间

g1原理及调优

1 g1简介

g1(garbage-first)是一款面向服务器的垃圾收集器,支持新生代和老年代空间的垃圾收集,主要针对配备多核处理器及大容量内存的机器,g1最主要的设计目标是: 实现可预期及可配置的stw停顿时间

2 g1堆空间划分

老大难的Java GC原理和调优,看这篇就够了

  • region

为实现大内存空间的低停顿时间的回收,将划分为多个大小相等的region。每个小堆区都可能是 eden区,survivor区或者old区,但是在同一时刻只能属于某个代

在逻辑上, 所有的eden区和survivor区合起来就是新生代,所有的old区合起来就是老年代,且新生代和老年代各自的内存region区域由g1自动控制,不断变动

  • 巨型对象

当对象大小超过region的一半,则认为是巨型对象(humongous object),直接被分配到老年代的巨型对象区(humongous regions),这些巨型区域是一个连续的区域集,每一个region中最多有一个巨型对象,巨型对象可以占多个region

g1把堆内存划分成一个个region的意义在于:

  • 每次gc不必都去处理整个堆空间,而是每次只处理一部分region,实现大容量内存的gc
  • 通过计算每个region的回收价值,包括回收所需时间、可回收空间,在有限时间内尽可能回收更多的垃圾对象,把垃圾回收造成的停顿时间控制在预期配置的时间范围内,这也是g1名称的由来: garbage-first

3 g1工作模式

针对新生代和老年代,g1提供2种gc模式,young gc和mixed gc,两种会导致stop the world

  • young gc
    当新生代的空间不足时,g1触发young gc回收新生代空间
    young gc主要是对eden区进行gc,它在eden空间耗尽时触发,基于分代回收思想和复制算法,每次young gc都会选定所有新生代的region,同时计算下次young gc所需的eden区和survivor区的空间,动态调整新生代所占region个数来控制young gc开销

  • mixed gc
    当老年代空间达到阈值会触发mixed gc,选定所有新生代里的region,根据全局并发标记阶段(下面介绍到)统计得出收集收益高的若干老年代 region。在用户指定的开销目标范围内,尽可能选择收益高的老年代region进行gc,通过选择哪些老年代region和选择多少region来控制mixed gc开销

4 全局并发标记

全局并发标记主要是为mixed gc计算找出回收收益较高的region区域,具体分为5个阶段

老大难的Java GC原理和调优,看这篇就够了

  • 阶段 1: 初始标记(initial mark)
    暂停所有应用线程(stw),并发地进行标记从 gc root 开始直接可达的对象(原生栈对象、全局对象、jni 对象),当达到触发条件时,g1 并不会立即发起并发标记周期,而是等待下一次新生代收集,利用新生代收集的 stw 时间段,完成初始标记,这种方式称为借道(piggybacking)

  • 阶段 2: 根区域扫描(root region scan)
    在初始标记暂停结束后,新生代收集也完成的对象复制到 survivor 的工作,应用线程开始活跃起来;
    此时为了保证标记算法的正确性,所有新复制到 survivor 分区的对象,需要找出哪些对象存在对老年代对象的引用,把这些对象标记成根(root);
    这个过程称为根分区扫描(root region scanning),同时扫描的 suvivor 分区也被称为根分区(root region);
    根分区扫描必须在下一次新生代垃圾收集启动前完成(接下来并发标记的过程中,可能会被若干次新生代垃圾收集打断),因为每次 gc 会产生新的存活对象集合

  • 阶段 3: 并发标记(concurrent marking)
    标记线程与应用程序线程并行执行,标记各个堆中region的存活对象信息,这个步骤可能被新的 young gc 打断
    所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次新生代收集

  • 阶段 4: 再次标记(remark)
    和cms类似暂停所有应用线程(stw),以完成标记过程短暂地停止应用线程, 标记在并发标记阶段发生变化的对象,和所有未被标记的存活对象,同时完成存活数据计算

  • 阶段 5: 清理(cleanup)
    为即将到来的转移阶段做准备, 此阶段也为下一次标记执行所有必需的整理计算工作:
    • 整理更新每个region各自的rset(remember set,hashmap结构,记录有哪些老年代对象指向本region,key为指向本region的对象的引用,value为指向本region的具体card区域,通过rset可以确定region中对象存活信息,避免全堆扫描)
    • 回收不包含存活对象的region
    • 统计计算回收收益高(基于释放空间和暂停目标)的老年代分区集合

5 g1调优注意点

full gc问题

g1的正常处理流程中没有full gc,只有在垃圾回收处理不过来(或者主动触发)时才会出现, g1的full gc就是单线程执行的serial old gc,会导致非常长的stw,是调优的重点,需要尽量避免full gc,常见原因如下:

  • 程序主动执行system.gc()
  • 全局并发标记期间老年代空间被填满(并发模式失败)
  • mixed gc期间老年代空间被填满(晋升失败)
  • young gc时survivor空间和老年代没有足够空间容纳存活对象

类似cms,常见的解决是:

  • 增大-xx:concgcthreads=n 选项增加并发标记线程的数量,或者stw期间并行线程的数量:-xx:parallelgcthreads=n
  • 减小-xx:initiatingheapoccupancypercent 提前启动标记周期
  • 增大预留内存 -xx:g1reservepercent=n ,默认值是10,代表使用10%的堆内存为预留内存,当survivor区域没有足够空间容纳新晋升对象时会尝试使用预留内存

巨型对象分配

巨型对象区中的每个region中包含一个巨型对象,剩余空间不再利用,导致空间碎片化,当g1没有合适空间分配巨型对象时,g1会启动串行full gc来释放空间。可以通过增加 -xx:g1heapregionsize来增大region大小,这样一来,相当一部分的巨型对象就不再是巨型对象了,而是采用普通的分配方式

不要设置young区的大小

原因是为了尽量满足目标停顿时间,逻辑上的young区会进行动态调整。如果设置了大小,则会覆盖掉并且会禁用掉对停顿时间的控制

平均响应时间设置

使用应用的平均响应时间作为参考来设置maxgcpausemillis,jvm会尽量去满足该条件,可能是90%的请求或者更多的响应时间在这之内, 但是并不代表是所有的请求都能满足,平均响应时间设置过小会导致频繁gc

调优方法与思路

如何分析系统jvm gc运行状况及合理优化?

gc优化的核心思路在于:尽可能让对象在新生代中分配和回收,尽量避免过多对象进入老年代,导致对老年代频繁进行垃圾回收,同时给系统足够的内存减少新生代垃圾回收次数,进行系统分析和优化也是围绕着这个思路展开

1 分析系统的运行状况

  • 系统每秒请求数、每个请求创建多少对象,占用多少内存
  • young gc触发频率、对象进入老年代的速率
  • 老年代占用内存、full gc触发频率、full gc触发的原因、长时间full gc的原因

常用工具如下:

  • jstat
    jvm自带命令行工具,可用于统计内存分配速率、gc次数,gc耗时,常用命令格式
jstat -gc <pid> <统计间隔时间>  <统计次数>

输出返回值代表含义如下:

老大难的Java GC原理和调优,看这篇就够了

例如: jstat -gc 32683 1000 10 ,统计pid=32683的进程,每秒统计1次,统计10次

  • jmap
    jvm自带命令行工具,可用于了解系统运行时的对象分布,常用命令格式如下
// 命令行输出类名、类数量数量,类占用内存大小,
// 按照类占用内存大小降序排列
jmap -histo <pid>

// 生成堆内存转储快照,在当前目录下导出dump.hrpof的二进制文件,
// 可以用eclipse的mat图形化工具分析
jmap -dump:live,format=b,file=dump.hprof <pid>
  • jinfo
    命令格式
jinfo <pid> 

用来查看正在运行的 java 应用程序的扩展参数,包括java system属性和jvm命令行参数

其他gc工具

  • 监控告警系统:zabbix、prometheus、open-falcon
  • jdk自动实时内存监控工具:visualvm
  • 堆外内存监控: java visualvm安装buffer pools 插件、google perf工具、java nmt(native memory tracking)工具
  • gc日志分析:gcviewer、gceasy
  • gc参数检查和优化:

2 gc优化案例

  • 数据分析平台系统频繁full gc

平台主要对用户在app中行为进行定时分析统计,并支持报表导出,使用cms gc算法。数据分析师在使用中发现系统页面打开经常卡顿,通过jstat命令发现系统每次young gc后大约有10%的存活对象进入老年代。

原来是因为survivor区空间设置过小,每次young gc后存活对象在survivor区域放不下,提前进入老年代,通过调大survivor区,使得survivor区可以容纳young gc后存活对象,对象在survivor区经历多次young gc达到年龄阈值才进入老年代,调整之后每次young gc后进入老年代的存活对象稳定运行时仅几百kb,full gc频率大大降低

  • 业务对接网关oom

网关主要消费kafka数据,进行数据处理计算然后转发到另外的kafka队列,系统运行几个小时候出现oom,重启系统几个小时之后又oom,通过jmap导出堆内存,在eclipse mat工具分析才找出原因:代码中将某个业务kafka的topic数据进行日志异步打印,该业务数据量较大,大量对象堆积在内存中等待被打印,导致oom

  • 账号权限管理系统频繁长时间full gc

系统对外提供各种账号鉴权服务,使用时发现系统经常服务不可用,通过zabbix的监控平台监控发现系统频繁发生长时间full gc,且触发时老年代的堆内存通常并没有占满,发现原来是业务代码中调用了system.gc()

总结

gc问题可以说没有捷径,排查线上的性能问题本身就并不简单,除了将本文介绍到的原理和工具融会贯通,还需要我们不断去积累经验,真正做到性能最优

篇幅所限,不再展开介绍常见gc参数的使用,我发布在github:

参考

《java performance: the definitive guide》 scott oaks

《深入理解 java 虚拟机:jvm 高级特性与最佳实践(第二版》 周志华

java性能调优实战

getting started with the g1 garbage collector

gc参考手册-java版

请教g1算法的原理——rednaxelafx的回答

java hotspot g1 gc的一些关键技术——美团技术团队

老大难的Java GC原理和调优,看这篇就够了