JVM 面试题库(精准备战版)

本文件编排策略

分类逻辑:不按知识模块简单罗列,而是按面试考察意图分层——

  • 第一层:基础认知题(⭐~⭐⭐)—— 考察你是否学过JVM,答不上来直接减分
  • 第二层:原理深度题(⭐⭐⭐)—— 考察你是否真正理解而非背诵,大多数候选人在这层开始分化
  • 第三层:实战与设计题(⭐⭐⭐⭐)—— 考察你能否将知识用于解决问题,高级岗位必考
  • 第四层:底层与前沿题(⭐⭐⭐⭐⭐)—— 高区分度题,答好直接拉开差距

星级说明:⭐ = 面试频率 × 区分度的综合评分,不单纯是难度。


第一层:基础认知题


Q1: JVM运行时数据区有哪些?哪些是线程私有的?

⭐⭐ | 几乎每场必问的开场题

【面试直答版】

JVM运行时数据区分为五大块。线程私有:程序计数器、Java虚拟机栈、本地方法栈。线程共享:堆、方法区。程序计数器是唯一不会OOM的区域。堆是GC的主战场,方法区在JDK8以后由元空间实现,使用本地内存。

【深度解析版】

  • 程序计数器:记录当前线程执行的字节码行号。多线程切换时靠它恢复执行位置。执行Native方法时值为Undefined。
  • 虚拟机栈:每个方法调用创建一个栈帧,包含局部变量表、操作数栈、动态链接、返回地址。-Xss 控制大小,过深递归触发 StackOverflowError
  • 本地方法栈:为Native方法服务。HotSpot中与虚拟机栈合二为一。
  • :对象实例的分配区域。分为新生代(Eden + 2个Survivor)和老年代。-Xms/-Xmx 控制大小。
  • 方法区:存储类元信息、运行时常量池。JDK8前用永久代实现(-XX:MaxPermSize),JDK8+用元空间(-XX:MaxMetaspaceSize),存放在本地内存中。

【追问预警】

  • 字符串常量池在哪?→ JDK7+在堆中
  • 静态变量存在哪?→ JDK7+在堆中(Class对象末尾)
  • 直接内存算不算运行时数据区?→ 不算,但NIO会使用,也可能OOM

Q2: 什么是双亲委派模型?为什么需要它?

⭐⭐ | 类加载必考题

【面试直答版】

双亲委派的核心逻辑是:类加载器收到加载请求时,先委托给父加载器处理,父加载器无法完成时自己才尝试加载。这保证了核心类库的安全性——用户无法写一个 java.lang.Object 来覆盖JDK的版本。同时保证了类的唯一性,同一个类不会被不同加载器重复加载导致类型混乱。

【深度解析版】

三层加载器:Bootstrap(加载核心库)→ Extension/Platform(扩展库)→ Application(classpath)。层级关系不是继承,是组合——每个加载器持有一个parent引用。

加载过程:loadClass() → 检查缓存 → 委托parent → parent失败 → 自己 findClass()

打破双亲委派的场景:

  • SPI机制:核心类(如java.sql.DriverManager)需要加载第三方JDBC驱动。用线程上下文ClassLoader向下委托
  • Tomcat:每个Web应用一个ClassLoader,优先加载自己的类(/WEB-INF/classes),实现应用隔离
  • OSGi/模块化:网状委派结构,支持热部署

【追问预警】

  • 怎么打破双亲委派?→ 重写 loadClass() 方法
  • Class.forName()ClassLoader.loadClass() 的区别?→ 前者默认触发初始化,后者不会
  • 为什么Tomcat要打破?→ 不同应用可能依赖同一个库的不同版本

Q3: JVM中对象是怎么创建的?

⭐⭐ | 考察对象分配流程的掌握

【面试直答版】

五个步骤:①类加载检查,确保类已经加载;②分配内存,堆规整用指针碰撞,不规整用空闲列表,并发安全通过TLAB或CAS保证;③分配的内存空间初始化为零值;④设置对象头信息,包括Mark Word、类型指针;⑤执行构造函数<init>()

【深度解析版】

  • 内存分配方式取决于GC算法:使用带压缩能力的收集器(如Serial、G1),堆内存规整,用指针碰撞(移动指针);使用CMS(标记-清除),堆内存碎片化,用空闲列表
  • TLAB:每个线程在Eden预分配一块私有缓冲区,对象分配时无锁操作(bump-the-pointer),极大减少了并发分配的锁竞争。默认开启
  • 零值初始化:这就是为什么Java中实例变量不赋值也有默认值(int为0,引用为null),而局部变量必须显式赋值

【追问预警】

  • 对象一定在堆上分配吗?→ 不一定。JIT的逃逸分析可能导致标量替换,对象拆散后在栈上分配
  • 对象头里有什么?→ Mark Word(锁信息、hashCode、GC年龄)+ 类型指针 + 对齐填充

Q4: 如何判断对象是否可以被回收?

⭐⭐ | GC入门题

【面试直答版】

JVM使用可达性分析算法。从GC Roots出发,沿引用链遍历,不可达的对象即为可回收。GC Roots包括:虚拟机栈中的局部变量、方法区中的静态属性和常量引用、本地方法栈中的JNI引用、JVM内部引用、被synchronized持有的对象等。

【深度解析版】

不使用引用计数法的原因:无法解决循环引用。A引用B,B引用A,二者都置null后计数仍不为0。

四种引用强度:强 > 软 > 弱 > 虚。

  • 强引用:Object o = new Object(),GC绝不回收
  • 软引用:SoftReference,内存不足时回收,适合做缓存
  • 弱引用:WeakReference,下次GC必回收。WeakHashMapThreadLocal.Entry 的key就是弱引用
  • 虚引用:PhantomReference,无法通过它获取对象,配合 ReferenceQueue 做资源清理

对象真正死亡需要两次标记:第一次不可达后检查 finalize(),如果在 finalize() 中重新建立引用链可以”复活”,但只有一次机会。

