Java 虚拟机:JVM篇

📌JVM篇

1.1 说一下JVM的内存结构?哪些是线程共享的,哪些是线程私有的?

✅ 正确回答思路:

这个问题我从JVM运行时数据区的5个部分来回答,先说整体结构,再说线程共享和私有的区别。

一、JVM运行时数据区的5个部分:

复制代码
JVM运行时数据区
├── 线程共享区域
│   ├── 堆(Heap)              ← 存储对象实例
│   └── 方法区(Method Area)    ← 存储类信息、常量、静态变量
│       └── 运行时常量池
│
└── 线程私有区域
    ├── 程序计数器(PC Register)  ← 记录当前线程执行的字节码行号
    ├── 虚拟机栈(VM Stack)       ← 存储局部变量、操作数栈、方法出口
    └── 本地方法栈(Native Stack) ← 为Native方法服务

详细说每一部分:

1. 堆(Heap)------ 线程共享

  • 作用 :存放对象实例数组,几乎所有的对象实例都在这里分配内存

  • 结构:分为

    新生代

    (Young Generation)和

    老年代

    (Old Generation)

    • 新生代又分:Eden区 + Survivor0区 + Survivor1区(8:1:1)
    • 老年代:存放长期存活的对象
  • GC区域:堆是垃圾回收器管理的主要区域,所以也叫"GC堆"

  • 异常 :如果堆中没有内存完成实例分配且堆无法再扩展时,抛出OutOfMemoryError: Java heap space

为什么线程共享? 因为对象是所有线程都能访问的,比如一个User对象,线程A创建了,线程B也要能访问到。

2. 方法区(Method Area)------ 线程共享

  • 作用 :存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码

  • JDK版本差异

    (重要!):

    • JDK 7及之前:叫永久代(PermGen),在JVM内存中
    • JDK 8及之后:改为元空间(Metaspace) ,使用的是直接内存(本地内存)
  • 为什么要改用元空间?

    1. 永久代大小固定,容易OOM(OutOfMemoryError: PermGen space)
    2. 元空间使用本地内存,大小可以动态扩展,更灵活
    3. 简化Full GC,永久代的GC很复杂
  • 运行时常量池:是方法区的一部分,存放编译期生成的各种字面量和符号引用

  • 字符串常量池:JDK 7之前在永久代,JDK 7之后移到了堆中

3. 虚拟机栈(VM Stack)------ 线程私有

  • 作用 :每个方法执行时都会创建一个

    栈帧

    (Stack Frame),用于存储:

    • 局部变量表(基本数据类型、对象引用)
    • 操作数栈(字节码指令的操作数)
    • 动态链接(指向运行时常量池的方法引用)
    • 方法出口(方法返回地址)
  • 生命周期:方法调用时压栈,方法返回时出栈

  • 异常:

    • 栈深度超过最大值:StackOverflowError(常见于递归调用没有终止条件)
    • 栈无法申请到足够内存:OutOfMemoryError

举例说明

java 复制代码
public void method1() {
    int a = 1;        // 局部变量a存在栈帧的局部变量表
    User user = new User();  // user引用存在栈,User对象存在堆
    method2();
}

public void method2() {
    int b = 2;
}

调用过程:
栈:[method1的栈帧] 
→ [method1的栈帧, method2的栈帧]  ← method2执行
→ [method1的栈帧]  ← method2执行完出栈
→ []  ← method1执行完出栈

4. 本地方法栈(Native Method Stack)------ 线程私有

  • 作用 :为虚拟机使用到的Native方法 服务(比如Thread.currentThread()这种用C/C++写的方法)
  • 区别:虚拟机栈为Java方法服务,本地方法栈为Native方法服务
  • 异常 :和虚拟机栈一样,StackOverflowErrorOutOfMemoryError

