🗑️ 垃圾回收
📖 概述
在C/C++等传统编程语言中,开发者需要手动释放不再使用的内存,否则可能会导致内存泄漏或内存溢出。这种手动内存管理方式虽然能够及时回收内存,但对开发者的能力要求较高。为了解决这一问题,Java在C/C++的基础上设计了一套自动内存管理机制,旨在降低开发者手动管理内存的复杂性,同时提供一个安全、高效的编程环境。
TIP
现代C++在11版本推出了智能指针,也实现了自动管理内存。
自动内存管理是Java语言的核心优势之一,其中最重要的功能为内存空间的分配与回收。在编写Java应用时,由于Java虚拟机会自动回收未使用的对象空间,因此开发者无需关心对象空间的释放。这些未使用的对象被称为“垃圾”,而这种自动释放垃圾对象空间的过程被称为垃圾回收(Garbage Collection
),通常简称为GC。
在JVM中,垃圾回收的工作由具体的垃圾回收器负责,它们主要负责回收堆中不再使用的对象空间。许多现代编程语言,如C#、Python和Go等,也具备自己的垃圾回收器。
需要说明的是,本文重点讨论的是HotSpot虚拟机对垃圾回收机制的实现。
🧵 线程不共享区域的回收
在JVM运行时数据区中,线程不共享的区域包括程序计数器、Java虚拟机栈和本地方法栈,这些区域在线程创建时分配,在线程销毁时释放。因此,垃圾回收器并不负责回收这三部分区域,它们的生命周期与线程的生命周期紧密相关。
⏳ 方法区的回收
方法区中回收的主要是不再使用的类。判定一个类是否可以被卸载,需要满足以下三个条件。
- 此类的所有对象都已经被回收,即堆中不存在任何该类对象以及子类对象。
- 加载该类的类加载器已经被回收。
- 该类对应的Class对象没有在任何地方被引用。
如果JVM确定一个类可以被卸载,它会选择合适的时机进行回收,而并非像堆中的对象一样总是会被回收。
在大多数开发场景中,类卸载并不常见,主要发生在OSGi和JSP等热部署场景下。例如,在JSP中,每个JSP文件都有一个单独的类加载器。当JSP文件被修改时,原有的类加载器会被回收,新的类加载器则会被创建以重新加载该JSP文件。
🧠 垃圾的判断方式
JVM在进行堆内存的垃圾回收操作之前,首先需要确定哪些对象不再被使用,并将这些对象视为“垃圾”。随后,JVM会释放这些“垃圾”对象所占用的内存空间,以进行内存清理。
确定对象是否可以被回收(即是否为“垃圾”对象),通常有两种方法:引用计数法(Reference Counting
)和可达性分析法(Reachability Analysis
)。
引用计数法
引用计数法会为每个对象关联一个引用计数器,该计数器是一个无符号的整数,用于表示关联对象被引用的次数。当对象被引用时,计数器将会加
引用计数法的优点在于实现简单且效率高。然而,当前主流的Java虚拟机并没有选择该算法来识别垃圾对象,其中最主要的原因是该算法很难解决对象之间的循环引用问题。
case
接下来,我们来看一个Java中循环引用的示例,代码如下。
首先,我们创建了两个Obj
类的实例对象,这两个对象相互持有对方的引用,各自占用大约100M的数据空间。其次,我们将这两个对象的引用变量设置为null
,此时在Java虚拟机栈中已经不包含对这两个对象的引用了。最后,我们显式地调用了System.gc()
方法,这个方法会向JVM发送一个GC请求,JVM会尽最大努力进行GC操作。
为了能更加直观地观察GC的过程,我们可以在程序运行前添加虚拟机参数-XX:+PrintGCDetails
,以便系统输出GC的日志信息。
可以看到,虽然这两个对象互相持有对方的引用,但由于它们已经从用户代码中失去了可访问性,因此它们仍然被JVM回收了。
TIP
Java的垃圾回收机制未采用引用计数法,这并不意味着这种技术不具有应用价值。实际上,引用计数法在许多现代编程语言中仍然被广泛应用以实现内存空间的自动管理,例如C++中的智能指针、Python中的垃圾回收器等。此外,这些语言都支持使用弱引用(WeakRef
)来解决循环引用问题。
可达性分析法
在可达性分析法中,对象被分为根对象(GC Root)和普通对象两种类型。该分析方法会从所有根节点出发,沿着引用链进行搜索。如果在搜索过程中能找到某个对象,那么该对象是可达的;反之,如果对象在引用链中未被找到,则该对象是不可达的。不可达的对象代表系统不再使用的对象,可以视为“垃圾”。
当前主流的Java虚拟机采用可达性分析算法来识别垃圾对象,而在Java的垃圾回收机制中,GC Root对象主要包括以下几种类型。
- 虚拟机栈(栈帧中的局部变量表)中引用的对象。
- 本地方法栈中引用的对象。
- 方法区中类的静态属性引用的对象。
- 方法区运行时常量池中引用的对象。
- 所有被同步锁持有的对象。
- JNI(Java Native Interface)引用的对象。
TIP
除了Java,现代编程语言如Go和C#等也采用可达性分析算法来识别垃圾对象。
📊 引用类型
无论是使用引用计数法还是可达性分析法来判断对象是否可回收,这些方法默认依据的引用关系均为强引用。但自JDK1.2起,为了使开发者能更灵活地参与内存管理,Java 扩展了对引用概念的定义,将其分类为五种类型:强引用(StrongReference
)、软引用(SoftReference
)、弱引用(WeakReference
)、虚引用(PhantomReference
)以及终结器引用(FinalReference
)。其中,软引用,弱引用和虚引用是JDK1.2版本中新增的。
强引用
在Java中,普通对象之间的引用关系被称为强引用,这种引用不需要通过任何特殊的Reference
类来包装。如果一个对象通过强引用与GC Root对象相关联,那么当系统内存不足时,JVM宁愿抛出一个OutOfMemoryError
错误来使程序异常终止,也不会去回收该对象。
软引用
在Java中,对象的软引用需要使用SoftReference
对象进行绑定。当对象仅通过软引用关联时,JVM会根据内存情况来管理该对象的生命周期。如果系统内存充足,JVM不会在GC中回收这些对象;然而,一旦内存变得紧张,JVM就会在GC中回收这些软引用的对象以释放内存空间。软引用常用于实现内存敏感的缓存场景中,例如Caffeine框架。
值得注意的是,如果一个对象被软引用关联,但又通过强引用关联到了GC Root对象,那么即使在内存紧张的情况下,由于强引用的存在,该对象也不会被回收。同时,软引用对象本身也需要用强引用与GC Root对象相关联,否则它也会被回收。
试想一下,如果一个被软引用关联的对象在内存紧张时被回收了,那么与之相关联的软引用SoftReference
对象本身还拥有存在的意义吗?答案是没有任何意义,因此软引用SoftReference
提供了一套队列机制,允许开发者在软引用关联对象被回收时扩展一些特定的行为。
在创建任何一个SoftReference
对象时,都可以选择将其注册到一个ReferenceQueue
队列中。当软引用关联的对象被回收时,那么该软引用对象本身就会被加入到注册的队列中,这个工作是在一个名为Reference Handler
的守护线程中完成的,该线程的优先级为MAX_PRIORITY
(最高)。也就是说,队列中出现的永远是空的软引用对象。
TIP
Reference Handler
线程定义在java.lang.ref.Reference
类中,它只是负责将状态为Pending
的引用对象添加到相应的引用队列中,而引用对象状态的标记工作是在底层JVM的垃圾回收(GC)过程中进行的。- 引用对象往往通过引用队列与GC Root对象建立强引用关系。当这些引用对象从队列中出队并被消费后,它们自身就会失去与GC Root对象的强引用关联,从而被垃圾回收器回收。因此,
ReferenceQueue
机制主要关注的是对象回收后的清理任务。
case
接下来,我们将使用软引用SoftReference
来实现一个简单的缓存示例,关系图如下。
实体ConcertInfo
对象用于描述演唱会的相关信息。为了简化示例,其中的数据项仅包括五个字段:ID编号、主办方、表演者、演出开始时间以及演出结束时间。
@Data
public class ConcertInfo {
/**
* 编号
*/
private String id;
/**
* 主办方
*/
private String sponsor;
/**
* 表演者
*/
private String performer;
/**
* 演出开始时间
*/
private LocalDateTime startPerfTime;
/**
* 演出结束时间
*/
private LocalDateTime endPerfTime;
}
实现这套缓存的主要难点在于:当软引用对象关联的实体对象被回收后,如何有效地回收该软引用对象及其对应的数据条目。为此,我们可以在软引用对象中额外维护一个实体id编号,并将其注册到一个引用队列中。这样一来,当软引用对象关联的实体对象被回收后,其自身就会出现在引用队列中。最后,我们可以逐个弹出引用队列中的空软引用对象,并使用它们的实体id编号从哈希表中移除相应的条目,以解除它们与哈希表的强引用关系。
public class ConcertCache {
private final static ConcertCache cache = new ConcertCache();
private final Map<String, ConcertReference> table;
private final ReferenceQueue<ConcertInfo> queue;
public ConcertCache getInstance() {
return cache;
}
private ConcertCache() {
table = new HashMap<>();
queue = new ReferenceQueue<>();
}
private static class ConcertReference extends SoftReference<ConcertInfo> {
@Getter
private final String id;
public ConcertReference(ConcertInfo concertInfo, ReferenceQueue<ConcertInfo> queue) {
super(concertInfo, queue);
id = concertInfo.getId();
}
}
public void add(ConcertInfo concertInfo) {
clean();
ConcertReference reference = new ConcertReference(concertInfo, this.queue);
this.table.put(concertInfo.getId(), reference);
}
public ConcertInfo get(String id) {
ConcertInfo info = null;
if (this.table.containsKey(id)) {
info = this.table.get(id).get();
}
if (info == null) {
clean();
}
return info;
}
private void clean() {
ConcertReference reference;
while ((reference = (ConcertReference) this.queue.poll()) != null) {
this.table.remove(reference.getId());
}
}
}
弱引用
在Java中,对象的弱引用需要使用WeakReference
对象进行绑定。弱引用的整体机制与软引用基本一致,它们之间主要的区别在于:当对象仅通过弱引用关联时,不论当前内存空间是否充足,该对象在GC过程中都会被直接回收。这意味着,与软引用相比,弱引用所关联的对象具有更短暂的生命周期。弱引用常用于对象监视器、复杂数据结构等场景中,例如在ThreadLocal
类中维护的数据结构就使用了弱引用。
弱引用也可以和引用队列ReferenceQueue
联合使用,当弱引用关联的对象被回收时,Reference Handler
线程就会把这个弱引用对象加入到其注册的引用队列中。
虚引用
虚引用也称为幽灵引用或幻影引用。在Java中,对象的虚引用需要使用PhantomReference
对象进行绑定。虚引用可以被看作是一种非实质性的引用,通俗来讲,虚引用可以理解为是一种“不存在“的引用。
通过虚引用,我们无法获取到与其关联的对象,因为它的get()
方法始终返回null
。此外,虚引用对关联对象的生命周期没有任何影响,其唯一的作用在于接收关联对象被回收的通知。因此,虚引用必须与引用队列联合使用,当虚引用关联的对象被回收时,Reference Handler
线程就会把这个虚引用对象加入到其注册的引用队列中。虚引用常用于对象回收清理的场景中,例如在DirectByteBuffer
对象被回收后,其相关的直接内存空间的释放工作就是通过虚引用机制来实现的。
在JDK9中,Object
类中的finalize
方法已被标记为过时,对象回收后的清理工作由java.lang.ref.Cleaner
类彻底替代。Cleaner
类继承了虚引用PhantomReference
,它为开发者提供了一种更优雅的方式来执行对象被回收后需要执行的清理代码,这一部分知识在章节Java新特性中进行了详细阐述。
终结器引用
在Java中,任何一个对象在被创建时,如果它对应的类重写了Object
父类的finalize
方法,那么JVM就会为该对象绑定一个终结器引用FinalReference
,并为该终结器引用对象注册一个引用队列ReferenceQueue
。
终结器引用不会影响关联对象的生命周期。不过,需要注意的是,当GC搜索首次找到关联对象时,它不会被回收。此时,JVM还是会将终结器引用对象的状态标记为Pending
,并由守护线程Reference Handler
将其添加到相应的引用队列中。此外,在JVM进程中存在一个守护线程FinalizerThread
。它通过死循环不断地将引用队列中的终结器引用对象弹出作处理,处理的核心逻辑为:首先获取尚未被销毁的关联对象,其次执行该对象的finalize
方法,最后消除关联对象与当前栈中变量的强引用以及终结器引用(在Java层面)。在执行完关联对象的finalize
方法后,JVM会对该对象进行记录,当GC搜索第二次找到关联对象时,它将直接被回收。
可以看出,终结器引用的主要作用是在对象被回收之后执行相应的清理工作。然而,目前来看,这种早期设计存在一定的缺陷,例如,对象在执行finalize
方法过程中,由于对象本身没有真正地被回收,因此我们可以在方法中将该对象重新与一个GC Root对象建立强引用关系,从而实现所谓的“自救”,但这将会导致该对象在后续的回收中无法触发清理工作的执行。同时,finalize
方法还存在一定的安全性和性能问题,且无法保证其执行时机。
基于上述内容,Java社区普通认为开发者应避免使用finalize
方法。在JDK9中,finalize
方法被标记为过时;在JDK18中,finalize
方法则被正式移除。
TIP
终结器引用的相关逻辑在Java中由java.lang.ref.Finalizer
类处理,该类继承自FinalReference
类。这两个类的访问权限都是包级私有,这意味着只有同一包内的类才可以访问它们。因此,开发者在应用层面无法直接与终结器引用机制进行交互,只能通过finalize
方法来介入。
⚙️ 垃圾回收算法
评估指标
进行垃圾回收时,JVM为了保证内存的完整性和一致性,往往需要暂时暂停用户线程,这种机制在Java中被称为Stop-The-World
(STW
)。这意味着,垃圾回收线程能够挂起所有用户线程,以确保在回收过程中没有其他线程对内存的修改。
TIP
即使现代先进的垃圾回收器采用了多线程并行处理以提高垃圾回收的效率,但最终仍然会挂起用户线程。这是因为在某些阶段,用户线程的暂停是必须的。
衡量一个垃圾回收算法的优劣可以从多个角度进行评估,主要包括吞吐量、最大暂停时间、内存使用率等指标。
- 吞吐量
吞吐量是指CPU执行用户代码的时间与CPU总执行时间的比值,即计算公式为:执行用户代码时间 / (执行用户代码时间 + GC时间)
。吞吐量值越高,表明垃圾回收算法的效率越高。
- 最大暂停时间
最大暂停时间是指应用程序运行过程中,由GC引起的最大停顿(SWT)时间。最大暂停时间越短,表明垃圾回收算法对应用程序的影响越小,用户的感知程度也随之降低。
- 内存使用率
内存使用率是指可分配内存空间大小与总内存空间大小的比值,即计算公式为:可分配内存空间大小 / (可分配内存空间大小 + GC预留内存空间大小)
。内存使用率越高,表明垃圾回收算法的空间消耗越低。
发展历史
在 Lisp
语言的创始人John McCarthy发布了第一个GC算法:标记-清除算法(Mark-Sweep
)。接着,在 Lisp
语言使用的、基于双存储区的垃圾回收机制,而这种著名的算法也被称为复制算法(Copying
),同时该算法也被引入到了Lisp
语言的首个实现版本中。至今,计算机科学家对GC算法的研究还未停止,而在长期的实际应用过程中,也诞生了很多优秀的GC算法,例如标记-整理算法(Mark-Compact
)、分代回收算法(Generational Collection
)等,它们都是对前两种GC算法的改进和补充。
标记-清除算法
标记-清除算法共分为“标记”和“清除”两个阶段。在标记阶段,所有存活的对象会被标记,这个过程可以使用引用计数法或可达性分析法。而在清除阶段,那些未被标记的死亡对象所占用的内存空间会被释放。
标记-清除算法的优点在于实现简单,系统只需为每个对象维护一个标志位。然而,它也存在以下缺点。
- 容易产生内存碎片
一块较大的内存空间是连续的,它可以容纳多个对象。然而,在使用标记-清除算法回收一些较小对象后,所释放的内存空间可能因单个空间过小而无法再次分配,从而导致内存碎片的出现。
- 内存分配速度慢
在使用标记-清除算法回收”垃圾“对象后,它们释放的内存空间可以被重新分配。又由于这些内存空间通常是不连续的,因此系统往往需要维护一个空闲链表来跟踪这些可重用的空间。这就意味着当内存重新分配时,需要遍历链表以寻找合适的空间。
复制算法
复制算法的出现是为了解决标记-清除算法所导致的内存碎片问题。该算法的核心思想是将内存空间分为两块大小相同的区域,通常称为“From空间”和“To空间”。在每次垃圾回收(GC)时,该算法会将“From空间”中存活的对象全部复制到“To空间”,并在完成后交换两块空间的角色,使得“From空间”成为新的可分配区域。
虽然复制算法有效防止了内存碎片的产生,但也带来了一些新的问题,其主要缺点包括以下两点。
- 内存使用效率较低,因为只能利用一半的空间来存储数据。
- 当存活对象的总大小非常大时,该算法的复制性能会显著下降。
标记-整理算法
标记-整理算法也被称为标记-压缩算法,是对前两种算法的改进。该算法分为“标记”和“整理”两个阶段。在标记阶段,所有存活的对象会被标记,这一过程可以通过引用计数法或可达性分析法来实现。接着,在整理阶段,所有存活的对象将被移动到内存的一端,同时清理掉超出该边界的内存空间。
标记-清除算法的优点在于不会产生内存碎片,并且内存使用率较高。然而,它也存在效率不高的缺点,性能损耗主要集中在整理阶段。例如,在Lisp2整理算法中,整个内存需要被遍历
分代回收算法
分代回收算法是一种基于对象生命周期特征的垃圾回收策略,它将内存划分为多个不同的“代”(区域)。通过对对象的存活时间和特性进行细致的分析和管理,每个代都能够制定相应的生命周期策略和垃圾回收机制。从专业角度看,分代回收算法是一种先进、灵活的现代垃圾回收技术,它融合了多种优秀的垃圾回收算法,具备强大的内存管理能力,并能够根据多样化的应用场景和实时条件进行自适应调整,显著提高系统的性能以及资源利用效率。
当前的Java虚拟机的垃圾回收实现大多采用分代回收算法,HotSpot亦是如此。HotSpot将内存区域(堆)划分为年轻代(Young Generation
)和老年代(Old Generation
)。年轻代的组成部分是一个伊甸园区(Eden
)和两个幸存者区(Survivor
,即S0和S1),它用于存储存活时间较短的对象;而老年代则用于存储存活时间较长的对象。
TIP
在JDK1.7及之前版本的HotSpot中,堆中还包含一部分被称为永久代(Permanent Generation
)的区域,它是方法区的一种实现。然而,在JDK1.8中,这一区域被移除,取而代之的是使用直接内存的元空间(Metaspace
)。
我们可以通过虚拟机参数-Xmn
来设置年轻代的内存分配大小,老年代的初始内存分配大小则由JVM自动计算,一般是根据堆空间的可用总大小减去年轻代的内存分配大小来确定的。另外,我们还可以利用虚拟机参数-XX:SurvivorRatio
来调整年轻代中伊甸园区与单个幸存者区之间的内存分配比例。不过,需要注意,在某些先进的垃圾回收器里,由于其区域分配较为复杂并涉及动态计算,因此这两个参数的设定可能并不生效。同时,老年代的空间通常具备动态扩容的特性。
case
接下来,我们将在JDK1.8中模拟一个年轻代与老年代的分配示例。首先,我们使用虚拟机参数-Xms1G
将堆的初始内存分配大小设置为1G;接着,使用参数-Xmn200M
将年轻代的内存分配大小设置为200M;最后,使用参数-XX:SurvivorRatio=4
指定伊甸园区与单个幸存者区的内存分配比例为4。
启动应用程序后,我们可以使用Arthas中的memory
命令来查看当前Java进程的内存使用情况,包括年轻代和老年代的信息。
在上图的数据项中,ps_eden_space表示伊甸园区,ps_survivor_space表示幸存者区,ps_old_gen表示老年代区域。由于S0和S1只有一块区域是可用的,且Arthas输出的是可用内存的详细数据,因此ps_survivor_space的total
值仅统计了单个幸存者区的大小(33792K,即33M)。此外,我们可以进行以下简单计算来验证虚拟机参数是否生效。
- 计算年轻代的内存分配大小:
134M + 33M * 2 = 200M
,这表明-Xmn
参数设置已生效。 - 计算堆的初始内存分配大小:
200M + 824M = 1024M
,这表明-Xms
参数设置已生效。 - 计算伊甸园区与单个幸存者区的内存分配大小比值:
134M ÷ 33M ≈ 4.060606061
,这表明-XX:SurvivorRatio
参数设置已生效。
需要注意的是,不同的JDK版本和垃圾回收器可能会导致这些内存区域的标识名称发生变化。
对象通常在年轻代的Eden区中进行分配。然而,当Eden区没有足够的空间来分配新对象时,虚拟机将触发一次Young GC
(也称为Minor GC
)。Young GC是针对年轻代区域进行的垃圾回收操作,这种GC方式采用复制算法,S0和S1两个幸存者区分别充当复制算法中的From空间和To空间。在Young GC过程中,首先Eden区和From空间中的存活对象会被复制到To空间中。其次,Eden区和From空间中的所有对象会被清理,即释放它们所占用的内存空间。最后,S0和S1的角色相互交换,即调换From空间与To空间指针。
JVM会为每个对象维护一个年龄计数器,该计数器由
当一些大对象(例如需要大量连续内存空间的数组和字符串等)被分配时,JVM会直接将它们放入老年代。这一行为与所使用的垃圾回收器及其相关参数有关。
在Young GC过程中,如果存活对象的总内存大小超过了单个幸存者区的容量,那么这些对象将被复制到老年代,而不是放入幸存者区。
以上提及的三种情况都可能导致对象被尝试分配到老年代。但是,如果老年代空间不足以容纳新对象,虚拟机会触发一次Full GC
,这种GC会对整个堆内存进行垃圾回收,包括年轻代、老年代以及永久代(如果存在的话)。专门针对老年代的垃圾回收称为Old GC
,它通常采用复制-整理算法,具体采用哪一种算法取决于JVM所选用的垃圾回收器以及JDK版本。最后,在Full GC结束后,如果对象还是无法被分配至老年代中,那么JVM将会抛出一个OutOfMemoryError
错误,即内存溢出。
TIP
在每次进行Young GC之前,JVM都会检查内存状况,以判断是否需要进行 Full GC。具体而言,如果老年代的最大连续空间小于或等于“新生代已使用的内存大小”或“历次晋升的平均内存大小”,则Young GC会转为Full GC;否则,将继续执行Young GC。这一机制通常被称为空间分配担保。
🧹 垃圾回收器
垃圾回收器是对垃圾回收算法的实现,也是JVM中承担GC工作的关键组件。随着JDK版本的迭代更新,多种垃圾回收器相继推出,并不断地进行优化和升级。
JVM通过分代回收算法来管理内存,针对年轻代和老年代分别设计了不同的垃圾回收器,下图展示了这些回收器之间的具体配对关系。在该图中,除了G1能够独立工作以外,其余的垃圾回收器都需要按照推荐的搭配进行协同工作。其中,推荐的垃圾回收器组合由实线表示,而虚线表示的组合则不推荐使用,并将在后续的版本中被淘汰。
Serial
Serial
是一种单线程的串行垃圾回收器,专门用于回收年轻代内存。它采用复制算法,在整个GC过程中会暂停所有用户线程。
Serial在单核处理器下的吞吐量表现非常出色,适合CPU资源匮乏的场景。然而,在多核CPU环境中,它的吞吐量并不理想,且在内存较大的情况下,GC可能会导致较长时间的停顿(STW)。
SerialOld
SerialOld
是Serial的老年代版本,也是一种单线程的串行垃圾回收器,专门用于回收老年代内存。它采用标记-整理算法,在整个GC过程中会暂停所有用户线程。
SerialOld往往与Serial搭配使用于单核CPU的场景中,我们可以通过虚拟机参数-XX:+UseSerialGC
来启用这一对垃圾回收器组合。
ParNew
ParNew
是一种多线程的并行垃圾回收器,专门用于回收年轻代内存。它采用复制算法,在整个GC过程中会暂停所有用户线程。因此,它被认为是Serial垃圾回收器的多线程版本,除并行GC处理外,其余功能与Serial基本保持一致。
ParNew充分利用了多核CPU的优势,提升了吞吐量,减少了GC的停顿时间(SWT),适用于CPU资源充足的场景,例如Server服务器。ParNew通常与老年代回收器CMS(下文将详细介绍)搭配使用,这对组合能够充分发挥多核CPU的作用。
在JDK9之前,我们可以通过虚拟机参数-XX:+UseParNewGC
来启用ParNew,此时默认的老年代垃圾回收器为SerialOld。同时,JVM在启动后会发出警告,提醒开发者不要使用这一组合,并计划在未来的版本中弃用该虚拟机参数。为此,我们可以额外添加虚拟机参数-XX:+UseConcMarkSweepGC
来启用CMS,从而消除警告。
在JDK9中,参数-XX:+UseParNewGC
被彻底弃用。但我们依旧可以使用参数-XX:+UseConcMarkSweepGC
,此时的垃圾回收器组合为ParNew + CMS
。然而,JVM在启动后也会发出警告,提醒开发者不要使用这一组合,并计划在未来的版本中弃用该参数。这一变化是因为G1(将在下文详细介绍)日益成熟,并在JDK9中成为默认的垃圾回收器,能够完全替代 ParNew + CMS
这一组合。
在JDK14中,参数-XX:+UseConcMarkSweepGC
被彻底弃用,这也意味着CMS与ParNew这一组合彻底退出历史舞台。
CMS
CMS
(Concurrent Mark Sweep
)是一种旨在实现最小回收停顿时间的垃圾回收器,专门用于回收老年代内存。CMS是HotSpot虚拟机首款真正意义上的并发垃圾回收器,它首次实现了GC线程与用户线程的同时工作。
CMS采用标记-清除算法,它将标记阶段细分为
可以看出,整个GC过程主要可以分为
- 初始标记:挂起所有用户线程,仅标记直接可达的根对象。此步骤完成的时间极短。
- 并发标记:根据可达性分析法,遍历对象图并标记存活对象。同时,记录老年代中发生变化的引用关系。在此步骤中,GC线程与用户线程并发进行。
- 重新标记:挂起所有用户线程,并修正并发标记步骤中由于引用关系变化导致的漏标、错标结果。
- 并发清除:清除垃圾对象,并将它们释放的内存空间维护在一个空闲列表中。在此步骤中,GC线程与用户线程并发进行。
与年轻代垃圾回收器ParNew类似,CMS也充分利用了多核CPU的优势,通过并发标记和清除,大幅减少了GC的停顿时间(SWT)。因此,在多核CPU环境下,使用ParNew + CMS
这一组合可以使GC的停顿控制在较短的时间内,从而显著提升用户体验。
我们可以通过虚拟机参数-XX:+UseConcMarkSweepGC
来启用CMS,以及通过虚拟机参数-XX:CMSInitiatingOcccupanyFraction
来设置触发GC的老年代内存占用率阈值。然而,正如前文所提及的,随着G1的出现和发展,CMS已经逐渐被替代,并在JDK14中被彻底弃用。CMS的主要缺点包括以下几点。
- CMS采用标记-清除算法,这可能导致在GC结束后出现内存碎片。不过,CMS也支持碎片整理功能,具体的整理行为由虚拟机参数
-XX:CMSFullGCsBeforeCompaction
控制。该参数默认值为,表示在多少次Full GC后进行内存整理。 - 在CMS的并发清理步骤,由于用户线程仍在并发运行,可能会产生新的垃圾对象,而这些对象不会在当前GC中得到及时回收,这就导致了“浮动垃圾”的出现。
- 如果产生大量浮动垃圾且无法及时清理,可能会导致内存无法为新对象分配空间。在这种情况下,CMS将退化为SerialOld单线程串行垃圾回收器来进行GC。
TIP
与其他垃圾回收器相比,CMS的GC机制较为特殊,它仅在老年代中进行并发清理,而不涉及整个堆的回收。
Parallel Scavenge
Parallel Scavenge
是一种多线程的并行垃圾回收器,专门用于回收年轻代内存。它采用复制算法,在整个GC过程中会暂停所有用户线程。
可以看出,Parallel Scavenge与ParNew非常相似,但其主要关注点在于吞吐量,旨在更有效地利用CPU。因此,Parallel Scavenge适合用于对高吞吐量有要求的场景。
Parallel Old
Parallel Old
是Parallel Scavenge的老年代版本,也是一种多线程的并行垃圾回收器,专门用于回收老年代内存。它采用标记-整理算法,在整个GC过程中会暂停所有用户线程。
Parallel Old往往与Parallel Scavenge搭配使用于多核CPU且对高吞吐量有要求的场景中,我们可以通过虚拟机参数-XX:+UseParallelGC
或-XX:+UseParallelOldGC
来启用这一对垃圾回收器组合。
TIP
ParNew + CMS
组合关注GC停顿时间,而Parallel Scavenge + Parallel Old
组合关注GC吞吐量,后者是JDK 1.8中的默认组合。- Parallel Old是在JDK 1.6中引入的,而在此之前,Parallel Scavenge只能与SerialOld一起使用。
在实际使用Parallel Scavenge + Parallel Old
这一垃圾回收器组合时,JVM提供了以下虚拟机参数,以便开发者能够干预和调整GC行为。
-XX:MaxGCPauseMillis
: 设置GC的最大停顿毫秒时间为millis
,其中millis
是该参数值。-XX:GCTimeRatio
: 设置GC的吞吐量为n / n + 1
,其中n
是该参数值。
需要说明的是,由于最大停顿时间和吞吐量通常无法同时得到保证,因此当设置以上这两个虚拟机参数时,JVM会尽力满足这些预设目标。此外,Oracle官方建议在使用此组合时,不应固定设置堆内存的最大值,而是启用堆内存的自动调整功能(包括扩展和收缩),这是因为堆内存的大小直接影响GC的吞吐量。堆内存的自动调整功能可以通过虚拟机参数-XX:+UseAdaptiveSizePolicy
来启用,且JVM默认已开启此功能。
G1
G1
(Garbage-First
)是一款面向服务端的现代垃圾回收器,它是JDK9中默认的垃圾回收器,可以同时用于年轻代和老年代的内存回收。
在传统的JVM垃圾回收器中,年轻代与老年代的在堆内存中的位置通常是连续的。然而,在G1的设计中,堆的各个区域内存不必连续。具体来说,整个堆被划分为多个大小相等的区域,每个区域称为一个Region
(区),这些区域从逻辑上组成了Eden、Survivor和Old等不同的内存部分。每个Region的默认大小是通过将堆空间大小除以 -XX:G1HeapRegionSize
进行设置,取值范围为1M至32M。
新创建的对象被分配在Eden区域。当年轻代空间不足时,G1将执行Young GC,即年轻代垃圾回收。实际上,Young GC的触发决策是基于多个因素的,包括年轻代内存使用情况、当前GC预测时间以及用户设定的最大停顿时间。如果GC预计所需时间明显超过用户设定的最大停顿时间,那么通常会触发Young GC。相反,如果GC预计时间远低于这个最大停顿时间,那么年轻代的对象分配可以继续进行,并且Region的数量会逐渐增加。这说明了G1能够通过调整和管理年轻代的Region数量来调控Young GC的时间开销。
G1中的Young GC会暂停所有用户线程,并由多个线程并行执行GC工作。
G1中的Young GC采用复制算法,在GC过程中,年轻代(包括Eden和Survivor)中存活的对象会被复制到新的Survivor区域。为此,G1需要分配一些新的Region作为新的Survivor区域。同时,G1还会记录对象的存活年龄,一旦对象的年龄达到预设的阈值(默认为
TIP
- Homongous在堆内存中永远不会移动,直至被清理。
- 属于Homongous的Region的剩余内存空间无法被再次利用,这会导致内存碎片的产生。
当老年代(包括Humongous区)在堆中的占有率超过预设的阈值时,G1会执行混合垃圾回收(Mixed GC
)。Mixed GC并不是对整个堆进行回收,而是专注于年轻代和部分老年代的清理。在Mixed GC过程中,G1会在满足用户设定的最大停顿时间的基础上,优先清理老年代中存活率较低的Region,以提高垃圾回收的效率,这也是G1命名的核心含义。触发Mixed GC的老年代占用率阈值可以通过虚拟机参数-XX:InitiatingHeapOccupancyPercent
进行设置,默认值为
Mixed GC采用复制算法,整个过程可以细分为
接下来将详细介绍Mixed GC过程中每个步骤所执行的具体操作。
- 初始标记:挂起所有用户线程,仅标记直接可达的根对象。实际上,此步骤会等待Young GC的发生,并利用Young GC的执行来完成标记工作。
- 并发标记:根据可达性分析法,遍历对象图并标记存活对象。同时,记录并发过程中发生的引用更新。在此步骤中,GC线程与用户线程并发进行。
- 最终标记:挂起所有用户线程,重新标记在并发标记步骤中发生引用更新的对象,主要解决漏标问题。
- 筛选回收:挂起所有用户线程,对Old Region的回收价值和成本进行排序,并在完成用户指定的停顿时间的基础上,优先回收性价比高的Region。
在G1中,当堆空间无法分配新的Region,即无法满足对象的分配需求时,系统会启动担保机制,执行一次Full GC。这一过程使用的是Serial Old单线程的Full GC,期间会暂停所有用户线程,并对整个堆进行单线程串行回收。
我们可以通过虚拟机参数-XX:+UseG1GC
来启用G1,以及通过虚拟机参数-XX:MaxGCPauseMillis
来设置G1的最大GC停顿时间(以毫秒为单位)。G1的最大优势在于其可控的停顿时间模型,这意味着它能够高效地保证每次GC产生的停顿时间不会超过用户设定的最大限制。此外,G1还具备出色的吞吐性能,这使得它在JDK9中被指定为默认的垃圾回收器。
TIP
- G1首次出现在JDK1.7中,但通常不建议在JDK9之前的版本中使用它,原因是其在较早的版本中尚未达到成熟状态。
- G1采用的垃圾回收算法是复制算法,但从整体分区的角度来看,它也可以被视为一种标记-整理算法。
- 由于G1采用了分区回收的策略,因此在内存较大的系统中,它具有明显的优势;而在内存较小的系统中,CMS垃圾回收器可能表现得更好。