文章目录
- JVM垃圾回收机制
-
- 内存分配和回收原则
-
-
- [对象优先在 Eden 区分配](#对象优先在 Eden 区分配)
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
- [主要进行 gc 的区域](#主要进行 gc 的区域)
- 空间分配担保
-
- 死亡对象判断方法
-
-
- 引用计数法
- 可达性分析算法
-
-
- [哪些对象可以作为 GC Roots 呢?](#哪些对象可以作为 GC Roots 呢?)
-
- 引用类型总结
- 如何判断一个常量是废弃常量?
- 如何判断一个类是无用的类?
-
- 垃圾收集算法
- 垃圾收集器
-
-
- [JDK 默认垃圾收集器](#JDK 默认垃圾收集器)
- 串行
-
-
- [Serial 收集器](#Serial 收集器)
- [Serial Old 收集器](#Serial Old 收集器)
- [ParNew 收集器](#ParNew 收集器)
-
- 吞吐量优先
-
-
- [Parallel Scavenge 收集器](#Parallel Scavenge 收集器)
- [Parallel Old 收集器](#Parallel Old 收集器)
-
- 响应时间优先
-
-
- [CMS 收集器](#CMS 收集器)
-
- [G1 收集器](#G1 收集器)
-
- 主要特点
- G1分区划分
- G1收集器的回收过程
- G1的两种回收策略
-
- [Young GC(新生代回收)](#Young GC(新生代回收))
- [Mix GC(混合回收)](#Mix GC(混合回收))
- 跨代引用和RSet(记忆集)
- [ZGC 收集器](#ZGC 收集器)
-
JVM垃圾回收机制
内存分配和回收原则
对象优先在 Eden 区分配
大多数情况下,对象在新生代中 Eden 区分配。
当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。GC 期间虚拟机如果发现当前对象无法存入 Survivor 空间(空间不足),就会通过 空间分配担保机制 把新生代的对象提前转移到老年代中去(下方对空间分配担保有详细解释),老年代上的空间足够存放当前对象,所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存。
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。
- G1 垃圾回收器会根据
-XX:G1HeapRegionSize
参数设置的堆区域大小和-XX:G1MixedGCLiveThresholdPercent
参数设置的阈值,来决定哪些对象会直接进入老年代。 - Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(
XX:ThresholdTolerance
是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
动态年龄
"Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积到某个对象时占用大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过 -XX:TargetSurvivorRatio=percent
来设置,参见 issue1199 ),取这个对象的年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值"。
jdk8 官方文档引用:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html。
额外补充说明(issue672):关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书。
如果你去 Oracle 的官网阅读相关的虚拟机参数,你会发现-XX:MaxTenuringThreshold=threshold
这里有个说明
Sets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.
主要进行 gc 的区域
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。Major GC,用于回收 老年代,CMS 收集器特有。
- 混合收集(Mixed GC):新生代 + 老年代两块 部分 区域的垃圾回收,G1 收集器特有
整堆收集 (Full GC):Full GC,新生代+老年代 完整 垃圾回收。
空间分配担保
空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。
《深入理解 Java 虚拟机》第三章对于空间分配担保的描述如下:
JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看
-XX:HandlePromotionFailure
参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX: HandlePromotionFailure
设置不允许冒险,那这时就要改为进行一次 Full GC。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
死亡对象判断方法
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
引用计数法
给对象中添加一个引用计数器:
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。
对象之间循环引用
所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA
和 objB
相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
可达性分析算法
这个算法的基本思想就是通过一系列的称为 "GC Roots" 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
下图中的 Object 6 ~ Object 10
之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。
可达性分析算法
哪些对象可以作为 GC Roots 呢?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
对象可以被回收,就代表一定会被回收吗?
即使在可达性分析法中不可达的对象,也并非是"非死不可"的,这时候它们暂时处于"缓刑阶段",要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize
方法。当对象没有覆盖 finalize
方法,或 finalize
方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。(finalize 方法是定义在 Object 类中的方法,每个对象都可以调用该方法,当对象被回收的时候,就会执行 finalize 方法)
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
Object
类中的finalize
方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的finalize
方法会被逐渐弃用移除。忘掉它的存在吧!参考:
引用类型总结
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与"引用"有关。
JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
强引用,弱引用,软引用,虚引用它们有什么区别?你知道吗?_强引用,软引用,弱引用,虚引用-CSDN博客
Java 引用类型总结
1.强引用(StrongReference)
以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
2.软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
3.弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4.虚引用(PhantomReference)
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
如何判断一个常量是废弃常量?
运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?
- JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
- JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
- JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
假如在字符串常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池了。
如何判断一个类是无用的类?
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
判定一个常量是否是"废弃常量"比较简单,而要判定一个类是否是"无用的类"的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 "无用的类":
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader
已经被回收。 - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是"可以",而并不是和对象一样不使用了就会必然被回收。
垃圾收集算法
标记-清除算法
标记-清除(Mark-and-Sweep)算法分为"标记(Mark)"和"清除(Sweep)"阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:
- 效率问题:标记和清除两个过程效率都不高。
- 空间问题:标记清除后会产生大量不连续的内存碎片。
标记-清除算法
关于具体是标记可回收对象还是不可回收对象,众说纷纭,两种说法其实都没问题,我个人更倾向于是前者。
如果按照前者的理解,整个标记-清除过程大致是这样的:
- 当一个对象被创建时,给一个标记位,假设为 0 (false);
- 在标记阶段,我们将所有可达对象(或用户可以引用的对象)的标记位设置为 1 (true);
- 扫描阶段清除的就是标记位为 0 (false)的对象。
复制算法
为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
复制算法
虽然改进了标记-清除算法,但依然存在下面这些问题:
- 可用内存变小:可用内存缩小为原来的一半。
- 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。
标记-整理算法
标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
标记-整理算法
由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
分代收集算法
在之前介绍 JVM 组成的时候说话,在堆中分为新生代和老年代。下面我们就要详细说明一下,在垃圾回收的时候它们到底有什么作用。
堆中的区域划分
首先,我们还是要重新介绍一下堆中的组成:
- 在 Java8 中,堆被分成了两份:新生代和老年代(比例为1:2)
如下图所示:
左边的新生代占了 1/3,右边的老年占了 2/3。在新生代中又划分为了三份:
- 一个是伊甸园区Eden,新生的对象都分配到这里。
- 还有两个幸存者区Survivor,分为 from 和 to。
- Eden区、from区、to区比例为 8:1:1。
以上就是目前堆中的结构划分,下面我们来介绍一下垃圾产生之后它们是如何工作的。
分代收集算法-工作机制
- 首先,我们刚才介绍过,新生的对象都需要存储到 Eden 区,如下图所示:
- 当伊甸园区内存不足的时候(如上图所示已经占满了),这个时候 JVM 就会使用之前我们讲过的 "可达性分析算法" 来去标记 Eden 区和 from 区中存活的对象,当然目前 from 区中是没有对象的。
- 假如标记了 A 对象是存活的,接下来就会采用 "复制算法" 将 A 对象复制到 to 区中。复制完毕之后,Eden 区和 from 区都要清空掉。操作后如下所示:
- 假如经过一段时间后,Eden 区内存又出现不足,如下所示:
- 这时候依然会采用 "可达性分析算法" 去标记 Eden 区和 to 区中存活的对象,然后把这些对象复制到 from 区中。
- 假如现在 1 和 A 这两个对象依然存活,这时候就直接复制到了 from 区中,然后 Eden 区和 to 区都会清空掉。操作后如下所示:
- 又经过了一段时间,又有一些新的对象存到了 Eden 区中,如下所示:
- 假如这时候 A 对象被挪动的次数太多,比如超过了 15 次,那么这时候 A 对象就不会再在 from 区和 to 区之间挪过来挪过去了。它会直接把 A 对象存储到 老年代 中。因为这种情况下,我们一般认为 A 对象会被一直引用着,它的存活时间会更长一些。
- 当然还有一种情况,假如我们的幸存者区已经内存不足了,或者说当前某个对象太大了,它也会提前晋升到老年代。例如下图中,A 对象代表挪动次数超过 15 次之后进入了老年代;而 w 对象是一个新生的对象,它并没有到达一定的挪动次数,所以 w 对象是正常地在新生代中进行复制挪动,等 w 对象挪动到一定次数也会进入到老年代中。
以上就是新生代和老年代工作的配合方式。
延伸面试问题: HotSpot 为什么要分为新生代和老年代?
根据上面的对分代收集算法的介绍回答。
垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。
JDK 默认垃圾收集器
(使用 java -XX:+PrintCommandLineFlags -version
命令查看):
- JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
- JDK 9 ~ JDK22: G1
相关概念
- 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
- 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上
- 吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 。
串行
Serial 收集器
Serial([ˈsɪəriəl]串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 "单线程" 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
新生代采用标记-复制算法,老年代采用标记-整理算法。
Serial 收集器
虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
Serial Old 收集器
Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
ParNew 收集器
ParNew [pa:]收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
新生代采用标记-复制算法,老年代采用标记-整理算法。
它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
并行和并发概念补充:
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
吞吐量优先
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?
Parallel[ˈpærəlel] a.并发的,并行的
Scavenge[ˈskævɪndʒ] v.打扫,清除
-XX:+UseParallelGC
使用 Parallel 收集器+ 老年代串行
-XX:+UseParallelOldGC
使用 Parallel 收集器+ 老年代并行
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代采用标记-复制算法,老年代采用标记-整理算法。
Parallel Old收集器运行示意图
这是 JDK1.8 默认收集器
JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 来禁用该功能
Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本。使用多线程和"标记-整理"算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
响应时间优先
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的老年代收集器。它非常符合在注重用户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep 这两个词可以看出,CMS 收集器是一种 "标记-清除"算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
- 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
CMS 收集器
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:
- 对 CPU 资源敏感;
- 无法处理浮动垃圾;并发清除的过程中,可能任然会有新的垃圾产生,这些垃圾就叫浮动垃圾,如果当用户需要存入一个很大的对象时,新生代放不下去,老年代由于浮动垃圾过多,就会退化为 serial Old 收集器,将老年代垃圾进行标记-整理,当然这也是很耗费时间的!
- 它使用的回收算法-"标记-清除"算法会导致收集结束时会有大量空间碎片产生。
CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除。
G1 收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
主要特点
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:
- 并行与并发 :G1 GC能够充分利用多核处理器的优势,通过并行执行垃圾收集任务来提高效率。同时,它的大部分工作都是与应用线程并发执行的,从而减少了停顿时间。
- 分区域收集 :G1 GC将整个堆内存划分为多个大小相等的独立区域(Region) ,这些区域在逻辑上是连续的,但在物理内存上可能不是连续的。每个Region([ˈriːdʒən])都可以扮演Eden区、Survivor区或Old区等角色。这种设计使得G1 GC能够更加灵活地进行内存管理和垃圾收集。
- 空间整合 :在整体上,G1 GC使用标记-整理算法来回收内存,以减少内存碎片的产生。但在两个Region之间进行垃圾收集时,它则采用标记-复制算法。这种组合策略有助于兼顾内存利用率和垃圾收集效率。
- 可预测的停顿 :这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
- 优先回收垃圾最多区域:G1 GC通过跟踪每个Region中的垃圾堆积情况,并根据回收价值和成本进行排序**,优先回收垃圾最多的Region**。这种策略有助于最大限度地提高垃圾收集的效率。
G1分区划分
G1收集器的分区划分是其核心特性之一,它允许G1更灵活、高效地管理内存和执行垃圾回收:
- 基本思想 :
G1收集器将整个Java堆划分为多个大小相等、独立的区域,这些区域被称为"Region"。每个Region[ˈriːdʒən]的大小可以根据堆空间的实际大小而定,通常在1MB到32MB之间,且必须是2的N次幂。这意味着Region的大小可以是1MB、2MB、4MB、8MB、16MB或32MB。默认情况下,整个堆空间被划分为约2048个这样的Region。 - 分区类型 :
G1的Region可以根据其用途和状态分为不同类型。主要包括:- 自由分区(Free Heap Region, FHR):这些Region当前没有包含任何对象,是空闲的,可以用于新的对象分配。
- 新生代分区(Young Heap Region, YHR):这些Region被划分为新生代,包括Eden区和Survivor区。新生代分区主要用于存储新创建的对象。
- 大对象分区(Humongous Heap Region, HHR):专门用于存储大对象。在G1中,只要对象的大小超过了一个Region容量的一半,就被认为是大对象。这些对象会被直接分配到Humongous Region中,且每个大对象都单独占用一个或多个连续的Humongous Region。
- 老年代分区(Old Heap Region, OHR):这些Region被划分为老年代,用于存储长时间存活的对象。
- 分区的管理和回收 :
G1收集器通过维护一个优先列表来跟踪各个Region中的垃圾堆积情况和回收价值。在垃圾回收过程中,G1会根据这个列表优先回收价值最大的Region。这种策略使得G1能够更有效地利用处理器资源,并最大限度地减少垃圾回收造成的停顿时间。 - 优点 :
G1的分区划分带来了几个显著优点。首先,它允许更细粒度的内存管理,提高了内存的利用率 。其次,通过优先回收垃圾最多的Region,G1能够保持较高的吞吐量并缩短停顿时间 。最后,G1的分区策略使其能够很好地适应不同的内存大小和垃圾回收需求。
为什么G1收集器需要设计巨型对象
G1收集器需要设计巨型对象(Humongous Objects)主要是出于对内存管理和垃圾收集效率的考虑。在G1收集器的设计中,整个堆内存被划分为多个大小相等的区域(Region),每个Region用于存放对象。然而,有些对象的大小可能会超过一个Region的容量,这就引出了巨型对象的概念。
巨型对象是指那些大小超过了一个Region容量50%以上的对象。由于这些对象太大,无法完整地存放在一个Region中,因此需要特殊处理。G1收集器通过引入巨型对象的概念,并为之设计专门的存储和管理机制,确保了这些大对象能够被有效地管理和回收。
具体来说,**巨型对象在G1中被直接分配到特殊的Humongous Region中,每个巨型对象可以独占一个或多个连续的Humongous Region。**这样做的好处是可以避免由于对象跨Region存储而导致的复杂性和性能开销。同时,G1收集器还会针对巨型对象进行特殊的垃圾回收策略,以提高垃圾收集的效率和整个系统的性能。
综上所述,G1收集器需要设计巨型对象主要是为了更有效地管理大内存对象,提高垃圾收集效率和整个系统的性能。
G1收集器的回收过程
G1收集器的回收过程主要包括以下几个步骤:
- 初始标记(Initial Marking) :
这个过程是STW(Stop-The-World停止一切)的,但通常耗时非常短。它标记出从GC Roots直接可达的对象,作为后续垃圾收集的基础。 - 并发标记(Concurrent Marking) :
在初始标记完成后,G1 GC会进入并发标记阶段。这个阶段与应用程序线程并发执行,通过递归地追踪所有可达的对象,并将它们标记为存活。这个过程是并发的,因此不会阻塞应用程序的执行。 - 最终标记(Final Marking) :
为了处理在并发标记过程中新产生的对象引用关系,G1 GC会执行一次短暂的STW的最终标记。这个阶段确保所有在并发标记阶段漏掉的对象都被正确标记。 - 筛选回收(Live Data Counting and Evacuation) :
在这个阶段,G1 GC会根据每个Region的垃圾堆积情况和回收价值进行排序,并选择性地回收部分Region中的垃圾对象。回收过程包括将存活的对象从一个Region复制或移动到另一个Region,并更新相关的引用。这个过程也是并发的,旨在最大限度地减少停顿时间。同时,这个阶段可能会涉及到对象的整理和压缩,以减少内存碎片。
此外,G1收集器还采用了分区(Region)的方式来管理内存,每个Region都被标记了不同的状态(如Eden、Survivor、Old等)。这种设计使得G1能够更灵活地进行内存分配和垃圾回收,从而提高了整体的效率和性能。
值得注意的是,G1收集器还提供了两种主要的垃圾回收模式:Young GC和Mixed GC。Young GC主要负责回收新生代中的垃圾对象,而Mixed GC则负责回收新生代和部分老年代中的垃圾对象。这两种模式都是根据堆内存的使用情况和GC的触发条件来自动选择的。
G1的两种回收策略
G1垃圾收集器是Java虚拟机(JVM)中的一个重要组件,它提供了两种主要的垃圾回收策略:Young GC(新生代回收)和Mix GC(混合回收)。这两种策略在回收对象和回收区域上有所不同,但都是为了提高垃圾回收的效率,减少停顿时间,从而提升应用程序的性能。
Young GC(新生代回收)
新生代收集的目标是仅处理新生代区域,即 Eden 区和 Survivor 区。
Young GC主要负责回收新生代中的对象。新生代通常包含新创建的对象,这些对象更有可能在短时间内变成垃圾。Young GC的执行过程相对较快,因为它只涉及新生代中对象的扫描和回收。
在Young GC过程中,和上述的G1回收步骤是不同的,Young GC过程是Stop-The-World(STW)的,意味着在回收过程中,应用程序的所有线程都会被暂停。但是,由于新生代中的对象通常较少,因此这个暂停时间通常较短,对应用程序的性能影响也较小。
具体操作如下:
- 从 GC Roots 出发遍历对象 :
- Young GC 会从 GC Roots 出发,逐个检查引用的对象。这些对象可以位于新生代(Eden 区和 Survivor 区)或老年代。如果引用到的是新生代的对象,GC 将认为这个对象是存活的。
- 对象的处理(复制) :
- 没有显式的"标记"过程 :在传统的"标记-清除"垃圾收集器中,存活对象会先被"标记",然后在标记完成后再进行清理。而在 G1 的 Young GC 中,找到存活对象后不会进行标记,而是直接处理 。处理方式是将存活对象 复制 到 Survivor 区或晋升到老年代。
- 即时复制:在遍历到存活对象时,GC 会立即将对象复制到 Survivor 区或老年代。这种复制操作相当于对存活对象进行"间接标记",因为复制完成后,原区域中未被复制的对象即被认为是不可达的,可以被回收。
- 遍历引用链 :
- 如果一个存活对象引用了其他对象,GC 将继续沿着这些引用链,递归地查找被引用的对象并对它们进行复制处理。这是类似于深度优先搜索的对象遍历方式。
- 未复制对象的回收 :
- 在完成所有对象的复制后,Eden 区和旧的 Survivor 区会被清空,所有未被复制的对象会被视为垃圾进行回收。
Mix GC(混合回收)
Mix GC则是G1收集器特有的回收策略,它不仅回收新生代中的所有Region,还会回收部分老年代中的Region。这种策略的目标是在保证停顿时间不超过预期的情况下,尽可能地回收更多的垃圾对象。
在Mix GC过程中,首先会进行全局并发标记(global concurrent marking),这个过程是并发的,与应用程序线程同时执行,用于标记出所有存活的对象。然后,在回收阶段,G1会根据标记结果选择收益较高的部分老年代Region和新生代Region一起进行回收。这个选择过程是基于对Region中垃圾对象的数量和回收价值的评估。(和上述G1垃圾回收步骤相同)
与Young GC不同,Mix GC的停顿时间可能会更长,因为它涉及到对老年代中对象的扫描和回收。但是,由于Mix GC能够回收更多的垃圾对象,因此它通常能够更有效地释放内存空间,减少垃圾堆积对应用程序性能的影响。
跨代引用和RSet(记忆集)
在垃圾收集过程中,跨代引用或跨Region引用是一个需要特别注意的现象:
-
跨代引用的概念 :
在垃圾收集领域,跨代引用指的是不同代际之间的对象相互引用。在G1收集器中,由于堆被划分为多个Region,跨代引用通常表现为跨Region引用。年轻代指向老年代的引用在垃圾收集中不是主要问题,因为即使年轻代的对象被清理,程序仍然可以正常运行,且未被标记到的老年代对象会在后续的Major GC中被回收。
-
老年代指向年轻代的引用问题 :
当存在老年代指向年轻代的引用时,情况就复杂了。在Minor GC阶段,我们不能简单地清理年轻代中的对象,因为老年代中可能还有对象持有对这些对象的引用。为了解决这个问题,我们需要一种机制来跟踪这些跨Region的引用。
新生代收集的目标是仅处理新生代区域,即 Eden 区和 Survivor 区。在通过GC Roots对新生代对象进行垃圾回收时,不会涉及到老年代对象,这时我们并不知道老年代对象是否指向年轻代对象
-
RSet(记忆集)的作用 :
RSet正是为了解决这个问题而设计的。它的主要作用是记录哪些Region中的对象有被老年代对象引用的情况。在GC时,通过扫描这些Region中的RSet,我们可以快速识别出需要保留的年轻代对象,从而避免扫描老年代,显著提高了垃圾收集的效率。RSet的实现本质上是一种哈希表,其中Key是Region的起始地址,Value是一个集合,存储了卡表的索引号。
RSet(RememberedSet)是一个非常重要的数据结构,用于记录并跟踪其他Region指向当前Region中对象的引用。在G1收集器的分区模型中,由于堆内存被划分为多个独立的Region,对象之间的引用关系可能跨越不同的Region。为了能够在垃圾收集过程中正确地识别和处理这些跨Region的引用,G1引入了RSet的概念。
每个Region都有一个与之关联的RSet,用于记录其他Region中指向该Region内对象的引用信息。当发生对象引用关系变化时,G1会更新相应的RSet,以确保垃圾收集的准确性。在垃圾收集过程中,G1会利用RSet来快速确定哪些Region之间存在引用关系,从而避免不必要的全堆扫描,提高垃圾收集的效率。
RSet的实现通常涉及一些优化技术,如使用位图(Bitmaps)或压缩表(CompressedTables)来紧凑地存储引用信息,以减少内存占用和提高访问速度。此外,G1还采用了一些策略来维护RSet的一致性,如在并发标记阶段使用写屏障(Write Barriers)来拦截并更新跨Region的引用。
- 减少YGC时的扫描开销 :
由于新生代的垃圾收集通常很频繁(即YGC),如果每次都需要扫描整个老年代来确定是否有对新生代的引用,那么开销将会非常大。通过RSet的跟踪机制,我们可以精确地知道哪些老年代Region中的对象引用了新生代对象,从而只扫描这些Region,大大降低了YGC时的扫描开销。 - 卡标记(Card Marking)技术与卡表(Card Table) :
HotSpot JVM为了更高效地处理老年代到新生代的引用问题,采用了卡标记技术。具体来说,它使用了一个称为卡表(Card Table)的数据结构来辅助标记过程。堆空间被划分为一系列的卡页(Card Page),每个卡页对应卡表中的一个标记项。当发生对老年代到新生代引用的写操作时,通过写屏障(Write Barrier)机制来更新卡表中对应的标记项。这样,在GC时,我们只需要扫描那些被标记为dirty的卡页所对应的Region即可快速找到所有老年代到新生代的引用关系。
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。
ZGC 收集器
与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。
ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。
ZGC 在 Java11 中引入,处于试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java15 已经可以正式使用了。
不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启用 ZGC:
java -XX:+UseZGC className
在 Java21 中,引入了分代 ZGC,暂停时间可以缩短到 1 毫秒以内。
你可以通过下面的参数启用分代 ZGC:
java -XX:+UseZGC -XX:+ZGenerational className
关于 ZGC 收集器的详细介绍推荐阅读美团技术团队的 新一代垃圾回收器 ZGC 的探索与实践 这篇文章。