Java 真实面试专题

13 篇 · 免费在线阅读

JVM 面试题精选

Java 后端真实面试专题 · JVM 篇

JVM 是真实面经里反复被追问、也是学员最常答不上来的硬骨头。每题三段: ① 标准答(讲透)→ ② 拓展(成体系带出关联点和必追问的)→ ③ 怎么接到你自己的项目

年限标签:🟢 3年内 🔴 3年+


1. 🟢 JVM 的内存结构(运行时数据区)有哪些?

标准答

  • JVM 的运行时数据区可以先按“是否被线程共享”来记。线程共享的是堆和方法区(HotSpot 在 JDK 8 以后主要由元空间实现);线程私有的是程序计数器、虚拟机栈和本地方法栈。
  • 保存绝大多数对象实例和数组,是垃圾收集器管理的主战场,通常再按新生代、老年代(或 G1 的 Region)组织。堆大小由 -Xms/-Xmx 等参数和 JVM 自适应策略共同决定。
  • 方法区/元空间保存类的元数据、运行时常量池、字段和方法描述、静态变量等。元空间使用本地内存,不等于“不会溢出”,动态生成大量类时仍可能触发 OutOfMemoryError: Metaspace
  • 虚拟机栈以线程为单位创建,每次方法调用生成一个栈帧,里面有局部变量表、操作数栈、动态链接和返回地址。栈帧随方法返回自动弹出,因此不由 GC 直接管理;栈空间耗尽通常是 StackOverflowErrorOutOfMemoryError
  • 程序计数器只记录当前线程下一条要执行的字节码指令地址;执行 native 方法时值可以为空。它是规范中唯一没有规定 OutOfMemoryError 的区域。本地方法栈服务于 JNI/native 调用,具体实现由 JVM 决定。

可以用下面的关系快速定位问题(JMM 的“主内存/工作内存”不是这张图里的运行时区域):

拓展

  • "哪些区域会 OOM?"——堆可能报 Java heap space,元空间可能报 Metaspace,直接内存/线程数不足也会 OOM;递归过深更常见的是 StackOverflowError。看到异常消息要先确认是哪一块。
  • 程序计数器为什么不会 OOM?它只保存一个与线程相关的地址值,内存占用固定且很小,是规范中唯一不会抛 OOM 的区域。
  • JDK 8 把永久代换成元空间;JDK 9 又把“扩展类加载器”改称平台类加载器。排查时应结合实际 JDK 版本和 jcmd VM.native_memory,不要把元空间占用当成堆占用。
  • 运行时区域与 JMM 要分开:前者描述 JVM 存什么、放在哪里,后者描述多线程读写共享变量的可见性和有序性。

往项目引 ⭐:"理解内存结构是排查 OOM 的基础——我项目出过堆 OOM(大对象/内存泄漏)和元空间 OOM(动态生成类太多),知道是哪块区域才能对症下药。"


2. 🟢 堆和栈有什么区别?

标准答

  • 是线程私有的调用结构。方法进入时创建栈帧,局部变量表里可能放基本类型值和对象引用,操作数栈用于执行字节码;方法返回后栈帧整体弹出,所以生命周期清晰、访问开销小。
  • 是线程共享的对象存储区,数组和大多数 new 出来的对象都在这里,由 GC 根据可达性决定何时回收。多个线程可以同时持有同一个堆对象的引用,因此共享数据仍需要同步。
  • “栈放引用、堆放对象”是便于记忆的简化说法:引用本身常在栈帧或对象字段里,但对象不一定物理上永远在堆上;JIT 的逃逸分析可能进行标量替换或消除分配。判断问题时应看对象是否逃逸,而不是只看源码里的 new
  • 栈和堆的错误也不同:栈空间耗尽常见 StackOverflowError,堆无法满足分配则是 OutOfMemoryError。调大 -Xmx 不能解决栈溢出,调大 -Xss 也可能减少可创建线程数。

拓展

  • "对象一定在堆上吗?"——不一定。HotSpot 可能把未逃逸对象拆成几个标量,甚至直接消除分配;“栈上分配”是常见的口语说法,具体结果取决于 JIT 优化和运行时版本。
  • 一个线程的栈不是共享堆的替代品。线程数很多时,每个线程的 -Xss 累加会吃掉本地内存,可能报 unable to create native thread
  • 发生 OOM 时先看 jcmd <pid> VM.flags、堆/本地内存统计和异常类型,再决定改 -Xmx-Xss 还是排查泄漏。

往项目引 ⭐:"我项目排查过一次 StackOverflowError——是一段递归没正确终止导致栈帧无限叠加。理解栈的机制让我一看异常就知道是递归或调用太深的问题。"


3. 🟢 new 一个对象的过程是什么?

标准答:① 检查类是否已加载,没加载先类加载;② 在上分配内存(指针碰撞或空闲列表);③ 内存初始化零值;④ 设置对象头(类型指针、Mark Word);⑤ 执行构造方法(init)赋初值。

