Java虚拟机堆

Java虚拟机堆

Java虚拟机堆

Java 堆(Java Heap)是 Java 虚拟机(JVM)运行时数据区中与应用程序关联最紧密、也是 JVM 管理的最大块内存区域。根据《Java 虚拟机规范》定义:除逃逸分析优化下的栈上分配对象、标量替换的零散数据外,所有对象实例及数组均在堆上分配内存,这是 Java 堆的核心特征。Java 堆完全由垃圾回收器自动化管理,开发者无需显式释放对象内存,GC 会自动识别并清理不再被引用的垃圾对象,实现内存的动态回收。

一、Java堆的分代划分逻辑

Java堆按对象存活特征逻辑划分为新生代(Young Generation)和老年代(Old Generation / Tenured Generation)两大区域,通过针对不同区域对象特征适配差异化垃圾回收算法来避免整堆扫描的低效问题,最大化回收效率,二者内存占比可通过JVM参数调整;

其中新生代作为新对象的核心分配区域,进一步细分为伊甸园区(Eden Space)和两个大小完全相等、角色动态互换的幸存区(From Survivor/S0 与 To Survivor/S1,任意时刻必有一个处于空闲状态),而老年代则用于存储熬过多次GC、生命周期较长的对象。

二、新生代(Young Generation)

新生代是 Java 堆中专门承载新创建对象的核心区域,为实现高效垃圾回收,进一步细分为三个逻辑子区域,且各区域内存占比默认遵循 -XX:SurvivorRatio=8 的配置,即伊甸园区(Eden Space)占新生代内存的 80%,两个幸存区(Survivor Space)各占 10%。其中,伊甸园区是新对象的首要分配目的地,除超过预设阈值的大对象、超长数组外,所有刚创建的对象均优先在此分配内存;幸存区则分为 From Survivor(S0)和 To Survivor(S1)两个大小完全相等、角色动态互换的子区域,任意时刻必有一个处于空闲状态,其核心作用是作为 Minor GC 后存活对象的临时存储载体,通过角色轮换实现对象存活时间的筛选。

当伊甸园区内存耗尽时,会触发仅针对新生代的 Minor GC(新生代垃圾回收),其核心执行流程基于复制算法:首先标记伊甸园区中仍被引用的存活对象,同时标记当前非空闲的 From Survivor(S0)中存活的对象;随后将所有标记为存活的对象复制到空闲的 To Survivor(S1)中,并为每个对象记录一次 GC 年龄(即经历的 Minor GC 次数);接着彻底清理伊甸园区和原 From Survivor(S0)的全部内存,释放无效对象占用的空间;最后互换两个幸存区的角色,原 From Survivor 变为下一次 GC 的 To Survivor,原 To Survivor 变为新的 From Survivor,确保下一次 Minor GC 仍有空闲幸存区可供复制,这一流程既保证了新生代内存的整洁性,又能通过 GC 年龄筛选出短期存活与潜在长期存活的对象。

三、老年代(Old Generation/Tenured Generation)

老年代是 Java 堆中存储生命周期较长、经过多次 GC 筛选后仍存活对象的区域,对象从新生代晋升至老年代的核心规则包括三类:

一是幸存区中的对象每经历一次 Minor GC 且存活,其 GC 年龄便累加 1,当年龄达到 JVM 预设的晋升阈值(默认 15,可通过 -XX:MaxTenuringThreshold 参数调整)时,会被认定为长期存活对象,触发晋升至老年代;

二是若某次 Minor GC 后,存活对象的总大小超过当前空闲幸存区的容量,为避免内存溢出,部分未达晋升年龄的对象会直接提前晋升至老年代;

三是对于超过预设阈值的大对象(如超大数组、长字符串,阈值可通过 -XX:PretenureSizeThreshold 参数指定),为减少复制算法的开销,JVM 会直接跳过新生代,将其分配至老年代。

