JVM垃圾回收机制(收集器、收集算法、卡表)

 2022-09-03
原文地址:https://blog.csdn.net/weixin_44196561/article/details/123908059

在java开发中,我们不需要过度的关注对象的回收和释放。因为JVM的垃圾回收机制可以帮助我们自动对内存中已经死亡或者长时间没有使用的对象进行清楚和回收来实现内存空间的有效利用,但是完全交由JVM来回收对象,那么就会增加回收性能的不确定性,所以面对特定业务场景就需要人为介入来实现垃圾回收的调优。

比如说对内存要求苛刻的情况下,需要提高对象的回收效率,在CPU使用率高的情况下就需要降低并发时垃圾回收的频率,所以说垃圾回收调优是一项必备技能。

JVM垃圾回收机制

一、回收发生在哪里?

二、什么时候回收

三、怎样去回收这些对象

一、回收发生在哪里

202209032228493691.png

在JVM内存区域中,主要由五个区域构成。方法区、堆、虚拟机栈、本地方法栈、程序计数器。红色部分区域为线程共享,蓝色为线程私有。

蓝色部分是线程私有的,他们会随着线程的创建而创建销毁而销毁,所以说这三个区域的内存分配和回收都是具有确定性的,即随着线程的创建而分配,随着线程的销毁而回收,那垃圾回收重点区域就是红色部分,在方法区中的话,主要就是回收废弃的常量和无用类。那堆主要是对对象的回收。所以垃圾回收关注的重点区域在于堆。

二、对象在什么时候可以被回收?

一般情况下,如果一个对象不再被引用了,那就代表该对象可以被回收。

有两种算法可以去判断该对象是否可以被回收。

引用计数法 :通过一个对象的引用计数器来判断该对象是否被引用了,每当该对象被引用时计数器就会加一,每当引用失效的时候,计数器就会减一,当对象的引用计数器的值为零的时候,就说明该对象不再被引用了,就可以被回收了。但是存在一个问题,虽然引用计数法实现简单,效率高,但是存在对象之间相互循环引用的问题。在主流的JVM虚拟机中,不会使用这种方法。以hotspot为例,它使用的是称之为可达性分析算法的一种算法。

202209032228510952.png

202209032228523023.png

202209032228532604.png

可达性分析 :它依赖于一些被称之为gc roots的对象。gc roots对象可以是虚拟机栈中引用的对象、方法区静态属性引用的对象、方法区常量引用的对象、本地方法栈中JNI引用的对象。以这些gc roots对象为起点,开始向下搜索,搜索走过的所有路径,那些能与gc roots对象联通的对象均为存活对象,如果gc roots到某一个节点是不可达的,没有联通的就说明这些对象就是不可用的,即为垃圾。

202209032228542285.png

以上两种算法都是基于引用的,在JDK1.2以后,JAVA对引用进行了扩充。分为四种引用。强引用、软引用、弱引用、虚引用。

强引用:被强引用关联的对象永远不会被垃圾回收。

软引用:被关联的对象当内存要溢出的时候就会去回收。

弱引用:只要发生垃圾回收就一定会被回收。

虚引用:唯一作用就是可以在回收的时候收到一个通知。

三、怎样去回收这些对象

在确定哪些垃圾可以被回收后,垃圾收集器要做的事情就是要进行垃圾回收。有几种常见的垃圾清除算法。

标记清除算法 :先对可回收的垃圾进行标记然后再清理掉,清理掉的垃圾区域就成了未使用的内存区域,但是它有一个很严重的问题,会产生大量的内存碎片,会导致我们没办法去申请一个比较大的连续的内存空间。

复制算法 :标记清除算法演化而来,解决了内存碎片问题,首先它将可用内存按容量来划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了之后,就把还活着的对象复制到另一块上面去,然后再把已使用过的内存空间一次性清理掉,这样就保证了内存空间的连续可用,也解决了内存碎片的问题,但是也有一个问题,空间必须被拆分为两半,如果有10G内存空间,但是你能分配的最大单个对象却只有5G。

标记整理算法 :它的标记过程依然和标记清除算法一样,但是后续不是直接对可回收对象进行清理,而是让所有存活对象都向一端进行移动,之后再清理掉边界以外的内存区域。这个效率不高,对内存变动频繁,需要整理所有存活对象的引用地址。

在融合以上三种基础的算法思想之后呢。就诞生了 分代回收算法 ,严格来说,它只是一套组合而已。首先,根据对象存活周期不同,可用将JAVA堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的算法。在新生代,每次垃圾收集时,都有大批对象死去,采用复制算法(效率高) ,在老年代,死得少,需要操作的次数就不是那么多,可以采用标记清除算法或者标记整理算法。