更准确地说,字节码先执行 new 指令:JVM 验证常量池中的类符号,并在需要时触发类加载和初始化;随后根据对象大小在堆中找到一块连续区域。分配出来的内存会先被置为零值(引用为 null、数值为 0、布尔为 false),再写入对象头和实例字段默认布局,最后调用 <init> 构造方法执行显式初始化。构造方法抛异常时,对象不会作为一个正常构造完成的实例交给调用方。

分配不一定都走全局锁:线程通常先从自己的 TLAB(Thread Local Allocation Buffer) 里顺序分配,TLAB 用尽再通过 CAS 申请新空间;堆使用指针碰撞还是空闲列表,取决于是否存在碎片以及所选收集器。对象头中的 Mark Word 还可能保存哈希值、GC 年龄和锁状态,类型指针指向类元数据。

拓展

  • "分配内存怎么保证并发安全?"——优先在 TLAB 中分配;跨线程共享的 Eden 剩余空间则用 CAS + 重试等方式保护。TLAB 不是额外的堆,而是堆中的线程私有切片。
  • 对象头通常包含 Mark Word 和类型指针,数组还会有数组长度字段;具体大小受压缩指针、JDK 和对象类型影响,不能死背固定字节数。
  • 类初始化和对象构造不是一回事:类的 static 初始化最多执行一次,对象构造每 new 一次都执行;只触发类加载并不一定马上触发初始化。
  • 这题能自然引到锁升级、对象逃逸和安全发布:构造完成前把 this 泄露给其他线程,仍可能读到未完成初始化的状态。

往项目引 ⭐:"理解 new 的过程帮我串起了很多知识——比如 synchronized 的锁状态就存在对象头 Mark Word 里,对象的 GC 年龄也在那,面试时能从'new 对象'自然延伸到锁和 GC。"


4. 🟢 类的加载过程?什么是双亲委派?

标准答:类加载五步——加载(读字节码)→ 验证(校验合法性)→ 准备(静态变量分配内存赋零值)→ 解析(符号引用转直接引用)→ 初始化(执行 static 代码、赋真实值)。 其中“加载、链接(验证/准备/解析)、初始化”是规范上的阶段,解析也可以被 JVM 延迟到真正使用符号引用时。初始化阶段会按文本顺序执行静态变量赋值和 static 代码块,并且由 <clinit> 保证在多线程下只执行一次;主动使用类(如 new、访问非 final 静态字段、反射)通常会触发初始化,被动使用常量则可能不会。

双亲委派不是“父加载器一定加载成功”,而是当前加载器收到请求后先询问父加载器,父加载器找不到时才由自己尝试。典型链路是应用类加载器 → 平台类加载器 → 启动类加载器;启动类加载器由 JVM 实现,未必是一个可直接拿到的 Java 对象。

拓展

  • "双亲委派的好处?"——防止应用伪造 java.lang.String 等核心类,且让同一份核心类尽量只加载一次,减少类型冲突。
  • "怎么打破双亲委派?"——容器可以重写 loadClass(Tomcat 的 webapp 类加载器常先查本地)、JDBC SPI 借助线程上下文类加载器发现驱动;这类机制必须处理好类隔离和资源释放。
  • 准备阶段静态字段先拿到零值,初始化阶段才写入显式值;static final 编译期常量可能在编译时内联,因此读取它不一定触发初始化。
  • 类的身份由“类名 + 定义它的类加载器”共同决定。两个加载器各自加载同名类,类型也不相同,可能出现 ClassCastException

往项目引 ⭐:"我项目用 SPI 机制加载第三方实现(如不同的支付/存储驱动),它就是打破双亲委派、用线程上下文类加载器加载实现类——理解类加载让我看得懂这种'插件式'扩展。"


5. 🔴 怎么判断一个对象可以被回收?(垃圾判定)

标准答:两种算法——

  • 引用计数:对象被引用次数为 0 就回收。缺点:解决不了循环引用,所以 JVM 不用它。
  • 可达性分析(JVM 用):从 GC Roots(栈中引用的对象、静态变量、常量、native 引用等)出发往下搜索,搜不到的对象就是垃圾。

可达性分析不是简单地看某个字段是否为 null,而是在安全点/安全区域由收集器遍历对象图。线程栈中的活动栈帧、已加载类的静态字段、运行时常量池引用、JNI 句柄等作为根,沿着强引用关系向下标记;没有被标记的对象才进入待回收集合。一个对象即使暂时不可达,也要等本轮收集真正处理后才释放其内存。

