
搞 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 MachineJava 虚拟机它是真正执行字节码的那台机器JRE 是 Java Runtime EnvironmentJava 运行环境它等于 JVM 加上 Java 类库也就是我们常说的 rt.jar、jre/lib 下那一堆东西JDK 是 Java Development KitJava 开发工具包它等于 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 上能跑的 exeLinux 上就是不行。Java 的解决办法不是让编译器直接生成机器码而是编译成一种中间格式——字节码保存在 .class 文件里。字节码不针对任何具体 CPU 和操作系统它只认 JVM。所以真正实现跨平台的不是 Java 语言而是各平台上的 JVM。你在 Windows 装了 Windows 版 JVM在 Linux 装了 Linux 版 JVM这两台 JVM 长得不一样但它们都能理解同一份 .class 字节码。打个比方字节码就像国际音标JVM 就像各地的口译员无论你到了哪个国家只要这个国家有对应的口译员你说的话人家就能听懂。JVM 本身也有不同的实现除了我们最常用的 HotSpotOracle/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。为什么这么分核心思路是分代假设大部分对象活不长用完就死。把短命对象集中放一起、长命对象放一起垃圾回收器可以区别对待避免每次回收都扫全堆。一个对象的一生大概是这样的对象 new 出来之后先放进 Eden 区。Eden 区一般占新生代的大部分空间。Eden 区快满的时候触发 Minor GC把还活着的对象复制到 Survivor 区。每扛过一次回收对象的年龄加 1。对象在 S0、S1 之间来回复制年龄涨到阈值默认 15之后就被晋升到老年代。老年代慢慢堆积空间不足时触发 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 自身核心类、平台类加载器PlatformJDK 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:MaxMetaspaceSize512M-XX:UseG1GC启用 G1 垃圾回收器JDK 9 默认生效-XX:MaxGCPauseMillis200设定 GC 停顿目标适合对卡顿敏感的场景-Dfile.encodingUTF-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 堆同时又把停顿目标设成 50msG1 可能会为了达成分区回收而频繁进行后台回收CPU 占用反而上升。所以内存越大越流畅在 JVM 场景里是不成立的堆内存过大意味着 GC 要管理的对象更多停顿风险更大。4.3 HMCL 实操路径与参数验证在 HMCL 中设置 JVM 参数的入口很简单启动器主界面左下角进入设置或者叫游戏设置选择具体的游戏版本展开高级设置或JVM 参数输入框把参数粘贴进去即可。不同版本的 HMCL 界面略有差异但核心逻辑一致它会为每个游戏版本单独保存一份 JVM 配置没设置过的版本走全局默认值。我这里给一个适合 8G 内存电脑跑中型整合包的参数模板-Xms2G -Xmx4G -XX:MaxMetaspaceSize512M -XX:UseG1GC -XX:MaxGCPauseMillis200 -Dfile.encodingUTF-8 -Djava.rmi.server.hostname127.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 显示 268435456256M说明单位写错了或者参数没被解析。这里提醒一句-Xmx的值可以写4G、4g、4096m、4096MJVM 都认但别写成4096这种不带单位的写法那会被当成字节数你会得到一个小得离谱的堆。5. 常见问题与排查实录5.1 OutOfMemoryError 全家桶速查JVM 运行时最常见的报错就是各种 OOM但 OOM 和 OOM 不一样排查方向完全不同。我把高频的几种整理成速查表报错信息含义排查方向OutOfMemoryError: Java heap space堆内存不足用 jmap 看堆使用情况检查是否有泄漏或堆太小OutOfMemoryError: Metaspace元空间不足检查是否加载了过多类调大 MaxMetaspaceSizeOutOfMemoryError: unable to create new native thread线程数达到系统上限检查是否创建了海量线程调整系统 ulimitStackOverflowError栈溢出通常是无限递归查递归调用、深层次框架调用是否异常堆内存 OOM 是出现率最高的。面对堆 OOM第一反应该是先确认是泄漏还是容量不足。区别方法很简单重启应用后观察内存走势。如果刚启动很正常运行一两天后内存缓缓爬到顶然后 OOM大概率是泄漏如果一启动就满那是初始容量本身就配小了。5.2 实战排查jmap jstat 双剑合璧排查堆 OOM我建议按这个顺序操作先用jps找到 Java 进程 PID。用jstat -gc pid 1000持续观察各代内存使用和 GC 频率。如果 Full GC 频繁发生说明老年代空间紧张。用jmap -heap pid查看当前堆配置和各区域使用率。注意高版本 JDK 里 jmap 部分功能可能需要加上权限或者改用jcmd。怀疑泄漏时导出堆快照jmap -dump:formatb,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:NewRatio1 -XX:SurvivorRatio8 -XX:MaxTenuringThreshold15全往上贴。参数本身无害但这话要两说对绝大多数应用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附近的内容那里通常直接告诉你崩溃发生在哪个类哪个方法比满屏搜报错经验帖快得多。