JVM 面试题
1. JVM 类加载机制与双亲委派
❓ 题目: 请描述 JVM 的类加载过程,以及双亲委派模型的工作原理和设计意图。
追问1:双亲委派模型被破坏过吗?举例说明哪些场景需要打破它。 追问2:一个 Class 在 JVM 中的唯一性由什么决定?如何实现类的隔离?
💡 答案:
主问题: JVM 的类加载过程分为五个阶段:加载、验证、准备、解析、初始化。加载阶段将 class 文件的二进制字节流读入内存并转化为方法区的运行时数据结构,同时在堆中生成对应的 Class 对象;验证阶段确保字节码符合 JVM 规范,不会危害虚拟机安全;准备阶段为静态变量分配内存并赋零值(注意不是赋初始值,比如 static int a = 1 在准备阶段 a 的值是 0,初始化阶段才会赋为 1);解析阶段将常量池中的符号引用替换为直接引用;初始化阶段执行类构造器 <clinit> 方法,也就是 static 变量的赋值和 static 代码块。
双亲委派模型的核心逻辑是:当一个类加载器收到加载请求时,它不会自己先加载,而是把这个请求委托给父类加载器,一直向上传递。只有当父加载器反馈自己无法加载时(在它的搜索范围内找不到这个类),子加载器才会尝试自己加载。这么设计的主要目的是保证 Java 核心类库的安全性——比如不管谁定义的 java.lang.String,最终都会由 Bootstrap ClassLoader 加载,防止有人自定义同名类替换 JDK 核心类造成安全隐患。另外也保证了同一个类不会被不同加载器重复加载。
追问1: 双亲委派模型在 JDK 发展过程中至少被”破坏”过三次。第一次是 JDK 1.2 引入双亲委派之前就已经存在的自定义类加载器,为了兼容需要重写 findClass() 而非 loadClass()。第二次是 SPI 机制——比如 JDBC 的 Driver 接口定义在 java.sql 包下,由 Bootstrap ClassLoader 加载,但具体的 MySQL Driver 实现类在 classpath 下,Bootstrap 加载不到。于是 Java 引入了线程上下文类加载器(Thread Context ClassLoader),让父加载器可以反向请求子加载器来加载类。Tomcat 也打破了双亲委派——为了支持多个 Web 应用各自使用不同版本的同一个第三方库,Tomcat 为每个 Web 应用提供了独立的 WebAppClassLoader,优先自己加载而不是向上委托,实现了应用的类隔离。
追问2: 一个 Class 在 JVM 中的唯一性由”全限定类名 + 它的类加载器实例”共同决定。也就是说,同一个 class 文件被两个不同的类加载器加载,JVM 中会存在两个独立的 Class 对象,它们之间互不相通,instanceof 判断也会返回 false。这种机制正是实现类隔离的基础——Tomcat 给每个 Web 应用分配独立的类加载器,不同的应用即使有同名类也互不干扰;OSGi 更进一步,每个模块(Bundle)都有自己的类加载器,并通过模块间的导入导出规则来精确控制类的可见性。
📌 易错点 / 加分项:
- 准备阶段给 static 变量赋的是零值而非代码中写的初始值,这是高频考点
- 解析阶段不一定要等到初始化之前,JVM 可以根据需要在解析阶段之后随时进行
findClassvsloadClass的区别——前者是模板方法中留给子类实现的扩展点
2. JVM 内存布局与 OOM 排查
❓ 题目: 请画出 JVM 运行时数据区的完整布局,并说明每个区域存储什么内容以及可能抛出什么异常。
追问1:一个线程的 OOM 会影响到其他线程吗?什么类型的 OOM 会导致进程直接挂掉,什么类型可能只是影响单个线程? 追问2:元空间(Metaspace)和永久代(PermGen)的根本区别是什么?为什么用 Metaspace 替换了 PermGen?
💡 答案:
主问题: JVM 运行时数据区分为线程共享和线程私有两部分。线程共享区域:堆(Heap)存放对象实例和数组,是 GC 的主要战场,内存不足抛 OutOfMemoryError: Java heap space;方法区(Method Area,JDK 8 后为 Metaspace)存放类的元信息、运行时常量池、静态变量、JIT 编译后的代码缓存等,内存不足抛 OutOfMemoryError: Metaspace。线程私有区域:程序计数器是一小块内存,指向当前正在执行的字节码指令地址,不会抛 OOM;Java 虚拟机栈由栈帧组成,每个栈帧包含局部变量表、操作数栈、动态链接和方法返回地址,栈深度超限抛 StackOverflowError,栈内存不足无法拓展抛 OutOfMemoryError——通常发生在大量线程并发时,因为每个线程都有一个独立栈,栈的总消耗 = 线程数 × 每个栈的大小;本地方法栈服务于 native 方法,行为和异常类似虚拟机栈。另外还有直接内存(Direct Memory),存放 NIO DirectByteBuffer 等,不受 GC 管理,内存不足抛 OutOfMemoryError: Direct buffer memory。
追问1: 这个问题的答案取决于 OOM 的类型。Java heap space OOM 通常影响整个进程——因为堆是所有线程共享的,堆空间耗尽后,任何线程尝试分配对象都会失败,而且大部分业务逻辑都涉及对象分配,所以基本上整个应用就不可服务了,但进程不一定直接退出,只是反复 full GC 直到 CPU 打满或彻底卡死。StackOverflowError 通常只影响当前线程——因为每个线程有自己独立的虚拟机栈,线程 A 的栈溢出不会破坏线程 B 的栈,线程 A 的异常导致它自己终止,其他线程继续正常工作(除非这个线程是关键业务线程影响整体)。Metaspace OOM 影响全局——类信息全局共享,无法加载新类所有线程都会受影响。Direct buffer memory OOM 也影响全局。
追问2: 本质区别在于内存的归属和 GC 行为。永久代是 JVM 堆的一部分,有固定大小上限(-XX:MaxPermSize),存放在堆中意味着它受堆 GC 管理,而类的生命周期通常和应用相同,频繁进行 PermGen 的 GC 效率很低,而且固定大小很难预估——动态加载大量类(如 JSP、动态代理)时容易溢出让应用宕机。Metaspace 移到了本地内存(Native Memory),默认只受物理内存限制而不再是 JVM 堆的一部分,它和堆 GC 完全解耦,垃圾回收只在类加载器不可达时触发类的卸载,比永久代更灵活。另外 PermGen 中除了类信息还混杂了字符串常量池和静态变量,JDK 7 已经将字符串常量池和静态变量移到了堆中,JDK 8 彻底移除 PermGen 改为 Metaspace 用来专门存放类元数据,职责更清晰。这个改动同时解决了 PermGen 大小难调优的问题——以前调 PermGen 是一个让人头疼的事,现在 Metaspace 可以自动增长,只需要通过 -XX:MaxMetaspaceSize 设置上限防止无限增长。
📌 易错点 / 加分项:
- JDK 7 中字符串常量池被移到了堆,JDK 8 才去除 PermGen,这两个时间节点不要混淆
OutOfMemoryError: unable to create new native thread的根因不是堆内存不够,而是操作系统对单个进程的线程数有限制,或虚拟内存不足分配新栈jmap -heap看堆、jstat -gc看 GC 统计、jstack看线程栈是 OOM 排查三件套
3. CMS vs G1 垃圾回收器
❓ 题目: 请对比 CMS 和 G1 垃圾回收器的工作流程、适用场景和各自的特点。
追问1:G1 的 Mixed GC 和 CMS 的并发收集有什么本质区别?G1 的”可预测停顿时间”是如何做到的? 追问2:如果你的应用升级到 JDK 17 甚至 21,你还会继续调优 G1 吗?ZGC 在什么场景下比 G1 更合适?
💡 答案:
主问题: CMS(Concurrent Mark Sweep)是老一代并发收集器,目标是”最短回收停顿时间”。它的回收流程分为:初始标记(STW,仅标记 GC Roots 直接可达的对象,很快)、并发标记(和用户线程并发,从 GC Roots 遍历对象图)、重新标记(STW,修正并发标记期间变化的引用,比初始标记稍长但比并发标记短)、并发清除(和用户线程并发,清扫垃圾对象)。CMS 的核心问题有三个:一是并发清除阶段会产生浮动垃圾,本次 GC 无法清除只能留到下次;二是”并发模式失败”——并发清除期间如果老年代不够分配新晋升的对象,会退化为 Serial Old 单线程回收,停顿时间暴涨;三是碎片化——CMS 不整理内存,长期运行后碎片严重可能触发 Full GC。
G1 的核心理念是将堆划分为大小相等的 Region,不再按物理上连续的年轻代和老年代来划分。G1 的回收流程:年轻代回收(Young GC)是 STW 的,类似 ParNew;混合回收(Mixed GC)是老年代回收的核心——包含初始标记(搭车在 Young GC 中)、并发标记、重新标记(STW)、筛选回收(STW,只回收垃圾占比高的 Region,将存活对象拷贝到空 Region,同时完成内存整理)。G1 的适用场景是”大堆 + 需要可预测的停顿时间”,CMS 适合 4-8G 中等堆且 JDK 版本较老的项目。
追问1: 本质区别有两层。第一,回收范围不同:CMS 在并发清除阶段尝试回收整个老年代;G1 的 Mixed GC 只回收一部分垃圾占比高的 Region,回收量可以灵活控制。第二,是否整理内存:CMS 是标记-清除,产生碎片;G1 是标记-整理(通过 region 间的存活对象拷贝),天然无碎片。G1 可预测停顿时间的原理是:G1 维护了一个”回收价值优先级”——每个 Region 记录了其中的垃圾比例和回收该 Region 估算的耗时,Mixed GC 时在用户设定的停顿时间目标(-XX:MaxGCPauseMillis,默认 200ms)范围内,按垃圾比例从高到低回收尽可能多的 Region,回收不完的下次继续。这不是精确的停顿时间保证,而是一个”软目标”——G1 会尽量满足,但如果回收最垃圾的 Region 耗时本身就超过目标,那也无法做到。
追问2: 实际上 JDK 17 之后 G1 已经很成熟,默认就是 G1,大多数场景不需要刻意调优了,只需要设一个合理的停顿时间目标和堆大小。ZGC 最合适的场景是”超大堆 + 超低延迟”:堆在几百 GB 甚至 TB 级别,停顿时间要求亚毫秒级(ZGC 的 STW 时间与堆大小无关,只与 GC Roots 数量有关,全并发标记和整理)。如果你的堆只有几个 GB 到十几个 GB,G1 完全够用,ZGC 的吞吐量损失反而得不偿失——ZGC 的全并发会导致吞吐量比 G1 低 5-10%。总结就是:大堆低延迟选 ZGC,中等堆吞吐量优先选 G1,如果堆很小(1G 以下)Parallel GC 可能吞吐量最高。
📌 易错点 / 加分项:
- CMS 的”并发模式失败”和”晋升失败”是两个不同的失败——前者是老年代没空间做并发清除,后者是老年代没空间接收新生代晋升
- G1 的 Humongous Region(巨型对象区)是一个容易漏的点:超过 Region 50% 大小的对象直接分配在连续 Region 中
- JDK 14 移除了 CMS(正式废弃),JDK 15 默认 G1,JDK 17 LTS 后 G1 是绝对主流
4. JVM 对象分配与逃逸分析
❓ 题目: Java 对象一定分配在堆上吗?逃逸分析是什么,它对对象分配和锁优化有什么影响?
追问1:标量替换(Scalar Replacement)是如何工作的?它和栈上分配是什么关系? 追问2:逃逸分析是一个”免费”的优化吗?它有没有代价?
💡 答案:
主问题: 对象不一定分配在堆上——即时编译器(JIT / C2 编译器)会通过逃逸分析来判断一个对象是否”逃逸”出了它的创建作用域。如果一个对象可证明不会逃逸出当前线程和当前方法,那么可以进行标量替换——将这个对象的成员变量拆分为独立的栈变量,直接在 CPU 寄存器或栈上分配这些变量,不需要在堆上分配这个对象本身。逃逸分析对锁优化也有直接影响:如果分析出某对象不会被其他线程访问,那么对这个对象的 synchronized 锁就会被”锁消除”——JIT 直接去掉锁操作,因为不需要保护。此外,如果锁对象只在当前线程内有竞争(对象自身不逃逸但可能会有多次加锁解锁),锁会被”锁粗化”——多次连续的加锁解锁合并为一对锁操作。
追问1: 标量替换是指:当 JIT 分析出某个对象的全部字段不逃逸时,就不再在堆上分配那个对象,而是把对象的每个基础类型字段拆解为一个独立的”标量”变量,放入 CPU 寄存器或当前线程的栈帧中执行。比如一个 Point 对象有两个 int 字段 x 和 y,JIT 分析后发现 Point 对象不需要被 GC 管理,就把它拆为两个独立的 int 变量 x 和 y。这跟严格意义上的”栈上分配”不完全一样——“栈上分配”是把整个对象在栈上分配为一个整体;标量替换更像是”根本不分配对象”。实际上 HotSpot 的 C2 编译器就是通过标量替换来实现这个优化的,而不是为每个对象在栈上单独分配内存块。效果是减少堆上的对象分配、减少 GC 压力。
追问2: 逃逸分析不是免费的——它本身需要消耗 JIT 的编译时间,分析过程需要遍历 IR(中间表示)节点、做数据流分析和连通性分析。对于非常大的方法或复杂的对象图,逃逸分析可能因为分析成本太高而放弃——JIT 只会分析”值得分析”的方法。而且逃逸分析也不是对所有情况都有效——如果方法中 new 了对象但作为方法返回值返回了,这个对象就逃逸了,分析白做。在实际效果上,对于框架代码、中间件代码的新建对象可能逃逸很多,但对于业务代码中大量 new StringBuilder 这类”用完即丢”的对象,标量替换能显著减少堆分配年轻代压力。
📌 易错点 / 加分项:
- 逃逸分析与标量替换不是一个东西——逃逸分析是”手段”,标量替换是”优化结果”
- 锁消除的条件是”对象完全不逃逸”,而不是”没有竞争”——两个不同线程各自分配一个不逃逸的对象,各自的锁都会消除
-XX:+DoEscapeAnalysis默认开启——这是 C2 优化的一部分,可以通过-XX:+PrintEscapeAnalysis观察分析结果
5. 方法内联与分层编译
❓ 题目: JIT 编译器中”方法内联”是什么?为什么说它是 JIT 最重要的优化之一?
追问1:分层编译(Tiered Compilation)机制是怎样的?C1 编译器和 C2 编译器各负责什么? 追问2:JIT 编译的触发条件是什么?方法调用多少次后会被 JIT 编译?
💡 答案:
主问题: 方法内联是指 JIT 编译器在编译一个方法时,如果这个方法调用了另一个方法,就直接把被调用方法的代码复制进来形成一个大方法,从而消除方法调用的开销(栈帧创建、参数复制、返回地址压栈等)。方法内联之所以被认为是 JIT 最重要的优化,是因为它不仅是自身优化,还是一个”雪球效应”的基础优化——方法内联后,代码块变大了,JIT 就可以在这个更大的代码块上做进一步的优化,比如:常量折叠(编译期确定的结果)、逃逸分析(被内联的函数中的对象可能原来逃逸但内联后不逃逸了)、死代码消除(被内联函数中的某些分支在调用时参数已确定、可以直接移除)。可以说 JIT 能做的大部分高级优化,都是靠”内联放大代码块、再做后续优化”这个组合拳。
追问1: 分层编译机制是将代码编译分为多个层级,JVM 根据方法的”热度”决定升级到哪个编译级别。C1 编译(0-3 级)是快速编译——生成中等优化程度的代码,编译速度快但生成的机器码质量一般。C2 编译(4 级)是深度编译——做完整的分析优化(内联、逃逸分析、循环展开等),编译耗时长但生成的机器码质量高。分层编译的核心思路是:先用解释执行或 C1 编译快速启动应用,然后后台监控哪些方法是热点,对热点方法用 C2 深度优化;不热的方法没必要花大成本去 C2 编译。这就是”快速启动 + 渐进优化”的平衡策略,比直接把所有方法都用 C2 编译的硬件资源利用率高得多。
追问2: JIT 编译的触发基于”方法调用计数器”和”回边计数器”两个指标。方法调用计数器(Invocation Counter)记录方法被调用的次数,回边计数器(Backedge Counter)记录方法中循环执行了多少次。当方法调用计数达到阈值(默认 C1 是 1500 次,C2 是 10000 次、JDK 8 中默认值),JVM 将该方法提交给 JIT 编译器进行编译。不是说 1501 次就立刻编译——JIT 编译线程在后台工作,方法可能已经执行了 2000 次编译才完成。还有一个重要的概念是 OSR(On Stack Replacement)——如果一个正在解释执行的循环迭代次数非常多还没退出方法时,JIT 会将当前循环体编译并在运行时切换过去,即使方法本身还没达到编译阈值。这种”插队”编译就是通过回边计数器触发的。
📌 易错点 / 加分项:
- 方法内联不是无限制的——有方法大小限制(
MaxInlineSize,默认 35 字节 bytecode),太大会放弃内联 -XX:+PrintInlining可以查看哪些方法被内联了,哪些被拒绝了以及拒绝原因——这是性能调优的利器- GraalVM 的 JIT 编译器(Graal)可以做到更激进的内联和部分逃逸分析