前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JVM中常用的垃圾收集器和收集算法(超详解G1收集器)

JVM中常用的垃圾收集器和收集算法(超详解G1收集器)

原创
作者头像
天下之猴
修改2024-09-20 12:37:42
251
修改2024-09-20 12:37:42

前置知识

垃圾收集收集的对象是谁?

主要为对象, 而提到对象, 我们需要知道对象什么时候被回收? 主要是引用失效的时候, 那什么时候引用失效, 下面就要讲讲对象的四种引用了

  • 强引用

只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,JVM也会直接抛出OutOfMemoryError,不会去回收。如果想中断强引用与对象之间的联系,可以显示的将强引用赋值为null,这样一来,JVM就可以适时的回收对象了

代码语言:txt
复制
 Object obj = new Object();  
  • 软引用

软引用是用来描述一些非必需但仍有用的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。这种特性常常被用来实现缓存技术,比如网页缓存,图片缓存等。

在 JDK1.2 之后,用java.lang.ref.SoftReference类来表示软引用。

代码语言:txt
复制
SoftReference<Object> softRef = new SoftReference<>(new Object());
  • 弱引用

弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。在 JDK1.2 之后,用 java.lang.ref.WeakReference 来表示弱引用。

代码语言:txt
复制
WeakReference<Object> weakRef = new WeakReference<>(new Object());
  • 虚引用

虚引用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,在 JDK1.2 之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。

代码语言:txt
复制
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);

另外还有一种引用是被动引用, 被动引用不会触发类的初始化, 因此也不会占用多余的内存空间, 这里不做扩展, 感兴趣的评论区留言, 可以看到我们实际使用中发部分都是通过强引用的方式来引用对象的, 因此了解垃圾收集算法和垃圾收集器对我们代码的性能优化非常重要, 这也是为什么大家经常看到有人说while里面别new对象了

垃圾收集器在哪里回收?

垃圾收集器主要针对堆中的垃圾, 有人问为什么不清除栈的垃圾, 下图为jvm的内存分布图

在java中栈是线程私有的区域, 会随着线程的结束而释放, 而方法区的对象一般声明之后都是不可变的, 不会产生大量垃圾, 因此也不要清理,而推内存的结构如下

新生代分为

Eden区与Survivo区, Survivo区又分为From区和To区 (PS: 永久代, 方法区, 元空间这三者其实都是一样的作用, 不同版本实现不同,这里不做过多讨论)

怎么知道要不要清理的?

引用计数法

引用计数法有点类似于redis中通过setnx实现分布式锁一样, 当有一处引用的时候计数器+1, 当引用失效的时候计数器-1, 当一个计数器为0的时候, 代表该对象已"死",

这种方法实现简单, 效率也高, 但是解决不了对象循环依赖问题, 比如下面代码, 将对象置空后, 仍然不会被清理掉

代码语言:txt
复制
Student a = new Student();
Student b = new Student();
a.friend = b;
b.friend = a;
a = null;
b = null;

//强制进行gc之后,上述空间仍然存在
System.gc();

可达性分析算法

主流的jvm所采用的方法, 此算法的核心思想为 : 通过一系列称为 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为" 引用链 " ,当 GC Roots到一个对象没有任何的引用链相连时 ( 从 GC Roots 到这个对象不可达) 时,证明此对象是不可用的。以下图为例:

GC Roots可以是什么?

  • 虚拟机栈中引用的对象
    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  • 本地方法栈内 JNI(通常说的本地方法,用native关键字修饰的)引用的对象
  • 方法区中类静态属性引用的对象
    • 比如:Java类的引用类型静态变量
  • 方法区中常量引用的对象
    • 比如:字符串常量池(string Table)里的引用
  • 所有被同步锁 synchronized 持有的对象
  • Java虚拟机内部的引用。
  • 基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
  • 反映 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

为什么堆中不同区用不用的垃圾收集器用不同的垃圾收集算法

其实就是类似于大家家里有时候厨房有厨房专用的拖把, 客厅有客厅专用的拖把(不同的区有不同的垃圾收集器), 有时候垃圾少, 用扫帚扫一下就行了, 垃圾多的话, 扫帚扫完拖把拖, 饮料洒地上了,直接用拖把(不同的垃圾收集算法), 不同房间的打扫方式不同, 客厅的沙发下面可能很少拖, 而卧室的桌子下面只要打扫的时候就可能会拖(不同的区使用不同的算法),而不同的家庭对应不同版本的jdk, 有时候可能上个家庭天天都拖, 而下个家庭可能只偶尔扫一下, 大扫除的时候才拖

