这段时间帮组里的新人做晋升辅导,发现一个特别普遍的问题:JVM的基础概念大家都能说个大概,但一往深处追问就会卡壳。比如都知道堆是放对象的,但问一句"所有对象一定在堆上分配吗?"就开始含糊;知道有垃圾回收,但问"Full GC的时候其他线程在干什么"又答不上来。这让我也决定把JVM完整地重新过一遍,于是便有了这篇复习笔记。
这篇笔记计划分上下两篇。上篇围绕运行时数据区域、类加载机制、对象分配三条主线展开,把JVM最底层的"骨架"搭清楚;下篇再安排垃圾回收、故障排查与调优实践。内容除了概念本身,更多的是一些容易在面试中暴露出来、平时写代码又很难察觉的细节。如果你正在准备JVM相关的面试,或者刚入职不久想系统建立对JVM的认知,这篇笔记应该能帮你省不少事。
需要提前说明的是,文章中涉及的JVM参数、默认行为基本都是围绕较常见的HotSpot虚拟机、JDK 8及以上版本来讲的。版本不同会有细节差异,这一点在复习时也要留个心眼。
1. JVM复习总纲:先搞清楚它到底解决了什么问题
1.1 跨平台只是最表面的答案
很多人一上来就说JVM的价值是实现"一次编译,到处运行",这话没错,但远不够。跨平台只是JVM附带的一个结果,它真正做的事,是构筑了一道从操作系统到Java代码之间的"中间层"。
你看C/C++写的程序,编译产物是机器码,直接跟CPU架构、操作系统绑定。而Java程序编译出来的是字节码,字节码不面向任何具体的CPU,它面向的是JVM这套抽象的指令集规范。真正把字节码翻译成当前机器能执行的指令的人,是JVM的即时编译器(JIT)和解释器。
这道中间层带来的好处不仅仅是跨平台,还在于它把所有和操作系统打交道的脏活累活都收拢到了一起:内存管理、线程调度、锁的适配、文件IO的系统调用封装……这些事全部交给JVM去适配当前平台,写Java的人面对的是一个统一的抽象。这也是JVM能发展出那么多高级特性的前提——如果不是Java代码最终运行在一个可被JVM全面接管的环境里,像垃圾回收自动管理内存这种能力是没法实现的。
理解了这一层,再看JVM里的各种设计,思路会通畅很多。所有机制的本质,都是在"屏蔽底层差异"和"提高执行效率"这两个目标之间做平衡。
1.2 理清JDK、JRE、JVM三者关系
热搜里有人搜"jre和jvm之间的关系",说明这块确实容易被绕晕。用一句话概括:JDK是Java开发工具包,JRE是Java运行环境,JVM是Java虚拟机。三者的关系就像工厂流水线、车间和核心机器的关系。
| 名称 | 包含内容 | 作用 |
|---|---|---|
| JDK | JRE + 编译器javac + 调试器jdb + 各种诊断工具(jps、jstat、jmap等) | 面向开发者,既负责编译,也负责运行 |
| JRE | JVM + Java标准类库 + 基础运行所需的库文件 | 面向运行者,只负责把编译好的class文件跑起来 |
| JVM | 运行时数据区域、执行引擎、类加载器等 | 真正执行字节码的引擎 |
从JDK 9开始,官方调整了发布形态,引入了模块系统,但JDK、JRE、JVM这套逻辑关系本质上没变。你下载一个JDK安装包,里面就自带了运行所需的JRE;而JVM不管是包含在JDK里还是JRE里,永远是那个真正"干活"的引擎。
复习时我会特别提醒自己一件事:JVM是规范,HotSpot是规范的一个具体实现。面试时说漏嘴把HotSpot和JVM划等号虽然一般不会被揪着不放,但如果你能把两者的层次分清楚,会显得体系感强不少。
1.3 我复习时采用的主线逻辑
JVM的知识点非常多,如果零散地记忆,很容易看过就忘。我自己复习时把整个体系拆成三条主线,上篇先覆盖前两条,第三条放下一部分单独写:
- 数据放哪里:运行时数据区域的划分,以及每块区域的作用和异常表现。
- 数据怎么进来:类从字节码变成可以实例化的Class对象,类加载机制做了什么。
- 数据怎么回收:对象创建之后,JVM如何识别垃圾、如何回收、如何调优。
沿着这条主线,所有细节都能挂到对应的节点上。比如遇到一个OutOfMemoryError,你要能判断它是堆内存溢出、栈溢出、还是元空间溢出,然后对应到具体是哪块运行时数据区域出了问题。能完成这种"报错→知识点"的反向映射,说明这条主线才算真正建立起来了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 运行时数据区域拆解:JVM面试的根据地
2.1 六块核心区域的职责与异常对照
JVM在运行Java程序时,会把内存划分为若干个不同的数据区域,这是《Java虚拟机规范》里定义的基础结构。HotSpot在实现时对规范做了一些调整,比如把方法区用元空间来实现,但整体框架没有变。
这六块区域,每一块负责的事都不同,抛出的异常也不同。复习时最好能一边画图一边把它们的边界记清楚:
| 区域 | 作用 | 存储内容 | 异常 |
|---|---|---|---|
| 程序计数器 | 记录当前线程正在执行的字节码指令地址 | 当前执行位置 | 不会OOM |
| 虚拟机栈 | 每个线程运行时的栈帧存储区 | 局部变量表、操作数栈、动态链接、方法出口 | StackOverflowError / OOM |
| 本地方法栈 | 为native方法执行服务 | native方法调用状态 | StackOverflowError / OOM |
| 堆 | 对象实例和数组分配的主要区域 | 几乎所有对象 | OOM |
| 方法区 | 存储类型信息、常量、静态变量等 | 类元数据、运行时常量池 | OOM |
| 直接内存 | 堆外内存,NIO使用 | 基于Channel和Buffer的读写分配的内存 | OOM |
这里面最容易混淆的是虚拟机栈和本地方法栈。简单记忆:Java方法调用走虚拟机栈,native方法调用走本地方法栈。在HotSpot的实现中,两者其实是一个栈,但规范层面是分开定义的。
堆和栈是复习的重头戏。堆是线程共享的,栈是线程私有的。栈的每次方法调用会生成一个栈帧,栈帧里最核心的是局部变量表和操作数栈——局部变量表存方法参数和内部定义的局部变量,操作数栈则像一个临时的"计算台",字节码指令要从局部变量表取值压到操作数栈,做完计算再写回去。
2.2 栈深度与栈帧:递归为什么会StackOverflow
很多人写递归被StackOverflowError坑过,这个报错的本质就是虚拟机栈的深度超出了JVM允许的最大值。每次方法调用都会生成一个栈帧压入当前线程的虚拟机栈,栈是有容量上限的,递归深度太深,栈帧太多,栈空间装不下了,就会抛这个错。
默认情况下HotSpot的栈容量大小是1MB(平台不同有差异),可以通过-Xss参数调整。栈的大小并非越大越好——它属于线程私有的内存,线程数越多,总的内存开销就越大。一台机器上如果线程数量非常多,每个线程的栈都设置得很宽,光是栈空间就可能把内存吃光。
复习时我建议做一个"画栈帧"练习:自己写一段简单的方法调用关系,比如A调B、B调C,然后把每一层栈帧里的局部变量表和操作数栈的状态画出来。这个方法看起来很笨,但对理解JVM执行模型特别有效,远胜过死记硬背那些概念。
2.3 "JVM内存结构"和"Java内存模型"是两码事
这是面试中最容易踩坑的一个混淆点,热搜词里"jvm内存模型"热度很高,但很多人在搜索这个词时,其实自己也不确定搜的到底是哪个概念。
简单区分:
- JVM内存结构(也叫运行时数据区域):讲的是数据存放在哪里,就是上面那张表格里的六块区域。
- Java内存模型(JMM):讲的是多线程环境下,共享变量的可见性问题、有序性问题和原子性如何被规范约束。它定义了一套抽象关系:主内存、工作内存,以及volatile、synchronized、happens-before这些规则。
举一个典型例子:volatile关键字相关的知识点,全部属于JMM的范畴,跟JVM内存结构里的哪块区域存volatile变量没有关系。如果面试官问"讲讲内存模型",建议先反问一句你指的是运行时区域还是JMM,既避免答偏,也显得你对这两个概念有清晰的边界意识。
JMM的底层又涉及CPU缓存、重排序、内存屏障等知识,这一块我建议放到并发编程专题里深入复习,在JVM基础篇里只需要做到"分得清两个概念"即可。
2.4 从内存区域推导OOM排查方向
面试里"内存溢出怎么排查"基本是必考题。从运行时数据区域的角度看,不同的OOM其实指向不同的区域,排查方向截然不同。
复现一下排查思路:
java.lang.OutOfMemoryError: Java heap space:堆内存溢出。优先考虑是对象太多、内存泄漏还是堆分配不够。用jmap dump堆快照、配合MAT或VisualVM分析实例占用。java.lang.OutOfMemoryError: Metaspace:元空间溢出。常见于动态生成类太多(比如热部署、CGLIB代理疯狂生成类),可以调大-XX:MaxMetaspaceSize,但更重要的是查生成类的源头。java.lang.StackOverflowError:栈溢出。查递归调用、无限循环调用。java.lang.OutOfMemoryError: Direct buffer memory:直接内存溢出。常出现在Netty这类使用NIO的框架里,排查堆外内存的分配。
这套对应关系建立起来之后,遇到OOM不会慌。你会条件反射式地先看异常信息里的区域关键词,再决定用哪一类工具去查,而不是一股脑把jmap、jstack全招呼上去乱试一通。
3. 类加载机制:连接字节码与运行时世界的桥
3.1 加载-验证-准备-解析-初始化五个阶段里的重点
类从磁盘上的.class文件变成可以被程序直接使用的Java对象,中间经历了完整的生命周期。规范里把它拆成五个阶段,面试高频考查的地方集中在"准备"和"初始化"这两个容易模糊的阶段。
加载阶段要干三件事:根据类的全限定名去找到二进制字节流;把字节流转换成方法区里的运行时数据结构;在堆里生成一个对应的Class对象作为访问入口。这里的"二进制字节流"不一定非得是磁盘里的.class文件,也可以来自网络、动态代理生成、数据库读取等等。
验证阶段是在安全层面把关,检查文件格式、字节码语义合法性等。准备阶段就很容易踩坑了:它会给类的静态变量分配内存并设置初始零值。比如你写了static int a = 100,在准备阶段结束后,a的值是0而不是100;真正把100赋值给a,要等到初始化阶段的<clinit>()方法执行时。例外是static final常量,如果在编译期就能确定值,准备阶段直接完成赋值。
解析阶段是把常量池里的符号引用替换成直接引用。初始化阶段是执行类构造器<clinit>(),它由静态变量赋值语句和静态代码块合并组成,并且JVM会保证在初始化一个类之前,先初始化它的父类。
3.2 双亲委派模型:为什么类要被一层层往上传
类加载器的层次结构是复习JVM绕不开的内容。从顶层往下分别是:启动类加载器(Bootstrap,负责加载JDK核心类库)、平台类加载器(Platform,JDK 9之后替代了扩展类加载器)、应用类加载器(Application,负责加载classpath下的类)。
双亲委派模型的工作规则一句话就能说清:当一个类加载器收到加载请求时,它不会自己先去加载,而是把这个请求委派给父加载器,每一层都是如此,直到传到启动类加载器。只有在父加载器反馈自己无法完成加载时,子加载器才会尝试自己动手。
这样做的核心价值是稳定性。举个例子:如果你自己在classpath里放了一个java.lang.String,双亲委派机制会让启动类加载器优先加载JDK自带的String,你自定义的这个类根本没机会被加载。这就防止了核心API被篡改。如果每个加载器都各加载各的,同一个类被不同加载器加载出多个版本,整个类型体系就乱套了。
关于双亲委派,我建议记住一个面试加分点:"双亲"这两个字容易给人误导,以为存在"父→子"的强制继承关系。实际上加载器之间是组合关系,并不是继承关系,在实现层面父加载器是在子加载器调用loadClass时被向上请求的。
3.3 打破双亲委派的几个真实场景
双亲委派模型并不是一个绝对不可打破的铁律。面试时能说出下面两三个场景,基本就能体现出你不仅知道概念,还知道它的边界在哪。
第一个典型是SPI机制。JDBC就是一个例子,java.sql.DriverManager在启动类加载器加载的rt.jar里,但实际的数据库驱动实现却打在了应用classpath下。启动类加载器根本不可能通过向上委派找到这些驱动类。解决办法是引入线程上下文类加载器,让核心类在需要时通过Thread.currentThread().getContextClassLoader()来反向使用应用类加载器加载服务实现。这等于把双亲委派的请求方向做了个"逆向操作"。
第二个场景是Tomcat这类Web容器。一个Tomcat实例往往会部署多个Web应用,每个Web应用的类需要隔离,不互相污染,还要支持热部署(卸载一个Web应用时,它加载的类要能被回收)。如果所有类都堆在同一个应用类加载器里,根本无法实现这些需求。Tomcat的做法是为每个Web应用创建独立的WebAppClassLoader,它先自己尝试加载WEB-INF/classes下的类,加载不到再向上委派。
第三个是OSGi模块化系统,它实现了更彻底的类加载器网状结构。这个场景平时工作接触得少,了解即可,能把前两个说清楚已经足够应付大多数面试了。
3.4 两个长得像但完全不同的异常
ClassNotFoundException和NoClassDefFoundError,这两个名字很容易弄混,我也见过不少人在简历里把两者混为一谈。它们最本质的区别是:前者是Exception,可以恢复;后者是Error,通常意味着JVM层面的错误。
ClassNotFoundException发生在类加载过程最前面的"查找"环节,常见于反射加载类时写错了全限定名,或者classpath下缺少对应的jar包。它是可以被代码捕获并处理的。
NoClassDefFoundError则更麻烦:它意味着类在编译期是存在的,类加载过程已经走过了某个阶段,但在真正的使用阶段发现类的定义丢失了。典型场景是项目里某个类在静态初始化时抛了异常,导致JVM标记这个类初始化失败,之后再有人想用这个类时就会报这个Error,而真正的根因往往被前面那个异常吞掉了。
排查NoClassDefFoundError时,我的习惯是先翻完整堆栈,找最底层的caused by,而不是盯着NoClassDefFoundError这行字本身。十次里有九次,真正的问题是一个静态块抛出的异常,或者是一个依赖的jar缺失。
4. new一个Java对象背后发生了什么事
4.1 从类加载检查到内存分配:对象的完整出生流程
写代码时一个new关键字毫不起眼,但JVM在背后做的事足够写篇长文。完整流程大概是这样的:
首先做类加载检查。当JVM遇到一条new指令时,它先去常量池中定位这个类的符号引用,然后检查这个类是否已经被加载、解析和初始化过。如果没有,要先走一遍完整的类加载流程。
接下来是分配内存。对象需要的内存大小在类加载完成后就已经可以确定了,分配方式有两种:如果堆内存规整,使用"指针碰撞",直接把指针往空闲方向挪动与对象大小相等的距离;如果堆内存不规整,使用"空闲列表",从列表里找一块足够大的空间分配给对象。
创建对象是一个非常高频的操作,并发场景下必须保证线程安全。JVM用了两种方案来解决:一种是对分配内存的动作做CAS同步处理;另一种更常用的思路是给每个线程在堆中预先分配一块自己的缓冲区——TLAB,线程在自己缓冲区里分配不需要同步。TLAB这个东西,在后面的调优里也会经常遇到。
内存分配完毕之后,JVM会将这块内存空间初始化为零值,这样对象的实例字段即使不赋初值也能有默认值(int默认0、boolean默认false之类)。然后是设置对象头,存储这个对象的哈希码、GC分代年龄、锁状态等信息。最后一步才是执行构造函数,按程序员写的代码初始化字段。
4.2 对象头、实例数据与对齐填充
一个对象在堆里的内存布局可以分为三部分:对象头、实例数据、对齐填充。
对象头包含两部分信息:第一部分叫Mark Word,存储对象自己的运行时数据,比如哈希码、GC分代年龄、锁状态标记;第二部分是类型指针,指向它的类元数据,JVM通过这个指针来确定对象是哪个类的实例。如果对象是一个数组,对象头里还必须有一块记录数组长度的数据,因为JVM无法通过元数据信息判断数组的大小。
实例数据是真正存储业务字段的地方,存储顺序受到字段声明顺序和虚拟机分配策略的影响。比如相同宽度的字段往往会被分配到一起,long和double这类64位类型通常排在前面。
对齐填充不是必然存在的,它只是一个"占位符"。HotSpot要求对象的大小必须是8字节的整数倍,由于对象头恰好是8字节的倍数,所以当实例数据部分加起来不满足对齐要求时,就会用对齐填充来补位。
这个知识点日常开发很难用到,但在估算缓存行、做性能优化或者使用sun.misc.Unsafe操作对象字段时,理解对象布局会非常有帮助。
4.3 逃逸分析、栈上分配与标量替换
回到开头那个问题:所有对象一定在堆上分配吗?答案是否定的。
现代JVM通过逃逸分析来判断对象的使用范围。如果一个对象被创建之后,只在当前方法内部使用,没有被传给其他方法,也没有被赋值给其他线程可见的变量,那它就没有"逃逸"出去。对于这类对象,JVM可以做栈上分配——直接在虚拟机栈的栈帧里给它分配空间,方法执行完,栈帧弹出,对象随之销毁,完全不需要垃圾回收介入。
栈上分配在HotSpot里的落地形式其实是标量替换。标量替换的做法更加激进:既然这个对象根本没有逃逸,那就干脆不要创建这个对象了,把这个对象的字段直接拆散成一个个独立的局部变量。对象聚合了多个字段,标量替换就是把这个聚合打散成原子变量来使用。
理解这个优化之后,再去看那些"循环里new对象会不会造成严重的GC压力"的问题,思路会开阔很多。对象如果只活在方法内部,经过JIT编译优化后,它可能根本没有在堆上真实存在过。这也是为什么JVM调优时不能只看表面代码,还要理解编译器可能做的各种激进优化。
4.4 对象访问定位的两种方式
要让程序使用对象,JVM需要建立栈上引用到堆中对象的访问通道。主流有两种实现方式:句柄访问和直接指针访问。
句柄访问的思路是在堆中划分出一块句柄池,栈帧里的引用变量指向句柄池中的一个句柄,句柄里才存储着对象实例数据的地址和类型数据的地址。好处是对象被移动(GC时很常见)时只需要改变句柄里的地址,引用变量本身不用动。坏处是每次访问对象都要多一次指针定位的开销。
直接指针访问的思路是,栈帧里的引用变量直接指向对象在堆中的地址,对象内部再存储类型数据的地址。好处是访问速度快,省一次指针定位,这也是HotSpot实现采用的方案,代价就是GC移动对象时需要同步更新所有指向它的引用。
复习时记住一句话:HotSpot使用直接指针访问。解释清楚直接指针和句柄访问的优劣对比,是面试中展示记忆深度的一个好机会。
5. 复习之外:把概念映射到真实报错上
5.1 "Failed to launch JVM"类启动错误的排查思路
复习完上面的理论,我习惯立刻做一遍"知识落地"的练习——把一些常见报错和知识点对应起来。热搜里那个"error invoking method. failed to launch jvm"就是一个很好的排查案例。
这个报错常在IDE启动时弹出来,字面意思是"启动JVM失败,调用方法时出错"。它的本质往往是IDE在启动过程中调用JVM时,读到了不合适的JVM参数或者找不到合适的JVM。
常见的触发原因大概有以下几类:
- IDE启动配置文件里的虚拟机参数不合理,比如-
Xmx设置得过大,超出了系统可用物理内存,或者设为了一个奇怪的数值。 - 系统装了多个JDK版本,PATH环境变量指向的Java版本和IDE要求的版本不一致。
- 32位与64位不匹配。32位的JVM在32位操作系统上默认的最大堆内存只有1.5GB左右,如果你在32位JVM上配了2GB的堆,启动必然失败。
- JAVA_HOME配置错误,指向了不存在的目录或JRE目录。
排查思路可以按照"看日志→查JAVA_HOME→查启动配置→命令行验证"的顺序走。先从IDE的启动日志里找到JVM启动那一行报错;然后确认当前生效的java版本,java -version看一眼;再检查启动配置文件里的内存参数;最后用命令行直接执行那个JVM命令,看是不是能复现。这个过程本身就是在复习JVM参数和运行时环境的知识点。
5.2 "无法编译为jvm目标17"之类的编译期目标错误解析
另一个很有代表性的问题是:"无法编译为jvm目标 17 配置的模块 'jeecg-boot-base-core': 指定的回退源"。这类报错在IDEA里很常见,问题根源几乎永远是三个地方没对齐:JDK版本、模块语言级别、编译器目标字节码版本。
先解释一下"回退源"这个概念。IDEA在编译某个模块时,如果发现模块的语言级别(Language Level)或字节码目标版本和当前使用的JDK不匹配,它会尝试"回退"到一个它认为兼容的源级别。但当某个模块明确被配置为jvm目标17,而周围环境条件不满足时,这个回退就失败了,于是报出这个让人一头雾水的错误。
解决步骤一般是这样:
- 先确认Project Structure里的Project SDK真的是17或更高版本。如果全局SDK配的是JDK 8,但模块的language level被设成了17,就会冲突。
- 再检查模块的Language Level设置,把它改成与目标版本一致的级别。
- 然后查看Settings里Java Compiler的"Per-module bytecode version",确保没有模块被单独指定为过低的版本。
- 如果项目是Maven或Gradle构建的,还要检查构建配置里compiler插件的source/target。Maven里常见的是maven-compiler-plugin的source和target属性,以及
<maven.compiler.source>这样的属性。
这类报错最大的迷惑性在于,它报的是某个模块,但根因可能在全局配置。所以排查时不要只盯着报错的模块看,而要自上而下把Project level到Module level再回到Build tool level逐层查一遍。
5.3 复盘:一个面试答法的参考框架
复习到最后一个环节,我会把这些知识点串成一个可以当面讲出来的"回答框架"。以"JVM内存模型"为例,很多人一上来就背堆、栈、方法区,背完之后面试官问一句"然后呢",就冷场了。
我自己的回答顺序是这样的:
先澄清概念。这里的"内存模型"指运行时数据区域还是JMM?两者择一展开,避免混淆。然后按"线程私有"和"线程共享"两个维度切分:线程私有的有程序计数器、虚拟机栈、本地方法栈;线程共享的有堆、方法区。接着逐个讲职责,每个区域讲完顺带一句异常类型。最后用一个实际案例收尾,比如一个线上OOM是怎么通过区域定位来排查的。
这个框架的关键不在于背下来,而在于它体现出了两个能力:一是概念边界清晰,二是知识能落到实践。面试官通常更看重后者。
复习JVM确实是一场硬仗,知识点多而且相互关联。我自己的体会是,不要贪多求快,每复习完一个板块,就强迫自己用一段大白话把这块讲给旁边的人听,讲不清楚的地方就是你还没吃透的地方。把上篇这几块内容嚼碎了,再进入垃圾回收和调优的下篇,会轻松很多。
