JVM 面试回答
框架:结论先行 → 核心原理 → 底层深入(30s / 2min / 5-10min)。每句结论都带「因为」,细节等追问再展开。
① JVM 内存结构
一句话结论(30s)
JVM 运行时数据区分成线程共享和线程私有两块:共享的是堆(存对象)和元空间(存类元数据),私有的是虚拟机栈、本地方法栈、程序计数器(每个线程一份)——核心设计是「共享区管对象,私有区管执行上下文」,因为这样线程切换只需换自己的栈和 PC,互不干扰。
核心原理(2min)
先想:JVM 把内存分成「共享」和「私有」两类,划分标准是什么?——标准是「这份数据该不该跟着线程走」:对象要全局可达就放共享区,执行上下文每个线程各一份就放私有区,这样线程切换只需换自己的栈和 PC,互不干扰。
线程共享区:
堆 Heap —— 所有对象、数组。又分新生代(Eden 8 + Survivor0 1 + Survivor1 1)和老年代
元空间 Metaspace —— 类元数据、方法信息、运行时常量池(用本地内存,不在堆里)
线程私有区(每线程一份):
虚拟机栈 —— 方法调用压栈帧(局部变量表 + 操作数栈 + 动态链接 + 返回地址)
本地方法栈 —— native 方法
程序计数器 PC —— 指向当前正在执行的字节码指令地址(唯一不会 OOM 的区域)
- 虚拟机栈:每个方法调用创建一个栈帧。局部变量表是槽位数组,实例方法第 0 号槽固定存
this;操作数栈是字节码的「寄存器」,iload_1取变量压栈、iadd弹出两个相加再压回——字节码不直接操作局部变量,都经操作数栈中转。 - 堆分代:新对象进 Eden,Minor GC 存活后进 Survivor,S0/S1 间反复复制,年龄到阈值(默认 15)晋升老年代。
- 程序计数器:线程切换时保存执行位置,切回来接着跑;native 方法时 PC 值为 undefined。
底层深入(5-10min)
元空间取代永久代的本质:先想:把类元数据放进堆里的永久代,有什么坏处?——它会和对象抢堆空间,而且容量被 -XX:MaxPermSize 配死。Java 8 之前类元数据放在堆里的永久代(PermGen),容量要 -XX:MaxPermSize 手动配死。一旦动态生成大量类——CGLIB 代理、JSP 编译、Lambda 表达式——永久代就 PermGen OOM。Metaspace 改用本地内存,默认只受物理内存限制,可用 -XX:MaxMetaspaceSize 设上限,从此没有「固定上限焦虑」。这是「数据放哪」的关键取舍:堆里 OOM 能靠 GC 救,元数据不该跟对象抢堆空间。
各区域 OOM 精确对应(面试高频,直接背这张表):
| 错误信息 | 对应区域 | 根因 |
|---|---|---|
OutOfMemoryError: Java heap space | 堆 | 对象太多 / 内存泄漏 |
OutOfMemoryError: Metaspace | 元空间 | 动态加载了太多类 |
StackOverflowError | 虚拟机栈 | 递归过深 |
OutOfMemoryError: Direct buffer memory | 直接内存 | NIO 堆外内存未释放 |
对象头也属于内存结构的关键细节:先想:一个连字段都没有的空对象,为什么也占内存?——因为还有对象头:每个对象有 Mark Word(存 hashCode、GC 分代年龄——4 位所以最多 15、锁状态)+ Klass Pointer(指向方法区类元数据)。Klass Pointer 在 64 位 JVM 开启指针压缩(堆 < 32GB 默认开启)时只占 32 位,否则 64 位——这是 JVM 指针压缩的落点。
对象头锁位的真实布局(oops/markWord.hpp,逐字):64 位 Mark Word 里锁状态只占最低 2 位、分代年龄占 4 位(所以最大 15)、其余给 identity hashCode:
// Bit-format of an object header (most significant first, big endian layout below):
//
// 32 bits:
// --------
// hash:25 age:4 self-fwd:1 lock:2
//
// 64 bits (without compact headers):
// ----------------------------------
// unused:22 hash:31 valhalla:4 age:4 self-fwd:1 lock:2
//
// [header | 00] locked locked regular object header (fast-locking in use)
// [header | 01] unlocked regular object header
// [header | 10] monitor inflated lock
// [ptr | 11] marked used to mark an object (header is swapped out)
// Number of bits
static const int lock_bits = 2;
static const int self_fwd_bits = 1;
static const int age_bits = 4;
// ...
static const uintptr_t locked_value = 0;
static const uintptr_t unlocked_value = 1;
static const uintptr_t monitor_value = 2;
static const uintptr_t marked_value = 3;
// lock accessors (note that these assume lock_shift == 0)
bool is_locked() const {
return (mask_bits(value(), lock_mask_in_place) != unlocked_value);
}
bool is_unlocked() const {
return (mask_bits(value(), lock_mask_in_place) == unlocked_value);
}
分析:lock:2 两个位编码四种状态——00 locked(fast-lock 轻量锁)、01 unlocked(无锁)、10 monitor(重量级锁)、11 marked(GC 转发标记)。这正好对上面试常考的「锁状态存对象头」,并自然引出下一段:这 2 位在锁升级时到底怎么被翻转。
锁升级的现代实现(runtime/synchronizer.cpp,逐字):JDK 15 起偏向锁已被移除(JEP 374),现在的升级路径是「栈上 fast-lock → 自旋 → inflate 成重量级 ObjectMonitor」,入口 ObjectSynchronizer::enter 的骨架:
LockStack& lock_stack = current->lock_stack();
if (!lock_stack.is_full() && lock_stack.try_recursive_enter(obj())) {
// Recursively fast locked
return;
}
if (lock_stack.contains(obj())) {
ObjectMonitor* monitor = inflate_fast_locked_object(obj(), ObjectSynchronizer::inflate_cause_monitor_enter, current, current);
bool entered = monitor->enter(current);
assert(entered, "recursive ObjectMonitor::enter must succeed");
cache_setter.set_monitor(monitor);
return;
}
while (true) {
// Fast-locking does not use the 'lock' argument.
// Fast-lock spinning to avoid inflating for short critical sections.
// The goal is to only inflate when the extra cost of using ObjectMonitors
// is worth it.
if (fast_lock_try_enter(obj(), lock_stack, current)) {
return;
} else if (fast_lock_spin_enter(obj(), lock_stack, current, observed_deflation)) {
return;
}
// ...
ObjectMonitor* monitor = inflate_and_enter(obj(), lock, ObjectSynchronizer::inflate_cause_monitor_enter, current, current);
// ...
}
分析:无竞争时 fast_lock_try_enter 直接把 Mark Word CAS 成 00(locked),锁信息记在线程自己的 LockStack 上而不进对象头,所以极轻量;短暂竞争时 fast_lock_spin_enter 先自旋;还拿不到才 inflate_and_enter 把 Mark Word 置为 10(monitor) 挂到重量级 ObjectMonitor 上。这段代码就是「锁状态 2 位」被真实使用的落点。
② GC 算法(标记-复制 / 标记-清除 / 标记-整理)
一句话结论(30s)
三种算法是在「碎片」和「空间/时间」之间的三种取舍:标记-复制无碎片但浪费空间(适合新生代),标记-清除不浪费空间但产生碎片(CMS 老年代),标记-整理无碎片也不浪费但移动对象代价大(需要 STW)——因为对象存活率决定选哪个,新生代对象大多朝生夕死用复制,老年代对象大多长寿用整理。
核心原理(2min)
先想:三种算法,为什么有的有碎片、有的浪费空间、有的要暂停?——本质都是在「碎片 / 空间 / 时间」三者间取舍,理解了这个就没必要死记。
| 算法 | 做法 | 优点 | 缺点 | 用在哪 |
|---|---|---|---|---|
| 标记-复制 | 标记存活,把存活对象复制到另一块,整块清空原区域 | 无碎片、分配用指针碰撞、简单高效 | 可用内存减半(实际浪费 10% 的 Survivor) | 新生代(Serial/ParNew/Parallel Scavenge) |
| 标记-清除 | 标记存活,直接清除未标记对象 | 不移动对象、不浪费空间 | 产生内存碎片,分配需维护空闲列表 | CMS 老年代 |
| 标记-整理 | 标记存活,把存活对象向一端移动,清理端边界以外 | 无碎片、不浪费空间 | 移动对象 + 更新引用,需 STW,代价高 | Serial Old / Parallel Old |
关键权衡逻辑:新生代对象死亡率极高(约 98% 朝生夕死),复制算法只需复制极少数存活对象,效率最高,浪费的空间也可接受;老年代对象长寿,复制要搬大量存活对象不划算,所以用「标记-清除」或「标记-整理」。清除快但有碎片、整理慢但无碎片——CMS 选清除追求低停顿,Parallel Old 选整理追求吞吐。
底层深入(5-10min)
并发标记的难题:三色标记与漏标。先想:标记个存活对象,怎么还能出错?——因为应用线程在你标记的同时还在改引用。STW 标记很简单(从 GC Roots DFS 遍历),但停顿随堆增长。并发标记时应用线程在同时改引用,就可能「漏标」——把存活对象误判成垃圾回收掉,程序崩溃。
三色标记把对象分三类:白色(未扫描,潜在垃圾)、灰色(已扫描但子对象未扫完)、黑色(自身及子对象都扫完)。并发标记的不变量是「黑色对象绝不直接引用白色对象」。
漏标的充要条件(同时满足两个才漏标):
条件①:黑色对象新增了对白色对象 X 的引用(黑已扫完,不会再回头扫)
条件②:X 原有的灰色→白色引用被删除(X 从可达变不可达)
两个条件同时满足 → X 漏标 → 存活对象被当垃圾回收 → 崩溃。
两种修法:
- CMS 增量更新(写后屏障):黑引用白时,写后屏障把白的重新标灰,Remark 阶段重扫——修好条件①「不让黑引白」。
- G1 SATB(写前屏障):引用被覆盖时,写前屏障把「旧引用指向的对象」记进
satb_mark_queue,保留并发标记开始时的快照——修好条件②「保留旧引用链」。代价是产生浮动垃圾(快照时还活、标记完已死的本轮不回收)。
这解释了为什么 G1 的 Remark 停顿比 CMS 短:CMS 要重扫对象,G1 只处理队列。
SATB 写前屏障的真实代码(gc/g1/g1BarrierSet.inline.hpp,逐字):所谓「写前屏障」就是 write_ref_field_pre,它在引用被覆盖之前把旧值 enqueue 进 SATB 队列:
template <class T>
inline void G1BarrierSet::enqueue(T* dst) {
G1SATBMarkQueueSet& queue_set = G1BarrierSet::satb_mark_queue_set();
if (!queue_set.is_active()) return;
T heap_oop = RawAccess<MO_RELAXED>::oop_load(dst);
if (!CompressedOops::is_null(heap_oop)) {
SATBMarkQueue& queue = G1ThreadLocalData::satb_mark_queue(Thread::current());
queue_set.enqueue_known_active(queue, CompressedOops::decode_not_null(heap_oop));
}
}
template <DecoratorSet decorators, typename T>
inline void G1BarrierSet::write_ref_field_pre(T* field) {
if (HasDecorator<decorators, IS_DEST_UNINITIALIZED>::value ||
HasDecorator<decorators, AS_NO_KEEPALIVE>::value) {
return;
}
enqueue(field);
}
template <DecoratorSet decorators, typename T>
inline void G1BarrierSet::write_ref_field_post(T* field) {
// Make sure that the card table reference is read only once. Otherwise the compiler
// might reload that value in the two accesses below, that could cause writes to
// the wrong card table.
CardTable* local_card_table = card_table();
CardValue* byte = local_card_table->byte_for(field);
if (*byte == G1CardTable::clean_card_val()) {
*byte = G1CardTable::dirty_card_val();
}
}
分析:write_ref_field_pre 把「旧引用指向的对象」先 enqueue 进线程本地 SATB 队列,保住并发标记开始时的快照——这就是修条件②「保留旧引用链」的真实实现。write_ref_field_post 则把目标字段所在的 Card 标 dirty(修条件①,供 RSet 记录跨 Region 引用)。两个屏障分工明确:一个保证旧对象不漏标,一个保证新引用不漏标。
③ G1 垃圾回收器
一句话结论(30s)
G1 把堆切成最多 2048 个 Region,让分代不再要求物理连续,回收时用 RSet 只扫「谁引用我」而不用扫全堆——核心是「用 Region 换灵活性、用 RSet 换不扫全堆」,把停顿控制在可预测的范围内。
核心原理(2min)
先想:老办法把堆物理分成新生代和老年代,有什么限制?——两块是连续的、固定大小的。G1 的思路是打碎成 Region,让分代不再要求物理连续。
- 堆布局:堆 = 一堆大小相等(1-32MB)的 Region,每个 Region 角色动态变化(Eden / Survivor / Old / Humongous / Free),不要求物理连续,GC 后释放的 Region 标记为 Free 复用。
- Humongous 对象:大小 ≥ 半个 Region 的大对象直接分配到一组连续 Region,跳过新生代——避免大对象在 Eden 和 Survivor 间反复复制。
- 三种 GC:
- Young GC:只回收新生代 Region;
- Mixed GC:回收新生代 + 一部分老年代 Region(优先回收垃圾最多的 Region),停顿可控,这是 G1 的主力;
- Full GC:正常情况下 G1 永不做 Full GC,只有分配速度远超回收速度、Free Region 耗尽(Concurrent Mode Failure)时才退化为 Serial Old 单线程 Full GC,秒级 STW。
底层深入(5-10min)
RSet(Remembered Set)——回收 Region 时「谁会引用我」:先想:只回收一个 Region,怎么知道别的 Region 有没有对象在引用它?——全堆扫描太贵,于是给每个 Region 记一份「谁引用我」的清单:回收一个 Region 不需要扫全堆,只需扫它的 RSet。每个 Region 的 RSet 记录「哪些外部 Region 的哪些 Card 引用了我的对象」。写引用时 G1 的写后屏障检查新引用是否跨 Region,是就把 Card 标 dirty 入 Dirty Card Queue,后台 Refinement 线程异步更新目标 Region 的 RSet——整个维护不在 GC 停顿内完成,对吞吐影响极小。
RSet 的真实结构(gc/g1/g1HeapRegionRemSet.hpp,逐字):每个 Region 持有一个 G1HeapRegionRemSet,内部是「Code Root Set(哪些 nmethod 的代码引用了本 Region 对象)」加「Card Set(哪些 Card 引用了本 Region 对象)」:
class G1HeapRegionRemSet : public CHeapObj<mtGC> {
// A set of nmethods whose code contains pointers into
// the region that owns this RSet.
G1CodeRootSet _code_roots;
// The collection set groups to which the region owning this RSet is assigned.
G1CSetCandidateGroup* _cset_group;
G1HeapRegion* _hr;
// Cached value of heap base address.
static HeapWord* _heap_base_address;
void clear_fcc();
G1CardSet* card_set() {
assert(has_cset_group(), "pre-condition");
return cset_group()->card_set();
}
// ...
inline void add_reference(OopOrNarrowOopStar from, uint tid);
};
分析:RSet 记的不是「我引用了谁」而是反过来的「谁引用了我」——_code_roots 记 JIT 编译后 nmethod 代码里的引用,card_set() 记堆里跨 Region 的引用,add_reference(from, tid) 就是写屏障标脏后由 Refinement 线程调进来登记「来自某 Card 的引用」。回收一个 Region 时只要扫这份 RSet 就能找到所有根,不用扫全堆。
SATB 并发标记:G1 并发标记基于 SATB(Snapshot-At-The-Beginning),保留标记开始时的引用快照,标记中新增/被覆盖的引用都按快照判断存活,所以不丢对象,代价是浮动垃圾。
Full GC 退化的根因与调优:-XX:InitiatingHeapOccupancyPercent(默认 45%)设太高 → 并发标记启动太晚 → Mixed GC 来不及释放 Region → Free Region 耗尽 → 退化 Full GC。调优方向是降低这个值,让并发标记更早启动。
④ ZGC 垃圾回收器
一句话结论(30s)
ZGC 靠着色指针 + 读屏障实现亚毫秒级停顿,且停顿时间不随堆大小增长——因为它的对象转移是并发的,不用 STW,这是和 G1 最本质的区别。
核心原理(2min)
先想:G1 已经很好了,为什么还要 ZGC?——因为 G1 停顿随堆增长,堆一大停顿就失控。ZGC 的目标是把停顿压到亚毫秒且不随堆增长。
- 设计目标:G1 停顿随堆增长(几十 GB 堆可达 200ms),ZGC 目标停顿 <1ms 且不随堆增长,适用几百 GB ~ TB 级堆。
- 着色指针(Colored Pointers):把 GC 元数据编码进 64 位指针的高 4 位——Finalizable(有 finalize)、Remapped(已指向新位置)、Marked0/Marked1(标记状态,交替使用免重置)。
- 读屏障(Load Barrier):应用线程读引用时,读屏障自动检查指针元数据,发现对象已移动就修正地址。关键创新是「自愈」:第一次读已移动对象时修正引用并更新字段,之后不再经过屏障——实现并发转移(转移时应用线程照常跑)。
- 对比 G1:G1 转移要 STW(暂停线程拷贝存活对象),ZGC 转移并发(读屏障自愈 + GC 线程批量转移),全程无 STW。
底层深入(5-10min)
着色指针的代价:先想:把 GC 元数据塞进指针,难道没有代价吗?——当然有,牺牲的是寻址空间:64 位指针让出 4 位给 GC 元数据,实际寻址能力从 64 位降到约 46 位(64TB)。Linux 默认只给用户态 47 位地址空间,ZGC 再占 4 位 → 实际可用约 43 位(8TB),对绝大多数应用够用。这就是「用寻址空间换停顿时间」的取舍。
着色指针的真实位布局(gc/z/zAddress.hpp,逐字):先看指针里到底哪些位是 GC 元数据、哪些位是地址:
// Layout of metadata bits in colored pointer / zpointer.
//
// A zpointer is a combination of the address bits (heap base bit + offset)
// and two low-order metadata bytes, with the following layout:
//
// RRRRMMmmFFrr0000
// **** : Used by load barrier
// ********** : Used by mark barrier
// ************ : Used by store barrier
// **** : Reserved bits
//
// +-------------+-------------------+--------------------------+
// | Bit pattern | Description | Included colors |
// +-------------+-------------------+--------------------------+
// | rr | Remembered bits | Remembered[0, 1] |
// +-------------+-------------------+--------------------------+
// | FF | Finalizable bits | Finalizable[0, 1] |
// +-------------+-------------------+--------------------------+
// | mm | Marked young bits | MarkedYoung[0, 1] |
// +-------------+-------------------+--------------------------+
// | MM | Marked old bits | MarkedOld[0, 1] |
// +-------------+-------------------+--------------------------+
// | RRRR | Remapped bits | Remapped[00, 01, 10, 11] |
// +-------------+-------------------+--------------------------+
// Reserved bits
const size_t ZPointerReservedShift = 0;
const size_t ZPointerReservedBits = 4;
const uintptr_t ZPointerReservedMask = z_pointer_mask(ZPointerReservedShift, ZPointerReservedBits);
// ...
// Marked bits
const size_t ZPointerMarkedShift = ZPointerRememberedShift + ZPointerRememberedBits;
const size_t ZPointerMarkedBits = 6;
const uintptr_t ZPointerMarkedMask = z_pointer_mask(ZPointerMarkedShift, ZPointerMarkedBits);
const uintptr_t ZPointerFinalizable0 = z_pointer_bit(ZPointerMarkedShift, 0);
const uintptr_t ZPointerFinalizable1 = z_pointer_bit(ZPointerMarkedShift, 1);
const uintptr_t ZPointerMarkedYoung0 = z_pointer_bit(ZPointerMarkedShift, 2);
const uintptr_t ZPointerMarkedYoung1 = z_pointer_bit(ZPointerMarkedShift, 3);
const uintptr_t ZPointerMarkedOld0 = z_pointer_bit(ZPointerMarkedShift, 4);
const uintptr_t ZPointerMarkedOld1 = z_pointer_bit(ZPointerMarkedShift, 5);
分析:注意这版源码(2026 版权的新 JDK)里,元数据位放在指针低位(低 4 位 Reserved + 低 612 位 Marked/Remapped),地址(堆基址位 + offset)落在高位,20 老版 ZGC 的「高 4 位」布局不同。读屏障只盯 uncolor 就是右移 load_shift_lookup 把低位元数据剥掉——与 JDK 11Remapped 这 4 位(Remapped00/01/10/11)判断对象是否已「搬家」。
读屏障自愈伪码(背下来能证明真懂):先想:读一个引用,怎么就知道它「搬家」了?——指针低位里藏着标记,读屏障读它时顺手检查:
Object* load_barrier(Object** field) {
Object* ptr = *field;
if (ptr & REMAPPED_MASK) // 对象已移动
return remap(ptr); // 修正为正确地址
if (ptr & MARKED_MASK) // 标记阶段
return mark_and_relocate(ptr); // 标记 + 可能移动
return ptr; // 正常引用
}
演进时间线(可作延伸钩子):JDK 11 引入实验版 → JDK 15 正式生产可用 → JDK 16 支持并发线程栈处理 → 后续版本不断降低停顿。分代 ZGC 在 JDK 21 引入,进一步优化吞吐。
⑤ 类加载过程
一句话结论(30s)
类加载分五个阶段:加载 → 验证 → 准备 → 解析 → 初始化,核心是「先加载进内存、再校验合法性、再分配静态变量零值、再解析符号引用、最后才执行初始化代码」——因为要保证类在被使用前是合法且完整初始化的。
核心原理(2min)
先想:一个 class 文件里的字节,是怎么一步步变成能 new 的 Class 的?——五个阶段缺一不可,每一步都在为「安全、正确地使用这个类」做铺垫。
- 加载:通过类全限定名获取二进制字节流,转成方法区运行时数据结构,生成对应的
Class对象。 - 验证:校验字节码合法性(文件格式、元数据、字节码、符号引用四层),防止恶意/损坏的字节码危害 JVM。
- 准备:为静态变量分配内存并赋零值(不是代码里的赋值);
static final常量在这一步直接赋真实值。 - 解析:把常量池的符号引用转成直接引用(可推迟到初始化后,支持动态绑定)。
- 初始化:执行
<clinit>()方法——静态变量真正赋值 + 静态代码块,按父类先、子类后的顺序执行。
对象创建时也会触发类加载检查:new 指令先查常量池符号引用,看类是否已加载/解析/初始化,没有就触发上述流程,且父类未初始化会先初始化父类。
底层深入(5-10min)
哪个阶段能篡改字节码:先想:五个阶段里,哪一步能「动手脚」改字节码?——只能趁字节流拿到、还没定型成 Class 的时候:加载阶段(获取二进制字节流之后、生成 Class 之前)。三种手段:
- 自定义类加载器重写
findClass(),在defineClass之前对字节码改写(ASM / Javassist / CGLIB); - Java Agent +
Instrumentation的ClassFileTransformer,在类加载时transform改写; - 验证阶段是「校验」不是「改写」,篡改过的字节码若不合规会被验证阶段拦下。
为什么分阶段而不是一步到位:先想:一步到位直接加载不就完了,为什么拆成五步?——拆开才能在中间插入「安全边界」:验证与准备/解析分离,让 JVM 有「先信任再使用」的边界;准备阶段赋零值保证即使初始化抛异常,静态变量也有确定默认值(int=0、引用=null),不会读到未定义状态。
验证→准备→解析在 HotSpot 里的落点(oops/instanceKlass.cpp,逐字):五个阶段中「验证、准备、解析」都发生在 link_class_impl(链接)里,看关键的 verification & rewriting 段:
if (!is_linked()) {
if (!is_rewritten()) {
// ...
{
bool verify_ok = verify_code(THREAD);
if (!verify_ok) {
return false;
}
}
// ...
// also sets rewritten
rewrite_class(CHECK_false);
}
// ...
// relocate jsrs and link methods after they are all rewritten
link_methods(CHECK_false);
分析:verify_code 是验证(调用字节码 Verifier 校验合法性,不合格直接 return false 抛 VerifyError);rewrite_class 是准备/重写——把常量池符号引用改写成 ConstantPoolCache 里的直接索引、把静态字段地址固化,这正是「解析符号引用」发生的地方;link_methods 则把方法解析并完成 vtable/itable 链接。三步被同一把 init_lock 串起来,先验证通过才允许重写和链接。
初始化的真实入口(instanceKlass.cpp,逐字):初始化就是执行 <clinit>,本质是一次对类初始化方法的 Java 调用:
methodHandle h_method(THREAD, class_initializer());
assert(!is_initialized(), "we cannot initialize twice");
// ...
if (h_method() != nullptr) {
ThreadInClassInitializer ticl(THREAD, this); // Track class being initialized
JavaCallArguments args; // No arguments
JavaValue result(T_VOID);
JavaCalls::call(&result, h_method, &args, CHECK); // Static call (no args)
}
分析:class_initializer() 取的就是字节码里的 <clinit> 方法,JavaCalls::call 以无参、void 返回的方式执行它,assert(!is_initialized()) 保证一个类只初始化一次。这解释了为什么静态代码块 + 静态变量赋值都打包进 <clinit>、且父类先于子类执行——父类的 <clinit> 会被先触发。
⑥ 双亲委派机制
一句话结论(30s)
双亲委派是「向上委派、向下查找」:一个类加载请求先交给父加载器,父加载不了才自己加载——因为这样能保证核心类(如 java.lang.String)只会被 Bootstrap 加载一次、不可篡改,实现安全性和唯一性。
核心原理(2min)
先想:加载一个类,凭什么是「从下往上问一圈、再从上往下找」?——因为核心类绝不能被自定义类顶包。先看清这条委派链,再理解它为什么这么设计:
Bootstrap ClassLoader (C++ 实现)
↑ 加载 rt.jar 等核心类,无 Java 层面父加载器
Extension ClassLoader
↑ 加载 jre/lib/ext,父是 Bootstrap
Application ClassLoader
加载 classpath 用户代码,父是 Extension
核心代码逻辑(ClassLoader.loadClass):查已加载缓存 → 委派父加载器 → 父加载不到 → 自己 findClass。所以自定义类加载器只应重写 findClass() 而不是 loadClass()——重写 loadClass 会破坏委派逻辑,可能导致核心类被自定义类替换。
经典实验:自定义 package java.lang; class String 能编译通过,但运行时 NoSuchMethodError: main——因为加载的仍是 rt.jar 里的核心 String。
底层深入(5-10min)
委派逻辑的真实代码(java/lang/ClassLoader.java,逐字):面试背的那句「先查缓存 → 委派父加载器 → 父加载不到自己 findClass」,就对应 loadClass(name, resolve) 的这三段:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
分析:findLoadedClass 是「向上查已加载缓存」;parent != null 走 parent.loadClass、否则 findBootstrapClassOrNull(Bootstrap 是 C++ 实现的,无 Java 父加载器);两处都拿不到才落到 findClass。正因这段逻辑把「委派」写死在 loadClass 里,所以自定义类加载器只应重写 findClass() 而非 loadClass()——重写后者会整个绕过委派链。
打破双亲委派的三大场景(都源于同一需求:「上层代码要调用下层实现」):先想:委派链明明挺好,为什么还要打破它?——因为「上层要调下层实现」这个需求委派链天生满足不了。
-
JDBC SPI(线程上下文类加载器 TCCL):
DriverManager由 Bootstrap 加载(rt.jar),却要调用 classpath 下第三方驱动com.mysql.cj.jdbc.Driver(AppClassLoader 加载)。父加载器无法向下委派——用Thread.currentThread().getContextClassLoader()(默认 AppClassLoader)反向加载 SPI 实现。 -
Tomcat 的 WebApp 隔离:每个 WebApp 独立 ClassLoader,优先从自己
/WEB-INF/classes加载,不向上委派——两个应用用同名 jar 的不同版本互不冲突。 -
OSGi 模块化:没有树状层次,每个 Bundle 按 Import-Package 平级查找。
「同一接口 instanceof 返回 false」的谜案:两个 ClassLoader 各自加载同名接口,字节码一样但在 JVM 里是两个不同 Class 对象——全限定名相同 + ClassLoader 不同 = 不同类。
⑦ 高级特性(四种引用)
一句话结论(30s)
四种引用按强度递减是强 → 软 → 弱 → 虚,对应 GC 的回收时机从「永不」到「每次 GC」,本质是给开发者提供「按需控制对象存活」的钩子——因为不是所有对象都值得用堆内存硬扛。
核心原理(2min)
先想:同样是「引用」,为什么还要分强弱?——因为有的对象该常驻、有的该让位,分四档就是给开发者「按需控制对象存活」的钩子。
| 类型 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用 | GC 永不回收 | 普通 new 出来的对象 |
| 软引用 | OOM 前回收 | 内存敏感缓存(Guava softValues) |
| 弱引用 | 每次 GC 回收 | ThreadLocal 的 key、WeakHashMap |
| 虚引用 | GC 时(get() 恒返回 null) | 跟踪 GC 事件,配合 Cleaner 释放堆外内存 |
- 软引用:JVM 抛 OOM 前会遍历所有软引用清空其对象,所以是「内置缓存驱逐策略」——内存充足正常服务,不足时自动腾空间,无需手动管理。
- 弱引用:ThreadLocal 的
Entry继承WeakReference,key 弱引用、但 value 是强引用——这是 ThreadLocal 泄漏的根因。 - 虚引用:
get()永远返回 null,它只是「GC 通知」机制而非「访问对象」通道。
底层深入(5-10min)
虚引用 + Cleaner 释放堆外内存的完整链路:先想:DirectByteBuffer 真正的大头在堆外,GC 又只扫堆、看不到堆外内存,那堆外内存谁来释放?——靠虚引用 + Cleaner「顺带」释放:
DirectByteBuffer 构造 → Cleaner.create(this, Deallocator(address,size,capacity))
→ GC 判定 DirectByteBuffer 变为 phantom-reachable → Cleaner 入 ReferenceQueue
→ Reference-Handler 线程调 Cleaner.clean() → Deallocator.run()
→ Unsafe.freeMemory(address) 释放堆外内存
GC 管的只是堆上几字节的 DirectByteBuffer 对象,堆外内存靠虚引用链「顺带」释放。Cleaner 内部维护一个双向链表(first 静态字段是强引用 GC Root),保证所有 Cleaner 不被提前 GC——否则堆外内存永远无法释放。
这条链路的真实代码(java/nio/Direct-X-Buffer.java.template,逐字,构建时生成进 DirectByteBuffer):释放堆外内存的 Runnable 就是这段 Deallocator,构造时用 Cleaner 把「this 这个堆对象」和「Deallocator」绑在一起:
private record Deallocator(long address, long size, int capacity) implements Runnable {
private Deallocator {
assert address != 0;
}
public void run() {
UNSAFE.freeMemory(address);
Bits.unreserveMemory(size, capacity);
}
}
private final Cleaner cleaner;
public Cleaner cleaner() { return cleaner; }
// ...
cleaner = BufferCleaner.register(this, new Deallocator(base, size, cap));
分析:Deallocator.run() 只做两件事——UNSAFE.freeMemory(address) 释放堆外地址、Bits.unreserveMemory 归还预留的堆外额度。GC 真正管到的只是堆上几字节的 DirectByteBuffer 对象,它变 phantom-reachable 后 Cleaner 才回调这个 run() 去释放堆外大块内存,这就是「虚引用顺带释放堆外内存」的落点。
虚引用 get() 恒返回 null(java/lang/ref/PhantomReference.java,逐字):虚引用不给你碰对象,只当 GC 事件通知用:
public T get() {
return null;
}
分析:因为 referent 一旦「虚可达」就意味着已被判死,JVM 刻意不让你再拿到它,所以 get() 恒返回 null。这正是虚引用与软/弱引用最本质的区别——它不是「访问对象」的通道,而是「跟踪 GC 事件」的钩子。
ThreadLocal 泄漏根因:先想:key 都做成弱引用了,为什么还会内存泄漏?——漏的其实不是 key,是 value:外部强引用断掉后,ThreadLocal 对象被回收(Entry.key → null),但 value 仍是强引用,且 Entry 通过 Thread → ThreadLocalMap → Entry 链仍然可达——所以 ThreadLocal.remove() 是必须的好习惯。
泄漏根因的真实代码(java/lang/ThreadLocal.java,逐字):ThreadLocalMap 的 Entry 确实只把 key 做成弱引用、value 是普通强引用字段:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
分析:super(k) 让 key 走 WeakReference 构造器(弱引用,GC 可回收),但 value 是 Entry 自己的普通字段——强引用。外部强引用断掉后 ThreadLocal 对象被回收、Entry 的 referent 变 null,可 value 仍被 Thread → ThreadLocalMap → Entry.value 这条强链拽着,除非 remove() 或 expungeStaleEntry 主动清,否则 value 一直泄漏。
追问清单(10 个追问点)
1. 内存结构:JVM 运行时数据区有哪些?
一句话结论:分线程共享(堆、元空间)和线程私有(虚拟机栈、本地方法栈、程序计数器)两大类。 展开:堆存对象,分新生代(Eden + 两个 Survivor)和老年代;元空间存类元数据(本地内存);虚拟机栈存栈帧(局部变量表、操作数栈等);PC 指向当前字节码指令,是唯一不会 OOM 的区域。之所以分共享/私有,因为对象要全局可达,执行上下文要线程隔离。
2. 内存结构:方法区和元空间的关系?为什么 1.8 移除永久代?
一句话结论:方法区是 JVM 规范的概念,元空间是 HotSpot 对它的落地实现,取代了永久代。
展开:永久代是堆里固定大小的一块,存类元数据,容量要 -XX:MaxPermSize 配死,动态生成大量类(CGLIB/JSP/Lambda)就 PermGen OOM。元空间改用本地内存,默认只受物理内存限制,从根上消除了「固定上限焦虑」。
3. GC算法:三种算法优缺点?
一句话结论:复制无碎片但浪费空间,清除不浪费但产生碎片,整理无碎片也不浪费但移动代价大。 展开:复制适合新生代(对象死亡率高,只需复制极少数存活对象);清除适合追求低停顿的 CMS;整理适合追求吞吐的 Parallel Old。核心依据是对象存活率:新生代朝生夕死用复制,老年代长寿对象用整理。
4. G1:Region 怎么划分?Mixed GC 和 Full GC 区别?
一句话结论:堆切成最多 2048 个大小相等(1-32MB)的 Region 动态分代;Mixed GC 回收新生代 + 部分老年代 Region,是 G1 主力,Full GC 是退化才发生的异常行为。
展开:Region 角色随 GC 动态切换(Eden/Survivor/Old/Humongous/Free)。Mixed GC 停顿可控;Full GC 只在分配速度远超回收、Free Region 耗尽(Concurrent Mode Failure)时退化为 Serial Old 单线程,秒级 STW。调优方向是降低 -XX:InitiatingHeapOccupancyPercent 让并发标记更早启动。
5. ZGC:核心原理?为什么亚毫秒?染色指针是什么?
一句话结论:ZGC 靠着色指针 + 读屏障实现并发转移,所以停顿 <1ms 且不随堆增长。 展开:染色指针是把 GC 元数据(Finalizable/Remapped/Marked0/Marked1)编码进 64 位指针高 4 位;读屏障在读引用时检查元数据,发现对象已移动就「自愈」修正——首次读修正、之后不再过屏障。G1 转移要 STW,ZGC 转移并发,这是停顿亚毫秒的根本原因,代价是寻址空间降到约 46 位。
6. G1/ZGC:什么场景选哪个?项目里根据什么选?
一句话结论:几 GB ~ 几十 GB 堆选 G1,几百 GB ~ TB 级、对停顿有极严格要求的选 ZGC。 展开:G1 停顿随堆增长(几十 GB 可达 200ms),但生态成熟、默认推荐;ZGC 停顿恒定 <1ms,适合超大堆和延迟敏感场景(如交易、实时系统)。实际选型看三点:堆大小、可接受的停顿上限、JDK 版本(ZGC 需 JDK 15+ 生产可用)。
7. 类加载:5 个阶段做什么?哪阶段能篡改字节码?
一句话结论:加载(取字节流生成 Class)→ 验证(校验合法性)→ 准备(静态变量赋零值)→ 解析(符号引用转直接引用)→ 初始化(执行 <clinit>),篡改发生在加载阶段。
展开:在加载阶段获取字节流后、defineClass 前改写,手段有重写 findClass()(ASM/Javassist)或 Java Agent 的 ClassFileTransformer。验证阶段只校验不改写,篡改过的字节码不合规会被拦下。
8. 双亲委派:模型是什么?为什么打破?Tomcat 怎么打破?
一句话结论:双亲委派是「向上委派、向下查找」;打破是因为「上层代码要调用下层实现」;Tomcat 让每个 WebApp 用独立 ClassLoader 优先加载自己的 /WEB-INF/classes。
展开:加载请求先给父加载器,父加载不了才自己 findClass,保证核心类唯一且不可篡改。JDBC SPI 用 TCCL 反向加载驱动;Tomcat 打破「先向上委派」,实现多个应用同名 jar 不同版本的隔离。自定义类加载器应只重写 findClass 而非 loadClass,避免破坏委派。
9. 高级特性:四种引用是什么?什么时候用?
一句话结论:强永不回收、软 OOM 前回收、弱每次 GC 回收、虚跟踪 GC 事件。
展开:软引用做内存敏感缓存(内存不足自动清空);弱引用做 ThreadLocal 的 key、WeakHashMap;虚引用配合 Cleaner 精确释放堆外内存(DirectByteBuffer 的释放链)。ThreadLocal 泄漏根因是 value 强引用 + Entry 仍可达,所以必须 remove()。
10. 引申:线上遇到过 OOM 吗?怎么排查?
一句话结论:先按错误信息定位是哪种 OOM(堆/元空间/栈/直接内存),再上对应工具查根因。
展开:堆 OOM 用 -XX:+HeapDumpOnOutOfMemoryError 抓 dump,jmap + MAT 看 Dominator Tree 找保留内存最大的泄漏源;元空间 OOM 用 jcmd VM.classloader_stats 看哪个 ClassLoader 加载了太多类;栈溢出用 jstack 看同一方法重复几百次栈帧;直接内存 OOM 用 jcmd VM.native_memory summary 或手动 cleaner().clean() 释放。