5. 程序计数器(Program Counter Register)------ 线程私有

  • 作用 :记录当前线程正在执行的字节码指令的行号(地址)

  • 为什么需要? 多线程环境下,CPU会在不同线程间切换,程序计数器记录每个线程执行到哪了,切换回来时能继续执行

  • 特点:

    • 如果执行的是Java方法,计数器记录的是字节码指令地址
    • 如果执行的是Native方法,计数器值为空(Undefined)
    • 唯一不会OOM的区域

二、线程共享 vs 线程私有 总结表:

区域 线程共享/私有 作用 会OOM吗
共享 存对象实例、数组
方法区 共享 存类信息、常量、静态变量
虚拟机栈 私有 存局部变量、操作数栈
本地方法栈 私有 为Native方法服务
程序计数器 私有 记录字节码行号 不会

三、实际项目经验(加分项):

"在我之前的项目中,遇到过java.lang.OutOfMemoryError: Java heap space,通过增大堆内存-Xmx4g解决了。还有一次遇到StackOverflowError,排查后发现是递归调用写错了,导致无限递归。"

💡 记忆口诀: "堆方共享存对象和类,栈计私有管执行状态"


1.2 什么是垃圾回收(GC)?对象什么时候会被回收?

✅ 正确回答思路:

GC是Java自动内存管理的核心,我从"什么是垃圾"、"如何判断垃圾"、"什么时候回收"三个方面来答。

一、什么是垃圾?

简单来说,不再被使用的对象就是垃圾,需要被回收掉,释放内存给新对象用。

二、如何判断一个对象是垃圾?(面试高频!)

有两种算法:

算法1:引用计数法(Java不用这个!)

  • 原理:给每个对象加一个引用计数器,有引用指向它就+1,引用失效就-1,计数器为0时就可以回收
  • 优点:实现简单,判断效率高
  • 致命缺陷 :无法解决循环引用问题!
java 复制代码
// 循环引用示例
public class Test {
    Object instance = null;
    
    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        
        a.instance = b;  // a引用b
        b.instance = a;  // b引用a
        
        a = null;
        b = null;
        
        // 虽然a和b都不可达了,但它们互相引用
        // 引用计数法会认为它们的计数器不为0,无法回收!
        System.gc();
    }
}

算法2:可达性分析算法(Java用的!)

  • 原理 :从一组叫做"GC Roots "的根对象开始,向下搜索,走过的路径叫引用链。如果一个对象到GC Roots没有任何引用链相连(不可达),这个对象就是垃圾。

GC Roots包括哪些对象?(面试爱问!)

复制代码
1. 虚拟机栈(栈帧中的局部变量表)中引用的对象
   比如:方法里的局部变量引用的对象
   
2. 方法区中类静态属性引用的对象
   比如:public static User user = new User();
   
3. 方法区中常量引用的对象
   比如:public static final String s = "hello";
   
4. 本地方法栈中JNI(Native方法)引用的对象

5. 所有被同步锁(synchronized)持有的对象

6. JVM内部的引用
   比如:基本数据类型对应的Class对象,异常对象,类加载器

可达性分析示例:

java 复制代码
public class GCRootsTest {
    private static User staticUser;  // GC Root(静态变量)
    
    public void method() {
        User localUser = new User();  // GC Root(局部变量)
        User temp = new User();
        
        // localUser和staticUser是GC Roots
        // 它们引用的User对象可达,不会被回收
        // temp没有被引用了,不可达,会被回收
    }
}

三、对象什么时候会被回收?(重要!)

即使通过可达性分析判定为不可达,对象也不是立即被回收,而是经过两次标记过程:

第一次标记

  • 可达性分析后发现对象不可达,进行第一次标记

  • 判断对象是否有必要执行

    复制代码
    finalize()

    方法

    • 如果对象没有覆盖finalize()方法,或者finalize()已经被JVM调用过,直接回收
    • 否则,将对象放入F-Queue队列,等待执行finalize()

第二次标记

  • JVM会用低优先级的Finalizer线程去执行F-Queue里对象的finalize()方法
  • finalize()是对象自我拯救 的最后机会!如果在finalize()中重新与引用链上的任何对象建立关联(比如把this赋值给某个类变量),第二次标记时它会被移出"即将回收"的集合
  • 如果对象在finalize()中没有拯救自己,就会被回收