拓展

  • "GC Roots 有哪些?"——活动线程的栈帧引用、静态字段/运行时常量池引用、JNI 全局或局部句柄,以及 JVM 内部的同步锁对象、系统类加载器等;具体根集合随实现和收集器变化。
  • 对象被判定不可达后不一定马上释放:历史上的 finalize() 允许对象在 finalize 中重新建立引用而“自救”,但执行时机不确定且 JDK 9 已弃用,资源应使用 try-with-resourcesCleaner 管理。
  • 调试泄漏时重点看“到 GC Root 的保留路径(retained size)”,而不是只看浅大小;静态集合、监听器、线程池和 ThreadLocal 是常见长链路。
  • 引用类型会改变可达性语义,但弱/软引用对象仍需配合 ReferenceQueue 或缓存淘汰策略,不能把 GC 当成业务过期机制。

往项目引 ⭐:"理解可达性分析帮我理解了内存泄漏的本质——对象其实没用了,但还被某个 GC Roots(如静态集合、ThreadLocal)引用着,所以一直回收不掉。排查泄漏就是找这种'本该死却还被引用'的对象。"


6. 🔴 垃圾回收算法有哪些?分代收集是什么?

标准答

  • 标记-清除:标记垃圾再清除,简单但产生内存碎片
  • 复制:内存分两半,存活对象复制到另一半,无碎片但浪费空间,适合对象存活率低的新生代
  • 标记-整理:标记后把存活对象移到一端,无碎片,适合存活率高的老年代
  • 分代收集:利用“绝大多数对象很快死亡、少数对象长期存活”的经验,把对象按年龄分区,年轻代优先用复制,老年代按收集器选择清除、整理或 Region 回收。现代 G1/ZGC 也会按 Region 和对象年龄做选择,不应把“新生代/老年代必须是连续两块”当成绝对规则。

一次典型的年轻代回收会扫描根和记忆集,把 Eden、From Survivor 中仍存活的对象复制到 To Survivor,年龄增加,最后交换 Survivor 角色。复制过程中需要写屏障/卡表记录跨代引用,否则扫描老年代的成本会很高;老年代回收则要承担更高的存活率和整理成本。

拓展

  • "为什么分代?"——若每次都扫描整个堆,短命对象会重复付出成本;分代可以让 Young GC 只处理高死亡率区域,把停顿控制在较小范围。
  • 新生代经典布局是 Eden + 两个 Survivor,常见比例约 8:1:1,但实际可由收集器和参数调整,G1 以 Region 管理,不一定呈现这个物理比例。
  • 标记-清除适合需要快速释放但能容忍碎片的场景;标记-整理可消除碎片却会移动对象、更新引用,停顿通常更长;复制需要额外 To 空间。
  • 评价算法要同时看吞吐、停顿、碎片、额外内存和对象移动成本,不能只说“某算法一定最好”。

往项目引 ⭐:"理解分代让我能看懂 GC 日志——大部分 GC 是新生代的 Minor GC(很快),频繁 Full GC 才是问题信号。我项目调优就是盯着别让对象过早进老年代、减少 Full GC。"


7. 🔴 对象什么时候从新生代进入老年代?

标准答:几种情况——① 年龄达到阈值(每次 Minor GC 存活年龄 +1,默认到 15 进老年代);② 大对象直接进老年代(避免在 Survivor 来回复制);③ 动态年龄判定(Survivor 中同年龄对象超一半,大于该年龄的直接晋升);④ Survivor 放不下,提前进老年代。

实际晋升由收集器和运行参数共同决定。以经典分代收集器为例,对象在 Eden 分配,Young GC 后复制到 Survivor 并记录年龄;年龄达到 -XX:MaxTenuringThreshold(常见上限 15)才是“通常路径”。如果本次存活对象总量超过 Survivor 可容纳空间,或者某个年龄段累计占比达到动态年龄判定阈值,JVM 会让对象提前晋升。某些收集器会直接在老年代分配大对象,G1 则把大对象称为 humongous object,处理规则略有不同。

拓展

  • "为什么大对象直接进老年代?"——在 Survivor 间反复复制会产生很高的带宽和停顿成本;但过多大对象仍可能造成老年代/Region 快速耗尽。
  • 对象年龄阈值不是固定常量,不能脱离收集器和 JDK 版本回答“永远是 15”;应结合 GC 日志中的 age table、晋升字节数判断。
  • 对象过早晋升会让老年代更快填满、增加 Mixed/Full GC;对象长期留在年轻代也可能造成 Young GC 频率过高,需要用分配速率和停顿数据平衡。
  • 排查时关注 -Xmn、Survivor 大小、MaxTenuringThreshold、大对象分配和晋升失败,而不是只盯一个阈值。

往项目引 ⭐:"我项目调过一次 Full GC 频繁——发现是有批量大对象(大集合)频繁进老年代。优化了对象大小和生命周期、调大新生代,Full GC 明显减少。理解晋升机制才能这么调。"


8. 🔴 有哪些垃圾回收器?G1 的设计思想是什么?

