假设有一座高度发达的“程序城市”,每个 Java 程序都活在这座城市里。城市有海关,负责给外地来的“类文件”办签证;有档案馆,存放全国通用的规章制度;有堆货场,保存所有运行中的对象;每位打工线程手里还有一本便签本,记录当前执行到哪一步。这座城市,就是 JVM。 我们常听到“Java 跨平台”,是因为城市下发的是统一图纸——字节码,而每座城市(不同操作系统)都能按图纸施工。有人担心对象太多没地方放,环卫队(垃圾回收器)会定时清扫没人引用的垃圾对象;有人嫌按图纸施工慢,老师傅(JIT 编译器)会把重复动作变成快捷模具。整座城市环环相扣:类加载受理行李,内存区域安排住宿,执行引擎负责干活,垃圾回收负责保洁。 这篇文章不追求背八股,而是带你从“城市规划”视角看懂 JVM 的内存模型和执行引擎:类从哪里来、住到哪里、如何被执行、又如何被清理。

岗位关联

学会 JVM 内存与执行引擎,你能准确指出线上 OOM、CPU 飙升、频繁 Full GC 的根源,这是后端与 AI 平台工程师排查性能问题的核心能力。

目录
  1. 可视化演示
  2. 类加载机制
  3. 双亲委派
  4. 运行时数据区
  5. 堆与垃圾回收
  6. 虚拟机栈详解
  7. 执行引擎与JIT
  8. 平台无关字节码
  9. 内存模型与线程
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示JVM运行时内存区域划分(堆/栈/方法区)以及对象在Eden、Survivor、Old Gen之间的分代垃圾回收流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

JVM内存模型 堆内存:Eden区分配新对象 Survivor区:Minor GC后存活对象 Old Gen:长期存活对象晋升 Heap = Eden + S0 + S1 + Old JVM分代内存管理与垃圾回收机制

类加载机制

想象一位持有签证的人来到 JVM 城市。海关不会因为他带着证件就立刻放行,而是要经过登记、验明身份、发放入境凭证等一系列流程。Java 里的 class 文件就像这位外来者,类加载机制就是海关那套完整的“入城手续”。

Java 程序不会在启动时把所有类都塞进内存。只有代码真的用到某个类,比如执行 new 操作、访问静态字段或调用静态方法时,JVM 才临时安排加载。这样按下不表、用到才办的策略,能大幅缩短启动时间。整个“入城”流程可以拆成三关:加载、链接、初始化。加载阶段寻找并读取 .class 文件的二进制流,将其转换成方法区里的运行时数据结构,同时生成一个 Class 对象作为这个类的“身份证”;链接阶段再细分为验证、准备、解析,检查字节码是否合法,为静态变量分配空间,把符号引用替换成真正的内存地址;初始化阶段才真正执行静态代码块和静态变量的赋值。

一个类不是天生就是市民,它先要验明正身、办手续,最后才领到身份证,拥有在城市里被使用的资格。理解这一点,你才会明白为什么线上程序首次访问某个类时会短暂变慢。

字节流→加载Class 对象→链接可执行类→初始化活跃类\displaystyle \text{字节流} \xrightarrow{\text{加载}} \text{Class 对象} \xrightarrow{\text{链接}} \text{可执行类} \xrightarrow{\text{初始化}} \text{活跃类}
可以理解为:先把货物入关,再检查贴标,最后通电启动。
  1. 加载从 class 文件中读取二进制数据,并转换成 Class 对象,相当于海关登记个人信息。
    Class 对象←二进制字节流\text{Class 对象} \leftarrow \text{二进制字节流}
  2. 链接验证字节码合法性,准备静态变量内存,解析符号引用,相当于核对资料并安排住处。
    字节流→验证/准备/解析可执行类\text{字节流} \xrightarrow{\text{验证/准备/解析}} \text{可执行类}
  3. 初始化执行静态变量赋值和 static 代码块,相当于通电后正式上岗。
    可执行类→clinit初始化完成\text{可执行类} \xrightarrow{clinit} \text{初始化完成}

双亲委派

JVM 城市的海关不是一个人说了算,而是分成总部、大区分部和基层窗口。基层收到一份“类入城申请”时,不会自作主张直接办理,而是先逐级上报,请上级先看看自己能不能处理。只有总部和分部都表示“这个类不归我管”,基层才会动手。这个“先向上请求,失败后再自己办理”的规则,就是双亲委派模型。

