JVM核心面试题
一、JVM 整体架构与组成部分
1. JVM 的主要组成部分有哪些?各自的作用是什么?
JVM(Java虚拟机)的主要组成部分包括类加载器 、运行时数据区 、执行引擎 和本地方法接口。下面分别说明它们的作用:
1. 类加载器(Class Loader)
- 作用 :负责将
.class字节码文件加载到内存中,并对字节码进行验证(验证加载的class文件是否正确)、准备(为static变量分配内存并赋0)、解析(将符号引用解析为直接引用)和初始化(执行静态代码块,为静态变量赋予真正的初始值)。 - 关键流程:加载 → 链接(验证、准备、解析)→ 初始化。
- 类加载器类型:启动类加载器、扩展类加载器、应用程序类加载器,以及用户自定义类加载器。
2. 运行时数据区(Runtime Data Areas)
JVM 将内存划分为多个区域,用于存储不同类别的数据:
| 区域分类 | 包含区域 | 主要作用 | 常见异常 |
|---|---|---|---|
| 线程私有 | 程序计数器 | 记录线程执行的字节码指令地址 | 无 |
| Java 虚拟机栈 | 存储栈帧(局部变量、操作数栈等) | StackOverflowError,OutOfMemoryError |
|
| 本地方法栈 | 为 Native 方法服务 | StackOverflowError,OutOfMemoryError |
|
| 线程共享 | 堆 (Heap) | 存放对象实例和数组,是垃圾回收的主要区域 | OutOfMemoryError |
| 方法区 (Method Area) | 存储类信息、常量、静态变量等 | OutOfMemoryError |
3. 执行引擎(Execution Engine)
- 作用:将字节码解释或编译为机器码,并执行。
- 核心组件 :
- 解释器:逐条解释字节码并执行,启动快但执行效率低。
- 即时编译器(JIT):将热点代码编译为本地机器码,提高执行效率。
- 垃圾回收器(GC):自动回收堆中不再使用的对象,避免内存泄漏。
4. 本地方法接口(Native Interface / JNI)
- 作用:允许 Java 代码调用本地应用程序(如 C/C++ 编写的库)中的方法,实现与操作系统或底层硬件的交互。
- 典型使用 :文件 I/O、网络通信等底层操作,Java 标准库中的
native方法即通过此接口实现。
二、类加载机制
2. 类加载的过程(加载、验证、准备、解析、初始化)每个阶段做了什么?
3. 什么是类加载器?双亲委派模型是什么?为什么要这样设计?如何打破?
类加载器 :负责将 .class 文件加载到 JVM 内存中,并生成 Class 对象。
双亲委派模型:当一个类加载器收到加载请求,首先委托父加载器去加载,只有父加载器无法加载时,自己才尝试加载。
作用 :避免类的重复加载,防止核心 API 被篡改(如用户自定义 java.lang.String 不会被加载)。
打破双亲委派模型主要有两种方式:
- 自定义ClassLoader重写
loadClass方法 :不先委派父类,自己优先加载。如 Tomcat 的WebappClassLoader会优先加载 Web 应用WEB-INF/classes和lib下的类,失败后才委托父类加载器,从而实现应用隔离和热部署。 - 线程上下文类加载器:让父加载器获取子加载器来加载类,如JDBC驱动加载。
4. 三个内置类加载器分别加载什么路径的类?
- 三种内置类加载器 :
- 启动类加载器(Bootstrap) :加载
jre/lib下的核心类库(如rt.jar),C++ 实现。 - 扩展类加载器(Extension) :加载
jre/lib/ext下的类。 - 应用程序类加载器(AppClassLoader) :加载
classpath下的类。
- 启动类加载器(Bootstrap) :加载
三、运行时数据区详解
5. 程序计数器的作用是什么?为什么是线程私有的?
作用:记录线程执行的字节码指令地址
原因:每个线程需要通过程序计数器来记录自己当前正在执行的字节码指令地址,各线程互不干扰,所以程序计数器是线程私有的。
6. 栈内存(Stack)存储什么?什么是栈帧?
栈内存 (通常指 Java 虚拟机栈,JVM Stack)存储的是 栈帧(Stack Frame)。
栈帧 是 JVM 中用于支持方法调用和执行的数据结构。每个方法被调用时,JVM 会在当前线程的虚拟机栈中创建一个新的栈帧。方法执行结束时,栈帧被销毁。
一个栈帧通常包含以下几部分:
| 组成部分 | 作用 |
|---|---|
| 局部变量表 | 存储方法参数和方法内定义的局部变量(基本类型、对象引用、returnAddress 等)。 |
| 操作数栈 | 用于存放计算过程中的中间结果,字节码指令从操作数栈取数据、压结果。 |
| 动态链接 | 指向运行时常量池中该方法的符号引用,支持动态方法调用(如多态)。 |
| 方法返回地址 | 方法执行完后,返回到调用方的位置(正常或异常返回)。 |
7. 什么情况下会抛出 OutOfMemoryError 和 StackOverflowError,如何排查?
1. StackOverflowError
- 发生区域:Java 虚拟机栈 或 本地方法栈。
- 触发条件:线程请求的栈深度超过了 JVM 允许的最大深度(通常由递归调用、方法调用链过长、大量局部变量导致栈帧过大引起)。
- 典型场景:无限递归(缺少递归出口)。
2. OutOfMemoryError (OOM)
不同区域会抛出不同消息的 OOM:
| 区域 | 触发条件 | 典型消息 |
|---|---|---|
| Java 堆 | 无法分配新对象,GC 后仍不足 | OutOfMemoryError: Java heap space |
| 元空间(JDK 8+) | 加载的类太多或元数据过大 | OutOfMemoryError: Metaspace |
| 直接内存 | 使用 ByteBuffer.allocateDirect() 超出限制 |
OutOfMemoryError: Direct buffer memory |
| 栈(若支持动态扩展) | 创建新线程无法分配栈内存 | OutOfMemoryError: unable to create new native thread |
| GC 开销超限 | GC 花费过多时间但回收很少 | OutOfMemoryError: GC overhead limit exceeded |
| 请求数组过大 | 请求的数组长度超过 JVM 限制 | OutOfMemoryError: Requested array size exceeds VM limit |
其中最常遇到的是 堆 OOM 和 元空间 OOM。
如何排查
第一步,保留现场证据。
恢复服务前,必须尽可能保留堆转储文件和线程堆栈。如果已经提前配置了 -XX:+HeapDumpOnOutOfMemoryError,那堆文件会自动生成,否则用 jmap 手动导出堆转储文件(.hprof), 并用 jstack 保留线程堆栈(前提:服务还未挂掉)。
shell
# 先查看 Java 进程 PID
jps
# 生成 dump(注意:执行时应用会暂停响应)
jmap -dump:format=b,file=/tmp/heap.hprof <PID>
shell
# 线程堆栈(可能协助确认死循环/大量线程)
jstack <PID> > thread.dump
第二步,紧急恢复服务。
然后尽快让系统恢复可用,我会先把出问题的节点从负载均衡中摘除,然后直接重启应用。如果是刚发版导致的,会立即回滚。
第三步,分析根因。
1、拿到堆转储文件后,用 Eclipse MAT 打开。重点关注两个视图:
-
Histogram :看哪些类的实例数量最多、占用内存最大。常见的如
byte[]、String或者自定义的业务对象。byte\[\]、char\[\]、java.lang.String 过多 → 文本数据处理问题
HashMapNode、ConcurrentHashMapNode、ConcurrentHashMapNode、ConcurrentHashMapNode 过多 → 缓存/集合未释放(内存泄漏)
自定义业务对象(如 OrderDTO)过多 → 批量查询/分页问题
-
Dominator Tree:找出真正的大对象,然后追踪它的 GC Root 引用链,定位是哪个类里的静态集合或缓存一直持有它,导致无法回收。(内存泄漏)
2、我会查看应用日志中是否有 OutOfMemoryError 的详细描述(比如是 heap space 还是 Metaspace)
3、分析线程堆栈,检查是否存在死循环导致堆 OOM。
第四步,根据 OOM 类型给出修复方案。
- 如果是 Java heap space ,分两种情况:
- 内存泄漏:比如使用了 HashMap 做缓存但没有清理,可以改用 Guava Cache 或软引用。
- 数据量过大:比如一次性查询几百万条记录,可以改为分页或流式处理。
- 如果是 Metaspace,检查是否存在类加载器泄漏(比如频繁热部署)。
- 如果是 Unable to create new native thread,说明线程数超限,需要排查是否无限创建线程池。
8. 方法区(Method Area)与元空间(Metaspace)的区别?方法区(永久代/元空间)存储了哪些信息?
区别:
-
方法区 :JVM 规范中定义的逻辑概念("思想"/"规范"),规定了要存储类信息、常量池、静态变量等。
-
元空间 :HotSpot 虚拟机在 JDK 8 及以后对方法区的具体实现("实现"),使用本地内存而非堆内存。
同理,JDK 7 及以前的永久代也是方法区的一种实现。
方法区(永久代/元空间)存储了哪些信息?
存储了常量池、类信息、方法信息。
四、对象与引用
9. 如何判断对象是否可回收?列举 GC Roots 有哪些?
答案:
-
引用计数法(Java 未采用):每个对象记录被引用的次数,为 0 时回收。缺点是无法解决循环引用(A 引用 B,B 引用 A,计数永远不为 0)。
-
可达性分析算法(Java 使用):从 GC Roots 对象(栈中的引用、静态变量、JNI 引用等)出发,通过引用链向下搜索,不可达的对象判定为可回收。
GC Roots 包括:虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区静态属性引用的对象、方法区常量引用的对象。
注意 :对象被判定为不可达后,会进入一个"复活"环节(finalize() 方法中可重新被引用),但不推荐使用。
10. Java 中的四种引用(强、软、弱、虚)区别及使用场景?
答案:
| 引用类型 | 回收时机 | 用途 |
|---|---|---|
| 强引用(Strong) | 永不回收(除非不可达) | 普通对象引用 |
| 软引用(Soft) | 内存不足时回收 | 实现内存敏感缓存(如图片缓存) |
| 弱引用(Weak) | 下次 GC 时回收 | WeakHashMap、ThreadLocal 中的 key |
| 虚引用(Phantom) | 随时可能回收,无法通过 get() 获取对象 | 跟踪对象被回收的状态,配合 ReferenceQueue 使用 |
示例:
java
Object obj = new Object(); // 强引用
SoftReference<Object> softRef = new SoftReference<>(obj); // 软引用
WeakReference<Object> weakRef = new WeakReference<>(obj); // 弱引用
obj = null; // 只有强引用被切断,软/弱引用才能发挥作用
五、垃圾回收(GC)机制
11. 讲一下垃圾回收机制
如何判定对象已死?(哪些需要回收)
现代 JVM 采用可达性分析算法 。它以一系列 GC Roots(如栈中局部变量、静态变量、常量等)为起点向下遍历引用链。如果一个对象无法通过任何引用链与 GC Roots 相连,即被判定为"不可达"的垃圾,等待被回收。(早期的引用计数法因无法解决循环引用问题已被淘汰。)
经典的垃圾回收算法(如何回收)
JVM 主要通过以下三种经典算法来清理内存:
- 标记-清除(Mark-Sweep):标记存活对象后直接清除垃圾。优点是简单,缺点是会产生大量内存碎片。
- 复制算法(Copying):将存活对象复制到新的内存区域,然后整块清理旧区域。优点是无碎片、效率高,缺点是内存利用率低(浪费一半空间)。
- 标记-整理(Mark-Compact):标记存活对象后,将它们向内存一端移动并整理,再清理边界外的内存。优点是无碎片且利用率高,缺点是移动对象有性能成本。
️ 分代收集理论(GC 的核心策略)
JVM 根据对象的存活周期,将堆内存划分为不同区域并采用组合策略:
- 新生代 :存放新创建的对象,绝大多数对象"朝生夕死"。因为存活率极低,通常采用高效的复制算法。
- 老年代 :存放熬过多次 GC 依然存活的对象。因为存活率高,通常采用标记-清除 或标记-整理算法。
️ 主流的垃圾收集器(谁来执行回收)
垃圾收集器是上述算法的具体实现,针对不同业务场景有不同的选择:
- Serial / Parallel:传统收集器,回收时会暂停所有应用线程(STW),适合对吞吐量要求高的后台任务。
- CMS:以获取最短停顿时间为目标,适合对响应速度要求高的 Web 应用(JDK 9 后逐渐被废弃)。
- G1 (Garbage First):JDK 9+ 的默认收集器,将堆划分为多个区域(Region),能建立可预测的停顿时间模型,适合大内存、多核的服务端应用。
- ZGC / Shenandoah:新一代超低延迟收集器,即使在 TB 级超大堆内存下,也能将停顿时间控制在极短范围内。
12. 垃圾回收算法有哪些?分别的优缺点?
标记-清除(Mark-Sweep)
- 原理:首先标记所有可达对象,然后清除未标记的对象。
- 优点:简单,不需要移动对象。
- 缺点:产生内存碎片,且标记和清除阶段都需要 STW(Stop-The-World),效率较低。
标记-复制(Mark-Copy)
- 原理:将内存分为两块(如 Eden 和 Survivor),只使用其中一块,当该块满时,将存活对象复制到另一块,然后清空原块。
- 优点:无内存碎片
- 缺点:内存利用率低(最多使用一半),存活对象多时复制成本高。
标记-整理(Mark-Compact)
- 原理:标记存活对象后,将所有存活对象向一端移动,然后清理边界以外的内存。
- 优点:无内存碎片,内存利用率高。
- 缺点:移动对象需要更新引用,STW 时间相对较长。
分代收集
实际生产中的 GC(如 HotSpot VM)不是单一算法 ,而是分代收集 :根据对象存活周期的不同,将堆划分为新生代(Young Generation) 和 老年代(Old Generation),不同区域使用不同算法。
| 区域 | 特点 | 选用算法 | 原因 |
|---|---|---|---|
| 新生代 | 对象存活率低(98% 对象朝生夕死) | 标记-复制 | 存活对象少,复制成本低;无碎片; |
| 老年代 | 对象存活率高,空间大 | 标记-清除 或 标记-整理 | 复制成本高;标记-清除配合 CMS 减少停顿;标记-整理用于 G1、ZGC 等避免碎片 |
13. Java 堆(Heap)的结构是怎样的?为什么要分代?
Java Heap
├── Young Generation (新生代)
│ ├── Eden 区
│ ├── Survivor 区 (S0 / From)
│ └── Survivor 区 (S1 / To)
└── Old Generation (老年代)
各区域的作用
Young Generation (年轻代)
- Eden:新创建的对象(除了大对象)首先分配在这里。
- Survivor (S0, S1):存放经过一次或多次垃圾回收后仍然存活的对象。每次 Minor GC (/ˈmaɪ.nə/)后,存活对象会在两个 Survivor 区间交替存放。
Old Generation :存放经历多次 Minor GC 后仍然存活、且达到一定年龄(阈值)的对象;也存放大对象(通过 -XX:PretenureSizeThreshold 直接分配在老年代)。
分代原因:老年代和新生代对象的存活周期不同,不同区域采用不同的垃圾回收算法,来提高垃圾回收的效率。
14. 常见的垃圾回收器有哪些?
经典垃圾回收器
- Serial
特点:单线程 GC,新生代采用复制算法,老年代采用标记-整理算法,GC 时会暂停所有应用线程(STW)。
适用:单 CPU 环境、客户端应用、内存较小的场景。 - ParNew
特点:新生代回收器,采用复制算法。Serial 的多线程版本,常与 CMS 配合使用。
适用:多 CPU 环境、希望低延迟且使用 CMS 的场景。 - CMS(Concurrent Mark Sweep)
特点:老年代并发收集,标记-清除算法。目标是最小化停顿时间。会产生内存碎片、浮动垃圾。
适用:对响应时间敏感的 Web 服务(JDK 8 及以前)。JDK 9 后标记为 deprecated,JDK 14 移除。 - Parallel (派若勒)
特点:新生代采用复制算法,老年代采用标记-整理算法。多线程,核心目标是追求高吞吐量。
适用:后台计算、批处理等对响应时间不敏感但对吞吐量敏感的场景。
现代垃圾回收器
-
G1(Garbage First)
特点:将堆划分为多个 Region,物理上不再连续,但逻辑上依然保留新生代和老年代的概念;G1会优先回收垃圾最多的 Region,以最小时间获取最大回收收益;可预测停顿时间模型:用户可指定期望的 GC 停顿时间。
局部采用标记-复制(Region 间),整体采用标记-整理算法(无碎片)。
适用:多核大内存(>4GB)环境,对延迟有一定要求(但不如 ZGC 极致)的场景,JDK 9 起默认使用。
-
ZGC(Z Garbage Collector)
特点:ZGC 是 JDK 11 引入的低延迟垃圾回收器,核心特点是停顿时间不超过 10ms ,且停顿不随堆内存增大而增加,支持 TB 级堆内存。它通过染色指针 和读屏障 实现几乎所有 GC 阶段的并发执行。
适用:适用于超大堆内存、对响应时间极其敏感的服务,如金融交易、实时数据分析等。
如何选择:
选择取决于应用对延迟、吞吐量、内存大小的要求:
- 低延迟优先(如交互式应用、Web 服务器) :
- 选用CMS、G1、ZGC,尽量缩短 STW 时间。
- 高吞吐量优先(如后台计算、批处理) :
- 选用Parallel Scavenge,充分利用多核 CPU 提高吞吐量。
- 小内存(< 4GB) :
- 默认的串行收集器(Serial)即可,单线程 GC 反而减少了上下文切换开销。
- 大内存(> 4GB) :
- 推荐 G1 或 ZGC,它们能更好地处理大堆,支持可预测的停顿时间。
15. CMS 收集器的四个阶段是什么?有什么优缺点?什么是"浮动垃圾"?
CMS(Concurrent Mark Sweep)收集器的核心运作过程主要分为以下四个阶段:
1. 初始标记(Initial Mark)
- 状态 :需要 Stop The World (STW),暂停所有用户线程。
- 任务:仅仅标记一下 GC Roots 能直接关联到的对象(即第一层对象)。
- 特点:速度极快,停顿时间非常短。
2. 并发标记(Concurrent Mark)
- 状态 :并发执行,GC 线程与用户线程同时工作。
- 任务:从"初始标记"阶段找到的对象开始,进行可达性分析,遍历整个对象图,找出所有存活的对象。
- 特点 :这是整个 CMS 回收过程中耗时最长的阶段,但因为它不与用户线程抢时间,所以不会造成明显的卡顿。
3. 重新标记(Remark)
- 状态 :需要 Stop The World (STW)。
- 任务:修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
- 特点:停顿时间通常比初始标记稍长,但远短于并发标记的时间。
4. 并发清除(Concurrent Sweep)
- 状态 :并发执行,GC 线程与用户线程同时工作。
- 任务:清理并删除掉标记阶段判断已经死亡的对象,释放其占用的内存空间。
- 特点:与用户线程一起运行,不产生明显的停顿
浮动垃圾指的是:在 CMS 的并发标记和并发清除阶段,由用户线程新产生、但无法在当前这次 GC 中被及时清理掉的垃圾对象。
16. 什么是 Stop-The-World?哪些情况下会发生?
Stop-The-World 是指 JVM 在执行某些特定任务(如垃圾回收、偏向锁撤销、代码反优化等)时,暂停所有正在运行的应用线程。
垃圾回收会导致STW
17. 什么是 Minor GC、Major GC 和 Full GC?它们的触发条件是什么?
| 特性 | Minor GC(新生代) | Major GC(老年代) | Full GC |
|---|---|---|---|
| 回收范围 | 仅新生代 | 仅老年代 | 整个堆(新生代+老年代)+ 元空间 |
| 触发条件 | Eden 区满 | 老年代满 | 老年代/元空间满、空间分配担保失败等 |
| 触发频率 | 极高 | 较低 | 极低 |
| 停顿时间 | 极短(毫秒级) | 较长(秒级) | 很长(秒级以上) |
| 性能影响 | 较小 | 较大 | 极大(需重点规避) |
空间分配担保失败:在执行 Minor GC 之前,JVM 会提前预判:如果新生代所有存活对象都进入老年代,老年代是否装得下?如果装不下,JVM 会放弃 Minor GC,直接触发 Full GC。
18. 频繁 GC 时,你的排查思路是什么?
频繁GC,重点关注 Full GC 的频率和耗时。
19. 频繁发生 Full GC 的原因可能有哪些?如何进行分析和优化?
一、常见原因分类
| 原因类别 | 具体表现 | 典型场景 |
|---|---|---|
| 1. 内存泄漏 | 老年代占用持续增长,GC 后不下降 | 静态容器无限制增长(如 Map/List)、未关闭资源、监听器未注销等 |
| 2. 对象分配速率过高 | 年轻代每次 Minor GC 后晋升到老年代的对象过多 | 大对象直接分配在老年代(如超大数组)、频繁创建临时大对象、瞬时高并发 |
| 3. 老年代空间碎片化 | Full GC 后可用空间不足,但总存活对象不大 | 未启用 CMS 或 G1 的压缩整理,或 CMS 产生严重碎片 |
| 4. 元空间(Metaspace)满 | Full GC 频繁,且 java.lang.OutOfMemoryError: Metaspace |
动态生成大量类(如反射、JSP、CGLIB 代理)、未设置 -XX:MaxMetaspaceSize |
20. gc调优思路
tex
是否频繁 Full GC?
├─ 是 → 检查老年代占用
│ ├─ 持续高不回落 → 内存泄漏 → 修复代码
│ ├─ 回落但快速上涨 → 晋升过快 → 调大年轻代 / 改 G1
│ └─ 回落且上涨慢 → 老年代容量不足 → 增大 -Xmx
└─ 否 → 关注 Minor GC 停顿
├─ 停顿过长 → 调大年轻代 / 改 G1
└─ 停顿可接受 → 无需调优
或者:
是否频繁 Full GC?
├─ 是 → 检查老年代占用 & 元空间
│ ├─ 老年代持续高不回落 → 内存泄漏 → 修复代码
│ ├─ 老年代回落但快速上涨 → 晋升过快或大对象 → 调大年轻代 / 检查大对象 / 改 G1
│ ├─ 老年代回落且上涨慢 → 容量不足 → 增大 -Xmx(物理内存允许下)
│ └─ 元空间持续涨 → 类加载泄漏 → 设 -XX:MaxMetaspaceSize,排查动态类
└─ 否 → 关注 Minor GC 停顿
├─ 单次停顿过长 → 改用 G1 并设 MaxGCPauseMillis
└─ 频率过高累积停顿长 → 调大年轻代
六、JVM 参数配置
21. JVM 参数 -Xms、-Xmx、-Xmn、-XX:MetaspaceSize 等的作用?
| 参数 | 作用 | 示例 | 备注 |
|---|---|---|---|
-Xms(memory start) |
初始堆内存大小 | -Xms512m |
建议与 -Xmx 设为相同,避免运行时扩容带来的性能开销 |
-Xmx(memory max) |
最大堆内存大小 | -Xmx2g |
根据物理内存和应用需求设置,过大会增加 GC 停顿 |
-Xmn(memory new) |
年轻代大小 | -Xmn256m |
通常为堆大小的 1/3~1/4。设置后老年代大小 = -Xmx - -Xmn(需注意元空间等非堆内存)。也可用 -XX:NewSize 和 -XX:MaxNewSize 分别控制 |
-XX:MetaspaceSize |
元空间触发 GC 的初始阈值 | -XX:MetaspaceSize=128m |
不是初始分配大小!当元空间使用量超过该值时触发 Full GC 进行类卸载。默认约 20M。可根据需要调大以减少频繁 GC |
-XX:MaxMetaspaceSize |
元空间最大大小 | -XX:MaxMetaspaceSize=256m |
默认无上限(受本地内存限制)。设置后超出会抛出 OutOfMemoryError: Metaspace |
-Xss( stack size) |
每个线程的栈内存大小 | -Xss1m |
默认 1M(不同平台略有差异)。减小可增加线程数,过小可能导致 StackOverflowError |
七、内存问题:OOM 与内存泄漏
22. 什么是内存溢出(OOM)?可能发生在哪些区域?如何排查?
内存溢出是内存耗尽导致程序崩溃
(注:具体内容已在问题7中详细说明,此处不再重复)
23. 什么是内存泄漏?如何定位?
内存泄漏是对象无法被回收导致内存逐渐被占满
24. 如何定位和解决内存泄漏(Memory Leak)问题?你会使用哪些工具?如何避免内存泄漏?
确认存在内存泄漏
-
现象:服务运行一段时间后响应变慢、频繁Full GC、老年代占用持续增长且不下降。
-
快速验证命令:
bash
jstat -gcutil <pid> 1000观察
O(老年代占用)是否持续接近100%,FGC和FGCT是否快速增长。 -
eclipse MAT分析 堆转储文件(hprof文件)
如何避免内存泄漏(核心三点)
- 静态容器 :用有界缓存(Caffeine、Guava Cache)代替无限制的
static Map/List。 - ThreadLocal :在
finally中调用remove()(尤其线程池环境)。 - 资源关闭 :使用 try-with-resources 或显式
close()。
八、实战案例与排查思路
25. 你是否有过 JVM 调优的实战经验?请描述一次你排查线上问题的完整过程
问题现象:线上服务运行 3 天后,接口响应逐渐变慢,甚至出现间歇性超时。
快速定位 :登录服务器,使用 top 确认 Java 进程 CPU 偏高。接着用 jstat -gcutil <pid> 1000 观察,发现老年代(Old)占用率持续在 90% 以上,且 Full GC 频率极高(几分钟一次),每次 GC 后老年代内存几乎无法回落。
导出堆转储 :在业务低峰期,执行 jmap -dump:live,format=b,file=heap.hprof <pid> 导出堆快照(注:提前配置了 -XX:+HeapDumpOnOutOfMemoryError 也可自动触发)。
分析根因:
- 使用 MAT 打开堆转储文件 ,点击 Histogram 按 Retained Heap 降序排列,发现
ConcurrentHashMap和String占用内存异常高。 - 切换至 Dominator Tree 视图 ,找到该
ConcurrentHashMap大对象,右键选择 Path to GC Roots -> exclude all phantom/weak/soft etc. references。 - 追踪引用链发现 :该 Map 被一个名为
QueryConditionCache的类的static字段强引用。 - 代码审查 :定位到代码发现,开发人员为了优化查询,定义了一个静态 Map 缓存查询条件,但缺乏淘汰机制(如 LRU 或过期时间),导致不同条件的查询不断写入,Map 无限制增长,最终耗尽老年代内存。
解决方案 :引入高性能本地缓存框架 Caffeine 替换原生 Map,并配置最大条数(maximumSize)和写入后过期时间(expireAfterWrite)。
java
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
// 原代码: private static Map<String, Criteria> queryCache = ...
// 使用Caffeine替换后
private static Cache<String, Criteria> queryCache = Caffeine.newBuilder()
// 设置最大条目数为 10000(按实际估算)
.maximumSize(10000)
// 设置写入后 1 小时过期
.expireAfterWrite(1, TimeUnit.HOURS)
// 可选:开启统计监控
.recordStats()
.build();
验证上线 :上线后观察监控,老年代内存曲线恢复为健康的"锯齿状",占用率稳定在 50% 左右,Full GC 频率从几分钟一次降为几小时一次(或基本消失),接口响应时间恢复正常。
九、线程问题排查
26. cpu100% || 线上CPU飙高排查思路?(+1)
具体排查流程:
步骤1:用 ps -ef 或jps找到PID
步骤2:top -H -p 找出高 CPU 线程
输出示例(只看最顶部几行):
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
24680 root 20 0 2.5g 800m 4200 S 0.3 8.2 0:12.34 java
24701 root 20 0 2.5g 800m 4200 R 85.2 8.2 5:23.12 java
24702 root 20 0 2.5g 800m 4200 S 2.1 8.2 0:10.21 java
...
步骤3:将线程PID转换成十六进制
shell
printf "%x\n" pid
输出:
607d
步骤4:用 jstack 导出线程栈
jstack 24680 | grep -A 20 "0x607d"
步骤5:根据输出进行分析
java
"Thread-5" #13 prio=5 os_prio=0 tid=0x00007f8a1c10a800 nid=0x607d runnable [0x00007f8a0c5f9000]
java.lang.Thread.State: RUNNABLE
at com.example.BusyLoop.run(BusyLoop.java:12)
at java.lang.Thread.run(Thread.java:748)
Locked ownable synchronizers:
- None
发现:
- 线程名
Thread-5,状态RUNNABLE。 - 位于
BusyLoop.java的第 12 行,代码可能是while(true) {}或空循环。
完整的一键脚本(面试加分项)
bash
#!/bin/bash
PID=$1
echo "High CPU threads in process $PID:"
top -H -p $PID -b -n 1 | awk 'NR>7 && $9>10 {print $1,$9}' | while read tid cpu; do
hex=$(printf "%x" $tid)
echo "TID=$tid (0x$hex) CPU=$cpu%"
jstack $PID | grep -A 15 "0x$hex"
done
用法:./find_cpu_thread.sh 24680
27. 线上线程死锁怎么快速定位和恢复?jstack 怎么找死锁?代码层面如何避免?
排查死锁(如果 CPU 不高但系统挂起)
如果怀疑死锁 (线程相互等待,导致系统无响应),可以直接用 jstack 检测:
bash
jstack -l 24680 | grep -A 30 "deadlock"
或者手动分析,jstack 会在末尾自动报告发现的死锁:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8a1c0a6a00 (object 0x00000007d5a8a5a0, a java.lang.Object)
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00007f8a1c0a6a80 (object 0x00000007d5a8a5b0, a java.lang.Object)
which is held by "Thread-1"
恢复步骤:
先立即保存现场再重启服务。
如何避免?
- 固定锁顺序(最有效):如果多个线程需要获取同一组锁,强制要求所有线程以相同的顺序获取锁。
- 使用
tryLock()带超时(ReentrantLock):尝试获取锁一段时间,失败则释放已持有的锁并重试或回退。
28. 如何通过 jstack 分析线程堆栈来定位死锁或线程阻塞问题?
第一步:获取线程快照
首先,你需要找到目标 Java 进程的 PID,然后导出线程堆栈信息。
-
查找进程 PID :
在 Linux/macOS 终端执行
jps -l或ps -ef | grep java,找到你应用对应的进程 ID。 -
导出堆栈文件
执行命令
jstack -l <pid> > thread_dump.log (-l携带锁信息)- 小技巧:为了排除偶发因素的干扰,建议每隔 5-10 秒连续抓取 3-5 次快照。如果同一个线程在多次快照中都处于阻塞状态,基本就能确认是问题的根源。
第二步:定位可疑线程
打开导出的 thread_dump.log 文件,重点搜索以下两种异常状态的线程:
- BLOCKED :线程正在等待获取一个由
synchronized保护的监视器锁(Monitor),但锁被其他线程持有。这通常意味着锁竞争非常激烈。 - WAITING / TIMED_WAITING :线程处于无限期等待或超时等待状态。这通常是因为调用了
Object.wait()、Thread.join()、LockSupport.park()或者CountDownLatch.await()等方法。如果线程长时间卡在这里,说明它在等待的条件一直没有被满足(例如没收到通知、异步任务没执行完等)。
第三步:分析堆栈,找到阻塞根因
找到可疑线程后,结合它的调用堆栈(Stack Trace)来定位具体的业务代码和阻塞原因。以下是三种最常见的阻塞场景:
场景一:死锁(Deadlock)
这是 jstack 最能直接发挥作用的场景。如果发生了死锁,jstack 会在日志的最下方自动检测并明确打印出死锁信息。
- 日志特征 :直接显示
Found one Java-level deadlock,并列出互相等待锁的线程名称和它们各自持有的锁。 - 解决思路:根据提示的类名和行号,检查代码中是否存在多个线程以不同的顺序获取多把锁的情况。
场景二:激烈的锁竞争(Lock Contention)
大量线程处于 BLOCKED 状态,都在等待同一把锁。
-
日志特征
"http-nio-8080-exec-10" #35 prio=5 os_prio=0 tid=0x... nid=0x... waiting for monitor entry [...] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.YourClass.yourMethod(YourClass.java:45) // 阻塞在具体的业务代码行 - waiting to lock <0x000000076b0d8e40> (a java.lang.Object) // 等待这把锁 -
解决思路 :找到
<0x000000076b0d8e40>这把锁被谁持有了(在日志中搜索这个十六进制地址,找locked <...>的线程),评估是否可以将synchronized的粒度改小,或者改用并发性能更好的工具类(如ConcurrentHashMap、ReentrantLock等)。
场景三:资源等待或逻辑缺陷(Resource Waiting)
线程处于 WAITING 或 TIMED_WAITING,但长时间没有恢复。
-
日志特征
"pool-1-thread-1" #20 prio=5 os_prio=0 tid=0x... nid=0x... waiting on condition [...] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000007d5c45850> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442) // 比如在等待队列里的任务 -
解决思路
:查看堆栈顶部的业务代码。
- 如果是
LinkedBlockingQueue.take,可能是生产者太慢或消费者卡死。 - 如果是
CountDownLatch.await(),可能是忘记调用countDown()或者触发countDown()的异步任务抛了异常被吞掉了。 - 如果是数据库连接池相关的等待,可能是连接池配置过小或数据库响应极慢。
- 如果是
十、常用调优工具与命令
29. 常见 JVM 调优工具有哪些?(jps、jstat、jmap、jstack、jinfo、MAT、Arthas)
| 工具 | 主要功能与用法示例 |
|---|---|
jps |
查看Java进程ID 。用法:jps -l |
jconsole |
图形化查看内存线程等信息 |
jstat |
实时监控GC和内存 。例如,每秒打印一次GC情况:jstat -gcutil <pid> 1000 |
jmap |
生成堆转储快照 (Heap Dump) 。用法:jmap -dump:format=b,file=heap.hprof <pid> |
jstack |
打印线程堆栈 (Thread Dump) 。用于分析死锁或高CPU占用问题。用法:jstack <pid> |
jcmd |
JVM诊断命令 。功能强大,推荐使用。例如:查看JVM参数、生成Heap Dump等。用法:jcmd <pid> help |
jinfo |
查看和修改JVM配置参数 。用法:jinfo -flags <pid> |
30. 请说明 jstat -gcutil 命令输出中各项指标的含义。
S0 / S1 (Survivor 0 / Survivor 1): 两个 Survivor 区的使用百分比。
E (Eden): Eden 区的使用百分比。
O (Old): 老年代(Old Generation)的使用百分比。
M (Metaspace): 元空间的使用百分比。
CCS (Compressed Class Space): 压缩类空间的使用百分比。
YGC (Young GC Count): 年轻代垃圾回收的次数。
YGCT (Young GC Time): 年轻代垃圾回收消耗的总时间(秒)。
FGC (Full GC Count): 老年代/全局垃圾回收的次数。
FGCT (Full GC Time): 老年代/全局垃圾回收消耗的总时间(秒)。
GCT (Total GC Time): 垃圾回收消耗的总时间(YGCT + FGCT)