【追问预警】

  • ThreadLocal为什么会内存泄漏?→ Entry的key是弱引用(ThreadLocal对象),value是强引用。线程池中线程不销毁,key被GC后value无法访问也无法回收。必须手动 remove()
  • finalize() 可靠吗?→ 不可靠。执行时机不确定,可能拖慢GC,已被 @Deprecated

Q5: 新生代为什么要分Eden和两个Survivor?

⭐⭐ | 理解分代设计的核心

【面试直答版】

新生代98%的对象都是”朝生夕死”的,所以用复制算法效率最高。但如果1:1划分,空间浪费50%。Eden + 两个Survivor的设计(默认8:1:1)让空间浪费降低到只有10%——只有一个Survivor在任何时刻是”空闲”的。

【深度解析版】

工作流程:新对象分配在Eden区,Minor GC时将Eden + 使用中的Survivor(如S0)中的存活对象复制到另一个Survivor(S1),然后清空Eden和S0。下次GC时S0和S1角色互换。

为什么两个Survivor而不是一个?如果只有一个Survivor:

  • Eden存活对象复制到Survivor → Survivor满了怎么办?只能直接进老年代
  • 或者Survivor也做标记清除 → 产生碎片,复制算法的优势全丢了

两个Survivor交替使用,保证了永远有一块干净的空间可以接收存活对象,避免碎片化。

【追问预警】

  • 什么时候对象会直接进老年代?→ ①大对象超过 PretenureSizeThreshold ②年龄达到 MaxTenuringThreshold(默认15)③动态年龄判定:Survivor中某年龄及以下对象总大小超过Survivor一半
  • Survivor区不够大怎么办?→ 分配担保,存活对象直接进老年代

第二层:原理深度题


Q6: 详细说说CMS和G1的区别

⭐⭐⭐ | 收集器对比是面试重灾区

【面试直答版】

CMS是老年代收集器,使用标记-清除算法,目标是最短停顿时间,但会产生内存碎片。G1是全堆收集器,将堆划分为等大小的Region,使用标记-复制算法,既能控制停顿时间又不产生碎片。G1从JDK9起成为默认收集器,CMS在JDK14被移除。

【深度解析版】

维度CMSG1
堆结构连续的新生代 + 老年代Region化(E/S/O/H动态分配)
算法标记-清除标记-复制(Region间)
碎片有碎片,需定期Full GC整理无碎片
停顿可控无法精确控制-XX:MaxGCPauseMillis 精确控制
并发标记算法增量更新(Incremental Update)SATB(Snapshot At The Beginning)
回收范围只收集老年代(搭配ParNew收集新生代)Mixed GC可以同时收集新生代+部分老年代Region
浮动垃圾有,可能导致Concurrent Mode Failure有,但Region化缓解了影响
适用堆大小中等堆(4~8GB)大堆(≥6GB效果好)

CMS的四个阶段:初始标记(STW)→ 并发标记 → 重新标记(STW)→ 并发清除

G1的核心创新

  • 每个Region都有一个”回收价值”评分,优先回收垃圾最多的Region(Garbage First名称由来)
  • Humongous Region处理大对象,避免大对象直接触发Full GC
  • Remember Set + Card Table 维护Region间的引用关系

【追问预警】

  • CMS的 Concurrent Mode Failure 是什么?→ 老年代预留空间不足以容纳并发期间晋升的对象,退化为Serial Old单线程收集。通过降低 CMSInitiatingOccupancyFraction 触发阈值来预防
  • G1什么时候会Full GC?→ 转移失败(to-space exhausted),Region分配不出来时,退化为单线程Full GC
  • SATB和增量更新的区别?→ 增量更新记录新增引用(“我新指向了谁”),SATB记录删除引用(“我之前指向谁”)。SATB对写屏障要求更简单,更适合Region化内存

Q7: ZGC了解吗?它是怎么做到亚毫秒停顿的?

⭐⭐⭐ | 高区分度题——大多数候选人只知道名字

【面试直答版】

ZGC通过染色指针读屏障两个核心技术实现了几乎全程并发的GC,停顿时间控制在亚毫秒级且不随堆大小增长。染色指针在64位指针中嵌入GC元数据,读屏障在用户线程读取对象时检查指针状态并自修复。

【深度解析版】

染色指针(Colored Pointers): 在64位指针的高位使用4个bit标记GC状态(Marked0、Marked1、Remapped、Finalizable)。因为地址只用了44位,所以最大堆为16TB。代价:不支持压缩指针(-XX:-UseCompressedOops)。

读屏障(Load Barrier): 用户线程每次从堆中加载对象引用时,读屏障会检查指针颜色:

  • 如果颜色正确 → 正常访问
  • 如果颜色表明对象已被移动 → 通过转发表找到新地址,修复引用,再访问

这让GC可以与用户线程并发地移动对象——不需要STW来更新所有引用,引用会在访问时”自愈”。

ZGC的GC阶段

  1. 暂停标记开始(STW,极短,仅扫描GC Roots)
  2. 并发标记(与用户线程同时运行)
  3. 暂停标记结束(STW,极短)
  4. 并发转移准备(选择要回收的页面)
  5. 暂停转移开始(STW,极短,仅处理转移根对象)
  6. 并发转移(移动对象,用户线程通过读屏障自修复引用)

三次STW都只处理GC Roots级别的工作,所以停顿与堆大小无关。

JDK21分代ZGC:早期ZGC不分代,全堆扫描。分代ZGC引入了年轻代和老年代的概念,减少了扫描范围,显著提升了吞吐量。

【追问预警】

  • ZGC和Shenandoah的区别?→ ZGC用染色指针+读屏障,Shenandoah用Brooks转发指针+读写屏障。ZGC在Oracle JDK中有,Shenandoah仅OpenJDK
  • ZGC为什么不支持压缩指针?→ 需要指针高位存GC信息,压缩指针只有32位不够用
  • ZGC适合什么场景?→ 大堆(TB级)、延迟敏感(金融交易、实时系统)