代码示例(对象自我拯救):

java 复制代码
public class FinalizeTest {
    public static FinalizeTest SAVE_HOOK = null;
    
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize方法被调用");
        // 自我拯救:重新关联到GC Roots
        FinalizeTest.SAVE_HOOK = this;
    }
    
    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeTest();
        
        // 第一次自救
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);  // 等待finalize执行
        
        if (SAVE_HOOK != null) {
            System.out.println("我还活着!");  // 会打印,自救成功
        }
        
        // 第二次自救
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        
        if (SAVE_HOOK != null) {
            System.out.println("我还活着!");
        } else {
            System.out.println("我死了!");  // 会打印,finalize只能救一次
        }
    }
}

注意finalize()方法不推荐使用!原因:

  • 运行代价高
  • 不确定性大(不知道什么时候执行)
  • 无法保证各个对象的调用顺序
  • JDK 9开始已经被标记为@Deprecated

四、什么时候触发GC?

Minor GC(Young GC)

  • 触发条件:Eden区满了
  • 回收区域:新生代(Eden + Survivor)
  • 特点:频繁、速度快

Major GC / Full GC

  • 触发条件:
    • 老年代空间不足
    • 方法区(元空间)空间不足
    • 通过Minor GC后进入老年代的平均大小 > 老年代剩余空间
    • 手动调用System.gc()(不保证一定执行)
  • 回收区域:老年代(Major GC)或 整个堆+方法区(Full GC)
  • 特点:慢,会引发STW(Stop The World)

五、实际项目经验:

"我们项目用的是G1垃圾回收器,通过-XX:+UseG1GC开启。有一次线上频繁Full GC导致接口超时,排查后发现是大对象直接进入老年代导致老年代频繁满,后来优化了代码,把大对象拆小,Full GC的频率就降下来了。"

💡 总结:

  • Java用可达性分析算法判断垃圾
  • GC Roots包括:栈中引用、静态变量、常量、Native引用等
  • 对象被回收要经过两次标记finalize()是自救机会(但不推荐用)
  • Minor GC回收新生代,Full GC回收整个堆

1.3 说一下常见的垃圾回收算法?各有什么优缺点?

✅ 正确回答思路:

垃圾回收算法决定了"怎么回收垃圾",我说4种经典算法,每种都说原理、优缺点、适用场景。

一、标记-清除算法(Mark-Sweep)------ 最基础

原理:

复制代码
第一步:标记(Mark)
    从GC Roots开始遍历,标记所有可达对象

第二步:清除(Sweep)
    遍历堆,把未被标记的对象清除掉

示意图(用文字表示):

复制代码
回收前:[对象A][垃圾][对象B][垃圾][垃圾][对象C]
         ↓标记可达对象
标记后:[✓对象A][垃圾][✓对象B][垃圾][垃圾][✓对象C]
         ↓清除未标记对象
回收后:[✓对象A][空闲][✓对象B][空闲][空闲][✓对象C]

优点:

  • 实现简单
  • 不需要移动对象

缺点:

  • 效率不高:标记和清除两个过程效率都不高
  • 产生内存碎片!清除后内存不连续,大对象可能无法分配内存

适用场景: CMS收集器的老年代回收用的就是这个算法

二、标记-复制算法(Mark-Copy)------ 新生代常用

原理:

复制代码
1. 把内存分成两块相等的区域(From区和To区)
2. 每次只用其中一块(From区)
3. GC时,把From区中存活的对象复制到To区
4. 清空From区
5. 交换From和To的角色

示意图:

复制代码
From区:[对象A][垃圾][对象B][垃圾][对象C][垃圾]
         ↓复制存活对象
To区:  [对象A][对象B][对象C][空闲................]
         ↓清空From区,交换角色
新From: [对象A][对象B][对象C][空闲................]
新To:   [空闲...................................]