下面只讲述常见的垃圾收集器CMS和G1以及常见的垃圾收集算法标记-清除, 标记-赋值, 标记-整理法

垃圾收集算法

有人问为什么都有个标记开头啊?能不能直接去掉啊, 可以理解为标记就是先找到正在使用的空间,后面打扫的时候, 避免误伤友军, 主意啊这里标记的不是要清理的垃圾 ,是仍在使用的对象啊

标记-清除算法

这是最基础的收集算法, 作用于老年代, 通常被CMS垃圾收集器所使用(还会被其他老年代收集器使用, 本文这里不做讨论), 分为标记和清除两个阶段

标记阶段:

  1. 找到根节点, 即上述提到的 GC Roots节点
  2. 找到后, 遍历对象图, 通过深度优先搜索, 标记所有被访问到的对象为活动对象
  3. 当对象图中所有可达对象都被标记为活动对象时,标记阶段结束。

清除阶段:

  1. 垃圾回收器会扫描整个堆内存,检查每个对象的标记状态。
  2. 未被标记的对象被认定为垃圾对象,因为它们不可达(不与任何根对象相连)。
  3. 释放未被标记的垃圾对象所占用的内存空间, 值得注意的是这个过程可能包括将这些内存空间标记为可用,以便在之后的内存分配中再次使用。
  4. 内存整理, 这一步通常不是该算法的核心部分, 而是垃圾回收器的优化步骤.

标记 - 清除 算法的不足主要有两个 :

1. 效率问题 : 标记和清除这两个过程的效率都不高

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

标记-复制算法

"复制"算法是为了解决"标记-清理"的效率问题。将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,工作量变小了,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。

作用于新生代区域, 这种算法虽然效率高, 但是会浪费一般的内存空间用于复制对象, 不适用于老年代, 因为老年代存活的声明周期长, 复制的对象多了之后, 会增加复制成本, 另外如果对象之间存在引用关系, 那么引用指针也需要及进行更新, 这也是额外的开销

标记-整理算法

类似于快慢指针, 作用在老年代中,复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。

针对老年代的特点,提出了一种称之为 " 标记 - 整理算法 " 。标记过程仍与 " 标记 - 清除 " 过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存, 用伪代码描述大致如下

代码语言:txt
复制
int[] arr = {1,1,0,1,0,0,1}; //1表示已标记的对象, 0表示要清理的对象, 2表示可用空间
int fast = 0;
int slow = 0;
for(fast = 0; fast < arr.length; fast++){
    if(arr[i] = 1){
        arr[slow++] = arr[fast];
    }
}
for(int i = slow; i < fast; i++){
    arr[i] = 2;
}

垃圾收集器

实现连接的收集器,表示可以设置新生代和老年代的垃圾收集器配合使用。比如Serial能和Serial Old配合使用,而不能和Parallel Old配合使用

G1收集器

特点:多线程,内存分区(各分区大小相同,同一分区逻辑角色相同,都是新生代等,回收以分区为单位,复制到另一个分区),首先回收垃圾最多的分区,低停顿,使用暂停预测模型。新老年代,整体标记整理,局部复制,独自工作

哪块内存中放的垃圾数量最多, 回收收益大, 就回收哪块区域, 这就是G1收集器的Mixed GC模式

内存管理方式

G1收集器将堆内存划分为多个大小相等的heap区, 每个heap区都是逻辑上连续的一段内存(virtual memory). 其中一部分区域被当成收集器相同的角色(eden, survivor, old), 但每个角色的区域个数都不是固定的。这在内存使用上提供了更多的灵活性。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象,那如果一个对象超过了整个Region容量,G1会怎么处理?答案是 G1会将这个超大对象存放在N个连续Humongous Region区域, G1的大多数行为都会将该区域作为老年代来对待