Q8: 类加载的过程是怎样的?<clinit>()<init>() 有什么区别?

⭐⭐⭐ | 常见的追问升级题

【面试直答版】

类加载分五步:加载、验证、准备、解析、初始化。<clinit>()类构造器,由编译器收集所有static变量赋值和static代码块合并生成,JVM保证线程安全且只执行一次。<init>()实例构造器,对应Java中的构造方法,每次new都会执行。

【深度解析版】

关键区别:

<clinit>()<init>()
触发时机类初始化时(首次主动使用)new对象时
执行次数每个类只执行一次每个对象执行一次
线程安全JVM保证加锁,多线程安全不保证
内容来源static变量赋值 + static{} 块实例变量赋值 + {} 块 + 构造方法
是否必须类没有static变量/块则不生成每个类至少有一个(默认无参)

<clinit>() 的线程安全性是枚举单例模式的理论基础——类初始化时创建枚举实例,由JVM保证只执行一次且多线程安全。

准备阶段的细节

static int a = 10;          // 准备阶段:a = 0 → 初始化阶段:a = 10
static final int b = 20;    // 准备阶段直接:b = 20(ConstantValue)
static final String c = new String("hello");
                             // 准备阶段:c = null → 初始化阶段:c = new String(...)
                             // 注意:new String()不是编译期常量

【追问预警】

  • 父类和子类的初始化顺序?→ 父类<clinit>() → 子类<clinit>() → 父类<init>() → 子类<init>()
  • 接口有<clinit>()吗?→ 有。但接口的<clinit>()不需要先执行父接口的<clinit>(),只有使用到父接口中定义的变量时才会触发
  • 为什么static final的字符串字面量在准备阶段赋值,而static final new String()不会?→ 字面量是编译期常量,写入ConstantValue属性;new是运行时操作,不是编译期常量

Q9: 什么是逃逸分析?它带来了哪些优化?

⭐⭐⭐ | JIT优化的高频考点

【面试直答版】

逃逸分析是JIT编译器的一项分析技术,判断对象的引用是否会逃逸出方法或线程的范围。如果对象不逃逸,JVM可以做三个优化:栈上分配(对象不进堆,方法结束自动释放)、标量替换(对象打散为基本类型变量)、锁消除(对象不逃逸出线程则同步锁无意义)。

【深度解析版】

逃逸分为两种:

  • 方法逃逸:对象作为参数传给其他方法、作为返回值
  • 线程逃逸:对象被赋值给其他线程可访问的变量(如类变量、实例变量被其他线程引用)
// 不逃逸 → 可以做全部三种优化
public int calculate() {
    Point p = new Point(1, 2);
    return p.x + p.y;
}

// 方法逃逸 → 不能栈上分配,但如果不逃逸出线程仍可以锁消除
public Point createPoint() {
    return new Point(1, 2); // 作为返回值逃逸
}

标量替换比栈上分配更常见——HotSpot实际上通过标量替换间接实现栈上分配的效果:

// 原始代码
Point p = new Point(x, y);
print(p.x + p.y);

// 标量替换后
int p_x = x;
int p_y = y;
print(p_x + p_y);
// Point对象根本没有被创建

开启参数:-XX:+DoEscapeAnalysis(JDK8+默认开启)

【追问预警】

  • 逃逸分析的局限?→ 分析本身需要时间,如果分析后发现对象逃逸了,分析时间就浪费了。对于短时运行的程序可能得不偿失
  • 所有对象都能栈上分配吗?→ 不能。必须不逃逸且能被标量替换。大对象或结构复杂的对象难以标量替换

Q10: String.intern() 在不同JDK版本中的行为有什么区别?

⭐⭐⭐ | 经典版本差异题,答好能体现深度

【面试直答版】

核心区别在于字符串常量池的位置变了。JDK6及之前常量池在永久代,intern() 会把字符串复制一份到永久代。JDK7+常量池移到了堆中,intern() 如果发现堆中已有这个字符串,直接把堆中对象的引用存入常量池,不再复制。

【深度解析版】

String s = new String("a") + new String("b"); // 堆中创建 "ab"
String interned = s.intern();

// JDK6:
//   常量池在永久代,intern()复制"ab"到永久代
//   s 指向堆,interned 指向永久代
//   s == interned → false

// JDK7+:
//   常量池在堆中,intern()发现堆中已有"ab"
//   直接把堆上"ab"的引用存入常量池
//   s 和 interned 指向同一个对象
//   s == interned → true

但如果先有字面量:

String s1 = "ab"; // 常量池中创建 "ab"
String s2 = new String("a") + new String("b"); // 堆中另一个 "ab"
String s3 = s2.intern(); // 常量池已有 "ab",返回常量池的引用
s2 == s3 // false(无论JDK6还是JDK7+)
s1 == s3 // true

【追问预警】

  • new String("abc") 创建了几个对象?→ 1个或2个。如果常量池中没有”abc”则先在常量池创建一个,再在堆上 new 一个。如果已有则只 new 一个
  • 大量使用 intern() 有什么风险?→ JDK6可能撑爆永久代OOM。JDK7+虽然在堆中,但常量池底层是HashTable,大量字符串会导致hash冲突,性能下降。可以通过 -XX:StringTableSize 调大桶数

第三层:实战与设计题


Q11: 线上服务频繁Full GC,怎么排查?

⭐⭐⭐⭐ | 实战第一题,考察系统性排查能力

【面试直答版】

四步走:第一,jstat -gcutil 确认Full GC频率和老年代使用趋势;第二,看GC日志确认是什么原因触发的Full GC;第三,jmap 导出堆转储,用MAT分析大对象和泄漏引用链;第四,根据分析结果定位是内存泄漏、配置不合理还是代码问题。

【深度解析版】

排查路径树