HotSpot垃圾分代回收算法

默认情况下,新生代占整个堆的三分之一空间;老年代占三分之二空间。

复制算法在新生代中具体操作过程

新生代内部通常又分为三个区,一个Eden区,两个survivor区。8:1:1。所有新对象都在Eden区中生成,等Eden区填满之后,将会触发一次新生代的垃圾回收,称之为minor GC。因为死得多,活得少,这时候就把少量存活的对象复制到两个survivor区中的任何一个,然后将eden区直接清空就完成了本地minor GC。当后面eden区又满了的时候,触发minor GC,此时不仅是要清除eden区的,之前被复制过去的survivor区可能也有垃圾对象,这时候就要将eden区和一个survivor区中存活对象复制到另一个survivor区,之后对这个survivor区和eden区进行清空。这样又完成了一个minor GC,每次需要打扫的都是一个eden区和一个survivor区。

当上面的重复次数多了,有的对象经过了很多次minor GC,已经变老了,相对年纪比较大,需要移动到老年代中去,这个年龄就是指的复制次数,新生代年龄默认阈值是15。超过15次minor GC还存活的对象放去老年代。在上面的过程中,有可能会出现存活的对象比较多,一个survivor区域装不到了,那么也会将那些存活对象放入老年代中去,这个就是新生代分配担保机制,当然这个情况很危险,这些对象过早老化了。老年代对象不都是来自于新生代,有一些大数组或者特别大的字符串会直接在老年代中进行创建,这样也就不存在晋升这个过程了。

在老年代进行标记整理清除算法的时候称为major GC。

HotSpot经典垃圾收集器

Serial :最古老的,是JDK1.3之前提供的一个唯一的垃圾收集器。Serial字面意思是串行的,反义词是并行,串行意味着它只能是单线程工作,不仅要求它是在一个cpu中的一个线程中执行的,它还要求了其他线程在其工作的时候都得停止,也就是说,它要求整个系统的工作方式在收集的时候是串行的。GC线程在执行的时候用户线程全部得停止下来直到收集线程结束。

202209032228558186.png

ParNew :Serial主要问题就算SWT时间过长,很耽搁事,ParNew就是多线程版本的Serial。用来减少SWT时间的。这个收集器只负责新生代,采用复制算法。

202209032228572157.png

Paraller Scavenge :简称PS。跟parnew差不多,也是新生代的收集器,依然采用的是复制算法也是多线程并行。它的出现主要是解决parnew没有解决的不确定性问题,虽然parnew可以减少SWT时间,但是这个减少的时间是不确定的。而PS的设计的目的是希望垃圾回收达到一个可控制吞吐量的一个状态。看下面的公式,肯定是吞吐量越高越好。为此PS提供了两个参数。一个是控制最大垃圾收集停顿时间(SWT)的,一个是控制吞吐量大小的。控制时间的时候其实就是要让收集垃圾时间变短也就是垃圾变少一点,调小了新生代。新生代调小了,时间短了但是新生代更容易满了,收集的频率也就上去了。频率上去,吞吐量就下来了(十分钟内,之前一次收集是5毫秒,现在变为了每一次收集是2毫秒,但是得收集3次。在十分钟这个限制内,也就是你代码运行的时间里总的垃圾收集时间从5变成了2*3,实际上是增大了垃圾收集时间)。PS还提供一个比较智能的参数:-XX:UseAdaptiveSizePolicy。它可以根据当前系统运行情况动态去调整新生代大小,Eden区域和Survivor区比例以及晋升老年代的对象年龄等,然后去到达一个最适合的停顿时间和吞吐量。通过这种参数去使用一种称之为GC 自适应的调节策略,这也是它的一个特点。

202209032228584178.png

202209032228595829.png

上面两个是用在新生代的,那么老年代就可以用万金油Serial,但是也要配合一个Parallel Old,简称PO,它采用的也是多线程和标记整理算法。


“并发标记”原理

因为HotSpot虚拟机中的所有垃圾回收器都是通过可达性分析来判断对象是否存活的,从而标记垃圾对象进行回收,这个标记阶段所有线程都会停下来(STW),因为要减少STW的这个时间,CMS和G1都是通过并发的可达性分析,也就是说并发标记的方式来减少停顿的,并发标记的时候进行可达性分析去判断对象存活的时候不需要用户线程停下来(同时进行),也就是说不需要stop the world,那么不需要的话,肯定会存在问题。

三色标记

2022090322290068810.png

