很多人把 JVM 五大核心模块当成一段面试题在背,但你要是在线上见过一次 Java 进程因为 Metaspace 不足反复 Full GC,或者在 Docker 容器里部署的 Java 程序莫名其妙重启却找不到堆栈,回来再看这五个模块,感觉会完全不一样。JVM 这五块——类加载子系统、运行时数据区、执行引擎、垃圾回收、本地方法接口——并不是孤立的概念,它们是一条完整的链路:类加载子系统把 .class 字节码装进运行时数据区,执行引擎逐条执行字节码指令,GC 负责回收执行过程中不再使用的对象,而本地方法接口负责让 Java 代码触达底层系统能力。这篇文章适合三类人:准备 JVM 面试的开发者、第一次调 JVM 参数的 Java 使用者,以及被线上内存/GC 问题逼到墙角的运维和研发。我会从整体链路讲起,同时把 CompileThreshold、元空间、G1、容器日志这些高频问题全部串进去。
1. 类加载子系统:JVM 打开 .class 文件的那条流水线
1.1 加载、链接、初始化到底干了什么
类加载子系统是整个 JVM 的入口,也是平常最容易被忽略的一环。很多人以为“类加载就是读一个 .class 文件”,其实这只是一个开头。严格来说,一个类从字节码变成可以创建对象的 Class 对象,要经过三个阶段:加载(Loading)、链接(Linking)、初始化(Initialization)。
加载阶段做三件事:通过类的全限定名获取二进制字节流,把字节流转换为运行时数据结构,在堆中生成对应的 java.lang.Class 对象。注意“获取二进制字节流”不一定是读磁盘文件,jar 包、网络流、运行时字节码生成(比如动态代理)都可以作为来源。我之前排查过一个诡异问题:同一个接口方法在某个环境里行为不一致,后来发现是 jar 包里混进了两个版本的类,加载阶段把错误版本读进来了。
链接阶段再细分为验证、准备、解析三个步骤。验证是为了防止恶意或损坏的字节码破坏 JVM 运行时,包括文件格式验证、字节码验证、符号引用验证。准备阶段为静态变量分配内存并设置零值,这里有个高频面试陷阱:static int a = 10 在准备阶段 a 的值是 0,真正赋值为 10 发生在初始化阶段。解析阶段把常量池中的符号引用替换为直接引用,这一步在 HotSpot 中可能被推迟到使用时才执行,属于“惰性解析”。
初始化阶段执行类构造器 <clinit> 方法,静态变量赋值和静态代码块都在这里。要记住触发初始化的时机:new 对象、访问静态字段、调用静态方法、反射操作、初始化子类前先初始化父类。如果没有这些触发条件,类可以被加载和链接,但不会初始化,这为后面的“懒加载”提供了基础。
1.2 双亲委派模型:为什么核心类不能被乱加载
类加载器在 JVM 里有明显的层级关系。Bootstrap ClassLoader 负责加载 JDK 自身目录下的核心类库;JDK8 里的扩展类加载器(Extension ClassLoader)和 JDK9 之后的平台类加载器(Platform ClassLoader)负责加载扩展类;应用类加载器(Application ClassLoader)负责加载 classpath 下的应用类。这里顺便把“jdk和jvm、jre的区别”说清楚:JDK = JRE + javac/jar/javadoc 等开发工具,JRE = JVM + Java 核心类库。Bootstrap 加载的就是 JRE 核心类库里那一层,这也是为什么平时只装 JRE 也能跑 Java 程序,但没法编译源码。
双亲委派模型的规则是:每个类加载器收到加载请求后,先委托给父加载器,逐级上报,只有父加载器无法完成加载时才由自己尝试。这样保证了同一个类在全 JVM 范围内只有一个定义,也不会被应用层覆盖成自己写的 java.lang.String。很多人问为什么不能直接打破双亲委派,其实是能打破的,像 Tomcat 的 WebAppClassLoader 就为了隔离多个 Web 应用而使用了“先尝试自己加载再委托父加载器”的模式。但核心类库这块不建议动,否则很容易出现 ClassCastException 或 NoClassDefFoundError。
1.3 排错与面试:Class.forName 和 ClassLoader.loadClass 的区别
面试里最爱考的一个点是 Class.forName() 和 ClassLoader.loadClass() 有什么区别。答案是:Class.forName() 默认会执行类的初始化,而 ClassLoader.loadClass() 默认只做加载和链接,不触发初始化。我记得之前做 JDBC 驱动注册时,必须用 Class.forName("com.mysql.jdbc.Driver") 来触发驱动类的静态代码块,从而向 DriverManager 注册驱动;如果改用 Thread.currentThread().getContextClassLoader().loadClass(...) 来加载,类虽然会被加载,但静态注册不会执行,后续拿连接就会找不到驱动。
真到线上排错的时候,加载阶段的问题往往比初始化阶段更难察觉。jar 包冲突、同一个类出现多份不同版本,是典型的类加载问题。可以用 -XX:+TraceClassLoading 参数启动应用,控制台会打印每个类实际从哪个 jar 加载。输出量很大,一般配合 grep 筛选,比如 java -XX:+TraceClassLoading -jar app.jar 2>&1 | grep 'com.example.SomeClass',能直接看到加载来源。如果发现核心类被非 Bootstrap 加载器加载,说明打包时有人把 JDK 类的同名 class 混进来了,这时候要重点检查依赖树。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 运行时数据区:堆、栈、元空间各管什么,内存模型别再混淆
2.1 线程私有区:程序计数器、虚拟机栈、本地方法栈
运行时数据区是 JVM 执行期间的内存分布,按线程共享与否分成两大块。线程私有的部分有三个:程序计数器、Java 虚拟机栈、本地方法栈。
程序计数器记录当前线程正在执行的字节码指令地址。分支、循环、异常跳转、线程切换恢复都依赖它,所以每个线程必须独立一份。特别提醒一个细节:执行 native 方法时,程序计数器的值是 undefined,因为 native 方法不是字节码指令,不需要靠它记住“执行到哪一行”。这个点面试时偶尔会考。
Java 虚拟机栈对应每个线程的方法调用栈,每次方法调用都会创建一个栈帧。栈帧里有局部变量表、操作数栈、动态链接、方法出口。栈帧的深度决定递归能走多深,深度不够会抛 StackOverflowError。-Xss 参数控制每个线程栈的大小,Linux x64 下默认一般是 1MB 左右。我曾在一台内存紧张的服务器上把 -Xss 调到 256k,当时表面上没问题,但并发高的时候频繁出现 StackOverflowError,原因就是部分方法调用链比较深,栈帧不够用了。所以这个参数不要为了省内存乱调。
本地方法栈是给 native 方法执行时用的栈。HotSpot 直接把本地方法栈和 Java 虚拟机栈合成了一个,所以参数都用 -Xss 控制。
2.2 线程共享区:堆、方法区/元空间、运行时常量池
堆是 Java 对象分配的主战场,也是 GC 工作的核心区域。物理上可以不连续,逻辑上要求连续。-Xms 和 -Xmx 控制堆初始大小和最大大小。如果只设置 -Xmx,不设置 -Xms,JVM 启动时堆用的是 -Xms 默认值(通常是物理内存的 1/64),之后按需扩展,这会导致启动阶段频繁发生扩容,影响运行稳定性。我前几年调优一台服务时发现明明给了 4G 堆,但启动时 jstat 看到的 Eden 区还是几百兆,后来才意识到 -Xms 没有跟着 -Xmx 一起配置。经验是:服务器上直接 -Xms 和 -Xmx 设置成一样,避免堆动态扩容。
方法区存放类的结构信息、常量池、静态变量、JIT 编译后的代码。JDK8 之前叫永久代,JDK8 开始改成元空间(Metaspace)。为什么要改?根本原因是永久代在堆内,大小受限且很难回收,经常因为加载过多类导致 java.lang.OutOfMemoryError: PermGen space。元空间改到本地内存后,默认只受物理内存限制,反而又带来一个新问题:如果不设置 -XX:MaxMetaspaceSize,元空间可能会不断膨胀,最终吃光宿主机内存。所以生产环境里我通常会给一个上限,比如 -XX:MaxMetaspaceSize=512m,同时配好 GC 日志,这样 MetaSpace 出问题时可以看到“Metadata GC Threshold”的征兆。
运行时常量池是方法区的一部分,存放编译期生成的字面量和符号引用。字符串常量池在 JDK7 之后被移到了堆里,目的是让字符串对象能更好地参与 GC 回收。如果你在 JDK8 里长期使用 String.intern() 并把大量字符串放入常量池,要小心堆溢出,这就是字符串常量池在堆里的直接体现。
2.3 “JVM内存模型”到底是哪套模型
这里必须区分两个完全不同的概念。面试里只要提到“JVM内存模型”,十有八九是在问运行时数据区,比如堆、栈、方法区怎么划分。但严谨的计算机理论里,Java 内存模型(Java Memory Model,JMM)是并发编程中关于主内存与线程工作内存的抽象,描述的是变量如何在多线程之间可见、volatile 和 synchronized 的内存语义、happens-before 规则等。它是用来解决原子性、可见性、有序性问题的,和“堆里有多少个 Region”没有直接关系。
我面试别人时,如果候选人能主动反问一句“你问的是运行时内存布局还是 JMM”,通常都会加分。因为这两个概念经常被混用,搞混之后连 OOM 的排查方向都会出错:堆溢出是 java.lang.OutOfMemoryError: Java heap space,而 JMM 讨论的是内存屏障和可见性,两边完全是两个世界。这部分的正确打开方式是:先答运行时数据区,再说一句“如果你指 JMM,那是另一个并发相关的模型”,让面试官决定要不要追问。
2.4 查看运行时数据区最直接的命令
生产上排查内存问题,光背理论没用,得会看数据。我常用的三层命令:
jstat -gc <pid> 1000:每秒打印一次 GC 和堆各区容量、使用率。jmap -heap <pid>:JDK8 下能看堆配置和各代容量;JDK9 之后官方建议改成jhsdb jmap --heap --pid <pid>,直接使用jmap会提示先用jhsdb。jcmd <pid> VM.flags:查看最终生效的 JVM 参数,适合确认-Xmx、-XX:MaxMetaspaceSize等有没有被其他配置覆盖。
OOM 场景下必须提前埋点:在启动参数里加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/,一旦内存溢出,JVM 会自动把堆 dump 到指定目录,后续用 MAT 分析是哪个对象占满了堆。这个配置成本极低,但很多项目根本没有加,等到 OOM 时只能靠系统日志瞎猜。
3. 执行引擎:解释器、JIT 和 CompileThreshold 的真相
3.1 执行引擎的三层结构
执行引擎的作用是逐条执行字节码指令。它可以拆成三块:解释器、即时编译器(JIT Compiler)、GC 入口。很多人第一次接触“JVM 用什么执行字节码”时会误解:以为 javac 编译出的 .class 已经可以直接被 CPU 执行。其实 javac 只是把 .java 源码翻译成跨平台的字节码,CPU 不认这东西。真正让字节码变成机器能跑的指令,靠的是 JVM 里的两套执行方式。
解释器按字节码一条一条解释执行,优点是启动快、不占额外编译时间;缺点是同样的代码每次执行都要重复解释,性能有上限。JIT 编译器负责在运行时把热点方法直接编译成本地机器码,后续调用直接执行机器码,速度快一个量级。这就是为什么 Java 程序刚启动时慢、跑一段时间后变快的原因:热点代码还没来得及编译成机器码,解释器还在工作。
3.2 热点检测与 CompileThreshold 参数
HotSpot 这个名字就是“热点检测虚拟机”的意思。它通过方法调用计数器和回边计数器来识别热点代码。方法调用计数器记录方法被调用的次数;回边计数器记录方法体内循环的执行次数。当计数器超过阈值时,该方法的字节码会被交给 JIT 编译器。
-XX:CompileThreshold 就是控制这个阈值的参数。经典 HotSpot 中,C1(Client 编译器)的阈值默认约为 1500,C2(Server 编译器)默认约为 10000。但这里要注意,JDK8 之后默认开启分层编译(TieredCompilation),实际触发条件要更复杂,还会受到 -XX:CompileThresholdScaling、-XX:TieredStopAtLevel 等参数影响。所以网上“默认执行 10000 次才编译”的说法并不严谨,要结合具体 JDK 版本和编译日志来看。我个人的经验是:核心服务很少真的去调 CompileThreshold,因为默认值在绝大多数业务下已经够用。真要调,可以用 -XX:+PrintCompilation 观察编译日志,看看实际编译发生的时间点,再决定要不要把阈值降低或升高。
还有一种常见需求:机器上跑的是常驻内存的计算服务,启动后很长一段时间都在反复执行核心算法。这时候可以把阈值调低一点,让热点代码尽快被编译成机器码,从而提升稳定期的吞吐。反过来,如果只是偶尔执行一次的任务型程序,JIT 反而是额外负担,保持默认让它自然衰减就好。方法调用计数器存在“半衰”机制:在一段时间内方法调用频率下降,计数器会被削减,避免为不再频繁执行的方法做无用编译。
3.3 JIT 优化:方法内联和逃逸分析
JIT 不只是翻译机器码,它还会做很多编译优化。
方法内联是最常见的一种:当一个短方法被频繁调用时,JIT 直接把方法体展开到调用方内部,省去方法调用的栈帧开销。-XX:MaxInlineSize 控制内联方法的最大字节码大小,默认一般是 35 字节。平时写代码时有意识地控制方法体积,其实也在帮 JIT 更容易做内联。
逃逸分析是另一个重要优化:如果 JVM 判断某个对象不会被传出去,只在当前方法内使用,那这个对象可能直接被分配到栈上,而不是堆里。栈上分配的对象随方法栈帧一起消亡,不需要 GC 介入,能显著减少垃圾回收压力。这个优化由 -XX:+DoEscapeAnalysis 控制,默认是开启的。不过要明确,栈上分配不是 Java 规范里规定“必须存在”的行为,只是 HotSpot 实现的一种优化手段,不能作为程序正确性的依赖。
3.4 JIT 认知和工具链版本的关联
JVM 版本认知不清,常常会在构建工具上栽跟头。比如 Gradle 报“The project's gradle version 6.7.1 is incompatible with the gradle jvm version”,这种错误本质是构建工具和 JDK 版本不兼容。Gradle 6.7.1 支持的 JDK 上限约为 JDK15,如果你用 JDK17 当 Gradle 的 JVM,它就会直接拒绝执行。解决思路有两种:修改 gradle-wrapper.properties 里的 distributionUrl 升级到兼容新 JDK 的 Gradle 版本;或者在 IDE 里把 Gradle JVM 指定为旧版本 JDK。这类问题虽然不在 JVM 内部,但说明了一个朴素的道理:用 JVM 技术栈的人,要对“JDK/JRE/JVM 三者关系”和“版本边界”有清晰认知。个人建议在开发和跑构建的机器上用 jenv 或 sdkman 管理多版本 JDK,避免项目间切换时互相污染。
4. 垃圾回收:从分代理论到 G1,对象死后 JVM 在做什么
4.1 判断对象可回收:引用计数和可达性分析
垃圾回收首先要解决一个问题:哪些对象已经“死了”。两种主流路线,引用计数和可达性分析。引用计数实现简单,每个对象维护一个计数器,计数为 0 就回收,但致命缺陷是循环引用:A 引用 B、B 引用 A,外部没人引用它们,计数却始终不为 0,内存就泄漏了。HotSpot 没有采用它。
可达性分析是从一组 GC Roots 出发,沿着引用链往下遍历,凡是不可达的对象都判定为可回收。GC Roots 包括:虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、JNI 引用的对象、活跃线程等。理解 GC Roots 对分析堆 dump 很有帮助,因为 MAT 里看到的大对象根部引用,往往就是这些来源。
4.2 分代回收与算法选择
堆内存被设计成新生代和老年代,是因为“弱分代假说”:绝大多数对象朝生夕死,而熬过多次 GC 的对象有更大的概率继续存活。新生代里对象存活率低,适合用标记-复制算法:把 Eden 和一块 Survivor 里的存活对象复制到另一块 Survivor,然后直接清掉整块区域,代价小、速度快。老年代对象存活率高,用标记-清除或标记-整理算法更合适,因为复制算法会频繁搬运大对象,开销反而大。
新生代内部再拆成 Eden 和两块 Survivor(From/To)。新对象优先分配到 Eden 区;Eden 满触发 Minor GC,存活对象复制到 Survivor,年龄加 1;年龄达到 -XX:MaxTenuringThreshold 默认 15 时晋升到老年代。大对象如果设置了 -XX:PretenureSizeThreshold,会直接进入老年代,避免在新生代反复复制。整个流程里,“Eden 满”是最常见的 Young GC 触发原因,GC 日志里 allocation failure 就是这种情况。
4.3 从 Serial 到 G1:收集器的演进逻辑
Serial 是单线程收集器,适合客户端或单核场景;Parallel 是多线程并行,目标是高吞吐,JDK8 默认收集器就是它。CMS 主打低停顿,通过并发标记清除来尽量缩短用户线程的停顿时间,但它的硬伤是内存碎片和并发模式失败导致的 Full GC 退化,JDK9 标记废弃,JDK14 直接移除。如果你还在维护老项目,尽早规划从 CMS 迁移到 G1。
G1 的出现改变了堆的物理布局。它把堆划分成多个大小相等的 Region,默认最多约 2048 个,每个 Region 大小从 1MB 到 32MB 不等,根据堆大小自动选择。逻辑上新生代、老年代仍然存在,但物理上不再连续。G1 的核心目标是“可预测停顿”,通过 -XX:MaxGCPauseMillis 设定期望停顿目标,默认 200 毫秒。它用暂停预测模型动态调整各代 Region 数量,这比 CMS 的停顿控制要稳。
G1 的 GC 分为 Young GC、并发标记周期、Mixed GC。Mixed GC 除了回收新生代,还会选择回收收益高的老年代 Region。这里有个高频面试点:G1 为什么要用 Remembered Set(RSet)?因为如果不记录跨 Region 引用,GC 时为了知道谁引用了当前 Region,就得全堆扫描,代价太大。RSet 记录了从其他 Region 指向当前 Region 的引用,GC 时可以快速定位,避免全堆扫描。了解 RSet 之后,再看 G1 的内存占用为什么比传统收集器高,就很好理解了。
未来的 ZGC、Shenandoah 面向的是更极端的低延迟场景,把停顿时间压到毫秒级甚至亚毫秒级,但原理和复杂度都更上一层楼。新手不要一上来就追新,先把 G1 的分代模型、RSet、停顿预测模型吃透,再往后看 ZGC 会轻松很多。
4.4 GC 日志:从看到到看懂
GC 日志是定位 GC 问题的第一手资料。JDK8 和 JDK9+ 的 GC 日志参数差别很大。JDK8 常用:
bash复制-verbose:gc -Xloggc:/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
JDK9+ 使用统一日志框架:
bash复制-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags
第一次看 GC 日志时,别被一堆数字吓到。先找几类关键信息:看到 allocation failure 一般是 Eden 分配失败触发 Young GC;看到 Metadata GC Threshold 说明元空间接近 -XX:MaxMetaspaceSize,这可能是类加载太多或者元空间配置太小;看到 Full GC (Ergonomics) 是 JVM 自动调节后的 Full GC,需要结合前后堆使用率判断是不是老年代增长过快。真正让 GC 日志发挥作用的前提,是日志文件被持久化到你能拿到的地方,否则容器一重启就全丢了。
5. 本地方法接口与实战排错:Native 调用、容器日志与版本兼容
5.1 本地方法接口在 JVM 里的位置
本地方法接口是 Java 调用底层 C/C++ 代码的桥梁。Java 层用 native 关键字声明方法,方法体由 JNI 实现。执行引擎遇到 native 方法时,会通过 Native Method Interface 跳到 C/C++ 侧代码执行,执行完再回到 Java 线程继续。像 System.currentTimeMillis()、文件流里的 readBytes()、线程相关的 start0(),都是 native 方法。
为什么需要 JNI 这一层?因为 JVM 本身要跨平台,就必须把平台相关的部分隔离出去。文件 IO、网络 Socket、底层系统调用都放在 JNI 层里,由各平台实现各自的版本,Java 层只保留稳定的接口。这也是“JVM 是 C++ 写的,Java 程序最终还是要通过系统调用触达操作系统”这句话背后的模块支撑。
面试时这个模块问得不多,但至少要知道 JNI 里的核心对象:JNIEnv 是每个线程都有的 JNI 环境指针,通过它可以调用 C 侧函数;jobject 是 Java 对象在 native 层的引用;GetStringUTFChars 等函数负责把 Java 字符串转成 C 字符串。我早期写过一点 JNI 调用本地库的代码,印象最深的是:在 native 侧持有的全局引用必须显式删除,否则会导致 Java 对象无法被 GC 回收。这个坑本质上还是“GC Roots”的延伸——JNI 全局引用也算 GC Roots。
5.2 容器部署案例:JVM 日志到底去哪儿了
很多人在 Docker 里跑 Java 程序,遇到进程异常重启的第一反应是“去容器里看日志”,但经常发现要么没有日志,要么只有一行 Killed。这时候问题就来了:JVM 的日志到底写在哪?
先说结论:docker logs 只收集容器 stdout/stderr,而 JVM 的 GC 日志和错误日志默认不会写到 stdout。如果你在启动命令里只写了 java -jar app.jar,没有配置任何日志参数,那么 GC 日志不会出现在 docker logs 里;hs_err_pid.log 默认写在 JVM 当前工作目录(通常是 WORKDIR),容器一销毁文件就没了。
我排查容器里 Java 进程崩溃的固定顺序是:
- 先执行
docker inspect <container>,看启动命令里有没有配置-XX:ErrorFile、-Xlog:gc*、-XX:+HeapDumpOnOutOfMemoryError这些关键参数。如果 Base 镜像里什么都没配,就直接去找镜像的 Dockerfile。 - 执行
docker logs看有没有应用本身的异常堆栈。如果应用日志和 GC 日志都没定向到 stdout,这里往往只有一行标准输出,信息非常有限。 - 检查宿主机挂载卷里有没有应用日志、GC 日志、hs_err_pid 文件。这是唯一能在容器重启后保留下来的途径。
- 确认进程到底是被谁杀掉的。执行
docker inspect,重点看State.OOMKilled字段和ExitCode。ExitCode 是 137(128+9,SIGKILL),同时 OOMKilled=true,说明是容器内存超限被内核 OOM killer 杀掉;这时候即使内存不足,Java 应用本身不会打印任何错误堆栈,因为它在眨眼之间就被强杀了。 - 如果怀疑 JVM 自己崩溃,必须验证能找得到 hs_err_pid 文件。这个文件包含崩溃时的线程栈、指令、内存映射,是定位 JVM 崩溃原因最直接的证据。正确姿势是在启动参数里加上
-XX:ErrorFile=/logs/hs_err_pid_%p.log,并确保/logs是挂载卷。
所以在容器里跑 Java,启动参数本身就要按“可观测”标准来写。我的基础模板一般是:
bash复制java -Xms2g -Xmx2g \
-XX:MaxMetaspaceSize=512m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/logs/ \
-XX:ErrorFile=/logs/hs_err_pid_%p.log \
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags \
-jar app.jar
JDK8 项目则把 -Xlog:gc* 替换成 -verbose:gc -Xloggc:/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps。这套配置能让绝大多数 JVM 崩溃和 OOM 场景留下可查痕迹。
5.3 一次 Gradle/JDK 版本不兼容的处理过程
开头热词里那个报错,我最近正好帮同事处理过一次:有台新装的机器默认 JDK 版本是 17,项目用的 Gradle 还是 6.7.1,一跑构建就报 “The project's gradle version 6.7.1 is incompatible with the gradle jvm version”。排查步骤很简单,但很多人会慌。
先确认当前生效的 Java 版本:java -version。然后看项目里 Gradle 的版本:gradle --version 或查 gradle-wrapper.properties。发现 Gradle 6.7.1 不支持 JDK17 后,就两条路:要么把包装器升级到支持 JDK17 的 Gradle 版本,比如 7.6 以上;要么在 IDE 里把 Gradle JVM 指定到本机已有的 JDK8 或 JDK11。升级 Gradle 更省事,但要注意插件兼容性;指定旧 JDK 更快,但会掩盖“构建机器应该统一版本”的规范问题。
这类问题确实不是 JVM 内部的锅,但它暴露了一个现实:很多人对 JDK、JRE、JVM 三者的边界不清楚。JVM 只负责运行字节码,JDK 版本决定编译和运行时的 API,JRE 则去掉开发工具只保留运行能力。构建工具链必须挂在合适的 JDK 版本上,否则就会出现这种不兼容。想要少踩坑,就用 jenv 或 sdkman 管理多版本,并且把 .java-version 这类文件提交到项目仓库里,让所有开发者和 CI 机器保持一致。
5.4 我的调优习惯:从默认行为开始,别一上来堆参数
最后讲一点个人经验。很多人看到线上 JVM 出问题,第一反应就是抄一份网上“调优参数”加到启动脚本里,结果问题没解决,反而引入了新的不确定性。我的习惯是:先不加任何额外参数,用默认配置观察一段时间,通过 jstat、GC 日志、堆 dump 判断瓶颈到底在哪个区域,然后再针对性地改。改的时候一次只动一个变量,记录改动前后的对比数据。JVM 调优最忌讳就是“参数大杂烩”,好几个参数一起改,出了问题你根本不知道是哪一行引起的。
还要理解五个模块之间是联动的:类加载太多会导致 Metaspace 膨胀,MetaSpace 膨胀会触发 Full GC,GC 停顿增加又会影响 JIT 热点的统计,反过来又影响执行引擎的编译质量。只看 GC 日志不看类加载,只看堆大小不看 JIT 状态,很容易得出错误结论。这也是为什么我在开头说“五个模块不是孤立概念”的原因。对刚入门 JVM 的同学