频繁Full GC

    ├─ jstat观察:Old区每次Full GC后使用率是否下降?
    │   │
    │   ├─ 下降了(正常回收)
    │   │   └─ 问题:为什么触发这么频繁?
    │   │       ├─ 新生代太小,大量对象过早晋升 → 增大-Xmn或调整NewRatio
    │   │       ├─ Survivor太小,动态年龄判定导致提前晋升 → 增大SurvivorRatio
    │   │       └─ 大对象直接进老年代 → 检查PretenureSizeThreshold或代码中的大对象分配
    │   │
    │   └─ 没下降(回收不掉)→ 内存泄漏
    │       └─ dump堆 → MAT分析
    │           ├─ Leak Suspects报告
    │           ├─ Dominator Tree看哪个对象占用最多
    │           └─ 看GC Roots到可疑对象的引用链

    ├─ 检查元空间:是否因为Metaspace满触发Full GC?
    │   └─ 动态代理/反射大量生成类?→ 调大MaxMetaspaceSize

    └─ 检查System.gc():是否有代码或框架调用了System.gc()?
        └─ RMI的DGC定期调用 / NIO框架主动GC → -XX:+DisableExplicitGC

常见内存泄漏模式

  1. 静态Map不断放入不移除
  2. ThreadLocal在线程池中未remove
  3. 连接/流未关闭
  4. 监听器注册未注销
  5. 内部类隐式持有外部类引用

【追问预警】

  • 线上不方便dump怎么办?→ -XX:+HeapDumpOnOutOfMemoryError 提前配好,或用 jcmd <pid> GC.heap_dump 在低峰期操作
  • dump文件几个G怎么分析?→ MAT支持打开大文件,但建议在内存足够的机器上分析。也可以用 jmap -histo 先看对象统计,缩小范围

Q12: 如何判断一个应用的JVM参数配置是否合理?

⭐⭐⭐⭐ | 考察调优思维而非背参数

【面试直答版】

看四个核心指标:Young GC频率和耗时、Full GC频率和耗时、GC后堆使用率、停顿时间是否满足业务SLA。健康状态下Young GC应该10~30秒一次,单次<50ms;Full GC应该几小时甚至几天一次,单次<1s。

【深度解析版】

评估框架

指标获取方式健康标准不健康信号
GC吞吐量GC总时间/运行时间> 95%< 90%
Young GC频率jstat YGCT/YGC10~30s/次<1s/次
Young GC耗时jstat YGCT/YGC< 50ms> 200ms
Full GC频率jstat FGC几小时一次几分钟一次
Full GC耗时jstat FGCT/FGC< 1s> 5s
GC后老年代GC日志< 60%> 80%持续不降

常见配置问题

  • -Xms-Xmx:堆大小动态调整触发额外Full GC → 改为相同值
  • 新生代比例过小:对象快速晋升老年代 → 适当增大 -Xmn
  • MetaspaceSize太小:频繁触发Full GC去卸载类 → 设置256m+
  • 使用了CMS但没设 CMSInitiatingOccupancyFraction → 默认92%可能太高,建议70%~80%

【追问预警】

  • -Xms-Xmx 为什么要设一样?→ 不同值时JVM会根据GC情况动态扩缩堆,扩堆可能需要Full GC腾出连续空间
  • G1的 MaxGCPauseMillis 设多少合适?→ 看业务容忍度。200ms是默认值,不要设得太小(如20ms),否则G1会极度收缩回收区域,导致回收不过来反而触发Full GC

Q13: CPU飙到100%怎么排查?

⭐⭐⭐⭐ | 不是纯JVM题,但JVM知识是排查核心

【面试直答版】

四步:top 找到CPU最高的Java进程;top -Hp <pid> 找到最耗CPU的线程;线程ID转16进制;jstack <pid> 导出线程栈,搜索对应线程的堆栈信息。常见原因是死循环、频繁GC、正则回溯。

【深度解析版】

# Step 1: 定位进程
top -c    # 找到CPU最高的java进程PID

# Step 2: 定位线程
top -Hp <pid>    # 找到该进程内CPU最高的线程TID

# Step 3: 转换线程ID
printf "%x\n" <tid>    # 例如:31204 → 79e4

# Step 4: 分析线程栈
jstack <pid> | grep "nid=0x79e4" -A 30

根据线程状态判断原因

jstack中的状态含义常见原因
RUNNABLE线程正在执行死循环、密集计算、正则灾难性回溯
BLOCKED等待获取锁锁竞争严重
WAITING/TIMED_WAITING等待中如果大量线程在这个状态,可能是线程池耗尽
大量线程在GC相关代码GC线程占满CPU堆内存不足,GC一直跑但回收不了多少

GC导致CPU高的排查:如果 jstat 显示GC非常频繁且每次回收效果很差,说明要么内存泄漏,要么堆太小。GC线程持续工作消耗大量CPU。

【追问预警】

  • 线上不能装工具怎么办?→ jstackjstat是JDK自带的,不需要额外安装。如果是容器环境,确保容器里有JDK(不是JRE)
  • Arthas了解吗?→ 阿里开源的Java诊断工具,thread -n 3 直接列出CPU最高的三个线程及其堆栈,更方便

Q14: 什么情况下会发生OOM?分别怎么解决?

⭐⭐⭐⭐ | 系统性考察对JVM内存模型的理解

【面试直答版】

五种常见OOM:堆溢出(对象太多)、栈溢出(递归太深或线程太多)、方法区溢出(类太多)、直接内存溢出(NIO的DirectByteBuffer)、GC overhead(GC耗时超过98%但只回收了不到2%的内存)。

【深度解析版】

OOM类型错误信息常见原因解决方案
堆溢出Java heap space内存泄漏;堆太小;大查询结果未分页dump分析泄漏点;增大-Xmx;优化代码
GC overheadGC overhead limit exceededGC耗时>98%且回收<2%堆空间本质是堆溢出的前兆,同上处理
元空间溢出Metaspace动态代理大量生成类;未卸载ClassLoader增大-XX:MaxMetaspaceSize;检查类加载逻辑
栈溢出StackOverflowError(技术上不是OOM)无限递归;方法调用链过深检查递归终止条件;增大-Xss
无法创建线程unable to create new native thread线程数超过OS限制减少线程数;增大ulimit -u;减小-Xss
直接内存溢出Direct buffer memoryNIO的DirectByteBuffer分配过多增大-XX:MaxDirectMemorySize;检查Buffer是否及时释放