JVM 中最常见的三个类加载器是:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用类加载器(Application ClassLoader)。它们像三级海关:启动类加载器负责加载最核心的 Java API,扩展类加载器负责加载扩展库,应用类加载器负责加载我们自己写的 classpath 代码。当应用类加载器收到一个类加载请求时,它会先问父加载器“你来不来”,父加载器再问上一级;一级一级向上推,直到最顶层都无法处理,请求才原路返回,由最开始发起请求的加载器尝试加载。

双亲委派最大的好处是安全:就算你自己写了一个 java.lang.String,也别想混进核心库,因为最顶层的启动类加载器早就把正牌 String 加载了。这也解释了为什么有些场景需要打破双亲委派,比如 Tomcat 容器希望每个 Web 应用拥有独立类加载器,避免不同应用之间互相污染。

loadClass(C)=findLoaded(C)  ∨  parent.loadClass(C)  ∨  findClass(C)\displaystyle \text{loadClass}(C) = \text{findLoaded}(C) \;\vee\; \text{parent.loadClass}(C) \;\vee\; \text{findClass}(C)
这句话的意思是:加载一个类,先查它是否已经加载过,没有再交给父加载器,父加载器也不行时,自己才动手。
  1. 查缓存先看这个类是否已经被加载过,避免同一个类被加载两次。
    findLoaded(C)\text{findLoaded}(C)
  2. 委托父级缓存没有命中,就调用父加载器的 loadClass,让上级先尝试。
    parent.loadClass(C)\text{parent.loadClass}(C)
  3. 自己加载整条父链都失败,最后才调用 findClass 读取字节流。
    findClass(C)\text{findClass}(C)

运行时数据区

JVM 城市内部不是一个大平层,而是分成功能明确的区域。有的区域是施工人员私有的小办公室,有的区域则是大家共享的公共仓库。运行时数据区就是这份“城市功能分区图”。

按照 JVM 规范,运行时数据区主要分为五块:程序计数器、虚拟机栈、本地方法栈、堆和方法区。程序计数器是当前线程正在执行的字节码行号指示器,像一个书签,线程切换后能立刻恢复位置;虚拟机栈保存当前线程的局部变量和中间结果,每个方法调用都会压入一个栈帧;本地方法栈服务于 native 方法;堆是几乎所有 Java 对象的家;方法区保存类型信息、常量、静态变量和 JIT 编译后的代码缓存,在 Java 8 之后通常由元空间 Metaspace 实现。

这样分工之后,每个线程的便签不互相干扰,公共货场则统一管理对象。线上排查内存问题时,首先要分清“是堆上对象太多,还是方法区元数据膨胀”,这就是运行时数据区知识给我们的定位能力。

RJVM=PC∪VMStack∪NativeStack⏟线程私有∪Heap∪MethodArea⏟线程共享\displaystyle \begin{aligned} R_{JVM} &= \underbrace{PC \cup VMStack \cup NativeStack}_{\text{线程私有}} \\ &\cup \underbrace{Heap \cup MethodArea}_{\text{线程共享}} \end{aligned}
线程私有区域各用各的互不打扰,线程共享区域需要处理并发访问。
  1. 程序计数器记录当前执行到哪一条字节码指令,是线程私有的“书签”。
    PC=lineNumber(当前指令)\text{PC} = \text{lineNumber}(\text{当前指令})
  2. 虚拟机栈与本地方法栈Java 方法和 native 方法各自需要的临时空间。
    VMStack∪NativeStack\text{VMStack} \cup \text{NativeStack}
  3. 堆与方法区所有线程共享,对象实例和类型元数据在这里存放。
    Heap∪MethodArea\text{Heap} \cup \text{MethodArea}

堆与垃圾回收

堆是 JVM 城市的公共货场,既放刚进货的新商品,也放需要长期保存的老物件。商品每天进进出出,总得有人定期收拾没人要的废品,否则仓库很快爆满。垃圾回收器就是这支环卫队。