标准答

  • Serial:单线程收集,停顿期间应用线程暂停,简单且额外开销低,适合客户端或很小的堆。
  • Parallel Scavenge/Parallel Old:多线程并行回收,目标是高吞吐量,JDK 8 服务端常见默认组合。
  • CMS:并发标记清除,追求低停顿,但会产生碎片,且并发阶段跟不上分配时可能 concurrent mode failure;JDK 14 已移除。
  • G1:把堆划分为多个大小相等的 Region,同时管理年轻代和老年代;根据每个 Region 的垃圾收益选择回收集合,并通过停顿目标做软实时调度。JDK 9 起成为默认收集器(具体仍以版本和参数为准)。
  • ZGC/Shenandoah:大量阶段与应用并发,借助着色指针/转发表等技术把停顿压到毫秒级,适合大堆和低延迟场景,但需要评估吞吐和版本支持。

选型先看业务目标:吞吐型批处理可优先 Parallel,通用服务常从 G1 开始;对超大堆和极低延迟有硬指标再评估 ZGC/Shenandoah。收集器名称只是起点,还要结合分配速率、堆大小、暂停分位数和 CPU 预算验证。

拓展

  • "G1 和 CMS 区别?"——CMS 以老年代并发标记清除为主,容易碎片;G1 以 Region 为单位做 Young/Mixed 回收,疏散存活对象,整体更容易控制碎片和停顿,但需要记忆集等额外开销。
  • -XX:MaxGCPauseMillis 是目标而不是硬保证;目标过低可能让 G1 选择更小的回收集合、牺牲吞吐并增加 GC 频率。
  • 生产环境不要只按 JDK 默认值选收集器,应在接近真实流量的压测中比较 p95/p99 停顿、CPU、吞吐和 Full GC 次数,并保留可回滚参数。
  • G1 的并发标记、Mixed GC 和 humongous object 是高频追问点;ZGC/Shenandoah 的具体实现则要按 JDK 版本回答。

往项目引 ⭐:"我项目用 G1,设了停顿时间目标,兼顾吞吐和延迟。面试被问'你用哪个 GC、为什么'时,我能答出'G1 + Region 化 + 可控停顿',并说我们怎么根据停顿和吞吐监控调参数。"


9. 🔴 G1 什么时候触发 Full GC?

标准答:G1 设计上尽量用 Mixed GC(混合回收)避免 Full GC,但以下情况会触发 Full GC(早期 G1 是单线程 Full GC、很慢):老年代空间不足、并发标记没跟上分配速度(分配失败)、元空间不足、显式 System.gc()

更常见的路径是:并发标记周期尚未完成,应用继续高速分配,Young/Mixed GC 找不到足够的可用 Region,出现 to-space exhausted 或晋升失败,只能退化成 Full GC;大对象(humongous object)连续占用 Region、堆碎片或元空间扩容失败也会放大风险。System.gc() 可能被业务或第三方库显式调用,是否真的执行还取决于 -XX:+DisableExplicitGC 和收集器实现。

拓展

  • G1 的 Full GC 通常是长时间 STW,应先从日志确认触发原因,不要看到一次就盲目调大堆。
  • 避免方法包括降低瞬时分配速率、拆分超大对象、保证并发标记提前启动、合理设置 InitiatingHeapOccupancyPercent,并给堆留出操作系统和非堆内存余量。
  • 监控 to-space exhaustedhumongous allocation、老年代占用曲线和并发标记耗时;JDK 版本不同,Full GC 的并行化和日志格式也不同。
  • 调大 -Xmx 只能延后问题,若是静态缓存泄漏、未关闭资源或类加载器泄漏,根因仍需从代码和引用链修复。

往项目引 ⭐:"我项目监控里盯着 G1 的 Full GC——一旦出现就要查是不是分配太快、并发标记跟不上。调过 IHOP 让标记提前启动,避免来不及回收触发 Full GC。"


10. 🟢 Minor GC、Full GC 的区别?什么是 STW?

标准答

  • Young/Minor GC主要处理年轻代,通常由 Eden 分配失败触发,存活对象被复制或疏散到 Survivor/老年代,频率高但单次范围小。
  • Mixed GC是 G1 的概念:一次同时回收年轻代和选中的老年代 Region,并不等于 Full GC。
  • Full GC通常意味着需要对整个堆(并可能涉及元空间)做更重的回收/整理,停顿长、吞吐损失大;具体边界随收集器而变,不能把所有“老年代回收”都叫 Full GC。
  • **STW(Stop The World)**表示某个阶段暂停 Java 应用线程以保证对象图一致,例如根扫描、对象转移或整理。并发收集器只是缩短 STW 阶段,不是完全没有停顿;还要关注安全点进入延迟。

