JVM系列之GC

概述

垃圾回收关注的是堆heap内存,需解决三个问题:哪些内存需要回收?什么时候回收?如何回收?

GC如何发起

垃圾回收针对不同的分区又分为MinorGC和FullGC,不同分区的触发条件又有不同。总体来说GC的触发分为主动和被动两类:

  • 主动:程序显式调用System.gc()发起GC(不一定马上甚至不会GC)
  • 被动:内存分配失败,需要清理空间

无论哪种情况,GC发起的方式都是一致的:

  1. 需要GC的线程发起一个VM_Operation操作(这是一个基类,不同垃圾回收器发起各自的子类操作,如CMS收集器发起的是VM_GenCollectFullConcurrent)
  2. 该操作投递到一个队列中,JVM中有一个VMThread线程专门处理队列中的这些操作请求,该线程调用VM_Operation的evaluate函数来处理具体每一个操作
  3. VM_Operation的evaluate函数调用自身的doit虚函数
  4. 各垃圾回收器派生的VM_Operation子类覆盖doit方法,实现各自的垃圾回收处理工作,一个典型的C++多态的使用

引用计数法

在对象中添加一个引用计数器,每当一个地方引用它时,计数器就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

问题:循环引用,循环依赖

可达性分析法

又叫根搜索算法,通过一系列的GC Roots,也就是根对象作为起始节点集合,从根节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连。

步骤:

  1. 找到所有根节点,即根节点枚举。停顿时间是非常短暂且相对固定的
  2. 标记:从GC Roots往下继续遍历对象图。停顿时间随着java堆中的对象增加而增加的。

为了减少停顿时间,需要让垃圾回收器和用户线程同时运行,即并发标记。

理论前提:该算法的全过程都需要基于一个能保障一致性的快照中才能够分析,这意味着必须全程冻结用户线程的运行。

三色标记

在遍历对象图的过程中,把访问的对象按照<是否访问过>这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾回收器访问过。在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾回收器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其它的对象引用指向黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾回收器访问过,但这个对象至少存在一个引用还没有被扫描过。
    在这里插入图片描述
    灰色对象是黑色对象与白色对象之间的中间态。当标记过程结束后,只会有黑色和白色的对象,而白色的对象就是需要被回收的对象。

但垃圾回收器和用户线程同时运行的。
垃圾回收器在对象图上面标记颜色,而同时用户线程在修改引用关系,引用关系修改,对象图就发生变化,这样就有可能出现两种后果:

  • 一种是把原本消亡的对象错误的标记为存活,这不是好事,但是其实是可以容忍的,只不过产生一点逃过本次回收的浮动垃圾而已,下次清理就可以。
  • 一种是把原本存活的对象错误的标记为已消亡,这就是非常严重的后果,一个程序还需要使用的对象被回收,那程序肯定会因此发生错误。

被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程。

GC Roots

可达性分析算法的起点是一组称为GC Roots的对象,包括:

  1. VM栈(栈帧中的本地变量表)中的引用的对象。
  2. 方法区中的类静态属性引用的对象。
  3. 方法区中的常量引用的对象。
  4. 本地方法栈中JNI本地方法的引用对象。

HotSpot怎么快速找到GC Root?
之所以要快速,是因为,执行GC时,是Stop The World,进程响应中断,GC后还要进行对象引用链追溯、对象的复制拷贝等工作,故而GC Roots遍历需要极高的效率。

包括HotSpot在内的现代JVM采取用空间换时间的策略,核心思想:提前将GC Roots的位置信息记录起来,GC时,按图索骥,快速找到它们。

HotSpot使用一组称为OopMap的数据结构。ordinary object pointer,普通对象指针,就是指一个Java对象,在JVM中或Hotspot源码层面,对应一个C++实例。在Java层面,叫Java对象,在JVM层面,叫oop。与之对应的就是klass,就是一个Java类在JVM中对应的C++实例。Map实际上是地图。

在类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在栈和寄存器中哪些位置是引用。在GC扫描时,就可以直接知道哪些是可达对象。

GC Roots的位置信息也就是在OopMap中。HotSpot源码中关于OopMap相关数据的创建代码分散在各个地方,可以通过在源码目录下搜索new OopMap关键字找到它们。在函数返回,异常跳转,循环跳转等时刻,JVM将记录OopMap相关信息供后续GC时使用。