为了提升清扫效率,JVM 通常把堆分成新生代和老年代。新生代再分为 Eden 区和两块 Survivor 区,绝大多数对象先在 Eden 区出生;经历一次 Minor GC 仍然存活的对象会进入 Survivor 区,每熬过一次 GC 年龄加一,达到阈值后被送入老年代。GC 判断对象是否该回收,靠的不是数引用次数,而是“可达性分析”:从一组 GC Roots 出发,沿着引用关系一直走,凡是走不到的对象,就是可以被回收的垃圾。标记-清除算法负责标记存活对象并清除垃圾,但会产生内存碎片;复制算法则将存活对象整块复制到另一块空区,适合年轻代这种“大部分都是垃圾”的场景。

理解堆与 GC,你才能看懂 Full GC 为什么会“暂停所有线程”。环卫队清理超大仓库时,必须让所有人停下手中的活,否则对象一边被搬一边被使用,极易出错。这也解释了为什么堆内存不是越大越好:仓库越大,清扫一次的时间可能越长。

回收对象={o∣o∉Reachable(GC Roots)}\displaystyle \text{回收对象} = \{ o \mid o \notin \text{Reachable}(\text{GC Roots}) \}
简单说:从城市交通枢纽出发,所有能通过道路到达的地方都是活区,到不了的地方就是废品区。
  1. GC Roots 集合栈上的局部变量、静态变量等作为起点,相当于城市几个主要出城口。
    Roots={stackRefs∪staticRefs∪JNI refs}\text{Roots} = \{ \text{stackRefs} \cup \text{staticRefs} \cup \text{JNI refs} \}
  2. 可达性扩散从 Roots 出发沿引用遍历,标记所有能到达的对象。
    Reachable(R)=⋃r∈Rdescendants(r)\text{Reachable}(R) = \bigcup_{r \in R} \text{descendants}(r)
  3. 清除不可达对象不在集合中的对象统一回收。
    garbage=Heap−Reachable(R)\text{garbage} = \text{Heap} - \text{Reachable}(R)

虚拟机栈详解

每个线程手里都有一本便签本,这就是虚拟机栈。线程每调用一个方法,就在便签本上垫一页新纸;方法执行完,这页纸就被撕掉。这一页一页的纸,术语叫栈帧。

一个栈帧内部有四个主要部分:局部变量表、操作数栈、动态链接、方法返回地址。局部变量表存放方法参数和方法内部定义的局部变量,有点像纸上事先写好的输入清单;操作数栈用来保存计算过程中的中间值,比如计算 a + b * c 时,中间结果会暂时堆在这里;动态链接指向运行时常量池中该方法的引用,让方法能正确找到自己的类信息;方法返回地址则记录“这段代码执行完该回到哪里”,像便签底部写好的回家路线。如果方法递归太深,便签本被垫得太厚,就会触发 StackOverflowError。

栈是程序员最容易感知的内存区域之一:递归深度过大、方法调用链过长,都会让便签本“爆页”。同时,一个线程的栈大小也直接影响可创建的线程数量,线上调优时往往需要在 -Xss 与线程数之间做平衡。

StackFrame=局部变量表⊕操作数栈⊕动态链接⊕方法返回地址\displaystyle \text{StackFrame} = \text{局部变量表} \oplus \text{操作数栈} \oplus \text{动态链接} \oplus \text{方法返回地址}
一个栈帧就是一次方法调用的一张便签,写清输入、草稿和归路。
  1. 局部变量表保存参数和局部变量,以槽位 Slot 为单位分配空间。
    local(i)=第 i 个槽位的变量\text{local}(i) = \text{第 } i \text{ 个槽位的变量}
  2. 操作数栈执行字节码时保存中间计算结果,栈深度由编译器确定。
    operandStack=push/pop(中间值)\text{operandStack} = \text{push/pop}(\text{中间值})
  3. 动态链接与返回地址将符号引用转为实际调用地址,并记录方法出口。
    returnAddress=caller instruction+1\text{returnAddress} = \text{caller instruction} + 1

执行引擎与JIT

城市施工队里有两种工人:一种拿着图纸逐步施工,虽然每一步都标准,但速度一般;另一种是经验丰富的老师傅,干同一件事干了几百次后,直接把流程压缩成一套模具,闭着眼睛也能快速完成。前者是解释器,后者是 JIT 编译器。

执行引擎是一个“翻译并执行”的引擎。它读取字节码指令,解释一条执行一条,这就是解释执行。解释执行的优点是启动快、不需要额外编译时间,但反复执行同一段代码时效率偏低。JIT 编译器的思路是:统计每个方法的调用次数,当调用次数达到某个编译阈值时,认为它是“热点代码”,于是把这一段字节码直接编译成当前操作系统能认识的本地机器码。编译后的机器码被缓存下来,之后再遇到同一方法,就无需再解释,直接运行机器码即可。