老年代的垃圾回收称为 Major GC,由于其常与 Minor GC 联动执行(整体称为 Full GC),且老年代对象存活率高、占用内存大,相较于 Minor GC,Full GC 的执行耗时显著更长,触发频率也更低,其核心触发条件包括老年代内存耗尽、新生代晋升对象总大小超过老年代剩余空间等,是 Java 应用性能调优中需重点控制的环节。

最大堆和初始堆的设置

当 Java 进程启动时,虚拟机会分配初始堆总空间,该大小由参数 -Xms 指定。默认情况下,虚拟机会维持堆总空间在-Xms范围内运行;若初始堆空间无法满足内存需求(如对象分配失败),虚拟机会动态扩展堆总空间,直到达到-Xmx指定的最大堆总空间上限。

Xms:指定初始堆总空间大小

Xmx:指定最大堆总空间大小

以下是验证堆内存分配的代码及参数说明:

java 复制代码
package cn.tx;
public class HeapAlloc {
    public static void main(String[] args) {
        System.out.print("maxMemory=");
        System.out.println(Runtime.getRuntime().maxMemory() + " bytes");
        System.out.print("free mem=");
        System.out.println(Runtime.getRuntime().freeMemory() + " bytes");
        System.out.print("total mem=");
        System.out.println(Runtime.getRuntime().totalMemory() + " bytes");

        byte[] b = new byte[1 * 1024 * 1024];
        System.out.println("分配了1M空间给数组");
        System.out.print("maxMemory=");
        System.out.println(Runtime.getRuntime().maxMemory() + " bytes");
        System.out.print("free mem=");
        System.out.println(Runtime.getRuntime().freeMemory() + "bytes");
        System.out.print("total mem=");
        System.out.println(Runtime.getRuntime().totalMemory() + " bytes");

        b = new byte[4 * 1024 * 1024];
        System.out.println("分配了4M空间给数组");
        System.out.print("maxMemory=");
        System.out.println(Runtime.getRuntime().maxMemory() + " bytes");
        System.out.print("free mem=");
        System.out.println(Runtime.getRuntime().freeMemory() + " bytes");
        System.out.print("total mem=");
        System.out.println(Runtime.getRuntime().totalMemory() + " bytes");
    }
}

设置 JVM 参数:

-XX:+PrintGCDetails -XX:+PrintCommandLineFlags -Xmx20m

-XX:+PrintGCDetails:打印垃圾回收的详细过程;

-XX:+PrintCommandLineFlags:启动时打印最终生效的所有 JVM 参数(包括手动指定的参数、JVM 根据系统环境自动推导的默认参数)。该参数属于 HotSpot 的非稳定参数(以-XX开头),不同版本间可能存在兼容性差异,但核心功能一致;

-Xmx20m:指定最大堆总空间为 20MB。

Runtime.getRuntime() 的三个内存方法,与堆内存的对应关系需明确:

maxMemory():对应堆的最大可用空间,并非直接等于-Xmx,实际值为-Xmx设定值 - 一个Survivor区的大小;

totalMemory():对应当前堆的总空间(新生代当前大小 + 老年代当前大小),初始值为-Xms,后续会按需扩展至-Xmx;

freeMemory():对应当前堆总空间中未被对象占用的空闲空间,即 totalMemory() - 已使用堆空间。

这里的最大可用内存就是指 -Xmx 的取值,当前总内存应该不小于 -Xms 的设定,因为当前总内存总是在 -Xms 和 -Xmx 之间,从 -Xms 开始根据需要向上增长。而当前空闲内存应该是当前总内存减去当前已经使用的空间。

-Xmx(对应 -XX:MaxHeapSize)指定的是JVM堆的总物理空间上限,但 Runtime.getRuntime().maxMemory() 返回的堆可用分配空间会小于该值,其原因是HotSpot虚拟机的堆遵循分代划分规则:堆被分为新生代(含Eden区与两个Survivor区)和老年代,新生代采用复制回收算法,需预留一个Survivor区作为存活对象的临时存储载体,该区域属于堆物理空间但无法用于分配对象,因此可用分配空间为堆总空间减去一个Survivor区的大小。

