📌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) ,使用的是直接内存(本地内存)
-
为什么要改用元空间?
- 永久代大小固定,容易OOM(OutOfMemoryError: PermGen space)
- 元空间使用本地内存,大小可以动态扩展,更灵活
- 简化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方法服务
- 异常 :和虚拟机栈一样,
StackOverflowError或OutOfMemoryError
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的缺点(面试爱问!):
- 对CPU资源敏感 :并发阶段会占用一部分CPU,导致应用程序变慢
- 默认开启的GC线程数:(CPU核心数 + 3) / 4
- 比如4核CPU,GC线程占1个核心(25%),用户线程只有75%
- 无法处理"浮动垃圾" :
- 并发清除阶段,用户线程还在运行,会产生新的垃圾
- 这些垃圾在本次GC无法清理,只能等下次
- 所以CMS不能等老年代满了再GC,要提前触发
- 产生内存碎片 (最大问题!):
- 用的是标记-清除算法,会产生大量碎片
- 碎片太多可能导致大对象无法分配,触发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的优点:
- 可预测的停顿时间:可以指定期望的GC停顿时间,G1会尽量满足
- 不会产生内存碎片:Region间用复制算法,整理内存
- 并发与并行:利用多核CPU优势
- 适合大堆:堆内存大于6GB时,G1比CMS更有优势
G1的缺点:
- 内存占用高:需要额外的内存来记录Region的引用关系(Remembered Set)
- 额外的执行开销:维护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
// }
}
()的特点:
- 父类的
<clinit>()先于子类执行 <clinit>()线程安全,JVM保证只有一个线程执行- 接口也有
<clinit>(),但不需要先执行父接口的 - 如果类没有静态变量和静态代码块,可以不生成
<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;
}
}
双亲委派模型的好处:
-
避免类的重复加载:父加载器已经加载的类,子加载器不会再加载一次
-
保证Java核心API不被篡改:
java// 即使你自己写一个java.lang.String // 也会被委派给Bootstrap ClassLoader加载 // Bootstrap只加载rt.jar里的String // 你的String永远不会被加载,保证了核心类库的安全
如何打破双亲委派?(面试加分题)
有些场景需要打破双亲委派:
- JNDI、JDBC :
- 启动类加载器加载的SPI接口需要加载用户代码(驱动),但启动类加载器无法访问用户代码
- 解决:引入线程上下文类加载器(Thread Context ClassLoader)
- Tomcat :
- 同一个JVM中部署多个应用,每个应用有自己的依赖库(可能版本不同)
- 解决:Tomcat自定义类加载器,先在Web应用目录下加载类,再委派给父加载器
- OSGi(热部署) :
- 需要动态加载和卸载模块
- 解决:自定义类加载器,完全自己控制加载逻辑
四、实际项目经验:
"我在使用JDBC时就见过双亲委派的打破。DriverManager类在rt.jar中,由Bootstrap ClassLoader加载,但MySQL驱动jar包在用户类路径,Bootstrap加载不到。所以DriverManager通过线程上下文类加载器(Application ClassLoader)去加载MySQL驱动,这就打破了双亲委派。"
💡 总结:
- 类加载分7个阶段:加载、验证、准备、解析、初始化、使用、卸载
- 双亲委派模型保证了类的唯一性和安全性
- JDBC、Tomcat等场景会打破双亲委派