☰
JVM内存模型、JRE关系与HMCL参数设置:从原理到实战排查
2026/10/9 11:29:37 网站建设 项目流程

搞 JVM 的人不一定都是架构师,但凡是能把 Java 跑到生产环境不出事的,JVM 这关一定得过。最近我看后台的搜索记录,"jvm内存模型""hmcl如何设置jvm参数""jre和jvm之间的关系"这几个热词一直挂在前面,说明大家的需求特别实在:一部分人想搞懂原理,另一部分人是真遇到了问题——比如 HMCL 启动 Minecraft 时内存拉满、频繁崩溃。这篇文章就顺着这条线,把 JVM 从概念到实操完整捋一遍:它到底是什么、内存模型怎么运转、和 JRE、JDK 怎么相处,再拿 HMCL 这个游戏启动器的真实场景,讲讲 JVM 参数怎么设、设错了怎么排查。适合刚学 Java 的学生、写了两三年代码但没系统啃过 JVM 的开发者,以及玩 Minecraft 模组时被内存报错折腾到崩溃的玩家。

1. 先理清关系:JVM、JRE、JDK 那个经典的三层结构

1.1 三个缩写,三层职责,别再答错面试题

很多人在简历上写"熟悉 Java",结果一问 JVM 和 JRE 的区别就卡壳。这三个词的逻辑其实特别简单:JVM 是 Java Virtual Machine,Java 虚拟机,它是真正执行字节码的那台"机器";JRE 是 Java Runtime Environment,Java 运行环境,它等于 JVM 加上 Java 类库(也就是我们常说的 rt.jar、jre/lib 下那一堆东西);JDK 是 Java Development Kit,Java 开发工具包,它等于 JRE 再加上编译器 javac、调试工具 jdb、打包工具 jar 这些开发工具。

用生活化一点的例子理解:JVM 像一台发动机,负责把油气混合气烧成动力;JRE 像一台装好发动机和油路的整车,你坐上去就能开;JDK 像一座汽车工厂,除了给你整车,还给你扳手、螺丝刀和整车图纸。开发时你需要 JDK,因为要编译;部署时只需要 JRE,因为只要跑;而真正干活的是最里面的 JVM。

这里有个细节很多人忽略:从 JDK 9 开始,官方不再单独提供 JRE 的安装包了,而是把运行时集成进 JDK,或者用jlink工具自己裁剪出一个精简运行时。所以现在你去看 Java 17、Java 21 的下载页面,很难找到传统意义上的"JRE"。这个概念在面试时答出来,基本能说明你关注过版本的演进。

1.2 "一次编写,到处运行"到底是谁的功劳

Java 刚出来时宣传的是"Write Once, Run Anywhere",这句话听着神乎其神,但仔细想就会发现问题:不同操作系统对程序的调用方式完全不一样,Windows 上能跑的 exe,Linux 上就是不行。Java 的解决办法不是让编译器直接生成机器码,而是编译成一种中间格式——字节码,保存在 .class 文件里。字节码不针对任何具体 CPU 和操作系统,它只认 JVM。

所以真正实现跨平台的不是 Java 语言,而是各平台上的 JVM。你在 Windows 装了 Windows 版 JVM,在 Linux 装了 Linux 版 JVM,这两台 JVM 长得不一样,但它们都能理解同一份 .class 字节码。打个比方,字节码就像国际音标,JVM 就像各地的口译员,无论你到了哪个国家,只要这个国家有对应的口译员,你说的话人家就能听懂。

JVM 本身也有不同的实现,除了我们最常用的 HotSpot(Oracle/OpenJDK 的默认实现),还有 Eclipse OpenJ9、GraalVM 等等。平时大家说的"JVM 参数"、"JVM 内存模型",默认指的都是 HotSpot,因为绝大多数生产环境和开发环境跑的它。后面的内容如果没有特别说明,也都以 HotSpot 为准。

2. JVM 内存模型:一次把运行时数据区讲透

2.1 五个区域,两类共享,一张表说清

JVM 在执行 Java 程序时,会把自己的内存划分为几个区域,官方把这套结构叫"运行时数据区"。网上讲 JVM 内存模型的图一大堆,但很多图要么过时(还画着 PermGen),要么太简略,我建议你直接记下面这张表:

