Skip to content
Go back

JVM 面试回答——内存结构、GC、G1/ZGC 与类加载

JVM 面试回答

框架:结论先行 → 核心原理 → 底层深入(30s / 2min / 5-10min)。每句结论都带「因为」,细节等追问再展开。


① JVM 内存结构

一句话结论(30s)

JVM 运行时数据区分成线程共享线程私有两块:共享的是堆(存对象)和元空间(存类元数据),私有的是虚拟机栈、本地方法栈、程序计数器(每个线程一份)——核心设计是「共享区管对象,私有区管执行上下文」,因为这样线程切换只需换自己的栈和 PC,互不干扰。

核心原理(2min)

先想:JVM 把内存分成「共享」和「私有」两类,划分标准是什么?——标准是「这份数据该不该跟着线程走」:对象要全局可达就放共享区,执行上下文每个线程各一份就放私有区,这样线程切换只需换自己的栈和 PC,互不干扰。

线程共享区:
  堆 Heap        —— 所有对象、数组。又分新生代(Eden 8 + Survivor0 1 + Survivor1 1)和老年代
  元空间 Metaspace —— 类元数据、方法信息、运行时常量池(用本地内存,不在堆里)

线程私有区(每线程一份):
  虚拟机栈        —— 方法调用压栈帧(局部变量表 + 操作数栈 + 动态链接 + 返回地址)
  本地方法栈      —— native 方法
  程序计数器 PC   —— 指向当前正在执行的字节码指令地址(唯一不会 OOM 的区域)

底层深入(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 漏标 → 存活对象被当垃圾回收 → 崩溃。

两种修法

这解释了为什么 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,让分代不再要求物理连续。

底层深入(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 的目标是把停顿压到亚毫秒且不随堆增长。

底层深入(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)落在高位,uncolor 就是右移 load_shift_lookup 把低位元数据剥掉——与 JDK 1120 老版 ZGC 的「高 4 位」布局不同。读屏障只盯 Remapped 这 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 的?——五个阶段缺一不可,每一步都在为「安全、正确地使用这个类」做铺垫。

  1. 加载:通过类全限定名获取二进制字节流,转成方法区运行时数据结构,生成对应的 Class 对象。
  2. 验证:校验字节码合法性(文件格式、元数据、字节码、符号引用四层),防止恶意/损坏的字节码危害 JVM。
  3. 准备:为静态变量分配内存并赋零值(不是代码里的赋值);static final 常量在这一步直接赋真实值。
  4. 解析:把常量池的符号引用转成直接引用(可推迟到初始化后,支持动态绑定)。
  5. 初始化:执行 <clinit>() 方法——静态变量真正赋值 + 静态代码块,按父类先、子类后的顺序执行。

对象创建时也会触发类加载检查new 指令先查常量池符号引用,看类是否已加载/解析/初始化,没有就触发上述流程,且父类未初始化会先初始化父类。

底层深入(5-10min)

哪个阶段能篡改字节码:先想:五个阶段里,哪一步能「动手脚」改字节码?——只能趁字节流拿到、还没定型成 Class 的时候:加载阶段(获取二进制字节流之后、生成 Class 之前)。三种手段:

为什么分阶段而不是一步到位:先想:一步到位直接加载不就完了,为什么拆成五步?——拆开才能在中间插入「安全边界」:验证与准备/解析分离,让 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 falseVerifyError);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 != nullparent.loadClass、否则 findBootstrapClassOrNull(Bootstrap 是 C++ 实现的,无 Java 父加载器);两处都拿不到才落到 findClass。正因这段逻辑把「委派」写死在 loadClass 里,所以自定义类加载器只应重写 findClass() 而非 loadClass()——重写后者会整个绕过委派链。

打破双亲委派的三大场景(都源于同一需求:「上层代码要调用下层实现」):先想:委派链明明挺好,为什么还要打破它?——因为「上层要调下层实现」这个需求委派链天生满足不了。

  1. JDBC SPI(线程上下文类加载器 TCCL)DriverManager 由 Bootstrap 加载(rt.jar),却要调用 classpath 下第三方驱动 com.mysql.cj.jdbc.Driver(AppClassLoader 加载)。父加载器无法向下委派——用 Thread.currentThread().getContextClassLoader()(默认 AppClassLoader)反向加载 SPI 实现。

  2. Tomcat 的 WebApp 隔离:每个 WebApp 独立 ClassLoader,优先从自己 /WEB-INF/classes 加载,不向上委派——两个应用用同名 jar 的不同版本互不冲突。

  3. 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 释放堆外内存

底层深入(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() 恒返回 nulljava/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() 释放。


Share this post on:

Previous Post
并发编程面试回答——锁、线程池、ThreadLocal 与 JUC
Next Post
SQL陷阱——NOT EXISTS vs NOT IN,NULL毁掉一切