优点:

  • 没有内存碎片!对象都是连续存放的
  • 效率高:只需要遍历存活对象,不用管垃圾对象

缺点:

  • 浪费空间:只能使用一半内存
  • 如果存活对象很多,复制开销大

实际应用(新生代):

新生代对象98%都是"朝生夕死"的,所以复制算法很适合。HotSpot虚拟机的新生代使用了改进版:

复制代码
新生代 = Eden区(80%) + Survivor0区(10%) + Survivor1区(10%)

1. 平时使用Eden + Survivor0(From)
2. GC时,把Eden和From中存活的对象复制到Survivor1(To)
3. 清空Eden和From
4. 下次GC时,To和From角色互换

这样只浪费10%的空间,不是50%!

三、标记-整理算法(Mark-Compact)------ 老年代常用

原理:

复制代码
第一步:标记(Mark)
    从GC Roots开始遍历,标记所有可达对象

第二步:整理(Compact)
    把所有存活对象向内存一端移动
    然后清理掉边界以外的内存

示意图:

复制代码
标记前:[对象A][垃圾][对象B][垃圾][垃圾][对象C][垃圾]
        ↓标记
标记后:[✓A][垃圾][✓B][垃圾][垃圾][✓C][垃圾]
        ↓整理(压缩)
整理后:[✓A][✓B][✓C][空闲........................]

优点:

  • 没有内存碎片
  • 不浪费空间(相比复制算法)

缺点:

  • 移动对象的开销大:需要更新所有指向这些对象的引用
  • 效率不如复制算法

适用场景: 老年代(对象存活率高,用复制算法不划算)

四、分代收集算法(Generational Collection)------ 现代JVM的标准

原理: 根据对象存活周期的不同,将堆分为新生代和老年代,针对不同代使用不同的算法。

复制代码
新生代:对象朝生夕死
    → 用复制算法(效率高,碎片少)
    
老年代:对象存活时间长
    → 用标记-清除 或 标记-整理(节省空间)

新生代GC(Minor GC)流程:

java 复制代码
// 对象分配流程
1. 对象优先在Eden区分配
2. Eden区满了触发Minor GC
3. 存活对象复制到Survivor区(From → To)
4. 对象在Survivor区每熬过一次GC,年龄+1
5. 年龄达到15(默认),晋升到老年代

为什么是15? 对象头中存储GC年龄的字段只有4bit,最大值就是15。

大对象直接进老年代:

java 复制代码
// JVM参数
-XX:PretenureSizeThreshold=1048576  // 大于1MB的对象直接进老年代

// 原因:避免大对象在新生代来回复制,开销大

五、4种算法对比表:

算法 优点 缺点 适用场景
标记-清除 简单,不移动对象 效率低,产生碎片 CMS老年代
标记-复制 高效,无碎片 浪费空间,移动对象开销大 新生代
标记-整理 无碎片,不浪费空间 移动对象开销大 老年代
分代收集 针对性强,综合前三者优点 实现复杂 现代JVM标准

六、实际项目经验:

"我们项目的JVM堆配置是-Xms2g -Xmx2g,新生代1.5g,老年代512m。新生代用的是复制算法(ParNew收集器),老年代用的是标记-整理(Parallel Old收集器)。之前遇到过老年代碎片化严重导致大对象分配失败,触发Full GC,后来调整了对象大小,减少了大对象的产生。"

💡 记忆口诀:

  • 标清简单但碎片
  • 标复高效占空间
  • 标整适合老年代
  • 分代组合最常见

1.4 说一下常见的垃圾收集器?G1和CMS的区别是什么?

✅ 正确回答思路:

垃圾收集器是垃圾回收算法的具体实现,我按新生代、老年代、全堆收集器分类来说,重点讲CMS和G1。

一、垃圾收集器分类(按代):

复制代码
新生代收集器:
├── Serial(串行)
├── ParNew(并行)
└── Parallel Scavenge(并行,吞吐量优先)

老年代收集器:
├── Serial Old(串行)
├── Parallel Old(并行)
└── CMS(并发标记清除)