JVM需要知道一个64bit的数据是一个引用还是一个long型变量?如果它不知道的话,如何进行内存回收呢?

保守式GC和准确式GC:
保守式GC:虚拟机不能明确分辨上面说的问题,无法知道栈中的哪些是引用,采用保守的态度,如果一个数据看上去像是一个对象指针(比如这个数字指向堆区,那个位置刚好有一个对象头部),那么这种情况下就将其当作一个引用。这样把可能不是引用的也当成引用,现实点的说就是懒政,这种情况下是可能产生漏网之鱼没有被垃圾回收的
准确式GC:明确知道一个64bit的数字是一个long还是一个对象引用。现代商业JVM均采用这种更先进的方式,JVM知道栈中和对象的结构中每一个地址单元里装的是什么东西,不会错杀漏杀。

安全点:
HotSpot只在特定的位置生成OopMap,这些位置称为安全点。程序执行过程中并非所有地方都可以停下来开始GC,只有在到达安全点是才可以暂停。安全点的选定基本上以是否具有让程序长时间执行的特征选定的。比如说方法调用、循环跳转、异常跳转等。具有这些功能的指令才会产生Safepoint。

GC算法

  1. 标记-清除(Mark-Sweep)
  2. 复制(Copying)
  3. 标记-整理(Mark-Compact)
  4. 分代收集(Generational Collection),借助前面三种算法实现

标记-清除

Mark-Sweep,最基础:

  1. 标记出所有需要回收的对象
  2. 在标记完成后统一回收所有被标记的对象

不足:

  1. 效率问题:标记和清除两个过程的效率都不高
  2. 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程的中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

对应的垃圾收集器是CMS收集器

标记-整理

Mark-Compact,复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点, 提出标记-整理算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

对应的垃圾收集器是Serial Old收集器、Parallel Old收集器。

复制

Copying,为解决效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。

不足:内存缩小为原来的一半
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的(新生代)Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor当回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的额Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被浪费。当然,98%的对象可回收只是一般场景下的数据,没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配。

分代收集算法

当前商业虚拟机都采用这种算法,思想:对堆内存区域进行分代,新生代和老年代,不同的区域采用不同垃圾收集算法。新生代用复制算法,老年代用标记-整理或标记-清除算法。

跨代引用

年轻代引用老年代的这种跨代不需要单独处理。
但是老年代引用年轻代的会影响young gc,这种跨代需要处理

GC回收器

主要垃圾收集器如下,图中标出它们的工作区域、垃圾收集算法,以及配合关系:
在这里插入图片描述

Serial

最基础、历史最悠久的收集器。如同它的名字(串行),它是一个单线程工作的收集器,使用一个处理器或一条收集线程去完成垃圾收集工作,进行垃圾收集时,必须暂停其他所有工作线程(STW,独占式),直到垃圾收集结束。适合单CPU服务器。
Serial是一个新生代收集器,Serial Old是Serial收集器的的老年代版本。Serial/Serial Old收集器的运行过程如图:
在这里插入图片描述

ParNew

实质上是Serial收集器的多线程并行版本,使用多条线程进行垃圾收集,多CPU,停顿时间比Serial少。ParNew/Serial Old收集器运行示意图:
在这里插入图片描述

Parallel Scavenge

ParallerGC,一款新生代收集器,基于标记-复制算法实现,也能够并行收集。和ParNew有些类似,但Parallel Scavenge主要关注的是垃圾收集的吞吐量。高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。吞吐量,就是CPU用于运行用户代码的时间和总消耗时间的比值,比值越大,说明垃圾收集的占比越小。
在这里插入图片描述

Serial Old

Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

Parallel Old

Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

CMS

Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。同样是老年代的收集器,采用标记-清除算法。GC分为四步:

  • 初始标记(CMS initial mark):单线程运行,需要Stop The World,标记GC Roots能直达的对象
  • 并发标记(CMS concurrent mark):无停顿,和用户线程同时运行,从GC Roots直达对象开始遍历整个对象图
  • 重新标记(CMS remark):多线程运行,需要Stop The World,标记并发标记阶段产生对象
  • 并发清除(CMS concurrent sweep):无停顿,和用户线程同时运行,清理掉标记阶段标记的死亡的对象

