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、标记-清除算法

正如其名,先通过对象判活、标记已死对象,然后再统一回收的算法。

缺点:

  1. 效率不高
  2. 标记清除后会产生大量不连续的内存碎片,空间利用率低。

2、复制算法

复制算法将一块内存划分为两块,每次只使用其中一块,先将存活对象复制到另一块内存中,再回收已死对象,这样就解决了标记-清除算法回收后空间碎片的问题,实现简单、运行高效。
适用于年轻代,死亡对象多复制对象少的场景。

缺点:

  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. 举个例子

  1. 一开始,你妈(单线程)进来看看大体上哪些东西(GC Roots直接关联)需要要收拾,记到本子上(初始标记),但不让你动(STW)
  2. 你站累了,央求你妈,她让你下来玩,她接着仔细看还有哪些东西需要收拾记在小本子上(并发标记,用户线程仍在执行)
  3. 你下来后弄乱了点其它东西,你妈让你停下来(STW),把家人也叫过来帮忙看看本子上没有的新弄乱的东西(重新标记,记录新增的垃圾)
  4. 你妈熬不过你的央求,放你下来了(用户线程恢复运行),按着小本子上记的该收拾的东西移到一个干净的角落摆整齐(标记-整理),因为你也要动,会妨碍到她,她动作很慢(并发执行与用户线程抢时间片),这次清理完时,只是把她最后一次记录的东西收拾完了,你在她记录完后弄乱的东西只能她下次再来收拾。(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. 举个例子

  1. 房间在没住进来前,地面就已经按大理石地砖分成了好多等块的位置(Region)
  2. 你妈进来,让你别动(STW),大体上看看屋子哪些东西一眼就看得出乱,小本本记下来(初始标记,直接关联GC Roots),你和她约法三章,一会要写作业。(设置停顿时间)
  3. 她说你可以动了(恢复用户线程),你在各个地砖上玩,她在其他地砖上记哪些地砖上垃圾比较多,由于你也在动,她动作很慢,怕伤到你。(并发执行)
  4. 时间久了,你妈也累了,不让你动了(STW),叫家人进来一起帮忙记(多线程并行,最终标记)。
  5. 最后,家人一起把地砖上相邻的有用的东西放在一个地砖上,清理原来的地砖(相邻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时用户线程未到达安全点解决方案:
    1. 抢先式中断 :发现需要中断时,线程未在安全点上,则让线程跑到最近安全点再中断。几乎没有虚拟机使用此方案。
    2. 主动式中断:当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调优参数会略有不同,请以使用版本文档为准!

参考

本文同步于本人CSDN

posted @ 2021-02-18 09:37  东北小狐狸  阅读(453)  评论(0编辑  收藏  举报