区域存什么线程私有还是共享抛什么错误
程序计数器当前线程执行到的字节码行号私有无
Java 虚拟机栈每个方法调用的栈帧(局部变量、操作数栈等)私有StackOverflowError、OutOfMemoryError
本地方法栈执行 native 方法时的栈私有StackOverflowError、OutOfMemoryError
堆几乎所有对象实例和数组共享OutOfMemoryError: Java heap space
方法区类信息、常量、静态变量、JIT 编译后的代码共享OutOfMemoryError: Metaspace

程序计数器是最不起眼但最不能少的一个区域。CPU 执行线程时需要知道下一行指令在哪,Java 的线程切换后也得记住之前执行到哪儿了,所以每个线程都有一个独立的程序计数器。它唯一的"特殊情况"是执行 native 方法时,计数器值是空(undefined),因为这时候执行的不再是 Java 代码。

Java 虚拟机栈是面试重灾区。每个线程启动时 JVM 会给它分配一块栈空间,每调用一个方法,就往栈里压入一个栈帧,方法返回就弹出栈帧。栈帧里存着局部变量表、操作数栈、动态链接和方法返回地址。局部变量表很有意思,它的槽位可以复用,所以局部变量的生命周期可能比你想的更长,这也是某些内存泄漏的隐藏来源。递归调用太深会栈溢出,就是因为你不停地压栈,把栈空间压爆了。

2.2 堆内存的内部结构:新生代和年老代的故事

堆是 JVM 管理的最大一块内存,也是垃圾回收的主战场。HotSpot 把堆分成了新生代和老年代,新生代里又细分为 Eden 区和两个 Survivor 区(S0、S1)。为什么这么分?核心思路是"分代假设":大部分对象活不长,用完就死。把短命对象集中放一起、长命对象放一起,垃圾回收器可以区别对待,避免每次回收都扫全堆。

一个对象的一生大概是这样的:

  1. 对象 new 出来之后,先放进 Eden 区。Eden 区一般占新生代的大部分空间。
  2. Eden 区快满的时候触发 Minor GC,把还活着的对象复制到 Survivor 区。每扛过一次回收,对象的年龄加 1。
  3. 对象在 S0、S1 之间来回复制,年龄涨到阈值(默认 15)之后,就被晋升到老年代。
  4. 老年代慢慢堆积,空间不足时触发 Major GC / Full GC。Full GC 通常是全局性的回收,停顿时间长,也是我们排查性能问题时最头疼的东西。

这个模型对应到 GC 算法就是"标记-复制"(新生代)和"标记-整理/标记-清除"(老年代)。为什么新生代用复制?因为大部分对象会死,复制少量幸存者成本低,还能顺便解决内存碎片问题。老年代对象存活率高,复制不划算,所以用标记-清除或标记-整理。

2.3 栈和堆怎么协作:从一个方法调用说起

很多人理解不了栈和堆的关系,我用一段最简单的代码说明:

public void foo() { User user = new User("张三"); bar(user); } public void bar(User u) { String name = u.getName(); }

当线程执行到foo()时,JVM 在栈上压入一个栈帧,栈帧里的局部变量表有user这个引用变量。user本身的值不是一个 User 对象,而是一个指针(或者说句柄),指向堆里那个真正的 User 对象。然后调用bar(),又压入一个新栈帧,u这个引用也指向同一个 User 对象。所以栈上放引用,堆里放对象本体,就这么简单。

这带来一个常见的误解:String name = u.getName()里的name是栈上的引用,还是堆上的对象?如果字符串是 new 出来的,它在堆里;如果是字面量,它在字符串常量池里。JDK 7 以后字符串常量池从方法区移到了堆里,这也是 JVM 内存模型在版本变化中容易被搞混的一个点。

2.4 JDK 8 的重要变化:PermGen 换成了 Metaspace

老读者应该记得,JDK 8 之前方法区由"永久代"(PermGen)实现,还在堆里占了一块固定大小的空间。JDK 8 把永久代整个移除,方法区改用"元空间"(Metaspace),它不再位于堆里,而是使用本地内存。为什么要改?最典型的原因是永久代大小很难定:定小了,加载大量类时会 OutOfMemoryError: PermGen space;定大了,白白浪费堆空间。改成元空间后,默认情况下类的元数据可以使用无限量的本地内存,理论上只受操作系统可用内存限制。

这个改动对实践的影响非常大。以前你发布一个大型 Spring Boot 应用,动不动就报 PermGen OOM,得调-XX:MaxPermSize;现在我们需要关注的是-XX:MaxMetaspaceSize。如果部署环境内存紧张,或者你要限制某个进程的内存占用,一定要配置这个参数,否则元空间可能把机器内存吃光。