CMS GC运行示意图如下:
在这里插入图片描述

G1

Garbage First(G1)GC是垃圾收集器的一个颠覆性的产物,开创局部收集的设计思路和基于Region的内存布局形式。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异。以前的收集器分代是划分新生代、老年代、持久代等。

G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。
在这里插入图片描述
这样就避免收集整个堆,而是按照若干个Region集进行收集,同时维护一个优先级列表,跟踪各个Region回收的价值,优先收集价值高的Region。

G1收集器的运行过程大致可划分为以下四个步骤:

  • 初始标记(initial mark),标记了从GC Root开始直接关联可达的对象。STW(Stop the World)执行
  • 并发标记(concurrent marking),和用户线程并发执行,从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象
  • 最终标记(Remark),STW,标记再并发标记过程中产生的垃圾
  • 筛选回收(Live Data Counting And Evacuation),制定回收计划,选择多个Region 构成回收集,把回收集中Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。需要STW
    在这里插入图片描述

G1 vs CMS

对比

  1. CMS收集器是获取最短回收停顿时间为目标的收集器,CMS工作时,GC工作线程与用户线程可以并发执行,以此来达到降低手机停顿时间的目的(只有初始标记和重新标记会STW)。但是CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降
  2. CMS仅作用于老年代,是基于标记清除算法,清理过程中会有大量的空间碎片
  3. CMS收集器无法处理浮动垃圾,由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉
  4. G1是一款面向服务端应用的垃圾收集器,适用于多核处理器、大内存容量的服务端系统。G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或CPU核心)来缩短STW的停顿时间,它满足短时间停顿的同时达到一个高的吞吐量
  5. 从JDK 9开始,G1成为默认的垃圾回收器。当应用有以下任何一种特性时非常适合用G1:Full GC持续时间太长或太频繁;对象的创建速率和存活率变动很大;应用不希望停顿时间长(长于0.5s甚至1s)
  6. G1将空间划分成很多块(Region),然后各自回收。堆比较大时可以采用,采用复制算法,碎片化问题不严重。整体上看属于标记整理算法,局部(region之间)属于复制算法
  7. G1 需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来更高的执行负载,影响效率。所以 CMS 在小内存应用上的表现要优于 G1,而大内存应用上 G1 更有优势,大小内存的界限是6GB到8GB。

有CMS,还要引入G1?G1主要解决内存碎片过多的问题。

CMS优点:CMS最主要的优点在名字上已经体现出来——并发收集、低停顿。

CMS3个明显的缺点:

  • Mark Sweep算法会导致内存碎片比较多
  • CMS的并发能力比较依赖于CPU资源,并发回收时垃圾收集线程可能会抢占用户线程的资源,导致用户程序性能下降
  • 并发清除阶段,用户线程依然在运行,会产生所谓的理浮动垃圾(Floating Garbage),本次垃圾收集无法处理浮动垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。

选择

如何选择GC,即需要考虑各个GC的适用场景:

  • Serial :如果应用程序有一个很小的内存空间(大约100 MB)亦或它在没有停顿时间要求的单线程处理器上运行
  • Parallel:如果优先考虑应用程序的峰值性能,并且没有时间要求要求,或者可以接受1秒或更长的停顿时间
  • CMS/G1:如果响应时间比吞吐量优先级高,或者垃圾收集暂停必须保持在大约1秒以内
  • ZGC:如果响应时间是高优先级的,或者堆空间比较大

GC种类

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的GC,分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的GC
    • 老年代收集(Major GC/Old GC):指目标只是老年代的GC。目前只有CMS收集器会有单独收集老年代的行为
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的GC。目前只有G1收集器会有这种行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的GC

Minor GC

新创建的对象优先在新生代Eden区进行分配,如果Eden区没有足够的空间时,就会触发Young GC来清理新生代。

频繁Minor GC?通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,可通过增大新生代空间-Xmn来降低Minor GC的频率。

Full GC