拓展

  • "Full GC 频繁的原因?"——内存泄漏、大对象/晋升失败、堆或元空间容量不足、并发标记跟不上、显式 System.gc(),以及本地内存/容器限制导致的间接压力。
  • 几乎所有收集器都包含 STW 阶段;CMS/G1/ZGC 只是把标记、重定位等部分工作与应用并发。判断影响应看暂停分位数和请求超时,而不是只看 GC 次数。
  • 可通过统一 GC 日志(如 -Xlog:gc* 或 JDK 8 的 -XX:+PrintGCDetails)关联分配速率、回收前后占用和停顿时长,避免只凭监控的一条计数判断。

往项目引 ⭐:"我项目把'Full GC 次数和耗时'作为核心监控指标——Full GC 频繁 = 有内存问题。一次告警就是内存泄漏导致老年代填满频繁 Full GC,定位修复后恢复。"


11. 🔴 线上 CPU 飙高怎么排查?

标准答

  1. top 找到 CPU 高的进程 pid。
  2. top -Hp pid 找到该进程里 CPU 高的线程 tid。
  3. 线程 tid 转 16 进制printf %x)。
  4. jstack pid 导出线程栈,搜那个 16 进制 nid,定位到具体代码。 jstack 中的 nid=0x... 就是这个十六进制 tid。连续抓取 2~3 次(间隔几秒)比只抓一次更容易区分持续死循环和瞬时尖峰。
  5. 同时看 GC、线程数、负载和请求指标,确认是业务线程、GC 线程还是 native 线程在消耗 CPU。常见原因有死循环、频繁 GC、正则回溯、锁竞争、自旋和日志风暴。

拓展

  • 如果是 GC 导致 CPU 高,线程栈会出现 GC Thread/VM Thread 等线程,但根因仍要看 GC 日志、堆占用和对象分配;先临时限流或回滚,再做 dump,避免故障扩大。
  • Arthasthread -n 10dashboard 或 async-profiler 火焰图能减少手工换算;在容器中还要确认 top 的 PID 是否与宿主机/容器 PID 命名空间一致。
  • 线程 dump 可能包含敏感参数,线上保存和传输要做权限控制;高 CPU 线程不一定是“有问题的代码”,也可能是正常批处理,需要结合请求量和历史基线判断。

往项目引 ⭐:"我项目处理过一次线上 CPU 100%——top -Hp + jstack 定位到一段异常导致的死循环,10 分钟修复。有这套完整命令链,面试官会觉得你真扛过线上事故。"


12. 🔴 内存溢出(OOM)和内存泄漏的区别?怎么排查 OOM?

标准答

  • 内存泄漏是对象已经没有业务价值,却仍被 GC Root 通过引用链保持可达,导致已用内存随时间增长;**内存溢出(OOM)**是某次申请无法满足而抛出 OutOfMemoryError。泄漏常常是 OOM 的原因,但大对象、容量估算不足或瞬时并发也可能直接造成 OOM。
  • 先看异常类型:Java heap space/GC overhead limit exceeded 多指堆,Metaspace 指类元数据,Direct buffer memory 指直接内存,unable to create native thread 指线程栈或系统线程资源。不同类型不能用同一套参数处理。
  • 堆 OOM 可预先配置 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=...,拿到 dump 后用 MAT/JProfiler 看 dominator tree、retained size 和从 GC Roots 到大对象的保留路径;同时保留 OOM 前的 GC 日志和业务时间线。

典型闭环是“确认类型 → 保留现场 → 找最大保留者 → 回到代码验证生命周期 → 修复并压测复现”:

拓展

  • 常见泄漏点:静态集合一直 add 不删、ThreadLocal 没 remove、监听器/线程池持有上下文、连接/流没关、无上限缓存和类加载器重复创建。
  • 不是所有 OOM 都是泄漏——也可能是真的需要那么多内存、单次分配超过可用连续空间,或容器 cgroup 限制小于 JVM 估算。扩容前要用数据证明对象存活集确实合理。
  • 现场保护要预留磁盘并限制 dump 权限;jmap -dump 可能触发较长停顿,生产上优先使用 OOM 自动 dump 或分阶段采样。
  • 元空间问题可用 jcmd <pid> VM.classloader_stats、类加载数量和 jcmd VM.native_memory 辅助判断;直接内存则看 Netty/NIO 统计和 MaxDirectMemorySize

往项目引 ⭐:"我项目排查过内存泄漏——dump 分析发现是一个静态 Map 当缓存用、只加不删,越积越大。改成有上限的 LRU 缓存(或加过期)后解决。能讲'dump → MAT → 找引用链'这套流程很加分。"


13. 🔴 JVM 调优一般调什么?

