更好的阅读体验 \huge{\color{red}{更好的阅读体验}} 更好的阅读体验
说起垃圾收集(Garbage Collection,下文简称GC),有不少人把这项技术当作Java语言的伴生产物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。
Java
内存运行时区域的各个部分,特别是程序计数器、虚拟机栈和本地方法栈这三个区域随线程而生、随线程而灭,而栈帧则在方法的进入和退出过程中执行出栈和入栈操作。这些区域的内存分配和回收是确定性的,因为在类结构确定时就已知每个栈帧分配的内存大小。Java
堆和方法区则具有不确定性,因为接口的不同实现类和方法的不同条件分支可能需要不同的内存。只有在运行时才能确定程序会创建哪些对象以及创建多少个对象,因此这部分内存的分配和回收是动态的。垃圾收集器的主要任务是管理这部分内存的分配和回收。内存回收的时机是由垃圾回收器(Garbage Collector)来决定的,而垃圾回收器的具体策略和时机会根据不同的实现而有所差异。一般情况下,以下几种情况会触发内存回收:
对象不再被引用:当一个对象不再被任何活动的引用所引用时,它就成为垃圾对象。垃圾回收器会周期性地扫描内存,找出这些不再被引用的对象,并将它们标记为可回收的。这是最常见的回收时机。
内存不足:当系统中的可用内存接近极限时,垃圾回收器会被触发来回收一些不再使用的对象,以释放内存空间。这种情况下的回收被称为压力驱动的回收。
程序显式调用:在某些情况下,程序可以显式地调用垃圾回收器来进行内存回收。例如,在程序中使用 System.gc()
方法可以建议垃圾回收器执行回收操作,但并不能保证立即执行回收。
程序空闲时:当程序处于空闲状态时,即没有活动的线程在运行,垃圾回收器可以利用这段时间来回收内存。例如,在 Java
中,当所有线程都处于等待状态或者没有活动时,垃圾回收器可能会被触发。
垃圾回收(Garbage Collection)是 JVM
自动管理内存的过程,它负责释放不再使用的对象所占用的内存空间,以便其他对象可以使用。垃圾回收器通过以下步骤来执行垃圾回收:
引用计数法是一种简单的垃圾回收算法:
垃圾回收器会定期扫描内存中的对象,将引用计数为 0 0 0 的对象回收释放内存。
引用计数法无法解决循环引用的问题,即若存在两个或多个对象之间形成循环引用,它们的引用计数器永远不会为 0 0 0,导致这些对象无法被回收,造成内存泄漏。
可达性分析是一种更为常见和有效的垃圾回收算法:
垃圾回收器会将不可达对象进行回收释放内存。
可达性分析算法能够解决循环引用的问题,因为只有可达的对象能够被标记,形成循环引用的对象将无法被标记,最终被判定为不可达对象,从而被回收。
现代 JVM
一般采用可达性分析算法来进行垃圾回收。
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
在 JDK1.2
版之前对象只有“被引用”和“未被引用”之分,在其之后进行了扩充,将引用分为了如下四种:
JDK1.2
版之后提供了 SoftReference
类来实现软引用。JDK1.2
版之后提供了 WeeakReference
类来实现弱引用。JDK1.2
版之后提供了 PhantomReference
类来实现虚引用。我们提供如下示例代码进行验证:
public class TestReference {
public static void main(String[] args) {
// 创建一个强引用对象
Object hardReference = new Object();
// 创建一个软引用对象
SoftReference<Object> softReference = new SoftReference<>(new Object());
// 创建一个弱引用对象
WeakReference<Object> weakReference = new WeakReference<>(new Object());
// 创建一个弱引用对象,并将其引用赋给一个强引用变量
WeakReference<Object> weakUseReference = new WeakReference<>(new Object());
Object hardUseReference = weakUseReference.get();
// WeakReference
// Object hardUseReference = weakUseReference;
// 创建一个虚引用对象,并指定引用队列
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
PhantomReference<Object> phantomReference = new PhantomReference<>(new Object(), referenceQueue);
// 执行垃圾回收
System.gc();
// 输出各个引用对象的状态
System.out.println("HardReference Obj = " + hardReference);
System.out.println("SoftReference Obj = " + softReference.get());
System.out.println("WeakReference Obj = " + weakReference.get());
System.out.println("HardUseReference Obj = " + hardUseReference);
System.out.println("WeakUseReference Obj = " + weakUseReference.get());
System.out.println("PhantomReference Obj = " + phantomReference.get());
}
}
在上述代码中,我们进行了如下过程试验:
hardReference
:创建了一个强引用对象,该对象不会被垃圾回收器回收,除非显式解除引用。softReference
:创建了一个软引用对象,当内存不足时,垃圾回收器会尽量保留该对象,直到内存真正不足时才会回收。weakReference
:创建了一个弱引用对象,当对象只被弱引用引用时,垃圾回收器可以在下一次回收时将其回收。weakUseReference
:创建了一个弱引用对象,并将其引用赋给一个强引用变量,此时对象不会被回收。phantomReference
:创建了一个虚引用对象,并指定了引用队列 referenceQueue
。虚引用的主要作用是跟踪对象被垃圾回收的状态,无法通过虚引用来获取对象实例。System.gc()
来触发垃圾回收。最后输出所有被引用的对象状态进行验证。我在弱引用下添加了被注释的代码片段:
WeakReference<Object> weakUseReference = new WeakReference<>(hardReference);
Object hardUseReference = weakUseReference;
在这段代码中,weakUseReference
弱引用对象是通过将 hardReference
强引用对象作为参数传递给构造函数创建的。然后,将 weakUseReference
赋值给 hardUseReference
强引用变量。
不同于原来的:
WeakReference<Object> weakUseReference = new WeakReference<>(new Object());
Object hardUseReference = weakUseReference.get();
weakUseReference
弱引用对象通过直接创建一个新的匿名对象传递给构造函数创建的。然后,通过调用 weakUseReference.get()
来获取弱引用对象所引用的对象,并将其赋值给 hardUseReference
强引用变量。
虽然两者在创建弱引用对象的方式和强引用变量的赋值方式不同,但结果上 weakUseReference
都没有被回收,本质上来看这两者都是在创建时都被赋值给了强引用变量 hardUseReference
。即使只有弱引用引用该对象,只要存在强引用变量引用该弱引用对象,该对象就不会被垃圾回收。
如果某个对象没有与 GC Roots
相连接的引用链,则被认为是不可达的,理论上已经可以将其视为辣鸡进行回收了,但实际上还能抢救一下:
finalize()
方法。finalize()
方法的执行:如果对象需要执行 finalize()
方法,将其放置在 F-Queue
队列中,由 Finalizer
线程去执行。执行finalize()方法并不保证等待其运行结束。F-Queue
中的对象进行第二次标记,如果对象在 finalize()
方法中成功拯救自己,重新与引用链上的对象建立关联,那么在第二次标记时将被移出"即将回收"的集合。finalize()
方法中拯救自己,那么它将被回收。我们来看如下代码示例:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("I feel gooooooooooood :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize(); // 调用父类的 finalize() 方法
FinalizeEscapeGC.SAVE_HOOK = this; // 重新连接
System.out.println("finalize() method executed!");
}
public static void main(String[] args) throws Throwable {
// 创建对象 link start!
SAVE_HOOK = new FinalizeEscapeGC();
for (int i = 0; i < 5; i++) {
// 断开连接
SAVE_HOOK = null;
System.out.println("God ! Please! no ! Please do something! Save me!");
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("Wasted :(");
}
}
}
}
在上述代码中:
FinalizeEscapeGC
对象,并将其引用赋值给SAVE_HOOK
。SAVE_HOOK
置为null
,断开对象的引用。System.gc()
方法,请求垃圾回收。finalize()
方法执行自救。SAVE_HOOK
是否为null
,如果不为null
,说明对象被成功拯救,并调用isAlive()
方法输出一条信息。SAVE_HOOK
为null
,说明对象未被成功拯救,输出一条信息表示对象已经被销毁。运行后,可以看到与i那些结果如下:
God ! Please! no ! Please do something! Save me!
finalize() method executed!
I feel gooooooooooood :)
God ! Please! no ! Please do something! Save me!
Wasted :(
God ! Please! no ! Please do something! Save me!
Wasted :(
God ! Please! no ! Please do something! Save me!
Wasted :(
God ! Please! no ! Please do something! Save me!
Wasted :(
可以看到被强制断开连接的对象只成功逃出了第一次回收,这是因为任何一个对象的 finalize()
方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize()
方法不会被再次执行,因此后续所有的自救都失败了。
分代收集理论是一种基于经验法则的内存管理策略,它建立在两个分代假说之上
弱分代假说(Weak Generational Hypothesis):
强分代假说(Strong Generational Hypothesis):
即使这两个假说已经很完善了,但在进行新生代的垃圾收集(Minor GC)时,若新生代中的对象有被老年代所引用,为了准确地确定新生代中的存活对象,必须额外遍历整个老年代中的所有对象,以确保可达性分析结果的正确性。然而,遍历整个老年代的所有对象会给内存回收带来很大的性能负担。因此便追加提出了第三条假说:
由此我们可以得出推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
但无论如何,分代收集理论的核心思想是,新生代中的对象往往具有较高的垃圾产生率,而老年代中的对象则具有较低的垃圾产生率。
因此,针对不同代的对象采用不同的垃圾收集策略,可以提高垃圾收集的效率。
基于先前的可达性分析和分代收集理论,有如下三种经典的垃圾回收算法:
除了上述三种策略,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
在 JDK1.3.1
之前,是虚拟机新生代区域收集器的唯一选择。
特点:
由于在用户的桌面应用场景中,内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,使用串行回收器是可以接受的。
所以,在客户端模式(一般用于一些桌面级图形化界面应用程序)下的新生代中,默认垃圾收集器至今依然是 Serial
收集器。
特点:
Serial
收集器的多线程版本,它能够支持多线程垃圾收集。除了多线程支持以外,其他内容基本与 Serial
收集器一致,并且目前某些JVM默认的服务端模式新生代收集器就是使用的ParNew收集器。
特点:
Parallel Scavenge
是面向新生代的垃圾收集器,采用标记复制算法实现。Parallel Old
是面向老年代的垃圾收集器,采用标记整理算法实现。与 ParNew
收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。
目前 JDK8
采用的就是这种 Parallel Scavenge + Parallel Old
的垃圾回收方案。
在 JDK1.5
,HotSpot
推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器第一次实现了让垃圾收集线程与用户线程同时工作。
特点:
由于采用标记-清除算法会产生大量的内存碎片,长此以往会有更高的概率触发 Full GC
,并且在与用户线程并发执行的情况下,也会占用一部分的系统资源,导致用户线程的运行速度一定程度上减慢。但这仍是当初低延迟最佳的选择,直到 G1
收集器的问世。
该垃圾收集器也是一款划时代的垃圾收集器,在 JDK7
的时候推出,主要面向于服务端的垃圾收集器,并且在 JDK9
时,取代了JDK8
默认的 Parallel Scavenge + Parallel Old
的回收方案。
G1
收集器使用了一种称为"分代收集"的算法,将堆内存划分为多个大小相等的区域(Region),并根据垃圾回收的需求进行动态调整。
其回收过程与CMS大体类似:
G1
收集器分为以下几个阶段:
G1
收集器会对每个区域独立进行垃圾回收,从而避免了全堆扫描的开销,减少了停顿时间。相比于 CMS
收集器,G1
收集器在垃圾回收的停顿时间上有更好的表现,并且可以避免碎片化问题。
JVM
的内存分配策略决定了如何为新对象分配内存空间。常见的内存分配策略有两种:
Eden
区分配:
JVM
将堆内存划分为不同的区域,其中 Eden
区是新对象分配的主要区域。JVM
将其分配到 Eden
区。当 Eden
区满时,触发 Minor GC
(新生代垃圾回收),将仍然存活的对象移动到 Survivor
区或老年代。JVM
会将其直接分配到老年代。JVM
给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。Eden
区诞生的对象经历一次 Minor GC
后存活会被移动到 Survivor
区且年龄计数器加一;Survivor
区每经历一次 Minor GC
且存活继续计数,当年龄计数器达到阈值(默认为15)则会将其移动到老年代。-XX:MaxTenuringThreshold
来设置对象晋升到老年代的年龄阈值。HotSpot
虚拟机并不是永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold
才能晋升老年代。Survivor
空间中相同年龄所有对象大小的总和大于 Survivor
空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 -XX:MaxTenuringThreshold
中要求的年龄。这种动态对象年龄判定的策略可以有效减少垃圾回收的频率,提高垃圾回收的效率,同时也能够更好地适应不同对象的生命周期。通过将长期存活的对象分配到老年代,并根据对象的年龄进行晋升判定,可以更好地管理内存,并减少垃圾回收对系统性能的影响。
在 Java
虚拟机中,进行 Minor GC
(新生代垃圾回收)之前,需要检查老年代的可用空间是否足够容纳新生代所有对象。如果足够,那么进行 Minor GC
是安全的。如果不足够,虚拟机会根据参数设置来决定如何处理。
如果允许担保失败(Handle Promotion Failure),虚拟机会进一步检查老年代的可用空间是否大于历次晋升到老年代对象的平均大小。如果大于平均大小,虚拟机会尝试进行一次有风险的 Minor GC
。这是因为如果大量对象在 Minor GC
后仍然存活,需要老年代进行分配担保,将无法容纳在 Survivor
空间中的对象直接送入老年代。这种担保需要老年代有足够的剩余空间来容纳这些对象。
为了确定是否进行 Full GC
来释放更多空间,虚拟机会将之前每次回收晋升到老年代对象容量的平均大小与老年代的剩余空间进行比较。这样做是为了估计有多少对象会在这次回收中存活下来。如果存活对象的数量远远高于历史平均值,就有可能导致担保失败。
当发生担保失败时,虚拟机需要重新发起一次 Full GC
,停顿时间会很长。为了避免频繁进行 Full GC
,通常会打开 HandlePromotionFailure
开关,以便进行有风险的 Minor GC
,而不是立即进行 Full GC
。