七、JVM 与类加载(J63–J70)
J63 · 知识点:运行时数据区
【题目】下列哪个区域不属于线程私有?
A. 虚拟机栈 B. 本地方法栈 C. 程序计数器 D. 堆
答案:D
【考点】JVM 内存区域中线程私有与线程共享的划分。
【结论】选 D。堆是 JVM 中所有线程共享的内存区域,不属于线程私有。
【逐项辨析】
- A. 虚拟机栈:线程私有。每个线程在创建时都会分配独立的虚拟机栈,生命周期与线程相同,存储栈帧(局部变量表、操作数栈、动态链接、方法返回地址),彼此隔离,互不可见。
- B. 本地方法栈:线程私有。与虚拟机栈类似,区别仅在于它为 native 方法服务,同样是每个线程独立拥有一份。
- C. 程序计数器:线程私有。记录当前线程所执行的字节码行号指示器,线程切换后能恢复到正确的执行位置,是 JVM 规范中唯一一个不会发生 OutOfMemoryError 的区域。
- D. 堆:线程共享。存放几乎所有对象实例和数组,是垃圾回收的主要区域,所有线程都可以访问堆中的对象(需配合同步机制保证线程安全),因此不属于线程私有。
【知识点】JVM 在运行时将内存划分为若干数据区,按线程归属可分为两大类:
- 线程私有区域:程序计数器、虚拟机栈、本地方法栈。这类区域随线程生而生,随线程灭而灭,不需要考虑垃圾回收问题(因为线程结束时内存自然释放)。
- 线程共享区域:堆、方法区(JDK8 后由元空间 Metaspace 实现)。这类区域在 JVM 启动时创建,所有线程共享,需要垃圾回收机制管理内存分配与回收。
此外,直接内存(Direct Memory)虽然不属于 JVM 运行时数据区的一部分,但在 NIO 中通过 DirectByteBuffer 频繁使用,也需要关注其大小限制(-XX:MaxDirectMemorySize)。
【记忆锚点】“私有栈计数,共享堆方法”——线程私有的是虚拟机栈、本地方法栈、程序计数器;共享的是堆和方法区。
【易混对比】
| 对比维度 | 线程私有区域 | 线程共享区域 |
|---|---|---|
| 代表成员 | 程序计数器、虚拟机栈、本地方法栈 | 堆、方法区(元空间) |
| 生命周期 | 与线程相同 | 与 JVM 相同 |
| 垃圾回收 | 不涉及 | 主要回收对象 |
| 内存溢出 | 虚拟机栈/本地方法栈会 StackOverflowError 或 OOM | 堆会 OOM,元空间会 OOM |
| 线程安全 | 天然安全,无需同步 | 需要同步机制保证安全 |
换问法:若题目改为“下列哪个区域会发生 OOM?”,答案应排除程序计数器(唯一不会 OOM 的区域),从堆、元空间、虚拟机栈中选择。
【自测】若一个多线程程序频繁创建大量线程,最可能导致哪个线程私有区域溢出?
答:虚拟机栈。每个线程都会分配独立的虚拟机栈空间(默认 1MB 左右,可通过 -Xss 调整),线程数过多时总内存消耗会超出系统限制,导致 OOM:unable to create new native thread。
与第 J64 题连考。
【知识关联】
- 同库关联:与 J64(堆分区)、J65(GC Roots)、J68(方法区存类元数据)、J54/J70(栈与工作内存)。
- 实现层:线程私有:PC、虚拟机栈、本地方法栈;共享:堆、元空间(JDK8+ 替代永久代)。
- 面试追问:① 哪些区域会 OOM?② 栈溢出与 OOM 区别?
【拓展延伸】
- 变式问法:对象分配在堆;局部变量在栈;类信息在元空间。
- 版本差异:JDK8 永久代→元空间(本地内存);字符串池移堆(J24)。
- 工程注意点:-Xmx/-Xms/-XX:MetaspaceSize;堆 dump 排 OOM;注意直接内存 NIO。
J64 · 知识点:堆内存分区
【题目】HotSpot JVM 中,新生代与老年代默认比例、以及 Eden:S0:S1 的默认比例是? A. 1:2 和 8:1:1 B. 2:1 和 8:1:1 C. 1:3 和 1:1:8 D. 3:1 和 1:1:8
答案:A
【考点】分代堆结构比例与复制算法的基础。
【结论】选 A。新生代与老年代默认比例为 1:2,新生代内部 Eden 与两个 Survivor 区默认比例为 8:1:1。
【推导过程】
- JVM 默认参数 -XX:NewRatio=2,表示老年代是新生代的 2 倍。
- 设新生代为 1 份,老年代为 2 份,则总堆为 3 份,因此新生代占堆的 1/3,老年代占堆的 2/3,比例为新生代:老年代 = 1:2。
- 新生代内部默认参数 -XX:SurvivorRatio=8,表示 Eden 区是每个 Survivor 区的 8 倍。
- 设 Survivor0(S0)为 1 份,Survivor1(S1)为 1 份,Eden 为 8 份,则新生代共 10 份,因此 Eden:S0:S1 = 8:1:1。
【逐项辨析】
- A. 1:2 和 8:1:1:正确。新生代:老年代 = 1:2(NewRatio=2),Eden:S0:S1 = 8:1:1(SurvivorRatio=8)。
- B. 2:1 和 8:1:1:错误。2:1 颠倒了新生代与老年代的比例,老年代实际更大。
- C. 1:3 和 1:1:8:错误。1:3 的新生代占比过小(实际为 1:2),且 Eden 区应该是最大的,而非 1:1:8 中 Eden 最小。
- D. 3:1 和 1:1:8:错误。3:1 表示新生代是老年代的 3 倍,与实际相反;1:1:8 同样颠倒了 Eden 与 Survivor 的比例。
【知识点】分代收集的理论基础是弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的,熬过多次垃圾收集的对象则越来越难以消亡。基于此,HotSpot 将堆划分为新生代(Young Generation)和老年代(Old Generation)。
新生代又分为 Eden 区和两个 Survivor 区(S0、S1,也叫 From Survivor 和 To Survivor)。对象优先在 Eden 区分配,当 Eden 区满时触发 Minor GC(Young GC),采用标记-复制算法:将 Eden 和 S0 中存活的对象复制到 S1,然后清空 Eden 和 S0;下次 GC 时角色互换(S0 和 S1 轮流充当 To Survivor)。对象每经历一次 Minor GC 年龄加 1(标记在对象头中),当年龄达到晋升阈值(默认 15,可通过 -XX:MaxTenuringThreshold 调整)或 Survivor 区空间不足时,对象晋升到老年代。
标记-复制算法在新生代适用的前提是新生代对象存活率低(约 90% 对象在 Minor GC 中死亡),因此复制开销很小。8:1:1 的比例正是基于这一统计规律设计的优化:只预留 10% 的 Survivor 空间即可容纳存活对象,远优于经典复制算法的 50% 内存浪费。
【记忆锚点】“新老一比二,艾登八一一”——新生代老年代 1:2,Eden:S0:S1 = 8:1:1。或者记参数名:NewRatio=2(老/新=2),SurvivorRatio=8(Eden/Survivor=8)。
【易混对比】
| 对比维度 | 新生代 | 老年代 |
|---|---|---|
| 默认堆占比 | 约 1/3 | 约 2/3 |
| 主要 GC 算法 | 标记-复制 | 标记-清除 / 标记-整理 |
| 触发条件 | Eden 区满 | 老年代空间不足 / 元空间不足 |
| GC 名称 | Minor GC / Young GC | Major GC / Old GC / Full GC |
| 对象特点 | 朝生夕灭,存活率低 | 长期存活,存活率高 |
| 典型参数 | -Xmn, -XX:SurvivorRatio | -XX:NewRatio |
换问法:若题目问“ Survivor 区占新生代的比例是多少?”,答案是 2/10 = 20%(因为 Eden:S0:S1 = 8:1:1,两个 Survivor 共占 2/10)。
【自测】某 JVM(默认分代口径)设置 -Xms300m -Xmx300m -XX:NewRatio=2 -XX:SurvivorRatio=8,求 Eden 区大致容量?
注意写法:
-Xms/-Xmx的数值直接紧跟参数名,不能加等号。JDK 17 实测java -Xms=300m -Xmx=300m -version输出Invalid initial heap size: -Xms=300m、Error: Could not create the Java Virtual Machine.,JVM 直接启动失败;-Xms300m -Xmx300m才能正常启动。-XX:系列参数才用=(如-XX:NewRatio=2)。 答:总堆 300MB,新生代占 1/3 即 100MB,老年代占 2/3 即 200MB。新生代中 Eden 占 8/10,因此 Eden 区约为 100MB × 0.8 = 80MB。
与第 J65 题连考。
【知识关联】
- 同库关联:与 J63/J65/J66/J67;对象从 Eden 到老年代的年龄阈值。
- 实现层:新生代 Eden+S0+S1;老年代;JDK8+ 元空间不在堆。
- 面试追问:① 对象何时进老年代?② 大对象策略?
【拓展延伸】
- 变式问法:Minor/Major/Full GC 区别。
- 版本差异:G1/ZGC 弱化固定分区概念(region)。
- 工程注意点:避免朝生夕死大对象进老年代;监控晋升速率;合理设置新生代比例。
J65 · 知识点:GC Roots
【题目】下列哪些对象可以作为 GC Roots?
A. 虚拟机栈(栈帧局部变量表)中引用的对象 B. 方法区中静态属性引用的对象 C. 方法区中常量引用的对象 D. 以上都是
答案:D
【考点】GC Roots 的可达性分析根集合。
【结论】选 D。A、B、C 三类引用对象均属于 GC Roots 集合,因此以上都是。
【逐项辨析】
- A. 虚拟机栈(栈帧局部变量表)中引用的对象:属于 GC Roots。当前正在执行的方法中的局部变量和参数所引用的对象,是程序运行时的活跃引用,必须保留。
- B. 方法区中静态属性引用的对象:属于 GC Roots。类的静态变量(static 修饰)生命周期与类相同,只要类未被卸载,其引用的对象就不能被回收。
- C. 方法区中常量引用的对象:属于 GC Roots。运行时常量池中的常量(如字符串常量池中的 String 对象)被类结构引用,也是 GC Roots 的一部分。
- D. 以上都是:正确。A、B、C 均属于标准的 GC Roots 来源。
【知识点】现代 JVM 普遍采用可达性分析(Reachability Analysis)算法来判断对象是否存活,而非早期的引用计数法(引用计数法存在循环引用问题,JVM 未采用)。可达性分析从一组称为 GC Roots 的根对象出发,沿着引用链向下搜索,如果一个对象到 GC Roots 没有任何引用链相连(即不可达),则判定该对象可回收。
完整的 GC Roots 集合包括:
- 虚拟机栈中引用的对象(局部变量表中的引用);
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native 方法)引用的对象;
- 所有被同步锁(synchronized)持有的对象;
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等;
- 活跃线程本身。
需要注意的是,GC Roots 是一个全局快照概念。即使在并发标记阶段,GC Roots 的选取也必须保证完整性,否则可能误回收仍在使用的对象。CMS、G1 等并发收集器在初始标记阶段需要 STW(Stop The World),正是为了准确获取 GC Roots 集合。
【记忆锚点】“两栈两方法,锁线常量池”——虚拟机栈、本地方法栈、方法区静态属性、方法区常量、同步锁、活跃线程、常量池。
【易混对比】
| 概念 | 可达性分析 | 引用计数法 |
|---|---|---|
| 原理 | 从 GC Roots 追踪引用链 | 每个对象维护引用计数器 |
| 循环引用 | 可以解决(不可达即回收) | 无法解决(计数器永不为零) |
| JVM 采用 | 是(主流实现) | 否 |
| 额外开销 | 需要全局扫描 | 赋值时维护计数器 |
| 代表语言 | Java、C# | Python(辅助使用)、Objective-C |
换问法:若题目问“引用计数法为什么不能用于 JVM 的垃圾回收?”,答案是它无法解决对象间的循环引用问题,导致内存泄漏。
【自测】以下代码中,objA 和 objB 在方法执行结束后能否被回收?若将它们改为互相引用的成员变量,能否被回收?
public void test() {
Object objA = new Object();
Object objB = new Object();
}答:上述代码中 objA 和 objB 在方法结束后均能被回收,因为它们作为局部变量,方法结束时虚拟机栈栈帧销毁,引用断裂,对象对 GC Roots 不可达。若改为类的成员变量且互相引用(如 objA.field = objB; objB.field = objA;),只要没有任何 GC Roots 引用到它们(例如所在对象本身不可达),JVM 的可达性分析依然能判定它们整体不可达,从而正确回收,不受循环引用影响。
与第 J66 题连考。
【知识关联】
- 同库关联:与 J66(可达性)、J63;与 ThreadLocal 泄漏(弱引用)面试高频延伸。
- 实现层:GC Roots 含栈局部变量、静态变量、JNI、活跃线程等;引用类型四级(强软弱虚)。
- 面试追问:① 循环引用为何能回收?② 软引用适用场景?
【拓展延伸】
- 变式问法:哪些算 GC Root;ThreadLocalMap key 弱引用问题。
- 版本差异:语义稳定。
- 工程注意点:缓存用软引用/Caffeine;及时 remove ThreadLocal;避免静态集合无界增长。
J66 · 知识点:GC 算法
【题目】下列哪种垃圾回收算法会产生内存碎片?
A. 标记-清除(Mark-Sweep) B. 标记-复制(Copying) C. 标记-整理(Mark-Compact) D. 分代收集
答案:A
【考点】三大 GC 算法的优缺点对比。
【结论】选 A。标记-清除算法在回收死亡对象后,存活对象位置不变,会在堆空间中留下大量不连续的内存碎片。
【逐项辨析】
- A. 标记-清除(Mark-Sweep):会产生内存碎片。算法分为标记和清除两个阶段:标记阶段从 GC Roots 遍历所有可达对象并做标记;清除阶段回收未被标记的对象。清除后存活对象保持原位,导致堆中出现大量不连续的空闲内存块。虽然总空闲内存可能充足,但无法满足大对象的连续内存分配需求,从而提前触发 Full GC。
- B. 标记-复制(Copying):不会产生内存碎片。将内存分为两块(或三块,如 Eden+S0+S1),每次只使用其中一块,GC 时将存活对象整体复制到另一块空闲区域,然后清空原区域。复制后的对象在目标区域中紧凑排列,空间连续,无碎片。代价是浪费一部分内存空间(经典实现浪费 50%,新生代优化后只浪费 10%)。
- C. 标记-整理(Mark-Compact):不会产生内存碎片。与标记-清除类似,但在清除(或整理)阶段将存活对象向内存空间的一端移动,然后清理边界以外的内存。整理后空闲空间连续,适合老年代这种存活对象多、复制成本高的场景。
- D. 分代收集:不是单一算法,而是一种策略框架。它根据对象存活周期将堆分为新生代和老年代,对不同代采用不同算法(新生代用复制,老年代用标记-清除或标记-整理)。因此分代收集本身不直接决定是否有碎片,而是取决于所选用的具体算法。
【知识点】三种基础 GC 算法的核心差异在于回收阶段对存活对象的处理方式:
标记-清除(Mark-Sweep):最基础,先标记后清除。优点是实现简单,不需要移动对象,回收阶段效率高。缺点是产生内存碎片,且标记和清除两个阶段的效率都随对象数量增加而下降。
标记-复制(Copying):将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。优点是实现简单,运行高效,没有内存碎片。缺点是对内存空间利用率低(需要预留复制空间),且对象存活率高时复制开销大。
标记-整理(Mark-Compact):标记过程与标记-清除相同,但后续步骤不是直接清理可回收对象,而是让所有存活对象都向内存空间的一端移动,然后直接清理掉端边界以外的内存。优点是无碎片,空间利用率高。缺点是移动对象需要更新所有引用地址(重定位),且需要 STW(Stop The World)暂停用户线程,停顿时间比标记-清除更长。
现代 JVM(如 HotSpot)采用分代收集算法作为整体策略,针对不同代的特点组合使用上述基础算法:新生代对象存活率低,适合标记-复制;老年代对象存活率高,适合标记-整理(或标记-清除配合碎片整理)。
【记忆锚点】“清除有碎片,复制浪费半,整理最圆满,分代组合干”——标记-清除产生碎片;标记-复制浪费空间;标记-整理无碎片但需移动对象;分代收集是策略组合。
【易混对比】
| 算法 | 是否移动对象 | 是否产生碎片 | 空间利用率 | 适用场景 | 停顿特点 |
|---|---|---|---|---|---|
| 标记-清除 | 否 | 是 | 高 | 老年代(早期) | 较短,但碎片累积后触发频繁 GC |
| 标记-复制 | 是 | 否 | 低(50% 或 10%) | 新生代 | 较短(存活对象少) |
| 标记-整理 | 是 | 否 | 高 | 老年代 | 较长(需移动大量对象) |
| 分代收集 | 视代而定 | 视算法而定 | 较高 | 整体堆 | 综合各代特点 |
换问法:若题目问“老年代通常使用哪种垃圾回收算法?”,答案应为标记-整理(或标记-清除配合整理),因为老年代对象存活率高,复制算法成本过高。
【自测】某 JVM 老年代使用标记-清除算法,运行一段时间后频繁出现“明明总空闲内存足够,但分配大对象时却触发 Full GC”的现象,请解释原因并提出改进建议。
答:原因是标记-清除算法产生了大量内存碎片,空闲内存不连续。虽然总空闲量满足大对象需求,但没有足够的连续内存块来容纳该对象,导致分配失败而触发 Full GC。改进建议:将老年代回收算法改为标记-整理(如使用 Serial Old、Parallel Old 收集器),或在标记-清除后定期执行内存整理(Compact),消除碎片。
与第 J67 题连考。
【知识关联】
- 同库关联:与 J65/J67;标记-清除/复制/标记-整理/分代收集。
- 实现层:新生代复制算法高效;老年代标记-整理;吞吐与停顿的权衡。
- 面试追问:① 为何分代?② 记忆集作用?
【拓展延伸】
- 变式问法:各算法优缺点表。
- 版本差异:G1/ZGC/Shenandoah 追求低停顿。
- 工程注意点:选收集器看延迟/吞吐目标;不要迷信默认参数。
J67 · 知识点:G1 收集器
【题目】关于 G1(Garbage First)收集器的特点,下列说法错误的是?
A. 将堆划分为多个大小相等的 Region B. 支持可预测的停顿时间(-XX:MaxGCPauseMillis) C. JDK9 起成为默认垃圾收集器 D. 只适用于内存小于 2GB 的小堆
答案:D
【考点】G1 的设计理念与适用场景。
【结论】选 D。G1 收集器主要面向大堆、服务端应用场景,而非仅限于小堆。
【逐项辨析】
- A. 将堆划分为多个大小相等的 Region:正确。G1 把堆划分为多个大小相等(默认 1MB~32MB,为 2 的幂次)的独立区域(Region),每个 Region 在逻辑上可动态扮演 Eden、Survivor 或 Old 角色,打破了传统固定分代的物理边界。
- B. 支持可预测的停顿时间(-XX:MaxGCPauseMillis):正确。G1 的核心设计目标之一就是提供可预测的停顿时间模型。它通过维护优先列表,优先回收垃圾最多的 Region(Garbage First 名称由来),并根据用户设定的 -XX:MaxGCPauseMillis 目标动态调整每次回收的 Region 数量。
- C. JDK9 起成为默认垃圾收集器:正确。JDK9 之前默认收集器为 Parallel GC(JDK8 及以前)或 CMS(需手动开启);JDK9 开始 G1 取代 Parallel GC 成为服务端模式的默认垃圾收集器。
- D. 只适用于内存小于 2GB 的小堆:错误。G1 的设计初衷恰恰是解决传统收集器在大内存(数 GB 到数十 GB)堆上的停顿时间过长问题。虽然 G1 在小堆上也能工作,但其优势在大堆(通常推荐堆大小 ≥ 4GB~6GB)上才能充分发挥。CMS 在小堆上反而表现不错,但在大堆上存在并发失败(Concurrent Mode Failure)风险。
【知识点】G1(Garbage First)收集器是 JVM 垃圾收集器发展史上的重要里程碑,其核心设计理念可以概括为以下几点:
Region 化堆布局:G1 不再将堆物理划分为连续的新生代和老年代,而是将整个堆划分为约 2048 个大小相等的 Region(默认根据堆大小自动计算,范围为 1MB~32MB,且必须是 2 的幂次)。每个 Region 在运行时动态标记为 Eden、Survivor、Old 或 Humongous(专门存放超大对象,跨多个连续 Region)。这种细粒度的划分使得 G1 可以增量式地回收内存,不必每次回收整个代。
可预测的停顿时间模型:G1 会跟踪每个 Region 的垃圾堆积量(回收价值 = 回收空间 / 回收耗时),维护一个优先列表。在每次 GC 时,根据用户设定的最大停顿时间目标(-XX:MaxGCPauseMillis,默认 200ms),选择回收价值最高的一批 Region 进行回收,从而在有限时间内获得最大的内存释放收益。
Mixed GC 与 Full GC:当老年代占用达到阈值(-XX:InitiatingHeapOccupancyPercent,默认 45%)时,G1 触发并发标记周期(Concurrent Marking Cycle),包括初始标记、并发标记、最终标记和筛选回收。之后执行的 Mixed GC 会同时回收新生代和部分老年代 Region。如果并发标记期间老年代空间不足,或对象分配速度超过回收速度,G1 会退化为 Full GC(整堆标记-整理、全程 STW),这是需要极力避免的。版本口径:「单线程串行」只适用于 JDK 9 及更早的 G1;JDK 10 的 JEP 307 起 G1 Full GC 已改写为多线程并行(本机 JDK 17 实测
-Xlog:gc,gc+task=debug日志:Pause Full (G1 Compaction Pause)伴随Using 6 workers of 15 for full compaction)。因此「G1 Full GC = 串行」是旧口径,按 JDK 10+ 应表述为「并行但仍是全局 STW 的兜底路径」。适用场景演进:G1 最初在 JDK6u14 作为实验特性引入,JDK7u4 正式商用,JDK9 成为默认收集器。其最佳适用场景是大堆(4GB 以上)、低延迟要求(停顿目标几百毫秒)的服务端应用。对于小堆(< 2GB)或批处理高吞吐场景,Parallel GC 可能是更轻量的选择。
【记忆锚点】“G1 分区管大堆,停顿可控垃圾追;JDK9 默认别用小,Garbage First Region 美。”——G1 用 Region 分区,目标是控制停顿时间,JDK9 默认,适合大堆而非小堆。
【易混对比】
| 对比维度 | G1 收集器 | CMS 收集器 | Parallel GC |
|---|---|---|---|
| 设计目标 | 可预测停顿 + 大堆 | 低停顿 | 高吞吐 |
| 堆结构 | Region 分区(不连续) | 连续分代 | 连续分代 |
| 回收算法 | 标记-复制(新生代)+ 标记-整理(老年代) | 标记-清除 | 标记-复制 + 标记-整理 |
| 内存碎片 | 较少(整理老年代) | 较多(清除算法) | 较少 |
| 默认 JDK | JDK9+ | JDK8 前需手动开启 | JDK8 默认 |
| 适用堆大小 | 大堆(≥4GB) | 中堆(1~4GB) | 小堆或大堆吞吐优先 |
| 并发能力 | 并发标记 + 部分并发回收 | 并发标记 + 并发清除 | 非并发 |
换问法:若题目问“G1 相比 CMS 的主要优势是什么?”,答案应聚焦于可预测的停顿时间模型和更少的内存碎片(G1 老年代使用整理而非清除)。
【自测】某应用使用 G1 收集器,运行中频繁出现“to-space exhausted”或“Evacuation Failure”日志,随后触发 Full GC,请分析原因并给出两个调优方向。 (本答提到的 -XX:MaxTenuringThreshold 晋升阈值在第 J64 题讲过,本题不重复展开)
答:原因通常是对象晋升速度过快或 Survivor/老年代空间不足,导致 GC 过程中无法为存活对象找到足够的空闲 Region 进行复制(Evacuation)。调优方向:(1)增大堆内存(-Xmx)或降低老年代占用阈值(-XX:InitiatingHeapOccupancyPercent),让并发标记更提前启动;(2)增大 Survivor 区空间(-XX:SurvivorRatio 调小)或提高晋升阈值(-XX:MaxTenuringThreshold),减少对象过早晋升到老年代。
与第 J68 题连考。
【知识关联】
- 同库关联:与 J66、J64;与 CMS 对比(已逐步废弃)。
- 实现层:Region 化堆;可预测停顿时间模型;优先回收价值大的 region;混合回收。
- 面试追问:① G1 适合什么场景?② 与 ZGC 区别?
【拓展延伸】
- 变式问法:-XX:MaxGCPauseMillis 含义。
- 版本差异:JDK9 起 G1 为默认;JDK10(JEP 307)起 G1 Full GC 由串行改为多线程并行;CMS JDK9 废弃、后续移除;ZGC JDK15 生产可用。
- 工程注意点:大堆低延迟可评估 ZGC;关注 region 大小与停顿目标;用 GC 日志分析。
J68 · 知识点:类加载过程
【题目】类加载(Class Loading)过程中五个阶段的正确顺序是? A. 加载 → 验证 → 准备 → 解析 → 初始化 B. 加载 → 准备 → 验证 → 解析 → 初始化 C. 初始化 → 加载 → 验证 → 准备 → 解析 D. 验证 → 加载 → 准备 → 解析 → 初始化
答案:A
【考点】类加载生命周期五阶段顺序。
【结论】选 A。类从被加载到虚拟机内存中开始,到卸载出内存为止,其生命周期包括加载、验证、准备、解析、初始化五个阶段的固定顺序。
【逐项辨析】
- A. 加载 → 验证 → 准备 → 解析 → 初始化:正确。这是《Java 虚拟机规范》规定的标准顺序,其中解析阶段在某些情况下可以在初始化之后开始(动态绑定支持)。
- B. 加载 → 准备 → 验证 → 解析 → 初始化:错误。验证必须在准备之前完成,因为准备阶段要为类变量分配内存并设置默认值,如果字节码未经验证就分配内存,存在安全风险。
- C. 初始化 → 加载 → 验证 → 准备 → 解析:错误。初始化是类加载的最后一个阶段,必须在加载、验证、准备之后执行,不可能排在首位。
- D. 验证 → 加载 → 准备 → 解析 → 初始化:错误。加载是类加载过程的第一个阶段,负责读取字节码并生成 Class 对象,验证不能先于加载,因为没有加载就没有字节码可供验证。
【知识点】类加载过程是 JVM 将类的字节码数据转换为运行时数据结构(Class 对象)并使其可用的全过程。五个阶段的详细职责如下:
加载(Loading):通过类的全限定名获取二进制字节流(来源可以是 .class 文件、JAR 包、网络、动态生成等);将字节流代表的静态存储结构转化为方法区的运行时数据结构;在堆中生成一个代表该类的 java.lang.Class 对象,作为方法区中该类数据的访问入口。
验证(Verification):确保字节流信息符合 JVM 规范且不会危害虚拟机安全。包括:文件格式验证(魔数 0xCAFEBABE、版本号等)、元数据验证(语义检查,如是否有父类、是否继承 final 类等)、字节码验证(控制流分析、数据流分析,确保跳转合法)、符号引用验证(解析阶段的前置检查)。
准备(Preparation):为类中定义的静态变量(static 修饰)分配内存并设置默认值(零值)。例如 int 类型默认 0,boolean 默认 false,引用类型默认 null。注意:final static 常量(编译期常量)在此阶段会直接初始化为代码中指定的值,而非默认值。
解析(Resolution):将常量池中的符号引用(如类名、方法名、字段名的符号表示)替换为直接引用(指向目标内存地址的指针、偏移量或句柄)。解析动作主要针对类、接口、字段、方法、方法类型、方法句柄、调用点限定符等。JVM 规范允许解析在初始化之后执行,以支持动态语言特性(如 invokedynamic 指令,JDK7+)。
初始化(Initialization):执行类构造器 <clinit>() 方法的过程。<clinit>() 是编译器自动收集类中所有静态变量的赋值动作和静态代码块(static {})合并产生的。虚拟机会保证父类的 <clinit>() 先于子类执行。JVM 对 <clinit>() 方法加锁,确保多线程环境下只有一个线程执行初始化,其余线程阻塞等待。
触发初始化的六种主动引用场景:new 实例化对象、访问静态变量(非 final)、调用静态方法、反射调用、子类初始化触发父类初始化、以及包含 main() 方法的启动类。
【记忆锚点】“加载验准备,解析再初始化”——加载、验证、准备、解析、初始化。或者英文谐音:“Load Verify Prepare Resolve Initialize”→“LVPRI”→“老牌仆人爱”。
【易混对比】
| 阶段 | 核心动作 | 关键细节 |
|---|---|---|
| 加载 | 读字节流,生成 Class 对象 | 来源多样,数组类由 JVM 直接创建 |
| 验证 | 四重校验保安全 | 文件格式、元数据、字节码、符号引用 |
| 准备 | 为 static 变量分配内存并赋零值 | final static 常量直接赋指定值 |
| 解析 | 符号引用转直接引用 | 可延迟到初始化之后(动态绑定) |
| 初始化 | 执行 <clinit>() | 静态变量赋值 + 静态代码块,父类先执行 |
换问法:若题目问“准备阶段静态变量被赋予的是默认值还是代码中指定的值?”,答案是默认值(零值),代码中指定的值在初始化阶段赋予(final static 编译期常量除外)。
【自测】分析以下代码的执行结果,并指出类加载的哪个阶段决定了该结果:
public class Test {
public static void main(String[] args) {
System.out.println(Sub.value);
}
}
class Super {
static int value = 1;
static { System.out.println("Super init"); }
}
class Sub extends Super {
static { System.out.println("Sub init"); }
}答:输出为 "Super init" 和 "1",不会输出 "Sub init"。根据《Java 虚拟机规范》,访问父类定义的静态字段只会触发定义该字段的父类初始化,不会触发子类初始化。类加载的初始化阶段负责执行 <clinit>(),而触发条件决定了哪些类进入初始化阶段。此处仅 Super 类被初始化。
与第 J69 题连考。
【知识关联】
- 同库关联:与 J69(双亲委派)、J19(初始化阶段)、J63(元空间)。
- 实现层:加载→验证→准备→解析→初始化;准备阶段赋零值,初始化执行 clinit。
- 面试追问:① 准备与初始化区别?② 何种情况触发初始化?
【拓展延伸】
- 变式问法:常量在准备/初始化的赋值时机;被动引用不触发。
- 版本差异:语义稳定。
- 工程注意点:避免静态块做重 IO;注意类初始化死锁;OSGi/热部署破坏双亲委派(J69)。
J69 · 知识点:双亲委派模型
【题目】双亲委派模型的主要作用不包括?
A. 避免类被重复加载 B. 保证 Java 核心类库(如 Object)不被自定义类篡改 C. 提高类加载效率 D. 保证类加载请求自下而上逐级委托
答案:C
【考点】双亲委派机制的原理与作用。
【结论】选 C。提高类加载效率并非双亲委派模型的设计目的,实际上委派机制会增加类加载器的查找层级开销。
【逐项辨析】
- A. 避免类被重复加载:属于双亲委派的作用。由于类加载请求优先由父加载器处理,同一个类不会被多个加载器重复加载,保证了类在 JVM 中的唯一性(同一个类 = 相同全限定名 + 相同类加载器)。
- B. 保证 Java 核心类库(如 Object)不被自定义类篡改:属于双亲委派的核心安全作用。启动类加载器(Bootstrap ClassLoader)优先加载核心类库,用户自定义的 java.lang.String 等类无法被加载,防止恶意代码替换基础类。
- C. 提高类加载效率:不属于双亲委派的主要作用。双亲委派通过向上委托增加了查找层级,反而可能降低首次加载的效率(虽然父加载器加载后的类可以被缓存复用)。其设计初衷是安全性和类的唯一性,而非效率优化。
- D. 保证类加载请求自下而上逐级委托:属于双亲委派的工作机制描述。标准委派链为:应用类加载器(Application/APP ClassLoader)→ 扩展/平台类加载器(Extension/Platform ClassLoader)→ 启动类加载器(Bootstrap ClassLoader)。
【知识点】双亲委派模型(Parents Delegation Model)是 Java 类加载器的核心架构,其工作流程如下:当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成;每一层类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器。只有当父加载器反馈自己无法完成这个加载请求(在它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
JDK8 及以前的三层类加载器结构:
- 启动类加载器(Bootstrap ClassLoader):由 C++ 实现,是 JVM 的一部分,负责加载 <JAVA_HOME>/lib 目录下的核心类库(如 rt.jar),以及被 -Xbootclasspath 指定路径中的类。它是所有加载器的顶层,没有父加载器(getParent() 返回 null)。
- 扩展类加载器(Extension ClassLoader):由 Java 实现(sun.misc.Launcher$ExtClassLoader),负责加载 <JAVA_HOME>/lib/ext 目录中的类库,或被 java.ext.dirs 系统变量指定路径中的类。
- 应用程序类加载器(Application ClassLoader):由 Java 实现(sun.misc.Launcher$AppClassLoader),负责加载用户类路径(ClassPath)上的类,是默认的类加载器。
JDK9 模块化改造后的变化:
- 扩展类加载器被改名为平台类加载器(Platform ClassLoader),负责加载 JDK 模块(如 java.base、java.sql 等)。
- 启动类加载器不再仅加载 rt.jar,而是加载核心模块。
- 引入了模块层(ModuleLayer)概念,但双亲委派的基本逻辑仍然保留。
打破双亲委派的典型场景:
- SPI(Service Provider Interface)机制:如 JDBC、JNDI 等,由启动类加载器加载的接口需要调用由应用类加载器加载的实现类。JDK 通过 Thread.setContextClassLoader() 设置线程上下文类加载器(默认是应用类加载器),实现了父加载器请求子加载器加载类的逆向委派。
- OSGi、Tomcat 等模块化/热部署框架:为了实现模块隔离和版本共存,自定义类加载器会优先加载本地类,再委派给父加载器。
【记忆锚点】“父先子后保安全,核心类库篡不了;SPI 场景要打破,线程上下文来救驾。”——双亲委派先让父加载器尝试,保证核心类安全;SPI 需要打破委派,用线程上下文类加载器解决。
【易混对比】
| 维度 | 双亲委派模型 | 打破双亲委派 |
|---|---|---|
| 加载顺序 | 自下而上委托,自上而下加载 | 子加载器优先加载 |
| 安全性 | 高(核心类防篡改) | 需自行保障 |
| 类唯一性 | 强(同一名称全局唯一) | 弱(可存在多版本) |
| 典型应用 | 标准 Java 应用 | Tomcat、SPI、OSGi |
| 线程上下文类加载器 | 用于逆向委派 | 关键实现手段 |
| 复杂度 | 简单 | 较复杂 |
换问法:若题目问“为什么自定义 java.lang.String 类不会生效?”,答案是因为双亲委派机制下,启动类加载器会优先加载核心类库中的 java.lang.String,自定义的 String 不会被应用程序类加载器加载。(补充口径:在 JDK 9+ 的模块化约束下,这类源码往往在 javac 阶段就被拒绝,见下方【自测】的实测报错;双亲委派是运行期的第二道防线,而不是唯一防线。)
【自测】以下代码能否运行成功?若能,输出什么?若不能,说明原因。
public class java.lang.String {
public static void main(String[] args) {
System.out.println("Custom String");
}
}答:连编译阶段都过不去,谈不上运行期。JDK 17 实测
javac MyString.java直接报错、不生成任何 class 文件:
MyString.java:1: 错误: 需要'{'
public class java.lang.String {
^
1 个错误原因是
class关键字后必须跟简单类名,不能写java.lang.String这样的限定名——这是语法错误,不是运行期行为。若改成「合法类名 +package java.lang;」的绕法,javac 17 依然在编译期拒绝:错误: 程序包已存在于另一个模块中: java.base(java.lang 属于核心模块 java.base,用户代码不得向其中添加类,-Xbootclasspath时代的老路子也被模块系统堵住了)。 因此正确口径是:在 JDK 9+ 上,手写 java.lang.String 在编译期就失败;双亲委派+java.*包名保护是运行期的第二道防线(即便用反射或自定义字节码塞入同名类,加载 java.lang.String 的仍是启动类加载器,用户版本不会生效)。旧答案里「编译阶段可能通过(取决于编译器)」「运行时报 SecurityException/ClassFormatError」在本机 JDK 17 上无法复现,属于把编译期问题误写成运行期问题。
与第 J70 题连考。
【知识关联】
- 同库关联:与 J68;与 SPI、Tomcat/WebappClassLoader、模块化打破委派的案例。
- 实现层:加载器层次 App→Ext/Platform→Bootstrap;先委托父加载器。
- 面试追问:① 为何需要双亲委派?② 如何打破?
【拓展延伸】
- 变式问法:自定义 ClassLoader 顺序;JDBC 线程上下文类加载器。
- 版本差异:JDK9 模块系统影响可见性;仍保留委派模型核心。
- 工程注意点:热部署/隔离容器要理解委派;避免同名类多版本冲突。
J70 · 知识点:JMM 与 happens-before
【题目】在 JMM(Java 内存模型)中,下列哪组操作之间可以建立 happens-before 关系?
A. 程序顺序规则、volatile 写/读、锁的解锁/加锁、线程的 start/join B. 任意两个无关操作之间 C. 只有 synchronized 修饰的操作 D. 只有 volatile 修饰的操作
答案:A
【考点】happens-before 八大规则。
【结论】选 A。程序顺序规则、volatile 写/读规则、锁规则、线程启动/终止规则均属于 JMM 中明确定义的 happens-before 规则。
【逐项辨析】
- A. 程序顺序规则、volatile 写/读、锁的解锁/加锁、线程的 start/join:正确。这四组分别对应 happens-before 规则中的程序次序规则、volatile 变量规则、管程锁定规则、线程启动规则和线程终止规则,均是 JMM 规范中正式定义的规则。
- B. 任意两个无关操作之间:错误。happens-before 关系不是任意两个操作之间都存在的,它只在特定规则定义的场景下成立。两个没有同步关系、没有共享变量、没有线程生命周期关联的操作之间,不存在 happens-before 关系,JVM 不保证它们的执行顺序和可见性。
- C. 只有 synchronized 修饰的操作:错误。synchronized 确实可以通过锁的解锁/加锁建立 happens-before 关系,但这只是八大规则之一。volatile、final、线程 start/join、程序顺序等同样能建立 happens-before 关系。
- D. 只有 volatile 修饰的操作:错误。volatile 的写/读可以建立 happens-before 关系(volatile 写 happens-before 后续 volatile 读),但这同样只是八大规则之一,不能涵盖所有场景。
【知识点】Java 内存模型(Java Memory Model, JMM)定义了多线程环境下共享变量的访问规则,核心目标是保证并发程序的正确性。happens-before 是 JMM 中判断数据竞争和可见性的核心概念:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 在 B 之前执行(按程序语义,非物理时钟)。
JMM 定义的八大 happens-before 规则:
程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作 happens-before 书写在后面的操作。注意:这里指的是单线程内的语义顺序,编译器和处理器可以进行不改变单线程执行结果的重排序。
管程锁定规则(Monitor Lock Rule):一个 unlock 操作 happens-before 后面对同一个锁的 lock 操作。这里“后面”指的是时间上的先后顺序。这是 synchronized 关键字和 ReentrantLock 保证可见性的基础。
volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作 happens-before 后面对该变量的读操作。这是 volatile 保证可见性的核心机制(通过内存屏障实现)。
线程启动规则(Thread Start Rule):Thread 对象的 start() 方法 happens-before 此线程的每一个动作。即主线程调用 start() 之前的所有操作对新启动的线程可见。
线程终止规则(Thread Termination Rule):线程中的所有操作都 happens-before 对此线程的终止检测。即线程执行的所有操作对调用 Thread.join() 成功返回、或 Thread.isAlive() 返回 false 的其他线程可见。
线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生(通过 Thread.interrupted() 或 Thread.isInterrupted())。
对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)happens-before 它的 finalize() 方法的开始。这保证了 finalize() 方法能看到对象构造完成后的状态。
传递性(Transitivity):如果 A happens-before B,且 B happens-before C,那么 A happens-before C。这使得开发者可以组合多个规则来推导更复杂的可见性保证。
【记忆锚点】“程锁挥启终断终传”——程序次序、管程锁定、volatile、线程启动、线程终止、线程中断、对象终结、传递性。或者记为“锁启终断终传,程序挥(volatile)不散”。
【易混对比】
| 规则 | 触发条件 | 可见性保证方向 |
|---|---|---|
| 程序次序 | 同一线程内,代码书写顺序 | 前操作 → 后操作 |
| 管程锁定 | 同一锁的 unlock → lock | 解锁线程 → 加锁线程 |
| volatile | 同一变量的 write → read | 写线程 → 读线程 |
| 线程启动 | Thread.start() | 主线程 → 新线程 |
| 线程终止 | 线程结束 → join 返回 / isAlive=false | 该线程 → 等待线程 |
| 线程中断 | interrupt() → 检测到中断 | 中断线程 → 被中断线程 |
| 对象终结 | 构造完成 → finalize() | 构造函数 → finalize 方法 |
| 传递性 | A→B 且 B→C | A → C |
换问法:若题目问“volatile 如何保证可见性?”,答案应说明:volatile 写操作会插入 StoreStore + StoreLoad 内存屏障,volatile 读操作会插入 LoadLoad + LoadStore 内存屏障,确保 volatile 写 happens-before 后续 volatile 读,从而使写操作的结果对读线程可见。
【自测】分析以下代码中变量 x 和 y 的最终可能结果,并说明哪些 happens-before 规则在起作用:
int x = 0, y = 0;
volatile int flag = 0;
// Thread A
x = 1;
flag = 1; // volatile write
// Thread B
if (flag == 1) { // volatile read
y = x;
}答:y 的最终值有两种可能:0 或 1,取决于线程 B 执行
if判定的时机;本题真正成立的结论是「若 B 读到 flag == 1,则 B 必读到了 x == 1」,而不是「y 一定等于 1」。
- B 先跑完判定(此刻 flag 仍为 0)→ 不进分支,y 保持初始值 0。
- B 读到 flag == 1 → 一定读到 x == 1,于是 y == 1。依据三条规则:(1)程序次序规则:Thread A 中 x=1 happens-before flag=1(同一线程书写顺序);(2)volatile 变量规则:A 的 flag=1(volatile 写)happens-before B 读到 flag==1(volatile 读);(3)传递性:x=1 → flag=1 → B 的 volatile 读 → y=x,因此这条路径上 B 读到的 x 不可能是旧值 0。 换句话说,volatile 提供的是条件性的可见性保证("看到标志位就看到它之前的所有写入"),它无法决定两个线程谁先执行;若 B 根本没读到 flag==1,happens-before 链就没有建立,y 仍是 0。(补充:示例中的 x/y/flag 写成局部变量只是为了说明语义,真实代码要放在共享字段或数组里;主线程若要打印最终结果,还需
join()依线程终止规则建立 happens-before。)这展示了 volatile 作为"轻量级同步"的典型用法与它的边界。
与第 J63 题连考。
【知识关联】
- 同库关联:与 J54(volatile)、J53(synchronized)、J60(CAS)、J63(工作内存概念)。
- 实现层:happens-before 规则:程序序、监视器锁、volatile、线程 start/join、传递性。
- 面试追问:① 什么是指令重排?② final 的 JMM 语义?
【拓展延伸】
- 变式问法:DCL 单例;无同步的共享标志为何不可靠。
- 版本差异:JDK5 完善 JMM。
- 工程注意点:共享可变状态最少化;优先不可变;用并发工具的文档语义。