彻底搞懂JVM:JDK/JRE关系、内存模型、垃圾回收与面试实战
2026/9/16 1:26:21 网站建设 项目流程

我面试的时候几乎必问一个问题:JDK、JRE、JVM 到底是什么关系?这个题看起来基础到不能再基础,但真正能答清楚的人,十个里面不超过三个。大多数人能背出“JDK是开发工具包,JRE是运行环境,JVM是虚拟机”,但我只要追一句“JVM跑一个Hello World的时候,内存里到底发生了什么”,场面就会安静下来。

这恰恰说明很多人对OpenJDK和JVM的认知是碎片化的:知道概念,不知道边界;知道名词,不知道原理。所以这篇文章我想把这些年积累的东西做一个系统梳理,从OpenJDK的术语全景开始,一路讲到JVM的类加载、运行时数据区、执行引擎和垃圾回收,最后落到面试题上。内容会比较长,但读完之后你获得的是一张完整的JVM地图,而不是一堆零散的知识点。适合刚接触JVM的人打基础,也适合工作几年想系统补课的老兵。

1. 从“JDK、JRE、JVM到底啥关系”说起:OpenJDK家族术语全景

1.1 JDK / JRE / JVM:天天在用,但很多人第一句就答错

先说JVM。JVM是Java Virtual Machine,Java虚拟机。它的本质是一个运行在操作系统之上的普通进程,但在这个进程内部,它模拟了一台完整的计算机:有“CPU”(执行引擎)、有“内存”(运行时数据区)、有“硬盘”(类文件加载与字节码存储),甚至还有自己的“垃圾回收部门”。你写的Java代码最终不是直接被操作系统执行,而是先编译成字节码,再由JVM解释或编译成机器码去执行。

JRE是Java Runtime Environment,Java运行时环境。它包括了JVM,再加上Java程序运行所依赖的核心类库,比如常用的java.langjava.utiljava.io这些包。简单理解:JRE = JVM + 核心类库。如果你只是要运行一个编译好的.class文件或.jar包,装了JRE就够了,不需要装编译器。

JDK是Java Development Kit,Java开发工具包。它包含JRE的全部内容,额外还提供了javac编译器、javap反汇编工具、jdb调试器、jar打包工具、jpsjstatjmap这些诊断工具。所以JDK = JRE + 开发工具。这也是为什么做开发的人一定要装JDK,而普通用户只需要JRE。

三者关系可以用一个套娃来记:JDK包含JRE,JRE包含JVM。但这里有个容易踩坑的细节:Java 9之后,官方不再提供独立的JRE安装包了。因为Java 9引入了模块化系统(JPMS),JDK本身被拆分成一组模块,你可以用jlink工具把JDK裁剪出一个精简的自定义运行时,相当于“自己拼一个JRE”。所以现在你在官网看到的下载选项基本只剩JDK,没有单独的JRE了。这个问题在面试里偶尔会出现,答案是“JDK 9之后不再提供独立JRE版本”。

另一件被问得很多的事是:为什么有人下载干净的OpenJDK之后,找不到javaws这个命令?因为javaws对应的是Java Web Start技术,它和Applet一起在JDK 11里被官方从JDK中移除了。所以如果你看到“openjdk 无javaws”这样的搜索词,原因不是装错了版本,而是Java官方主动砍掉了这个过时的桌面分发技术。

1.2 OpenJDK与Oracle JDK:不是两个竞争产品,而是“参考实现”与商业发行版

OpenJDK是Java平台标准的开源参考实现,它的源代码在GPL v2 + Classpath Exception许可下开源,任何人都可以下载、修改、分发。Oracle JDK是在OpenJDK基础上构建的商业版本,早期它包含一些闭源组件(比如Flight Recorder、Java Mission Control),加上Oracle自己的测试和支持体系,并且提供长期商业支持。

在JDK 8及更早的时期,OpenJDK和Oracle JDK的差异还比较明显。但从JDK 11开始,Oracle把大部分有差异的功能都贡献给了OpenJDK社区,两者在功能上已经基本对齐。区别主要体现在发布节奏、许可证和支持模式上:

对比项OpenJDKOracle JDK
许可证GPL v2 + Classpath Exception,免费OTN License Agreement,商用需付费(有免费使用条款)
发布节奏跟随OpenJDK社区版本跟随OpenJDK版本,提供LTS长期支持
二进制构建方各家发行商(Temurin、Zulu等)Oracle官方
功能差异与Oracle JDK基本一致(JDK 11+)包含Oracle专属支持与认证
日常开发选哪个免费、社区活跃,推荐生产环境按需购买商业支持

一个很实用的建议:个人学习和企业内部一般用途,直接用OpenJDK或者基于OpenJDK的发行版就够了;如果是银行、大型企业这类对SLA有严格要求的环境,再考虑购买Oracle的商业支持。这份钱买的是“出问题有人管”,而不是功能。

1.3 各路OpenJDK发行版选型对比

