1 垃圾回收的概念
垃圾回收(Garbage Collection,GC)是自动管理内存的一种机制,用于释放不再使用的对象所占用的内存空间,防止内存溢出。垃圾回收器通过识别和回收那些已经死亡或长时间未使用的对象,来优化内存的使用。
在 Java 语言出现之前,开发者需要手动管理内存,例如在 C 和 C++ 中,开发者需要编写构造函数和析构函数来创建和销毁对象。Java 引入了垃圾回收机制,简化了内存管理,开发者无需手动释放内存,从而减少了内存泄漏和悬空指针等问题。
2 垃圾判断算法
在 Java 虚拟机(JVM)中,垃圾回收器需要判断哪些对象是垃圾,哪些对象仍然存活。垃圾判断算法是垃圾回收机制的核心部分,常见的算法包括引用计数算法和可达性分析算法。
2.1 引用计数算法
引用计数算法(Reference Counting)通过在对象头中维护一个引用计数器来记录对象被引用的次数。每当对象被引用时,引用计数器加 1;当引用被删除时,引用计数器减 1。当引用计数器为 0 时,对象被认为是垃圾,可以被回收。
2.1.1 示例
String s = new String("沉默王二");
s = null; // 引用计数为 0,对象可以被回收
在这个例子中,字符串对象 "沉默王二"
最初被 s
引用,引用计数为 1。当 s
被设置为 null
时,引用计数变为 0,对象可以被回收。
2.1.2 优点
- 实时性:引用计数算法将垃圾回收分散到应用程序的运行过程中,而不是集中在垃圾收集时,因此不会导致“Stop-The-World”事件。
2.1.3 缺点
- 循环引用问题:引用计数算法无法解决循环引用的问题。如果两个对象相互引用,即使它们不再被外部引用,它们的引用计数也不会为 0,导致无法被回收。
2.1.4 循环引用示例
public class ReferenceCountingGC {
public Object instance;
public static void testGC() {
ReferenceCountingGC a = new ReferenceCountingGC();
ReferenceCountingGC b = new ReferenceCountingGC();
a.instance = b;
b.instance = a;
a = null;
b = null;
}
}
在这个例子中,a
和 b
相互引用,导致它们的引用计数永远不会为 0,即使它们已经不再被外部引用,也无法被回收。
2.2 可达性分析算法
可达性分析算法(Reachability Analysis)通过从一组称为 GC Roots 的对象开始,遍历对象图,标记所有可达的对象。不可达的对象被认为是垃圾,可以被回收。
2.2.1 GC Roots
GC Roots 是一组必须活跃的引用,它们是程序运行时的起点,是一切引用链的源头。在 Java 中,GC Roots 包括以下几种:
- 虚拟机栈中的引用:方法的参数、局部变量等。
- 本地方法栈中 JNI 的引用:通过 JNI(Java Native Interface)调用的本地代码中的引用。
- 类静态变量:类的静态属性引用的对象。
- 运行时常量池中的常量:字符串常量或类类型常量。
2.2.2 示例
- 虚拟机栈中的引用
public class StackReference {
public void greet() {
Object localVar = new Object(); // 局部变量,存在于虚拟机栈中
System.out.println(localVar.toString());
}
public static void main(String[] args) {
new StackReference().greet();
}
}
在这个例子中,localVar
是一个局部变量,存在于虚拟机栈中,可以被认为是 GC Roots。在 greet
方法执行期间,localVar
引用的对象是活跃的。当 greet
方法执行完毕后,localVar
的作用域结束,引用的对象将不再被 GC Roots 引用,因此可以被回收。
- 本地方法栈中 JNI 的引用
public native void nativeMethod();
JNIEXPORT void JNICALL Java_NativeExample_nativeMethod(JNIEnv *env, jobject thisObj) {
jobject localRef = (*env)->NewObject(env, ...); // 本地方法栈中的 JNI 引用
}
在这个例子中,localRef
是一个 JNI 引用,存在于本地方法栈中。在本地方法执行期间,localRef
引用的对象是活跃的。一旦本地方法执行完毕,除非 localRef
是全局的,否则它引用的对象将被回收。
- 类静态变量
public class StaticFieldReference {
private static Object staticVar = new Object(); // 类静态变量
public static void main(String[] args) {
System.out.println(staticVar.toString());
}
}
在这个例子中,staticVar
是一个类静态变量,引用的对象存储在元空间中,可以被认为是 GC Roots。只要 StaticFieldReference
类未被卸载,staticVar
引用的对象就不会被回收。
- 运行时常量池中的常量
public class ConstantPoolReference {
public static final String CONSTANT_STRING = "Hello, World"; // 字符串常量
public static final Class<?> CONSTANT_CLASS = Object.class; // 类类型常量
public static void main(String[] args) {
System.out.println(CONSTANT_STRING);
System.out.println(CONSTANT_CLASS.getName());
}
}
在这个例子中,CONSTANT_STRING
和 CONSTANT_CLASS
是运行时常量池中的常量,它们引用的对象可以被认为是 GC Roots。只要 ConstantPoolReference
类未被卸载,这些常量引用的对象就不会被回收。
2.3 小结
- 引用计数算法通过维护对象的引用计数来判断对象是否可以被回收,但它无法解决循环引用的问题。
- 可达性分析算法通过从 GC Roots 开始遍历对象图,标记所有可达的对象,解决了循环引用的问题。
在 Java 中,垃圾回收器通常使用可达性分析算法来判断对象是否可以被回收。GC Roots 包括虚拟机栈中的引用、本地方法栈中的 JNI 引用、类静态变量和运行时常量池中的常量。
3 垃圾收集算法
在确定了哪些对象是垃圾之后,垃圾收集器需要选择合适的算法来回收这些垃圾。垃圾收集算法的设计目标是高效地回收垃圾,同时尽量减少对应用程序性能的影响。常见的垃圾收集算法包括标记-清除算法、复制算法、标记-整理算法和分代收集算法。
3.1 标记-清除算法(Mark-Sweep)
标记-清除算法是最基础的垃圾回收算法,分为两个阶段:
- 标记阶段:垃圾收集器从 GC Roots 开始,遍历所有可达的对象,并标记这些对象为存活对象。
- 清除阶段:垃圾收集器清除所有未被标记的对象,即垃圾对象。
优点
- 实现简单:标记-清除算法的逻辑清晰,易于实现。
缺点
- 内存碎片问题:清除阶段只是简单地回收垃圾对象,不会对内存进行整理,导致内存碎片化。内存碎片化可能会导致在分配大对象时无法找到足够的连续内存,从而触发新一轮的垃圾收集。
3.2 复制算法(Copying)
复制算法是为了解决标记-清除算法的内存碎片问题而设计的。它将可用内存划分为大小相等的两块区域,每次只使用其中一块。
- 复制阶段:当一块内存用完后,垃圾收集器将存活的对象复制到另一块内存中。
- 清理阶段:复制完成后,垃圾收集器清理已使用过的内存区域。
优点
- 内存连续性:复制算法保证了内存的连续性,避免了内存碎片问题。
- 高效性:由于只需要复制存活的对象,复制算法的效率较高。
缺点
- 内存利用率低:复制算法只能利用一半的内存空间,另一半内存始终处于空闲状态,导致内存利用率较低。
3.3 标记-整理算法(Mark-Compact)
标记-整理算法结合了标记-清除算法和复制算法的优点,分为三个阶段:
- 标记阶段:与标记-清除算法相同,垃圾收集器标记所有可达的对象。
- 整理阶段:将所有存活的对象移动到内存的一端,确保内存的连续性。
- 清理阶段:清理掉端边界以外的内存区域。
优点
- 解决内存碎片问题:标记-整理算法通过整理内存,避免了内存碎片问题。
- 内存利用率高:与复制算法不同,标记-整理算法可以利用全部内存空间。
缺点
- 效率较低:整理阶段需要移动所有存活的对象,导致效率较低。
3.4 分代收集算法(Generational Collection)
分代收集算法并不是一种独立的算法,而是根据对象的存活周期将内存划分为不同的代(通常是新生代和老年代),并根据各代的特点采用不同的垃圾收集算法。
3.4.1 新生代(Young Generation)
- 特点:新生代中的对象通常存活时间较短,大部分对象在创建后很快就会变成垃圾。
- 算法:由于新生代中大部分对象是短命的,垃圾收集器通常采用复制算法。新生代进一步分为 Eden 区和两个 Survivor 区(From 和 To)。每次垃圾收集时,存活的对象从 Eden 区和 From 区复制到 To 区,然后清理 Eden 区和 From 区。
3.4.2 老年代(Old Generation)
- 特点:老年代中的对象通常存活时间较长,对象存活率高。
- 算法:由于老年代中的对象存活率高,垃圾收集器通常采用标记-清除算法或标记-整理算法。老年代的垃圾收集称为 Major GC,通常会触发“Stop-The-World”事件。
优点
- 优化性能:分代收集算法根据对象的存活周期选择合适的垃圾收集算法,从而优化垃圾收集的性能。
- 减少停顿时间:通过将内存划分为不同的代,垃圾收集器可以更频繁地进行 Minor GC(新生代垃圾收集),减少 Major GC 的频率,从而减少停顿时间。
3.5 小结
- 标记-清除算法是最基础的垃圾回收算法,但存在内存碎片问题。
- 复制算法解决了内存碎片问题,但内存利用率较低。
- 标记-整理算法结合了标记-清除和复制算法的优点,解决了内存碎片问题,但效率较低。
- 分代收集算法根据对象的存活周期将内存划分为不同的代,并采用不同的垃圾收集算法,从而优化垃圾收集的性能。
不同的垃圾收集算法适用于不同的场景,垃圾收集器的设计需要在内存利用率、垃圾收集效率和停顿时间之间找到平衡。
4 新生代和老年代
在 Java 虚拟机(JVM)中,堆(Heap)是最大的一块内存区域,也是垃圾收集器管理的主要区域。堆内存被划分为新生代(Young Generation)和老年代(Old Generation),其中新生代又进一步分为 Eden 区和两个 Survivor 区(From 和 To)。
4.1 新生代(Young Generation)
4.1.1 Eden 区
根据 IBM 的研究,大约 98% 的对象是“朝生夕死”的,即这些对象在创建后很快就会变成垃圾。因此,大多数对象在新生代的 Eden 区中分配。
- 对象分配:当对象被创建时,首先在 Eden 区中分配内存。
- Minor GC:当 Eden 区没有足够的空间分配新对象时,JVM 会触发一次 Minor GC(新生代垃圾收集)。Minor GC 的频率较高,回收速度也较快。
- 存活对象处理:在 Minor GC 之后,Eden 区中绝大部分对象会被回收,而那些存活的对象会被移动到 Survivor 区的 From 区。如果 From 区空间不足,存活对象会直接进入 To 区。
4.1.2 Survivor 区
Survivor 区是新生代中的一个缓冲区,分为 From 区和 To 区。它的主要作用是减少对象直接进入老年代的频率,从而减少 Major GC 的发生。
4.1.3 为什么需要 Survivor 区?
如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被直接送到老年代。这样会导致老年代很快被填满,进而频繁触发 Major GC。然而,很多对象虽然在一次 Minor GC 中存活下来,但可能在第二次或第三次 Minor GC 中就会被回收。因此,直接将这些对象送入老年代并不是一个明智的选择。
Survivor 区的存在意义在于:
- 减少进入老年代的对象:Survivor 区通过预筛选机制,确保只有那些在多次 Minor GC 中存活下来的对象才会被晋升到老年代。
- 减少 Major GC 的频率:通过减少进入老年代的对象数量,Survivor 区有效地减少了 Major GC 的发生频率。
4.1.4 Survivor 区为什么分为两块?
Survivor 区分为两块(From 和 To)的主要目的是解决内存碎片化问题。
-
内存碎片化问题:如果没有 Survivor 区,或者 Survivor 区只有一个区域,那么在 Minor GC 后,Eden 区被清空,存活的对象被放入 Survivor 区。此时,Survivor 区中可能存在一些需要被回收的对象。在这种情况下,垃圾收集器只能采用标记-清除算法,这会导致内存碎片化。
-
双 Survivor 区的优势:通过设置两个 Survivor 区,每次 Minor GC 时,存活的对象会从 Eden 区和 From 区复制到 To 区。第二次 Minor GC 时,From 区和 To 区的角色互换,存活的对象从 Eden 区和 To 区复制到 From 区。这种机制确保了在任何时候,总有一个 Survivor 区是空的,另一个 Survivor 区是无碎片的。
-
为什么不是更多块?:如果 Survivor 区被划分为更多块,每一块的空间会变得更小,容易导致 Survivor 区满,进而增加对象进入老年代的频率。两块 Survivor 区是经过权衡后的最佳方案。
4.2 老年代(Old Generation)
老年代占据堆内存的 2/3,主要用于存放存活时间较长的对象。老年代的垃圾收集称为 Major GC,每次 Major GC 都会触发“Stop-The-World”事件,即暂停所有用户线程,直到垃圾收集完成。
4.2.1 老年代的垃圾收集算法
由于老年代中的对象存活率较高,复制算法在老年代中效率较低,因此老年代通常采用 标记-整理算法 或 标记-清除算法。
- 标记-整理算法:将所有存活的对象移动到内存的一端,然后清理掉端边界以外的内存区域,避免了内存碎片问题。
- 标记-清除算法:标记所有存活的对象,然后清除未标记的对象,但可能会导致内存碎片。
4.2.2 对象进入老年代的条件
除了通过内存担保机制将无法安置的对象直接进入老年代外,以下几种情况也会导致对象进入老年代:
- 大对象
大对象是指需要大量连续内存空间的对象。这类对象不管其生命周期长短,都会直接进入老年代。这是为了避免在 Eden 区和 Survivor 区之间进行大量的内存复制操作。
- 长期存活对象
JVM 为每个对象定义了一个 对象年龄(Age)计数器。对象在 Survivor 区中每经历一次 Minor GC,年龄就会增加 1 岁。当对象的年龄达到某个阈值(默认是 15 岁)时,它会被晋升到老年代。
-
年龄阈值设置:可以通过 JVM 参数
-XX:MaxTenuringThreshold
来调整对象晋升到老年代的年龄阈值。默认值为 15,但可以通过以下命令查看:java -XX:+PrintFlagsFinal -version | grep MaxTenuringThreshold
- 动态对象年龄
JVM 并不强制要求对象必须达到 15 岁才能进入老年代。如果 Survivor 区中某个年龄段的对象总大小超过了 Survivor 区的一半,那么该年龄段及以上年龄段的所有对象都会在下一次垃圾回收时被晋升到老年代。
这种动态调整机制类似于负载均衡中的动态调度算法,能够根据对象的实际存活情况优化内存使用,减少垃圾收集的频率。
4.3 小结
- 新生代:大多数对象在 Eden 区中分配,经过 Minor GC 后,存活的对象会被移动到 Survivor 区。Survivor 区分为 From 和 To 两个区域,用于减少内存碎片化,并减少对象进入老年代的频率。
- 老年代:老年代用于存放存活时间较长的对象,垃圾收集采用标记-整理或标记-清除算法。对象进入老年代的条件包括大对象、长期存活对象和动态对象年龄。
通过合理的内存划分和垃圾收集算法,JVM 能够在保证内存利用率的同时,减少垃圾收集的停顿时间,从而提高应用程序的性能。
5 Stop The World
在 Java 垃圾收集(Garbage Collection,GC)过程中,“Stop The World” 是一个重要的概念。它指的是在垃圾收集器执行垃圾回收时,JVM 会暂停所有的用户线程(即应用程序线程),直到垃圾收集完成。这种暂停被称为 “Stop The World” 事件。
5.1 为什么需要 “Stop The World”?
“Stop The World” 的主要目的是确保垃圾收集器能够准确地识别和回收垃圾对象。如果在垃圾收集过程中,用户线程继续运行并修改堆中的对象,可能会导致以下问题:
- 对象状态不一致:用户线程可能会修改对象的引用关系,导致垃圾收集器无法正确判断对象的可达性。
- 内存泄漏或误回收:如果垃圾收集器在收集过程中无法准确识别对象的引用关系,可能会导致某些对象被错误地回收(即本应存活的对象被回收),或者某些垃圾对象未被回收(即内存泄漏)。
因此,为了确保垃圾收集的准确性,JVM 在执行垃圾收集时会暂停所有用户线程,即触发 “Stop The World” 事件。
5.2 “Stop The World” 的影响
“Stop The World” 事件会对 Java 应用程序的性能产生显著影响,尤其是在停顿时间较长的情况下:
- 响应时间变长:如果垃圾收集器的停顿时间过长,应用程序的响应时间会显著增加,尤其是在对实时性要求较高的应用中,如交易系统、游戏服务器等,这种情况是不可接受的。
- 用户体验下降:对于用户交互频繁的应用程序(如 Web 应用或桌面应用),长时间的停顿会导致用户体验下降,甚至可能导致用户认为应用程序无响应。
5.3 减少 “Stop The World” 的策略
为了减少 “Stop The World” 事件对应用程序性能的影响,Java 提供了多种垃圾收集器,并引入了并发垃圾收集技术。以下是一些常见的策略:
5.3.1 并发垃圾收集器
并发垃圾收集器(如 G1(Garbage First) 和 ZGC(Z Garbage Collector))通过并发执行垃圾收集任务,尽量减少 “Stop The World” 的停顿时间。
- G1 收集器:G1 收集器通过将堆内存划分为多个区域(Region),并采用并发标记和混合收集的方式,减少了停顿时间。G1 的目标是让停顿时间控制在用户指定的范围内(通过
-XX:MaxGCPauseMillis
参数设置)。 - ZGC 收集器:ZGC 是一种低延迟的垃圾收集器,它的设计目标是让停顿时间不超过 10 毫秒。ZGC 通过并发执行大部分垃圾收集任务,显著减少了 “Stop The World” 的影响。
5.3.2 分代收集
分代收集算法将堆内存划分为新生代和老年代,并根据对象的存活周期采用不同的垃圾收集策略。新生代中的对象通常存活时间较短,因此垃圾收集器可以更频繁地进行 Minor GC,而老年代中的对象存活时间较长,垃圾收集器会减少 Major GC 的频率。
通过分代收集,垃圾收集器可以更高效地管理内存,减少 “Stop The World” 事件的发生频率。
5.3.3 增量式垃圾收集
增量式垃圾收集器通过将垃圾收集任务分解为多个小任务,并在应用程序运行的间隙中逐步执行这些任务,从而减少 “Stop The World” 的停顿时间。
5.3.4 并行垃圾收集
并行垃圾收集器(如 Parallel GC)通过多线程并行执行垃圾收集任务,加快垃圾收集的速度,从而减少停顿时间。虽然并行垃圾收集器仍然会触发 “Stop The World” 事件,但它通过并行处理提高了垃圾收集的效率。
5.4 小结
“Stop The World” 是 Java 垃圾收集过程中不可避免的一个挑战。它确保了垃圾收集器能够准确地识别和回收垃圾对象,但同时也会对应用程序的性能产生影响。为了减少 “Stop The World” 的停顿时间,Java 提供了多种垃圾收集器(如 G1 和 ZGC),并通过并发、分代和增量式垃圾收集等技术,尽量减少对应用程序性能的影响。
在实际应用中,开发者需要根据应用程序的性能需求和内存使用情况,选择合适的垃圾收集器,并通过调优参数(如 -XX:MaxGCPauseMillis
)来平衡内存的有效利用和应用程序的响应性能。
6 总结
Java 的垃圾回收机制通过自动管理内存,简化了开发者的内存管理工作。垃圾回收器通过引用计数算法和可达性分析算法判断垃圾对象,并使用标记清除、复制、标记整理和分代收集等算法进行垃圾回收。新生代和老年代的划分进一步优化了垃圾回收的效率。尽管“Stop The World”事件会对性能产生影响,但现代垃圾收集器通过并发收集技术,尽量减少了停顿时间。
7 思维导图
8 参考链接
深入理解 JVM 的垃圾回收机制