JVM 内存模型与垃圾回收
校招考察频度:★ 高频(生死线·底层核心) 一句话速记:堆分年轻代老年代存对象,元空间放直接内存除永久代;GC 从 CMS 标记碎片退化到 G1 的 Region 价值排序,再到 ZGC 的着色指针与读屏障;三色标记漏标防守分增量更新与 SATB 原始快照;双亲委派向上委派保安全,Tomcat 隔离与 SPI 上下文加载器实现打破。
在 Java 后端面试中,JVM 是区分“调包侠”与“高潜工程师”最清晰的分水岭。考察核心聚焦在运行时内存划分、GC 收集器的代际演化、并发标记算法的防漏标机制,以及类加载器的隔离原理。
一、JVM 运行时数据区(JMM 内存结构)
JVM 运行时数据区架构
1. 线程私有区域
- 程序计数器(Program Counter Register):记录当前线程正在执行的字节码指令地址或行号。每个线程独立拥有,保证在多线程上下文切换后能准确恢复执行点。JVM 规范中唯一没有定义任何 OutOfMemoryError 的区域。
- Java 虚拟机栈(JVM Stack):主管 Java 方法的执行。每个方法被调用时都会在栈中压入一个栈帧(Stack Frame):
- 局部变量表:存放方法参数与局部变量(基本数据类型与对象引用
reference)。 - 操作数栈:执行字节码计算的临时工作区(如压栈、出栈、算术操作)。
- 动态链接:指向运行时常量池的方法符号引用。
- 方法出口:正常或异常返回的处理地址。
- 常见异常:栈深度超出限制抛出
StackOverflowError(如死递归);栈动态扩展申请不到内存抛出OutOfMemoryError。
- 局部变量表:存放方法参数与局部变量(基本数据类型与对象引用
- 本地方法栈(Native Method Stack):作用同虚拟机栈,专门为 JVM 调用本地 JNI(C/C++)方法服务。
2. 线程共享区域
- Java 堆(Heap):JVM 中占用内存最大的一块,几乎所有对象实例都在此分配。
- 年轻代(Young Generation):Eden 区与两个 Survivor 区(S0/S1),默认比例 8:1:1。绝大部分对象在此快速创建并被 Minor GC 回收。
- 老年代(Old Generation):占用堆内存的约 2/3。存放长生命周期对象、大对象(超过
-XX:PretenureSizeThreshold)或多次 GC 后晋升的对象(默认经过 15 次 Minor GC)。
- 元空间(Metaspace,JDK 1.8+):替代 JDK 1.7 的永久代(PermGen)。
- 彻底移入本地直接内存(Native Memory):其大小只受宿主机实际可用物理内存限制(可通过
-XX:MaxMetaspaceSize限制上限)。 - 为什么干掉永久代? 永久代使用 JVM 堆内内存,上限固定且极其难以调优,容易因动态生成类(如 Spring CGLIB、动态代理、反射框架)频繁爆发
java.lang.OutOfMemoryError: PermGen space。迁移到本地直接内存彻底根除了此顽疾。
- 彻底移入本地直接内存(Native Memory):其大小只受宿主机实际可用物理内存限制(可通过
二、垃圾回收三大算法与对象存活判定
1. 存活判定:可达性分析算法(Reachability Analysis)
- 抛弃引用计数法(无法解决循环引用)。
- 从 GC Roots 作为起始节点沿着引用链向下搜索,无法从任何 GC Root 到达的对象判定为可回收对象。
- 可作为 GC Roots 的对象:
- 虚拟机栈(栈帧中的局部变量表)引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象(如字符串常量池中的引用)。
- 本地方法栈 JNI(Native 方法)引用的对象。
- 被同步锁(
synchronized)持有的对象。
2. 三大基础回收算法对比
| 算法 | 运作过程 | 优缺点 | 适用场景 |
|---|---|---|---|
| 标记-清除 (Mark-Sweep) | 1. 标记存活对象 2. 直接清除未标记对象 | 优:实现简单 缺:产生大量不连续内存碎片,大对象无法分配提前触发 Full GC | 老年代(早期) |
| 标记-复制 (Mark-Copy) | 内存对半分,存活对象复制到另一半,清空整块原区域 | 优:无内存碎片,运行高效 缺:可用内存缩小一半,存活率高时代价极大 | 年轻代(Eden ➔ S0/S1) |
| 标记-整理 (Mark-Compact) | 标记后将所有存活对象向内存一端移动,清理边界外内存 | 优:无内存碎片,利用率 100% 缺:移动对象需更新所有引用指针,耗费 CPU | 老年代(主流) |
三、垃圾收集器代际演化(CMS ➔ G1 ➔ ZGC)
1. CMS 收集器:以获取最短回收停顿时间为目标
老年代收集器,基于标记-清除算法,核心是与用户线程并发执行。
CMS 收集时序:
[初始标记 (STW)] ──> [并发标记 (无STW)] ──> [重新标记 (STW)] ──> [并发清除 (无STW)]
(仅找直接关联) (遍历全局引用链) (修正并发变动) (释放死对象内存)- 致命短板(为什么被淘汰?):
- 内存碎片:基于标记-清除,碎片过多导致大对象无法分配,会退化为 Serial Old 单线程收集并进行全量 STW 内存整理,停顿时间长达数秒甚至几十秒。
- 浮动垃圾:在并发清除阶段,用户线程新产生的垃圾无法在本次 GC 中被回收,必须留待下一次 GC。
2. G1 收集器(Garbage-First,JDK 9+ 默认)
面向服务端应用的垃圾收集器,开创了化整为零的 Region 分块架构。
- Region 化内存模型:不再有物理隔离的连续年轻代/老年代,而是将堆内存切分为 2048 个大小均等(1MB ~ 32MB)的 Region。每个 Region 动态扮演 Eden、Survivor、Old 或 Humongous(超过半个 Region 的巨型对象连续块)。
- G1 的两大核心机制:
- 停顿预测模型(
-XX:MaxGCPauseMillis):G1 跟踪每个 Region 的回收价值(垃圾占比与回收耗时),在有限的停顿时间内,优先回收垃圾最多的 Region(Garbage-First 名字来源)。 - Remembered Set(RSet,记忆集):解决跨 Region 引用问题。每个 Region 维护一个 RSet,记录“哪些其他 Region 的卡表(Card Table)引用了我”,使得回收单个年轻代 Region 时无需扫描全堆。
- 停顿预测模型(
3. ZGC 收集器(JDK 15+ 生产成熟):毫秒级超低停顿
设计目标:在任意大堆(从几百 MB 到 16TB)下,将 STW 停顿时间控制在 1ms ~ 10ms 以内。
- 着色指针(Colored Pointers):ZGC 的 64 位对象指针中,用高位 4 个 bit 存储对象的标记状态(Finalizable, Remapped, Marked0, Marked1),直接通过指针本身即可获知对象状态,无需访问对象头。
- 读屏障(Load Barrier)与并发转移:当 GC 线程正在将存活对象搬迁到新地址时,用户线程读取到旧指针,读屏障会拦截该操作,优先将指针修正为新地址(指针自愈机制,Self-Healing),从而让垃圾回收的全过程(包括对象搬迁)与用户线程几乎完全并发,消除漫长的 STW。
四、三色标记算法与并发漏标问题
在 CMS 和 G1 与用户线程并发标记的过程中,对象引用关系可能随时被改变,由此引入了经典的三色标记模型:
三色定义:
- 黑色(Black):自身已存活,且其包含的所有引用子节点都已扫描完毕。
- 灰色(Gray):自身已存活,但其包含的部分引用子节点尚未扫描完毕。
- 白色(White):尚未被扫描到的候选节点;若遍历结束仍为白色,即为死对象。1. 漏标(错杀存活对象)发生的两个充要条件
若在并发扫描期间发生以下两件事,存活的白色对象会被误判为垃圾删除:
- 黑色对象新增了一条指向某个白色对象的引用。
- 灰色对象删除了指向该白色对象的直接或间接引用。
结果:黑色对象不会被重复扫描,而灰色对象又与其断开了联系,导致该白色对象永远无法被遍历到,在清除阶段被当做垃圾回收,造成数据丢失!
初始状态: [黑色 A] [灰色 B] ───引用───> [白色 C]
用户线程修改: [黑色 A] ──新增引用──> [白色 C]
同时修改: [灰色 B] ──断开引用──x
最终导致: 由于 A 是黑色不再被扫描,C 永远无法被染黑,在清除阶段被误杀!2. 解决方案:增量更新 vs 原始快照(SATB)
破坏上述两个条件之一即可阻止漏标:
| 方案 | 破坏条件 | 代表收集器 | 解决原理 |
|---|---|---|---|
| 增量更新 (Incremental Update) | 破坏条件 1(破坏插入) | CMS | 只要黑色对象新增了指向白色对象的引用,写屏障就将该黑色对象重新退化为灰色,在重新标记阶段重新扫描其所有子引用。 |
| 原始快照 (SATB) | 破坏条件 2(破坏删除) | G1 | 当灰色对象要断开对白色对象的引用时,写屏障会在删除前将该白色对象的旧引用快照记录到 SATB 队列中。后续重新标记时仍将其视为存活对象。虽然可能产生少量浮动垃圾,但换取了更高的并发标记吞吐。 |
五、类加载机制与双亲委派模型
1. 类加载五个阶段
- 加载(Loading):通过全限定类名获取
.class二进制字节流,在内存中生成java.lang.Class对象。 - 验证(Verification):确保字节流符合 JVM 规范(文件格式、元数据、字节码与符号引用验证,防止恶意代码)。
- 准备(Preparation):为类变量(
static修饰)在方法区/元空间分配内存并初始化为默认零值(如int为 0,实际赋值在初始化阶段完成;但static final常量在准备阶段直接赋最终值)。 - 解析(Resolution):将常量池内的符号引用替换为直接内存地址引用。
- 初始化(Initialization):执行编译器生成的类构造器
<clinit>()方法,按代码顺序执行静态代码块与静态变量赋值。
2. 双亲委派模型(Parents Delegation Model)
┌─────────────────────────────┐
│ Bootstrap ClassLoader 根加载 │ 加载 JAVA_HOME/lib/rt.jar 核心类
└──────────────▲──────────────┘
│ (向上委派)
┌──────────────┴──────────────┐
│ Extension ClassLoader 扩展 │ 加载 JAVA_HOME/lib/ext 扩展类
└──────────────▲──────────────┘
│ (向上委派)
┌──────────────┴──────────────┐
│ Application ClassLoader 应用│ 加载用户 classpath 下的类
└──────────────▲──────────────┘
│ (向上委派)
┌──────────────┴──────────────┐
│ Custom ClassLoader 自定义 │ 用户自定义类加载器 (如 Tomcat WebAppClassLoader)
└─────────────────────────────┘- 工作机制:当类加载器收到类加载请求时,自己不会先去尝试加载,而是自底向上委派给父类加载器执行;只有当父类加载器反馈无法加载时,子加载器才会尝试自己加载。
- 核心价值:保证 Java 核心类库的安全与全局唯一性。例如无论哪个类加载器要加载
java.lang.String,最终都会委派给最顶层的 Bootstrap ClassLoader 加载系统自带的核心类,防止用户恶意编写同名类篡改系统行为。
3. 如何打破双亲委派?四大经典实战场景
双亲委派并不是强制约束,而是官方提倡的良好实践。打破双亲委派的核心在于重写 ClassLoader 的 loadClass() 方法(若只想自定义加载路径,仅需重写 findClass() 即可遵循双亲委派):
- Tomcat Web 容器:
- 痛点:一个 Tomcat 下部署了应用 A 和应用 B,应用 A 依赖 Spring 4.0,应用 B 依赖 Spring 5.0,两者的同名类全限定名完全相同。
- 方案:Tomcat 自定义了
WebAppClassLoader,打破双亲委派,优先加载自身 Web 应用目录下的类,找不到时才向上传递,完美实现了不同应用的类隔离。
- Java SPI 机制(如 JDBC 驱动):
- 痛点:核心接口
java.sql.Driver位于rt.jar,由最顶层的 Bootstrap ClassLoader 加载;但具体的厂商驱动实现(如 MySQL 的com.mysql.cj.jdbc.Driver)位于用户的classpath,Bootstrap ClassLoader 根本无法识别和加载。 - 方案:通过线程上下文类加载器(Thread Context ClassLoader),让顶层的父加载器能够向下“反向借用”子类加载器加载业务驱动。
- 痛点:核心接口
- OSGi 模块化与热部署:
- 方案:网状的类加载器拓扑结构,每个 Bundle 拥有独立的类加载器,需要替换某个模块时,直接连同其类加载器一起卸载替换。
六、校招高频面试追问
- 什么是内存泄漏?在 Java 中如何导致内存泄漏?
- 内存泄漏是指不再使用的对象由于仍被有效 GC Roots 引用着,导致垃圾收集器无法回收它们。
- 典型案例:
①ThreadLocal忘记调用remove():ThreadLocalMap 的 Entry 继承了弱引用WeakReference,Key 会被回收为 null,但 Value 是强引用,只要线程池的核心线程不销毁,Value 就永远无法回收。
② 静态集合类强引用未清理:如static List不断追加元素且永不清理。
③ 未关闭的流或数据库连接。
String a = new String("abc")创建了几个对象?- 1 个或 2 个。
- 如果字符串常量池中先前没有
"abc",会在常量池中先创建 1 个"abc"对象,然后在 Java 堆中通过new再创建 1 个 String 对象(包装了指向常量池字符数组的引用),共 2 个。 - 如果常量池中已经存在
"abc",则只在堆中new1 个 String 对象,共 1 个。
- Minor GC、Major GC、Full GC 有什么区别?
- Minor GC(Young GC):仅清理年轻代(Eden + S0/S1)。频率极高,速度极快。
- Major GC:清理老年代。通常伴随至少一次 Minor GC。
- Full GC:清理整个堆内存(年轻代 + 老年代 + 元空间)。速度慢,STW 停顿长,线上必须尽量避免频繁 Full GC。
- 线上服务频繁发生 Full GC,该怎么排查?
- 使用
jstat -gcutil <pid> 1000实时查看年轻代、老年代内存增速与 GC 频次。 - 配置
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/log/dump.hprof,在 OOM 瞬间生成内存快照;或通过jmap -dump:format=b,file=dump.hprof <pid>手动导出。 - 将 dump 文件导入 MAT(Memory Analyzer Tool) 或 JProfiler,查看支配树(Dominator Tree),揪出占用内存最大的前几位大对象(通常是静态大集合、未分页的 SQL 查询全表载入内存、或死循环生成对象)。
- 使用
- Java 中的强引用、软引用、弱引用、虚引用有什么区别?
- 强引用(Strong):最普遍的
Object obj = new Object()。只要强引用存在,垃圾收集器宁可抛出 OOM 也绝不会回收它。 - 软引用(SoftReference):在系统将要发生内存溢出(OOM)之前,会将这些软引用对象列入回收范围进行二次回收。常用于内存敏感的缓存。
- 弱引用(WeakReference):只要垃圾回收器一运行,无论内存是否充足,都会回收被弱引用关联的对象。典型应用如
ThreadLocalMap的 Key。 - 虚引用(PhantomReference):形同虚设,随时可能被回收,必须和引用队列(
ReferenceQueue)联合使用,唯一目的就是在对象被回收时收到一个系统通知(如管理堆外内存 DirectByteBuffer 的释放)。
- 强引用(Strong):最普遍的