标准答:核心目标是减少 Full GC 频率和停顿。常调:

  • 先定义目标(吞吐、p99 停顿、响应时间和容器内存上限),再基于 GC 日志和监控建立基线:分配速率、各代占用、晋升量、暂停原因、CPU 和 OOM 次数。
  • 容量方面常设置 -Xms/-Xmx(相等可避免运行时扩容抖动),并为元空间、线程栈、直接内存和 native 库预留余量。G1 等收集器下不要机械设置 -Xmn,避免破坏 JVM 的自适应布局。
  • 按目标选择收集器:吞吐优先可用 Parallel,通用低延迟常用 G1,超大堆再评估 ZGC/Shenandoah;通过 -XX:MaxGCPauseMillis、IHOP 等参数表达目标,而不是追求某个“万能值”。
  • 代码层优化往往收益更大:减少临时对象和大对象、复用缓冲、限制缓存、缩短长事务/批次生命周期。每次只改一组变量,压测后比较 p50/p99、吞吐和 CPU。

拓展

  • 调优要先监控定位再调(GC 日志、监控平台),并记录 JDK、收集器、堆大小、流量和版本,保证前后数据可比。
  • 很多“GC 问题”其实是代码问题(内存泄漏、大对象、批量过大);盲目加大堆可能让 Full GC 变得更久,盲目降低停顿目标也可能牺牲吞吐。
  • JDK 9+ 建议统一使用 -Xlog:gc*,safepoint,JDK 8 使用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps;上线前验证日志轮转、磁盘容量和回滚开关。
  • 调优完成要做故障演练(突发流量、老年代填满、OOM dump)并设置告警阈值,避免参数只在压测环境有效。

往项目引 ⭐:"我项目调优是数据驱动的——先看 GC 日志和监控(Full GC 频率、停顿、各代占用),定位是对象过早晋升,再调新生代大小和晋升阈值。不是上来就堆参数。"


14. 🔴 JDK8 的内存模型相比之前有什么变化?

标准答:最大变化是永久代(PermGen)被元空间(Metaspace)取代。永久代在堆里、大小固定容易 OOM;元空间用本地内存、默认随需扩展,存类元信息。同时字符串常量池从永久代移到了堆。

JDK 8 的 HotSpot 仍保留堆、线程栈和本地内存等概念,但把类元数据从固定大小的 PermGen 移到本地内存中的 Metaspace;字符串常量池则在 JDK 7 已迁移到堆,不能把这两件事都归因于 JDK 8。元空间会按需扩容,受进程/容器本地内存约束,也可用 -XX:MaxMetaspaceSize 设置上限。压缩类指针开启时还会有与元空间地址范围相关的布局约束。

拓展

  • "为什么改?"——PermGen 容量固定且受堆外限制,框架动态生成类、热部署时很容易 PermGen OOM;Metaspace 使用本地内存,扩展更灵活,也便于按类加载器卸载。
  • 元空间也会 OOM(动态代理、脚本编译、重复热部署导致类加载器泄漏),可用 -XX:MaxMetaspaceSize 限制,但上限过小会过早触发 OOM;应同时查类加载器存活和代理数量。
  • -XX:PermSize/-XX:MaxPermSize 是旧版参数,JDK 8 应改看 MetaspaceSize/MaxMetaspaceSize。字符串池在堆中,字符串过多通常体现为堆压力而不是元空间压力。
  • 迁移 JDK 8 后要重新评估容器内存:堆参数之外还要加上元空间、线程栈、直接内存和 JIT Code Cache 的预算。

往项目引 ⭐:"我项目是 JDK8/11,知道元空间用本地内存——排查过一次元空间 OOM,是某框架动态生成代理类太多。理解这个变化才知道去看元空间而不是堆。"


15. 🔴 强引用、软引用、弱引用、虚引用的区别?

标准答

  • 强引用是普通变量/字段指向对象,只要从 GC Root 可达就不会被回收;内存紧张时 JVM 也不会主动清理强引用。
  • 软引用在对象即将造成内存压力时可能被清理,适合“有则用、没有可重建”的数据,但清理时机和顺序不可控,不应替代有容量和过期策略的缓存。
  • 弱引用在下一次 GC 判定不可达时就会被清理,典型是 WeakHashMap 的 key 和 ThreadLocalMap 的 key;弱引用对象不能保证跨 GC 存活。
  • 虚引用通过 ReferenceQueue 接收回收通知,本身不能通过 get() 取得对象,常用于跟踪堆外资源生命周期。四种引用都应结合队列和显式关闭逻辑使用。

拓展

  • "ThreadLocal 为什么 key 用弱引用?"——线程池线程长期存活时,ThreadLocal 实例被回收后 key 可变成 null;但 Entry 的 value 仍是强引用,直到下一次访问触发清理,因此业务代码必须在 finallyremove()
  • SoftReference 的回收策略受 JVM 堆压力、收集器和版本影响,缓存更推荐 Caffeine 等有明确大小/TTL/统计的实现;软引用只适合作为兜底。
  • 使用 ReferenceQueue 可以在引用被处理后释放对应的 native 句柄;若只创建引用而不消费队列,相关辅助对象本身也可能积累。
  • 判断泄漏时要看“强引用链”,不要看到对象类型是 WeakReference 就认定一定不会泄漏。