而生产环境中将初始堆 -Xms 与最大堆 -Xmx 设为相等的操作具备合理性:若 -Xms 小于 -Xmx,堆空间不足时会先触发新生代GC回收垃圾,仍不足则动态扩展堆,此过程会产生额外性能开销,二者设为相等可让JVM启动时直接分配最大堆空间,避免堆动态扩展及伴随的不必要GC,从而提升程序运行的稳定性与性能。

新生代区域容量的配置

新生代大小配置:-Xmn

参数 -Xmn 用于固定新生代的总容量。设置较大的新生代会直接减小老年代的容量,这一参数对系统性能与 GC 行为有显著影响。

经验规则:新生代大小通常建议设置为整个堆空间的 1/3 ~ 1/4,以平衡新生代 GC 频率与老年代内存压力。

新生代分区比例:-XX:SurvivorRatio

该参数定义了新生代中 Eden 区与单个 Survivor 区(From 或 To)的容量比例。

公式为:-XX:SurvivorRatio = Eden区容量 / From Survivor区容量 = Eden区容量 / To Survivor区容量

默认值为 8,此时新生代按 Eden:From:To = 8:1:1 划分,Eden 区占新生代总容量的 8/10,From Survivor 区与 To Survivor 区各占 1/10

实验验证:堆分配参数对 GC 的影响

我们通过一段连续申请 10MB 内存(每次 1MB)的 Java 程序,验证不同堆参数组合下的内存分配与 GC 行为:

java 复制代码
public class NewSizeDemo {
    public static void main(String[] args) throws IOException {
        byte[] b = null;
        for (int i = 0; i < 10; i++) {
            b = new byte[1 * 1024 * 1024];
        }
    }
}

使用 -Xmx20m -Xms20m -Xmn2m -XX:SurvivorRatio=2 -XX:+PrintGCDetails 运行,执行结果如下:

-Xmx20m -Xms20m 设定堆初始 / 最大值为 20MB,-Xmn2m设定新生代总大小 2MB,-XX:SurvivorRatio=2拆分出 Eden 区 1024KB、From/To Survivor 区各 512KB,老年代则为 18MB;-XX:+PrintGCDetails打印 GC 详情。

整个内存分配与 GC 过程如下:

  • 第一次 1MB 数组分配与 Minor GC 触发:
    JVM 分配对象时遵循 "优先新生代分配" 原则,因此首次创建 1MB byte 数组时,会先尝试在 Eden 区(1024KB)分配。理论上 1MB 数组与 Eden 区容量相当,但 JVM 分配时需预留少量内存用于对象头、内存管理等基础开销,实际尝试分配时触发Allocation Failure(分配失败),进而触发 Minor GC(新生代 GC)。此次 GC 中,新生代仅存在程序运行必需的基础小对象(如类加载对象、主线程对象等,无可回收垃圾),因此 GC 后 Eden 区仍无足够空间容纳 1MB 数组;JVM 随即触发 "大对象直接进入老年代" 规则(该数组大小 1024KB 超过 Survivor 区 512KB 的阈值),将该数组直接分配至老年代。
  • 后续 9 次 1MB 数组的分配:
    后续每次创建 1MB byte 数组时,JVM 会先检测对象大小 ------ 因 1MB 远超 Survivor 区阈值,直接命中 "大对象优先进入老年代" 规则,跳过新生代分配流程,无需尝试 Eden 区分配,因此未触发任何 GC。每次分配后,旧的 byte 数组因引用被覆盖成为垃圾,但程序运行过程中未触发 Full GC(老年代 GC),这些垃圾暂未被回收。
  • 最终堆内存状态:
    全程仅首次分配触发 1 次 Minor GC,最终堆状态显示:
    • 老年代占用 10368KB(约 10MB),包含最后 1 个存活的 1MB byte 数组,以及 9 个未被 Full GC 回收的 1MB 垃圾对象;
    • 新生代 Eden 区使用 69%,该占用来自程序运行必需的基础小对象,这些小对象在 Minor GC 中存活;
    • From Survivor 区占用 98%,是 Minor GC 时 Eden 区的存活小对象被复制至此导致;To Survivor 区作为备用区,保持 0% 占用;
    • 元空间占用 3225KB(类元数据、方法信息等基础开销),无异常。

