
1. 为什么我劝你别死背JVM面试题先搞懂这套骨架1.1 JVM、JRE与JDK三兄弟的边界前几天一个读者私信我马上要面试了把 JVM 速记资料发我一份最好是面试题加答案那种。我反手发了一张运行时数据区图让他先别做题把下面这句话读三遍JDK 是开发工具包JRE 是运行环境JVM 是运行环境里真正干活的虚拟机。很多人在搜JVM速记的时候第一反应就是找一份面试题背答案。但 JVM 这套知识其实是一个完整的链条断掉任何一环背了也白背。JVM 的全称是 Java Virtual Machine它负责把编译后的字节码.class 文件转换成当前操作系统能识别的机器指令。这正是 Java 能一次编写到处运行的底层原因。JREJava Runtime Environment在 JVM 外面包了一圈核心类库像 java.lang、java.util 这些运行时必须的类都在 JRE 里。JDKJava Development Kit又在 JRE 外面补上了 javac、jdb、javap 这些开发维护工具。写代码用 JDK跑程序用 JRE执行字节码的是 JVM这三者的边界一旦清晰很多基础面试题就不需要背了。1.2 一条主线搞定JVM工作原理从字节码到运行时数据区JVM 的工作原理可以用一条主线串起来javac 把 .java 源码编译成 .class 字节码类加载器把字节码加载进运行时数据区字节码执行引擎逐条解释或者 JIT 编译成机器码执行运行过程中由垃圾回收器自动管理内存。这部分有个高频搜索词叫jvm工作原理我建议你把它简化成三件事内存怎么分、类怎么加载、垃圾怎么回收。内存怎么分就是运行时数据区类怎么加载就是类加载机制垃圾怎么回收就是 GC 相关的整块内容。今天这篇速记就是沿着这条主线往下走每章解决一个问题最后落到面试和实战工具上。对刚入门的人来说先把这张地图刻在脑子里再去啃《深入理解Java虚拟机》会轻松很多。2. 运行时数据区速记一块堆两个栈外加方法区与本地方法栈2.1 堆的分代结构与对象分配流程运行时数据区是整个 JVM 内存模型的核心。速记思路很简单先分线程共享和线程私有。线程共享的是堆和方法区JDK 8 后叫元空间线程私有的是虚拟栈、本地方法栈和程序计数器。堆是 JVM 管理的最大一块内存几乎所有对象实例和数组都在这里分配也是垃圾回收的主战场。堆内存通常分成新生代和老年代。新生代又细分为 Eden 区和两个 Survivor 区S0、S1常见比例是 8:1:1。为什么要两个 Survivor因为新生代主要用复制算法回收回收时把存活对象从一个 Survivor 复制到另一个 Survivor始终保持一个 Survivor 为空这样既能整理碎片又不用像标记整理那样移动大量对象。对象分配的基本流程是新对象优先在 Eden 区分配Eden 空间不足时触发一次 Minor GC存活对象被复制到 S0下一次 Minor GC 再把 S0 和 Eden 的存活对象复制到 S1如此反复。每经历一次 GC 对象年龄加一默认超过 15 次后晋升到老年代。大对象比如很长的数组、字符串会直接进老年代因为大对象在新生代反复复制成本太高。在实际工作中碰到expiring daemon because jvm heap space is exhausted这类错误时很多人第一反应就是调大 -Xmx这不一定对。这个报错本质上就是 JVM 堆空间耗尽GC 之后仍然无法分配新对象JVM 只好收缩退出。它可能只是你的应用真的需要更大堆但也可能是存在内存泄漏或者 -Xms 和 -Xmx 差距太大导致堆在运行中反复扩容。我见过不少 Gradle 构建项目明明代码没问题只是默认 daemon 堆只有 1G编译大工程时被撑爆这时候把 -Xmx 调到 4G 再配合 -Xms 对齐问题立刻消失。2.2 虚拟机栈和栈帧一个调用到底发生了什么线程私有的区域里虚拟机栈平时存在感不高但它和最常见的 StackOverflowError 直接相关。虚拟机栈描述的是 Java 方法执行时的线程内存模型每调用一个方法JVM 就会在当前线程的虚拟机栈中压入一个栈帧方法执行完就弹出栈帧。栈帧内部有四样关键内容局部变量表、操作数栈、动态链接、方法出口。局部变量表存放基本数据类型、对象引用和 returnAddress 类型单位是槽Slotlong 和 double 因为占用 64 位需要占两个槽。操作数栈是字节码指令的工作台几乎所有计算都先在局部变量表取值压入操作数栈然后执行指令再把结果写回局部变量表或堆。动态链接用来支持方法调用时的多态运行期才能确定从运行时常量池解析出对应方法引用。方法出口记录调用者的返回地址异常抛出时也有对应的异常处理表。速记可以这么想一个线程的虚拟机栈就是一大摞栈帧每个栈帧是一张临时工作台。递归调用无终止条件时栈帧会一直增加直到栈深度超出 JVM 允许的范围这时抛 StackOverflowError如果栈容量允许动态扩展但内存不够就会抛 OutOfMemoryError。相关参数是 -Xss用它控制线程栈大小默认值因平台而异一般是 512KB 到 1MB。调大 -Xss 能支持更深的递归但线程栈本身占用的是操作系统内存调太大反而会让可创建的线程数量变少这在高并发服务里是个容易被忽略的坑。2.3 方法区、常量池和直接内存常被忽略的第三块地方法区存放类元信息类名、访问修饰符、字段描述、方法字节码、运行时常量池等等。JDK 8 之前方法区的实现叫永久代位置在 JVM 堆内JDK 8 之后永久代被移除换成了元空间Metaspace位置挪到了本地内存。为什么做这个改动因为永久代内存上限不好控制经常出现 PermGen 空间不足而且永久代里的类元信息回收条件苛刻Full GC 后仍然可能泄漏。换成元空间后默认使用本地内存除非显式设置 -XX:MaxMetaspaceSize否则只受物理内存限制。这解决了许多框架动态生成类过多导致的 OOM但也带来了新的坑如果元空间设置过大而宿主机的容器内存有限可能把整个进程内存耗尽。所以容器化部署 JVM 应用时一定不要只盯堆内存还要给元空间和直接内存留出余量。运行时常量池是方法区的一部分用来保存编译器生成的字面量和符号引用。String.intern() 的字符串缓存、Integer 缓存这类机制都和常量池有关。直接内存Direct Memory严格来说不算运行时数据区但 NIO 和 Netty 重度依赖它。DirectByteBuffer 在堆外分配内存可以避免数据在堆内和堆外之间来回拷贝性能确实好但如果用完之后没有及时释放或者 -XX:MaxDirectMemorySize 设置过小一样会有 OOM。我见过一个 Netty 应用堆内存很健康GC 也很正常但老是报Direct buffer memory就是因为没有关注直接内存的分配上限。3. 类加载机制从.class到实例的完整链路3.1 加载、验证、准备、解析、初始化五个阶段类加载机制回答的是字节码是怎么变成可用的类这个问题。一个类从被加载到被卸载完整生命周期包括加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析三个步骤统称为连接。加载阶段做的事情比较机械根据类的全限定名获取二进制字节流把字节流中的静态存储结构转换成方法区的运行时数据结构再在堆中生成一个 java.lang.Class 对象作为访问入口。验证阶段会检查字节码是否符合 JVM 规范防止恶意或错误代码混进来。准备阶段为静态变量分配内存并设为零值比如 int 默认 0、对象引用默认 null这是很多人容易答错的地方准备阶段还没有执行任何赋值语句static int x 10 这个赋值动作要到初始化阶段才执行。解析阶段把运行时常量池里的符号引用替换为直接引用。初始化阶段才真正执行类构造器 () 方法包括静态变量赋值和静态代码块。速记口诀加验准解初。面试时还要能说出主动引用的触发条件new 对象、访问静态字段、调用静态方法、反射、初始化子类时先初始化父类、JVM 启动时包含 main 方法的类、JDK 7 动态语言支持等。反过来被动引用不会触发初始化通过子类访问父类的静态字段时子类不会被初始化通过数组定义类不会触发比如A[] arr new A[10]访问编译期常量也不会因为常量在编译阶段已经进入常量池运行时不依赖类初始化。3.2 双亲委派模型为什么必须从父加载器开始类加载器之间默认采用双亲委派模型。JVM 内置三层启动类加载器 Bootstrap ClassLoader 负责加载 JDK 核心类库扩展/平台类加载器负责加载扩展目录下的类应用类加载器 App ClassLoader 负责加载 classpath 下我们写的业务类。双亲委派的核心逻辑是一个加载器收到加载请求后先不自己加载而是把请求交给父加载器父加载器又继续往上交只有父加载器返回无法加载时子加载器才尝试自己加载。为什么必须从父加载器开始最重要的目的是防止核心类被篡改。比如你自己写一个 java.lang.String双亲委派会优先让 Bootstrap 加载真正的 JDK 里的 String你的字符串类根本不会被加载这样 Java 运行环境的核心类才不会被恶意顶替。同时它还能保证同一个类只被加载一次避免出多个版本。面试里相对高级的问题是如何打破双亲委派。记住两个关键词重写 loadClass 方法而不是重写 findClass。ClassLoader 源码里 loadClass 实现的是双亲委派逻辑findClass 是留给你自定义加载逻辑的模板方法。Tomcat 的 WebAppClassLoader 会重写 loadClass优先加载当前 Web 应用目录下的类这样同一个 Tomcat 里可以部署两个不同版本的同名库而互不干扰。JDBC 的 DriverManager 是启动类加载器加载的但它要调用由应用类加载器加载的数据库驱动这明显违背了双亲委派的方向于是 JDK 引入了线程上下文类加载器Thread Context ClassLoader把父加载器无法加载的请求交给线程上下文加载器反向处理。3.3 打破双亲委派热部署是怎么做到的热部署几乎是每个 Java Web 开发都碰过的场景。实现原理一句话每个部署周期创建一个新的自定义类加载器让新的类和旧类分别由不同加载器加载然后替换加载器引用。旧加载器如果没有被外部引用就会变成垃圾可被回收。这里面最经典的坑是同一个类名的实例如果由不同类加载器加载它们在 JVM 中就是两个完全不同的类。做 instanceof 判断和类型强转时如果跨越了类加载器边界会直接抛 ClassCastException。Spring Boot DevTools 的热重启也是基于自定义类加载器实现的它把业务代码层单独拆到一个重启类加载器中依赖库用基础类加载器从而加快重启速度。理解了这个机制以后遇到明明类文件是新的但运行结果还是旧的这种诡异问题你就知道先去排查是不是旧类加载器没有被回收。4. 垃圾回收速记判断存活、分代回收与三色标记4.1 可达性分析谁能当GC RootsJVM 判断对象是否可以回收主流用的是可达性分析而不是引用计数。引用计数方案简单但解决不了循环引用A 引用 BB 引用 A外部已经没人引用它们可是引用计数还是 1就永远无法回收。可达性分析则是从一系列 GC Roots 出发沿着引用链搜索能到达的对象就被标记为存活到达不了的则判定为可回收。GC Roots 具体包括这么几类虚拟机栈栈帧中的局部变量表中引用的对象方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中 JNI 引用的对象Java 虚拟机内部的引用比如基本类型对应的 Class 对象、一些常驻的异常对象所有被 synchronized 持有的对象JMXBean、JVMTI 回调等。实际面试不用背全但至少要说清楚前三类尤其是栈上的局部变量引用指向堆中的对象这一条。和垃圾回收相关的引用类型也常考。强引用就是普通的Object obj new Object()只要存在强引用GC 永远不会回收这也是内存泄漏最常见的根源。软引用SoftReference在内存不足时GC 才会回收适合做缓存。弱引用WeakReference在下次 GC 时一定会被回收ThreadLocalMap 的 key 就是弱引用。虚引用PhantomReference不能通过它访问对象只能用来在对象被回收时收到一个系统通知常和引用队列联合使用。4.2 垃圾收集器速查Serial、Parallel、CMS、G1、ZGC垃圾收集器是 jvm 垃圾回收器关键词下面最常被问的部分。我不建议你死记每个收集器的所有细节先记一张选型表收集器区域算法特点适用场景Serial / Serial Old新生代 / 老年代复制 / 标记整理单线程STW 时间较长客户端环境、小堆ParNew新生代复制Serial 的多线程版配 CMS 使用Parallel Scavenge / Parallel Old新生代 / 老年代复制 / 标记整理吞吐量优先后台计算、批处理CMS老年代标记清除并发低延迟会产生碎片对响应时间要求高G1全堆分 Region标记整理 复制可预测停顿JDK9 默认服务端大堆ZGC全堆分 Region着色指针 读屏障停顿极低10ms超大堆低延迟CMS 曾经很火但它在 JDK 9 被标记废弃JDK 14 之后正式移除。CMS 的致命问题是并发模式失败并发清理过程中老年代被快速填满JVM 只能退化到 Serial Old 做 Full GC停顿时间反而更长。G1 把堆分成很多大小相等的 Region每个 Region 都可能被动态标记为 Eden、Survivor 或 Old通过 -XX:MaxGCPauseMillis 设置软性的停顿目标它避免了 CMS 的大碎片问题。ZGC 又更进一步用染色指针和读屏障把停顿时间压缩到毫秒级但内存占用和 CPU 开销也更高。选收集器不能只追新要结合服务容忍的是吞吐下降还是停顿变长。4.3 三色标记算法与并发标记的漏标问题CMS 和 G1 在并发标记阶段都用了三色标记算法用来让 GC 线程和应用线程并发运行而不必全程停顿。三色指的是白色表示对象还没被扫描到灰色表示对象已被访问但其引用还未被全部扫描黑色表示对象及其引用都已被扫描完毕。标记结束之后仍为白色的对象就是不可达对象可以被回收。并发标记最大的风险是漏标一个黑色对象在某个时刻新引用了白色对象同时灰色对象与白色对象的引用关系断开了这个白色对象就会被误判为垃圾。解决漏标有两个方向。CMS 采用增量更新Incremental Update它记录黑色对象新引用了白色对象这个动作把黑色对象重新变灰标记结束时再扫描一遍新插入的引用。G1 采用原始快照SATB它记录引用被删除的动作确保在标记开始时处于存活状态的对象即使后续引用断掉也会保留在本次标记集合中。聊到这一层面试官会觉得你对 GC 不是背参数而是真正理解了算法。5. 常用JVM调优参数与线上排查经验5.1 调优参数速查表先用好三件套JVM 调优参数多到吓人但日常真正高频用到的其实就是一小批。先把三件套玩明白-Xms、-Xmx、-XX:HeapDumpOnOutOfMemoryError。其他参数都可以在这个基础上按需加。参数作用建议-Xms初始堆大小与 -Xmx 保持一致避免扩容-Xmx最大堆大小按业务峰值设置留足外存余地-Xmn新生代大小一般为堆的 1/3 到 1/2-XX:MaxMetaspaceSize元空间上限容器环境必须限制防内存超卖-XX:UseG1GC启用 G1JDK9 默认一般无需显式设置-XX:MaxGCPauseMillisG1 停顿目标不要设太低否则 GC 更频繁-XX:HeapDumpOnOutOfMemoryErrorOOM 时导出堆快照强烈建议开启-XX:HeapDumpPath堆快照路径指定到有剩余磁盘的目录-Xlog:gc*打印 GC 日志JDK9 后替代 -XX:PrintGCDetails5.2 线上堆内存溢出排查从报错到定位的完整链路之前一个线上服务每到凌晨都会报一次OutOfMemoryError: Java heap space而且重启后又能撑一天。很多人遇到这种问题会立刻去加内存但我这边的排查链路是固定的一套全程不靠猜。先在启动参数里确保加了-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath/data/dump这样下次 OOM 时会自动生成 hprof 文件。如果服务还能起来也可以用jmap -dump:formatb,fileapp.hprof 进程ID手动导一份堆快照。接着用 Eclipse MAT 打开快照重点看 Dominator Tree 里 Retained Heap 最大的对象再看 GC Roots 路径也就是到底是谁还引用着这个大对象。我当时查出来是一个多线程并发写本地缓存的工具类Key 用了永不失效的条数限制却忘了设最大容量日积月累把堆填满了。定位到根因后把缓存的淘汰策略改成基于容量的 LRU再把 -Xms 和 -Xmx 对齐之后一周都没再出现报警。还有一类常见报错和 IDE 或构建工具有关比如搜到的 expiring daemon because jvm heap space is exhausted基本就是 Gradle daemon 的堆设置得太小在 gradle.properties 里增加org.gradle.jvmargs-Xmx4g一般能解决。又比如启动 IDEA 时报 cannot read:D:\...\vmoptions 或 JVM reference can not find the corresponding jvm service多半是 IDE 的 vmoptions 文件路径非法或者 JDK 引用丢失。处理办法很简单删掉用户目录下损坏的 .vmoptions 文件恢复默认再在 Project Structure 里重新添加一次 JDK基本都能恢复。5.3 别盲目调优一个案例和三个误区有次我把一个接口的堆从 4G 调到 16G以为内存大就不卡结果 Full GC 变得少但单次停顿接近一秒调用方直接超时。后来看了 GC 日志才发现对象分配速率并不高主要问题是缓存占比过大。于是把堆调回 6G重点优化缓存淘汰停顿反而降下来了。调优第一准则先确定目标再看指标最后才动参数不要为了调而调。三个常见误区值得单独写一下。第一只看堆不看元空间、直接内存和线程栈。容器环境里堆设置过大元空间和直接内存就会挤占操作系统内存最终 OOM Killer 可能直接把进程杀掉。第二盲目照搬旧版本的 CMS 参数在 JDK 15 之后启动直接报 Unknown VM option。第三把新生代调得非常大以为 GC 次数变少就万事大吉结果 Minor GC 单次停顿变长对象晋升率反而上升。记住这条验收标准调优前后的对比必须看 GC 日志中的停顿时间、吞吐量、Full GC 频率和内存分配率感觉永远不如数字可靠。6. JVM面试高频问题速答含陷阱6.1 基础类问题速答网上最热的搜索词就是jvm面试的时候经常会提出那些问题这里我按出现频率整理了一份速答模板。基础类问题先记住这几道JVM、JRE、JDK 分别是什么答JVM 是执行字节码的虚拟机JRE 是 JVM 加核心类库的运行时环境JDK 在 JRE 基础上再包含开发调试工具。Java 为什么跨平台答源码编译成字节码字节码在不同平台上由对应版本的 JVM 解释或编译执行JVM 屏蔽了操作系统差异。类加载过程有哪几步答加载、验证、准备、解析、初始化其中验证、准备、解析属于连接阶段。双亲委派模型是什么答加载类时先让父加载器尝试父不行才轮到子加载器目的是防止核心类被篡改、保证类唯一性。什么情况下类不会被初始化答子类访问父类静态字段、通过数组创建对象、访问编译期常量都不会触发初始化。6.2 内存与GC类问题速答内存和 GC 类是 JVM 面试题的重灾区。高频问题里藏着很多陷阱尤其是下面几个程序计数器会 OOM 吗不会它是唯一不抛 OutOfMemoryError 的区域因为它只保存当前字节码行号内存占用极小。方法区会 OOM 吗JDK 8 前会永久代溢出JDK 8 后元空间如果设置了上限照样会 Metaspace OOM。如何判断对象可以被回收可达性分析从 GC Roots 出发不可达的对象可回收。对象什么时候进入老年代默认熬过 15 次 Minor GC、大对象直接进入、动态年龄判定三种情况。CMS 和 G1 有什么区别CMS 基于标记清除G1 把堆划分为 Region 并采用复制算法CMS 会有碎片G1 的可预测停顿更好两者都涉及并发标记但漏标处理方式不同CMS 用增量更新G1 用原始快照 SATB。能答出最后一条的人不多。6.3 排查工具类问题速答JDK 自带的排查工具现在也是面试高频。jps查看 Java 进程jstack抓线程快照看死锁与阻塞jmap导出堆快照和查看类加载信息jstat监控 GC 情况jcmd几乎能替代前面多个命令。面试官如果问线上 CPU 飙高怎么排查标准思路是先用 top 定位到 CPU 最高的进程再用 top -Hp 定位到具体线程把线程号转成十六进制然后 jstack 找到对应线程栈翻业务代码。这套流程最好自己真实跑一遍比背一百道题都有用。还有一个经常被忽略的问题是如何查看 JVM 默认参数答案是用java -XX:PrintFlagsFinal -version。很多调优题其实都可以先靠这个命令探底。最后分享一个我个人的速记习惯我把运行时数据区画成一页纸正面画区域布局反面写类加载五个阶段和 GC 流程面试前只复习这张纸。JVM 速记的真正价值不是背答案而是能用一条主线解释清楚线上一个 OOM内存从哪来、类怎么加载、垃圾怎么回收、参数怎么调。当你串起这条线的时候这套速记才真正变成你自己的东西。