往项目引 ⭐:"我项目本地缓存用过软引用——内存够时缓存命中、内存紧张时自动被回收,避免缓存把内存撑爆 OOM。也理解了 ThreadLocal 弱引用 key + 强引用 value 为什么必须 remove。"


16. 🟢 什么是 JIT?解释执行和编译执行?

标准答:Java 字节码先解释执行(一行行翻译),JVM 的 JIT(即时编译器) 会把热点代码(频繁执行的方法/循环)编译成本地机器码缓存起来,后续直接执行,大幅提速。

HotSpot 通常采用分层编译:解释器先快速启动,C1 编译器做较快、较保守的优化,C2 在收集到足够运行画像后做更激进的内联、范围检查消除、逃逸分析等优化。编译后的机器码放在 Code Cache 中;如果运行假设被打破(例如实际类型发生变化),JIT 会反优化(deoptimization)退回解释执行并重新编译。因此“Java 越跑越快”是有条件的,启动阶段和业务流量变化时可能出现抖动。

拓展

  • "怎么判断热点?"——方法调用计数和循环回边计数达到阈值后进入编译队列,阈值会受分层编译、CPU 和运行时参数影响。
  • 常见优化包括方法内联、常量折叠、锁消除/粗化、逃逸分析和向量化;优化依赖运行画像,不保证每次都发生。
  • 压测要预热到编译稳定,并区分冷启动、编译线程 CPU 和业务线程 CPU;可以用 JFR、-XX:+PrintCompilation(诊断环境)观察。
  • -Xint 可强制解释执行用于对比,生产不应随意关闭 JIT;Code Cache 满或频繁反优化也会造成性能回落。

往项目引 ⭐:"理解 JIT 让我知道为什么压测要'预热'——刚启动是解释执行慢,跑一会热点被 JIT 编译后才到稳定性能,所以压测数据要看预热后的。"


17. 🔴 类加载器有哪几种?

标准答

  • **启动类加载器(Bootstrap)**负责加载核心模块(如 java.base),由 JVM 实现;通常通过 null 表示其父加载器。
  • JDK 8 还有扩展类加载器(Extension),JDK 9 模块化后对应平台类加载器(Platform),负责加载平台模块。
  • **应用类加载器(Application/System)**通常加载 classpath/module path 上的业务类和依赖。
  • 自定义类加载器可重写查找或委派逻辑,用于插件隔离、热部署、加密字节码和多版本依赖。每个加载器都参与类身份判断,并按双亲委派协作。

拓展

  • "Tomcat 为什么自定义类加载器?"——每个 webapp 使用独立加载器,通常优先加载自己的类,再委派公共类,实现应用间隔离和独立热部署;停止应用时若仍有线程/静态对象引用该加载器,会造成 Metaspace 泄漏。
  • OSGi、插件系统和脚本引擎也依赖自定义加载器。设计时要明确哪些 API 放在父加载器可见的公共层,避免同名接口被不同加载器定义。
  • 可用 ClassLoader#getParent()jcmd VM.classloader_stats 和堆 dump 的加载器保留路径定位类加载器泄漏。
  • JDK 9+ 回答“扩展加载器”时最好说明平台类加载器,避免把旧版目录结构当成当前实现。

往项目引 ⭐:"我项目部署在 Tomcat,多个应用各自的类加载器隔离,所以不同应用用不同版本的同名 jar 不会冲突——理解类加载器才明白这种隔离是怎么来的。"


18. 🟢 String 创建了几个对象?字符串常量池了解吗?

标准答String s = "abc" 在常量池里有就复用、没有就创建一个;String s = new String("abc") 创建两个(常量池一个 + 堆里一个),返回堆里的引用。intern() 能把字符串放入/返回常量池的引用。

要区分“编译期常量”和运行期表达式:String a = "a" + "b" 通常在编译期折叠为一个常量;String b = x + y 会在运行期创建拼接结果(现代 JDK 可能由 invokedynamic 生成优化代码)。new String("abc") 至少创建一个新的堆对象,常量池中若没有 "abc" 还会先建立池项,因此面试回答“两个”要注明前提。intern() 会返回字符串池中唯一的规范引用,JDK 7+ 的池位于堆中,是否值得调用取决于字符串规模和生命周期。

拓展

  • "为什么 String 不可变?"——可安全共享和缓存 hashCode,作为 HashMap key 时哈希值不会变化,也避免类加载、路径和权限参数被篡改;不可变性还使常量池复用成为可能。
  • JDK 7+ 字符串常量池在堆里(JDK 8 的 PermGen 变化不要混为一谈)。大量短命字符串会增加堆分配和 GC 压力,和常量池不是一回事。
  • 循环拼接优先 StringBuilder(单线程)或 StringBuffer(需要同步);现代编译器会优化同一表达式内的 +,但无法替代循环中的显式累加优化。
  • intern() 会把字符串长期挂在池中,滥用可能造成堆压力;比较内容用 equals,不要依赖 == 的池化偶然性。