OpenJDK只是一个源代码仓库,真正到你电脑上跑的二进制包,是由不同组织各自编译构建的。这里有几个常见的发行版,容易让人脸盲:

  • Eclipse Temurin:原AdoptOpenJDK,后来归到Eclipse基金会名下。社区认可度很高,提供LTS版本,免费、开源、兼容性好,目前我最推荐的中坚选择。
  • Azul Zulu:Azul公司提供,免费版和商业版都有,对ARM架构、云原生场景支持不错,主打低延迟和稳定性。
  • Alibaba Dragonwell:阿里开源的OpenJDK发行版,针对高并发、云原生场景做了优化,在阿里内部大规模生产验证过,国内团队用得不少。
  • Microsoft Build of OpenJDK:微软维护的构建版本,免费,主要面向Azure生态,质量也不错。
  • Amazon Corretto:AWS维护,免费,长期更新,云上部署很方便。

我的选型原则很简单:没有特殊诉求就用Eclipse Temurin的LTS版本(比如11或17,现在17是主流,21也已经很稳了);如果团队里已经有云厂商的深度绑定,那就用对应云厂商的发行版,省心。至于“哪个性能最好”,说实话在大多数业务场景下,不同发行版的性能差异远小于JVM参数和代码质量带来的差异,不必过度纠结。

1.4 还要认识的几个JVM实现与相关术语

很多人以为“JVM = HotSpot”,其实HotSpot只是其中一种实现,只不过它是OpenJDK默认携带的那一个,所以大家接触到的绝大多数“JVM”就是HotSpot。

除了HotSpot,还有几个名字值得认识:

  • OpenJ9:IBM捐献给Eclipse基金会的JVM实现,从IBM J9发展而来,特点是启动快、内存占用低,在云原生和微服务场景下有一批忠实用户。
  • GraalVM:Oracle推出的高性能JVM,最大卖点是不只跑Java,还能跑JavaScript、Python、Ruby等多种语言;同时提供Native Image技术,可以把Java应用AOT编译成原生可执行文件,启动速度和内存占用都远优于传统JVM。
  • JRockit:曾经的BEA公司产品,后来被Oracle收购,已经退出历史舞台,但它的JFR(飞行记录器)技术后来融入了HotSpot,所以面试偶尔会提到这个名字。

在OpenJDK生态中还会看到这些高频术语:JSR(Java规范请求,一个新功能从提案到落地的过程)、JEP(JDK增强提案,JDK自身的具体改进项)、TCK(技术兼容套件,用来验证某个实现是否符合Java标准)。这些概念不要求你背得很熟,但看OpenJDK官方邮件和版本说明时认识了它们,阅读速度会快很多。

2. 把JVM当成一台完整电脑:类加载、内存、执行引擎、GC的分工

2.1 JVM并不是“解释器”,它是一台标准化的抽象电脑

很多人对JVM的第一印象是“把字节码翻译成机器码的解释器”,这个印象说对了一半。JVM确实有解释执行的能力,但它的本质更接近一台抽象的计算机:它定义了字节码指令集、寄存器(以栈形式体现)、内存模型、异常处理规则等一整套标准。

我习惯用一个类比来解释JVM的架构:把JVM想象成一台独立的电脑,它自己有一套“零件”:

  • 类加载子系统相当于硬盘和操作系统的一部分,负责把.class文件从磁盘装载到内存中,并校验、准备、解析这些类的元数据。
  • 运行时数据区相当于内存条,JVM自己管理一块独立的内存区域,把堆、栈、方法区规划得清清楚楚。
  • 执行引擎相当于CPU,逐条执行字节码指令,并把热点方法编译成本地机器码。
  • 垃圾回收器相当于后台的自动清理进程,它不需要你主动释放内存,而是自己扫描、标记、回收死掉的对象。

理解了这套对应关系,再看JVM架构就不会觉得抽象。JVM之所以要这么设计,核心目的是“屏蔽平台差异”:你的Java代码编译成的字节码,不管在Linux、Windows还是macOS上,在JVM内部看到的执行逻辑都是一样的。这就是Java“一次编写,处处运行”的根源。

2.2 两个子系统+一个数据区:JVM的模块拼图

严格来说,JVM内部可以拆成三个主要部分:

第一部分是类加载子系统。它负责查找并加载类的二进制数据,然后把Class对象放到方法区。类加载不是一个瞬间动作,它会经历加载、验证、准备、解析、初始化五个阶段,我在第4节会详细展开。

第二部分是运行时数据区。这是JVM内存模型的实际载体,又细分为程序计数器、虚拟机栈、本地方法栈、Java堆、方法区(元空间)五块。前三个是线程私有的,后两个是线程共享的。这个划分非常重要,因为很多面试题和线上问题都出在这几块内存的边界上。