全堆收集器:
├── G1(Garbage First)------ JDK 9默认
└── ZGC、Shenandoah(低延迟)

二、新生代收集器(简单过):

Serial收集器

  • 最古老,单线程
  • GC时必须暂停所有工作线程(STW)
  • 适合Client模式的应用(单核CPU、几十MB堆)

ParNew收集器

  • Serial的多线程版本
  • 常和CMS配合使用(CMS收集老年代,ParNew收集新生代)
  • 适合多核CPU

Parallel Scavenge收集器

  • 关注吞吐量(运行用户代码时间 / (运行用户代码时间 + GC时间))
  • 适合后台运算任务
  • 参数:-XX:MaxGCPauseMillis(最大GC停顿时间),-XX:GCTimeRatio(吞吐量大小)

三、老年代收集器(重点):

Serial Old收集器

  • Serial的老年代版本
  • 单线程,标记-整理算法
  • 作为CMS的后备方案

Parallel Old收集器

  • Parallel Scavenge的老年代版本
  • 多线程,标记-整理算法
  • 和Parallel Scavenge配合,追求吞吐量

CMS收集器(Concurrent Mark Sweep)------ 面试重点!

目标 :获取最短回收停顿时间(低延迟)

算法:标记-清除

4个步骤

复制代码
1. 初始标记(Initial Mark)------ STW,但很快
   只标记GC Roots能直接关联的对象

2. 并发标记(Concurrent Mark)------ 和用户线程并发
   从GC Roots开始遍历整个对象图,耗时长但不停顿

3. 重新标记(Remark)------ STW,但比初始标记稍长
   修正并发标记期间用户线程继续运行导致的标记变动

4. 并发清除(Concurrent Sweep)------ 和用户线程并发
   清除标记为垃圾的对象

CMS的优点

  • 并发收集:GC线程和用户线程可以同时工作
  • 低停顿:只有初始标记和重新标记需要STW,停顿时间短

CMS的缺点(面试爱问!):

  1. 对CPU资源敏感 :并发阶段会占用一部分CPU,导致应用程序变慢
    • 默认开启的GC线程数:(CPU核心数 + 3) / 4
    • 比如4核CPU,GC线程占1个核心(25%),用户线程只有75%
  2. 无法处理"浮动垃圾"
    • 并发清除阶段,用户线程还在运行,会产生新的垃圾
    • 这些垃圾在本次GC无法清理,只能等下次
    • 所以CMS不能等老年代满了再GC,要提前触发
  3. 产生内存碎片 (最大问题!):
    • 用的是标记-清除算法,会产生大量碎片
    • 碎片太多可能导致大对象无法分配,触发Full GC
    • 解决:-XX:+UseCMSCompactAtFullCollection(Full GC时整理碎片)

四、G1收集器(Garbage First)------ JDK 9默认,面试超高频!

目标 :在可控停顿时间 内获得高吞吐量

核心思想 :把堆划分成多个大小相等的Region(默认2048个),每个Region可以是Eden、Survivor、Old、Humongous(存放大对象)。

G1和CMS的核心区别(必答!):

对比项 CMS G1
内存布局 传统分代(新生代、老年代连续) Region分区(逻辑分代)
算法 标记-清除(产生碎片) 标记-整理(Region间是复制,无碎片)
停顿时间 不可控 可控(-XX:MaxGCPauseMillis=200)
垃圾优先回收 不支持 支持(优先回收垃圾多的Region)
大对象处理 容易触发Full GC Humongous Region专门存储
适用场景 追求低延迟,老年代6GB以下 追求可控停顿,大堆(6GB以上)

G1的回收步骤

复制代码
1. 初始标记(Initial Mark)------ STW
   标记GC Roots直接关联的对象
   
2. 并发标记(Concurrent Mark)
   遍历对象图,标记存活对象
   
3. 最终标记(Final Mark)------ STW
   处理并发标记阶段遗留的SATB(Snapshot At The Beginning)记录
   
