Java垃圾收集器与内存分配策略深度解析
在 Java 与 C++ 的世界里,内存动态分配与垃圾收集技术仿佛筑起了一道高墙。墙外的人渴望进入,享受自动内存管理的便利;而墙内的人却试图突破,追求更高的性能与控制力。今天,就让我们深入探讨 Java 的垃圾收集器与内存分配策略,一窥其背后的奥秘。
3.1 概述
垃圾收集(Garbage Collection,简称 GC)并非 Java 的专属产物。早在 1960 年,Lisp 语言就率先引入了内存动态分配与垃圾收集技术。当时,Lisp 的设计者 John McCarthy 就明确了垃圾收集需要解决的三大问题:哪些内存需要回收?何时回收?如何回收?
经过半个多世纪的发展,内存动态分配与回收技术已相当成熟。然而,当系统面临内存溢出、内存泄漏问题,或者垃圾收集成为性能瓶颈时,开发者仍需深入了解这些“自动化”技术的细节,以便进行有效的监控与优化。
在 Java 中,程序计数器、虚拟机栈、本地方法栈等区域的内存分配与回收具有确定性,随线程的生命周期而自然回收。而 Java 堆和方法区则截然不同,它们的内存分配与回收具有高度不确定性,这也是垃圾收集器重点关注的区域。
3.2 对象已死?
在 Java 堆中,垃圾收集器的核心任务是区分存活对象与死亡对象。那么,如何判断一个对象是否存活呢?
3.2.1 引用计数算法
引用计数算法是一种简单直观的判断方法。它为每个对象设置一个引用计数器,每当有引用指向该对象时,计数器加一;当引用失效时,计数器减一。当计数器为零时,表示该对象不再被使用,可以被回收。
然而,引用计数算法存在明显的缺陷。它无法解决对象之间的循环引用问题。例如,两个对象互相引用,即使它们已经无法被外部访问,它们的引用计数也永远不会为零,从而导致内存泄漏。
3.2.2 可达性分析算法
当前主流的编程语言(如 Java、C# 等)都采用可达性分析算法来判断对象是否存活。该算法从一组称为“GC Roots”的根对象出发,沿着引用链向下搜索。如果一个对象到 GC Roots 之间没有任何引用链相连,则该对象被认为是不可达的,可以被回收。
在 Java 中,以下对象可以作为 GC Roots:
-
虚拟机栈(栈帧中的本地变量表)中引用的对象。
-
方法区中类静态属性引用的对象。
-
方法区中常量引用的对象。
-
本地方法栈中 JNI 引用的对象。
-
Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象、常驻异常对象等。
-
所有被同步锁持有的对象。
-
反映 Java 虚拟机内部情况的 JMX Bean、JVMTI 中注册的回调、本地代码缓存等。
3.2.3 再谈引用
从 JDK 1.2 开始,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用四种,分别对应不同的内存回收策略。
-
强引用:最传统的引用形式,如
Object obj = new Object()
。只要强引用存在,垃圾收集器永远不会回收被引用的对象。 -
软引用:用于描述一些有用但非必须的对象。当内存不足时,垃圾收集器会回收这些对象。适用于缓存场景。
-
弱引用:比软引用更弱,被弱引用关联的对象只会在下一次垃圾收集时被回收。适用于监听器等场景。
-
虚引用:最弱的引用形式,无法通过虚引用来获取对象实例。主要用于在对象被回收时接收通知。
3.2.4 生存还是死亡?
即使对象在可达性分析中被判定为不可达,也不一定会被立即回收。如果对象覆盖了 finalize()
方法,并且该方法尚未被虚拟机调用过,那么该对象会被放入 F-Queue 队列中,由 Finalizer 线程执行其 finalize()
方法。如果对象在 finalize()
方法中重新与引用链上的某个对象建立关联,那么它将被移出回收队列;否则,它将被回收。
需要注意的是,finalize()
方法的执行代价高昂,且存在不确定性。它并不等同于 C++ 中的析构函数,也不建议用于关闭外部资源等清理工作。现代 Java 开发中,应尽量避免使用 finalize()
方法。
3.2.5 回收方法区
方法区(如 HotSpot 虚拟机中的元空间或永久代)也可能进行垃圾收集,主要回收废弃的常量和不再使用的类。回收废弃常量相对简单,而判定一个类是否可以被回收则需要满足以下三个条件:
-
该类的所有实例都已被回收。
-
加载该类的类加载器已被回收。
-
该类对应的
java.lang.Class
对象没有被引用。
HotSpot 虚拟机提供了 -XX:+PrintClassHistogram
参数用于查看类的加载情况,以及 -XX:+TraceClassLoading
和 -XX:+TraceClassUnloading
参数用于跟踪类的加载与卸载。
3.3 垃圾收集算法
垃圾收集算法主要分为两大类:引用计数式垃圾收集和追踪式垃圾收集。由于引用计数式垃圾收集在主流 Java 虚拟机中未被采用,本文主要介绍追踪式垃圾收集算法及其相关理论。
3.3.1 分代收集理论
分代收集理论是现代垃圾收集器的核心设计思想。它基于以下两个分代假说:
-
弱分代假说:绝大多数对象都是朝生夕灭的。
-
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
根据这两个假说,Java 堆被划分为新生代和老年代。新生代主要存储新创建的对象,这些对象通常具有较短的生命周期;老年代则存储经过多次垃圾收集后仍然存活的对象。
分代收集理论还引入了跨代引用假说:跨代引用相对于同代引用来说只占极少数。基于这一假说,垃圾收集器在进行新生代收集时,只需关注老年代中存在跨代引用的区域,而无需扫描整个老年代。这一机制通过“记忆集”(Remembered Set)实现。
3.3.2 标记-清除算法
标记-清除算法是最基础的垃圾收集算法,由 John McCarthy 在 1960 年提出。它分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,然后统一回收这些对象。
标记-清除算法的主要缺点是执行效率不稳定,且容易产生内存碎片。当堆中对象数量较多时,标记和清除过程的开销会显著增加。此外,标记-清除算法可能导致内存碎片化,影响后续大对象的分配。
3.3.3 标记-复制算法
标记-复制算法通过将内存划分为两块区域,每次只使用其中一块,当内存用完时,将存活对象复制到另一块区域,然后清理已使用过的内存。这种算法的优点是实现简单,运行高效,但缺点是会浪费一半的内存空间。
现代 Java 虚拟机(如 HotSpot)通常采用一种改进的标记-复制算法,称为“Appel 式回收”。它将新生代划分为一个较大的 Eden 空间和两个较小的 Survivor 空间。每次分配内存时,只使用 Eden 和其中一个 Survivor 空间。垃圾收集时,将 Eden 和 Survivor 中的存活对象复制到另一个 Survivor 空间,然后清理 Eden 和已使用的 Survivor 空间。
3.3.4 标记-整理算法
标记-整理算法主要用于老年代的垃圾收集。它在标记阶段与标记-清除算法相同,但在后续步骤中,将所有存活对象向内存空间一端移动,然后清理边界以外的内存。这种算法的优点是可以避免内存碎片化问题,但缺点是移动对象和更新引用的开销较大。
3.4 HotSpot 的算法细节实现
3.4.1 根节点枚举
根节点枚举是垃圾收集过程中的关键步骤。HotSpot 虚拟机通过 OopMap 数据结构快速准确地完成 GC Roots 枚举。OopMap 记录了对象内每个偏移量上数据的类型,以及栈和寄存器中引用的位置。这样,垃圾收集器在扫描时可以直接获取这些信息,而无需从头开始查找。
3.4.2 安全点
安全点是 HotSpot 虚拟机中用于暂停用户线程的机制。由于生成 OopMap 的成本较高,HotSpot 并非为每条指令都生成 OopMap,而是在特定位置记录这些信息,这些位置被称为安全点。当垃圾收集发生时,用户线程会运行到最近的安全点并暂停。
HotSpot 使用主动式中断机制,通过设置标志位让线程在安全点上主动中断挂起。轮询标志的操作被优化为一条汇编指令,以提高效率。
3.4.3 安全区域
安全区域用于解决线程处于 Sleep 或 Blocked 状态时无法响应垃圾收集的问题。线程进入安全区域时会标识自己,垃圾收集器在回收时可以忽略这些线程。线程离开安全区域时,会检查垃圾收集是否完成,若未完成则等待。
3.4.4 记忆集与卡表
记忆集用于记录从非收集区域指向收集区域的指针集合,以避免扫描整个老年代。卡表是记忆集的一种常见实现形式,通过字节数组记录卡页的状态。HotSpot 虚拟机中,卡表的每个元素对应一个卡页,卡页大小为 512 字节。当卡页内存在跨代引用时,对应的卡表元素会被标记为“脏”。
3.4.5 写屏障
写屏障用于维护卡表的状态。HotSpot 虚拟机通过写屏障技术,在引用类型字段赋值时更新卡表。写屏障分为写前屏障和写后屏障,HotSpot 主要使用写后屏障。
3.4.6 并发的可达性分析
并发的可达性分析是现代垃圾收集器的关键技术之一。它允许垃圾收集器与用户线程并发执行,从而减少停顿时间。HotSpot 虚拟机通过三色标记算法和写屏障技术实现并发可达性分析。
三色标记算法将对象分为白色、灰色和黑色三种状态,分别表示尚未访问、已访问但引用未扫描、已完全扫描。并发执行时,可能会出现“对象消失”问题,即原本存活的对象被误标为已消亡。为解决这一问题,HotSpot 虚拟机采用了增量更新和原始快照两种解决方案。