白色表示这个对象没有被垃圾回收器访问过,黑色表示被垃圾回收器访问过了并且它的所有引用也被扫描过了,对于黑色对象,回收器不会再回过头来再次检查的,灰色表示它被垃圾回收器访问过了,但是这个对象还存在一些引用是没有被扫描的。

1)

2022090322290187811.png 扫描结束。黑色的就是存活对象,白色的就是没有被访问过的,就是垃圾。会被清理。上面这个过程,用户线程没有干扰我们。如果说有用户线程来干扰,会出现什么问题?

2)

2022090322290296212.png当我们正在处灰色对象的时候,灰色对象下面的引用被之前扫描过的黑色对象引用了。

3)

2022090322290435413.png 灰色原来的那个引用断了,被黑色的引用了,意味着用户线程修改了引用关系,这个时候灰色对象只能去扫描右边的白色对象了。

2022090322290541814.png 白色对象明明应该是黑色存活的,此时变成了白色当成垃圾清除掉了。

以上用三色标记过程就描述了“并发标记”过程中出现对象消失的问题。本质就是扫描过程中插入了一条或者多条从黑色对象到白色对象的新引用,并且同时去掉了灰色对象到该白色对象的直接引用或者间接引用。

出现对象消失的问题一定要同时满足上面颜色部分的两个条件。打破其中一个即可。第一种叫做增量更新,第二种叫做原始快照(satb)

CMS

CMS 全称 Concurrent Mark Sweep,是一款 并发的、使用标记-清除 算法、针对老年代的垃圾回收器,其最大特点是 让垃圾收集线程与用户线程同时工作

2022090322290788715.png

CMS收集器过程:

1)初始标记:使用 STW 出现短暂停顿,仅标记一下 GC Roots 能直接关联到的对象,速度很快

2)并发标记:进行 GC Roots 开始遍历整个对象图,在整个回收过程中耗时最长,不需要 STW,可以与用户线程并发运行

3)重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,比初始标记时间长但远比并发标记时间短,需要 STW(不停顿就会一直变化,采用写屏障 + 增量更新来避免漏标情况)

4)并发清除:清除标记为可以回收对象, 不需要移动存活对象 ,所以这个阶段可以与用户线程同时并发的

优点:并发收集低延迟。

缺点:无法清除浮动垃圾;因为是并发的,占用线程资源导致应用程序变慢,CPU利用率不够高;采用的标记清除算法要产生内存碎片。

参数设置:

  • -XX:+UseConcMarkSweepGC:手动指定使用 CMS 收集器执行内存回收任务

    开启该参数后会自动将 -XX:+UseParNewGC 打开,即:ParNew + CMS + Serial old的组合

  • -XX:CMSInitiatingoccupanyFraction:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收

    • JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68% 时,会执行一次CMS回收
    • JDK6 及以上版本默认值为 92%
  • -XX:+UseCMSCompactAtFullCollection:用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生,由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长

  • -XX:CMSFullGCsBeforecompaction设置在执行多少次 Full GC 后对内存空间进行压缩整理

  • -XX:ParallelCMSThreads:设置 CMS 的线程数量

    • CMS 默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads 是年轻代并行收集器的线程数
    • 收集线程占用的 CPU 资源多于25%,对用户程序影响可能较大;当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕

G1

2022090322290939116.png

G1(Garbage-First)是一款面向服务端应用的垃圾收集器, 应用于新生代和老年代 、采用标记-整理算法、软实时、低延迟、可设定目标(最大 STW 停顿时间)的垃圾回收器,用于代替 CMS,适用于较大的堆(>4 ~ 6G),在 JDK9 之后默认使用 G1。

G1 对比其他处理器的优点:

并发与并行:

  • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力,此时用户线程 STW
  • 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此不会在整个回收阶段发生完全阻塞应用程序的情况
  • 其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,JVM 的 GC 线程处理速度慢时,系统会 调用应用程序线程加速垃圾回收 过程

分区算法

从分代上看,G1 属于分代型垃圾回收器,区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。从堆结构上看, 新生代和老年代不再物理隔离 ,不用担心每个代内存是否足够,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC

将整个堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32 MB之间且为 2 的 N 次幂,所有 Region 大小相同,在 JVM 生命周期内不会被改变。G1 把堆划分成多个大小相等的独立区域,使得每个小空间可以单独进行垃圾回收

新的区域 Humongous :本身属于老年代区,当出现了一个巨型对象超出了分区容量的一半,该对象就会进入到该区域。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储,为了能找到连续的 H 区,有时候不得不启动 Full GC

G1 不会对巨型对象进行拷贝,回收时被优先考虑,G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉

空间整合:

  • CMS:标记-清除算法、内存碎片、若干次 GC 后进行一次碎片整理
  • G1:整体来看是基于标记 - 整理算法实现的收集器,从局部(Region 之间)上来看是基于复制算法实现的,两种算法都可以避免内存碎片。
  • 可预测的停顿时间模型(软实时 soft real-time) :可以指定在 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒 ,由于分块的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,对于全局停顿情况也能得到较好的控制,G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间,通过过去回收的经验获得),在后台维护一个 优先列表 ,每次根据允许的收集时间优先回收价值最大的 Region,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

缺点:

G1需要维护卡表,更占内存了,执行负载比较高。

应用场景:

  • 面向服务端应用,针对具有大内存、多处理器的机器
  • 需要低 GC 延迟,并具有大堆的应用程序提供解决方案

G1收集器过程:

1)初始标记

2)并发标记

3)最终标记

原始快照:要去删除一个引用关系的时候,一般是灰色对象指向白色对象,此时记录删除操作,并发标记结束之后,根据记录再去扫描一次看是否被删除。


跨代引用、卡表、写屏障

跨代引用 :在进行minor GC的时候,先扫描新生代进行可达性分析来标记出哪些是垃圾对象然后进行清理,如果只扫描新生代的话,那肯定会有一些对象没有被新生代中的其他对象引用的,但是有一些对象是被老年代中的对象引用的,那么在进行minor GC的时候,这个对象就不应该被清理掉。如下图

2022090322291106717.png

所以说,不能简单的认为,在新生代中一个不可达对象它一定是垃圾。那此时就应该去遍历老年代,看这些对象有没有被老年代中的对象引用,如果没有的话肯定就是垃圾了,但是扫描整个老年代代价太大了,其实发生跨代引用的现象是非常少的,所以没有必要因为极少数的跨代引用对象去扫描整个老年代。解决办法就是,在新生代中建立一个称之为 记忆集(卡表) 的数据结构,这个结构其实就是一个指针的集合,每个指针都是指向一个非收集区域,这个卡表存储在新生代中记录着老年代中的对象是否有引用新生代的对象,就可以知道哪些对象发生了跨代引用,此时就不需要去扫描整个老年代了,这个指针指向的是一个内存区域,不是指向对象,因为维护对象消耗太大了。这个对象和内存区域的区别叫做卡精度,通过卡精度去实现记忆集称之为卡表。后面进行Minor GC的时候就根据卡表去把老年代那些存在跨代引用的区域中的对象全部进行计算。这样就避免了扫描整个老年代。同时这个卡表是动态维护的,因为运行时区域的引用可能发生变化,比如说CMS和G1收集器工作的时候用户线程修改了引用。

引用发生变化(赋值的那一刻)如何去维护卡表。HotSpot通过 写屏障 技术来维护卡表。其实就是一个AOP切面通知。

2022090322291260118.png

各种收集器对比

Serial收集器

Serial收集器作为最基础,历史最悠久的收集器,曾经是JDK1.3之前HotSpot虚拟机新生代收集器的唯一选择。
算法:标记-复制
缺点:他是一个单线程收集器,在执行垃圾收集时会出现"Stop The World",所有的用户线程暂停,即系统出现卡顿。
优点:因为它是单线程执行,在单核或者核数比较少的处理器环境中它没有线程切换的开销,专心做垃圾回收,可以获得最高的手机效率。
适用场景:桌面应用和近几年流行起来的部分微服务中,分配给虚拟机管理的内存非常小,收集一两百兆的新生代只需要几十毫秒,最多一百毫秒,
只要是频率不高,用户可以说是无感知,这类场景下还是一个非常好的选择。

ParNew收集器

ParNew是Serial的多线程版本,除了是多线程并行收集之外并没有其他的创新,但是在JDK7之前,它是除了Serial唯一一个可以和CMS收集器配合工作的收集器(关于CMS收集器后面会说,为什么要配合使用,HotSpot虚拟机是基于分代思想设计的,Serial和ParNew是新生代收集器,必须配合老年代收集器使用)。在JDK9,官方不再推荐ParNew+Serial Old(Serial的老年代收集器),所以ParNew只能和CMS配合使用,可以理解为ParNew并入和CMS,从此ParNew退出了历史舞台。单核系统中,ParNew并不会展示出比Serial更好的效果,在多核处理器中,默认开启的线程数和处理器核数相同,可以使用-XX:ParallerGCThread参数限制垃圾回收线程数。
算法:标记-复制

Parallel Scavenge(PS)收集器

