📖 学习指南
🎯 学习目标 :通过本文,你将系统掌握 JVM 的核心知识,能够自信地应对任何相关面试问题。
适合人群
🔰 初学者 :想系统学习 JVM 的开发者
🚀 有经验者 :想深入理解 JVM 原理的高级开发者
💼 面试准备者 :想刷 JVM 面试题的求职者
学习建议
先理解概念,再深入原理 :先搞懂”是什么”,再搞懂”为什么”
结合实战场景 :不要死记硬背,要理解实际应用场景
动手实践 :在自己电脑上安装环境,执行本文的示例代码
反复复习 :面试前一周,每天复习 10 个问题
学习时间估算
⏱️ 快速复习 (只看一句话总结):2 小时
📚 系统学习 (看深度解析):1-2 天
💪 深入理解 (研究源码):1 周+
🗺️ 知识图谱 mindmap
root((JVM))
基础概念
核心概念1
核心概念2
核心概念3
高级特性
特性1
特性2
实战应用
应用场景1
应用场景2
性能优化
优化技巧1
优化技巧2
⚠️ 常见陷阱与误区 陷阱 1:概念理解错误 ❌ 错误示例 :
✅ 正确做法 :
陷阱 2:忽略边界条件 ❌ 错误做法 :
✅ 正确做法 :
💡 面试技巧 技巧 1:结构化回答 不要只回答”是什么”,要按照以下结构回答:
一句话总结 (概念)
深度解析 (原理、实现、优缺点)
面试加分回答 (实际项目经验、源码理解、行业最佳实践)
技巧 2:结合实战场景 不要只背概念,要结合实际项目经验回答。
技巧 3:引导到你会的方向 如果遇到不会的问题,不要慌,可以引导到你会的方向。
🎯 实战演练(真实面试场景) 场景 1:请你设计一个系统? 回答思路 :
需求分析 :明确系统需求
技术选型 :选择合适的技术栈
架构设计 :设计系统架构
性能优化 :考虑性能瓶颈和优化方案
🚀 学习路径总结 第一阶段:基础概念(1-2 天)
第二阶段:高级特性(2-3 天)
第三阶段:实战应用(1 周+)
第四阶段:面试准备(1 周)
📚 扩展学习资源 官方资源
书籍推荐
博客推荐
JVM 面试八股文30条(深度详解版)
本文力求把每个知识点讲透,不只是背答案,而是理解「为什么」。 适用于 Java 后端开发面试深入复习,建议结合实战场景理解。
一、JVM 内存区域(1-6)
第 1 题:JVM 的内存区域是怎么划分的? 一句话结论
JVM 内存 = 堆 + 虚拟机栈 + 本地方法栈 + 程序计数器 + 元空间。Java 8 之后,方法区(永久代)被元空间(Metaspace)取代。
深度解析 JVM 内存区域全景图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ┌─────────────────────────────────────────────────────┐ │ JVM 内存区域 │ ├──────────────────┬──────────────────────────────────┤ │ │ 元空间(Metaspace) │ │ │ (类元数据、常量池、方法字节码) │ │ ├──────────────────────────────────┤ │ 所有线程共享 │ 堆(Heap) │ │ │ (对象实例、数组) │ │ │ 分代:新生代 + 老年代 │ ├──────────────────┼──────────────────────────────────┤ │ │ 虚拟机栈(Stack) │ │ 线程私有 │ (栈帧:局部变量表、操作数栈) │ │ │ 本地方法栈(Native 方法) │ │ │ 程序计数器(PC Register) │ └──────────────────┴──────────────────────────────────┘
各区域详解:
① 程序计数器(PC Register)
1 2 3 4 int a = 1 ; int b = 2 ; int c = a + b;
程序计数器记录的是当前线程正在执行的字节码指令的地址 。
如果执行的是 Java 方法,存的是字节码指令地址 ;
如果执行的是 Native 方法,存的是 undefined 。
为什么需要程序计数器? → 线程切换后,要恢复到正确的执行位置。为什么是线程私有? → 每个线程的执行位置不同,不能共享。
② 虚拟机栈(JVM Stack)
1 2 3 4 5 6 7 8 public void methodA () { int x = 1 ; methodB(); }public void methodB () { int y = 2 ; }
栈的结构:
1 2 3 4 5 6 7 8 9 10 栈(线程私有): [栈帧 B ] ← 栈顶(正在执行的方法) | 局部变量表 | 操作数栈 | 动态链接 | 方法返回地址 [栈帧 A ] | ... [栈帧 main] | ...
栈帧(Stack Frame) :每个方法执行时都会创建一个栈帧,方法结束时栈帧出栈。
局部变量表 :存放方法参数和局部变量(编译时确定大小)。操作数栈 :字节码指令的工作区(如 iadd 会从操作数栈弹出两个数,相加后压栈)。
StackOverflowError:
1 2 3 4 5 public void infiniteRecursion () { infiniteRecursion(); }
③ 本地方法栈(Native Method Stack)
和虚拟机栈类似,但是为 Native 方法 (如 System.currentTimeMillis())服务。 HotSpot 虚拟机中,本地方法栈和虚拟机栈是合二为一 的。
④ 堆(Heap)★ 最重要的区域
1 2 3 Object obj = new Object (); int [] arr = new int [100 ];
堆的结构(分代收集理论):
1 2 3 4 5 6 7 8 9 10 11 12 堆(所有线程共享): ┌────────────────────────────────┐ │ 新生代(Young) │ │ ┌─────────┬─────────┬──────┐ │ │ │ Eden │ From │ To │ │ │ │ (伊甸园)│(幸存区) │(幸存区)│ │ │ │ 80 % │ 10 % │ 10 % │ │ │ └─────────┴─────────┴──────┘ │ ├────────────────────────────────┤ │ 老年代(Old ) │ │ (存活时间长的对象) │ └────────────────────────────────┘
为什么分代? → 研究表明:大多数对象都是「朝生夕死」的(IBM 研究:98% 的对象活不过一次 GC)。 → 分代后,可以对不同代使用不同的垃圾回收算法 ,提高 GC 效率。
⑤ 元空间(Metaspace,Java 8+)
Java 8 之前 :方法区 = 永久代(PermGen),在堆上,容易 OOM。Java 8 之后 :方法区 = 元空间(Metaspace),在本地内存 (Native Memory)中,不再受 -XX:MaxPermSize 限制。
元空间中存放什么?
1 2 3 4 5 1. 类的元数据(Class 对象、字段、方法、字节码)2. 运行时常量池(Runtime Constant Pool)3. 方法字节码4. JIT 编译后的本地代码缓存
为什么要用元空间替代永久代?
1 2 3 4 5 6 7 String s1 = "abc" ;String s2 = new String ("abc" );
面试加分回答
「JVM 内存区域是理解 GC、OOM、性能调优的基础。面试时经常追问:字符串常量池在哪里? 答案是:Java 7 之前在永久代,Java 7 开始移到堆中。另外,直接内存(Direct Memory) 不是 JVM 运行时数据区的一部分,但 NIO 的 DirectByteBuffer 会分配直接内存,不受 Java 堆限制,所以 -Xmx 设置得再大,也可能发生 OOM(直接内存溢出)。」
一句话结论
永久代容易 OOM(大小固定,难调优),元空间使用本地内存(理论上无限),且 GC 效率更高(元空间中的类元数据只在 ClassLoader 卸载时回收,不需要频繁 GC)。
深度解析 永久代的问题:
问题 ①:大小难调 → 永久代的大小在启动时固定(-XX:MaxPermSize),如果设置太小,容易 OOM;如果设置太大,浪费内存。
问题 ②:GC 复杂 → 永久代在堆上,会被 Full GC 回收(永久代的 GC 和老年代绑定),但类元数据的回收条件很苛刻(ClassLoader 卸载),导致 Full GC 频繁但效果不好。
元空间的优势:
1 2 3 4 -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m
优势 ①:自动扩容 → 元空间使用本地内存,可以动态扩容(直到物理内存耗尽)。
优势 ②:GC 更简单 → 元空间中的类元数据只在 ClassLoader 卸载 时回收(此时元数据全部可回收),不需要像永久代那样复杂的可达性分析。
优势 ③:字符串常量池移到堆中 → Java 7 开始,字符串常量池从永久代移到堆中,便于 GC 回收不再使用的字符串。
元空间的内存管理:
1 2 3 4 1. 当需要存储类元数据时,从操作系统申请内存2. 当类元数据不再使用时(ClassLoader 卸载),释放内存给操作系统3. 元空间有专门的垃圾回收器(Metaspace GC),但不频繁触发
面试加分回答
「元空间和永久代的区别是 JVM 内存管理的一个经典改进。面试时可能会追问:元空间会不会 OOM? 答案是:会!虽然元空间默认不限制大小,但如果设置了 -XX:MaxMetaspaceSize,或者物理内存耗尽,仍然会 OOM(MetaspaceError)。另外,元空间的 GC 触发条件是:元空间使用率达到 -XX:MetaspaceSize 时触发 Full GC ,所以如果元空间增长很快(如大量使用动态代理),需要调整 MetaspaceSize 避免频繁 Full GC。」
第 3 题:对象的创建过程是怎样的? 一句话结论
new Object() 的过程 = 类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行 <init> 方法(构造函数)。
深度解析 完整流程:
1 Object obj = new Object ();
步骤 ①:类加载检查
1 2 3 1. 这个类是否已经被加载、解析、初始化?2. 如果没有 → 执行类加载过程(见第 16 题)
步骤 ②:分配内存
内存分配的两个方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 方式 1:指针碰撞(Bump the Pointer) → 适用于:堆内存规整(没有内存碎片) → 原理:用一个指针分隔「已用内存」和「空闲内存」 分配内存时,指针向空闲端移动对象大小的距离 |已用内存 | 空闲内存 | ↑ 指针 → 分配 16 字节:指针向后移动 16 字节 方式 2:空闲列表(Free List) → 适用于:堆内存不规整(有内存碎片) → 原理:JVM 维护一个「空闲内存块列表」 分配时从列表中找到足够大的块
哪种方式取决于垃圾收集器是否有压缩整理功能:
指针碰撞 :Serial、ParNew(标记-复制算法,内存规整)
空闲列表 :CMS(标记-清除算法,会产生碎片)
并发安全问题:
步骤 ③:初始化零值
1 2 3 4 int a; boolean flag; Object obj;
这步操作保证了 Java 代码中的成员变量可以不赋初值就直接使用 (有默认零值)。
步骤 ④:设置对象头(Object Header)
1 2 3 4 5 1. Mark Word(标记字段,8 字节) → 存储:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID2. Klass Pointer(类型指针,4 或 8 字节) → 指向该对象的类元数据(存在元空间中)
Mark Word 的状态变化:
1 2 3 4 5 6 7 8 9 10 Mark Word (32 位 JVM ,25 位可用): ┌──────────────────────────────────────────────┐ │ 状态 │ 存储内容 │ ├──────────────────────────────────────────────┤ │ 未锁定 │ 哈希码 + 分代年龄 + 无锁标志 │ │ 偏向锁 │ 偏向线程 ID + 时间戳 + 分代年龄│ │ 轻量级锁 │ 指向栈中锁记录的指针 │ │ 重量级锁 │ 指向互斥量(Monitor )的指针 │ │ GC 标记 │ 空(CMS 垃圾收集时使用) │ └──────────────────────────────────────────────┘
步骤 ⑤:执行 <init> 方法(构造函数)
1 2 3 4 5 6 7 8 9 10 11 12 public class Person { private int age = 25 ; private String name = "Alice" ; public Person () { } }
面试加分回答
「对象的创建过程涉及很多细节,面试时经常追问:对象一定在堆上分配吗? 答案是:不一定!JVM 有 逃逸分析(Escape Analysis) 优化,如果一个对象没有逃逸出方法(即不会被其他线程访问),JVM 可以在栈上分配对象 (栈上分配的对象,方法结束时自动销毁,不需要 GC)。另外,标量替换 (把对象拆成基本类型)也是逃逸分析的一个优化手段。」
第 4 题:对象的内存布局是怎样的? 一句话结论
对象在内存中 = 对象头(Header)+ 实例数据(Instance Data)+ 对齐填充(Padding)。对象头包含 Mark Word 和 Klass Pointer。
深度解析 对象内存布局:
1 2 3 4 5 6 7 8 9 10 11 12 13 ┌─────────────────────────────────────────────────┐ │ Java 对象在内存中 │ ├──────────────┬──────────────────────────────────┤ │ 对象头 │ Mark Word(8 字节) │ │ (Header ) │ Klass Pointer(4 /8 字节) │ ├──────────────┼──────────────────────────────────┤ │ 实例数据 │ 成员变量 a (4 字节) │ │ (Instance │ 成员变量 b (8 字节) │ │ Data) │ ... │ ├──────────────┼──────────────────────────────────┤ │ 对齐填充 │ 填充到 8 字节的倍数 │ │ (Padding ) │ │ └──────────────┴──────────────────────────────────┘
对象头详解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ┌──────────────────────────────────────────────────────┐ │ 位数(从高位到低位) │ 含义 │ ├──────────────────────────────────────────────────────┤ │ 25 位 │ 未使用 │ │ 31 位 │ 哈希码(调用 hashCode() 后填充)│ │ 4 位 │ 分代年龄(最大 15 ,因为 4 位)│ │ 1 位 │ 偏向锁标志 │ │ 2 位 │ 锁状态标志 │ │ 1 位 │ GC 标记 │ └──────────────────────────────────────────────────────┘ → 指向该对象的类元数据(在元空间中) → 开启压缩指针(-XX:+UseCompressedOops,默认开启)时占 4 字节 → 不开启时占 8 字节
实例数据(Instance Data):
1 2 3 4 5 6 public class Person { private int age; private String name; private long salary; private byte gender; }
字段重排(Field Reordering): JVM 为了节省空间,会对字段进行重排(把相同大小的字段放在一起):
1 2 3 4 5 6 7 8 9 10 int a; byte b; long c; long c; int a; byte b; padding;
对齐填充(Padding):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class A { int a; }public class B { byte b; }
面试加分回答
「对象的内存布局是理解 synchronized 锁升级、对象哈希码、GC 分代年龄的基础。面试时可能会追问:为什么 GC 分代年龄最大是 15? 答案是:分代年龄存储在 Mark Word 的 4 位中(2^4 = 16,但 0 表示未经过 GC,所以最大是 15)。另外,对象的哈希码什么时候计算? 答案是:第一次调用 hashCode() 时计算,并存储在 Mark Word 中(这也是为什么加了偏向锁的对象调用 hashCode() 会导致锁升级)。」
第 5 题:对象的访问定位方式有哪些? 一句话结论
Java 通过栈上的引用(Reference)访问堆中的对象,有两种方式:句柄访问 和 直接指针访问。HotSpot 使用直接指针访问。
深度解析 方式 ①:句柄访问(Handle Access)
1 2 3 4 5 6 7 8 栈(引用) 堆 │ │ 引用(Reference ) │ └──→ 句柄池(Handle Pool) │ ├──→ 到对象实例数据的指针(在堆中) └──→ 到对象类型数据的指针(在元空间中)
优点 :引用中存储的是稳定的句柄地址 ,对象被移动(GC 时)时,只需要修改句柄中的指针,不需要修改引用本身。缺点 :多了一次指针定位的开销(访问对象需要两次指针跳转)。
方式 ②:直接指针访问(Direct Pointer Access)★ HotSpot 使用
1 2 3 4 5 6 7 栈(引用) 堆 │ │ 引用(Reference ) │ └──→ 对象实例数据 │ └──→ 对象头中的 Klass Pointer → 元空间中的类元数据
优点 :访问速度快(只需要一次指针跳转)。缺点 :对象被移动时(GC 时),需要修改所有对该对象的引用。
为什么 HotSpot 选择直接指针访问?
面试加分回答
「对象的访问定位方式是一个比较冷门但有趣的知识点。面试时可能会追问:引用(Reference)存在哪里? 答案是:局部变量(如 Object obj)存在虚拟机栈的局部变量表 中;成员变量(如 class A { Object obj; })存在堆中的对象实例数据 中。另外,Java 中的引用有四种类型(强引用、软引用、弱引用、虚引用),见第 9 题。」
第 6 题:OutOfMemoryError 有哪些类型?分别发生在什么区域? 一句话结论
OOM 有 5 种常见类型:Java 堆溢出、虚拟机栈/本地方法栈溢出、元空间溢出、直接内存溢出、GC Overhead Limit Exceeded。
深度解析 ① Java 堆溢出(最常见)
1 2 3 4 5 6 7 8 9 List<byte []> list = new ArrayList <>();while (true ) { list.add(new byte [1024 * 1024 ]); }
② 虚拟机栈溢出(StackOverflowError)
1 2 3 4 5 6 7 8 9 10 public void infiniteRecursion () { infiniteRecursion(); }
注意 :StackOverflowError 不是 OOM ,是另一种错误(Error)。
③ 元空间溢出(Java 8+)
1 2 3 4 5 6 7 8 9 10 11 12 while (true ) { Enhancer enhancer = new Enhancer (); enhancer.setSuperclass(Person.class); enhancer.setUseCache(false ); enhancer.create(); }
④ 直接内存溢出
1 2 3 4 5 6 7 8 9 while (true ) { ByteBuffer.allocateDirect(1024 * 1024 * 100 ); }
⑤ GC Overhead Limit Exceeded
面试加分回答
「OOM 的排查是 Java 工程师的必备技能。生产环境中,OOM 的排查流程是:
查看错误日志,确定 OOM 类型;
如果是堆溢出,用 jmap -histo:live pid 查看堆中对象分布,找到占用内存最多的类;
用 jmap -dump:format=b,file=heap.hprof pid 导出堆转储文件,用 MAT(Memory Analyzer Tool)分析内存泄漏;
如果是元空间溢出,检查是否加载了太多类(如动态代理使用不当)。」
二、垃圾回收基础(7-10)
第 7 题:如何判断对象是否可以被回收? 一句话结论
Java 使用可达性分析算法(Reachability Analysis):从 GC Roots 出发,不可达的对象可以被回收。
深度解析 引用计数法(Reference Counting,Java 不用):
1 2 3 4 5 6 7 8 9 10 11 12 Object A = new Object (); Object B = A; B = null ; A = null ; Obj1.field = Obj2; Obj2.field = Obj1;
可达性分析算法(Java 使用):
1 2 3 4 5 6 7 8 9 10 11 可达性分析: 从「GC Roots」出发,沿着引用链搜索 → 能被搜索到的对象:存活 → 不能被搜索到的对象:可回收 GC Roots 包括: 1. 虚拟机栈中引用的对象(局部变量) 2. 本地方法栈中 JNI 引用的对象 3. 元空间中类静态属性引用的对象 4. 元空间中常量引用的对象 5. 所有被同步锁(synchronized)持有的对象
图解:
1 2 3 4 5 6 7 8 9 GC Roots(根对象) │ ├──→ Object A ←──┐ │ │ ├──→ Object B │ │ │ └──→ Object C →─┘ (C 被 A 引用,A 是 GC Root 可达的 → C 存活)Object D(没有任何 GC Root 能到达)→ 可回收
面试加分回答
「可达性分析有个细节:枚举根节点(GC Roots Enumeration)需要 Stop The World(STW) 。因为如果不暂停所有线程,根节点集合会不断变化,分析就不准确。但现代 GC(如 CMS、G1、ZGC)都做了优化,STW 的时间非常短(毫秒级)。另外,OopMap (Ordinary Object Pointer Map)是 HotSpot 用来快速枚举 GC Roots 的数据结构,不需要遍历整个栈找引用。」
第 8 题:GC Roots 有哪些? 一句话结论
GC Roots = 虚拟机栈引用 + 本地方法栈引用 + 静态变量 + 常量 + 被同步锁持有的对象 + Java 虚拟机内部引用。
深度解析 固定可作为 GC Roots 的对象(固定不变):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public void method () { Object obj = new Object (); }static Object staticObj = new Object (); static final Object CONST_OBJ = new Object (); synchronized (lockObj) { }
动态加入的 GC Roots(根据收集器不同):
1 2 3 4 5 6 7 8 CMS 收集器: → 年轻代中的对象,可能被老年代对象引用 → 所以「老年代对象」也是 GC Roots (需要扫描老年代)G1 收集器: → 每个 Region 有一个 Remembered Set (记忆集) → 记录了「其他 Region 中哪些对象引用了本 Region 的对象」 → 这些引用也是 GC Roots
面试加分回答
「GC Roots 的枚举是 GC 的一个核心步骤。在分代收集 中,Young GC 需要扫描老年代(因为老年代对象可能引用年轻代对象),这很慢。解决方式是 Card Table(卡表) :把老年代划分为 512 字节的卡页,如果卡页中有对象引用了年轻代,就标记为「脏卡」,Young GC 时只需要扫描脏卡,不需要扫描整个老年代。」
第 9 题:Java 的四种引用类型? 一句话结论
强引用(不回收)> 软引用(内存不足时回收)> 弱引用(下次 GC 就回收)> 虚引用(随时可回收,用于回收通知)。
深度解析 ① 强引用(Strong Reference)
1 2 3 4 5 6 7 Object obj = new Object (); obj = null ;
强引用是 Java 中最常见的引用,几乎没有约束。
② 软引用(Soft Reference)
1 2 3 4 5 6 7 8 9 10 11 12 13 import java.lang.ref.SoftReference;Object obj = new Object (); SoftReference<Object> softRef = new SoftReference <>(obj); obj = null ;
③ 弱引用(Weak Reference)
1 2 3 4 5 6 7 8 9 10 11 import java.lang.ref.WeakReference;Object obj = new Object (); WeakReference<Object> weakRef = new WeakReference <>(obj); obj = null ;
ThreadLocal 的内存泄漏问题 :
④ 虚引用(Phantom Reference)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import java.lang.ref.PhantomReference;import java.lang.ref.ReferenceQueue;Object obj = new Object (); ReferenceQueue<Object> queue = new ReferenceQueue <>(); PhantomReference<Object> phantomRef = new PhantomReference <>(obj, queue); obj = null ;
面试加分回答
「四种引用的强度排序:强引用 > 软引用 > 弱引用 > 虚引用。面试时经常追问:ThreadLocal 为什么要用弱引用? 答案是:如果 key 是强引用,即使 ThreadLocal 的外部强引用被回收了,ThreadLocal 也不会被回收(因为 ThreadLocalMap 的 key 还持有强引用)→ 内存泄漏。用弱引用后,ThreadLocal 的外部强引用被回收时,ThreadLocal 会在下次 GC 时被回收,减少内存泄漏的风险(但 value 仍然需要手动 remove() 才能完全避免泄漏)。」
第 10 题:垃圾回收算法有哪些? 一句话结论
三种基础算法:标记-清除、标记-复制、标记-整理。分代收集理论:新生代用标记-复制,老年代用标记-清除或标记-整理。
深度解析 ① 标记-清除算法(Mark-Sweep)
1 2 3 4 5 6 7 8 9 10 流程: 1. 标记阶段:从 GC Roots 出发,标记所有存活对象 2. 清除阶段:遍历整个堆,回收未标记的对象 优点: → 实现简单 缺点: → 效率不高(需要两次遍历) → 会产生内存碎片(存活对象散落各处,大对象可能分配失败)
② 标记-复制算法(Copying)★ 新生代使用
1 2 3 4 5 6 7 8 9 10 11 12 13 流程: 1 . 把内存分成两块:From 和 To (大小相等) 2 . 只使用 From 块 3 . GC 时,把 From 中存活的对象复制到 To 块(连续存放) 4 . 清空 From 块 5 . 交换 From 和 To 的角色 优点: → 没有内存碎片(复制过去是连续的) → 效率高(只需要遍历一次) 缺点: → 浪费一半内存(From 和 To 各占 50% )
HotSpot 的优化(Eden + Survivor):
1 2 3 4 5 6 7 8 9 10 11 新生代(Young Gen): Eden(伊甸园) :80% From Survivor :10% To Survivor :10% GC 时: → Eden + From 中存活的对象 → 复制到 To → 清空 Eden + From → From 和 To 交换角色 → 只浪费 10% 的内存(To 区),不是 50% !
③ 标记-整理算法(Mark-Compact)★ 老年代使用
1 2 3 4 5 6 7 8 9 10 11 12 流程: 1. 标记阶段:从 GC Roots 出发,标记所有存活对象 2. 整理阶段:把所有存活对象向一端移动(压缩) 3. 清理边界以外的内存 优点: → 没有内存碎片 → 不浪费内存 缺点: → 移动对象成本高(需要更新所有引用该对象的指针) → STW 时间长
三种算法对比:
算法
是否碎片
是否浪费内存
STW 时间
适用场景
标记-清除
❌ 有碎片
✅ 不浪费
中等
老年代(CMS)
标记-复制
✅ 无碎片
❌ 浪费一半
短
新生代
标记-整理
✅ 无碎片
✅ 不浪费
长
老年代(Serial Old、Parallel Old)
面试加分回答
「标记-复制算法有一个有趣的细节:对象存活率很高时,复制算法的效率会很低 (需要复制大量对象)。所以复制算法只适合对象存活率低 的场景(新生代,98% 的对象活不过一次 GC)。另外,HotSpot 的 Survivor 区设计 也是经过优化的:如果 Survivor 区太小,存活对象放不下,就会提前晋升到老年代 (Premature Promotion),导致老年代增长过快,引发 Full GC。」
三、垃圾收集器(11-17)
第 11 题:常见的垃圾收集器有哪些? 一句话结论
新生代收集器:Serial、ParNew、Parallel Scavenge;老年代收集器:Serial Old、Parallel Old、CMS;整堆收集器:G1(Java 7+)、ZGC(Java 11+)、Shenandoah(Java 12+)。
深度解析 收集器的发展史:
1 2 3 4 5 6 7 Java 的发展: Java 1 .3 :Serial(单线程,STW 很长) Java 1 .4 :Parallel(多线程,但还是要 STW) Java 5 :CMS(第一款并发收集器,标记阶段可以与用户线程并发) Java 7 :G1(里程碑,不再物理分代,而是逻辑分代) Java 11 :ZGC(超低延迟,< 10 ms) Java 12 :Shenandoah(类似 ZGC,OpenJDK 社区驱动)
各收集器特点:
收集器
作用区域
线程数
算法
目标
适用场景
Serial
新生代
单线程
标记-复制
简单高效(客户端)
客户端、嵌入式
Serial Old
老年代
单线程
标记-整理
简单
Client 模式
ParNew
新生代
多线程
标记-复制
配合 CMS
服务端(Java 8-)
Parallel Scavenge
新生代
多线程
标记-复制
吞吐量优先
后台计算
Parallel Old
老年代
多线程
标记-整理
吞吐量优先
配合 PS
CMS
老年代
并发
标记-清除
低延迟
互联网应用(Java 8-)
G1
整堆
并发
标记-整理+复制
可预测停顿
大内存(Java 8+ 推荐)
ZGC
整堆
并发
染色指针
超低延迟(< 10ms)
超大内存(Java 11+)
收集器的搭配关系:
1 2 3 4 5 6 合法的搭配: Serial + Serial Old (客户端) ParNew + CMS (Java 8 主流) Parallel + Parallel Old (吞吐量优先) G1(独立,不需要搭配) (Java 8 + 推荐) ZGC(独立) (Java 11 +,超低延迟)
Java 8 的默认收集器 :Parallel Scavenge + Parallel Old(吞吐量优先)Java 9+ 的默认收集器 :G1
面试加分回答
「收集器的选择是一个经典问题。面试时经常问:你们的线上服务用的什么收集器?为什么? 典型回答:Java 8 用 CMS(低延迟),Java 8+ 用 G1(可预测停顿)。如果是大内存(> 32GB)+ 低延迟(< 10ms) 的场景,用 ZGC。另外,CMS 已经在 Java 9 被标记为 Deprecated,Java 14 被移除,所以新项目不要用 CMS 了。」
第 12 题:CMS 收集器的工作原理? 一句话结论
CMS(Concurrent Mark Sweep)= 一款并发的、低延迟的老年代收集器。流程:初始标记(STW)→ 并发标记 → 重新标记(STW)→ 并发清除。
深度解析 CMS 的完整流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 CMS 收集器(老年代): ┌─────────────────────────────────────────────┐ │ 阶段 1 :初始标记(Initial Mark)【STW】 │ │ → 标记 GC Roots 直接关联的对象 │ │ → 速度很快(只标记直接关联) │ ├─────────────────────────────────────────────┤ │ 阶段 2 :并发标记(Concurrent Mark) │ │ → 与用户线程并发执行 │ │ → 从「初始标记」的结果出发,遍历整个对象 │ │ 图,标记所有存活对象 │ │ → 因为用户线程在运行,对象图可能变化 │ ├─────────────────────────────────────────────┤ │ 阶段 3 :重新标记(Remark )【STW】 │ │ → 修复「并发标记」期间用户线程修改的引用 │ │ → 使用「增量更新」算法 │ │ → 比初始标记慢,但比 Full GC 快很多 │ ├─────────────────────────────────────────────┤ │ 阶段 4 :并发清除(Concurrent Sweep) │ │ → 与用户线程并发执行 │ │ → 清除未标记的对象 │ │ → 不会产生内存碎片(标记-清除算法) │ └─────────────────────────────────────────────┘
CMS 的优点:
CMS 的缺陷:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
面试加分回答
「CMS 的「重新标记」阶段使用了 写后屏障(Write Barrier) 来记录引用变化。具体来说,是 增量更新(Incremental Update) 算法:当黑色对象的引用变化时,把它标记为灰色(需要重新扫描)。另一个算法是 原始快照(SATB,G1 使用) ,记录删除前的引用关系。面试时可能会追问这两个算法的区别。」
第 13 题:G1 收集器的工作原理? 一句话结论
G1(Garbage First)= 面向服务端应用的收集器,把堆划分为多个大小相等的 Region,优先回收垃圾最多的 Region(Garbage First)。可预测停顿时间。
深度解析 G1 的内存布局(不再物理分代):
1 2 3 4 5 6 7 8 9 10 11 G1 的堆布局: ┌────┬────┬────┬────┬────┬────┬────┬────┐ │ R1 │ R2 │ R3 │ R4 │ R5 │ R6 │ R7 │ R8 │ ... └────┴────┴────┴────┴────┴────┴────┴────┘ 每个 Region(Region)大小相等(1 MB ~ 32 MB,必须是 2 的幂) 每个 Region 可以是: → Eden Region(新生代) → Survivor Region(幸存区) → Old Region(老年代) → Humongous Region(大对象,占连续多个 Region)
关键点 :G1 不再是物理分代(固定新生代、老年代各占多少),而是逻辑分代 (Region 的身份可以变化)。
G1 的收集流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 G1 收集器: ┌─────────────────────────────────────────────┐ │ 阶段 1 :初始标记(Initial Mark )【STW 】 │ │ → 借用在 Young GC 的暂停中完成 │ │ → 标记 GC Roots 直接关联的对象 │ ├─────────────────────────────────────────────┤ │ 阶段 2 :并发标记(Concurrent Mark ) │ │ → 与用户线程并发执行 │ │ → 遍历对象图,标记存活对象 │ │ → 使用 SATB (原始快照)算法处理引用变化 │ ├─────────────────────────────────────────────┤ │ 阶段 3 :最终标记(Final Mark )【STW 】 │ │ → 处理 SATB 缓冲区中剩余的引用变化 │ ├─────────────────────────────────────────────┤ │ 阶段 4 :筛选回收(Live Data Counting │ │ and Evacuation )【STW 】 │ │ → 计算每个 Region 的回收价值(垃圾占比)│ │ → 优先回收垃圾最多的 Region (Garbage │ │ First !) │ │ → 把存活对象复制到空的 Region (标记- 复制│ │ 算法,无碎片) │ └─────────────────────────────────────────────┘
G1 的核心优势:
G1 的重要参数:
1 2 3 4 5 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:InitiatingHeapOccupancyPercent=45
面试加分回答
「G1 的 Humongous Object(大对象) 处理是一个细节:大小超过 Region 一半的对象,会被分配到 Humongous Region (连续的多个 Region)。大对象会直接进入老年代 ,且 G1 在 Full GC 时才会回收大对象。所以如果应用中大对象很多 ,会导致频繁 Full GC。解决方式:调大 Region 大小(-XX:G1HeapRegionSize),或减少大对象的使用。」
第 14 题:ZGC 的工作原理? 一句话结论
ZGC(Java 11+)= 超低延迟的收集器(STW < 10ms),核心是「并发压缩」+「染色指针」+「内存多重映射」。
深度解析 ZGC 的设计目标:
ZGC 的核心技术:
① 染色指针(Colored Pointer)
1 2 3 4 5 6 7 8 9 10 11 12 指针结构(64 位): ┌──────────┬────────┬────────┬────────┬────────┬──────────┐ │ 高 18 位 │ Final │ Remap │ Marked │ Marked │ 低 42 位 │ │ (unused) │ izable │ │ 0 │ 1 │ (对象地址)│ └──────────┴────────┴────────┴────────┴────────┴──────────┘ → Finalizable:是否可被 finalize() → Remap:是否指向最新的对象地址(因为 ZGC 会移动对象) → Marked0 / Marked1:标记位(用于并发标记)
为什么要把标记存在指针里? → 这样在标记阶段,不需要访问对象本身 ,只需要看指针的标记位! → 可以并发标记,不需要 STW!
② 内存多重映射(Multi-Mapping)
③ 并发压缩(Concurrent Compaction)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ZGC 的 GC 周期: ┌─────────────────────────────────────────────┐ │ 阶段 1 :初始标记(STW ,很短 < 1 ms ) │ │ → 标记 GC Roots 直接关联的对象 │ ├─────────────────────────────────────────────┤ │ 阶段 2 :并发标记(并发) │ │ → 遍历对象图,标记存活对象 │ │ → 通过「染色指针」实现并发 │ ├─────────────────────────────────────────────┤ │ 阶段 3 :再标记(STW ,很短) │ │ → 处理并发标记期间的引用变化 │ ├─────────────────────────────────────────────┤ │ 阶段 4 :并发转移准备(并发) │ │ → 选择需要回收的 Region │ ├─────────────────────────────────────────────┤ │ 阶段 5 :初始转移(STW ,很短 < 1 ms ) │ │ → 转移 GC Roots 直接关联的对象 │ ├─────────────────────────────────────────────┤ │ 阶段 6 :并发转移(并发) │ │ → 转移剩余存活对象 │ │ → 通过「转发指针」实现并发 │ └─────────────────────────────────────────────┘
面试加分回答
「ZGC 的 转发指针(Forwarding Pointer) 是并发转移的关键:当对象被移动到新地址后,ZGC 在原对象地址放一个「转发指针」,指向新地址。这样,如果有其他线程访问旧地址,会通过转发指针自动跳转到新地址(不需要 STW 修改所有引用)。这个技术让 ZGC 的 STW 时间极低(< 10ms)。另外,ZGC 在 Java 21 已经正式生产可用 (不再是实验特性),推荐大内存、低延迟场景使用。」
第 15 题:三色标记算法? 一句话结论
三色标记 = 白色(未标记)、灰色(自身标记,引用未标记)、黑色(自身和引用都已标记)。CMS 使用「增量更新」,G1 使用「原始快照(SATB)」来解决漏标问题。
深度解析 三色标记的定义:
1 2 3 4 5 6 7 8 9 白色(White ): → 尚未被标记的对象(GC 开始时,所有对象都是白色) 灰色(Gray ): → 已经被标记,但它的引用(字段)还没有被扫描 黑色(Black ): → 已经被标记,且它的所有引用也都已经扫描过了 → 黑色对象是「安全」的,不会被回收
标记过程:
1 2 3 4 5 6 7 8 9 10 11 初始状态:所有对象都是白色 ↓ 从 GC Roots 出发,把直接关联的对象标记为灰色 ↓ 从灰色对象出发,扫描它的所有引用: → 把白色引用标记为灰色 → 把当前灰色对象标记为黑色 ↓ 重复,直到没有灰色对象 ↓ 剩余的白色对象 → 可回收
漏标问题(Dijkstra 算法):
1 2 3 4 5 6 7 8 9 10 11 12 13 初始状态: A(黑) → B(灰) → C(白) 用户线程执行: A.field = C; B.field = null ; 标记线程继续: → B 被标记为黑色(它的引用都扫描过了) → C 仍然是白色(因为 A 是黑色,不会被扫描) → C 被误回收!(但实际上 A 还引用着 C)
两种解决方案:
① 增量更新(Incremental Update,CMS 使用)
② 原始快照(SATB,Snapshot-At-The-Beginning,G1 使用)
面试加分回答
「三色标记是并发 GC 的基础理论。面试时可能会追问:为什么 G1 用 SATB 而不是增量更新? 答案是:SATB 的写屏障(Write Barrier)开销更小 (只需要记录删除的引用,不需要重新扫描黑色对象)。另外,ZGC 使用的是染色指针 ,不需要写屏障,所以 ZGC 的性能更好(对应用吞吐量的影响 < 15%,而 G1 可能影响 20%~30%)。」
第 16 题:记忆集(Remembered Set)和卡表(Card Table)? 一句话结论
记忆集(RSet)= 记录「其他 Region/代 中哪些对象引用了本 Region/代 的对象」。卡表(Card Table)= 记忆集的实现方式,把内存划分为卡页,标记哪些卡页中有跨代/跨 Region 引用。
深度解析 问题背景(分代收集):
1 2 3 4 5 Young GC 需要扫描: → 新生代(必然要扫描) → 老年代(因为老年代对象可能引用新生代对象!) 问题:如果每次 Young GC 都扫描整个老年代 → 太慢了!
卡表(Card Table):
1 2 3 4 5 6 7 8 卡表: → 把老年代划分为 512 字节的「卡页(Card Page)」 → 每个卡页对应卡表中的 1 个 bit → 如果某个卡页中有对象引用了新生代对象 → 标记为「脏卡(Dirty Card )」 Young GC 时: → 只需要扫描「脏卡」对应的卡页 → 不需要扫描整个老年代!
图解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 老年代(Old Gen): ┌────────┬────────┬────────┬────────┐ │ Card 0 │ Card 1 │ Card 2 │ Card 3 │ ... └────────┴────────┴────────┴────────┘ (512B) (512B) (512B) (512B) 卡表(Card Table): ┌───┬───┬───┬───┬───┐ │ 0 │ 1 │ 0 │ 1 │...│ └───┴───┴───┴───┴───┘ │ │ │ │ 脏卡 │ │ │ ↓ ↓ 扫描 Card 1 扫描 Card 3
记忆集(Remembered Set,RSet):
1 2 3 4 5 6 7 8 G1 中的 RSet : → 每个 Region 有一个 RSet → RSet 记录了「哪些其他 Region 的对象引用了本 Region 的对象」 Why G1 需要 RSet ? → G1 不再物理分代,而是把堆分成多个 Region → Young GC 时,需要找到「所有引用了新生代 Region 的对象」 → RSet 就是这个索引!
RSet 的结构:
1 2 3 4 5 6 Region A 的 RSet : → 记录了:Region B 、Region C 、Region D 中有对象引用了 Region A 的对象 GC 时: → 只需要扫描 Region B 、C 、D 中的对应对象 → 不需要扫描整个堆!
面试加分回答
「卡表和 RSet 是 JVM 性能优化的的重要技术。面试时可能会追问:写屏障(Write Barrier)是什么? 答案是:当程序修改对象引用时(如 obj.field = newObj),JVM 会执行一段额外的代码(写屏障),用来标记卡表 (如果是跨代引用)或更新 RSet (如果是跨 Region 引用)。写屏障会有一定的性能开销,但比起扫描整个老年代/整个堆,这个开销是值得的。」
第 17 题:并发标记的问题(漏标、误标)? 一句话结论
并发标记的核心问题:1. 漏标(对象应该存活但被回收了,严重!)→ 用增量更新或 SATB 解决;2. 误标(对象应该被回收但仍然存活,无害)→ 下次 GC 会回收。
深度解析 漏标(Missing Mark,严重问题):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 初始状态(并发标记期间): A(已标记,黑色) → B(已标记,灰色) → C(未标记,白色) 用户线程执行: A.x = C; B.x = null ; 标记线程继续: → B 扫描完成,标记为黑色 → C 仍然是白色(A 是黑色,不会扫描 A 的引用) → C 被回收! 问题:A.x 仍然引用着 C,但 C 已经被回收了 → 程序崩溃!
解决方案(见第 15 题):
增量更新(CMS) :黑色对象新增引用时,重新标记为灰色
原始快照 SATB(G1) :灰色对象删除引用时,记录旧引用,确保对象不被漏标
误标(Over-Marking,无害问题):
1 2 3 4 5 6 7 8 9 10 11 12 13 初始状态: A(黑色) → B(灰色) → C(白色) 用户线程执行: B.x = null ; 标记线程继续: → 因为 B 是灰色,C 会被标记为存活 → 但实际上 C 已经没有被引用了! 结果:C 被误标记为存活,本次 GC 不会回收 C → 无害!下次 GC 时,C 会被正确回收
误标是无害的 ,因为垃圾只是「晚一点回收」,不会导致程序错误。漏标是有害的 ,会导致程序访问已回收的对象(悬挂指针),造成崩溃或数据错误。
面试加分回答
「并发标记的漏标问题 是设计并发 GC 时的核心挑战。CMS 和 G1 用了不同的方案:CMS 用增量更新 (记录新增的引用),G1 用SATB (记录删除的引用)。这两种方案各有优劣:增量更新可能导致重复扫描 (黑色对象被重新标记为灰色,需要重新扫描);SATB 可能导致过度标记 (实际上已经不再引用的对象,仍然被标记为存活)。G1 选择 SATB 的原因是:SATB 的写屏障开销更小,更适合大堆 场景。」
四、类加载机制(18-21)
第 18 题:类加载的过程? 一句话结论
类加载 = 加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)。其中验证、准备、解析合称「连接(Linking)」。
深度解析 完整流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 类加载的全过程(5 个阶段): ┌─────────────────────────────────────────────┐ │ 阶段 1 :加载(Loading) │ │ → 通过类的全限定名获取 .class 文件的二进制│ │ 字节流 │ │ → 将字节流所代表的静态存储结构转化为方法│ │ 区的运行时数据结构 │ │ → 在内存中生成一个 java.lang.Class 对象 │ │ (作为方法区这个类的各种数据的访问入口)│ ├─────────────────────────────────────────────┤ │ 阶段 2 :验证(Verification) │ │ → 确保 .class 文件的字节流符合 JVM 规范│ │ → 防止恶意字节码攻击 │ │ → 包括:文件格式验证、元数据验证、字节码│ │ 验证、符号引用验证 │ ├─────────────────────────────────────────────┤ │ 阶段 3 :准备(Preparation) │ │ → 为类变量(static 变量)分配内存 │ │ → 并设置初始值(零值) │ │ → 注意:不是代码中赋的值! │ │ → 如:static int a = 10 ; │ │ 准备阶段:a = 0 (零值) │ │ 初始化阶段:a = 10 (代码中赋的值)│ ├─────────────────────────────────────────────┤ │ 阶段 4 :解析(Resolution) │ │ → 将常量池中的符号引用替换为直接引用 │ │ → 符号引用:"java.lang.Object" │ │ → 直接引用:指向对象的指针 │ ├─────────────────────────────────────────────┤ │ 阶段 5 :初始化(Initialization) │ │ → 执行类构造器 <clinit>() 方法 │ │ → <clinit>() 方法是编译器自动收集类中 │ │ 所有类变量的赋值动作和静态代码块合并 │ │ 产生的 │ │ → 什么时候执行初始化? │ │ * 遇到 new 、getstatic、putstatic、│ │ invokestatic 指令时 │ │ * 使用反射调用时 │ │ * 初始化子类时,父类要先初始化 │ │ * 主类(main 方法所在类) │ └─────────────────────────────────────────────┘
准备阶段 vs 初始化阶段(重要!):
1 2 3 4 5 6 7 8 9 10 11 12 13 public class Test { private static int value = 10 ; private static Object obj = new Object (); private static final int CONST = 20 ; }
面试加分回答
「类加载的初始化阶段 是面试高频追问点。需要注意:() 方法是线程安全的 (JVM 会保证多线程环境下,只有一个线程能执行 () 方法,其他线程会阻塞等待)。这也是静态内部类实现单例模式 的线程安全保证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class Singleton { private static class Holder { private static final Singleton INSTANCE = new Singleton (); } public static Singleton getInstance () { return Holder.INSTANCE; } } ```」 --- ### 第 19 题:双亲委派模型? #### 一句话结论 **双亲委派模型 = 类加载器收到加载请求时,先委托父加载器加载,父加载器无法加载时才自己加载。防止核心类被篡改(如自定义 java.lang.Object)。** --- #### 深度解析 **类加载器的层次结构:**
双亲委派模型(Parent Delegation Model): ┌─────────────────────────────────────────────┐ │ 启动类加载器(Bootstrap ClassLoader) │ │ → C++ 实现,是 JVM 的一部分 │ │ → 加载 /jre/lib 下的核心类库│ │ → 如:java.lang.、java.util. │ └─────────────────┬───────────────────────────┘ │ 子加载器 ┌─────────────────┴───────────────────────────┐ │ 扩展类加载器(Extension ClassLoader,Java 8)│ │ → Java 实现,sun.misc.Launcher$ExtClassLoader│ │ → 加载 /jre/lib/ext 下的扩展类│ └─────────────────┬───────────────────────────┘ │ 子加载器 ┌─────────────────┴───────────────────────────┐ │ 应用程序类加载器(Application ClassLoader) │ │ → Java 实现,sun.misc.Launcher$AppClassLoader│ │ → 加载用户类路径(ClassPath)上的类 │ │ → 是程序中默认的类加载器 │ └─────────────────┬───────────────────────────┘ │ 子加载器 ┌─────────────────┴───────────────────────────┐ │ 自定义类加载器(Custom ClassLoader) │ │ → 继承 java.lang.ClassLoader │ │ → 可以重写 loadClass() 或 findClass() │ └─────────────────────────────────────────────┘
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 - - - * * 双亲委派的工作流程:* * ```java protected Class < ?> loadClass (String name , boolean resolve ) { Class < ?> c = findLoadedClass (name ); if (c == null ) { try { if (parent != null ) { c = parent .loadClass (name , false ); } else { c = findBootstrapClassOrNull (name ); } } catch (ClassNotFoundException e ) { } if (c == null ) { c = findClass (name ); } } return c ; }
双亲委派的好处:
面试加分回答
「双亲委派模型不是强制约束,而是一个推荐的最佳实践 。Java 中有些场景就打破了双亲委派:1. SPI(Service Provider Interface) :如 java.sql.Driver(JDBC 驱动),核心接口由启动类加载器加载,但实现类由应用程序类加载器加载,启动类加载器无法加载应用程序类路径上的类 → 使用线程上下文类加载器(Thread Context ClassLoader) 来解决;2. OSGi :为了实现模块化,每个模块有自己的类加载器,不再遵循双亲委派。」
第 20 题:如何打破双亲委派模型? 一句话结论
打破双亲委派 = 重写 loadClass() 方法(不推荐)或重写 findClass() 方法(推荐)。典型场景:Tomcat 的 WebAppClassLoader、SPI 机制、OSGi。
深度解析 方式 ①:重写 loadClass()(不推荐,会破坏双亲委派)
1 2 3 4 5 6 7 8 9 10 11 12 public class CustomClassLoader extends ClassLoader { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { Class<?> c = findClass(name); if (c == null ) { c = super .loadClass(name); } return c; } }
为什么不推荐重写 loadClass()? → 会破坏双亲委派模型,可能导致核心类被篡改! → Java 官方推荐:重写 findClass(),不要重写 loadClass()
方式 ②:重写 findClass()(推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class CustomClassLoader extends ClassLoader { private String classPath; @Override protected Class<?> findClass(String name) throws ClassNotFoundException { try { byte [] classData = getClassData(name); return defineClass(name, classData, 0 , classData.length); } catch (Exception e) { throw new ClassNotFoundException (name); } } private byte [] getClassData(String className) { } }
典型场景:Tomcat 的 WebAppClassLoader
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Tomcat 的类加载器结构(打破了双亲委派): ┌─────────────────────────────────────────────┐ │ 启动类加载器(Bootstrap ) │ ├─────────────────────────────────────────────┤ │ 扩展类加载器(Extension ) │ ├─────────────────────────────────────────────┤ │ 应用程序类加载器(Application ) │ ├─────────────────────────────────────────────┤ │ WebAppClassLoader (每个 Web 应用一个) │ │ → 先加载自己 Web 应用下的类 │ │ → 加载不了,再委托父加载器 │ │ → 这样,不同 Web 应用可以使用不同版本的 │ │ 依赖库(如 Spring ) │ └─────────────────────────────────────────────┘
面试加分回答
「打破双亲委派的经典场景是 Tomcat 的 WebAppClassLoader 。如果每个 Web 应用都遵循双亲委派,那么所有 Web 应用都只能使用同一个版本的库 (在 ClassPath 上的版本)。但实际情况是:不同 Web 应用可能依赖同一个库的不同版本。Tomcat 的解决方案是:每个 Web 应用有自己的 WebAppClassLoader,先加载自己应用下的类,加载不了再委托父加载器 ,从而打破了双亲委派。」
第 21 题:自定义类加载器的场景? 一句话结论
自定义类加载器的典型场景:热部署(Hot Swap)、模块化(如 OSGi)、字节码加密/解密、从非标准位置加载类(如网络、数据库)。
深度解析 场景 ①:热部署(Hot Swap)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class HotSwapClassLoader extends ClassLoader { @Override protected Class<?> findClass(String name) { byte [] classData = loadClassData(name); return defineClass(name, classData, 0 , classData.length); } }ClassLoader loader1 = new HotSwapClassLoader (); Class<?> clazz1 = loader1.loadClass("com.example.MyService" );Object service1 = clazz1.newInstance();ClassLoader loader2 = new HotSwapClassLoader (); Class<?> clazz2 = loader2.loadClass("com.example.MyService" );Object service2 = clazz2.newInstance();
场景 ②:字节码加密/解密
1 2 3 4 5 6 7 8 9 10 11 public class EncryptedClassLoader extends ClassLoader { @Override protected Class<?> findClass(String name) { byte [] encryptedData = readEncryptedClass(name); byte [] decryptedData = decrypt(encryptedData); return defineClass(name, decryptedData, 0 , decryptedData.length); } }
场景 ③:从网络/数据库加载类
1 2 3 4 5 6 7 8 9 10 11 public class NetworkClassLoader extends ClassLoader { @Override protected Class<?> findClass(String name) { byte [] classData = downloadClassData(name); return defineClass(name, classData, 0 , classData.length); } }
面试加分回答
「自定义类加载器的一个重要细节:同一个类,被不同的类加载器加载,JVM 会认为它们是不同的类 。即使类的全限定名相同,如果类加载器不同,它们也是不兼容的(赋值时会报 ClassCastException)。这也是为什么在热部署 时,需要创建一个新的类加载器来加载新版本的类,而不能直接使用旧的类加载器。」
五、JVM 调优(22-26)
第 22 题:常见的 JVM 调优参数? 一句话结论
JVM 调优参数 = 堆内存设置(-Xms、-Xmx)+ 垃圾收集器选择(-XX:+UseG1GC 等)+ gc 日志(-Xlog:gc )+ 溢出时转储(-XX:+HeapDumpOnOutOfMemoryError)。 *
深度解析 ① 堆内存设置(最常用):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 -Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1g
② 垃圾收集器选择:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 -XX:+UseSerialGC -XX:+UseParallelGC -XX:+UseConcMarkSweepGC -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+UseZGC -XX:+UseShenandoahGC
③ GC 日志(调优必备):
1 2 3 4 5 6 7 8 9 -Xlog:gc* -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log -Xlog:gc*:file=/path/to/gc.log:time ,uptime ,level,tags
④ OOM 时转储堆内存(生产环境必备):
1 2 3 4 5 6 7 8 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/heapdump.hprof -XX:OnOutOfMemoryError="sh /path/to/alert.sh"
面试加分回答
「JVM 调优参数的设置需要结合应用场景 。面试时可能会问:你们的生产环境用的什么参数? 典型回答(Java 8 + G1):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 -Xms4g -Xmx4g -XX :+UseG1GC -XX :MaxGCPauseMillis= 200 -XX :+PrintGCDetails -XX :+PrintGCDateStamps -Xloggc :/var/log/app/gc .log -XX :+HeapDumpOnOutOfMemoryError -XX :HeapDumpPath=/var/log/app/heapdump .hprof`` `」 --- ### 第 23 题:如何排查 CPU 飙高问题? #### 一句话结论 **CPU 飙高排查流程 = ` top` 找 CPU 高的进程 → ` top -Hp pid` 找 CPU 高的线程 → ` jstack pid | grep 线程 ID(16 进制)` 找对应的线程栈 → 分析线程栈找出问题代码。** --- #### 深度解析 **完整排查步骤:** ```bash # 步骤 1:找出 CPU 使用率高的 Java 进程 top # 找到 PID(如:12345) # 步骤 2:找出该进程中 CPU 使用率高的线程 top -Hp 12345 # 找到线程 ID(如:12367) # 步骤 3:把线程 ID 转换成 16 进制(因为 jstack 中的线程 ID 是 16 进制) printf "%x\n" 12367 # 输出:304f # 步骤 4:用 jstack 导出线程栈,并查找该线程 jstack 12345 | grep -A 20 "304f"
常见原因:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 while (true ) { }for (int i = 0 ; i < 100_000_000 ; i++) { Math.sqrt(i); }
面试加分回答
「CPU 飙高问题排查是 Java 工程师的必备技能。除了 top + jstack,还可以用 arthas (阿里开源的 Java 诊断工具)来排查:
1 2 3 4 5 6 7 8 java -jar arthas-boot.jar thread -n 5 jad com.example.MyService trace com.example.MyService methodName
arthas 比 jstack 更强大,生产环境必备!」
第 24 题:如何排查内存泄漏? 一句话结论
内存泄漏排查 = jmap -histo:live pid 查看堆中对象分布 → 找到异常多的类 → jmap -dump:format=b,file=heap.hprof pid 导出堆转储 → 用 MAT(Memory Analyzer Tool)分析泄漏点。
深度解析 完整排查步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 jmap -histo:live 12345 | head -50 jmap -dump:format=b,file=heap.hprof 12345
常见内存泄漏场景:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 static List<Object> list = new ArrayList <>();public void add (Object obj) { list.add(obj); }public class MyListener implements SomeListener { public MyListener () { SomeEventSource.addListener(this ); } }
面试加分回答
「内存泄漏的排查,除了 jmap + MAT,也可以用 arthas 的 dashboard 命令实时监控堆内存使用情况,用 heapdump 命令在线导出堆转储。另外,Java 8 的 jmap 命令在执行时会导致 JVM 停顿(STW) ,如果堆很大(如 32GB),停顿时间可能很长(几十秒)。解决方式:使用 G1 的 -XX:+G1HeapDumpBeforeFullGC ,在 Full GC 之前自动 dump 堆,不需要手动执行 jmap。」
第 25 题:JVM 调优的思路? 一句话结论
JVM 调优 = 明确目标(吞吐量 vs 延迟) → 收集 GC 日志 → 分析 GC 日志(用 GCViewer 或 GCEasy) → 调整参数 → 验证效果。没有银弹,需要结合应用场景。
深度解析 调优步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 JVM 调优的完整流程: ┌─────────────────────────────────────────────┐ │ 步骤 1 :明确调优目标 │ │ → 吞吐量优先?(后台计算、批处理) │ │ → 延迟优先?(Web 服务、API 服务) │ ├─────────────────────────────────────────────┤ │ 步骤 2 :收集 GC 日志 │ │ → -Xlog :gc * 开启 GC 日志 │ │ → 让程序运行一段时间(如 24 小时) │ ├─────────────────────────────────────────────┤ │ 步骤 3 :分析 GC 日志 │ │ → 使用工具:GCViewer、GCEasy(在线) │ │ → 关注指标: │ │ * GC 总耗时 │ │ * 单次 GC 最大停顿时间 │ │ * GC 频率 │ │ * 堆内存使用情况 │ ├─────────────────────────────────────────────┤ │ 步骤 4 :调整参数 │ │ → 如果 Full GC 频繁:调大堆内存 │ │ → 如果 Young GC 频繁:调大新生代 │ │ → 如果停顿时间长:换低延迟收集器(G1、ZGC)│ ├─────────────────────────────────────────────┤ │ 步骤 5 :验证效果 │ │ → 重新收集 GC 日志 │ │ → 对比调优前后的指标 │ └─────────────────────────────────────────────┘
调优案例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 -Xms8g -Xmx8g -XX:+HeapDumpOnOutOfMemoryError -Xmn4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
面试加分回答
「JVM 调优的最重要原则 :不要过早优化 (Premature Optimization)。在调优之前,先确认:1. 是否是代码问题(如内存泄漏、死循环);2. 是否是数据库问题(如慢查询)。很多所谓的「JVM 调优」,实际上应该是「代码优化」或「数据库优化」。另外,GCEasy (https://gceasy.io/)是在线的 GC 日志分析工具,上传 GC 日志后,会自动生成分析报告(包括 GC 频率、停顿时间、内存使用情况等),非常方便。」
第 26 题:逃逸分析? 一句话结论
逃逸分析(Escape Analysis)= JVM 的一种分析技术,判断对象是否「逃逸」出方法(即是否被其他线程或外部方法访问)。如果没有逃逸,JVM 可以做标量替换、栈上分配、锁消除等优化。
深度解析 什么是「逃逸」?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public Object escape1 () { Object obj = new Object (); return obj; }public void escape2 () { Object obj = new Object (); someMethod(obj); }public void noEscape () { Object obj = new Object (); System.out.println(obj); }
逃逸分析的优化:
① 栈上分配(Stack Allocation)
1 2 3 4 5 6 7 8 9 10 public void method () { for (int i = 0 ; i < 1000000 ; i++) { Object obj = new Object (); } }
② 标量替换(Scalar Replacement)
1 2 3 4 5 6 7 8 9 10 11 12 13 public void method () { Point p = new Point (1 , 2 ); System.out.println(p.x + p.y); }
③ 锁消除(Lock Elision)
1 2 3 4 5 6 7 8 9 10 public void method () { synchronized (new Object ()) { } }
面试加分回答
「逃逸分析是 JVM 的很致优化 之一。但需要注意的是:逃逸分析本身是有成本的 (需要分析对象是否逃逸)。对于短方法 (如 getter/setter),逃逸分析的成本可能高于优化带来的收益。另外,标量替换 和 栈上分配 在 HotSpot 中并没有完全实现(主要是标量替换),因为实现栈上分配需要修改垃圾收集器,成本很高。面试时可能会追问:Java 中的对象一定在堆上分配吗? 答案是:理论上可以栈上分配,但 HotSpot 目前主要使用标量替换。」
六、实战问题(27-30)
第 27 题:方法区、栈、堆分别存什么? 一句话结论
方法区(元空间)存:类元数据、常量、静态变量、JIT 缓存;栈存:局部变量、方法参数、引用(对象地址);堆存:对象实例、数组。
深度解析 各区域存储内容的详细对比:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ┌─────────────────────────────────────────────────────────────┐ │ 区域 │ 存储的内容 │ ├─────────────────────────────────────────────────────────────┤ │ 元空间 │ 1 . 类的元数据(Class 对象、字段、方法) │ │ (Metaspace) │ 2 . 运行时常量池(Runtime Constant Pool) │ │ │ 3 . 静态变量(static 变量) │ │ │ 4 . 常量(static final ) │ │ │ 5 . JIT 编译后的本地代码缓存 │ ├─────────────────────────────────────────────────────────────┤ │ 虚拟机栈 │ 1 . 局部变量(基本类型的值、引用类型的地址)│ │ (Stack) │ 2 . 方法参数 │ │ │ 3 . 操作数栈 │ │ │ 4 . 动态链接 │ │ │ 5 . 方法返回地址 │ ├─────────────────────────────────────────────────────────────┤ │ 堆 │ 1 . 对象实例(new 出来的对象) │ │ (Heap) │ 2 . 数组 │ │ │ 3 . 字符串常量池(Java 7 + 移到堆中) │ └─────────────────────────────────────────────────────────────┘
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 public class Test { private static int staticVar = 10 ; private static final String CONST = "hello" ; public void method () { int localVar = 20 ; Object obj = new Object (); String s = "world" ; } }
面试加分回答
「方法区(元空间)中存储的内容,面试时经常追问:字符串常量池在哪里? 答案是:Java 7 之前在永久代(方法区),Java 7 开始移到堆中。静态变量在哪里? 答案是:Java 7 之前在永久代,Java 7 开始移到堆中(Class 对象在元空间,但静态变量引用的对象在堆中)。这些细节反映了 JVM 内存模型的演进。」
第 28 题:synchronized 的锁升级过程? 一句话结论
synchronized 的锁升级 = 无锁 → 偏向锁(单线程访问)→ 轻量级锁(多线程交替访问)→ 重量级锁(多线程同时访问)。锁升级后不能降级(但偏向锁可以撤销)。
深度解析 锁的状态(存储在对象头的 Mark Word 中):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 synchronized 的锁升级过程: ┌─────────────────────────────────────────────┐ │ 状态 1 :无锁(No Lock ) │ │ → 对象没有被任何线程锁定 │ ├─────────────────────────────────────────────┤ │ 状态 2 :偏向锁(Biased Lock ) │ │ → 假设:只有一个线程会访问同步块 │ │ → 原理:在对象头中记录「偏向线程 ID」 │ │ → 这个线程再次进入同步块时,不需要加锁!│ │ → 只需要检查对象头中的线程 ID 是否是自己│ │ → 性能接近无锁! │ ├─────────────────────────────────────────────┤ │ 状态 3 :轻量级锁(Lightweight Lock ) │ │ → 假设:多线程交替访问同步块(没有竞争) │ │ → 原理:在栈帧中创建「锁记录」(Lock Record )│ │ → 用 CAS 把对象头的 Mark Word 替换为指向│ │ 锁记录的指针 │ │ → 如果 CAS 成功 → 获取轻量级锁 │ │ → 如果 CAS 失败(有竞争)→ 升级为重量级锁│ ├─────────────────────────────────────────────┤ │ 状态 4 :重量级锁(Heavyweight Lock ) │ │ → 假设:多线程同时访问同步块(有竞争) │ │ → 原理:使用操作系统的 Mutex Lock (互斥锁)│ │ → 获取锁失败的线程会被阻塞(挂起) │ │ → 唤醒阻塞的线程需要上下文切换(成本高) │ └─────────────────────────────────────────────┘
锁升级的触发条件:
锁升级的性能影响:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 性能排序(从快到慢): 无锁 > 偏向锁 > 轻量级锁 > 重量级锁 偏向锁: → 只有一个线程访问同步块时,性能接近无锁 → 适合单线程场景 轻量级锁: → 多线程交替访问时,用 CAS 竞争锁(自旋锁) → 适合线程交替执行同步块的场景 重量级锁: → 多线程同时竞争锁时,线程会被阻塞(挂起) → 上下文切换成本高,性能最差
面试加分回答
「锁升级是 JVM 对 synchronized 的重要优化(Java 6 引入)。面试时可能会追问:为什么有了 synchronized,还需要 java.util.concurrent.locks.Lock(如 ReentrantLock)? 答案是:1. ReentrantLock 可以尝试获取锁(tryLock()),而 synchronized 会一直阻塞;2. ReentrantLock 可以中断(lockInterruptibly()),而 synchronized 不支持中断;3. ReentrantLock 可以绑定多个条件变量(Condition),而 synchronized 只有一个等待队列。但 Java 6 之后,synchronized 的性能已经和 ReentrantLock 接近,优先使用 synchronized(更简洁)。」
第 29 题:JVM 有哪些命令行工具? 一句话结论
JVM 命令行工具 = jps(查看 Java 进程)、jstat(查看 GC 情况)、jmap(查看堆内存)、jstack(查看线程栈)、jinfo(查看 JVM 参数)、jcmd(全能工具)。
深度解析 常用工具详解:
① jps(JVM Process Status):查看 Java 进程
② jstat(JVM Statistics Monitoring Tool):查看 GC 情况和类加载情况
1 2 3 4 5 6 7 8 9 10 11 jstat -gc 12345 1000 10 jstat -class 12345
③ jmap(Memory Map for Java):查看堆内存使用情况,导出堆转储
1 2 3 4 5 6 7 8 jmap -heap 12345 jmap -histo:live 12345 | head -50 jmap -dump:format=b,file=heap.hprof 12345
④ jstack(Stack Trace for Java):查看线程栈
1 2 3 4 5 6 7 8 jstack 12345 jstack -l 12345 jstack 12345 | grep -A 10 "deadlock"
⑤ jinfo(Configuration Info for Java):查看和修改 JVM 参数
1 2 3 4 5 6 7 8 jinfo -flags 12345 jinfo -flag MaxHeapSize 12345 jinfo -flag +HeapDumpOnOutOfMemoryError 12345
⑥ jcmd(JVM Command):JDK 7+ 的全能工具
1 2 3 4 5 6 7 8 9 10 11 12 jcmd -l jcmd 12345 VM.flags jcmd 12345 GC.heap_dump /path/to/heap.hprof jcmd 12345 GC.run_finalization jcmd 12345 GC.run
面试加分回答
「JVM 命令行工具是排查问题的基础。但生产环境中,这些工具可能无法正常使用 (如目标进程没有响应)。这时可以用 jcmd 或 JMX (Java Management Extensions)。另外,arthas (阿里开源)是比这些命令行工具更强大的诊断工具,支持在线反编译、查看方法执行耗时、监控 JVM 状态等,生产环境必备!」
第 30 题:线上 OOM 如何快速止损? 一句话结论
线上 OOM 快速止损 = 保留现场(dump 堆、保存 GC 日志)→ 快速重启(恢复服务)→ 分析现场(用 MAT 分析 heap dump)→ 修复代码 → 验证。
深度解析 完整止损流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 线上 OOM 快速止损: ┌─────────────────────────────────────────────┐ │ 步骤 1:保留现场(最重要!) │ │ → 如果配置了 -XX:+HeapDumpOnOutOfMemory │ │ Error ,OOM 时会自动 dump 堆 │ │ → 如果没有配置,手动 dump: │ │ jmap -dump:format =b,file =heap.hprof │ │ pid │ │ → 保存 GC 日志、应用日志 │ ├─────────────────────────────────────────────┤ │ 步骤 2:快速重启(恢复服务) │ │ → 如果是集群部署,先重启一台机器 │ │ → 观察重启后是否正常 │ │ → 如果正常,滚动重启所有机器 │ ├─────────────────────────────────────────────┤ │ 步骤 3:分析现场 │ │ → 用 MAT 打开 heap.hprof │ │ → 查看 Leak Suspects Report │ │ → 找到内存泄漏的点 │ ├─────────────────────────────────────────────┤ │ 步骤 4:修复代码 │ │ → 根据 MAT 的分析结果,修复代码 │ │ → 如:及时清理集合、关闭连接、 │ │ ThreadLocal.remove() 等 │ ├─────────────────────────────────────────────┤ │ 步骤 5:验证 │ │ → 本地复现问题 │ │ → 修复后,压测验证 │ │ → 发布上线 │ └─────────────────────────────────────────────┘
OOM 的常见原因和解决方案:
面试加分回答
「线上 OOM 的最重要原则 :先恢复服务,再排查问题 。很多新手会先在线上排查问题(如用 jmap dump 堆),但这可能导致服务长时间不可用。正确的做法:1. 如果配置了自动 dump,直接重启;2. 如果没有自动 dump,在重启之前手动 dump(如果 dump 时间不影响服务可用性的话);3. 重启后,在测试环境复现问题,用 dump 文件分析。另外,-XX:+HeapDumpOnOutOfMemoryError 一定要开启 (性能影响极小),这是线上排查 OOM 的唯一依据!」
总结:JVM 面试复习 Checklist
模块
题号
核心考点
JVM 内存区域
1-6
内存区域划分、对象创建过程、对象内存布局、OOM 类型
垃圾回收基础
7-10
可达性分析、GC Roots、四种引用、GC 算法
垃圾收集器
11-17
收集器对比、CMS、G1、ZGC、三色标记、RSet
类加载机制
18-21
类加载过程、双亲委派、打破双亲委派、自定义类加载器
JVM 调优
22-26
调优参数、CPU 飙高排查、内存泄漏排查、调优思路、逃逸分析
实战问题
27-30
各区域存储内容、synchronized 锁升级、JVM 工具、OOM 止损
最后的建议: JVM 的知识点很多,但面试中最高频 的是:垃圾收集器(CMS/G1/ZGC)、类加载机制、JVM 调优、OOM 排查。 建议把这几块深入理解,并结合实际场景 (如:你们的线上服务用的什么收集器?为什么?)来准备,这样面试时更有说服力。 祝你面试顺利!🚀
如果觉得这篇文章对你有帮助,欢迎分享给更多的小伙伴!
补充篇:BAT 高频遗漏题(31-38题) 第 31 题:字符串常量池(String Pool)是什么? 一句话总结 :字符串常量池是 JVM 为了复用字符串对象而维护的一块特殊内存区域;"abc" 字面量自动入池,new String("abc") 强制在堆里新建对象。
深度解析 :
1 2 3 4 5 6 7 字符串常量池的位置变化: JDK 6 及之前:方法区(永久代) JDK 7 开始:移到堆里(可以被 GC 回收) 为什么会移动? → 方法区的永久代空间有限,容易 OOM → 堆里可以自动扩容,且可以被 GC
intern() 方法 (面试高频!):
1 2 3 4 5 6 String s1 = new String ("abc" );String s2 = s1.intern();String s3 = "abc" ; System.out.println(s2 == s3); System.out.println(s1 == s2);
面试加分回答 :
“我们项目里有一处坑:从数据库查出大量字符串,每次 new String(rs.getString(1)),导致堆里大量重复字符串对象。后来改成直接用 rs.getString(1).intern(),让重复字符串共享常量池里的对象,节约了约 30% 的堆内存。但要注意:intern() 有性能开销(要查常量池),且 JDK 7+ 常量池在堆里,太多唯一字符串会导致堆压力大。”
第 32 题:直接内存(Direct Memory)是什么?为什么 NIO 用它? 一句话总结 :直接内存是堆外内存 (不受 GC 直接管理),NIO 的 DirectByteBuffer 用它来避免用户态和内核态之间的内存拷贝,提升 I/O 性能 。
深度解析 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 普通 I / O (不带直接内存): 应用程序缓冲区(堆内) ↓ 拷贝(用户态 → 内核态) 内核缓冲区 ↓ 拷贝(内核态 → 硬件) 磁盘/ 网卡 共 2 次拷贝! 直接内存 I / O (DirectByteBuffer ): 直接内存(堆外,和内核共享) ↓ 零拷贝(mmap 或 sendfile ) 内核缓冲区 ↓ 零拷贝 磁盘/ 网卡 共 0 ~ 1 次拷贝!快很多!
直接内存的坑 (面试高频!):
1 2 3 4 5 6 7 8 9 ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024 );
面试加分回答 :
“我们 Netty 项目里用到了大量直接内存(Netty 默认用 DirectByteBuffer 做 I/O 缓冲区)。有一次线上出现 OOM: Direct buffer memory,排查发现是 Netty 的 PlatformDependent.directBufferPreferred() 返回 true,但直接内存没设上限(-XX:MaxDirectMemorySize=2g),默认跟堆内存一样大,加上堆的 8G,物理内存 16G 不够用了。解决方案:显式设置 -XX:MaxDirectMemorySize=4g。”
第 33 题:对象访问定位有哪几种方式? 一句话总结 :Java 通过栈上的 reference 访问堆里的对象,有两种方式:句柄访问 和直接指针访问 ;HotSpot 用的是直接指针访问 。
深度解析 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 方式 1 :句柄访问(类似二级指针) [栈] ref erence → [堆] 句柄池(到对象实例数据的指针、到对象类型数据的指针) → [堆] 对象实例数据 ↓ [方法区] 对象类型数据(Class 信息) 优势:对象移动时(GC 复制算法),只需改句柄里的指针,栈里的 ref erence 不用改 劣势:多一次寻址,慢一点 方式 2 :直接指针访问(HotSpot 用的这个!) [栈] ref erence → [堆] 对象实例数据(里面存了到对象类型数据的指针) ↓ [方法区] 对象类型数据(Class 信息) 优势:少一次寻址,快! 劣势:对象移动时(GC),栈里的 ref erence 也要更新(但 HotSpot 的 GC 会做这个,如 G1 的 RSet)
面试加分回答 :
“HotSpot 用直接指针访问,主要是性能考虑(少一次寻址)。但很多人不知道:这个设计导致 GC 做标记时,除了标记堆里的对象,还要扫描栈里的 reference——如果栈很深(如几千层递归),GC roots 扫描会非常慢。这也是为什么大栈(如 -Xss 设太大)会影响 GC 性能。”
第 34 题:ZGC 有哪些改进?和 G1 比有什么优势? 一句话总结 :ZGC 是 JDK 11+ 的低延迟 GC(STW < 10ms),核心改进是染色指针 + 内存多重映射 + 读屏障 ,停顿时间和堆大小基本无关。
深度解析 :
对比项
G1
ZGC(JDK 17+ 生产可用)
目标停顿时间
< 200ms(可设 -XX:MaxGCPauseMillis)
< 10ms(不随堆大小增加)
算法
区域化 + 标记整理
并发标记 + 染色指针 + 转移
内存占用
约堆的 10~20% 作为 Region 开销
约堆的 3% 作为染色指针开销
适用堆大小
4GB ~ 32 GB(太大 G1 也会卡)
8 GB ~ 16 TB(真的!)
JDK 版本
JDK 7+
JDK 17+(生产推荐)
ZGC 的三大黑科技 :
1 2 3 4 5 6 7 8 9 10 11 12 13 ① 染色指针(Colored Pointer) → 在指针里存元数据(Marked0、Marked1、Remapped、Finalizable) → 不用额外的标记位(如 G1 的 Remember Set ) → 内存多重映射:同一个物理页映射到 3 个虚拟地址,通过指针的"颜色" 判断对象状态 ② 读屏障(Load ) → 访问对象时,如果对象已经被转移(移动了位置),读屏障会自动修正指针 → 这是在"读对象" 时发生的,所以叫"读屏障" → 优点:转移过程和应用程序并发执行,STW 极短 ③ 内存多重映射(Multi-Mapping) → 同一个物理内存页,映射到 3 个虚拟地址(对应指针的 3 种"颜色" ) → 切换"视图" 时,不用真的拷贝内存,只改虚拟地址映射(极快!)
面试加分回答 :
“我们公司从 JDK 11 升级到 JDK 17 的主要原因就是 ZGC。原来 G1 在堆 16G 时,Full GC 偶尔会停顿 23 秒,对实时交易系统来说不可接受。切换到 ZGC 后,99.99% 的停顿都在 10ms 以内,GC 日志里甚至基本看不到 STW 日志。但要注意:ZGC 的读屏障会有一定的吞吐量开销(约 515%),如果应用是吞吐量优先(如离线计算),G1 可能更合适。”
第 35 题:G1 的 Humongous Object(大对象)是什么? 一句话总结 :G1 中超过 Region 大小 50% 的对象是大对象,直接进入 Humongous Region ,避免多次拷贝,但会独占整个 Region。
深度解析 :
1 2 3 4 5 6 7 8 9 10 11 12 大对象(Humongous Object )定义: → 对象大小 > Region 大小的 50 % → 例如:Region = 4 MB ,对象 > 2 MB 就算大对象 存储方式: → 不用 Eden 区,直接分配到 Old 区(Humongous Region ) → 连续分配多个 Region (如果对象 > 1 个 Region ) → 不会被拷贝(省去了 YGC 时的拷贝开销) 问题: → 大对象占用大量连续 Region ,可能导致碎片 → 大对象太多,会触发 Full GC (G1 最怕这个!)
面试加分回答 :
“我们项目里有一个坑:用 ArrayList<byte[]> 缓存大文件,每个 byte[] 是 3MB,Region=4MB,所以每个 byte[] 都是大对象,直接进 Old 区,很快触发 Full GC。解决方案:① 调大 Region 大小(-XX:G1HeapRegionSize=8m)② 或者把大对象拆成小块(< 2MB)③ 或者不用堆内存,用直接内存(DirectByteBuffer)。”
第 36 题:JVM 有哪些性能监控和故障排查工具? 一句话总结 :命令行工具有 jps、jstat、jmap、jstack、jinfo ;GUI 工具有 JConsole、VisualVM、JProfiler、Arthas 。
深度解析 :
工具
作用
使用场景
jps
查看正在运行的 Java 进程
快速找到 Java 进程 ID
jstat
监控 JVM 统计信息(GC、类加载)
线上看 GC 频率和耗时
jmap
生成堆转储快照(heap dump)
OOM 后分析内存泄漏
jstack
生成线程快照
排查死锁、线程死循环
jinfo
查看/修改 JVM 参数
动态调整参数(不用重启)
Arthas
在线诊断工具(阿里开源)
线上排查问题(无需重启)
Arthas 常用命令 (面试高频!):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 java -jar arthas-boot.jar thread -n 3 trace com.example.service UserService getUserById jad com.example.service.UserService options
面试加分回答 :
“我们线上排查问题首选 Arthas,不用重启应用。曾经遇到一个 CPU 100% 的问题,用 Arthas 的 thread -n 3 找到最耗 CPU 的线程,再用 jad 反编译对应的类,发现是同事写了个死循环。另外,jmap -histo:live pid 可以强制触发 Full GC 后再统计对象(live 参数),用来判断是不是有内存泄漏很有效。”
第 37 题:JIT 编译器(即时编译)是什么?C1 和 C2 编译器有什么区别? 一句话总结 :JIT(Just-In-Time)把热点代码 (频繁执行的方法/循环)编译成机器码,提升执行速度;**C1(Client 编译器)**启动快,**C2(Server 编译器)**峰值性能好。
深度解析 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 为什么需要 JIT? → 解释执行:JVM 读字节码 → 逐条解释成机器码 → 慢 → JIT 编译:热点代码 → 直接编译成机器码(存在 CodeCache)→ 快(接近 C++) 热点代码 detection(热点探测): → 方法调用计数器(Invocation Counter):方法被调用多次 → 回边计数器(BackEdge Counter):循环执行多次 C1 vs C2: C1(Client Compiler): → 编译快,优化少(简单优化) → 适合客户端应用(启动要快) C2(Server Compiler): → 编译慢,优化多(逃逸分析、锁消除、标量替换等) → 适合服务端应用(峰值性能重要)
分层编译(Tiered Compilation,JDK 7+ 默认开启) :
1 2 3 4 5 6 7 8 9 Level 0 :解释执行Level 1 :C1 编译(简单优化)Level 2 :C1 编译(更多优化)Level 3 :C1 编译(全量优化)Level 4 :C2 编译(极致优化) → 先让 C1 快速编译,跑起来 → 热点代码再让 C2 重新编译(极致优化) → 兼顾启动速度和峰值性能!
面试加分回答 :
“JIT 的逃逸分析(Escape Analysis)是很多优化的基础——如果对象只在方法内使用(没有逃逸出方法),JIT 可以做:① 栈上分配(不在堆里分配,方法结束自动销毁,不用 GC)② 锁消除(如果对象没有逃逸,加的 synchronized 可以去掉)③ 标量替换(把对象拆成基本类型,存在寄存器里,更快)。可以用 -XX:+DoEscapeAnalysis 开启(JDK 7+ 默认开启)。”
第 38 题:Java 内存模型(JMM)的八大原子操作是什么? 一句话总结 :JMM 定义了主内存 和工作内存 之间的交互协议,有 8 种原子操作:lock、unlock、read、load、use、assign、store、write。
深度解析 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 JMM 内存划分: → 主内存(Main Memory):所有线程共享,存储对象实例、类信息 → 工作内存(Working Memory):每个线程私有的内存,存储该线程用到的变量副本8 种原子操作(必须成对出现!): 主内存 工作内存 │ │ │ read (读取) │ │────────────────►│ │ │ load(载入)→ 变量副本 │ │ │ │ use (使用)→ 执行引擎 │ │ │ │ assign(赋值)← 执行引擎 │ store(存储) │ │◄────────────────│ │ write (写入) │ │────────────────►│ │ │ 另外:lock (锁定)/ unlock(解锁):作用于主内存,给其他线程发信号
volatile 的底层实现 (连接 8 种操作):
1 2 3 4 5 6 7 volatile 变量的特殊规则: → read /load/u se 必须连续出现(保证每次用都从主内存读,不缓存) → assign/store/ write 必须连续出现(保证每次改都立即刷新到主内存) → 这就保证了: ① 可见性(一个线程改了,其他线程立刻能看到) ② 禁止指令重排序(内存屏障)
面试加分回答 :
“JMM 的 8 种原子操作是理解 volatile、synchronized 底层的关键。有个易错点:read 和 load 必须连续执行(中间不能插入其他指令),这就是 volatile 保证’每次都从主内存读’的底层实现。另外,JDK 5 修正了 JMM 的定义(JSR-133),之前的 JMM 有缺陷(volatile 的语义不够强),现在的是正确的。”
JVM 38 题完结!
补充篇(续):BAT 高频遗漏题(39-45题)
第 39 题:Compact String(Java 9+ 字符串优化)是什么? 一句话结论
Java 9 引入了 Compact String,将 String 的内部存储从 char[](每个字符 2 字节)改为 byte[](根据编码用 1 或 2 字节),节省约 50% 内存。
深度解析 Java 8 及之前的 String 存储:
1 2 3 4 5 6 7 8 public final class String { private final char [] value; }
Java 9+ 的 Compact String:
1 2 3 4 5 6 7 8 9 public final class String { private final byte [] value; private final byte coder; }
性能对比:
场景
Java 8(char[])
Java 9+(byte[])
内存节省
纯英文 “abc”
6 字节
3 字节
50%
中英文混合 “你好abc”
10 字节
8 字节(你2+好2+3)
20%
纯中文 “你好”
4 字节
4 字节
0%
面试加分回答
「Compact String 是 Java 9 的一个重要优化。在生产环境中,如果应用有大量纯英文的字符串(如 JSON、XML、日志),升级到 Java 9+ 可以节省约 30%~50% 的堆内存 (字符串通常占堆的很大比例)。另外,Compact String 是默认开启 的,不需要任何参数配置。如果想禁用(调试时),可以用 -XX:-CompactStrings。」
第 40 题:安全点(Safepoint)和安全区域(Safe Region)? 一句话结论
安全点(Safepoint)= 线程可以安全暂停的位置(如方法调用、循环跳转);安全区域(Safe Region)= 一段代码内,线程可以随时暂停。GC、锁降级等需要所有线程到达安全点才能执行。
深度解析 为什么需要安全点?
1 2 3 4 5 6 7 8 9 场景:JVM 要执行 GC(需要 Stop-The-World) → 如果线程正在执行 native 代码,JVM 无法中断 → 所以需要「安全点」:线程执行到某个位置时,检查是否需要暂停 安全点的选择: ① 方法调用(Method Call ) ② 循环跳转(Loop Back Edge ) ③ 异常抛出(Exception Throw ) → 这些位置,JVM 会插入「安全点检查」的代码
安全点的工作原理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public void method () { if (needSafepoint) { pauseThread(); } int a = 1 ; if (needSafepoint) { pauseThread(); } method2(); }
安全区域(Safe Region):
1 2 3 4 5 6 7 8 问题:如果线程在等锁(synchronized),它一直在 native 代码(mutex lock) → JVM 无法插入安全点检查 → 线程永远到不了安全点 → GC 无法执行(一直等) 解决:安全区域 → 线程进入「安全区域」时,标记自己 → JVM 执行 GC 时,不需要等「安全区域」内的线程(它们已经标记了) → 线程离开「安全区域」时,检查是否 GC 完成,没完成就继续等
面试加分回答
「安全点是 JVM 实现 Stop-The-World 的基础。面试时可能会追问:为什么安全点不选在每条字节码指令后面? 答案是:如果每条指令都检查安全点,性能开销太大!所以只选在「方法调用」和「循环跳转」这些「不那么频繁」的位置。另外,JVM 参数 -XX:+UseCountedLoopSafepoints 可以让「计数循环」(如 for (int i = 0; i < 1000000; i++))也在每次循环结束时检查安全点,避免大循环导致 GC 停顿时间过长。」
第 41 题:写屏障(Write Barrier)和读屏障(Read Barrier)? 一句话结论
写屏障(Write Barrier)= 当程序修改对象引用时,JVM 插入一段额外代码(用来更新卡表、Remember Set 等);读屏障(Read Barrier)= 当程序读取对象引用时,JVM 插入一段额外代码(用来支持并发 GC,如 ZGC)。
深度解析 写屏障(Write Barrier):
1 2 3 4 5 6 7 8 9 10 11 12 场景:CMS 的并发标记、G1 的 Remember Set 维护 → 当程序修改对象引用时(如 obj.field ) → JVM 需要记录这个变化(用于 ) 写屏障的类型: ① 写前屏障(Pre-Write ) → 在修改引用之前执行 → G1 的 ()用到 ② 写后屏障(Post-Write ) → 在修改引用之后执行 → CMS 的卡表更新、 的 更新用到
代码示例(G1 的写后屏障):
1 2 3 4 5 6 7 8 9 10 11 12 13 obj.field = newObj; WriteBarrierPost(obj, "field" , newObj) { cardTable[obj >> 9 ] = DIRTY; rememberSet[obj.region].add(newObj.region); } obj.field = newObj;
读屏障(Read Barrier,ZGC 用到):
1 2 3 4 5 6 7 8 场景:ZGC 的并发转移(Concurrent Relocation) → ZGC 在转移对象时,对象的地址会变 → 如果程序读取旧地址,需要自动跳转到新地址 读屏障的作用: → 当程序读取对象引用时(如 Object o = obj.field) → JVM 检查这个引用是否指向「已转移的对象」 → 如果是,自动修正为「转发指针」(Forwarding Pointer )指向的新地址
代码示例(ZGC 的读屏障):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Object o = obj.field;Object o = ReadBarrier(obj, "field" ) { Object result = obj.field; if (isForwarded(result)) { return getForwardingPointer(result); } return result; }
面试加分回答
「写屏障是分代 GC 和分区 GC 的基础(需要记录跨代/跨 Region 引用)。读屏障是并发转移 GC (如 ZGC、Shenandoah)的基础(需要修正旧引用)。面试时可能会追问:写屏障的性能开销有多大? 答案是:写屏障会有一定的性能开销(每次修改引用都要执行额外代码),但比起扫描整个老年代/整个堆,这个开销是值得的。ZGC 的读屏障开销更大(每次读取引用都要检查),但 ZGC 通过染色指针 (把标记信息存在指针里)来减少读屏障的开销。」
第 42 题:Code Cache(代码缓存)是什么?如何调优? 一句话结论
Code Cache = JIT 编译器生成的本地代码(机器码)的存储区域。如果 Code Cache 满了,JIT 编译会停止,导致性能下降。
深度解析 Code Cache 的作用:
1 2 3 4 5 6 7 8 JVM 执行字节码的方式: ① 解释执行(Interpreter):逐条解释字节码 → 慢 ② JIT 编译(Just-In-Time):热点代码编译成机器码 → 快 Code Cache: → 存储 JIT 编译后的机器码 → 当方法被调用「足够多次」(如 10000 次),JIT 会把它编译成机器码,存入 Code Cache → 之后调用这个方法,直接执行机器码(不需要解释)→ 快!
Code Cache 的大小:
1 2 3 4 5 6 7 java -XX:+PrintFlagsFinal -version | grep CodeCache
Code Cache 满了会怎样?
1 2 3 4 5 6 7 现象: → JVM 输出日志:[CodeCache is full. Compiler has been disabled.] → JIT 编译停止(不再编译新方法) → 新方法只能用解释执行 → 性能下降! 解决:调大 Code Cache -XX:ReservedCodeCacheSize=512 m
Code Cache 的调优参数:
1 2 3 4 5 6 7 8 -XX:InitialCodeCacheSize=32m -XX:ReservedCodeCacheSize=512m -XX:+UseCodeCacheFlushing
面试加分回答
「Code Cache 是 JIT 编译器的「工作区」。在生产环境中,如果应用有很多热点方法(如大型电商系统的商品搜索、订单提交等),Code Cache 可能会满。建议:监控 Code Cache 的使用率 (可以用 JConsole 或 VisualVM 查看),如果使用率 > 80%,就调大 -XX:ReservedCodeCacheSize。另外,Java 9+ 引入了 AOT 编译 (Ahead-Of-Time),可以在启动前就编译好机器码,减少启动后的 JIT 编译压力。」
第 43 题:压缩指针(Compressed Oops)的详细原理? 一句话结论
压缩指针(Compressed Oops)= 在 64 位 JVM 上,用 32 位存储对象指针(而不是 64 位),节省约 50% 的内存(对象引用占的空间)。前提是堆内存 < 32GB。
深度解析 为什么需要压缩指针?
1 2 3 4 5 6 7 8 9 10 11 12 64 位 JVM 的问题: → 对象指针(Oop,Ordinary Object Pointer)占 8 字节(64 位) → 如果堆中有 10 亿元素的对象数组,每个元素是一个指针(8 字节) → 光是指针就占了 8GB 内存! 压缩指针: → 用 32 位存储对象指针 → 但 32 位只能表示 4GB 内存 → 不够用? 解决:对齐(Alignment) → JVM 要求对象地址是 8 的倍数(因为对象头是 8 字节对齐) → 所以指针的「低 3 位」永远是 0 → 可以存 2^35 字节 = 32GB!
压缩指针的原理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 假设:堆内存 = 32 GB(< 32 GB 才能用压缩指针) 对象地址的范围:0 ~ 2 ^35 - 1 → 用 32 位存储时,实际存的是「真实地址 / 8 」 → 读取时,左移 3 位(× 8 )得到真实地址 示例: 真实地址:0x00000000 (0 ) 压缩指针:0x00000000 (0 ) 真实地址:0x00000008 (8 ) 压缩指针:0x00000001 (1 ) 真实地址:0x00000010 (16 ) 压缩指针:0x00000002 (2 ) → 用 32 位,可以表示 0 ~ 2 ^32 -1 = 4294967295 个「压缩指针值」 → 对应真实地址:0 ~ 4294967295 × 8 = 34359738360 字节 ≈ 32 GB
如何开启/关闭压缩指针?
1 2 3 4 5 -XX:+UseCompressedOops -XX:-UseCompressedOops
压缩指针的效果:
对比项
开启压缩指针
关闭压缩指针
对象指针大小
4 字节
8 字节
堆内存限制
< 32GB
无限制
内存占用
少 ~50%
多 ~50%
适用场景
堆 < 32GB(大多数场景)
堆 >= 32GB(大内存场景)
面试加分回答
「压缩指针是 JVM 内存优化的重要手段。在生产环境中,如果堆内存 < 32GB,一定要开启压缩指针**(-XX:+UseCompressedOops,默认开启),可以节省约 30%~50% 的内存(对象引用占的空间)。但如果堆内存 >= 32GB,压缩指针会自动关闭(因为 32 位最多表示 32GB),这时可以考虑 升级到 64GB 堆**(虽然指针占更多内存,但总内存更多,可能性能更好)。另外,Java 15+ 引入了 Compressed Class Pointers (压缩类指针),可以进一步节省元空间的内存。」
第 44 题:On-Stack Replacement(OSR,栈上替换)是什么? 一句话结论
OSR(On-Stack Replacement)= JIT 编译器可以在方法执行过程中,把「解释执行」的热循环替换成「编译后的机器码」,不需要等方法调用结束。
深度解析 为什么需要 OSR?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 场景:一个长时间运行的循环 public void method () }OSR 的作用: → JIT 发现「循环」是热代码(执行了很多次) → 在循环执行过程中,把「解释执行的循环」替换成「编译后的机器码」 → 不需要等方法调用结束 → 提升性能!
OSR 的工作原理:
1 2 3 4 5 6 7 8 步骤: 1. JIT 监控循环的执行次数 2. 如果循环执行次数 > 阈值(如 10000 次) → JIT 编译这个循环(生成机器码) 3. 在循环的某个「安全点」(通常是循环末尾) → 替换栈帧(Stack Frame)的内容 → 把「解释执行的栈帧」替换成「编译后的栈帧」 4. 之后循环用编译后的机器码执行 → 快!
OSR 的适用场景:
1 2 3 4 5 6 7 8 9 10 11 12 13 public void process () { for (int i = 0 ; i < 100_000_000 ; i++) { } }public void init () { while (true ) { } }
面试加分回答
「OSR 是 JIT 编译器的「黑科技」之一。在没有 OSR 之前,如果热代码在一个「大循环」里,JIT 无法编译它(因为 JIT 通常只在「方法调用」时编译)。有了 OSR 之后,即使热代码在循环里,JIT 也能编译它。面试时可能会追问:OSR 和普通的 JIT 编译有什么区别? 答案是:普通的 JIT 编译是在「方法调用」时替换(从解释执行换成机器码执行);OSR 是在「循环执行过程中」替换(不需要等方法调用结束)。另外,OSR 编译后的代码和普通的 JIT 编译代码是分开存储的 (因为栈帧布局不同)。」
第 45 题:JVM 调优实战案例(高频!) 一句话结论
JVM 调优实战 = 明确目标(吞吐量 vs 延迟) → 收集 GC 日志 → 分析 GC 日志(用 GCViewer 或 GCeasy) → 调整参数 → 验证效果。
深度解析 案例 1:Full GC 频繁(最常见)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 现象: → 应用程序运行一段时间后,频繁 Full GC → GC 日志里有很多 [Full GC (Ergonomics )] 或 [Full GC (Allocation Failure )] → 应用响应变慢 原因: ① 堆内存不足(可能是内存泄漏) ② 元空间不足(加载了太多类) ③ 老年代太小(对象提前晋升) 解决: -Xms8g -Xmx8g -XX :MetaspaceSize= 256m -XX :MaxMetaspaceSize= 512m -Xmn4g
案例 2:GC 停顿时间长(影响响应时间)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 现象: → GC 日志里,[GC pause] 的时间很长(如 > 500ms) → 应用响应时间变慢(P99 延迟升高) 原因: ① 使用了 Serial Old 或 Parallel Old (标记-整理算法,STW 时间长) ② 堆内存太大(如 32GB),GC 时需要扫描整个堆 解决: -XX :+UseG1GC -XX :MaxGCPauseMillis= 200 -XX :+UseZGC -Xms16g -Xmx16g
案例 3:CPU 飙高(GC 线程占用了太多 CPU)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 现象: → top 命令看到 Java 进程的 CPU 使用率 > 100 % → 用 top -Hp pid 看到有很多 GC 线程 原因: ① 使用了 CMS (并发标记会占用 CPU ) ② 堆内存不足,GC 频繁 解决: -Xms8g -Xmx8g -XX :+UseG1GC -XX :+UseZGC
案例 4:OOM:Java heap space(堆内存不足)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 现象: → 应用抛出 java.lang.OutOfMemoryError : Java heap space → 应用崩溃 原因: ① 堆内存真的不足(需要调大) ② 内存泄漏(对象无法被回收) 解决: -Xms8g -Xmx8g -XX :+HeapDumpOnOutOfMemoryError -XX :HeapDumpPath=/var/log/app/heapdump .hprof
面试加分回答
「JVM 调优的最重要原则 :不要过早优化 (Premature Optimization)。在调优之前,先确认:1. 是否是代码问题(如内存泄漏、死循环);2. 是否是数据库问题(如慢查询)。很多所谓的『JVM 调优』,实际上应该是『代码优化』或『数据库优化』。另外,GC 日志分析 是调优的基础,推荐用 GCeasy (https://gceasy.io/)在线分析 GC 日志,会自动生成分析报告(包括 GC 频率、停顿时间、内存使用情况等),非常方便。」
第 46 题:JVM 类加载的详细过程是什么? 一句话结论
JVM 类加载 = 加载(Load)→ 连接(Link)→ 初始化(Initialize)。连接又分验证(Verify)、准备(Prepare)、解析(Resolve)三步。
深度解析 类加载的三个阶段 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 1 . 加载(Load) ↓ ① 通过类的全限定名获取定义此类的二进制字节流 ② 将字节流所代表的静态存储结构转为方法区的运行时数据结构 ③ 在内存中生成一个 java.lang.Class 对象(作为方法区这个类的各种数据的访问入口)2 . 连接(Link) ├── 2.1 验证(Verify):确保字节码格式正确(魔数 CAFEBABE、语义合法) ├── 2.2 准备(Prepare):为类变量(static 变量)分配内存并初始化默认值(0 / false / null ) └── 2.3 解析(Resolve):将常量池中的符号引用转为直接引用(可选,可延迟)3 . 初始化(Initialize) ↓ 执行类构造器 <clinit> () 方法(由编译器自动收集所有类变量的赋值动作和静态代码块合并产生)
示例 :
1 2 3 4 5 6 7 public class LoadTest { static int a = 10 ; static final int B = 20 ; static { System.out.println("静态代码块" ); } }
触发类初始化的条件(主动引用) :
场景
示例
new 对象
new LoadTest()
访问类的静态变量(非 final)
int x = LoadTest.a
访问类的静态方法
LoadTest.staticMethod()
反射调用
Class.forName("LoadTest")
初始化子类
子类初始化时,父类必须先初始化
主类(main 方法所在类)
JVM 启动时自动初始化主类
注意 :访问 static final 常量(如 B = 20)不会 触发类初始化(常量在编译阶段已经放入常量池)。
面试加分回答
类加载的核心是初始化阶段 (<clinit>() 方法)。这个方法由编译器自动收集所有类变量的赋值动作 和静态代码块 合并产生。JVM 会保证在多线程环境下,<clinit>() 方法会被正确地加锁、同步(所以静态代码块在多线程环境下是线程安全的)。另外,类加载是懒惰的 (Lazy Initialization):只有在第一次使用类时才会初始化,这也是为什么第一次调用某个类的方法时会稍微慢一点的原因。
第 47 题:双亲委派模型是什么?如何打破双亲委派? 一句话结论
双亲委派 :子类加载器先委托父加载器加载,父加载器加载不了时才自己加载 。打破双亲委派的典型场景:Tomcat(Web 应用隔离)、SPI(JDBC 驱动加载)、OSGi 。
深度解析 双亲委派模型的工作流程 :
1 2 3 4 5 6 7 8 9 10 11 12 13 自定义 ClassLoader ↓ 自己不加载,先委托父加载器 应用类加载器(AppClassLoader) ↓ 自己不加载,先委托父加载器 扩展类加载器(ExtClassLoader) ↓ 自己不加载,先委托父加载器 启动类加载器(BootstrapClassLoader) ↓ 没有父加载器了,自己尝试加载 ↓ 加载不了,退回给子加载器 扩展类加载器尝试加载 ↓ 加载不了,退回给子加载器 应用类加载器尝试加载 ↓ 加载不了,抛 ClassNotFoundException
双亲委派的好处 :
好处
说明
避免重复加载
父加载器加载过的类,子加载器不会重复加载
核心类不被篡改
你写的 java.lang.String 不会被加载(启动类加载器已经加载了真正的 String)
打破双亲委派的场景一:Tomcat(Web 应用隔离)
1 2 3 4 5 6 7 8 Tomcat 的 ClassLoader 层级: BootstrapClassLoader(JVM 核心类) ↓ ExtClassLoader(扩展类) ↓ AppClassLoader(Web 应用共享类) ↓ WebAppClassLoader(每个 Web 应用一个,隔离!)
Tomcat 需要打破双亲委派 :每个 Web 应用可以有自己版本的依赖(如不同版本的 Spring),不能委托给父加载器(否则会冲突)。
打破双亲委派的场景二:SPI(如 JDBC 驱动)
1 2 3 4 5 6 7 ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class);
如何自定义 ClassLoader 打破双亲委派?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class MyClassLoader extends ClassLoader { @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { Class<?> c = findLoadedClass(name); if (c == null ) { c = findClass(name); } if (c == null ) { throw new ClassNotFoundException (name); } if (resolve) { resolveClass(c); } return c; } @Override protected Class<?> findClass(String name) { byte [] bytes = loadClassBytes(name); return defineClass(name, bytes, 0 , bytes.length); } }
面试加分回答
双亲委派模型是 JVM 类加载的默认机制 ,但不是强制的。打破双亲委派的核心原因 是隔离需求 (Tomcat 的每个 Web 应用需要隔离)和SPI 机制 (核心库需要加载用户代码)。实际项目中,如果你需要热加载 (如开发时的热部署),也需要自定义 ClassLoader 打破双亲委派。另外,Java 9 引入的**模块化系统(JPMS)**对类加载机制做了重构,不再是传统的双亲委派模型,但这是进阶话题,面试中很少问到。
第 48 题:方法内联(JIT 优化)是什么? 一句话结论
方法内联 是 JIT 的最重要的优化 之一:把频繁调用的小方法 的字节码直接复制到调用方,减少方法调用的开销(压栈、跳转、返回)。
深度解析 方法调用的开销 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class InlineTest { private int x = 10 ; public int getX () { return x; } public void test () { int sum = 0 ; for (int i = 0 ; i < 1000000 ; i++) { sum += getX(); } } }
方法内联后(JIT 自动优化) :
1 2 3 4 5 6 7 public void test () { int sum = 0 ; for (int i = 0 ; i < 1000000 ; i++) { sum += x; } }
方法内联的条件 :
条件
说明
方法体积小
默认:-XX:MaxInlineSize=35 字节码(小于 35 字节码才内联)
热点方法
调用频率高的方法(由热点探测算法判断)
非递归
递归方法不能被内联(会无限展开)
非 native
native 方法不能被内联
如何查看方法内联(JVM 参数) :
1 2 3 4 5 6 java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp
面试加分回答
方法内联是 JIT 优化的核心 ,它不仅能减少方法调用的开销,还能触发其他优化 (如死代码消除、逃逸分析)。实际项目中,写代码时可以把频繁调用的小方法 拆出来(提高代码可读性),JIT 会自动帮你内联(不会影响性能)。但如果你用反射调用方法 ,JIT 就无法内联(反射调用开销大),这也是为什么反射性能差的原因之一。
第 49 题:逃逸分析(JIT 优化)是什么?有哪些优化? 一句话结论
逃逸分析 是 JIT 的高级优化 :分析对象的作用域,如果对象没有逃逸出方法 (即没有被人引用),就可以做栈上分配、锁消除、标量替换 等优化。
深度解析 什么是「对象逃逸」?
1 2 3 4 5 6 7 8 9 10 11 12 13 public class EscapeAnalysis { public User escape () { User user = new User (); return user; } public void noEscape () { User user = new User (); System.out.println(user.getName()); } }
逃逸分析的三大优化 :
优化一:栈上分配(Stack Allocation)
1 2 3 4 5 6 7 8 9 10 11 12 13 public void test () { for (int i = 0 ; i < 1000000 ; i++) { User user = new User (); } }public void test () { for (int i = 0 ; i < 1000000 ; i++) { User user = new User (); } }
优化二:锁消除(Lock Elision)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public void test () { for (int i = 0 ; i < 1000000 ; i++) { synchronized (new Object ()) { } } }public void test () { for (int i = 0 ; i < 1000000 ; i++) { } }
优化三:标量替换(Scalar Replacement)
1 2 3 4 5 6 7 8 9 10 11 12 13 public void test () { User user = new User (); user.setName("Alice" ); System.out.println(user.getName()); }public void test () { String name = "Alice" ; System.out.println(name); }
开启/关闭逃逸分析(JVM 参数) :
1 2 3 4 5 -XX:+DoEscapeAnalysis -XX:-DoEscapeAnalysis
面试加分回答
逃逸分析是 JIT 的高级优化 ,它能做的优化(栈上分配、锁消除、标量替换)都能显著减少 GC 压力和同步开销。但要注意:逃逸分析本身也有开销 (需要分析对象的作用域),对于短生命周期的小对象 ,逃逸分析带来的收益可能抵不过分析的开销。另外,逃逸分析在 C2 编译器(Server Compiler)中才生效 ,C1 编译器(Client Compiler)不做逃逸分析。这也是为什么服务器端的 Java 应用性能比客户端好的原因之一。
第 50 题:JVM 的线程模型是怎样的?和用户线程、内核线程的关系? 一句话结论
HotSpot JVM 的线程模型是 1:1 模型 :一个 Java 线程直接映射到一个操作系统内核线程(由 OS 调度)。Java 没有自己实现的用户线程(Green Threads),线程的创建、调度、销毁都由操作系统完成。
深度解析 三种线程模型 :
模型
说明
优点
缺点
代表
1:1(内核线程模型)
一个用户线程映射到一个内核线程
真正的并行(多核 CPU)
线程创建/切换开销大
Java、Linux pthread
N:1(用户线程模型)
多个用户线程映射到一个内核线程
线程切换快(不需要系统调用)
无法真正并行(一个内核线程)
早期的 Green Threads
M:N(混合模型)
M 个用户线程映射到 N 个内核线程
兼顾切换速度和并行能力
实现复杂
Go 的 Goroutine
Java 的线程模型(1:1) :
1 2 3 4 5 6 7 8 9 10 public class ThreadModel { public static void main (String[] args) { Thread t = new Thread (() -> { System.out.println("Hello" ); }); t.start(); } }
为什么 Java 不用用户线程(Green Threads)?
1 2 3 4 5 6 7 8 9 Green Threads(用户线程): + 线程切换快(不需要系统调用) - 无法利用多核 CPU(一个内核线程) - 如果某个线程阻塞(如 IO),整个进程都会阻塞 内核线程(1:1 模型): + 真正并行(多核 CPU) + 一个线程阻塞,其他线程不受影响 - 线程创建/切换开销大
Java 能实现「轻量级线程」吗?—— Project Loom(虚拟线程)
1 2 3 4 5 6 7 8 9 10 11 public class VirtualThread { public static void main (String[] args) { for (int i = 0 ; i < 1_000_000 ; i++) { Thread.startVirtualThread(() -> { System.out.println("Hello from virtual thread" ); }); } } }
虚拟线程(Virtual Thread)= M:N 模型:多个虚拟线程映射到少量平台线程(内核线程),兼顾了切换速度和并行能力。
面试加分回答
JVM 的线程模型是1:1 模型 (一个 Java 线程对应一个 OS 内核线程),这是 Java 能利用多核 CPU 的根本原因。但这也带来了线程创建/切换开销大 的问题(每个线程占 1MB 栈空间,线程切换需要系统调用)。Java 19 引入的虚拟线程(Project Loom)就是为了解决这个问题:虚拟线程是 轻量级线程 (占用极少内存),由 JVM 调度,底层映射到少量平台线程(内核线程),可以创建数百万个虚拟线程而不会耗尽内存。这是 Java 并发编程的重大突破,未来可能取代线程池成为主流的并发模型。