JVM系列之二 分代、垃圾收集算法与垃圾收集器
前言
本文是笔者JVM系列的第二篇文章,尽我所能将Java堆的分代、垃圾收集算法与垃圾收集器讲出来,如果阅读过程中遇到突然出现的术语,请先参考【七、术语参考】,如文中有错误或表述不准确的地方,感谢评论指出。
一、垃圾回收概念
对象分配内存在堆上,当对象不再使用,它们就变成了垃圾,需要被清理以释放内存,这个过程称作垃圾收集(简称GC,Garbage Collection)。
二、对象是否死亡
垃圾回收需要确定哪些对象是不可用的,所以需要确定对象已“死”,针对对象判活有以下两种算法:
引用计数算法
:一个对象每被其他方法或对象引用,就为它的引用数+1,不再引用时引用数-1,当对象引用数为0时,对象已死。可达性分析
:基本思想是通过一系列可以作为GC Roots的对象作为起始点,向下搜索被关联的对象,当可达性分析完成、还未被关联的对象已死。
- 引用计数算法很难解决对象之间循环引用的问题,所以没有Java虚拟机使用此算法。
- 可作为GC Roots的对象有虚拟机栈中引用的对象、方法区静态引用的对象、方法区常量引用的对象、本地方法栈中JNI(Native方法)引用的对象
三、分代概念
了解了可达性分析就可以知道哪些对象已死,当GC发生时就需要在整个堆中所有对象开始可达性分析,随着堆的容量变大,每次GC要回收的对象就越多,分析的时间就越长,回收效率将大幅降低,而且很多对象常驻内存每次都被分析也会浪费时间。
于是,分代概念出现了,分代将整个堆分为年轻代与老年代,将存活周期短的对象放入年轻代,将存活周期长的对象放入老年代,当内存不足时,回收单个代比回收整个堆效率要高。
上图所示即堆内存划分,其中年轻代、老年代、永久代在Java堆实现,在Oracle JDK1.8后废弃了永久代的概念,将永久代移动到非堆内存中,改名为元空间。绿色表示已废弃,用作对比。
1、年轻代
年轻代回收频率高,为了进一步缩小回收范围,将年轻代又分为:
- Eden:新生对象首先会分配到这里
- Survivor:初生区回收后存活对象转移到幸存区,S0与S1使用时每次只用其中一块,回收时交换。
- S0(From Survior):发生回收的区域,存活对象复制到S1,下次运行时暂不使用
- S1(To Survior):接收S0的存活对象,作为下次运行时使用的区域
当年轻代发生GC后内存不足,则将Survivor区存活的对象按一定规则进入老年代,由老年代作年轻代的后备。年轻代内存不足还有我老年代嘛~
2、老年代
老年代处于堆中,活得久的对象都在这里。当年轻代放不下大对象时,将大对象和活得久的对象放到老年代,老年代的特点是回收频率低、这里的对象都长寿,没有其他内存对此代做后备。
3、永久代
永久代是 HotSpot 虚拟机中的概念,在堆中实现了方法区,与年轻代和老年代分堆内存。由于方法区中存有大量类元数据与字面量常量池,类元数据卸载条件苛刻等原因常驻此代,几乎不发生GC,永久代由此得名。同时也因为类元数据难以卸载,当永久代内存到达设置值时,容易发生OOM。
在Oracle JDK 1.8之后,将永久代移出了 JVM 堆内存,进入直接内存并改名为元空间,按直接内存回收内存的方式管理(不设定元空间大小时,只受限于剩余内存的余量;设定元空间最大值时,则在接近满时,由 JVM 进行一次垃圾回收,不再像堆中回收规则那么多。
可以说是永久代从亲娘家搬到了后娘家,不只改了姓,还变成了后娘养的😆)。
4、元空间
元空间是JDK 1.8后,使用直接内存(或称 堆外内存 或 非堆内存)实现了方法区,作为永久代的替代品。元空间不再分配在堆上,所以年轻代与老年代的可用内存会更多,也避免了因永久代 OOM 出现的 Full GC,减少了STW(Stop The World)与 GC(垃圾回收)的频率,提高了性能。
4.1. 元空间与永久代区别
- 分配位置:永久代分配在堆上;元空间分配在堆外内存。
- 回收时刻:永久代与老年代几乎同时进行GC,回收频率与;元空间使用量到达设置最大值前才会进行GC,默认此值无限,受物理内存限制。
- 设置最大内存值:永久代在划分最大值时就直接占用最大值空间;而元空间则依旧按原来的方式,用一些就申请稍大一些的内存,直到到达最大值后不再申请新内存。
四、垃圾收集算法
有了分代和对象可达性分析算法还不够,回收性能高低需要通过合适的垃圾回收算法决定。常见的垃圾回收算法有四种:标记-清除算法、复制算法、标记-整理算法、分代回收算法。
1、标记-清除算法
正如其名,先通过对象判活、标记已死对象,然后再统一回收的算法。
缺点:
- 效率不高
- 标记清除后会产生大量不连续的内存碎片,空间利用率低。
2、复制算法
复制算法将一块内存划分为两块,每次只使用其中一块,先将存活对象复制到另一块内存中,再回收已死对象,这样就解决了标记-清除算法回收后空间碎片的问题,实现简单、运行高效。
适用于年轻代,死亡对象多复制对象少的场景。
缺点:
- 代价高,需要将内存缩小为原来的一半。不适用于老年代,这种对象存活率较高的复制操作效率低。
- 需要其它内存作担保,防止出现GC后Survivor区仍无法分配内存的情况。
3、标记-整理算法
标记-整理算法算是对标记-清除算法的一个改进,但标记后并不是直接清理,而是将存活对象向一端移动,然后清理掉端边界以外的内存,其实可以视为“标记-移动-清除”。适用于老年代,存活对象多,死亡对象少的场景。
缺点:不适用于年轻代,存活对象少,死亡对象多的场景。
4、分代回收算法
有了分代的概念并了解了以上几种算法的优缺点,就可以针对不同分代的特点选用适合的GC算法。
- 年轻代对象朝生夕死,死亡对象远多于存活对象,且有老年代担保,使用复制算法更合适。
- 老年代对象存活率高,存活对象远多于死亡对象,没有内存担保,使用标记-清理或标记-整理更适合。
五、垃圾收集器
垃圾收集器是垃圾回收算法的具体实现,以下是JVM设计团队给出的各垃圾收集器适用的分代情况
以上图示,相连的垃圾回收器可配合使用。
1、Serial 收集器
Serial 收集器 是最早的垃圾收集器,使用复制算法,特点是启动单线程去清理,需暂停所有工作线程,有STW,直到收集完成。用于年轻代,是Client模式下默认的年轻代收集器。
1.1. Serial 执行流程
- 停止用户线程,STW
- 开启单线程使用复制算法,复制存活对象到To Survivor区
- 交换使用Survivor区指针
- 清理From Survivor区垃圾
- 恢复用户线程
1.2. VM 参数
-XX:+UseSerialGC
:使用 Serial 收集器,Client模式默认开启,其他模式关闭,打开此开关默认同时开启 Serial + Serial Old 收集器配合回收堆内存。
1.3. 举个例子
你妈(单线程)打扫卫生时,她会让你不要动(STW),把你弄乱的东西搬到不碍事(S1)的地方摆好(复制),接着她告诉你一会先去那个收拾好的地方玩(S1),然后开始扫地(清理S0)。扫完她退出房间,你又可以玩耍了 😄
2、ParNew 收集器
ParNew 收集器 是对 Serial 收集器的多线程版本,使用复制算法,特点是暂停所有工作线程,STW,开启多个GC线程并行垃圾收集,直到收集完成。用于年轻代,是Server模式下首选的收集器。原因是除Serial外,只有它能与CMS收集器配合工作。
2.1. ParNew 执行流程
- 停止用户线程,STW
- 开启多线程并行将存活对象复制到S0(To Survivor)区
- 交换使用Survivor区指针(原来使用S0换成S1,反之亦然)
- 并行清理S1(From Survivor)区垃圾
- 恢复用户线程
2.2. VM 参数
-XX:+UseParNewGC
:默认关闭,打开后使用ParNew + Serial Old收集器组合进行内存回收。
2.3. 举个例子
一家人(多线程)冲进你的房间,警告你不要动(STW),他们要一起打扫卫生(并行),把除垃圾外的东西移动到另一半房间中(复制存活对象到S1),然后再一起扫地脏的半个房间,完事后才让你动,并且只能去另一半还没使用的房间玩……
3、Parallel Scavenge 收集器
Parallel Scavenge 收集器与ParNew 收集器很类似,也是多线程并行、年轻代收集器,使用复制算法,用在 Server 模式下。与 ParNew 的区别在于,Parallel Scavenge更关注吞吐量,能实现自适应GC调优。
3.1. Parallel Scavenge 执行流程
- 停止用户线程,STW
- 开启多线程,并行复制存活对象到 S1 区
- 交换使用 Survivor 区指针
- 并行清理 S0 区垃圾
- 恢复用户线程
清理过程中PS收集器会计算停顿时间与吞吐量,停顿时间与吞吐量即将不满足前会提前停止垃圾收集,恢复用户线程。
3.2. VM 参数
-XX:+UseParallelGC
:Server模式默认开启,其他模式默认关闭,打开后使用Parallel Scavenge + Serial Old 收集器组合内存回收。-XX:MaxGCPauseMillis
: 控制最大垃圾收集停顿时间,单位毫秒,仅在使用Parallel Scavenge 收集器时生效。-XX:GCTimeRatio
: 直接控制吞吐量大小,默认值99,表示允许1%的时间用来GC,仅在Parallel Scavenge 收集器时生效。-XX:+UseAdaptiveSizePolicy
:自适应GC调优开关,默认开启,使用此参数则无需指定年轻代大小(-Xmn
)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio
)、晋升老年代对象大小(-XX:PretenureSizeThreshold
)等参数,由虚拟机自动设置。
3.3. 举个例子
一家人(多线程)冲进你的房间要打扫卫生,你和他们约定好“只给他们3分钟时间打扫卫生”(制定停顿时间或吞吐量要求),打扫卫生的时候你不会动(STW),就算时间到了没清理完,也别打扫了你还要学习呢(时间片用完退出GC,恢复用户线程)。于是可能出现他们隔一会进来打扫3分钟,过了一会又进来打扫了3分钟……由于打扫时间不是很长,你感觉还可以接受 🤔
4、Serial Old 收集器
Serial Old 收集器是Serial 收集器的老年代版本,也是单线程垃圾收集,使用标记-整理算法,主要用在Client模式下,是CMS收集器出现 Concurrent Mode Failure
错误时的后备方案。可与 Parallel Scavenge收集器配合使用。
4.1. Serial Old 执行流程
- 停止用户线程,STW
- 开启单线程使用标记-整理清理垃圾
- 清理完成,恢复用户线程
4.2. VM 参数
由 -XX:+UseSerialGC
或 开启CMS时作为备用方案连带使用。
4.3. 举个例子
你妈(单线程)过来扫地,先让你不要动(STW),这次妈妈看地上东西比较多(老年代存活对象多),决定不搬到桌子上了,只把地上有用的东西搬到地面的一个干净的角落(标记-整理),然后把其余的地方都扫了一遍(清理整理好的对象边界外的内存),然后她出了门,你又可以玩耍了(退出GC线程,恢复用户线程)。
5、Parallel Old 收集器
Parallel Old 收集器是Parallel Scavenge 收集器的老年代版本,使用多线程并行和标记-整理算法。注重吞吐量和CPU敏感的场合,优先考虑Parallel Scavenge与Parallel Old收集器配合使用。
5.1. Parallel Old 执行流程
- 停止用户线程,STW
- 开启多线程并行使用标记-整理清理垃圾
- 清理完成,恢复用户线程
5.2. VM 参数
-XX:+UseParallelOldGC
:默认关闭,开启后使用Parallel Scavenge + Parallel Old 收集器组合内存回收。
5.3. 举个例子
一家人(多线程)又冲进来帮忙打扫卫生,警告你不要动(STW),你说“我不动可以,但你们要3分钟后出去下,我要和女朋友打电话”(制定吞吐量或停顿时间计划),家人们露出会心的微笑并同意了你的建议,他们定睛一看屋子里垃圾不多,能用的东西很多但是乱(老年代存活对象多,垃圾少),为了省事把那些能用的东西一起(并行)搬到一个角落(标记-整理),然后把垃圾清理干净,让你从桌子上下来(恢复用户线程),出了门 😆
6、CMS 收集器
CMS(Concurrent Mark Sweep) 收集器目标是最短GC的停顿时间,特点是并发收集、GC停顿时间短,使用标记-清除算法。互联网应用与B/S系统服务端使用较多。
6.1. CMS 执行流程
此收集器工作流程相对复杂,大体分四个步骤:
- 初始标记(initial mark):STW停止工作线程,单标记线程标记与GCRoots直接关联的对象,标记完成工作线程继续执行。
- 并发标记(concurrent mark):标记与GCRoots间接关联的对象,并发标记不停止工作线程,标记线程与工作线程同时运行。
- 重新标记(remark):STW停止工作线程,开启多线程标记 并发标记 后工作线程产生的新垃圾,标记完成恢复工作线程运行。
- 并发清除(concurrent sweep):开始清理线程与工作线程并发执行。
工作流程步骤执行时间比较:并发清理 = 并发标记 > 重新标记 > 初始标记
笔者注:并发清理与并发标记时间不一定相等,两者都比较慢可视为约等于,没必要较真。
6.2. CMS收集器缺点
- 使用标记-清除算法,会出现大量不连续的内存碎片而导致Full GC。当出现
Concurrent Mode Failure
,则需要Serial Old收集器垫底,可能出现新的Full GC。 - CMS收集器对CPU敏感,当CPU数量少于4个时,对应用程序性能影响较大。
- 无法处理浮动垃圾,主要是并发清理时工作线程产生的垃圾需要下次GC时才能标记清理掉。
6.3. VM 参数
-XX:+UseConcMarkSweepGC
:默认关闭,使用ParNew + CMS + Serial Old组合内存回收,如果CMS收集器出现Concurrent Mode Failure
,则使用 Serial Old收集器作后备收集器。-XX:CMSInitiatingOccupancyFraction
:提高CMS启动阈值(老年代已用内存占比),降低回收频次。-XX:+UseCMSCompactAtFullCollection
:开启此参数,CMS将在即将Full GC前进行内存碎片的合并。-XX:CMSFullGCsBeforeCompaction
:设置执行多少次不整理碎片的Full GC后来一次带整理的,默认值为0,即每次Full GC时都进行碎片整理。
6.4. 举个例子
- 一开始,你妈(单线程)进来看看大体上哪些东西(GC Roots直接关联)需要要收拾,记到本子上(初始标记),但不让你动(STW)
- 你站累了,央求你妈,她让你下来玩,她接着仔细看还有哪些东西需要收拾记在小本子上(并发标记,用户线程仍在执行)
- 你下来后弄乱了点其它东西,你妈让你停下来(STW),把家人也叫过来帮忙看看本子上没有的新弄乱的东西(重新标记,记录新增的垃圾)
- 你妈熬不过你的央求,放你下来了(用户线程恢复运行),按着小本子上记的该收拾的东西移到一个干净的角落摆整齐(标记-整理),因为你也要动,会妨碍到她,她动作很慢(并发执行与用户线程抢时间片),这次清理完时,只是把她最后一次记录的东西收拾完了,你在她记录完后弄乱的东西只能她下次再来收拾。(CMS无法清理浮动垃圾)
7、G1 收集器
G1(Garbage First)是面向服务端应用的GC收集器,保留分代概念的同时,将堆分成多块同样大小的内存块Region,回收整个堆的内存,关注停顿时间。
7.1. G1 的特点
- 有价值的垃圾优先回收(最短时间内释放最多的内存的内存块)
- 回收范围是整个堆,保留年轻代与老年代的概念,将整个堆分成多个大小相等的独立区域(Region),年轻代与老年代物理不再隔离。
- 充分发挥多核CPU优势,采用并行与并发缩短STW时间。
- 没有空间碎片。整体可视作使用标记-整理算法,局部(两个Region间复制)可看作是复制算法,两种算法不会产生碎片。
- 可以准确设置在M毫秒内,停顿时间不得超过N毫秒。
7.2. G1 为什么能缩短停顿时间?
G1缩短停顿时间的秘决,是建立在通过 维护 Region垃圾价值 优先回收列表上的,根据允许的收集时间优先回收价值最大的Region,同时记录各Region中创建对象的引用关系集合(Remembered Set)来避免全堆扫描,以提高引用可达性分析与标记的时间。
7.3. G1 执行流程
G1收集器大致回收步骤:
- 初始标记:单线程标记GC Roots直接关联的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段与用户程序并发执行时,能在可用的Region中创建新对象,此阶段需要STW,耗时很短。
- 并发标记:与用户线程并发执行标记,从GC Roots开始进行可达性分析,标记存活对象,此阶段耗时较长。
- 最终标记:停顿用户线程,STW,多线程并行标记修正 并发标记 阶段用户线程运行产生的新垃圾。同时将记录信息合并到 Remembered Set 中,用作筛选回收的依据。
- 筛选回收:停顿用户线程,STW,根据Remembered Set 对各个Region回收价值进行排序,根据用户设置的停顿时间,制定回收计划,并多线程并行回收最有价值的Region。回收完成恢复用户线程。
G1 与 PS/PS Old相比,最大的好处是停顿时间更加可控,可预测。
7.4. VM 参数
-XX:+UseG1GC
:默认关闭,使用G1收集器回收堆内存。
7.5. 举个例子
- 房间在没住进来前,地面就已经按大理石地砖分成了好多等块的位置(Region)
- 你妈进来,让你别动(STW),大体上看看屋子哪些东西一眼就看得出乱,小本本记下来(初始标记,直接关联GC Roots),你和她约法三章,一会要写作业。(设置停顿时间)
- 她说你可以动了(恢复用户线程),你在各个地砖上玩,她在其他地砖上记哪些地砖上垃圾比较多,由于你也在动,她动作很慢,怕伤到你。(并发执行)
- 时间久了,你妈也累了,不让你动了(STW),叫家人进来一起帮忙记(多线程并行,最终标记)。
- 最后,家人一起把地砖上相邻的有用的东西放在一个地砖上,清理原来的地砖(相邻Region复制),最后把这些有用的东西移到一个角落摆整齐(整体看作标记-整理),看约定的时间到了,退出了你的房间(GC停顿时间到达,退出GC进程,恢复用户线程)。
7.6. 是否该选用 G1
如果当前使用的收集器满足需要,就不必使用 G1;如果应用追求低停顿,可以尝试 G1;如果你的应用追求吞吐量,那么使用PS/PS Old 收集器组合想必会更适合,G1对吞吐量没有什么提升。
六、内存溢出是什么?
内存溢出在Java中用 OutOfMemeryError
表示,当堆内存中无法为新生对象申请内存后,GC(垃圾回收)无效时,则报出内存溢出错误,简称 OOM 或 OOME。
1、OOM 出现的原因
- 老年代内存不足:java.lang.OutOfMemoryError:Javaheapspace
- 永久代内存不足:java.lang.OutOfMemoryError:PermGenspace (JDK 1.7及以前)
- 元空间内存设置较小:java.lang.OutOfMemoryError: Metaspace
- 代码bug,占用内存无法及时回收
2、出现OOM简单处理
- 如服务已设置
-XX:+HeapDumpOnOutMemoryError
及-Xloggc
指示出现OOM时进行生成堆Dump(堆快照文件),取堆Dump文件分析问题改正问题,重新部署新程序 - 如未设置,设置堆溢出生成Dump参数,重启服务,伺机问题复现时收集并分析堆Dump
- 设置堆内存最小值
-Xms
和最大值-Xmx
,最大值参考历史利用率设置 - 设置GC垃圾收集器为G1
- 启用GC日志
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps
,方便后期分析 - 如果是元空间内存溢出,调大
-XX:MaxMetaspaceSize
的值,如-XX:MaxMetaspaceSize=100m
七、术语参考
垃圾收集
(简称GC,Garbage Collection):垃圾收集,清理JVM中无用的对象,释放其占用的内存。STW
:垃圾收集过程中会出现Stop The World(STW)即停顿用户线程执行清理,清理完成后恢复用户线程继续运行。随着垃圾收集器越来越优秀,用户线程停顿的时间会越来越短,但仍未完全消除。安全点
(Safepoint):代码执行过程中特殊的位置,程序可暂停的点。发生GC时,需要用户线程跑到最近的安全点上才可以开始清理。- GC时用户线程未到达安全点解决方案:
- 抢先式中断 :发现需要中断时,线程未在安全点上,则让线程跑到最近安全点再中断。几乎没有虚拟机使用此方案。
- 主动式中断:当GC需要暂停线程时,在安全点处设置中断标记,用户线程主动去软件轮询这个标志,如果有中断标记即中断线程执行。
安全区域
:指在一段代码片段中,引用关系不会发生变化,在该区域的任何地方发生gc都是安全的。为了解决用户线程准备GC时不在安全点的问题。并行收集
:指的是多线程收集,但工作线程仍处于暂停状态。并发收集
:工作线程与垃圾收集线程同时执行。吞吐量
(Throughput):即工作线程执行的时间占程序总运行时间的比值。即吞吐量=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
。Full GC
:老年代GC,又名 Major GC。Minor GC
:年轻代GC。
八、如何选择GC收集器:
- 如果堆比较小,只有大约100m左右,使用
-XX:+UseSerialGC
- 如果应用运行在单核心CPU上,并且对停顿时间没要求,使用
XX:+UseSerialGC
- 追求最巅峰的性能(高吞吐量),并且对停顿时间要求不多或能满足要求时,使用
-XX:+UseParallelGC
- 响应时间比总吞吐量更重要,垃圾收集暂停必须保持最短时,使用
-XX:+UseG1GC
- 响应时间优先级高,且有巨大的堆,考虑升级jdk到11,并使用
-XX:+UseZGC
(OracleJDK)或-XX:+UseShenandoahGC
(OpenJDK) - 以上判断都建立在应用程序代码对新版本JDK的兼容性上,如果是旧版本项目依赖旧版本JDK,堆大概在6G及以下,使用CMS或许会更好,如果堆更大,可尝试G1。
总结
了解JVM虚拟机垃圾回收机制与垃圾收集器的实现适用场景,有助于解决线上出现Java程序内存溢出的问题。
本文系参考了《深入理解Java虚拟机 第2版》第3章内容整理的学习笔记,侵权删。
最后,由于本人水平有限,行文中难免出现错误,希望看官可以评论指出,感激不尽!对于各版本JDK的JVM调优参数会略有不同,请以使用版本文档为准!
参考
- 《深入理解Java虚拟机 第2版》周志明著
- https://blogs.oracle.com/jonthecollector/our-collectors
- https://blog.51cto.com/lizhenliang/2164876
- https://docs.oracle.com/en/java/javase/14/gctuning/available-collectors.html#GUID-9E4A6B11-BB94-424F-90EF-401287A1C333
本文同步于本人CSDN