也是一款新生代收集器,同样使用标记-复制算法实现,它和ParNew非常相似,其他收集器关注点是尽可能地缩短垃圾收集时的用户线程停顿时间,但是Paraller Scavenge目标时达到一个可控制的吞吐量。吞吐量就是处理器用于运行用户代码的时间和处理器总耗时的比值。

2022090322291410019.png

Serial Old 收集器

Serial Old是Serial收集器的老年代版本,单线程运行,基于标记整理算法,这个收集器的主要意义也是提供客户端模式下的HotSpot虚拟机使用。

Parallel Old收集器

Parallel Old是Parallel ScaVenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
适用场景:注重吞吐量或者处理器资源比较稀缺的场合

CMS收集器

它的出现是为了实现以最短回收停顿时间这一目标,在互联网网站或者基于浏览器的B/S架构中系统的响应速度是最为关注的,系统的卡顿会给用户带来非常不好的体验
它是基于标记-清除算法实现的,它的收集过程分为四个阶段

  1. 初始标记:会出现Stop The World,只是标记能被GC Roots直接关联到的对象,速度很快
  2. 并发标记:与户线程并发执行,从GC Roots直接关联的对象,也就是初始标记的对象开始遍历整个对象图,耗时比较长,但是不会出现
    Stop The World
  3. 重新标记:为了解决并发标记阶段因为用户线程继续运行导致的对象引用关系变化的部分(关于并发标记有一个增量更新和原始快照更新的区别,
    此处用的就是增量更新方法),该阶段会出现Stop The World,但是时间也是非常短
  4. 并发清除:清理掉通过前面三个标记阶段标记为已经死亡的对象,此阶段不会出现Stop The World,与用户线程并发执行

CMS垃圾收集器虽然并发环境下停顿短,但是因为他会占用一部分处理器资源,导致应用程序的资源占用量变小,吞吐量降低。
CMS默认开启线程数(处理器核心数+3)/4,即在四核及以上处理器中,垃圾收集占用资源不超过25%,核数越多,所占比例越小。
因为CMS并且因为是并发标记,在标记阶段用户线程还在执行,会出现新的垃圾,本次无法收集,只有下次再收集。它是基于标记清除算法,会让内存碎片化
当一个大对象要创建时找不到对应大小的空间,就会触发Full GC,JVM中提供了碎片整理的参数,
-XX:CompactAtFullCollection和-XX:CMSFulGCsBeforeCompaction,都是用于在Full出发之前进行一次碎片整理。

Garbage First-G1

G1收集器在JDK9取代了Parallel Sacvenge加Parallel Old的组合,CMS也被声明为不推荐使用,在G1出现之前其他的收集器收集的范围要么是整
个新生代,要么是整个老年代,要么是整个Java堆。而G1把Java堆划分为多个大小相同的独立的区域(Region),每个Region根据需要划分为新生代
的Eden空间,Suvivor空间或者老年代空间。Region中还有一类特殊的区域(Humongous),专门用于存储大对象,G1认为只要是大小超过Region一半
的对象就是大对象。
G1这种设计思路同时产生了几个问题,

  1. 如何解决跨代引用,(记忆集)
  2. 并发标记阶段如何保证垃圾收集线程和用户线程互不影响,使用原始快照,CMS是用的增量更新
  3. 垃圾收集的停顿时间如何设置

收集过程
a. 初始标记:标记GC Roots能够直接关联到的对象,修改TAMS指针值,让下一阶段用户线程并发运行时,能够正确的在可用的Region中分配对象,
会出现Stop The World
b. 并发a标记:从GC Roots开始,用可达性分析算法扫描整个堆中的对象图,找出需要回收的对象,与用户线程并发执行,扫描完对象图之后,还需要
重新处理SATB并发时有引用变动的对象
c. 最终标记:Stop The World,非常短暂,用于处理并发标记阶段结束后仍然遗留下来的SATB记录
d. 筛选回收:这一阶段会更新Region的统计结果,对各个Region的回收价值和成本进行排序,然后根据用户期望的停顿时间指定回收计划,可以选择多
个Region构成回收集,把决定回收的那一部分的Region中存活的对象复制到空的Region,然后清理掉旧的Region,这部操作涉及到对象的移动,所
以会停止用户线程,出现Stop The World

优点:可以指定最大停顿时间、Region的内存布局,按收益动态制定垃圾收集策略,整体看是标记-整理,但是局部又是标记-复制,两个维度看都不会产
生内存碎片,有利于程序长时间运行。
缺点:G1为了解决跨代引用的问题,有更多的卡表,并且卡表的设计比CMS更加复杂,所以相对于CMS占用更多的内存资源。