详解垃圾回收算法、垃圾回收器、垃圾回收类型

1、到底谁是垃圾?

要进行垃圾回收,最为重要的一个问题是:判断谁是垃圾?

联想其日常生活中,如果一个东西经常没被使用,那么这个对象可以说就是垃圾。在 Java 中也是如此,如果一个对象不可能再被引用,那么这个对象就是垃圾,应该被回收。

根据这个思想,我们很容易想到使用引用计数的方法来判断垃圾。在一个对象被引用时加一,被去除引用时减一,这样我们就可以通过判断引用计数是否为零来判断一个对象是否为垃圾。这种方法我们一般称之为「引用计数法」。

上面的这种方法虽然简单,但是其存在一个致命的问题,那就是循环引用。

A 引用了 B,B 引用了 C,C 引用了 A,它们各自的引用计数都为 1。但是它们三个对象却从未被其他对象引用,只有它们自身互相引用。从垃圾的判断思想来看,它们三个确实是不被其他对象引用的,但是此时它们的引用计数却不为零。这就是引用计数法存在的循环引用问题。

而现今的 Java 虚拟机判断垃圾对象使用的是:GC Root Tracing 算法。其大概的过程是这样:从 GC Root 出发,所有可达的对象都是存活的对象,而所有不可达的对象都是垃圾。

可以看到这里最重要的就是 GC Root 这个集合了,其实 GC Root 就是一组活跃引用的集合。但是这个集合又与一般的对象集合不太一样,这些集合是经过特意筛选出来的,通常包括:

所有当前被加载的 Java 类
Java 类的引用类型静态变量
Java类的运行时常量池里的引用类型常量
VM的一些静态数据结构里指向GC堆里的对象的引用
等等
简单地说,GC Root 就是经过精心挑选的一组活跃引用,这些引用是肯定存活的。那么通过这些引用延伸到的对象,自然也是存活的。

2、如何进行垃圾回收?

1、标记-清除(Mark-Sweep)算法

这是最基础的算法,标记-清除算法就如同它的名字样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。
这种算法的不足主要体现在效率和空间,从效率的角度讲,标记和清除两个过程的效率都不高;从空间的角度讲,标记清除后会产生大量不连续的内存碎片, 内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。标记-清除算法执行过程如图:
在这里插入图片描述

2、复制(Copying)算法

复制算法是为了解决效率问题而出现的,它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。复制算法的执行过程如图:

在这里插入图片描述
不过这种算法有个缺点,内存缩小为了原来的一半,这样代价太高了。现在的商用虚拟机都采用这种算法来回收新生代,不过研究表明1:1的比例非常不科学,因此新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,意思是每次新生代中可用内存空间为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。

3.标记-整理(Mark-Compact)算法

复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。根据老年代的特点,有人提出了另外一种标记-整理算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。标记-整理算法的工作过程如图:
在这里插入图片描述

标记清除算法虽然会产生内存碎片,但是不需要移动太多对象,比较适合在存活对象比较多的情况。而复制算法虽然需要将内存空间折半,并且需要移动存活对象,但是其清理后不会有空间碎片,比较适合存活对象比较少的情况。而标记压缩算法,则是标记清除算法的优化版,减少了空间碎片。

4、分代思想

试想一下,如果我们单独采用任何一种算法,那么最终的垃圾回收效率都不会很好。其实 JVM 虚拟机的建造者们也是这么想的,因此在实际的垃圾回收算法中采用了分代算法。

所谓分代算法,就是根据 JVM 内存的不同内存区域,采用不同的垃圾回收算法。例如对于存活对象少的新生代区域,比较适合采用复制算法。这样只需要复制少量对象,便可完成垃圾回收,并且还不会有内存碎片。而对于老年代这种存活对象多的区域,比较适合采用标记压缩算法或标记清除算法,这样不需要移动太多的内存对象。

试想一下,如果没有采用分代算法,而在老年代中使用复制算法。在极端情况下,老年代对象的存活率可以达到100%,那么我们就需要复制这么多个对象到另外一个内存区域,这个工作量是非常庞大的。

在这里我们再深入地聊一聊新生代里采取的垃圾回收算法。如我们上面所说,新生代的特点是存活对象少,适合采用复制算法。而复制算法的一种最简单实现便是折半内存使用,另一半备用。但实际上我们知道,在实际的 JVM 新生代划分中,却不是采用等分为两块内存的形式。而是分为:Eden 区域、from 区域、to 区域 这三个区域。那么为什么 JVM 最终要采用这种形式,而不用 50% 等分为两个内存块的方式?

要解答这个问题,我们就需要先深入了解新生代对象的特点。根据IBM公司的研究表明,在新生代中的对象 98% 是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间。所以在HotSpot虚拟机中,JVM 将内存划分为一块较大的Eden空间和两块较小的Survivor空间,其大小占比是8:1:1。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Eden空间。