第三部分是执行引擎。它包括解释器、JIT编译器、垃圾回收器。解释器将字节码逐行解释为机器码,启动快但执行慢;JIT编译器会分析热点代码,把高频方法整体编译成机器码缓存起来;垃圾回收器负责自动管理堆内存。

除了这三部分,还有本地方法接口(JNI)这块拼图,它允许Java代码调用C/C++等本地方法,比如操作底层操作系统API。JNI用的场景不算多,但它的存在解释了为什么会出现UnsatisfiedLinkError、为什么有些内存占用在堆外看不到——那些可能是native方法直接分配的内存。

2.3 一个请求进入JVM后的完整路径

把这套架构串联起来看,一次完整的Java代码执行过程是这样的:

你执行java MyApp启动一个Java进程,JVM初始化完成后,由启动类加载器加载MyApp类,然后调用它的main方法。main方法是一个线程的入口,JVM会为这个线程分配一个虚拟机栈,并在栈上压入第一个栈帧。栈帧里保存了main方法需要的局部变量、操作数栈和其他信息。

main方法里出现new指令,JVM就在Java堆中为对象分配内存;对象里的实例字段被赋默认值,随后构造方法被执行。如果程序调用其他方法,JVM就压入新的栈帧;方法执行完毕,栈帧弹出,返回值留在调用方的操作数栈中。

随着线程运行,对象的引用关系不断变化。某个时刻,垃圾回收器判断出某些对象已经不可达,就会在合适的时机回收它们占用的堆内存。JIT编译器则在后台统计每个方法的执行次数,把热点方法编译成机器码,让后续调用不再走解释执行。

这条路径里其实没有哪一块是可以独立存在的:没有类加载子系统,类就进不了内存;没有运行时数据区,代码就没有地方放数据和指令;没有执行引擎,字节码永远只是躺在内存里的一段废话;没有GC,堆里早就堆满了垃圾。JVM架构的优雅之处就在这里,各司其职,又互相咬合。

3. 运行时数据区拆解:每一块内存的职责、异常与配置参数

3.1 线程私有区:程序计数器、虚拟机栈、本地方法栈

运行时数据区是面试和线上排查的重灾区,我见过太多人在“堆和栈的区别”上翻车。先把线程私有的三块讲清楚。

程序计数器(PC寄存器)是最小的一块内存,每个线程一条。它记录了当前线程正在执行的字节码行号。一旦执行的是native方法,程序计数器的值是undefined。这块区域是JVM规范里唯一不会抛出OutOfMemoryError的地方,因为它只是一个行号指示器,占用极小。

虚拟机栈描述的是线程内方法调用的内存模型。每当一个方法被执行,JVM就会创建一个栈帧,里面包含局部变量表、操作数栈、动态链接、方法返回地址等信息。局部变量表存放方法参数和方法内定义的局部变量,它的大小在编译期就已经确定;操作数栈是字节码指令的工作区,执行iadd这种运算时,JVM会从操作数栈弹出两个数,计算完再压回去。

如果线程请求的栈深度超过了JVM允许的最大深度,就会抛出StackOverflowError。典型的触发场景是无限递归。栈的大小通过-Xss参数调整,默认值在大多数平台上大约是512KB到1MB。需要注意的是,栈太大会导致能创建的线程数量变少,因为线程栈属于原生内存,受操作系统进程地址空间限制。我调过一些高并发服务,线程数动辄几千,这时候每个线程的栈少一点,整体内存占用差别会很大。

本地方法栈和虚拟机栈职责类似,区别在于它服务于native方法。HotSpot虚拟机做了个取巧的操作:把本地方法栈和虚拟机栈合并成同一个,所以你在HotSpot的线程转储里看不到单独的本地方栈区域。但这只是实现细节,不同JVM可以有自己的实现方式。

3.2 线程共享区:堆和方法区

Java堆是所有线程共享的最大一块内存,几乎所有的对象实例和数组都在这里分配。堆是垃圾回收的主战场,所以也常被称为“GC堆”。

方法区用于存放已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。很多人对方法区的理解容易和“永久代”混在一起。这里有个历史演变必须说清楚:

JDK 7及之前,方法区的实现叫永久代(PermGen),用的是JVM堆内的内存。-XX:MaxPermSize用来设置永久代上限。永久代的大小很难预估,如果你频繁动态生成类(比如CGLIB代理),很容易把永久代打满,抛出java.lang.OutOfMemoryError: PermGen space

JDK 8之后,方法区的实现换成了元空间(Metaspace),不再使用JVM堆内存,而是直接使用本地内存。默认情况下元空间不受JVM堆大小限制,理论上可以用完机器的物理内存。这带来两个实际影响:一是PermGen space错误消失,取而代之的是Metaspace溢出;二是需要关注-XX:MaxMetaspaceSize这个参数,防止元空间无限增长拖垮操作系统。另外,字符串常量池在JDK 7里就从永久代移到了堆中,所以现在面试问“字符串常量池在哪”,答案是“在堆里”,不是“在方法区里”。