JIT 还会做一系列优化,比如逃逸分析:如果一个对象只在一个线程内部使用、不会被外部引用,就没必要非要放到公共堆上,可以尝试在栈上分配,减少 GC 压力。这也解释了为什么“解释执行一定慢”“JIT 编译一定好”都是片面的,JVM 是聪明的裁判,它只在真正值得提速的地方请老师傅出手。

callCount(m)≥Tthreshold  ⟹  JITCompile(m)\displaystyle \text{callCount}(m) \ge T_{threshold} \;\Longrightarrow\; \text{JITCompile}(m)
意思是:某方法被反复调用到一定次数后,老师傅才肯为它打造专属模具。
  1. 方法调用计数JVM 为每个方法维护一个计数器,每次调用就加一。
    callCount(m):=callCount(m)+1\text{callCount}(m) := \text{callCount}(m) + 1
  2. 阈值判断计数达到阈值时触发编译,HotSpot 常把阈值简写成 CompileThreshold。
    callCount(m)≥Tthreshold\text{callCount}(m) \ge T_{threshold}
  3. JIT 编译把热点字节码转换成 CPU 能直接执行的本地指令。
    bytecode→JITnative code\text{bytecode} \xrightarrow{\text{JIT}} \text{native code}

平台无关字节码

为什么 Java 能“一次编写,到处运行”?因为 JVM 城市下发的是统一图纸,但每个城市的施工队都可以按自己的材料和标准干活。代码编译后不直接变成某台机器的指令,而是变成一份中立图纸——字节码。

javac 把 .java 源码编译成 .class 文件,里面的字节码是一套由 JVM 定义的指令集。这套指令不依赖具体操作系统,Linux、Windows、macOS 上的 JVM 都读得懂。真正把字节码翻译成机器码的,是每一台设备上的 JVM 实现。所以跨平台的关键不是 Java 源码,而是字节码加 JVM 适配层。操作系统差异、文件系统差异、底层 I/O 调用差异,都被 JVM 这层“施工队”消化掉了。

理解了这张图,你就明白为什么部署 Java 服务时往往“把 jar 包扔上去就能跑”,也明白为什么如果在代码里直接依赖 Windows 路径、Linux shell 命令,跨平台就会出问题。图纸是统一的,但工地上不能有太多“本地土规矩”。

Java 源码→javac字节码→JVM 适配机器指令\displaystyle \text{Java 源码} \xrightarrow{\text{javac}} \text{字节码} \xrightarrow{\text{JVM 适配}} \text{机器指令}
源码是设计图,字节码是统一图纸,JVM 是本地施工队。
  1. 源码编译javac 将 .java 编译成 .class 文件。
    Java Source→javacClass File\text{Java Source} \xrightarrow{javac} \text{Class File}
  2. 字节码指令Class 文件里存放 JVM 指令,如 iload、invokevirtual 等。
    instruction set⊂Class File\text{instruction set} \subset \text{Class File}
  3. 本地适配不同平台的 JVM 把同一条字节码解释成自己平台的机器码。
    bytecode→JVMOS-native instruction\text{bytecode} \xrightarrow{\text{JVM}} \text{OS-native instruction}

内存模型与线程

JVM 城市里有很多线程同时干活,但大家不是随时都直接读写同一块公告栏。为了提高效率,每个线程都有自己的“工作内存”,相当于个人草稿纸。线程先把变量从主内存读到草稿纸上,改完再写回公告栏。如果一个人改了草稿纸却忘了贴回公告栏,其他人看到的仍是旧数字。

Java 内存模型(JMM)定义的正是这种“主内存-工作内存”之间的交互规则。volatile 是其中最常用的关键字:被 volatile 修饰的变量,线程读写时都直接对准主内存,并且禁止编译器对相关指令进行重排序。因此 volatile 能保证可见性,但不能保证 count++ 这种复合操作的原子性。另一个更底层的概念是 happens-before(先行发生原则):如果操作 A happens-before 操作 B,那么 A 对内存的写入结果对 B 可见。比如解锁操作 happens-before 后续对同一把锁的加锁操作;volatile 写 happens-before 后续对同一变量的 volatile 读。

