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

发布于:2025-07-18 ⋅ 阅读:(15) ⋅ 点赞:(0)

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)

 


网站公告

今日签到

点亮在社区的每一天
去签到