3.3 堆里再分代:新生代与老年代,以及常见OOM现场

堆内部会进一步分代,这是垃圾回收策略的基础。以HotSpot为例,堆被划分为年轻代和老年代。年轻代又分成一个Eden区和两个Survivor区(From和To,也叫S0和S1)。默认的Eden区远大于Survivor区,比例可以通过-XX:SurvivorRatio调整,默认是8,即Eden占年轻代的80%。

为什么要这样分?因为绝大多数对象“朝生夕灭”——在方法里临时创建的对象用完就没人引用了。把它们集中放在Eden区,垃圾回收时只需要清扫一小块区域,效率很高。只有当对象经历若干次回收仍然存活,它的“年龄”增长并超过阈值(默认15,-XX:MaxTenuringThreshold),才会被挪到老年代。这种设计背后是分代假说:熬过越多次GC的对象,越不容易死。

了解了分代,再看常见的OOM就很有画面感了。java.lang.OutOfMemoryError: Java heap space就是堆满了,创建新对象找不到足够空间,最常见的原因是内存泄漏或者堆设置过小;java.lang.OutOfMemoryError: Metaspace是元空间被类元数据撑爆;还有java.lang.OutOfMemoryError: unable to create new native thread,这往往和线程数、栈内存、操作系统ulimit限制有关,不属于标准的运行时数据区,但排查时很难绕开。

3.4 查看和配置内存的命令实战

排查内存问题,绕不开几个JDK自带命令。这里分享一套我常用的操作流程。

先通过jps -l找到Java进程的PID:

jps -l 12345 my-app.jar

然后查看堆配置和各区使用情况:

jstat -gcutil 12345 1000

这条命令每隔1秒输出一次GC统计信息,包括Eden、S0、S1、老年代、元空间的使用百分比,以及各代的GC次数和耗时。我判断一个服务是否需要调优,通常先跑几分钟jstat,观察老年代增长曲线是否稳定。如果老年代占用持续上升且伴随频繁Full GC,基本可以确认有问题。

查看完整的堆配置可以用jmap -heap

jmap -heap 12345

在JDK 8里这条命令很好用;JDK 11之后推荐使用jhsdb jmap --heap --pid 12345。需要注意的是,jmap是重量级工具,生产环境执行会触发Stop The World,建议在低峰期操作,或者用jcmd 12345 GC.heap_info替代,影响更小。

常规内存参数我列一张表,这张表也是面试里“JVM调优”问题的核心答案:

参数作用示例
-Xms堆初始大小-Xms2g
-Xmx堆最大大小-Xmx2g
-Xmn年轻代大小-Xmn512m
-Xss线程栈大小-Xss256k
-XX:MetaspaceSize元空间初始大小-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize元空间上限-XX:MaxMetaspaceSize=512m
-XX:SurvivorRatioEden与Survivor比例-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold晋升老年代年龄阈值-XX:MaxTenuringThreshold=15

一个非常常见的问题:为什么建议把-Xms-Xmx设置成一样?因为如果初始堆小于最大堆,JVM会在堆使用率升高时申请扩充内存,在GC后又尝试缩水。堆扩容和缩容的过程会触发多次Full GC,对应用有可见影响。生产环境直接把初始值和最大值设成一样,让堆大小保持稳定,省去无谓的GC停顿。

还有一个热搜词让我印象很深:expiring daemon because jvm heap space is exhausted。这是Gradle构建时的经典报错,本质上是构建守护进程的JVM堆设置太小。解决办法是在gradle.properties里加上:

org.gradle.jvmargs=-Xmx2048m

这比去改代码快得多,也是“JVM堆空间耗尽”在构建工具场景下的典型解法。

4. 从源码到机器码:类加载机制、字节码指令与JIT编译

4.1 Class文件的二进制结构:魔数、常量池与Code属性

如果说第3节是站在“外面看”JVM,这一节要钻进.class文件内部。很多人写了多年Java,却从没打开过Class文件看一眼,其实里面的结构非常规整。

.class文件是一段以0xCAFEBABE开头的二进制数据,这个魔数用来告诉JVM“这是一个合法的Class文件”。接下来是次版本号和主版本号,主版本号决定了这个Class文件能在哪个版本的JVM上运行。比如主版本号52对应Java 8,55对应Java 11,61对应Java 17。如果你用JDK 17编译的程序丢到JDK 8的JVM上跑,会看到UnsupportedClassVersionError,原因就在这里。

文件的核心部分是常量池,它保存了类名、方法名、字段名、字符串字面量、符号引用等所有常量。随后是访问标志、字段表、方法表、属性表。方法表里最关键的属性是Code,它存放了方法对应的字节码指令列表、异常表、局部变量表描述等信息。

理解Class文件的结构有什么实际用处?至少它让你明白一件事:Java的“编译”并不是把代码变成机器码,而是先变成一种平台无关的中间格式。真正把字节码变成机器码的工作,延迟到了JVM内部执行阶段完成。这种设计让类文件可以在任何实现了JVM规范的环境中运行。