关键问题:为什么 total=eden+from,而不是 total=eden+from+to

JVM 日志中 PSYoungGen 的 total 显示为 1536KB,且刚好等于 Eden 区与 From Survivor 区的大小之和,这一现象的核心原因的是 JVM 对新生代活跃内存的统计规则。

这一统计规则的本质是适配 Minor GC 的 "复制算法" 机制。Survivor 区设计为 From 和 To 两个分区,核心作用是实现存活对象的转移与暂存:日常对象分配仅在 Eden 区和当前活跃的 From Survivor 区进行,To Survivor 区则始终作为 "备用空闲区",不参与常规分配流程。正因为 To 区在 Minor GC 触发前处于空闲状态,仅用于承接 GC 后 Eden 区和 From 区的存活对象,JVM 在统计 PSYoungGen 的 total 时,仅会计入当前可用于分配的活跃区域(Eden+From),而将备用的 To 区排除在 total 统计之外,这并不意味着 To 区不属于新生代内存,它仍是新生代总大小 2048KB 的重要组成部分,只是其备用属性决定了日常不纳入活跃内存统计。

当后续触发 Minor GC 时,Eden 区和 From 区的存活对象会被完整复制到 To 区,随后 From 和 To 区会发生角色互换:原来的 To 区变为新的活跃 From 区,纳入下一次 total 统计;原来的 From 区则变为新的备用 To 区,不再计入统计。但无论角色如何轮换,PSYoungGen total的数值始终保持为 Eden 区与单个 Survivor 区的大小之和(1536KB),这一统计方式既贴合 Survivor 区的双区轮换机制,也能准确反映新生代当前可用于对象分配的活跃内存总量。

使用 -Xmx20m -Xms20m -Xmn8m -XX:SurvivorRatio=2 -XX:+PrintGCDetails 运行

堆总大小为 20MB,其中新生代因 -Xmn8m 设定为 8MB,老年代则为 12MB,结合 SurvivorRatio=2 的规则,新生代进一步拆分为 Eden 区 4MB、From/To Survivor 区各 2MB。JVM 统计 PSYoungGen total 时,仍遵循新生代活跃内存的统计规则,计入 Eden 与当前活跃的 From 区之和。

整个内存分配与 GC 过程如下:

每次创建的 1MB 数组都会被优先分配到 Eden 区(4MB),Eden 区可容纳 4 个 1MB 数组,第 4 次创建 1MB 数组时JVM 尝试为该数组分配 1MB 空间,此时分配后 Eden 区将被完全占满触发Allocation Failure(分配失败),JVM 的逻辑是 "尝试分配前检测到 Eden 区无足够剩余空间",因此先触发 Minor GC 回收新生代中已失去引用的旧 byte 数组(前 3 次创建的数组因引用被覆盖已成垃圾),腾出 Eden 区空间后,再完成第 4 个数组的分配。

第 4 次分配触发的 Minor GC 回收了前 3 个垃圾数组,Eden 区重新腾出空间,但后续继续创建第 5-10 个 1MB 数组时,Eden 区会再次被逐步占满:每一次分配新数组前,Eden 区剩余空间都不足以容纳 1MB,因此 JVM 会反复触发 Minor GC(共三次),每次 GC 都回收此前失去引用的旧数组,确保新数组能成功分配到 Eden 区。这也是日志中出现三次 [GC (Allocation Failure)] 的核心原因 ------ 本质是 Eden 区在循环分配中反复被占满,每次分配前都需通过 Minor GC 回收垃圾。

最终堆内存状态解读

from/to space 2048K:三次 Minor GC 过程中,Eden 区的存活小对象(包括 JVM 加载的类对象、main 线程的 Thread 对象、栈帧关联的局部变量表 / 常量池小对象、JVM 内存管理辅助对象等)被复制到当前活跃的 From 区;To 区作为 GC 备用区,全程未承接存活对象,因此保持 0% 占用。