通过这种方式,内存的空间利用率达到了90%,只有10%的空间是浪费掉了。而如果通过均分为两块内存,则其内存利用率只有 50%,两者利用率相差了将近一倍。

5、分区思想

分代思想按照对象的生命周期长短将其分为了两个部分(新生代、老年代),但 JVM 中其实还有一个分区思想,即将整个堆空间划分成连续的不同小区间。

每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个区间,可以较好地控制 GC 时间。

3、垃圾回收器

讲完垃圾回收机制,我们在讲讲垃圾回收器。
总的来说,Java 虚拟机的垃圾回收器可以分为四大类别:串行回收器、并行回收器、CMS 回收器、G1 回收器

1、串行回收器

串行回收器是指使用单线程进行垃圾回收的回收器。因为每次回收时只有一个线程,因此串行回收器在并发能力较弱的计算机上,其专注性和独占性的特点往往能让其有更好的性能表现。

串行回收器可以在新生代和老年代使用,根据作用于不同的堆空间,分为新生代串行回收器和老年代串行回收器。

新生代串行回收器

串行收集器是所有垃圾回收器中最古老的一种,也是 JDK 中最基本的垃圾回收器之一。

在新生代串行回收器中使用的是复制算法。在串行回收器进行垃圾回收时,会触发 Stop-The-World 现象,即其他线程都需要暂停,等待垃圾回收完成。因此在某些情况下,其会造成较为糟糕的用户体验。

使用 -XX:+UseSerialGC 参数可以指定使用新生代串行收集器和老年代串行收集器。当虚拟机在 Client 模式下运行时,其默认使用该垃圾收集器。

老年代串行回收器

在老年代串行回收器中使用的是标记压缩算法。其与新生代串行收集器一样,只能串行、独占式地进行垃圾回收,因此也经常会有较长时间的 Stop-The-World 发生。

但老年代串行回收器的好处之一,就是其可以与多种新生代回收器配合使用。若要启用老年代串行回收器,可以尝试以下参数:

-XX:UseSerialGC:新生代、老年代都使用串行回收器。
-XX:UseParNewGC:新生代使用 ParNew 回收器,老年代使用串行回收器。
-XX:UseParallelGC:新生代使用 ParallelGC 回收器,老年代使用串行回收器。

2、并行回收器

并行回收器在串行回收器的基础上做了改进,其使用多线程进行垃圾回收。对于并行能力强的机器,可以有效缩短垃圾回收所使用的时间。

根据作用内存区域的不同,并行回收器也有三个不同的回收器:新生代 ParNew 回收器、新生代 ParallelGC 回收器、老年代 ParallelGC 回收器。

新生代 ParNew 回收器

新生代 ParNew 回收器工作在新生代,其只是简单地将串行回收器多线程化,其回收策略、算法以及参数和新生代串行回收器一样。

新生代 ParNew 回收器同样使用复制的垃圾回收算法,其垃圾收集过程中同样会触发 Stop-The-World 现象。但因为其使用多线程进行垃圾回收,因此在并发能力强的 CPU 上,其产生的停顿时间要短于串行回收器。

但在单 CPU 或并能能力弱的系统中,并行回收器效果会因为线程切换的原因,其实际表现反而不如串行回收器。

要开启新生代 ParNew 回收器,可以使用以下参数:

-XX:+UseParNewGC:新生代使用 ParNew 回收器,老年代使用串行回收器。
-XX:UseConcMarkSweepGC:新生代使用 ParNew 回收器,老年代使用 CMS。
-XX:ParallelGCThreads:指定 ParNew 回收器的工作线程数量。

新生代 Parallel GC 回收器

新生代 Parallel GC 回收器与新生代 ParNew 回收器非常类似,其也是使用复制算法,都是多线程、独占式的收集器,也会导致 Stop-The-World。但其余 ParNew 回收器的一个重大不同是:其非常注重系统的吞吐量。

之所以说新生代 Parallel GC 回收器非常注重系统吞吐量,是因为其有一个自适应 GC 调节策略。我们可以使用 -XX:+UseAdaptiveSizePolicy 参数打开这个策略,在这个模式下,新生代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数都会被自动调节,已达到堆大小、吞吐量、停顿时间的平衡点。

Parallel GC 回收器提供了两个重要参数用于控制系统的吞吐量。

-XX:MaxGCPauseMillis:设置最大垃圾收集停顿时间。在 ParallelGC 工作时,其会自动调整响应参数,将停顿时间控制在设置范围内。为了达到目的,其可能会使用较小的堆,但这会导致 GC 较为频繁。
-XX:GCTimeRatio:设置吞吐量大小,其实一个 0 - 100 的整数。假设 GCTimeRatio 的值为 n,那么系统将不花费超过 1/(1+n) 的时间用于垃圾手机。比如 GCTimeRatio 值为 19,那么系统用于垃圾收集的时间不超过 1 /(1+19) = 5%。默认情况下,它的取值是 99,即不超过 1% 的时间用于垃圾收集。
新生代 Parallel GC 回收器可以使用以下参数启用:

-XX:+UseParallelGC:新生代使用 Parallel 回收器,老年代使用串行回收器。
-XX:+UseParallelOldGC:新生代使用 ParallelGC 回收器,老年代使用 ParallelOldGC 回收器。

老年代 ParallelOldGC 回收器

老年代 ParallelOldGC 回收器也是一种多线程并发的回收器,与新生代 ParallelGC 收集器一样,其也是注重吞吐量的收集器,只不过其是作用于老年代。

ParallelOldGC 回收器使用的是标记压缩算法,只有在 JDK 1.6 中才可以使用。我们可以使用-XX:UseParallelOldGC参数在新生代中使用 ParallelGC 收集器,在老年代中使用 ParallelOldGC 收集器。参数 -XX:ParallelGCThreads也可以用于设置垃圾回收时的线程数量。

3、CMS 回收器

与 ParallelGC 和 ParallelOldGC 不同,CMS 回收器主要关注系统停顿时间。CMS 回收器全称为 Concurrent Mark Sweep,意为标记清除算法,其是一个使用多线程并行回收的垃圾回收器。

工作步骤

CMS 的主要工作步骤有:初始标记、并发标记、预清理、重新标记、并发清除和并发充值。其中初始标记和重新标记是独占系统资源的,而其他阶段则可以和用户线程一起执行。

在整个 CMS 回收过程中,默认情况下会有预清理的操作,我们可以关闭开关 -XX:-CMSPrecleaningEnabled 不进行预清理。因为重新标记是独占 CPU 的,因此如果新生代 GC 发生之后,立刻出发一次新生代 GC,那么停顿时间就会很长。为了避免这种情况,预处理时会刻意等待一次新生代 GC 的发生,之后在进行预处理。

主要参数

启动 CMS 回收器刻意使用参数:-XX:+UseConcMarkSweepGC,线程并发数量刻意通过 -XX:ConcGCThreads 或 -XX:ParallelCMSThreads 参数设定。

此外,我们还可以设置 -XX:CMSInitiatingOccupancyFraction 来指定老年代空间使用阈值。当老年代空间使用率达到这个阈值时,会执行一次 CMS 回收,而不像其他回收器一样等到内存不够用的时候才进行 GC。

我们之前说过标记清除算法的缺点是会产生内存碎片,因此 CMS 回收器会产生较多内存碎片。我们可以使用 XX:+UseCMSCompactAtFullCollection 参数让 CMS 在完成垃圾回收后,进行一次内存碎片整理。使用 -XX:CMSFullGCsBeforeCompaction 参数设置进行多少次 CMS 回收后,进行一次内存压缩。

此外,如果希望使用 CMS 回收 Perm 区,那么则可以打开 -XX:+CMSClassUnloadingEnabled 开关。打开该开关后,如果条件允许,那么系统会使用 CMS 的机制回收 Perm 区 Class 数据。

4、G1 回收器

G1 回收器是 JDK 1.7 中使用的全新垃圾回收器,从长期目标来看,其是为了取代 CMS 回收器。

G1 回收器拥有独特的垃圾回收策略,和之前所有垃圾回收器采用的垃圾回收策略不同。从分代看,G1 依然属于分代垃圾回收器。但它最大的改变是使用了分区算法,从而使得 Eden 区、From 区、Survivor 区和老年代等各块内存不必连续。

在 G1 回收器之前,所有的垃圾回收器其内存分配都是连续的一块内存,如下图所示。

在这里插入图片描述
而在 G1 回收器中,其将一大块的内存分为许多细小的区块,从而不要求内存是连续的。
在这里插入图片描述

从上图可以看到,每个Region被标记了 E、S、O 和 H,说明每个 Region 在运行时都充当了一种角色。所有标记为 E 的都是 Eden 区的内存,它们散落在内存的各个角落,并不要求内存连续。同理,Survivor 区、老年代(Old)也是如此。

从上图我们还可以看到 H 是以往算法中没有的,它代表 Humongous。这表示这些 Region 存储的是巨型对象(humongous object,H-obj),当新建对象大小超过 Region 大小一半时,直接在新的一个或多个连续 Region 中分配,并标记为 H。

堆内存中一个 Region 的大小可以通过 -XX:G1HeapRegionSize 参数指定,大小区间只能是1M、2M、4M、8M、16M 和 32M,总之是2的幂次方。如果G1HeapRegionSize 为默认值,即把设置的最小堆内存按照2048份均分,最后得到一个合理的大小。