往项目引 ⭐:"我项目里高频拼接(如拼 SQL、拼日志)一律用 StringBuilder,循环里用 + 会产生大量临时 String 对象、加重 GC——理解 String 不可变和常量池才知道为什么。"


19. 🟢 happens-before 和 Java 内存模型(JMM)了解吗?

标准答:JMM 规定了多线程下共享变量的可见性、有序性规则。每个线程有自己的工作内存,改了共享变量要刷回主内存别人才看得到。happens-before 是判断"前一个操作的结果对后一个操作是否可见"的规则,如:锁的解锁 happens-before 后续加锁、volatile 写 happens-before 后续读。

JMM 是抽象内存模型,不等同于 JVM 的堆/栈划分。它解决的是可见性、有序性和部分原子性:线程可能把共享变量缓存到寄存器/工作内存,编译器和 CPU 也可能重排指令;同步原语通过建立 happens-before 约束,让读线程看到正确结果。常见规则包括:同一线程内前面的操作 happens-before 后面的操作;对同一把锁的解锁 happens-before 后续加锁;对同一变量的 volatile 写 happens-before 后续读;线程 start() 前的操作对新线程可见,线程终止对 join() 返回的线程可见。

拓展

  • JMM 主要约束可见性和有序性;volatile 读写本身具有可见性和一定有序性,但不能把 count++ 这种读-改-写复合操作变成原子操作,计数仍需锁或原子类。
  • synchronized 同时提供互斥和内存可见性;final 字段在构造完成且未发生 this 泄漏时有特殊的安全初始化保证。
  • 发生“偶尔读旧值”时,先检查是否存在数据竞争和安全发布,再决定加 volatile、锁、并发容器还是消息传递;只加内存屏障不一定解决业务竞态。
  • 和 JVM 内存结构(堆、栈、元空间)是两套概念,面试时先声明这一点能避免后续回答混乱。

往项目引 ⭐:"理解 JMM 让我知道为什么并发下共享变量要加 volatile 或锁——不是值没改,是改了没及时刷到主内存、别的线程看到旧值。我项目状态标志位加 volatile 就是为了可见性。"


20. 🟢 你项目里有没有做过 JVM 相关的排查或优化?

标准答:结合真实经历讲——如排查 OOM(dump + MAT 找泄漏)、CPU 飙高(top -Hp + jstack)、Full GC 频繁(GC 日志定位对象晋升问题调参数)。讲清"现象 → 工具 → 定位 → 解决"。

建议按固定模板回答,而不是罗列工具:

  1. 现象和影响:什么时候发生、请求延迟/错误率/CPU/堆曲线如何变化。
  2. 保留现场:GC 日志、线程 dump、堆 dump、JFR 或 Arthas 采样,记录 JDK 和启动参数。
  3. 定位证据:把高占用对象/线程与代码调用链、发布版本和流量变化对应起来,排除监控误报。
  4. 修复与验证:限制缓存、拆分批次、修复死循环或调整收集器;压测和灰度后比较 p99、Full GC、CPU 和内存斜率。

拓展

  • 没真实经历也别瞎编,可以说“了解排查思路”,并明确哪些是演练结果、哪些是线上数据;面试官更看重证据链和止损动作。
  • 工具分工:jstack 看线程状态,jmap/jcmd 看堆和类,jstat 看代际变化,MAT 看引用链,Arthas/JFR/async-profiler 看运行画像,GC 日志看暂停和回收原因。
  • 线上操作要考虑开销和权限:先限流/摘流量,再抓取适量现场;dump 文件脱敏、加密并设置保留期限。
  • 复盘应补上预防措施(容量阈值、回归压测、告警和自动化诊断),否则只能算一次性救火。

往项目引 ⭐:"我项目处理过 CPU 飙高(jstack 定位死循环)和内存泄漏(MAT 定位静态缓存只增不减),都是'现象→工具→定位→修复'的完整闭环。哪怕年限不长,能讲出一次真实排查经历就比纯背 JVM 概念强。"


你能答到第几层?

  • 三段都能答、还能往项目引:JVM 这块你超过大多数候选人了。
  • 标准答 + 拓展能成体系答:知识扎实,差把它接到一次真实排查/调优经历上。
  • 标准答都磕巴:JVM 有主线(内存结构 → 类加载 → GC → 调优排查),跟着学一遍 + 动手做一次 OOM/GC 排查就懂。

这是面试专题的「JVM 篇」,网站上还有并发、MySQL、Redis、Spring、微服务、消息队列、项目场景等系统整理。 🌐 更多真实面试专题与资料:smallredtech.com 💬 想系统学 / 简历与辅导咨询,加微信:Ahongbb666(备注「面试题」)