4.2 基于操作数栈的字节码:用javap看一个方法

字节码指令集是JVM的“汇编语言”,它和x86/AArch64这类寄存器指令集最大的区别是:大部分指令都在操作数栈上完成计算,指令本身不指定具体寄存器。这种方式让字节码可以跨平台,代价是比寄存器指令集多做几次压栈和弹栈操作。

拿一个最简单的加法方法举例:

public int add(int a, int b) { return a + b; }

编译后用javap -c反汇编:

javap -c Add.class

输出:

public int add(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: ireturn

逐条解释:iload_1把局部变量表里下标为1的int类型值(也就是第一个参数a)压入操作数栈,iload_2同理压入biadd从栈顶弹出两个int相加,把结果压回栈顶;ireturn弹出栈顶int值作为方法返回值。整个流程完全围绕操作数栈展开,没有出现任何物理寄存器编号。

字节码指令集的话题可以延伸到面试里的“指令集架构”区分:JVM属于基于栈的指令集架构,相对于ARM/x86这些基于寄存器的指令集,字节码更紧凑、更易跨平台,但执行时需要更多栈操作。这也是为什么JIT编译对Java性能如此重要——它会把基于栈的字节码优化成高效的机器码,抵消掉栈式执行的部分开销。

4.3 类加载三阶段与双亲委派模型

类加载从JVM角度看,真正干的活可以拆成三个阶段:加载、连接、初始化。

加载阶段,JVM通过类加载器的findClass方法找到对应的.class字节流,把它读进内存,生成一个java.lang.Class对象作为访问入口。

连接阶段又分成三步。验证是检查字节流格式是否符合Class文件规范、语义是否合法,这是安全的第一道防线;准备是为静态变量分配内存并赋默认零值。这里有个经典面试陷阱:如果一个类里有public static int value = 100,准备阶段结束之后value的值是0,不是100。真正赋值100要等到初始化阶段;解析是把常量池中的符号引用替换为直接引用,比如把“java/lang/String”这个符号解析为实际的内存地址或句柄。

初始化阶段执行<clinit>()方法,也就是所有静态变量的赋值语句和静态代码块。这里需要注意:JVM规范对初始化时机有严格限定——遇到new指令、读写静态字段、调用静态方法、反射访问类、初始化子类时父类未初始化等情形,才会触发初始化。

类加载器这块,JDK 8及之前是三层的规矩:启动类加载器(Bootstrap)负责加载JAVA_HOME/lib下的核心类;扩展类加载器(Extension)负责加载JAVA_HOME/lib/ext下的类;应用类加载器(Application)负责加载classpath下的类。JDK 9模块化之后,扩展类加载器被平台类加载器(Platform)取代,负责加载一些平台模块。

双亲委派模型是面试必问点:任何一个类加载器收到加载请求时,都会先把它交给父加载器去处理,父加载器处理不了,子加载器才自己动手。这样做的核心目的是防止核心类被篡改。试想如果你在classpath里放一个自己写的java.lang.String,应用类加载器会先把请求委托给父加载器,一直委托到启动类加载器,而启动类加载器已经在JDK里加载过真正的java.lang.String了,它不会再加载你这个。这保证了JVM里永远只有一种String实现。

4.4 执行引擎:解释器与JIT编译器如何配合

类加载到内存之后,真正跑起来靠的是执行引擎。执行引擎有两种执行方式:解释执行和编译执行。

解释执行是指解释器逐条把字节码翻译成本地机器码然后执行,优点是启动快、无需等待编译,缺点是每条字节码都要经历翻译过程,整体执行慢。编译执行是指JIT(Just-In-Time)编译器在运行时把整个方法编译成本地机器码,后续调用直接执行机器码,执行效率高很多。

HotSpot虚拟机采用分层编译的策略来兼顾启动速度和峰值性能。方法刚被调用时先用解释器跑,同时JVM通过方法调用计数器和回边计数器统计热点。当一个方法调用次数超过阈值(-XX:CompileThreshold,默认约10000次),JIT编译器就把这个方法编译成本地代码。这带来的一个现象是:Java程序刚启动时性能一般,跑一段时间“热起来”之后性能才上去。

HotSpot里有两代JIT编译器,C1(Client Compiler)编译速度快、生成的代码优化程度相对轻,C2(Server Compiler)会做更深度的优化,比如方法内联、逃逸分析、锁消除。分层编译下,方法先用C1快速编译,达到更高阈值后再用C2甚至Graal重新编译。

JIT最吸引我的优化之一是逃逸分析。如果JVM分析出某个对象不会被方法外部引用,即“不逃逸”,它可以把对象拆成若干标量直接分配到栈上,而不是堆上,这样对象随方法弹出栈自动销毁,完全不用GC参与。很多开发者在网上看到“逃逸分析能消除synchronized锁”的说法,其实现代JVM确实可以在确认对象不会被多线程访问时进行锁消除,不过这些都是JIT根据运行信息自动判断的,不需要也不应该在代码里手动“优化”。

5. 垃圾回收从理论到实战:GC Roots、分代收集与收集器选型

5.1 怎么判断垃圾:从引用计数到可达性分析

开GC这一节之前先打个预防针:网上关于GC的“玄学”帖子太多了,动不动就是“调优让GC次数降低99%”,其实大多数场景下GC调优是收益递减的。想真正理解GC,还是得从最底层的判定逻辑开始。

判断一个对象是否“该死”,经典的方案之一是引用计数法:每个对象维护一个引用计数器,被引用一次计数器加1,引用失效计数器减1,计数器为0就意味着没人引用了。听起来很直观,但这种方案有个致命缺陷——循环引用。A引用B、B引用A,但外部没人再引用这两个对象,它们的计数器却都不是0,永远不会被回收。JVM的主流实现没有用纯引用计数法,而是采用可达性分析。

可达性分析的思路很像一个感染传播过程:JVM从一组被称为GC Roots的根节点出发,沿着对象之间的引用关系向下遍历,能被遍历到的对象就是“活”的,遍历不到的就会被判定为可回收。用“根节点”这个词很形象,只有从根出发有路径可达,这个对象才算是活在JVM的引用网络里。

GC Roots包括哪些对象?主要四类:

  • 虚拟机栈中引用的对象,也就是当前所有活跃方法局部变量和参数引用到的对象。
  • 方法区中静态属性引用的对象,也就是static字段指向的对象。
  • 方法区中常量引用的对象,比如字符串常量池里的引用。
  • 本地方法栈中JNI引用的对象。

需要注意的是,对象被标记为可回收不等于立刻被清掉。HotSpot里对象至少还有一次“自救”机会:finalize()方法在回收前会被调用一次(如果对象重写了它)。不过finalize()机制在JDK 9之后已经被标记为废弃,影响面很大却不建议使用,最好直接忘掉这个方案,用Cleaner或者try-with-resources做资源释放。

5.2 分代收集与GC类型:Minor、Major、Full、Mixed

确定了哪些对象是垃圾之后,怎么收集它们?分代收集理论是HotSpot的默认策略:根据对象的存活周期不同,把堆分为年轻代和老年代,不同代用不同策略。

年轻代回收称为Minor GC(也叫Young GC)。对象优先分配在Eden区,Eden满后触发Minor GC,把存活对象复制到Survivor区。复制算法在“存活对象少、垃圾多”的年轻代场景下非常高效,因为它只需要遍历存活对象。每熬过一次Minor GC,对象年龄加1,超过阈值进入老年代。Minor GC的过程里还会处理一个情况:大对象(比如大数组)会直接进入老年代,避免在年轻代反复复制,控制参数是-XX:PretenureSizeThreshold,但这只对Serial和ParNew收集器有效,G1有自己的大对象分配机制(humongous region)。

老年代回收称作Major GC或者Full GC。Major GC针对老年代回收,Full GC通常意味着对整个堆(年轻代+老年代)做回收。每次Minor GC之前,JVM会估算老年代的剩余空间能不能容纳即将晋升的对象,如果不够,就可能提前触发Full GC。这解释了为什么你经常看到“Minor GC伴随Full GC”的现象——年轻代回收引发的晋升压力传导到了老年代。

G1还引入了一种Mixed GC,它不只是回收年轻代,还会把一部分老年代Region纳入回收范围,目标是控制停顿时间的同时推进老年代回收。理解了这几种GC类型的区别,再看GC日志就不至于一头雾水。

5.3 主流回收器演进:Serial到G1再到ZGC

垃圾收集器是GC的具体执行者,HotSpot历史上出现过很多种,对它们的最初印象可以整理成一张表:

收集器工作模式适用场景状态
Serial单线程,STW客户端小应用、单核环境老古董
ParNewSerial的多线程版配合CMS使用已被G1取代
Parallel Scavenge / Parallel Old多线程,吞吐量优先后台批处理、大吞吐场景JDK 8默认
CMS并发标记清除对停顿敏感的服务端JDK 9废弃,JDK 14移除
G1Region化,可预测停顿多核大内存服务端JDK 9+默认
ZGC并发,超低停顿超大堆、极低延迟JDK 15转正

Serial和Parallel算第一梯队,核心思路是“回收时暂停所有用户线程”(Stop The World,STW),差别只是一个单线程一个多线程。Parallel系列优先考虑吞吐量,也就是“花更多时间在业务计算上,而不是GC上”,适合不介意偶发秒级暂停的后台任务。

CMS是第一代面向低延迟的收集器,采用“标记-清除”算法,试图让回收和用户线程并发执行。可惜它有两个天生毛病:一是会产生内存碎片,碎片多了无法分配大对象,最终触发Full GC;二是并发收集阶段CPU竞争严重,在老年代回收的同时业务线程也变慢了。JDK 9果断把它标记为废弃,JDK 14直接移除。

G1是现在最主流的默认收集器。它的创新在于把整个堆划分成一个个大小相等的Region(默认约2048个),不再严格区分连续的年轻代和老年代,而是让它们以Region为单位交织分布。G1通过追踪每个Region的回收价值(能回收多少垃圾、预计耗时多少),维持一个“可预测的停顿时间模型”,-XX:MaxGCPauseMillis默认200毫秒,G1会在这类目标约束下挑选收益最高的Region来回收。

ZGC是面向超大堆低延迟的下一代方案,它把STW时间控制在毫秒甚至亚毫秒级。核心技巧包括染色指针(Colored Pointers)和读屏障,用指针上的标记位记录对象状态,让大部分回收工作与应用线程并发完成。如果你面对的是几十GB甚至TB级堆,且对延迟极其敏感,ZGC值得一试。

5.4 调优参数与GC日志解读

调优之前必须先会看日志。JDK 9之后GC日志参数大改版,老参数-XX:+PrintGCDetails已经废弃,新语法是:

-Xlog:gc*:file=gc.log:time,uptime,level,tags

这条命令会输出包含GC事件的日志文件,带上时间戳和标签。我拿一段真实日志片段解释怎么读:

[2025-01-10T12:00:01.123+0800][gc,ihop] GC(0) Ignoring stale humongous candidates [2025-01-10T12:00:01.456+0800][gc,start] GC(0) Pause Young (Concurrent Start) [2025-01-10T12:00:01.789+0800][gc,mmu] GC(0) MMU changed from 100.00% to 99.37% [2025-01-10T12:00:02.001+0800][gc,phases] GC(0) Eden: 1024.0M->0.0M

第一行Pause Young表示这是一次年轻代停顿;Eden: 1024M->0M说明Eden区被清空,逻辑上符合“年轻代回收后垃圾被清掉”的预期。如果日志里出现频繁的Pause Full,说明老年代或元空间已经撑不住,需要认真排查。

调优参数方面,我先给出几个高频项,然后说我的原则:

参数作用
-XX:+UseG1GC使用G1收集器(JDK 9+默认)
-XX:MaxGCPauseMillis=200G1目标停顿时间
-XX:G1HeapRegionSize=16mG1 Region大小
-XX:+ParallelGCThreads=8并行GC线程数
-XX:+DisableExplicitGC禁止System.gc()触发Full GC
-XX:+HeapDumpOnOutOfMemoryErrorOOM时自动导出堆转储
-XX:HeapDumpPath=/tmp/app.hprof堆转储输出路径

我不太赞成上来就堆一堆奇怪的参数,大多数线上服务用默认G1参数加上合理的-Xms/-Xmx就够了。真正需要动手调优的信号很明确:GC日志显示单次停顿超过预期、Full GC频率持续升高、应用吞吐量有明显下降。调节奏应该是“先确定堆大小,再调整停顿时间目标,最后才是拿Region大小和晋升阈值做精准手术”。另外强烈建议永远加上-XX:+HeapDumpOnOutOfMemoryError,这样线上OOM时能留下现场证据,否则排查内存泄漏会变成无头悬案。

6. 把这些原理变成面试得分点:高频JVM问题实测梳理

6.1 两张高频考点图:内存模型与类加载流程

面试里最常出现的JVM问题,翻来覆去就是那么几个。我自己面试别人时,第一题通常问“JVM内存模型”,因为这道题的答案能直接反映候选人对运行时数据区的熟悉程度。答题要拿分,得按这个层次来:

第一层先把五块区域说出来:程序计数器、虚拟机栈、本地方法栈、Java堆、方法区(元空间)。第二层说清楚哪些线程私有、哪些线程共享:程序计数器和栈是线程私有的,堆和方法区是线程共享的。第三层补一句关键区分:栈存方法调用信息,堆存对象实例,方法区存类元数据。第四层如果还有余力,举一个例子,比如“栈里栈帧有局部变量表和操作数栈,方法里定义的对象引用在栈上,对象本身在堆上”。能答到第四层的基本就能过。

类加载机制是第二个必考点。答题顺序推荐按五个阶段来:加载、验证、准备、解析、初始化。准备阶段别忘了强调静态变量先赋默认零值,初始化才赋真实值。然后补充双亲委派模型,讲清楚为什么要父加载器优先。如果能把“打破双亲委派”的场景说出来(SPI机制、Tomcat的Web应用类加载器),这道题就是加分项。

6.2 双亲委派:能打破吗?在哪里打破?

面试官最喜欢在双亲委派后面追问一句:“双亲委派能不能打破?怎么打破?”

答案是可以打破,而且Java生态里早就有人在打破。最经典的场景是SPI(Service Provider Interface)。你用的DriverManagerServiceLoader都涉及一个矛盾:这些类由启动类加载器加载,但它们需要调用第三方厂商的实现类(比如MySQL驱动),而第三方实现类在classpath下,由应用类加载器加载。如果严格遵守双亲委派,启动类加载器根本没机会加载到第三方实现。

JDK的解决方案是把“查找实现类”的线程上下文类加载器(Thread Context ClassLoader)传给ServiceLoader,让应用类加载器去加载实现类。这相当于绕过了父加载器的查询路径,是Java标准库自己打破双亲委派的一个著名例子。

另一个更直观的场景是Tomcat。一个Tomcat能同时跑多个Web应用,每个应用可能有自己版本的Spring和依赖。如果都用同一个应用类加载器,类就会被共享,互相污染。Tomcat为每个Web应用创建独立的类加载器,优先加载当前Web应用WEB-INF/classesWEB-INF/lib下的类,而不是先委托给父加载器。所以面试被问到“哪里打破了双亲委派”,答“JDBC驱动加载时的线程上下文类加载器,以及Tomcat的Web应用类加载器”基本就稳了。

6.3 OOM排查与Full GC优化实战

面试里另一个高发题是“线上Full GC频繁,你怎么排查”。这个问题考察的不是背诵能力,而是真实的排障思路。我通常按四步走:

第一步,先看监控确认现象:老年代是否持续增长、Full GC频率和单次停顿时间有没有明显变化。没有监控时用jstat -gcutil <pid> 1000观察,重点关注老年代使用率的变化曲线。

第二步,获取堆转储快照,用命令或者配置-XX:+HeapDumpOnOutOfMemoryError。拿到.hprof文件后用MAT或JVisualVM分析,按Dominator Tree看谁占用了最多的堆空间。内存泄漏案例里,最经典的对象往往是java.util.HashMapArrayList或者各种连接池缓存。

第三步,通过线程转储(jstack)对比快照,找到持有大对象引用的可疑业务代码。很多时候,罪魁祸首是“一个被当成了缓存但没有清理机制的Map”,或者是一次批量查询把几十万行数据加载进内存。

第四步,定位后修复。常见修复思路包括:给对象生命周期加显式清理、限制集合大小、分批查询、改用弱引用。如果是代码没问题纯粹堆太小,那就调大-Xmx,但这属于“止痛”,不是“治病”。

这套排查流程我在真实项目中走完过无数遍,也见过太多“直觉派”程序员——不看快照,全靠猜,最后把一个简单泄漏问题拖成通宵难题。排障最忌讳猜,数据不会骗人。

6.4 几个容易被“一问就崩”的细节

最后补充一些我面试时经常用来“试探深浅”的细节题,几乎每个都能筛掉一批人。

第一个是“JDK 8默认的垃圾回收器是什么”。很多人下意识答G1,但正确答案是Parallel Scavenge + Parallel Old,也就是ParallelGC,默认目标是最大化吞吐量。G1是从JDK 9才开始成为默认收集器的。如果候选人在这个问题上栽了,说明他对JDK版本的演进没有概念。

第二个是“String.intern()之后字符串存在哪”。这题考方法区和堆的边界理解。JDK 7之前字符串常量池在永久代,JDK 7及之后被移动到堆中。一个由new String("abc").intern()产生的字符串,实际存储在堆的字符串常量池里。

第三个是“System.gc()会立刻触发Full GC吗”。答案分两层:会向JVM发出GC请求,但JVM不一定立刻执行,HotSpot通常会进行一次Full GC,但不保证。生产环境一般建议用-XX:+DisableExplicitGC禁掉显式调用,防止框架无意中触发Full GC。

第四个是“StackOverflowErrorOutOfMemoryError分别发生在哪”。前者出现在线程栈深度超限,比如无限递归;后者分类比较多,堆满、元空间满、无法创建线程都属于它。这道题看起来简单,但能同时准确说出栈异常和堆异常的区别,就说明对运行时数据区的理解不是背出来的。

这些小细节单独看都不难,难得是能把它们串联成一个体系。我也一直跟身边的同事说,JVM知识靠突击背题很容易忘,最好的办法是带着问题去线上环境观察:看到一次Full GC就去翻GC日志,看到一次OOM就去分析堆转储,几次下来,内存模型和垃圾回收这块就彻底长在脑子里了。

写到这里,这篇文章基本把OpenJDK术语、JVM架构、内存模型、类加载、字节码执行、垃圾回收再到面试实战这条线完整串了一遍。我的建议是不要把它们当孤立的知识点去背,最好按照“一段代码从运行到被杀掉”的完整叙事去理解:源码被javac编译成Class文件,类加载器把它拉进内存,运行时数据区给它腾出位置,执行引擎把字节码翻译成机器码,GC在后台清理对象。这条主线建立起来之后,JVM对你来说就不再是一堆术语的排列组合了。接下来如果你想继续深入,可以找个线上服务打开GC日志,对照这篇文章里的概念慢慢看,你会发现那些参数和名词都变成了一个又一个真实的故事。

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

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

立即咨询