工作步骤

G1 收集器的收集过程主要有四个阶段:

1、新生代 GC
2、并发标记周期
3、混合收集
4、如果需要,可能进行 FullGC

新生代 GC 与其他垃圾收集器的类似,就是清空 Eden 区,将存活对象移动到 Survivor 区,部分年龄到了就移动到老年代。

并发标记周期则分为:初始标记、根区域扫描、并发标记、重新标记、独占清理、并发清理阶段。其中初始标记、重新标记、独占清理是独占式的,会引起停顿。并且初始标记会引发一次新生代 GC。在这个阶段,所有将要被回收的区域会被 G1 记录在一个称之为 Collection Set 的集合中。

混合回收阶段会首先针对 Collection Set 中的内存进行回收,因为这些垃圾比例较高。G1 回收器的名字 Garbage First 就是这个意思,垃圾优先处理的意思。在混合回收的时候,也会执行多次新生代 GC 和 混合 GC,从而来进行内存的回收。

必要时进行 Full GC。当在回收阶段遇到内存不足时,G1 会停止垃圾回收并进行一次 Full GC,从而腾出更多空间进行垃圾回收。

相关参数

打开 G1 收集器,我们可以使用参数:`-XX:+UseG1GC。

设置目标最大停顿时间,可以使用参数:-XX:MaxGCPauseMillis。

设置 GC 工作线程数量,可以使用参数:-XX:ParallelGCThreads。

设置堆使用率触发并发标记周期的执行,可以使用参数:-XX:InitiatingHeapOccupancyPercent。

4、垃圾回收的类型

我们经常会听到许多垃圾回收的术语,例如:Minor GC、Major GC、Young GC、Old GC、Full GC、Stop-The-World 等。但这些 GC 术语到底指的是什么,它们之间的区别到底是什么?今天我们就来详细说说。

Minor GC

从年轻代空间回收内存被称为 Minor GC,有时候也称之为 Young GC。对于 Minor GC,你需要知道的一些点:

当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以 Eden 区越小,越频繁执行 Minor GC。
当年轻代中的 Eden 区分配满的时候,年轻代中的部分对象会晋升到老年代,所以 Minor GC 后老年代的占用量通常会有所升高。
质疑常规的认知,所有的 Minor GC 都会触发 Stop-The-World,停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的,因为大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 Survivor 区或者老年代空间。如果情况相反,即 Eden 区大部分新生对象不符合 GC 条件(即他们不被垃圾回收器收集),那么 Minor GC 执行时暂停的时间将会长很多(因为他们要JVM要将他们复制到 Survivor 区或老年代)。

Major GC

从老年代空间回收内存被称为 Major GC,有时候也称之为 Old GC。

许多 Major GC 是由 Minor GC 触发的,所以很多情况下将这两种 GC 分离是不太可能的。

Minor GC 作用于年轻代,Major GC 作用于老年代。 分配对象内存时发现内存不够,触发 Minor GC。Minor GC 会将对象移到老年代中,如果此时老年代空间不够,那么触发 Major GC。因此才会说,许多 Major GC 是由 Minor GC 引起的。

Full GC

Full GC 是清理整个堆空间 —— 包括年轻代、老年代和永久代(如果有的话)。因此 Full GC 可以说是 Minor GC 和 Major GC 的结合。

当准备要触发一次 Minor GC 时,如果发现年轻代的剩余空间比以往晋升的空间小,则不会触发 Minor GC 而是转为触发 Full GC。因为JVM此时认为:之前这么大空间的时候已经发生对象晋升了,那现在剩余空间更小了,那么很大概率上也会发生对象晋升。既然如此,那么我就直接帮你把事情给做了吧,直接来一次 Full GC,整理一下老年代和年轻代的空间。

另外,即在永久代分配空间但已经没有足够空间时,也会触发 Full GC。

Stop-The-World

Stop-The-World,中文一般翻译为全世界暂停,是指在进行垃圾回收时因为标记或清理的需要,必须让所有执行任务的线程停止执行任务,从而让垃圾回收线程回收垃圾的时间间隔。

在 Stop-The-World 这段时间里,所有非垃圾回收线程都无法工作,都暂停下来。只有等到垃圾回收线程工作完成才可以继续工作。可以看出,Stop-The-World 时间的长短将关系到应用程序的响应时间,因此在 GC 过程中,Stop-The-World 的时间是一个非常重要的指标。

参考文档:
1、https://www.cnblogs.com/xiaoxi/p/6486852.html
2、https://www.cnblogs.com/chanshuyi/p/jvm_serial_08_jvm_garbage_collection.html
3、https://www.cnblogs.com/chanshuyi/p/jvm_serial_09_jvm_garabage_collector.html
4、https://www.cnblogs.com/chanshuyi/p/jvm_serial_10_gc_type.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值