一个反直觉的场景-Xss 越大,可创建的线程数反而越少。因为每个线程都要分配栈空间,总内存有限时栈空间越大,能创建的线程越少。

【追问预警】

  • 堆溢出和内存泄漏的区别?→ 泄漏是”不该活着的对象还活着”,溢出是”空间真的不够了”。泄漏是溢出的常见原因,但不是唯一原因
  • 怎么让JVM OOM时自动dump?→ -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/

Q15: 说说你对G1调优的理解

⭐⭐⭐⭐ | 高级岗位必问

【面试直答版】

G1的调优哲学是”少干预”——首先设好堆大小和 MaxGCPauseMillis 目标,G1自己会调节新生代大小、晋升阈值等。如果达不到停顿目标,再逐步调整。核心原则:不要手动设置新生代大小(-Xmn),这会禁用G1的自适应调节。

【深度解析版】

G1调优步骤

  1. 基线设置

    -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200

    先跑起来看GC日志。

  2. 如果Mixed GC跟不上老年代增长

    • 降低 -XX:InitiatingHeapOccupancyPercent(默认45%)→ 提前启动并发标记
    • 增大 -XX:ConcGCThreads(并发标记线程数)
  3. 如果停顿时间超标

    • 减小 -XX:G1HeapRegionSize(更细粒度的Region回收)
    • 减小 -XX:G1MixedGCCountTarget(默认8次,减小意味着每次Mixed GC回收更多Region)
  4. 如果有大对象问题

    • 大对象分配在Humongous Region,可能导致碎片化
    • 增大 Region Size 让大对象不被视为Humongous
    • 或优化代码减少大对象(常见:大byte数组)

G1调优的禁忌

  • ❌ 不要设 -Xmn-XX:NewRatio——会禁用G1的自适应新生代调节
  • ❌ 不要把 MaxGCPauseMillis 设得太低——G1会拼命缩小回收范围,反而适得其反
  • ❌ 不要开启 -XX:+UseAdaptiveSizePolicy——G1有自己的自适应策略

【追问预警】

  • G1的Region大小怎么设?→ 默认自动计算(堆大小/2048),范围1~32MB,必须是2的幂。一般不需要手动设置
  • G1的Full GC是什么算法?→ JDK10前是单线程标记-整理(Serial Old),JDK10+是多线程并行的Full GC

第四层:底层与前沿题(高区分度)


Q16: 安全点和安全区域是什么?为什么GC需要它们?

⭐⭐⭐⭐⭐ | 高区分度题 —— 能答好说明真正理解了GC的并发机制

【面试直答版】

安全点是代码中特定的位置,只有线程执行到安全点时GC才能开始。因为GC需要一个一致性快照——对象引用关系在GC过程中不能变。安全区域是一段代码区间,在这个区间内引用关系不变化,用于处理线程处于Sleep或Blocked状态不能主动走到安全点的情况。

【深度解析版】

为什么不能在任意位置GC?

GC需要遍历所有GC Roots和引用关系,如果线程正在执行过程中引用关系在变,GC的结果就不准确。所以必须让所有线程停在一个”安全的位置”——此时的引用关系是确定的。

安全点(Safepoint)的位置选择

  • 方法调用
  • 循环跳转(回边)
  • 异常跳转

这些位置的特点是”程序执行不会太久不遇到”。如果选太少,GC要等很久;选太多,运行时检查开销大。

怎么让线程跑到安全点?

两种方案:

  • 抢先式中断(几乎不使用):GC先中断所有线程,没在安全点的恢复执行直到走到安全点
  • 主动式中断(主流方案):GC设一个标志位,线程在安全点检查标志位,发现需要GC就主动挂起

安全区域(Safe Region): 线程在Sleep/Blocked状态时不能主动走到安全点。但这些线程的引用关系不会变化,所以可以标记为”在安全区域内”。GC不需要等这些线程,直接开始。线程要离开安全区域时需要检查GC是否完成。

【追问预警】

  • 安全点对性能有影响吗?→ 有。“Time To Safepoint”是一个重要性能指标。如果某些线程迟迟到不了安全点(比如大的uncounted loop),会拖慢所有线程(所有线程必须都到安全点GC才能开始)
  • JDK中怎么看安全点信息?→ -XX:+PrintSafepointStatistics(JDK8)或 -Xlog:safepoint(JDK9+)

Q17: 方法调用在JVM层面是怎么实现的?解释分派机制

⭐⭐⭐⭐⭐ | 高区分度题 —— 连接了字节码指令和多态机制

【面试直答版】

JVM有五条方法调用指令:invokestatic(静态方法)、invokespecial(构造器/私有/super)、invokevirtual(虚方法)、invokeinterface(接口方法)、invokedynamic(动态调用,Lambda用的)。前两个在编译期就确定了调用目标(静态分派),后面的需要运行时确定(动态分派)。多态的实现就依赖 invokevirtual 的动态分派——通过虚方法表查找实际类型的方法实现。

【深度解析版】

静态分派(编译期确定):

Human man = new Man();
say(man); // 编译期根据man的静态类型(Human)选择say(Human)
          // 这就是为什么方法重载在编译期确定

动态分派(运行时确定):

Human man = new Man();
man.speak(); // 运行时根据man的实际类型(Man)查找speak()
             // 这就是多态/方法重写的实现原理

invokevirtual 的查找过程

  1. 找到操作数栈顶元素的实际类型 C
  2. 在类型C中查找方法,找到则权限检查,通过则返回
  3. 找不到则沿继承链向上查找父类
  4. 都找不到 → AbstractMethodError

虚方法表(vtable)优化: 每次都沿继承链查找太慢,所以JVM为每个类建一个虚方法表。表中存放各个方法的实际入口地址。子类重写了父类方法→对应slot指向子类实现;没重写→指向父类实现。