这种分Region进行清理的方式相较于整个堆内存清理, 清理效率更高, 相当于有两个保洁阿姨, 一个保洁阿姨是等你家里垃圾多了之后在进行打扫, 另一个是看到你家里哪个地方有垃圾, 就进行清理, 对于阿姨来说, 第一种阿姨轻松点, 但作为居住的我们肯定喜欢第二种阿姨, 不知道这样说大家有没有感觉到G1带来的优点

怎么判断是否清理某块Region的

垃圾比例:G1 会监控每个 Region 中的垃圾比例,如果某个 Region 中的垃圾比例超过了一定的阈值,就会将该 Region 列入垃圾回收的候选列表。

空闲度:G1 也会考虑每个 Region 的空闲度,如果某个 Region 中的空闲度很高,可能会降低该 Region 被回收的优先级。

优先级:G1 会根据一些策略来确定哪些 Region 优先进行回收,通常会优先选择包含最多垃圾的 Region 进行回收,以最大程度地减少垃圾回收的成本。

将Java堆分成多个独立Region后, Region里面存在的跨Region引用对象如何解决?

问题产生原因

我们需要知道, G1进行可达性算法分析的时候, 判断的区域不是一整个堆, 而是一个Region一个Region来判断哪些对象需要清理的, Region只是一个逻辑上的内存区域, 当一个Region中的对象中包含另一个Region中的引用时, 这样G1也不知道引用的对象是否可达, 就会触发堆扫描, 自己亲自去看看, 被引用的那块区域到底是不是可达的

G1如何解决的

首先我们需要知道一个概念, 记忆集(Remembered Set), 什么是记忆集呢? 记忆集在存储上本质上是哈希表, key是Region的起始地址, Value是一个集合, 这个集合里面存储的元素是卡表的索引号. 集合里面的卡表为双向卡表(一般的卡表记录的是我指向谁, 双向卡表额外记录了谁指向我)

知道了数据集之后,当一个对象在一个 Region 中引用了另一个 Region 中的对象时,这个引用关系会被记录在 Remembered Set 中。当垃圾回收器需要扫描某个 Region 时,它可以通过 Remembered Set 快速定位到其他 Region 中可能存在的引用关系,告诉G1, 那块地方我在使用, 你别操心了奥, 从而避免堆扫描

Minor GC流程

触发条件

等到Eden区满了之后,会触发Minor GC。Minor GC同样也是会发生Stop The World的

具体步骤

  1. 根扫描, 参考上面提到过的收集算法中标记的步骤, 这里扫描的整块区域为堆,而不是Region了
  2. 更新, 在每个Region中,都有一个结果集用来记录其他Region和当前Region的关系, 对于年轻代和老年代的Region他的结果集只保存了老年代的引用, 年轻代的没有保留, 因为年轻代的会被清理, 之后将扫描的结果集信息进行处理, 虽然老年代不会不会直接持有新生代对象的引用, 但仍然可能存在跨代引用情况, 因此需要将老年代对象持有年轻代对象的相关引用都加入到GC Roots下,避免被回收掉
  3. 处理结果集,将扫描之后存活的对象往「空的Survivor区」或者「老年代」存放,其他的Eden区进行清除
  4. 处理下软引用、弱引用、JNI Weak等引用,结束收集

Mixed GC流程

  1. 初始标记(Initial Marking) :仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
    1. 这个过程是「共用」了Minor GC的 Stop The World(Mixed GC 一定会发生 Minor GC),复用了「扫描GC Roots」的操作。并且在这个过程中,老年代和新生代都会扫。
  2. 并发标记(Concurrent Marking) :从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记(Final Marking) :对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。如果在开始时,G1就认为它是活的,那就在此次GC中不会对它回收,即便可能在「并发阶段」上对象已经变为了垃圾。

所以,G1也有可能会存在「浮动垃圾」的问题。但是总的来说,对于G1而言,问题不大(毕竟它不是追求一次把所有的垃圾都清除掉,而是注重 Stop The World时间)

标记阶段完成后,G1就可以知道哪些heap区的empty空间最大。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

G1使用暂停预测模型(pause prediction model)来达到用户定义的目标暂停时间,并根据目标暂停时间来选择此次进行垃圾回收的heap区域数量.