使用 -Xmx30m -Xms30m -Xmn20m -XX:SurvivorRatio=8 -XX:+PrintGCDetails 运行

堆总大小 30MB 拆分为新生代 20MB、老年代 10MB,新生代内部则按比例拆分为 Eden 区 16MB、From/To Survivor 区各 2MB,而 JVM 统计 PSYoungGen total 时仍遵循新生代活跃内存的统计规则,计入 Eden 与当前活跃的 From 区之和。

整个内存分配与 GC 过程如下:

循环 10 次创建 1MB 的 byte 数组,总大小仅 10MB,而 Eden 区容量达 16MB,完全能够容纳所有创建的 byte 数组,因此全程未触发任何 Minor GC。所有对象(包括循环创建的 byte 数组、程序运行必需的基础小对象)都直接分配在 Eden 区,而 From/To Survivor 区因未触发 GC,无需承接存活对象,全程保持 0% 占用,处于闲置状态,老年代全程无任何对象分配,仅保留初始化后的空内存状态。

老年代与新生代的容量比例

-XX:NewRatio = 老年代容量 / 新生代容量

该参数用于设置老年代与新生代的容量比例,是 -Xmn 之外更灵活的比例化配置方式

默认值:多数 JVM 版本默认值为 2,即 老年代容量 : 新生代容量 = 2:1;

计算逻辑:若堆总容量为 X,则 新生代容量 = X / (NewRatio + 1),老年代容量 = X * NewRatio / (NewRatio + 1);

优先级:-Xmn(绝对大小)的优先级高于 -XX:NewRatio(比例),若同时配置两个参数,-Xmn 会覆盖 NewRatio 的计算结果。

使用 -Xmx20m -Xms20m -XX:NewRatio=2 -XX:+PrintGCDetails 运行

堆总大小 20MB,根据 NewRatio=2 的核心规则,理论上新生代大小 = 堆总大小 /(NewRatio+1)=20MB/3≈6.67MB,老年代 = 20MB×2/3≈13.33MB;JVM 会按内存页对齐调整,最终新生代实际约 6MB(6144KB),老年代约 13.5MB(13824KB)。

新生代内部未显式指定SurvivorRatio,采用默认值 8,因此拆分为:Eden 区≈5.5MB(5632KB)、From/To Survivor 区各 512KB,而 PSYoungGen total 6144K 仍遵循新生代活跃内存的统计规则,计入 Eden 与当前活跃的 From 区之和。

整个内存分配与 GC 过程如下:

代码循环 10 次创建 1MB 的 byte 数组,JVM 判定 "大对象" 的核心阈值为 Survivor 区大小,1MB 数组远超该阈值,本应直接进入老年代,但实际触发两次 Minor GC,核心原因是:

JVM 分配对象遵循 "优先新生代尝试" 原则,首次创建 1MB 数组时,会先尝试在 Eden 区(5632KB)分配,Eden 区可容纳 5 个 1MB 数组,创建第 6 个时 Eden 区空间占满,触发 Allocation Failure(分配失败),进而触发第一次 Minor GC;此次 GC 回收了新生代中失去引用的旧 byte 数组(因引用覆盖成为垃圾),但新创建的 1MB 数组因 "大对象直接进入老年代" 规则,直接分配到老年代;后续循环中 Eden 区再次被占满,触发第二次 Minor GC,两次 GC 均仅回收新生代垃圾,未触发 Full GC,新创建的当前这个 1MB 数组,都会因 "大对象规则" 直接进入老年代。需要注意的是 Minor GC 后这些存活小对象被复制到 From Survivor 区,若小对象总量超出 Survivor 区容量,超出部分才会触发老年代空间担保、晋升至老年代。

堆溢出参数

堆内存溢出的定义与影响

Java 程序运行时,若堆空间无法满足新对象的内存分配需求(且 GC 无法释放足够空间),会抛出 java.lang.OutOfMemoryError: Java heap space 错误(简称堆 OOM),这是最常见的内存溢出类型之一。