Human的vtable:          Man的vtable:
┌───────┬──────────┐   ┌───────┬──────────┐
│speak()│Human.speak│   │speak()│Man.speak │  ← 重写了
│eat()  │Human.eat  │   │eat()  │Human.eat │  ← 没重写,指向父类
└───────┴──────────┘   └───────┴──────────┘

invokedynamic(JDK7引入):不再绑定具体类,而是由用户代码(Bootstrap Method)在运行时决定调用目标。Java中Lambda表达式就是通过它实现的——编译时生成 invokedynamic 指令,运行时通过 LambdaMetafactory 动态生成实现类。

【追问预警】

  • Lambda为什么用invokedynamic而不是内部类?→ invokedynamic更灵活,JVM可以选择最优策略(生成类、方法句柄等),且不在编译期产生大量内部类文件
  • 重载和重写的本质区别?→ 重载是静态分派(编译期,看静态类型),重写是动态分派(运行时,看实际类型)

Q18: JMM(Java内存模型)和JVM内存结构是一回事吗?

⭐⭐⭐⭐⭐ | 高区分度题 —— 大量候选人混淆这两个概念

【面试直答版】

完全不是一回事。JVM内存结构是运行时数据区的物理划分——堆、栈、方法区等。JMM(Java Memory Model)是并发编程的抽象规范——定义了线程如何通过主内存和工作内存交互,规定了 volatilesynchronizedfinal 的内存语义,解决的是可见性、有序性、原子性问题。

【深度解析版】

JMM的核心抽象

线程A的工作内存          主内存          线程B的工作内存
┌─────────────┐    ┌──────────┐    ┌─────────────┐
│  变量x的副本  │←──→│  变量x    │←──→│  变量x的副本  │
│  变量y的副本  │←──→│  变量y    │←──→│  变量y的副本  │
└─────────────┘    └──────────┘    └─────────────┘

每个线程有自己的工作内存(CPU缓存/寄存器的抽象),对变量的操作在工作内存中进行,通过主内存同步。

JMM定义的8个原子操作:lock、unlock、read、load、use、assign、store、write。

happens-before规则(JMM的核心):

  1. 程序顺序规则:同一线程内,前面的操作happens-before后面的
  2. volatile变量规则:写happens-before读
  3. 锁规则:unlock happens-before后续的lock
  4. 传递性:A hb B,B hb C → A hb C
  5. 线程start规则:start() happens-before子线程的任何操作
  6. 线程join规则:子线程的任何操作happens-before join() 返回

指令重排序:编译器和CPU可能重排指令顺序来优化性能,但必须遵守happens-before规则。volatile 通过内存屏障禁止特定重排序。

【追问预警】

  • volatile 的底层实现?→ 写volatile变量时插入StoreStore和StoreLoad屏障,读时插入LoadLoad和LoadStore屏障。x86架构下写volatile使用lock前缀指令
  • DCL(双重检查锁)单例为什么需要volatile?→ 因为 instance = new Singleton() 分三步:分配内存→初始化→赋值引用。指令重排可能导致赋值在初始化之前,其他线程拿到未初始化完的对象

Q19: 什么是卡表和写屏障?在GC中起什么作用?

⭐⭐⭐⭐⭐ | 高区分度题 —— 理解跨代引用问题的关键

【面试直答版】

卡表是记忆集的实现方式。它将老年代划分为固定大小的卡页(通常512字节),用一个字节数组记录每个卡页是否包含指向新生代的引用。Minor GC时只需扫描”脏卡”对应的老年代区域,而不是整个老年代。写屏障是在引用赋值操作前后插入的额外代码,用于在引用关系变化时维护卡表的脏标记。

【深度解析版】

为什么需要卡表?

Minor GC只回收新生代,但老年代可能引用新生代对象。为了找到这些跨代引用,最笨的办法是扫描整个老年代——代价太大。卡表把扫描范围从”整个老年代”缩小到”有跨代引用的卡页”。

老年代内存空间(被划分为512字节一个的Card Page):
┌────────┬────────┬────────┬────────┬────────┐
│ Page 0 │ Page 1 │ Page 2 │ Page 3 │ Page 4 │
└────────┴────────┴────────┴────────┴────────┘

Card Table(字节数组):
┌───┬───┬───┬───┬───┐
│ 0 │ 1 │ 0 │ 1 │ 0 │   1=脏卡(该Page有对新生代的引用)
└───┴───┴───┴───┴───┘

Minor GC时只扫描 Page 1 和 Page 3

写屏障(Write Barrier)

不是内存屏障(Memory Barrier),而是JVM在引用赋值字节码周围插入的额外逻辑:

// 伪代码:引用赋值
void oop_field_store(oop* field, oop value) {
    *field = value;                           // 实际赋值
    post_write_barrier(field, value);         // 写后屏障:标记卡表
}

void post_write_barrier(oop* field, oop value) {
    card_table[((uintptr_t)field) >> 9] = DIRTY;  // >> 9 = / 512
}

写屏障的问题——伪共享: 卡表在内存中是连续的字节数组。多个线程更新相邻卡表项时,可能位于同一CPU缓存行(64字节),导致缓存行频繁失效。JDK7+ 提供了 -XX:+UseCondCardMark——先检查卡表项是否已经是脏的,是则不写(避免无谓的缓存行失效)。

G1中的Remember Set: G1的Region没有固定的”新生代/老年代”物理分区,任意Region之间都可能有引用。G1为每个Region维护一个Remember Set,记录”谁引用了我”。这比全局卡表更精细,但内存开销也更大(通常占堆的10%~20%)。

【追问预警】

  • 写屏障和volatile的内存屏障是一回事吗?→ 完全不同。写屏障是GC用来维护引用关系元数据的代码片段,内存屏障是CPU指令级的内存可见性保证
  • G1的RSet开销大怎么办?→ 这是G1的已知问题。ZGC通过染色指针+读屏障的方案避免了维护RSet

Q20: 从字节码角度解释 try-catch-finally 的实现

