1. JVM 内存结构概览
详细请参考:
Java 虚拟机在执行 Java 程序时,会将不同类型的数据分配到不同的内存区域,这些内存区域各自承担不同的职责。理解 JVM 的内存结构是深入掌握对象分配与垃圾回收策略的前提。
1.1 Java 内存模型简述
Java 内存模型(Java Memory Model,JMM)定义了线程之间共享变量的可见性与有序性保障,其核心并非物理内存结构,而是抽象行为规则。然而,与之紧密关联的 JVM 运行时数据区才是程序实际运行的内存基础。
Java 虚拟机规范定义了如下运行时内存结构:
程序计数器
虚拟机栈
本地方法栈
Java 堆(Heap)
方法区(JDK 8 后演变为元空间 Metaspace)
其中,与垃圾回收密切相关的主要是 Java 堆和方法区(元空间),因为这两者中的内存是“共享的”,生命周期较长。
1.2 Java 堆(Heap)分代结构
Java 堆是 JVM 管理的最大一块内存空间,几乎承载了所有的对象实例。根据对象生命周期不同,堆被进一步划分为不同的区域:
年轻代(Young Generation)
Eden 区
Survivor 区(From、To)
老年代(Old Generation)
这种分代结构的设计基于“多数对象朝生夕死”的假设,大量临时对象会在短时间内被回收,因此年轻代的回收频率更高。
// 示例代码:通过对象创建演示 Eden 区分配
public class EdenAllocation {
public static void main(String[] args) {
byte[] allocation1 = new byte[2 * 1024 * 1024];
byte[] allocation2 = new byte[2 * 1024 * 1024];
byte[] allocation3 = new byte[2 * 1024 * 1024];
byte[] allocation4 = new byte[4 * 1024 * 1024]; // 触发 Minor GC
}
}
运行参数示例:
-XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
此配置将堆内存固定为 20M,年轻代为 10M,其中 Eden:Survivor = 8:1:1,可用于观察对象在 Eden 区的分配与 Minor GC 的触发。
1.3 方法区与元空间
方法区(Method Area)用于存储类的结构信息(如类元数据、静态变量、常量池等),在 JDK 8 之前由永久代(PermGen)实现,但永久代存在一些难以扩展的问题,如空间固定、类卸载难。
JDK 8 起,永久代被废除,取而代之的是元空间(Metaspace)。
元空间不再是 JVM 堆的一部分,而是使用本地内存进行管理。
// 示例代码:模拟类加载导致元空间溢出
import javassist.ClassPool;
public class MetaspaceOOM {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
while (true) {
pool.makeClass("MetaspaceOOM" + System.nanoTime()).toClass();
}
}
}
运行参数示例:
-XX:MaxMetaspaceSize=64M
该示例可用于模拟元空间溢出(Metaspace OutOfMemoryError),从而帮助理解元空间的内存限制。
1.4 非堆内存与直接内存
除了堆内存,JVM 还使用非堆内存(如直接内存 Direct Memory),这些内存区域由 sun.misc.Unsafe
或 ByteBuffer.allocateDirect()
直接分配,不受堆大小限制,但仍受系统物理内存与 JVM 参数约束。
// 示例代码:申请直接内存
import java.nio.ByteBuffer;
public class DirectMemoryExample {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocateDirect(100 * 1024 * 1024);
System.out.println("直接内存分配成功");
}
}
若频繁使用而未正确释放,可能导致 OutOfMemoryError: Direct buffer memory
。
2. 对象在内存中的分配原则
对象在 JVM 中的内存分配并非随意进行,而是由一整套策略驱动。这些策略不仅决定了对象的初始落位,还决定了它的晋升轨迹。理解这些规则是 GC 调优的前提。
2.1 对象优先在 Eden 区分配
在大多数情况下,新创建的对象都会优先分配在年轻代的 Eden 区。这一原则基于“朝生夕死”的假设,认为大多数对象生命周期较短,因此应尽早在 Minor GC 中回收。
public class EdenTest {
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
byte[] temp = new byte[1 * 1024 * 1024];
}
}
}
运行参数:
-XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
可以观察到大量对象在 Eden 中被快速分配与回收。
2.2 大对象直接进入老年代
所谓“大对象”,是指需要大量连续内存空间的对象,如长数组、BufferedImage 等。若直接放入 Eden,不仅容易造成内存碎片,还可能频繁触发 GC。
因此 JVM 提供了参数 -XX:PretenureSizeThreshold
,超过该阈值的对象会直接进入老年代。
public class BigObjectTest {
public static void main(String[] args) {
byte[] big = new byte[5 * 1024 * 1024];
}
}
参数示例:
-XX:+UseSerialGC -Xms20M -Xmx20M -XX:PretenureSizeThreshold=3145728 -XX:+PrintGCDetails
此设置将大于 3MB 的对象直接分配到老年代。
2.3 长期存活的对象晋升到老年代
在经历多次 Minor GC 后仍然存活的对象,JVM 会将其从年轻代晋升到老年代。
晋升的判断依据主要是对象的“年龄”,该年龄由 JVM 跟踪。
默认情况下,15 次 GC 后对象会晋升,但这一阈值可通过 -XX:MaxTenuringThreshold
修改。
public class TenuringThresholdTest {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] allocation1 = new byte[_1MB / 4];
byte[] allocation2 = new byte[4 * _1MB];
byte[] allocation3 = new byte[4 * _1MB];
allocation3 = null;
allocation3 = new byte[4 * _1MB];
}
}
参数:
-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution -XX:+PrintGCDetails
观察日志中“tenured”字段可判断对象是否已晋升。
2.4 动态对象年龄判定
为提升内存利用率,JVM 不仅仅依据年龄阈值来决定对象是否晋升,还引入了“动态年龄判断”。
具体逻辑是:如果某一年龄段所有对象的总大小超过 Survivor 区的一半,那么年龄 ≥ 当前值的对象将直接晋升老年代。
这种机制可避免 Survivor 区频繁爆满,提高 GC 效率。
2.5 空间分配担保机制
在执行 Minor GC 前,JVM 会检查老年代是否有足够空间用于担保转移对象。如果担保失败,就会触发一次 Full GC。
这机制称为“空间分配担保”(Allocation Guarantee)。
参数 -XX:+HandlePromotionFailure
在 JDK 6u24 后默认开启,表示若老年代担保失败将触发 Full GC,而不是直接崩溃。
3. 垃圾回收算法详解
详细请参考:JVM 垃圾收集算法全面解析
JVM 中的垃圾回收并非使用一种统一的算法,而是根据不同代的特点和 GC 策略,组合使用多种经典算法以优化内存回收效率和应用停顿时间。本章将详细介绍几种核心垃圾回收算法。
3.1 标记-清除算法(Mark-Sweep)
该算法是最基础的垃圾回收方法,分为两个阶段:
标记(Mark): 遍历所有可达对象,做标记。
清除(Sweep): 清理所有未被标记的对象,释放内存空间。
优点:
实现简单,适用于老年代。
缺点:
内存碎片严重,因清除后不会整理空间,导致大对象分配失败。
GC 停顿时间较长。
// 模拟内存碎片的产生
List<byte[]> allocations = new ArrayList<>();
for (int i = 0; i < 100; i++) {
allocations.add(new byte[1 * 1024 * 1024]);
}
for (int i = 0; i < 50; i++) {
allocations.set(i, null); // 模拟内存空洞
}
System.gc();
3.2 标记-整理算法(Mark-Compact)
为解决标记-清除算法中的碎片问题,标记-整理算法在标记后将存活对象向一端移动,随后清理末尾空间。
优点:
避免内存碎片。
缺点:
移动对象代价较高。
老年代 GC(如 CMS 的 Full GC)常采用该算法。
// 实际不可见,但可以通过 -XX:+PrintGCDetails 观察整理日志
System.gc();
3.3 复制算法(Copying)
该算法将内存划分为两块(如 Eden 和 Survivor),每次只使用其中一块。当发生 GC 时,将存活对象复制到另一块,清空原始区域。
优点:
无需整理,无碎片,速度快。
缺点:
空间浪费(仅使用一半内存)。
年轻代通常使用该算法,因对象生命周期短,复制效率高。
// 创建大量临时对象观察复制行为
for (int i = 0; i < 10000; i++) {
byte[] temp = new byte[1 * 1024 * 1024];
}
运行参数:
-XX:+UseSerialGC -XX:+PrintGCDetails
3.4 分代收集理论(Generational Collection)
现代 JVM 垃圾回收的核心理论,即将堆分为不同“代”处理对象:
年轻代(Young): 使用复制算法,频繁回收。
老年代(Old): 使用标记-清除或标记-整理算法,回收频率低。
分代收集的关键优势:
针对不同生命周期的对象采用不同算法,提升效率与性能。
示例参数(Serial GC 分代行为):
-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
4. 主流垃圾收集器比较
本章将介绍 JVM 中常见的几种垃圾收集器(GC),探讨它们使用的算法、应用场景、性能特征以及各自优劣,帮助开发者合理选择合适的 GC 策略。
4.1 Serial 垃圾收集器
详细请参考:垃圾收集器-Serial 垃圾收集器-Serial Old
特点: 单线程处理所有 GC 工作,包括标记与回收,适合内存较小、单核 CPU 环境。
年轻代算法:复制算法
老年代算法:标记-整理
是否并行:否
是否并发:否
优点: 实现简单,额外开销低。 缺点: GC 期间所有线程停止(Stop-The-World),延迟高。
使用场景:命令行工具、嵌入式设备、小型服务。
示例参数:
-XX:+UseSerialGC
4.2 Parallel 垃圾收集器(又称 Throughput Collector)
详细请参考:垃圾收集器-Parallel Scavenge 垃圾收集器-Parallel Old
特点: 多线程处理 GC,目标是最大化吞吐量(即 GC 时间占比最小)。
年轻代算法:并行复制
老年代算法:并行标记-整理(Parallel Old)
是否并行:是
是否并发:否
优点: 吞吐量高,适合批量处理任务。 缺点: 停顿时间仍然较长,不适用于低延迟场景。
使用场景:数据处理、后台批量任务、高计算密度服务。
示例参数:
-XX:+UseParallelGC
4.3 CMS(Concurrent Mark Sweep)
详细请参考:垃圾收集器-CMS
特点: 以最小化 GC 停顿时间为目标,通过并发标记与清除减少停顿时间。
年轻代算法:并行复制(默认)
老年代算法:标记-清除
是否并行:部分并行
是否并发:是(老年代)
优点: 响应时间快,适合用户交互型应用。 缺点: 会产生内存碎片;并发阶段会占用 CPU,可能影响业务线程。
使用场景:中大型网站、互联网服务、需要响应速度的系统。
示例参数:
-XX:+UseConcMarkSweepGC
提示: CMS 在 JDK 9 中标记为“Deprecated”,推荐迁移到 G1。
4.4 G1(Garbage First)
详细请参考:垃圾收集器-G1(Garbage First)
特点: 以区域(Region)为单位管理内存,实现并行与并发收集,兼顾吞吐与低停顿。
算法:并行 + 并发 + 分代 + 增量压缩
是否并行:是
是否并发:是
优点: 停顿可控、适合大内存、高并发。 缺点: 调优复杂,学习成本略高。
使用场景:大中型企业级系统、大内存部署环境。
示例参数:
-XX:+UseG1GC
4.5 ZGC(Z Garbage Collector)
详细请参考:垃圾收集器-ZGC
特点: 极低延迟的垃圾收集器,GC 停顿时间控制在 1~2ms,适用于大内存场景。
算法:并发标记 + 重分配(Colored Pointer)
是否并行:是
是否并发:是
优点: 停顿时间极低,适合低延迟业务。 缺点: 对硬件和系统版本有要求;内存占用偏高。
使用场景:金融交易系统、在线游戏、大数据实时分析。
示例参数(JDK 15+):
-XX:+UseZGC
4.6 Shenandoah
详细请参考:垃圾收集器-Shenandoah
特点: 与 ZGC 类似,追求“并发整理”的低延迟垃圾回收器,停顿时间不随堆大小线性增长。
算法:并发标记 + 并发整理
是否并行:是
是否并发:是
优点: 停顿极短,适用于响应时间敏感的场景。 缺点: 和 ZGC 一样对 JVM 支持版本、内存等有较高要求。
使用场景:云服务、电商、高并发应用。
示例参数(JDK 12+):
-XX:+UseShenandoahGC
5. 内存分配与回收在不同收集器中的实现
不同垃圾收集器不仅在算法上有所差异,更重要的是它们在内存分配、对象晋升、回收触发机制等方面具有独特的实现细节。本章将结合前三章的基础,深入分析各收集器在内存管理方面的具体策略。
5.1 G1 中的内存分配机制
G1 将整个堆划分为若干个大小相同的 Region(区域),每个 Region 可充当 Eden、Survivor 或 Old 区的一部分。
内存分配:
G1 优先从空闲 Region 中分配 Eden 区。
Survivor 区由 G1 动态分配数量的 Region。
老年代也是由多个 Region 构成。
GC 触发与过程:
G1 使用 预测模型,决定在可接受的停顿时间内清理哪些 Region(Mixed GC)。
使用 Remembered Set(RSet)记录跨 Region 的引用关系,提升并发可达性分析效率。
// G1 GC 示例参数
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xmx512m -Xms512m -XX:+PrintGCDetails
晋升机制:
对象在 Minor GC 中经过多次复制会晋升到老年代 Region。
G1 的 Mixed GC 会同时清理部分老年代与年轻代,提高老年代回收效率。
5.2 ZGC 的颜色指针机制
ZGC 引入了革命性的“着色指针(Colored Pointers)”技术,以便实现高并发、低延迟回收。
内存分配:
内存区域被分为小对象区域(小于 256KB)与大对象区域。
所有 Region 被动态映射管理,不同内存块通过元数据统一追踪。
GC 过程分为三个阶段:
并发标记(Concurrent Mark): 标记可达对象。
并发重定位(Concurrent Relocate): 对象迁移并更新引用。
并发重映射(Concurrent Remap): 通过“指针解码”更新地址引用。
ZGC 不会在 GC 期间移动对象导致长时间 Stop-The-World,只需短暂停顿用于 GC 开始和结束信号。
// ZGC 示例参数
-XX:+UseZGC -Xmx2G -Xms2G -XX:+PrintGCDetails
优势在于:
极短暂停时间
不依赖分代模型
适合极大堆内存的环境(最大支持 16TB)
5.3 Shenandoah 的并发回收
Shenandoah 与 ZGC 类似,采用 Region + 并发压缩 + 并发引用更新 模式,目标是停顿时间与堆大小解耦。
内存布局与分配:
与 G1 相似,堆被划分为 Region
每次 GC 会选取多个 Region 并发标记和移动对象
GC 步骤:
并发标记(Concurrent Mark)
并发清理(Concurrent Cleanup)
并发压缩(Concurrent Compact)
并发引用更新(Concurrent Update References)
// Shenandoah 示例参数
-XX:+UseShenandoahGC -Xmx2G -Xms2G -XX:+PrintGCDetails
与 ZGC 区别:
Shenandoah 采用 读屏障(Read Barrier) 实现并发引用更新
ZGC 使用 指针编码
优点:
停顿时间低
支持更低版本 JDK(JDK 11+)
可动态触发回收(如低占用 GC)