典型错误日志:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at geym.zbase.ch3.heap.DumpOOM.main(DumpOOM.java:20)

堆 OOM 会直接导致当前线程终止,若发生在主线程或核心业务线程中,程序会被迫退出;在生产环境中,可能引发服务不可用、业务中断、数据丢失等严重问题。因此,捕获 OOM 现场信息是排查根因(如内存泄漏、堆配置不足、大对象过度创建等)的关键。JVM 提供了专门的参数,用于在堆 OOM 时自动导出堆快照(Heap Dump,即堆内存的完整快照),为问题排查提供核心依据。

  • 基础参数:-XX:+HeapDumpOnOutOfMemoryError
    作用:启用「堆 OOM 时自动生成堆转储文件」功能,该参数默认关闭(需显式启用)。
    行为:当触发 OutOfMemoryError: Java heap space 时,JVM 会在指定路径生成二进制堆转储文件(.hprof 格式),包含溢出时刻堆中所有对象的类型、引用关系、内存占用等关键信息。
    注意:该参数仅对堆 OOM 生效,对其他类型 OOM(如元空间 OOM、栈溢出)不触发堆转储。
  • 路径参数:-XX:HeapDumpPath
    作用:配合 HeapDumpOnOutOfMemoryError 使用,指定堆转储文件的存放路径(支持绝对路径 / 相对路径)。
    默认值:未指定时,堆转储文件默认生成在 JVM 启动目录下,文件名格式为 java_pid<进程ID>.hprof。
    使用规则:需确保目录已存在且 JVM 进程有写入权限;可自定义文件名,需以 .hprof 为后缀(便于分析工具识别)。

实际场景:Nacos 中的配置示例

Nacos 作为主流的 Java 中间件,其启动脚本(bin/startup.sh)中默认配置了堆 OOM 诊断参数,配置示例如下:

java 复制代码
import java.util.Vector;

public class DumpOOM {
    public static void main(String[] args) {
        Vector v=new Vector () ;
        for (int i=0; i<25; i++)
            v.add(new byte[1*1024*1024]) ;
    }
}

-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:/a.dump

虚拟机将当前的堆导出,并保存到 D:/a.dump 文件下,使用MAT等工具打开该文件进行分析,如图所示,可以很容易地找到这些 byte 数组和保存它们的 Vector 对象实例。

相关推荐
Larry_Yanan2 小时前
Qt多进程(十)匿名管道Pipe
开发语言·qt
callJJ2 小时前
WebSocket 两种实现方式对比与入门
java·python·websocket·网络协议·stomp
一条咸鱼_SaltyFish2 小时前
Spring Cloud Gateway鉴权空指针惊魂:HandlerMethod为null的深度排查
java·开发语言·人工智能·微服务·云原生·架构
i***13242 小时前
SpringCloud实战十三:Gateway之 Spring Cloud Gateway 动态路由
java·spring cloud·gateway
计算机徐师兄2 小时前
Java基于微信小程序的食堂线上预约点餐系统【附源码、文档说明】
java·微信小程序·食堂线上预约点餐系统小程序·食堂线上预约点餐微信小程序·java食堂线上预约点餐小程序·食堂线上预约点餐小程序·食堂线上预约点餐系统微信小程序
无心水3 小时前
【分布式利器:腾讯TSF】10、TSF故障排查与架构评审实战:Java架构师从救火到防火的生产哲学
java·人工智能·分布式·架构·限流·分布式利器·腾讯tsf
Boilermaker199210 小时前
[Java 并发编程] Synchronized 锁升级
java·开发语言
Cherry的跨界思维10 小时前
28、AI测试环境搭建与全栈工具实战:从本地到云平台的完整指南
java·人工智能·vue3·ai测试·ai全栈·测试全栈·ai测试全栈
MM_MS10 小时前
Halcon变量控制类型、数据类型转换、字符串格式化、元组操作
开发语言·人工智能·深度学习·算法·目标检测·计算机视觉·视觉检测