⭐⭐⭐⭐⭐ | 高区分度题 —— 考察对字节码层面的理解

【面试直答版】

编译器为 try-catch-finally 生成一个异常表(Exception Table)。异常表每行记录了:监控的字节码范围(from~to)、异常处理器的位置(target)、捕获的异常类型。finally块不是通过特殊机制实现的,而是将finally的代码复制到每个可能的出口路径中——正常返回路径、每个catch末尾、以及一个兜底的any异常处理器中。

【深度解析版】

try {
    int a = 1;
} catch (Exception e) {
    int b = 2;
} finally {
    int c = 3;
}

编译后的异常表结构:

Exception table:
  from  to  target  type
    0    4    7     Class Exception    // try块范围[0,4),Exception处理在7
    0    4   15     any                // try块范围[0,4),任意异常处理在15(finally的兜底)
    7   11   15     any                // catch块范围[7,11),任意异常处理在15

finally的本质: 编译器将 int c = 3 这段代码分别复制到:

  1. try块正常结束后
  2. catch块正常结束后
  3. 一个type为any的异常处理器中(用于try或catch中抛出未捕获的异常时仍然执行finally)

经典面试题:finally中return的诡异行为

public static int test() {
    try {
        return 1;
    } finally {
        return 2;
    }
}
// 返回2,不是1

字节码层面:try中的 return 1 先将1存入局部变量表的临时slot,然后执行finally的代码(复制过来的),finally中的 return 2 直接返回2,覆盖了原来的返回。

public static int test() {
    int x = 0;
    try {
        x = 1;
        return x;
    } finally {
        x = 2;
    }
}
// 返回1,不是2

字节码层面:return x 先将x的值(1)复制到临时slot,然后执行finally(x改为2),但return用的是临时slot中保存的值(1)。

【追问预警】

  • JDK6之前的finally实现?→ 使用 jsr/ret 指令跳转到finally子程序,JDK6+改为代码复制方式
  • Throwable.addSuppressed() 是怎么回事?→ try-with-resources中,如果try块和close()都抛异常,close()的异常会通过addSuppressed附加到主异常上,不会丢失

Q21: 什么是JIT的去优化(Deoptimization)?什么时候会触发?

⭐⭐⭐⭐⭐ | 高区分度题 —— 理解JIT不是”编译一次永远生效”

【面试直答版】

去优化是JIT将已编译的本地代码作废,退回到解释执行的过程。当JIT基于乐观假设做的优化被运行时事实推翻时就会触发。典型场景:类层次变化导致内联的虚方法不再正确,或者分支预测的某个”从未走过的分支”被走到了。

【深度解析版】

JIT为了追求极致性能,会做很多推测性优化(Speculative Optimization)

  1. 类型推测内联(Speculative Inlining)

    void process(Animal a) { a.speak(); }

    如果运行一段时间内a总是Dog类型,JIT会直接内联 Dog.speak() 的代码。一旦传入Cat → 类型推测失败 → 去优化

  2. 未捕获异常路径优化: JIT发现某个异常从未被抛出 → 去掉异常处理相关代码。一旦异常真的发生 → 去优化

  3. 类加载导致类层次变化: JIT看到某个方法只有一个实现 → 直接内联。后来加载了新的子类覆盖了该方法 → 去优化

去优化的代价

  • 退回解释执行,性能骤降(可能是10x~100x的差距)
  • 然后重新收集性能数据,重新JIT编译
  • 如果反复去优化 → JIT可能放弃优化该代码,标记为”don’t compile”

实际影响

  • 这就是为什么Java应用有”预热”阶段——JIT需要时间收集数据并编译优化
  • 也是为什么基准测试需要预热若干轮再测量

【追问预警】

  • 怎么观察去优化?→ -XX:+PrintCompilation-XX:+TraceDeoptimization
  • AOT编译(GraalVM Native Image)和JIT的区别?→ AOT提前编译,启动快但缺少运行时信息无法做推测性优化。JIT启动慢但峰值性能通常更高

Q22: 讲讲你对GC中”三色标记”算法的理解

⭐⭐⭐⭐⭐ | 高区分度题 —— CMS/G1/ZGC并发标记的核心理论

【面试直答版】

三色标记是并发GC标记阶段的核心算法。白色代表未访问,灰色代表已访问但其引用还未全部扫描,黑色代表已访问且所有引用都已扫描。标记过程就是将所有可达对象从白色变为黑色。并发标记的难点是用户线程同时在修改引用关系,可能导致漏标(存活对象被错误回收)。CMS用增量更新解决,G1用SATB解决。

【深度解析版】

标记过程

初始状态:GC Roots → 灰色,其余所有对象 → 白色

循环:取出一个灰色对象
      ├─ 将它所有引用的白色对象标记为灰色
      └─ 将自己标记为黑色
直到没有灰色对象

结果:白色 = 垃圾,黑色 = 存活

并发标记的问题——漏标

漏标的充要条件(同时满足才会漏标):

  1. 赋值器插入了一条从黑色对象到白色对象的新引用
  2. 赋值器删除了所有从灰色对象到该白色对象的引用
标记中:
  A(黑) → B(灰) → C(白)

用户线程操作:
  A.ref = C    // 黑色指向白色(条件1)
  B.ref = null // 灰色到白色的引用断开(条件2)

结果:
  A(黑) → C(白)   B(灰) → null
  A已经是黑色不会再扫描,C永远不会被标记为灰色
  C被漏标 → 存活对象被错误回收!

两种解决方案(打破任一条件即可):

方案打破条件实现使用者
增量更新打破条件1记录黑色对象新增的引用。重新标记时以这些黑色对象为根再扫描一次CMS
SATB打破条件2记录灰色对象删除的引用。被删除的引用目标视为存活(可能产生浮动垃圾,但不会漏标)G1

SATB的精妙之处:在并发标记开始时对引用关系做”快照”(逻辑上的),之后删除的引用仍被认为存在。这意味着可能多标(浮动垃圾),但保证不漏标。浮动垃圾在下一轮GC回收,代价可接受。

