JVM 内存分配与垃圾回收策略

1. JVM 内存结构概览

详细请参考:

Java 内存区域全解

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.UnsafeByteBuffer.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)

该算法是最基础的垃圾回收方法,分为两个阶段:

  1. 标记(Mark): 遍历所有可达对象,做标记。

  2. 清除(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 过程分为三个阶段:

  1. 并发标记(Concurrent Mark): 标记可达对象。

  2. 并发重定位(Concurrent Relocate): 对象迁移并更新引用。

  3. 并发重映射(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 步骤:

  1. 并发标记(Concurrent Mark)

  2. 并发清理(Concurrent Cleanup)

  3. 并发压缩(Concurrent Compact)

  4. 并发引用更新(Concurrent Update References)

    // Shenandoah 示例参数
    -XX:+UseShenandoahGC -Xmx2G -Xms2G -XX:+PrintGCDetails

与 ZGC 区别:

  • Shenandoah 采用 读屏障(Read Barrier) 实现并发引用更新

  • ZGC 使用 指针编码

优点:

  • 停顿时间低

  • 支持更低版本 JDK(JDK 11+)

  • 可动态触发回收(如低占用 GC)

相关推荐
程序猿20235 小时前
MAT(memory analyzer tool)主要功能
jvm
期待のcode7 小时前
Java虚拟机的非堆内存
java·开发语言·jvm
jmxwzy11 小时前
JVM(java虚拟机)
jvm
Maỿbe12 小时前
JVM中的类加载&&Minor GC与Full GC
jvm
人道领域13 小时前
【零基础学java】(等待唤醒机制,线程池补充)
java·开发语言·jvm
小突突突13 小时前
浅谈JVM
jvm
饺子大魔王的男人14 小时前
远程调试总碰壁?局域网成 “绊脚石”?Remote JVM Debug与cpolar的合作让效率飙升
网络·jvm
天“码”行空1 天前
java面向对象的三大特性之一多态
java·开发语言·jvm
独自破碎E1 天前
JVM的内存区域是怎么划分的?
jvm
期待のcode1 天前
认识Java虚拟机
java·开发语言·jvm