3. 类加载机制:JVM 怎么把 .class "读"进去

3.1 五个阶段,重点不是背名字,而是理解顺序

每次有人让我讲类加载机制,我都说别把五个阶段背得滚瓜烂熟就完事,你得知道它为什么要这么设计。类从 .class 文件变成可用的 Class 对象,要经历加载、验证、准备、解析、初始化五个阶段。

加载阶段,JVM 通过类加载器把 .class 文件的二进制流读进来,在堆里生成一个 java.lang.Class 对象。验证阶段是安全检查,防止字节码里的非法操作破坏 JVM 本身,比如检查访问控制、代码跳转指令是否合法。准备阶段有点误导,它只是为类变量(static 变量)分配内存并设置默认值,注意,这时候还没有执行 Java 代码里写的赋值语句,所以public static int x = 100在准备阶段结束时,x 的值是 0,而不是 100。解析阶段把符号引用替换为直接引用,比如把方法名、字段名变成具体的内存地址或句柄。初始化阶段才是真正执行静态变量的赋值语句和静态代码块。

我见过很多排障新手,以为静态变量一定是从一开始就有正确值,结果在初始化顺序上栽了跟头。举个例子,你在一个类里写static A a = new B();,如果 B 类的静态代码块又反过来访问 A 的静态字段,就可能出现"先看到 null 再看到值"的诡异现象。理解了准备和初始化的分离,这类问题一下就清楚了。

3.2 双亲委派机制:为什么你的类不会乱掉

类加载器是 JVM 里最容易被低估的设计。HotSpot 默认有三个层次:启动类加载器(Bootstrap,加载 JDK 自身核心类)、平台类加载器(Platform,JDK 9 之前叫扩展类加载器 Extension,加载 Java SE 的扩展库)、应用类加载器(Application,加载 classpath 上的类)。

"双亲委派"说的是:当一个类加载器需要加载某个类时,它不会自己先动手,而是先把请求委派给父加载器,父加载器再委派给更上一级,直到顶层。只有父加载器找不到这个类时,子加载器才会尝试自己加载。理解这个机制的关键在于它保证了两件事:一是类的唯一性,同一个类不会被不同加载器重复加载成不同的 Class 对象;二是安全,你写一个java.lang.String想冒充 JDK 的类,启动类加载器会先加载到真正的 String,你的版本根本没机会上场。

在大型应用里你还会见到自定义类加载器,比如 Tomcat 为每个 Web 应用创建独立的类加载器,就是为了实现应用间类库隔离和热部署。这块的原理深究起来能写一本书,但面试和日常够用的话,把"委派给父级"这个动作画成一条链背下来,再理解"为什么需要唯一性和安全性"就够了。

4. 实操:HMCL 里设置 JVM 参数到底在调什么

4.1 为什么"HMCL 如何设置 JVM 参数"能成为热搜

这是很有意思的一个现象:HMCL 是一款开源的 Minecraft 启动器,本质就是一个 Java GUI 程序,但它给无数玩家暴露了 JVM 参数配置的入口。很多玩家不知道 JVM 是什么,只知道游戏崩溃时网上说"把内存调到 4096"或者"加个 GC 参数就不卡了"。于是 "HMCL 设置 JVM 参数" 成了 JVM 话题里搜索量极大的真实需求。

Minecraft 是 Java 写的,游戏本体和大量模组跑在 JVM 上。原版游戏对内存要求不算高,但装了大型整合包之后,几百上千个模组一起加载,类加载和对象创建的压力剧增,内存动不动就飙到几个 G。HMCL 启动时会把玩家配置的 JVM 参数原样传给 Java 进程,所以你在启动器里填的参数,本质上和你在命令行里写java -Xmx4G -jar minecraft.jar没区别。

4.2 核心参数逐条拆解:别再只改 -Xmx

玩家圈的教程大多只说"改 -Xmx",但真正的 JVM 调优要关注的不止这一个。我按日常使用频率,把参数分成几类列出来:

参数作用典型值
-Xms堆内存初始大小-Xms2G
-Xmx堆内存最大大小-Xmx4G或-Xmx6G
-Xss每个线程栈大小默认 1M,一般不用动
-XX:MaxMetaspaceSize元空间上限-XX:MaxMetaspaceSize=512M
-XX:+UseG1GC启用 G1 垃圾回收器JDK 9+ 默认生效
-XX:MaxGCPauseMillis=200设定 GC 停顿目标适合对卡顿敏感的场景
-Dfile.encoding=UTF-8设置文件编码解决中文乱码

先说-Xms和-Xmx。很多人只知道 -Xmx 是最大堆,不知道 -Xms 是初始堆。如果初始堆设得很小,而 -Xmx 设得很大,JVM 会在运行中不断扩容堆,扩容是有开销的,体现在现象上就是"启动后一段时间偶发卡顿"。所以对服务端应用,我通常建议 -Xms 和 -Xmx 设成一样的值,避免动态伸缩。对游戏客户端来说,-Xms 可以设低一点,毕竟本机内存还要给系统和浏览器留余量,但至少不要低于 -Xmx 的一半。

再说 G1。JDK 8 更新到 292 之后、以及 JDK 9 及以上版本,G1 已经是默认垃圾回收器,所以-XX:+UseG1GC在较新版本里写不写都一样。但有经验的玩家还是会在 HMCL 里显式写出来,目的其实是让其他参数(比如-XX:MaxGCPauseMillis)表现得更有确定性。G1 的优势是能控制停顿时间,它把堆划分为很多小 Region,后台尽量增量回收,而不是动不动 Full GC。这个特性对游戏特别友好——连续 500ms 的暂停在多人生存战里就是致命的。

还有一个容易踩坑的参数是-XX:MaxGCPauseMillis。它的语义是"尽力让 GC 停顿时间不超过这个目标",但 G1 并不能严格保证。如果你的堆内存设得太大,比如给 JVM 分配了 12G 堆,同时又把停顿目标设成 50ms,G1 可能会为了达成分区回收而频繁进行后台回收,CPU 占用反而上升。所以"内存越大越流畅"在 JVM 场景里是不成立的,堆内存过大意味着 GC 要管理的对象更多,停顿风险更大。

4.3 HMCL 实操路径与参数验证

在 HMCL 中设置 JVM 参数的入口很简单:启动器主界面左下角进入"设置"(或者叫"游戏设置"),选择具体的游戏版本,展开"高级设置"或"JVM 参数"输入框,把参数粘贴进去即可。不同版本的 HMCL 界面略有差异,但核心逻辑一致:它会为每个游戏版本单独保存一份 JVM 配置,没设置过的版本走全局默认值。

我这里给一个适合 8G 内存电脑跑中型整合包的参数模板:

-Xms2G -Xmx4G -XX:MaxMetaspaceSize=512M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Dfile.encoding=UTF-8 -Djava.rmi.server.hostname=127.0.0.1

-Xmx 建议设置为物理内存的 1/4 到 1/2。8G 内存的机器,开 4G 堆给 MC 是极限了,因为操作系统、浏览器、HMCL 自己都要吃内存。如果整机只有 8G 还开了一堆后台程序,4G 堆容易触发操作系统的内存换页,表现为"内存看着没满,但游戏一卡一卡"。16G 内存的机器可以开到 6G,超过 6G 对多数整合包来说没有明显收益,反而增加 GC 压力。

参数设置好之后,怎么确认它真的生效了?最直接的方法是在 HMCL 启动时的输出日志里看 JVM 启动参数,或者在游戏内按 F3 打开调试界面,右侧能看到实际的内存分配。更专业的办法是用 JDK 自带的工具:先找到 Java 进程的 PID,用jps -l就能列出来。然后执行:

jinfo -flag MaxHeapSize <pid> jinfo -flag MaxMetaspaceSize <pid>

返回结果就是你当前进程真正的配置值。如果你在 HMCL 里写了 4096m,但 jinfo 显示 268435456(256M),说明单位写错了或者参数没被解析。这里提醒一句:-Xmx的值可以写4G、4g、4096m、4096M,JVM 都认,但别写成4096这种不带单位的写法,那会被当成字节数,你会得到一个小得离谱的堆。

5. 常见问题与排查实录

5.1 OutOfMemoryError 全家桶速查

JVM 运行时最常见的报错就是各种 OOM,但 OOM 和 OOM 不一样,排查方向完全不同。我把高频的几种整理成速查表:

报错信息含义排查方向
OutOfMemoryError: Java heap space堆内存不足用 jmap 看堆使用情况,检查是否有泄漏或堆太小
OutOfMemoryError: Metaspace元空间不足检查是否加载了过多类,调大 MaxMetaspaceSize
OutOfMemoryError: unable to create new native thread线程数达到系统上限检查是否创建了海量线程,调整系统 ulimit
StackOverflowError栈溢出,通常是无限递归查递归调用、深层次框架调用是否异常

堆内存 OOM 是出现率最高的。面对堆 OOM,第一反应该是"先确认是泄漏还是容量不足"。区别方法很简单:重启应用后观察内存走势。如果刚启动很正常,运行一两天后内存缓缓爬到顶然后 OOM,大概率是泄漏;如果一启动就满,那是初始容量本身就配小了。

5.2 实战排查:jmap + jstat 双剑合璧

排查堆 OOM,我建议按这个顺序操作:

  1. 先用jps找到 Java 进程 PID。
  2. 用jstat -gc <pid> 1000持续观察各代内存使用和 GC 频率。如果 Full GC 频繁发生,说明老年代空间紧张。
  3. 用jmap -heap <pid>查看当前堆配置和各区域使用率。注意,高版本 JDK 里 jmap 部分功能可能需要加上权限,或者改用jcmd。
  4. 怀疑泄漏时,导出堆快照:
jmap -dump:format=b,file=/tmp/heap.hprof <pid>

然后用 VisualVM、MAT 这类工具分析。MAT 打开快照后,第一眼看 Leak Suspects 报告,它会直接告诉你哪些实例占据了大部分堆,以及持有一致性引用的根路径。我印象很深的一个案例:一个服务每次请求都会往一个静态 Map 里塞数据,永远不清理,堆快照一打开就能看到那个 Map 里有几十万条记录,全是泄漏现场,一目了然。

排查时还有一个反直觉的经验:不要一上来就给应用加内存。如果你把一个本来泄漏的程序从 2G 调到 8G,它只是把崩溃时间从"每天一次"延长到"三天一次",代价却是同一台服务器能跑的实例数大大减少。正确做法是先用工具确认到底是谁占着内存,再决定加内存还是修代码。

5.3 新手最容易犯的五个 JVM 配置错误

第一个错误是在 HMCL 里把 -Xmx 调到接近总内存。有人 8G 内存的电脑直接给 MC 分 7G,然后系统卡成幻灯片,还怪 JVM 垃圾。几乎所有组件都在和操作系统抢物理内存,堆申请的内存只有真正用到时才实际占用物理页,但用了之后 GC 释放也是滞后归还的,所以给 JVM 的内存要和操作系统之间留出足够缓冲。

第二个错误是只改 -Xmx 不改 -Xms。初始堆和最大堆差距过大,会带来频繁扩容的隐形开销,服务端建议两者相同,客户端建议差值不要超过一倍。

第三个错误是照抄网上的"神级参数"。网上流传一些 G1 调优大礼包,什么-XX:NewRatio=1 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15全往上贴。参数本身无害,但这话要两说:对绝大多数应用,JVM 的默认参数是经过大量工程测试的,你乱改之后可能更糟。调参的正确姿势是"改一个参数、压测观察、记录结果、再改下一个",一次改十个你根本不知道是哪一行救了你的命。

第四个错误是把 JVM 参数写在错误的位置。HMCL 里有些版本把参数输入框放在"全局设置",有些在"版本设置"里,填错地方的结果是参数看似配了,实际启动命令里根本没有。

第五个错误是忽略 32 位和 64 位的问题。32 位 JVM 的最大堆只有 4G(实际可用更低),你填 -Xmx8G 会直接启动失败。现在的 Minecraft 启动器通常自带 64 位 Java,但如果你自己下载 JRE 时手滑装了 32 位版本,就会遇到这种诡异问题。判断方法是启动时看 JVM 日志里有没有 64-Bit 字样。

我个人在实际维护中的体会是,JVM 调优这件事,很多时候是"少即是多"的:先把内存和 GC 参数设置合理,比堆砌几百行参数有用得多。我做 Java 服务器运维这几年,最值得的一笔投入就是把 JVM 的内存模型真正搞懂,而不是背参数。最后再分享一个小技巧:遇到 JVM 崩溃先别慌,找到 hs_err_pid 开头的日志文件,直接搜索# Problematic frame附近的内容,那里通常直接告诉你崩溃发生在哪个类哪个方法,比满屏搜报错经验帖快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询