JVM 这块八股文,是 Java 面试绕不过去的坎,也是很多人背了又忘、忘了又背的东西。平时写代码可能感觉不到它的存在,但一到线上 OOM、GC 停顿、容器被 Kill,又得回头啃这些理论。这篇文章不打算面面俱到,我想结合自己踩过的坑,把 Java 虚拟机相关的高频知识点串一遍:从 JDK/JRE/JVM 区别、内存模型、G1 收集器,到调优参数和常见报错排查,让你既能应付面试,也能真正拿它来定位问题。如果你是准备跳槽的 Java 开发,或者正在被 JVM 调优折磨的朋友,这篇可以当一份“先查目录再细看”的笔记。
1. 先把 JDK、JRE、JVM 的关系搞清楚
1.1 这三个名词到底谁包含谁
很多面试八股文第一题就是“JDK、JRE、JVM 的区别”,但真到用的时候,很多人还是分不清楚。JVM(Java Virtual Machine)是 Java 虚拟机,负责把字节码解释或编译成机器码执行,这是 Java 跨平台的基础。JRE(Java Runtime Environment)是 Java 运行时环境,里面包含 JVM 和 Java 核心类库,比如rt.jar、java.base模块里的那些类,有了 JRE 就能跑 Java 程序。JDK(Java Development Kit)是 Java 开发工具包,它包含 JRE,还额外提供了javac、jdb、jconsole、jstat这些开发、调试和监控工具。
打个比方,JDK 像一个完整的厨房,里面有锅碗瓢盆和菜谱(开发工具);JRE 是只保留了炉灶和基本调料(运行环境);JVM 则是那个真正把菜炒熟的过程,它负责把 Java 字节码变成当前操作系统能理解的机器指令。所以结论很简单:JDK 包含 JRE,JRE 包含 JVM。实际开发机上通常会装 JDK,生产服务器如果只跑程序,理论上装 JRE 就够了,但很多公司为了排查问题方便,也会直接装 JDK。
1.2 “编译一次,到处运行”的真实含义
Java 源文件先通过javac编译成.class字节码文件,这个文件不是给 CPU 直接执行的,而是给 JVM 看的。JVM 在 Windows、Linux、macOS 上分别有对应的实现,所以同一个.class文件能跑在不同平台上。很多面试题问“Java 是编译型还是解释型语言”,本质上它更像是“编译 + 解释”的混合体:字节码一开始是由解释器逐行解释执行,当某段代码成为热点代码后,JVM 会通过 JIT(Just-In-Time)编译器把它编译成本地机器码,下次执行直接调用,速度就快很多。
这里要提醒一句:JVM 本身是一个规范,像 HotSpot、OpenJ9、GraalVM 都是它的实现。八股文里说“JVM 是 HotSpot”,严格来说不准确,HotSpot 只是最流行的一种实现,日常默认装 OpenJDK 或 Oracle JDK 用的就是它。面试时如果能把“规范 vs 实现”说清楚,会明显加分。
2. 内存模型:面试至少考五块区域,别只背三大区域
2.1 运行时数据区全景图
JVM 内存模型按线程私有和线程共享来划分,标准答案是五个部分:程序计数器、虚拟机栈、本地方法栈、Java 堆、方法区(JDK 8 以后叫元空间)。程序计数器是每个线程私有的,记录当前线程正在执行的字节码行号,这个区域是唯一不会抛OutOfMemoryError的地方。虚拟机栈也是线程私有的,存储栈帧,里面包含局部变量表、操作数栈、动态链接、方法出口等信息,方法调用深了就会抛StackOverflowError。
本地方法栈服务于 native 方法,这个了解即可。Java 堆是线程共享的,存放对象实例,是 GC 的主要区域,也是面试里最常问的一块。方法区在 JDK 8 之前叫永久代,JDK 8 之后改成了元空间(Metaspace),它存的是类的元信息、常量池、静态变量等,元空间默认使用本地内存,不受-Xmx限制,这也是很多老项目升级到 JDK 8 后遇到 Metaspace OOM 的根源。
2.2 堆内存为什么还要分新生代和老年代
Java 堆不是一块大平地,而是按照对象存活时间分成了新生代(Young)和老年代(Old)。新生代内部又分为 Eden 区和两个 Survivor 区(S0、S1),默认比例是 8:1:1。新创建的对象大部分会先放到 Eden 区,Eden 满了触发 Minor GC,存活对象被复制到 Survivor 区,每经过一次 Minor GC,年龄加 1,默认年龄到 15 就会晋升到老年代。这样分代的核心理由是:绝大多数对象都是朝生夕死的,与其全堆扫描,不如在新生代里用复制算法快速清理,代价小、效率高。
老年代里的对象存活时间更长,GC 频率更低,但一旦触发 Major GC / Full GC,通常耗时较长。所以面试里如果问“为什么新生代用复制算法,老年代用标记清除或标记整理”,回答要点就是:复制算法在存活率低时效率高,但浪费一部分空间;老年代对象存活率高,复制成本太大,需要用标记清除或标记整理来减少内存碎片。
2.3 “JVM 的三大区域”到底怎么回答
很多旧版的八股文会简化成“堆、栈、方法区三大区域”,有些面试官也习惯这么问。但如果你只答“堆、栈、方法区”,可能翻车。更稳妥的答法是先给标准五区域,然后补一句:早期教材常说的“三大区域”指的是堆、虚拟机栈、方法区,本质上是按主要内存结构做的简略分类。这样既显得你有知识储备,又不会和面试官较劲。
我个人的理解是,面试官真正想考的是“线程私有 vs 线程共享”这个边界。栈和程序计数器是线程私有的,堆和方法区是线程共享的。多线程环境下,每个线程有自己的栈,但对象都在堆里共享,所以才需要加锁、可见性这些机制。能把这个延伸到并发编程,回答就会很立体。
3. 垃圾收集器选型:G1 为什么能成为默认
3.1 常见收集器对比
从 JDK 8 默认的 Parallel Scavenge + Parallel Old,到 JDK 9 之后默认的 G1,很多人只记住了名字,不知道选型逻辑。我按实际使用场景给一张表:
| 收集器 | 工作区域 | 算法 | 特点 | 适用场景 |
|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程,停顿时间长 | 客户端应用、内存极小 |
| Parallel Scavenge | 新生代 | 复制 | 多线程,吞吐量优先 | 后台计算任务 |
| Parallel Old | 老年代 | 标记整理 | 多线程,吞吐量优先 | 与 Parallel 配套 |
| CMS | 老年代 | 标记清除 | 并发,低停顿 | 对响应时间敏感的 Web 应用 |
| G1 | 整堆 | 分区 + 局部复制 | 可预测停顿,兼顾吞吐 | 多核大内存,JDK 9 起默认 |
| ZGC | 整堆 | 染色指针 | 停顿极低(毫秒级) | 超大堆,低延迟场景 |
CMS 在 JDK 9 里被废弃,JDK 14 移除了。如果你还在维护老项目,遇到“Unrecognized VM option 'UseCMSCompactAtFullCollection'”这种报错,多半是 JDK 版本升级后没有删掉 CMS 相关参数。
3.2 G1 的内存模型和关键参数
G1 把整个堆分成一个一个的 Region,每个 Region 大小默认从 1MB 到 32MB,由 JVM 根据堆大小自动决定,也可以通过-XX:G1HeapRegionSize手动指定。Region 之间逻辑上仍然是分代的,Eden、Survivor、Old 区域都由若干个 Region 组成,还有一类叫 Humongous 的区域,专门放超过 Region 一半大小的大对象。G1 最大的特点是“整堆统一管理”,不再像以前的收集器那样强制严格分代,这样既能做新生代回收,也能做老年代回收,并且可以设置期望的 GC 停顿时间-XX:MaxGCPauseMillis=200。
实际调优时,不要把停顿时间设得太小,比如设成 10ms,那样 G1 会频繁做垃圾回收,反而导致吞吐量暴跌。我见过有人把-XX:MaxGCPauseMillis设成 50ms,结果 Full GC 反而变多了。G1 的调优核心是平衡停顿时间和吞吐量,先用默认值跑一段时间,再用jstat -gcutil观察 Old 区的使用趋势,再考虑要不要调整堆大小和 Region 大小。另一个高频参数是-XX:G1ReservePercent,默认值是 10,意思是预留 10% 的堆空间用于晋升,避免老年代放不下触发 Full GC,这个值在内存紧张时可以适当调大,但不能过大,否则可用堆变小。
3.3 -XX:CompileThreshold 到底是什么
热词里有jvm参数 -xx:compilethreshold,这个参数经常被误解为 GC 参数,其实它和 JIT 编译有关。JVM 发现某个方法被反复执行,就会用 JIT 编译器把它编译成本地代码,这个“反复执行多少次”就是编译阈值。-XX:CompileThreshold=10000意思是方法调用次数达到 10000 次后触发 C2 编译。C1(Client 编译器)和 C2(Server 编译器)的默认阈值不一样,C1 通常是 1500,C2 通常是 10000,而且开启分层编译后情况更复杂。
面试时如果被问到“JIT 编译原理”,可以从计数器说起:JVM 为每个方法维护调用计数器和回边计数器,循环体每执行一次,回边计数器加一,到阈值后进入编译队列。编译完成后再替换为本地代码。实际工作中,一般不需要动这个参数,了解它能帮你理解为什么“JVM 要跑一段时间才变快”,因为热点代码被编译了。
4. 调优参数和容器场景,别让 JVM 吃满容器内存
4.1 堆参数与 GC 日志参数怎么配
最常见的参数组合是这个:
java -Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m \ -Xloggc:/data/logs/gc.log -XX:+PrintGCDetails \ -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/heap.hprof -jar app.jar-Xms和-Xmx分别指定堆初始大小和最大大小,通常建议设成一样,避免运行期扩容导致停顿。但堆大小不是越大越好,也要给堆外内存、线程栈、元空间留余地。一个粗略的经验法则是:如果机器总内存 8G,给 JVM 堆设置 4G 左右,剩余留给操作系统和堆外。容器场景下则要结合容器的内存 limit 来设,比如容器限 2G,堆最多给 1.2G-1.5G,留 500M 左右给元空间、线程栈和 IO 缓冲。
JDK 8u191 之后默认开启UseContainerSupport,JVM 能自动感知容器内存限制。如果你的 JDK 版本比较老,又在 Docker 里跑,请务必升级,或者显式设置-XX:MaxRAMPercentage=75.0,意思是用容器可用内存的 75% 作为堆上限。不要再用-Xmx写死一个数字,否则容器扩缩容后内存参数就过时了。
4.2 Docker 容器中 JVM 启动不了、异常重启怎么办
热词里有一条特别典型:“docker 容器部署的 java 程序异常重启,jvm日志在哪儿”。这里有个常见误区:容器退出不一定是 JVM 抛错,很可能是被操作系统杀了。当容器物理内存超限,cgroup 的 OOM Killer 会直接杀掉进程,JVM 连抛 OOM 的机会都没有。这时候应用日志里看不到异常,docker ps显示容器已退出,docker inspect能看到"OOMKilled": true。
排查步骤我一般这么走:
- 先看容器状态:
docker inspect <container> | grep -i oom - 再看宿主机日志:
dmesg | grep -i java,确认是否有Out of memory: Kill process记录 - 检查
RestartCount,如果被重启策略拉起,再进容器看/proc/1/cgroup,确认内存限制 - 最后配合
jstat、jmap在容器内做在线诊断,如果能保留现场的话
JVM 自身的崩溃日志叫hs_err_pid<pid>.log,比如hs_err_pid12345.log,它默认生成在进程工作目录。如果容器的工作目录没挂载出来,容器一删日志就没了。所以部署时建议在启动参数里显式指定工作目录,并用-XX:ErrorFile=/data/logs/hs_err_%p.log把崩溃日志固定下来。GC 日志同理,容器里千万别只输出到 stdout,一旦容器重启就找不到了。
4.3 JVM 或 Spring Boot 会设置 SQL 执行 10 秒自动关闭吗
热词里这个问题问得很有意思。答案是:JVM 本身绝对不会去管 SQL 执行时间,它不认识 SQL。Spring Boot 也不会在 JVM 层面做这种拦截,真正可能“10 秒断掉”的是连接池、数据库驱动或中间件配置。比如 HikariCP 的connectionTimeout默认 30 秒,是获取连接的超时时间;MySQL JDBC 驱动的socketTimeout默认 0,表示不超时,但很多云厂商或中间件会默认设置 10 秒的 socket 超时来保护资源。MyBatis 也没有全局 statement timeout,除非你给单个 SQL 设置了timeout属性。
所以排查“SQL 执行 10 秒就失败”时,不要一上来就怀疑 JVM,先看数据源配置。spring.datasource.hikari.connection-timeout、spring.datasource.hikari.validation-timeout,以及 MySQL URL 里的connectTimeout、socketTimeout参数,这几项才是重点。常见配置大概是:
spring: datasource: hikari: connection-timeout: 30000 validation-timeout: 5000 max-lifetime: 1800000如果 SQL 本身执行超过 10 秒,最好检查数据库慢查询日志和锁等待,而不是 Java 应用设置。
5. 高频报错和 OOM 排查,我来盘一份速查表
5.1 常见的 OutOfMemoryError 到底怎么定位
java.lang.OutOfMemoryError: Java heap space是最常见的,说明堆里对象太多,或者有对象泄漏。先用jps找到进程,再用jmap -heap <pid>看堆的使用情况,用jstat -gcutil <pid> 1000观察 GC 频率。如果 Eden 一直在波动,Old 区持续上涨,基本可以判断有对象无法被回收。比较靠谱的办法是在启动参数里打开-XX:+HeapDumpOnOutOfMemoryError,OOM 时自动导出堆快照,然后用 MAT 或 VisualVM 分析。看谁占了内存、有没有重复的线程栈引用,这是定位泄漏的核心。
还有一种启动时直接报java.lang.OutOfMemoryError: insufficient memory,这其实不是堆满了,而是 JVM 在分配堆外内存(比如元空间、线程栈)时系统内存不足。常见于容器内存设得太小,或者机器上开了太多其他进程。解决思路是调小 JVM 内存、检查宿主机可用内存、调整线程数上限。另外,unable to create new native thread也别忽略,通常是线程数超过操作系统限制,ulimit -u或容器 pid 限制导致的,不是堆的问题。
5.2 快速定位 GC 或 JVM 参数不一致的问题
启动时加-XX:+PrintCommandLineFlags可以打印最终生效的 JVM 参数。这个参数在排查“为什么我配了-Xmx2g但实际进程占了 4G”非常有用,能看到 JVM 实际用了哪些参数。还有一个常见坑:同一台机器装了多个 JDK 版本,java -version和JAVA_HOME指向不一致,启动脚本里$JAVA_HOME/bin/java是好的,但java命令本身却是另一个版本,导致参数不兼容。
如果发现 Java 进程的 GC 日志没生效,先检查参数顺序。Java 的-X参数必须写在-jar之前,写在后面会被当成程序参数,这是很多人踩过的坑。
5.3 常见启动/编译报错速查表
我在群聊和博客里经常看到类似的报错,整理成一张表,方便直接对号入座:
| 报错内容 | 原因 | 处理办法 |
|---|---|---|
No JVM could be found on your system | 系统找不到 Java 运行时,JAVA_HOME未设置或PATH不对 | 安装 JDK,配置JAVA_HOME和PATH,执行java -version验证 |
[error] could not get JVM parameters and dynamic configurations properly | 工具脚本读取jvm.config失败,常见于路径含空格、JDK 版本不匹配 | 检查JAVA_HOME路径,查看启动脚本中的jvm.config文件内容 |
错误: 源发行版 17 需要目标发行版 17 | Maven 编译 source/target 版本与当前 JDK 版本不一致 | 在pom.xml里配置maven.compiler.source、maven.compiler.target,保持和 JDK 一致 |
java: You aren't using a compiler supported by lombok... | 当前 JDK 版本太新,Lombok 版本太老 | 升级 Lombok 到 1.18.30+,或回退 JDK 版本 |
java.lang.NullPointerException in mapping processor | MapStruct 或其它注解处理器在编译期 NPE | 检查 DTO 映射方法、升级依赖版本,开启编译日志获取堆栈 |
drozer: 找不到 Java | 命令行工具需要JAVA_HOME环境变量,但没有配置 | 设置JAVA_HOME并把%JAVA_HOME%\bin加入 PATH |
Could not reserve enough space for object heap | 32 位系统或系统内存不足,堆参数超过可用内存 | 减少-Xmx,检查总内存和进程数 |
实际排查时,先看错误出现的位置是在启动前还是运行中。启动前的多半是环境变量、JDK 版本问题;运行中的多半是内存、GC、资源限制问题。我每一次调试 JVM,一定会先确认三件事:用的哪个 JDK、启动参数是什么、GC 日志在哪里。这三件事没搞清楚,后面只会浪费更多时间。
5.4 面试八股文高频题,快速自查
这里再把 JVM 面试里出现频率最高的问题列一下,你可以用来查漏补缺:
- JVM 的内存区域有哪些?哪些线程共享,哪些线程私有?
- 对象在堆中的分配过程?什么情况下会进入老年代?
- 如何判断对象可回收?什么时候用可达性分析?什么对象可以作为 GC Roots?
- G1 和 CMS 有什么区别?为什么 G1 成为默认?
- 什么是类加载机制?双亲委派模型是什么?为什么需要破坏它?
- 什么是 JIT?解释执行与编译执行的区别?
- 如何查看 JVM 进程参数?
jstat、jmap、jstack各自有什么用?
这些问题看起来都是背诵题,但最好结合上文里的场景去理解。比如“对象什么时候进入老年代”,不只是回答“年龄到 15”,还要说出大对象直接进老年代、动态年龄判断、存活区放不下直接进老年代这些细节,面试官就会觉得你是真的理解,而不是背的。
6. 最后分享几个小习惯
我在实际项目里被 JVM 问题折磨过很多次,现在养成了几个习惯。第一,所有 Java 服务启动脚本里,强制加上-XX:+PrintCommandLineFlags和-XX:+HeapDumpOnOutOfMemoryError,为事后排查留证据。第二,GC 日志和崩溃日志一定要落到持久化磁盘,容器场景必须挂载出来,不然容器一重启就是“案发现场被清理”。第三,不要随便抄网上那种-Xmx8g的命令,先看机器内存和容器限制,再留出堆外内存的余量,JVM 调优更像是“做减法”,把不必要的参数删干净,比堆一堆参数更重要。
JVM 这块内容很多,一篇不可能覆盖到全部。但只要你把内存模型、垃圾收集器、常用参数、线上排查工具这几条主线打通,再去啃源码也好、看实战文章也好,都不会觉得那么乱了。我把这篇文章定位成“八股文系列”的起步,后续有时间再聊类加载、JIT 和工具链,希望这些经验能帮你少走一点弯路。