需要强调的是, G1并不是一款实时垃圾收集器(real-time collector). 能以极高的概率在设定的目标暂停时间内完成,但不保证绝对在这个时间内完成。 基于以前收集的各种监控数据, G1会根据用户指定的目标时间来预估能回收多少个heap区. 因此,收集器有一个相当精确的heap区耗时计算模型,并根据该模型来确定在给定时间内去回收哪些heap区

4. 筛选回收(Live Data Counting and Evacuation) :负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根 据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的 存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程, 由多条收集器线程并行完成的。

一般来说,Mixed GC会选定所有的年轻代Region,部分「回收价值高」的老年代Region(回收价值高其实就是垃圾多)进行采集

被G1标记为适合回收的heap区将使用转移(evacuation)的方式进行垃圾回收. G1将一个或多个heap区域中的对象拷贝到其他的单个区域中,并在此过程中压缩和释放内存,基于标记-整理。

CMS

特点: 多线程,初始标记(单),并发标记(并发),重新标记(多线程,但与用户线程不并发),并发清除(并发,失败使用Serial Old),老年代,标记清除,可与Serial,ParNew搭配。

CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收 集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用 尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收 集器就非常符合这类应用的需求。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

清理步骤 :

  1. 初始标记(CMS initial mark), 这一步标记的是GC Roots能直接关联到的对象, 什么是直接关联的对象呢就是假把根对象的引用作为一个树, 树的第二层就是直接关联的对象, 再往更深的层次之后就是间接关联的, 还有年轻代中存有老年代引用的对象
  2. 并发标记(CMS concurrent mark), 这一步会遍历堆的所有区域, 找到所有的根节点, 进行可达性分析, 将所有可达的节点标记为存活状态, 值得注意的是这一步没有STW操作, 用户线程也在工作, 随时可能更新引用域,因此可能会在标记的过程中出现,本该被清理的对象没有被标记, 不该被清理的又被标记了,
  3. 并发预清理, 主要作用为减少重新标记所花费的时间, 上面提到过第二部没有STW不能保证准确性, 因此才有了第四步, 这一步会将第二步,未被标记的对象进行清除
  4. 重新标记(CMS remark), 为了修正并发标记期间,因用户程序继续运 作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始 标记阶段稍长一些,但远比并发标记的时间短,暂停。
  5. 并发清除(CMS concurrent sweep), 这一阶段采用标记-清除算法,在这个过程中, 同样不会STW,仍然会有用户线程不断产生垃圾, 因此在这步中没有被清理掉的垃圾只能留到下次清理, 这些垃圾叫做"浮动垃圾"

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。(第一个,第三个步骤暂停,第二个,第四个并发执行)

问题 :

在步骤二中我们说过由于并发执行可能出现本该被清理的对象没有被标记, 不该被清理的又被标记了, 被清理的对象没有被标记这个问题可以在重新标记得到解决, 但是不该被清理的被标记这一个问题该如何解决呢?

G1与CMS的区别与选择

G1收集器是垃圾收集器理论进一步发展的产物,它与前面的CMS收集器相比有两 个显著的改进:

  1. G1收集器是基于“标记-整理”算法实现的收集器,也就是说它 不会产生空间碎片,这对于长时间运行的应用系统来说非常重要。
  2. 它可以非常精确 地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收 集上的时间不得超过N毫秒,这几乎已经是实时Java (RTSJ)的垃圾收集器的特征了。

当然没有任何东西是完美的, G1也有以下缺点

用户程序运行过程中, G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

  1. 内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间; 相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的
  2. 在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障, CMS用写后屏障来更新维护卡表;
  3. 而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的,为双向卡表)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。

由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前置知识
    • 垃圾收集收集的对象是谁?
      • 垃圾收集器在哪里回收?
        • 怎么知道要不要清理的?
          • 引用计数法
          • 可达性分析算法
      • 为什么堆中不同区用不用的垃圾收集器用不同的垃圾收集算法
      • 垃圾收集算法
        • 标记-清除算法
          • 标记-复制算法
            • 标记-整理算法
            • 垃圾收集器
              • G1收集器
                • 内存管理方式
                • 怎么判断是否清理某块Region的
                • 将Java堆分成多个独立Region后, Region里面存在的跨Region引用对象如何解决?
                • Minor GC流程
                • Mixed GC流程
              • CMS
                • G1与CMS的区别与选择
                相关产品与服务
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档