多个线程共享数据时,不能靠“感觉”判断先后,必须依赖 JMM 提供的规则。加锁、volatile、并发容器,本质上都是在“公告栏”和“草稿纸”之间建立可靠的信息传递,而不是玄学般地碰运气。

write(x,v)  hb  read(x)  ⟹  value(read(x))=v\displaystyle \text{write}(x, v) \;\text{hb}\; \text{read}(x) \;\Longrightarrow\; \text{value}(\text{read}(x)) = v
先行发生原则就像邮局规则:只要快递单上写明“先寄出后签收”,收件人拿到的一定是寄出的那个包裹。
  1. 主内存与工作内存共享变量存放在主内存,线程私有副本在工作内存。
    MainMemory→WorkMemory→useThread\text{MainMemory} \to \text{WorkMemory} \xrightarrow{\text{use}} \text{Thread}
  2. volatile 语义写 volatile 立即刷新主内存,读 volatile 直接从主内存取。
    volatile write→Main Memory→volatile read\text{volatile write} \to \text{Main Memory} \to \text{volatile read}
  3. happens-before 推导若 A 先行发生于 B,且同一变量,则 B 可见 A 的写入。
    A  hb  B⇒Visible(A,B)A \;\text{hb}\; B \Rightarrow \text{Visible}(A, B)

AI 应用场景

模型服务 OOM 排查

阶段:模型服务部署与监控

解决的问题:传统方式是等进程崩溃后盲目加内存,但加了还是 OOM;掌握 JVM 堆内存、GC Roots 和可达性分析后,可以直接从堆转储文件里找到谁“攥着”对象不放。就像环卫队扫不干净,不是因为扫得不勤,而是有一个仓库管理员一直把所有箱子都标记为“保留”。

具体案例:某 AI 推理服务运行 3 天后频繁 OOM,表现形式是堆内存缓慢上涨直到溢出。开发人员用 jmap 导出堆转储,发现所有历史推理请求的结果对象都被一个静态缓存 List 强引用,导致 GC 永远无法回收。定位到堆上这条“根引用链”后,改成弱引用并限制缓存大小,服务稳定运行。

算法平台插件热更新

阶段:算法引擎版本管理与多租户隔离

解决的问题:多个算法模型 jar 包可能依赖同一个类但版本不同,如果全部交给同一个应用类加载器,会出现类冲突。打破双亲委派,让每个算法包拥有独立类加载器,才能做到像一个个独立“工作室”互不干扰。

具体案例:机器学习平台需要同时运行用户上传的自定义预处理代码。平台为每个任务创建独立类加载器,任务结束就卸载加载器并回收相关类。这样不同用户即使上传同名类,也不会互相覆盖,真正实现隔离式热更新。

高并发服务 GC 调优

阶段:在线推理与高吞吐优化

解决的问题:面对高并发请求,Minor GC 过频、Full GC 过长都直接影响响应时间。掌握分代堆布局和复制算法后,可以根据对象存活率调整 Eden 与 Survivor 比例,让 GC 像错峰清扫一样减少对业务的影响。

具体案例:某在线推荐系统接口 P99 延迟经常突然飙高,查看 GC 日志后发现老年代频繁 Full GC。通过调整新生代大小、增加 Survivor 区空间,并让短期对象尽可能在年轻代被回收,Full GC 频率大幅下降,接口延迟恢复平稳。

Python 演示