4. 筛选回收(Live Data Counting and Evacuation)------ STW
   根据用户期望的停顿时间,选择垃圾最多的若干Region进行回收(复制算法)

G1的优点

  1. 可预测的停顿时间:可以指定期望的GC停顿时间,G1会尽量满足
  2. 不会产生内存碎片:Region间用复制算法,整理内存
  3. 并发与并行:利用多核CPU优势
  4. 适合大堆:堆内存大于6GB时,G1比CMS更有优势

G1的缺点

  1. 内存占用高:需要额外的内存来记录Region的引用关系(Remembered Set)
  2. 额外的执行开销:维护Remembered Set需要额外的CPU时间

五、如何选择垃圾收集器?

复制代码
堆内存 < 100MB:
    → Serial + Serial Old(单核Client模式)

堆内存 < 4GB,追求低延迟:
    → ParNew + CMS

堆内存 > 6GB,追求吞吐量:
    → Parallel Scavenge + Parallel Old

堆内存 > 6GB,追求可控停顿:
    → G1(推荐!)

极致低延迟(毫秒级停顿):
    → ZGC 或 Shenandoah(JDK 11+)

六、实际项目经验:

"我们的项目堆内存8GB,一开始用的是ParNew + CMS,但是老年代经常碎片化,触发Full GC导致接口超时。后来换成了G1,设置-XX:MaxGCPauseMillis=200,GC停顿时间基本控制在200ms以内,Full GC频率大大降低,系统稳定性提升了。"

具体配置:

bash 复制代码
# CMS配置
java -Xms4g -Xmx4g -Xmn3g 
     -XX:+UseConcMarkSweepGC 
     -XX:+UseCMSCompactAtFullCollection 
     -XX:CMSFullGCsBeforeCompaction=5 
     -jar app.jar

# G1配置(推荐)
java -Xms8g -Xmx8g 
     -XX:+UseG1GC 
     -XX:MaxGCPauseMillis=200 
     -XX:G1HeapRegionSize=16m 
     -jar app.jar

💡 总结:

  • CMS追求低延迟,但有碎片问题
  • G1是分代收集器的集大成者,适合大堆,停顿可控
  • JDK 9+推荐用G1,JDK 11+追求极致低延迟可以用ZGC

1.5 什么是类加载?类加载的过程是什么?

✅ 正确回答思路:

类加载是JVM把.class文件加载到内存并生成Class对象的过程。我从类加载的时机、完整流程、双亲委派机制三个方面来答。

一、什么时候会触发类加载?

有6种情况会触发类的加载(主动引用):

java 复制代码
1. 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时
   对应场景:
   - new 创建对象
   - 读取或设置静态字段(被final修饰的常量除外)
   - 调用静态方法
   
2. 使用反射调用类时
   Class.forName("com.example.User");
   
3. 初始化一个类时,发现父类还没初始化,先初始化父类
   
4. JVM启动时,会先加载包含main()方法的主类
   
5. 使用JDK 7的动态语言支持时(MethodHandle)

6. 接口中定义了default方法,实现类初始化时要初始化接口

被动引用不会触发类加载(面试爱考陷阱!):

java 复制代码
// 1. 通过子类引用父类的静态字段,不会导致子类初始化
System.out.println(SubClass.value);  // value是父类的静态字段,只会初始化父类

// 2. 通过数组定义来引用类,不会触发类初始化
SuperClass[] arr = new SuperClass[10];  // 不会初始化SuperClass

// 3. 常量在编译期会存入调用类的常量池,不会初始化定义常量的类
System.out.println(ConstClass.CONST);  // CONST是final static的,不会初始化ConstClass

二、类加载的完整流程(7个阶段):

复制代码
1. 加载(Loading)
   ↓
2. 验证(Verification)
   ↓
3. 准备(Preparation)      这三个阶段统称为"链接"
   ↓
4. 解析(Resolution)
   ↓
5. 初始化(Initialization)
   ↓
6. 使用(Using)
   ↓