在这里插入图片描述
触发条件有多个,Full GC时会STW,STOP THE WORD。

  1. 在执行Young GC之前,JVM会进行空间分配担保,如果老年代的连续空间小于新生代对象的总大小(或历次晋升的平均大小),则触发一次Full GC
  2. 显式调用System.gc()方法
    此方法的调用是建议 JVM 进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,增加 Full GC 的频率,也即增加间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存。可通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc()
  3. jmap -dump等命令
  4. 大对象直接进入老年代,从年轻代晋升上来的老对象,尝试在老年代分配内存时,但是老年代内存空间不够
  5. Concurrent Mode Failure
    执行CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(有时空间不足是CMS GC时当前的浮动垃圾过多,导致暂时性的空间不足触发Full GC),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
  6. JDK 1.7 及以前的永久代空间不足
    在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出OOM,为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
  7. 老年代空间不足,老年代内存使用率过高,达到一定比例,也会触发Full GC
  • 方法区内存空间不足:如果方法区由永久代实现,永久代空间不足 Full GC。

频繁Full GC怎么办

Full GC的排查思路大概如下:

  1. 清楚从程序角度,有哪些原因导致FGC?
    • 大对象:系统一次性加载过多数据到内存中(如SQL查询未做分页),导致大对象进入老年代
    • 内存泄漏:频繁创建大量对象,无法被回收(比如IO对象使用完后未调用close方法释放资源),先引发FGC,最后导致OOM
    • 程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发FGC
    • 程序BUG
    • 代码中显式调用gc方法,包括自己的代码甚至框架中的代码
    • JVM参数设置问题:包括总内存大小、新生代和老年代的大小、Eden区和S区的大小、元空间大小、垃圾回收算法等
  2. 清楚排查问题时能使用哪些工具
    • 公司的监控系统:可全方位监控JVM的各项指标
    • JDK的自带工具,包括jmap、jstat等常用命令:
      # 查看堆内存各区域的使用率以及GC情况
      jstat -gcutil -h20 pid 1000
      # 查看堆内存中的存活对象,并按空间排序
      jmap -histo pid | head -n20
      # dump堆内存文件
      jmap -dump:format=b,file=heap pid
      
    • 可视化的堆内存分析工具:JVisualVM、MAT等
  3. 排查指南
    • 查看监控,以了解出现问题的时间点以及当前FGC的频率(可对比正常情况看频率是否正常)
    • 了解该时间点之前有没有程序上线、基础组件升级等情况
    • 了解JVM的参数设置,包括:堆空间各个区域的大小设置,新生代和老年代分别采用哪些垃圾收集器,然后分析JVM参数设置是否合理
    • 再对步骤1中列出的可能原因做排除法,其中元空间被打满、内存泄漏、代码显式调用gc方法比较容易排查
    • 针对大对象或者长生命周期对象导致的FGC,可通过jmap -histo命令并结合dump堆内存文件作进一步分析,需要先定位到可疑对象
    • 通过可疑对象定位到具体代码再次分析,这时候要结合GC原理和JVM参数设置,弄清楚可疑对象是否满足进入到老年代的条件才能下结论

对象什么时候会进入老年代

  1. 长期存活的对象将进入老年代
    在对象的对象头信息中存储着对象的迭代年龄,迭代年龄会在每次YoungGC之后对象的移区操作中增加,每一次移区年龄加一,当这个年龄达到15(默认)之后,这个对象将会被移入老年代。可以通过这个参数设置这个年龄值:- XX:MaxTenuringThreshold
  2. 大对象直接进入老年代
    有一些占用大量连续内存空间的对象在被加载就会直接进入老年代。这样的大对象一般是一些数组,长字符串之类的对。HotSpot虚拟机提供这个参数来设置:-XX:PretenureSizeThreshold
  3. 动态对象年龄判定
    为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到- XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
  4. 空间分配担保
    假如在Young GC之后,新生代仍然有大量对象存活,就需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代。

Stop The World如何让Java线程都停下来

对象移动后,引用如何修正?

垃圾回收的过程将伴随着对象的迁徙,而一旦对象迁徙之后,之前指向它的所有引用(包括栈里的引用、堆里对象的成员变量引用等等)都将失效。

参考

垃圾回收器比较: G1 vs CMS
Java GC的5个问题
9种常见的CMS GC问题分析与解决
GC知识

posted @ 2022-09-08 20:54  johnny233  阅读(33)  评论(0编辑  收藏  举报  来源