python51 行
# -*- coding: utf-8 -*-# 模拟 JVM 堆上的可达性分析和标记-清理回收 class MiniJVM:    def __init__(self):        self.heap = {}        self.next_id = 1        self.gc_root = None        self.gc_count = 0     def alloc(self, refs):        oid = "obj%d" % self.next_id        self.next_id += 1        self.heap[oid] = list(refs)        return oid     def mark_sweep(self):        self.gc_count += 1        alive = set()        if self.gc_root is not None:            alive.add(self.gc_root)        stack = [self.gc_root]        while stack:            oid = stack.pop()            if oid is None or oid not in self.heap:                continue            for ref in self.heap[oid]:                if ref not in alive:                    alive.add(ref)                    stack.append(ref)        before = len(self.heap)        self.heap = {oid: refs for oid, refs in self.heap.items() if oid in alive}        print("GC第%d次: 清理前%d个对象, 清理后%d个对象" % (self.gc_count, before, len(self.heap))) def simulate():    jvm = MiniJVM()    a = jvm.alloc([])    b = jvm.alloc([])    c = jvm.alloc([])    d = jvm.alloc([])    # a -> b -> c 是可达链; d 没有被任何根引用    jvm.heap[a].append(b)    jvm.heap[b].append(c)    jvm.gc_root = a    jvm.heap[d] = [a]  # 即使 d 引用 a, d 自身不可达    print("初始堆对象:", sorted(jvm.heap.keys()))    jvm.mark_sweep()    print("回收后堆对象:", sorted(jvm.heap.keys())) if __name__ == "__main__":    simulate()
目的:用 Python 实现 JVM 垃圾回收中最核心的可达性分析与标记-清理,演示 GC Roots 如何决定对象存活。
  1. jvm.heap[a].append(b)建立对象引用关系:a 引用了 b。
  2. jvm.gc_root = a把 a 作为 GC Roots,模拟线程栈上的局部变量。
  3. stack = [self.gc_root]从根出发,用栈做深度优先遍历。
  4. alive.add(ref)每发现一个可达对象就标记一次。
  5. self.heap = {oid: refs for oid, refs in self.heap.items() if oid in alive}只保留存活对象,完成清除。

常见误区

误区我一直以为子加载器会先自己尝试加载类,如果找不到,再委托给父加载器去处理。

正解正好相反。双亲委派模型是“先父后子”:子加载器收到请求后会先问父加载器,父加载器再往上问,只有整条父链都失败,才轮到自己加载。这就像基层海关收到申请先逐级上报,而不是直接擅自盖章。它的核心目的是避免核心类被重复加载甚至被伪造。

为何常见:我们日常生活中办事往往“先自己解决,不行再找上级”,所以很容易把这种直觉套到类加载器上。

误区线上服务 OOM,第一反应是把堆内存最大上限调大,觉得堆越大越不容易出问题。

正解堆过大可能导致单次 Full GC 耗时更久,因为 GC 需要从 GC Roots 遍历整个更大规模的存活对象图,STW 时间也会上升。正确做法是先分析存活对象大小、对象分配速率和 GC 日志,再确定合理堆大小,而不是盲目调大。

为何常见:人们常把“空间不够”理解为唯一瓶颈,忽略了 GC 暂停时间与堆规模的关系。

误区听说 Java 8 用元空间替换了永久代,那方法区是不是就不存在了?

正解方法区是 JVM 规范定义的一个逻辑区域,用于存放类元数据、常量、静态变量等信息。Java 8 之前,HotSpot 用永久代实现它;Java 8 之后,用元空间实现,字符串常量池也移到了堆中。方法区这个逻辑概念仍然存在,只是底层实现换了块地。

为何常见:大家把“永久代被移除”误理解为“方法区被移除”,其实是实现方式变了,规范仍然保留。

误区既然 JIT 能把字节码编译成机器码,那应该所有代码都 JIT 编译,干脆关闭解释器。

正解JIT 编译需要额外的 CPU 时间和内存缓存。对于只执行一次或几次的冷代码,解释执行反而更快;只有热点代码才值得频繁采样、编译、优化。HotSpot 采用混合模式,解释器负责快速启动,JIT 负责在热点处加速。

为何常见:我们容易对比“运行一次解释 vs 运行一次编译后代码”的耗时,却忽略编译本身也需要时间。

误区只要程序里不再引用某个对象,GC 马上会把它清掉,所以不必担心内存泄漏。

正解GC 判断的依据是可达性,不是“是否还在使用”。如果一个对象仍然被某个静态集合强引用,即使业务上已经不用它了,GC 依然认为它活着。这种无意识持有引用就是最常见的内存泄漏来源。

为何常见:人们把“不再使用”和“不可达”混为一谈,忽略了静态容器和缓存等隐性强引用。

面试题

Q1. 在 JVM 中,负责把 class 文件读入并生成 Class 对象的核心组件是哪个?

单选题 · easy

  • A.字节码解释器
  • B.类加载子系统← 正确答案
  • C.垃圾回收子系统
  • D.JIT 编译子系统

答案:B。类加载子系统完成加载、链接和初始化,其中加载阶段会生成 Class 对象。解释器和 JIT 都是执行引擎的一部分,而不是负责加载类的组件。

