Skip to content

七、JVM 与类加载(J63–J70) ​

J63 · 知识点:运行时数据区 ​

【题目】下列哪个区域不属于线程私有?

A. 虚拟机栈 B. 本地方法栈 C. 程序计数器 D. 堆

答案:D

【考点】JVM 内存区域中线程私有与线程共享的划分。

【结论】选 D。堆是 JVM 中所有线程共享的内存区域,不属于线程私有。

【逐项辨析】

  • A. 虚拟机栈:线程私有。每个线程在创建时都会分配独立的虚拟机栈,生命周期与线程相同,存储栈帧(局部变量表、操作数栈、动态链接、方法返回地址),彼此隔离,互不可见。
  • B. 本地方法栈:线程私有。与虚拟机栈类似,区别仅在于它为 native 方法服务,同样是每个线程独立拥有一份。
  • C. 程序计数器:线程私有。记录当前线程所执行的字节码行号指示器,线程切换后能恢复到正确的执行位置,是 JVM 规范中唯一一个不会发生 OutOfMemoryError 的区域。
  • D. 堆:线程共享。存放几乎所有对象实例和数组,是垃圾回收的主要区域,所有线程都可以访问堆中的对象(需配合同步机制保证线程安全),因此不属于线程私有。

【知识点】JVM 在运行时将内存划分为若干数据区,按线程归属可分为两大类:

  1. 线程私有区域:程序计数器、虚拟机栈、本地方法栈。这类区域随线程生而生,随线程灭而灭,不需要考虑垃圾回收问题(因为线程结束时内存自然释放)。
  2. 线程共享区域:堆、方法区(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 GCMajor 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 集合包括:

  1. 虚拟机栈中引用的对象(局部变量表中的引用);
  2. 方法区中类静态属性引用的对象;
  3. 方法区中常量引用的对象;
  4. 本地方法栈中 JNI(Native 方法)引用的对象;
  5. 所有被同步锁(synchronized)持有的对象;
  6. 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等;
  7. 活跃线程本身。

需要注意的是,GC Roots 是一个全局快照概念。即使在并发标记阶段,GC Roots 的选取也必须保证完整性,否则可能误回收仍在使用的对象。CMS、G1 等并发收集器在初始标记阶段需要 STW(Stop The World),正是为了准确获取 GC Roots 集合。

【记忆锚点】“两栈两方法,锁线常量池”——虚拟机栈、本地方法栈、方法区静态属性、方法区常量、同步锁、活跃线程、常量池。

【易混对比】

概念可达性分析引用计数法
原理从 GC Roots 追踪引用链每个对象维护引用计数器
循环引用可以解决(不可达即回收)无法解决(计数器永不为零)
JVM 采用是(主流实现)否
额外开销需要全局扫描赋值时维护计数器
代表语言Java、C#Python(辅助使用)、Objective-C

换问法:若题目问“引用计数法为什么不能用于 JVM 的垃圾回收?”,答案是它无法解决对象间的循环引用问题,导致内存泄漏。

【自测】以下代码中,objA 和 objB 在方法执行结束后能否被回收?若将它们改为互相引用的成员变量,能否被回收?

java
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 算法的核心差异在于回收阶段对存活对象的处理方式:

  1. 标记-清除(Mark-Sweep):最基础,先标记后清除。优点是实现简单,不需要移动对象,回收阶段效率高。缺点是产生内存碎片,且标记和清除两个阶段的效率都随对象数量增加而下降。

  2. 标记-复制(Copying):将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。优点是实现简单,运行高效,没有内存碎片。缺点是对内存空间利用率低(需要预留复制空间),且对象存活率高时复制开销大。

  3. 标记-整理(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 垃圾收集器发展史上的重要里程碑,其核心设计理念可以概括为以下几点:

  1. Region 化堆布局:G1 不再将堆物理划分为连续的新生代和老年代,而是将整个堆划分为约 2048 个大小相等的 Region(默认根据堆大小自动计算,范围为 1MB~32MB,且必须是 2 的幂次)。每个 Region 在运行时动态标记为 Eden、Survivor、Old 或 Humongous(专门存放超大对象,跨多个连续 Region)。这种细粒度的划分使得 G1 可以增量式地回收内存,不必每次回收整个代。

  2. 可预测的停顿时间模型:G1 会跟踪每个 Region 的垃圾堆积量(回收价值 = 回收空间 / 回收耗时),维护一个优先列表。在每次 GC 时,根据用户设定的最大停顿时间目标(-XX:MaxGCPauseMillis,默认 200ms),选择回收价值最高的一批 Region 进行回收,从而在有限时间内获得最大的内存释放收益。

  3. 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 的兜底路径」。

  4. 适用场景演进:G1 最初在 JDK6u14 作为实验特性引入,JDK7u4 正式商用,JDK9 成为默认收集器。其最佳适用场景是大堆(4GB 以上)、低延迟要求(停顿目标几百毫秒)的服务端应用。对于小堆(< 2GB)或批处理高吞吐场景,Parallel GC 可能是更轻量的选择。

【记忆锚点】“G1 分区管大堆,停顿可控垃圾追;JDK9 默认别用小,Garbage First Region 美。”——G1 用 Region 分区,目标是控制停顿时间,JDK9 默认,适合大堆而非小堆。

【易混对比】

对比维度G1 收集器CMS 收集器Parallel GC
设计目标可预测停顿 + 大堆低停顿高吞吐
堆结构Region 分区(不连续)连续分代连续分代
回收算法标记-复制(新生代)+ 标记-整理(老年代)标记-清除标记-复制 + 标记-整理
内存碎片较少(整理老年代)较多(清除算法)较少
默认 JDKJDK9+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 对象)并使其可用的全过程。五个阶段的详细职责如下:

  1. 加载(Loading):通过类的全限定名获取二进制字节流(来源可以是 .class 文件、JAR 包、网络、动态生成等);将字节流代表的静态存储结构转化为方法区的运行时数据结构;在堆中生成一个代表该类的 java.lang.Class 对象,作为方法区中该类数据的访问入口。

  2. 验证(Verification):确保字节流信息符合 JVM 规范且不会危害虚拟机安全。包括:文件格式验证(魔数 0xCAFEBABE、版本号等)、元数据验证(语义检查,如是否有父类、是否继承 final 类等)、字节码验证(控制流分析、数据流分析,确保跳转合法)、符号引用验证(解析阶段的前置检查)。

  3. 准备(Preparation):为类中定义的静态变量(static 修饰)分配内存并设置默认值(零值)。例如 int 类型默认 0,boolean 默认 false,引用类型默认 null。注意:final static 常量(编译期常量)在此阶段会直接初始化为代码中指定的值,而非默认值。

  4. 解析(Resolution):将常量池中的符号引用(如类名、方法名、字段名的符号表示)替换为直接引用(指向目标内存地址的指针、偏移量或句柄)。解析动作主要针对类、接口、字段、方法、方法类型、方法句柄、调用点限定符等。JVM 规范允许解析在初始化之后执行,以支持动态语言特性(如 invokedynamic 指令,JDK7+)。

  5. 初始化(Initialization):执行类构造器 <clinit>() 方法的过程。<clinit>() 是编译器自动收集类中所有静态变量的赋值动作和静态代码块(static {})合并产生的。虚拟机会保证父类的 <clinit>() 先于子类执行。JVM 对 <clinit>() 方法加锁,确保多线程环境下只有一个线程执行初始化,其余线程阻塞等待。

触发初始化的六种主动引用场景:new 实例化对象、访问静态变量(非 final)、调用静态方法、反射调用、子类初始化触发父类初始化、以及包含 main() 方法的启动类。

【记忆锚点】“加载验准备,解析再初始化”——加载、验证、准备、解析、初始化。或者英文谐音:“Load Verify Prepare Resolve Initialize”→“LVPRI”→“老牌仆人爱”。

【易混对比】

阶段核心动作关键细节
加载读字节流,生成 Class 对象来源多样,数组类由 JVM 直接创建
验证四重校验保安全文件格式、元数据、字节码、符号引用
准备为 static 变量分配内存并赋零值final static 常量直接赋指定值
解析符号引用转直接引用可延迟到初始化之后(动态绑定)
初始化执行 <clinit>()静态变量赋值 + 静态代码块,父类先执行

换问法:若题目问“准备阶段静态变量被赋予的是默认值还是代码中指定的值?”,答案是默认值(零值),代码中指定的值在初始化阶段赋予(final static 编译期常量除外)。

【自测】分析以下代码的执行结果,并指出类加载的哪个阶段决定了该结果:

java
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 及以前的三层类加载器结构:

  1. 启动类加载器(Bootstrap ClassLoader):由 C++ 实现,是 JVM 的一部分,负责加载 <JAVA_HOME>/lib 目录下的核心类库(如 rt.jar),以及被 -Xbootclasspath 指定路径中的类。它是所有加载器的顶层,没有父加载器(getParent() 返回 null)。
  2. 扩展类加载器(Extension ClassLoader):由 Java 实现(sun.misc.Launcher$ExtClassLoader),负责加载 <JAVA_HOME>/lib/ext 目录中的类库,或被 java.ext.dirs 系统变量指定路径中的类。
  3. 应用程序类加载器(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 阶段就被拒绝,见下方【自测】的实测报错;双亲委派是运行期的第二道防线,而不是唯一防线。)

【自测】以下代码能否运行成功?若能,输出什么?若不能,说明原因。

java
public class java.lang.String {
    public static void main(String[] args) {
        System.out.println("Custom String");
    }
}

答:连编译阶段都过不去,谈不上运行期。JDK 17 实测 javac MyString.java 直接报错、不生成任何 class 文件:

text
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 规则:

  1. 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作 happens-before 书写在后面的操作。注意:这里指的是单线程内的语义顺序,编译器和处理器可以进行不改变单线程执行结果的重排序。

  2. 管程锁定规则(Monitor Lock Rule):一个 unlock 操作 happens-before 后面对同一个锁的 lock 操作。这里“后面”指的是时间上的先后顺序。这是 synchronized 关键字和 ReentrantLock 保证可见性的基础。

  3. volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作 happens-before 后面对该变量的读操作。这是 volatile 保证可见性的核心机制(通过内存屏障实现)。

  4. 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法 happens-before 此线程的每一个动作。即主线程调用 start() 之前的所有操作对新启动的线程可见。

  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都 happens-before 对此线程的终止检测。即线程执行的所有操作对调用 Thread.join() 成功返回、或 Thread.isAlive() 返回 false 的其他线程可见。

  6. 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生(通过 Thread.interrupted() 或 Thread.isInterrupted())。

  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)happens-before 它的 finalize() 方法的开始。这保证了 finalize() 方法能看到对象构造完成后的状态。

  8. 传递性(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→CA → C

换问法:若题目问“volatile 如何保证可见性?”,答案应说明:volatile 写操作会插入 StoreStore + StoreLoad 内存屏障,volatile 读操作会插入 LoadLoad + LoadStore 内存屏障,确保 volatile 写 happens-before 后续 volatile 读,从而使写操作的结果对读线程可见。

【自测】分析以下代码中变量 x 和 y 的最终可能结果,并说明哪些 happens-before 规则在起作用:

java
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。
  • 工程注意点:共享可变状态最少化;优先不可变;用并发工具的文档语义。

持续学习,持续积累。