深入理解 JVM 垃圾回收机制
在 Java 开发领域,JVM(Java 虚拟机)的垃圾回收机制是保障程序高效稳定运行的关键环节。它自动处理内存管理中繁琐且易错的垃圾回收任务。
一、垃圾回收的基本概念
在程序运行过程中,会不断创建各种对象,这些对象占用内存空间。当某些对象不再被程序使用时,它们所占用的内存就需要被回收,以便重新分配给其他新创建的对象。垃圾回收机制就是 JVM 自动识别并回收这些 “垃圾” 对象内存的过程。
那么,JVM 如何判断一个对象是垃圾呢?这里主要有两种方法:
引用计数算法
每个对象都有一个引用计数器,当对象被引用时,计数器加 1;当引用失效时,计数器减 1。当计数器值为 0 时,表示该对象不再被引用,可以被回收。然而,这种算法存在循环引用的问题,例如:
public class ReferenceCountingGC {
public Object instance = null;
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
// 此时,objA 和 objB 相互引用,但实际上它们已经没有外部引用,应该被回收,但引用计数算法无法处理这种情况
objA = null;
objB = null;
}
}
可达性分析算法
JVM 中的垃圾回收器主要采用可达性分析算法来确定对象是否为垃圾。该算法以一系列称为 “GC Roots” 的根对象作为起始点,从这些根对象开始向下搜索,搜索所走过的路径称为引用链。如果一个对象到 GC Roots 没有任何引用链相连,那么这个对象就是不可达的,可被判定为垃圾对象。GC Roots 通常包括以下几种对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
例如:
public class ReachabilityAnalysisGC {
public static void main(String[] args) {
Object obj1 = new Object();
Object obj2 = new Object();
// 将 obj2 赋值给 obj1 的成员变量,形成引用关系
obj1 = obj2;
// 此时,之前创建的第一个 Object 对象由于没有任何引用链可达 GC Roots,
将被判定为垃圾对象
}
}
二、常见的垃圾回收算法
标记 - 清除算法(Mark-Sweep)
这是最基础的垃圾回收算法。它分为两个阶段:首先标记出所有需要回收的对象,然后统一回收被标记的对象。其优点是简单直接,缺点也很明显。标记和清除过程效率不高,而且清除后会产生大量不连续的内存碎片,当后续需要分配较大对象时,可能无法找到足够连续的内存空间,导致不得不提前触发另一次垃圾回收。
复制算法(Copying)
将内存划分为大小相等的两块,每次只使用其中一块。当这一块内存用完后,就将存活的对象复制到另一块内存中,然后一次性清理掉使用过的那块内存。该算法的优点是简单高效,没有内存碎片问题,因为每次都是复制存活对象到新的连续内存空间。但它的代价是将可用内存缩小为原来的一半,对于内存资源紧张的情况不太友好。在新生代中,由于大部分对象都是 “朝生夕死”,存活对象较少,所以比较适合使用复制算法,例如 Sun HotSpot 虚拟机的新生代就采用了这种算法,将新生代划分为 Eden 区和两个 Survivor 区(通常比例为 8:1:1)。
标记 - 整理算法(Mark-Compact)
结合了标记 - 清除算法和复制算法的优点。首先标记出所有存活对象,然后将所有存活对象向一端移动,最后直接清理掉边界以外的内存。这样既解决了标记 - 清除算法的内存碎片问题,又不需要像复制算法那样牺牲一半的内存空间。适用于老年代,因为老年代中的对象存活率较高,如果使用复制算法成本太高。
分代收集算法(Generational Collection)
根据对象的存活周期将内存划分为不同的代,一般分为新生代和老年代。新生代中对象存活率低,采用复制算法进行垃圾回收;老年代中对象存活率高,采用标记 - 整理算法或标记 - 清除算法进行垃圾回收。这种算法充分利用了不同代对象的特点,提高了垃圾回收的效率。
三、JVM 中的垃圾回收器
JVM 中有多种垃圾回收器,它们各有特点,适用于不同的应用场景。
Serial 回收器
这是一个单线程的垃圾回收器,在进行垃圾回收时会暂停所有用户线程(“Stop The World”)。它的优点是简单高效,对于单 CPU 环境或者小型应用来说,由于没有线程切换的开销,性能表现不错。适用于客户端模式下的 JVM,例如一些简单的桌面应用程序。
ParNew 回收器
是 Serial 回收器的多线程版本,在新生代采用复制算法进行垃圾回收。它能够充分利用多 CPU 的优势,提高垃圾回收的效率。通常与 CMS 回收器配合使用,在新生代使用 ParNew 回收器,老年代使用 CMS 回收器,适用于多 CPU 环境下的服务器应用。
Parallel Scavenge 回收器
也是一个新生代垃圾回收器,采用复制算法。它的特点是关注系统的吞吐量,即单位时间内 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。通过参数可以精确控制吞吐量的大小,适合在后台运算而不需要太多交互的任务,例如一些科学计算、数据处理等批处理任务。
Serial Old 回收器
Serial 回收器的老年代版本,是一个单线程的、采用标记 - 整理算法的垃圾回收器。主要用于在客户端模式下与 Serial 回收器搭配使用,或者在服务端模式下作为 CMS 回收器的后备预案,在并发收集发生 “Concurrent Mode Failure” 时使用。
Parallel Old 回收器
Parallel Scavenge 回收器的老年代版本,采用标记 - 整理算法。在注重吞吐量的应用场景中,与 Parallel Scavenge 回收器配合使用,能够在新生代和老年代都实现较高的吞吐量。
CMS 回收器(Concurrent Mark Sweep)
以获取最短回收停顿时间为目标的垃圾回收器。它的工作过程比较复杂,主要分为四个阶段:初始标记、并发标记、重新标记和并发清除。初始标记和重新标记阶段仍然需要暂停所有用户线程,但时间很短;并发标记和并发清除阶段与用户线程并发执行,大大减少了垃圾回收时应用的停顿时间。适用于对响应时间要求较高的应用,如 Web 服务器等。但它也有一些缺点,例如会产生内存碎片,并且在并发阶段会占用一定的 CPU 资源,影响应用的整体性能。
G1 回收器(Garbage First)
面向服务端应用的垃圾回收器,主要应用在多 CPU 和大内存的环境下。它将堆内存划分为多个大小相等的 Region,在进行垃圾回收时,优先回收垃圾最多的 Region。G1 回收器采用了标记 - 整理算法,避免了内存碎片问题,同时能够预测垃圾回收的停顿时间,通过参数可以指定一个期望的停顿时间,让垃圾回收在不影响应用响应时间的前提下进行。适用于大型的分布式系统、数据处理应用等对内存和响应时间都有较高要求的场景。
四、垃圾回收的调优策略
在实际应用中,为了让 JVM 的垃圾回收机制更好地适应业务需求,往往需要进行一些调优。
确定垃圾回收器
根据应用的类型、性能要求等因素选择合适的垃圾回收器。例如,如果是对响应时间敏感的 Web 应用,可能优先考虑 CMS 回收器或 G1 回收器;如果是注重吞吐量的批处理应用,则可以选择 Parallel Scavenge 回收器与 Parallel Old 回收器的组合。
调整堆内存大小
合理设置新生代和老年代的大小。一般来说,可以根据应用中对象的生命周期特点进行调整。如果新生代设置过小,会导致频繁的 Minor GC;如果老年代设置过小,容易引发 Full GC。可以通过参数 -Xms(初始堆大小)和 -Xmx(最大堆大小)来设置堆内存的初始值和最大值,以及 -Xmn 来设置新生代的大小。
控制对象晋升到老年代的年龄
对象在新生代中经过多次 Minor GC 后仍然存活,就会晋升到老年代。可以通过参数 -XX:MaxTenuringThreshold 来调整对象晋升到老年代的年龄阈值,根据应用中对象的存活情况进行优化,避免过早或过晚地将对象晋升到老年代。
优化 CMS 回收器
当使用 CMS 回收器时,可以调整一些参数来优化其性能。例如,-XX:CMSInitiatingOccupancyFraction 参数可以设置老年代使用达到多少百分比时触发 CMS 回收;-XX:+UseCMSCompactAtFullCollection 参数可以在 Full GC 后进行内存碎片整理,减少内存碎片的产生。
监控与分析
使用 JVM 提供的监控工具,如 jstat、jconsole、VisualVM 等,对垃圾回收的过程进行监控和分析。通过查看垃圾回收的频率、停顿时间、内存使用情况等指标,及时发现问题并进行调整。例如,如果发现 Full GC 过于频繁,可以进一步分析是内存泄漏导致对象无法回收,还是堆内存设置不合理等原因。