Q2. 在双亲委派模型中,子加载器收到类加载请求后首先应该做什么?

单选题 · easy

  • A.由自己直接加载
  • B.询问父类加载器← 正确答案
  • C.立刻抛出异常
  • D.扫描本地磁盘

答案:B。双亲委派的顺序是“先向上,后向下”。子加载器先调用父加载器,父加载器再继续向上,只有整条父链都失败,子加载器才会自己尝试,所以不是“自己先上”。

Q3. 以下哪个区域属于线程私有?

单选题 · easy

  • A.堆数据区
  • B.方法区
  • C.虚拟机栈← 正确答案
  • D.元数据区

答案:C。虚拟机栈、程序计数器和本地方法栈都是线程私有区域;堆、方法区是线程共享区域。元数据区属于方法区实现,也与线程共享。

Q4. 对象进入老年代的直接原因通常是?

单选题 · mid

  • A.被线程栈直接引用
  • B.在新生代熬过多次GC← 正确答案
  • C.对象正好分配在方法区
  • D.类加载器版本不匹配

答案:B。对象在新生代每经历一次 Minor GC 仍存活,年龄就会增加;达到阈值后晋升到老年代。大对象也可能直接进入老年代,但本题四个选项里最常规的晋升原因是“多次存活于 Minor GC”。

Q5. 以下哪些对象可以作为 GC Roots 的起点?

多选题 · mid

  • A.虚拟机栈中的引用← 正确答案
  • B.静态属性指向对象← 正确答案
  • C.方法区常量持有对象← 正确答案
  • D.JNI 全局引用对象← 正确答案

答案:ABCD。GC Roots 常见包括虚拟机栈中的局部变量引用、静态变量引用、方法区常量引用、JNI 引用等。从这些根出发做可达性分析,根能到达的对象存活,到达不了的对象回收。

Q6. 以下哪些属于 JVM 规范中的运行时数据区?

多选题 · mid

  • A.程序计数器← 正确答案
  • B.虚拟机栈← 正确答案
  • C.方法区← 正确答案
  • D.操作数栈

答案:ABC。JVM 规范中的运行时数据区包含程序计数器、虚拟机栈、本地方法栈、堆、方法区。操作数栈是虚拟机栈内某个栈帧的组成部分,不是独立的一块运行时数据区。

Q7. JIT 编译器的主要作用是什么?

单选题 · mid

  • A.把 Java 源码编译成 class 文件
  • B.把热点字节码编译为本地机器码← 正确答案
  • C.把本地方法栈映射到内核
  • D.把堆对象直接复制到老年代

答案:B。javac 负责把 Java 源码编译成 class 文件;JIT 是在运行期把“热点字节码”编译成当前平台机器码,从而加速后续执行。对象晋升是 GC 的事,与 JIT 无关。

Q8. 关于 volatile 的语义,以下哪个说法最准确?

单选题 · hard

  • A.能保证复合操作的原子性
  • B.禁止指令重排并保证可见性← 正确答案
  • C.会强制把对象移入老年代
  • D.可以替代锁的所有功能

答案:B。volatile 保证变量在多个线程之间的可见性,并禁止编译器、CPU 对相关指令重排序。它不保证 i++ 这类复合操作原子性,也不能完全替代 synchronized。

Q9. 以下哪些机制可能优化 JVM 性能?

多选题 · hard

  • A.JIT 热点编译← 正确答案
  • B.逃逸分析优化← 正确答案
  • C.分代垃圾回收← 正确答案
  • D.双亲委派模型

答案:ABC。JIT 把热点代码编译成机器码,逃逸分析减少堆分配,分代 GC 提高回收效率;双亲委派主要保证类加载安全和唯一性,不是性能优化手段。

Q10. 容器为了隔离应用而主动打破双亲委派的典型例子是?

单选题 · extreme

  • A.JDBC 驱动加载
  • B.Tomcat 独立类加载器← 正确答案
  • C.启动类加载器加载 rt.jar
  • D.应用类加载器加载 classpath

答案:B。Tomcat 为每个 Web 应用创建独立 WebAppClassLoader,先由自己加载应用类,再委托父加载器,这正是打破双亲委派以隔离应用的例子。JDBC SPI 使用的线程上下文类加载器也是打破案例,但本题最典型的是 Tomcat 独立类加载器。