7. 卸载(Unloading)

详细说每个阶段:

阶段1:加载(Loading)

JVM要做3件事:

复制代码
1. 通过类的全限定名获取定义此类的二进制字节流
   - 可以从.class文件读取
   - 也可以从ZIP包(JAR/WAR)、网络、数据库、动态代理生成

2. 将字节流代表的静态存储结构转化为方法区的运行时数据结构

3. 在堆中生成一个代表这个类的java.lang.Class对象
   作为方法区这个类的各种数据的访问入口

阶段2:验证(Verification)

确保Class文件的字节流符合JVM规范,不会危害JVM安全。分4步:

复制代码
1. 文件格式验证
   - 是否以魔数0xCAFEBABE开头?
   - 主次版本号是否在当前JVM处理范围内?
   - 常量池的常量是否有不被支持的类型?

2. 元数据验证
   - 这个类是否有父类(除了Object,所有类都应该有父类)?
   - 这个类的父类是否继承了不允许被继承的类(被final修饰的类)?
   - 如果这个类不是抽象类,是否实现了父类或接口中要求实现的所有方法?

3. 字节码验证(最复杂)
   - 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
   - 保证跳转指令不会跳转到方法体以外的字节码指令上

4. 符号引用验证
   - 符号引用中通过字符串描述的全限定名能否找到对应的类?
   - 符号引用中的类、字段、方法的访问性(private/public等)是否可被当前类访问?

阶段3:准备(Preparation)

为类的静态变量 分配内存,并设置默认初始值(零值)。

java 复制代码
public class Test {
    public static int value = 123;  // 准备阶段:value = 0,初始化阶段:value = 123
    public static final int CONST = 456;  // 准备阶段:CONST = 456(因为是final,编译期已确定)
}

注意

  • 实例变量不会在这个阶段分配内存,它们会在对象实例化时随着对象一起分配在堆中
  • 这里的初始值是数据类型的零值(0、null、false),不是代码中设置的初始值

阶段4:解析(Resolution)

将常量池内的符号引用 替换为直接引用

复制代码
符号引用:用一组符号来描述引用的目标,比如"com/example/User"
直接引用:直接指向目标的指针、相对偏移量或能间接定位到目标的句柄

解析动作主要针对:
- 类或接口的解析
- 字段解析
- 方法解析
- 接口方法解析

阶段5:初始化(Initialization)

执行类构造器<clinit>()方法。

<clinit>()方法是编译器自动收集类中所有类变量的赋值动作静态代码块中的语句合并产生的。

java 复制代码
public class Test {
    static {
        System.out.println("静态代码块");
        i = 0;  // 赋值可以,但在静态块之前,不能访问
    }
    
    public static int i = 1;  // 类变量赋值
    
    // 编译后生成的<clinit>()方法相当于:
    // static void <clinit>() {
    //     System.out.println("静态代码块");
    //     i = 0;
    //     i = 1;  // 最终i=1
    // }
}

()的特点

  1. 父类的<clinit>()先于子类执行
  2. <clinit>()线程安全,JVM保证只有一个线程执行
  3. 接口也有<clinit>(),但不需要先执行父接口的
  4. 如果类没有静态变量和静态代码块,可以不生成<clinit>()

三、双亲委派模型(面试必考!)

什么是类加载器?

类加载器负责"加载"阶段的第一步:通过类的全限定名获取二进制字节流。

类加载器的分类

复制代码
Bootstrap ClassLoader(启动类加载器)
    ↑ 父加载器
Extension ClassLoader(扩展类加载器)
    ↑ 父加载器
Application ClassLoader(应用程序类加载器)
    ↑ 父加载器
Custom ClassLoader(自定义类加载器)

1. Bootstrap ClassLoader(启动类加载器)

  • C++实现,是JVM的一部分
  • 加载<JAVA_HOME>/lib目录下的核心类库,比如rt.jar(java.lang.*等)
  • 无法被Java程序直接引用