【追问预警】

  • 为什么G1选SATB而不是增量更新?→ 增量更新在重新标记阶段需要重新扫描黑色对象的引用,开销大。SATB只需处理写屏障缓冲区,且重新标记阶段的工作量更可控
  • ZGC用什么方案?→ ZGC不用三色标记+写屏障的方式,而是用染色指针+读屏障。在读取引用时检查指针颜色来保证标记正确性

Q23: 类加载器的命名空间隔离在实际系统中有什么应用?

⭐⭐⭐⭐⭐ | 高区分度题 —— 从理论到实际架构

【面试直答版】

同一个类被不同的类加载器加载后,在JVM中是两个不同的类型——它们的 Class 对象不同,instanceof 互不通过。这个特性被广泛应用于模块隔离。Tomcat用它实现Web应用隔离(不同应用可以依赖同一个库的不同版本),OSGi用它实现热部署,以及各种插件系统。

【深度解析版】

Tomcat的ClassLoader体系

          Bootstrap

          Extension

          Application

       ┌──────┴──────┐
    Common CL      Catalina CL

   ┌───┴───┐
WebApp1 CL  WebApp2 CL
  • Common ClassLoader:加载Tomcat共享库,两个WebApp都能看到
  • WebApp ClassLoader:每个应用独立,优先加载自己 /WEB-INF/classes/WEB-INF/lib
  • WebApp1依赖Spring 4,WebApp2依赖Spring 5 → 各自的ClassLoader加载各自的Spring → 互不干扰

打破了双亲委派:WebApp ClassLoader先自己加载,加载不了再委托给Common → 与标准双亲委派相反。但对于java.*等核心类,仍然遵循双亲委派(安全保障)。

热部署的原理

  1. 创建新的ClassLoader
  2. 用新ClassLoader加载修改后的类
  3. 旧ClassLoader及其加载的类可以被GC(前提:无引用指向旧ClassLoader和它加载的任何类)
  4. 这就是为什么类卸载条件很苛刻——所有实例被回收、Class对象无引用、ClassLoader被回收

【追问预警】

  • 为什么不能热替换已加载的类?→ JVM规范不允许重新定义已加载的类(标准机制下)。只能通过新ClassLoader加载新版本的类
  • Java Agent的类重定义是怎么回事?→ Instrumentation.redefineClasses() 可以替换已加载类的字节码,但有严格限制(不能改类结构,只能改方法体)

Q24: 说说你对JVM内存分配和回收策略的整体理解

⭐⭐⭐⭐ | 综合题——考察对全局的把握

【面试直答版】

JVM的内存策略围绕一个核心观察:绝大多数对象朝生夕死,少数长期存活。所以用分代收集——新生代用复制算法高频快速回收短命对象,老年代用标记-整理/清除低频处理长寿对象。分配上,TLAB实现无锁快速分配,大对象直接进老年代避免复制开销。晋升通过年龄阈值和动态年龄判定来平衡新生代和老年代的压力。

【深度解析版】

分配策略的完整链路

new 对象

  ├─ 栈上分配?(逃逸分析+标量替换)→ 是 → 栈上,随方法结束释放

  ├─ TLAB分配?→ 是 → 线程私有Eden区,无锁bump-the-pointer

  ├─ 大对象?→ 是 → 直接老年代(避免在新生代来回复制)

  └─ Eden区 CAS分配

回收策略的全局视角

Minor GC(频繁,快速)

  │ 存活对象 → Survivor区交替复制
  │ 年龄达标 → 晋升老年代
  │ Survivor不够 → 分配担保进老年代


老年代逐渐填满

  ├─ CMS: 并发标记清除(尽量不停顿)
  │       失败时退化为Serial Old(大停顿)

  ├─ G1: Mixed GC 选择性回收高价值Region
  │      失败时Full GC(JDK10+并行)

  └─ ZGC: 几乎全程并发,亚毫秒停顿

关键的设计权衡

  • 空间 vs 时间:复制算法浪费空间换取无碎片高效回收
  • 吞吐量 vs 延迟:Parallel优化吞吐量(适合批处理),CMS/G1/ZGC优化延迟(适合在线服务)
  • 精确性 vs 开销:G1的RSet精确记录跨Region引用但内存开销大(10%~20%),ZGC用读屏障省去了RSet但每次读取都有额外检查

【追问预警】

  • 不分代可以吗?→ 可以(早期ZGC就不分代),但效率低——每次GC扫描全堆。分代利用了对象生命周期的统计规律来减少工作量
  • GraalVM的Epsilon GC了解吗?→ 只分配不回收的”no-op” GC,用于性能基准测试或确定无GC需求的短期任务

附录:面试高频参数速查

参数作用默认值
-Xms / -Xmx堆初始/最大大小物理内存1/64 / 物理内存1/4
-Xmn新生代大小堆的1/3
-Xss线程栈大小512K~1M(OS相关)
-XX:MetaspaceSize元空间初始大小约20M
-XX:MaxMetaspaceSize元空间最大大小无限制
-XX:NewRatio老年代/新生代比例2
-XX:SurvivorRatioEden/Survivor比例8
-XX:MaxTenuringThreshold晋升年龄阈值15(CMS为6)
-XX:PretenureSizeThreshold大对象直接进老年代的阈值0(不限制)
-XX:MaxGCPauseMillis目标最大GC停顿200ms(G1)
-XX:InitiatingHeapOccupancyPercentG1触发并发标记的堆占用率45%
-XX:CMSInitiatingOccupancyFractionCMS触发的老年代占用率92%(JDK6+)
-XX:ParallelGCThreads并行GC线程数CPU核数(≤8时),否则 8+(CPU-8)*5/8
-XX:ConcGCThreads并发GC线程数ParallelGCThreads/4
-XX:+UseCompressedOops压缩指针64位JVM堆<32G时默认开启
-XX:+HeapDumpOnOutOfMemoryErrorOOM时自动dump默认关闭