2. Extension ClassLoader(扩展类加载器)

  • Java实现,sun.misc.Launcher$ExtClassLoader
  • 加载<JAVA_HOME>/lib/ext目录下的类库
  • 开发者可以直接使用

3. Application ClassLoader(应用程序类加载器)

  • Java实现,sun.misc.Launcher$AppClassLoader
  • 加载用户类路径(ClassPath)上的类库
  • 如果没有自定义类加载器,这就是程序默认的类加载器

双亲委派模型(Parent Delegation Model)

工作流程

复制代码
1. 类加载器收到类加载请求
2. 不会自己先加载,而是委派给父加载器
3. 父加载器也往上委派,直到启动类加载器
4. 父加载器无法完成加载(找不到类),子加载器才会尝试自己加载

代码示例

java 复制代码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查类是否已经被加载
        Class<?> c = findLoadedClass(name);
        
        if (c == null) {
            try {
                if (parent != null) {
                    // 2. 委派给父加载器
                    c = parent.loadClass(name, false);
                } else {
                    // 3. 父加载器为null,说明是Bootstrap ClassLoader
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器无法加载
            }
            
            if (c == null) {
                // 4. 父加载器无法加载,自己加载
                c = findClass(name);
            }
        }
        
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

双亲委派模型的好处

  1. 避免类的重复加载:父加载器已经加载的类,子加载器不会再加载一次

  2. 保证Java核心API不被篡改

    java 复制代码
    // 即使你自己写一个java.lang.String
    // 也会被委派给Bootstrap ClassLoader加载
    // Bootstrap只加载rt.jar里的String
    // 你的String永远不会被加载,保证了核心类库的安全

如何打破双亲委派?(面试加分题)

有些场景需要打破双亲委派:

  1. JNDI、JDBC
    • 启动类加载器加载的SPI接口需要加载用户代码(驱动),但启动类加载器无法访问用户代码
    • 解决:引入线程上下文类加载器(Thread Context ClassLoader)
  2. Tomcat
    • 同一个JVM中部署多个应用,每个应用有自己的依赖库(可能版本不同)
    • 解决:Tomcat自定义类加载器,先在Web应用目录下加载类,再委派给父加载器
  3. OSGi(热部署)
    • 需要动态加载和卸载模块
    • 解决:自定义类加载器,完全自己控制加载逻辑

四、实际项目经验:

"我在使用JDBC时就见过双亲委派的打破。DriverManager类在rt.jar中,由Bootstrap ClassLoader加载,但MySQL驱动jar包在用户类路径,Bootstrap加载不到。所以DriverManager通过线程上下文类加载器(Application ClassLoader)去加载MySQL驱动,这就打破了双亲委派。"

💡 总结:

  • 类加载分7个阶段:加载、验证、准备、解析、初始化、使用、卸载
  • 双亲委派模型保证了类的唯一性和安全性
  • JDBC、Tomcat等场景会打破双亲委派
相关推荐
快乐zbc2 小时前
苍穹外卖 - 菜品起售/停售复习笔记
java·笔记
Cosmoshhhyyy2 小时前
《Effective Java》解读第41条:用标记接口定义类型
java·开发语言
Zzz 小生2 小时前
LangChain Tools:工具使用完全指南
jvm·数据库·oracle
Anastasiozzzz3 小时前
深入浅出:理解控制反转 (IoC) 与 Spring 的核心实现
java·后端·spring
前路不黑暗@3 小时前
Java项目:Java脚手架项目的 B 端用户服务(十四)
android·java·开发语言·spring boot·笔记·学习·spring cloud
亓才孓3 小时前
[SpringBoot]UnableToConnectException : Public Key Retrieval is not allowed
java·数据库·spring boot
嵌入式×边缘AI:打怪升级日志3 小时前
编写Bootloader实现下载功能
java·前端·网络
wuqingshun3141593 小时前
什么是浅拷贝,什么是深拷贝,如何实现深拷贝?
java·开发语言·jvm
Stringzhua4 小时前
队列-优先队列【Queue3】
java·数据结构·队列