一、根本矛盾:编译期的"模板" vs 运行期的"实体"
写完 User.java 编译成 User.class,这个 .class 文件到底是什么?
.class文件是类的"设计图纸"——静态地躺在磁盘上,记录了类的结构(字段、方法、父类、接口),但还不是"活的"东西。
JVM 要用这个类,必须经历两个阶段:
磁盘上的 User.class(图纸)
│
│ 第一阶段:类加载
↓
JVM 内存里的 Class<User> 对象(活模板)
│
│ 第二阶段:类实例化
↓
JVM 堆里的 User 实例(活对象)
两个阶段的本质区别:
| 阶段 | 输入 | 输出 | 频率 |
|---|---|---|---|
| 类加载 | .class 文件(磁盘字节) | Class<User> 对象(内存元数据) | 一个类只加载一次 |
| 类实例化 | Class<User> 对象(模板) | new User() 实例(堆上对象) | 可以 new 无数次 |
类加载是"把图纸读进内存变成活模板",类实例化是"根据模板批量生产对象"。 模板只读一次,对象可以造无数个。
这正是 Java 区别于 C/C++ 编译型语言的核心:
- C/C++ 编译时就把类的代码静态链接进可执行文件
- Java 编译时只生成 .class 图纸,运行时才按需加载——这就是"动态加载"的本质
二、类加载的触发时机:主动使用 vs 被动使用
JVM 不会把所有 .class 都一次性加载,而是用到才加载(延迟加载,Lazy Loading)。
但"用到"这个词很模糊,JVM 规范明确规定了 6 种主动使用场景,只有这些才会触发类加载(更准确地说是触发"初始化"阶段):
new创建对象实例- 读写类的静态字段(非 final 的)
- 调用类的静态方法
- 反射调用(
Class.forName) - 初始化子类时,父类如果还没初始化,先初始化父类
- JVM 启动时的主类(含
main方法的类)
被动使用不会触发类加载,几个反直觉的例子:
// 例1:通过子类访问父类的静态字段,只加载父类,不加载子类
class Parent { static int value = 42; }
class Child extends Parent {}
System.out.println(Child.value); // 只触发 Parent 的加载,Child 不会被加载
// 例2:数组定义不触发类加载
Parent[] arr = new Parent[10]; // Parent 类不会被加载,只是创建了数组对象
// 例3:final 常量在编译期已知,直接内联,不触发加载
class Constants { static final int MAX = 100; }
System.out.println(Constants.MAX); // 编译期 MAX=100 就内联进字节码了,运行时不加载 Constants
判断标准:这段代码运行时,是否真的需要类的"运行时信息"?需要才加载,编译期能确定的(如 final 常量)就不加载。
三、类加载的完整过程:五个阶段
类加载是一个流水线,从 .class 到 Class 对象,分五个阶段:
.class 文件
│
1. 加载(Loading)─── 通过类加载器把字节码读入内存,生成 Class 对象
│
2. 验证(Verification)─── 检查字节码格式、元数据、字节码、符号引用是否合法
│
3. 准备(Preparation)─── 为静态字段分配内存并赋"零值"(不是代码里写的初值)
│
4. 解析(Resolution)─── 把常量池里的符号引用替换成直接引用(指针/偏移量)
│
5. 初始化(Initialization)─── 执行类的 <clinit> 方法(静态变量赋真实值 + static 块)
│
↓
Class<User> 对象,可以被实例化
3.1 加载(Loading)
通过类加载器把 .class 文件的字节流读入内存,转换成方法区的运行时数据结构,并在堆里生成一个 Class<User> 对象,作为方法区数据的访问入口。
3.2 验证(Verification)
确保字节码安全合法。检查四类东西:
- 文件格式(魔数 0xCAFEBABE、版本号)
- 元数据(语义合法,如是否有父类)
- 字节码(方法体逻辑合法,如不跳转到方法外)
- 符号引用(引用的类、字段、方法真实存在)
3.3 准备(Preparation)—— 关键陷阱
为静态字段分配内存,赋零值,不是代码里写的初值:
public class Config {
static int count = 42; // 准备阶段:count = 0(int 零值)
static String name = "default"; // 准备阶段:name = null(引用零值)
static final int MAX = 100; // 准备阶段:MAX = 100(final 常量直接赋值,不等初始化)
}
准备阶段赋零值,初始化阶段才赋真实值。 这是理解"静态变量为什么会有中间 null 状态"的关键。
3.4 解析(Resolution)
把常量池里的符号引用(一个字符串,如 "java/lang/String")替换成直接引用(内存地址/偏移量)。
可以发生在初始化之前,也可以延迟到第一次使用该符号时(延迟解析)。
3.5 初始化(Initialization)—— 真正执行代码
执行类的 <clinit> 方法。<clinit> 是编译器自动生成的,内容是:
- 静态变量的赋值语句(count = 42)
- 静态代码块(static { ... })
按源码顺序执行。JVM 保证 <clinit> 在多线程下被正确加锁同步——这也是单例模式"静态内部类实现"线程安全的原理:
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton(); // 在 Holder 类的 <clinit> 里执行
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 触发 Holder 类的初始化,JVM 保证 <clinit> 线程安全
}
}
<clinit>的线程安全是 JVM 规范保证的,不需要开发者加锁。 这是比 synchronized DCL 更优雅的单例实现。
四、类加载器与双亲委派模型
4.1 谁来加载类——类加载器(ClassLoader)
JVM 内置三套类加载器,形成层级:
启动类加载器(Bootstrap ClassLoader)
│ 加载:$JAVA_HOME/lib 下的核心类(java.lang.*、java.util.*)
│ 实现:C++ 写的,JVM 的一部分,Java 里拿不到引用(getClassLoader() 返回 null)
↓
扩展类加载器(Extension ClassLoader) JDK 9+ 改名:平台类加载器(Platform ClassLoader)
│ 加载:$JAVA_HOME/lib/ext 下的扩展类
│ 实现:Java 写的(ExtClassLoader)
↓
应用程序类加载器(Application ClassLoader)
│ 加载:classpath 下的类(业务代码、第三方库)
│ 实现:Java 写的(AppClassLoader),默认的类加载器
↓
自定义类加载器
加载:网络、加密文件、热部署目录等特殊来源
4.2 双亲委派模型:为什么先问爸爸
类加载器收到加载请求时,不会自己先加载,而是先委托给父加载器。父加载器再向上委托,直到顶层。只有父加载器说"我加载不了",才自己尝试加载。
加载 com.example.User 的请求
AppClassLoader 收到
│ "问我爸"
↓
ExtClassLoader 收到
│ "问我爸"
↓
BootstrapClassLoader 收到
│ "java.lang 我管,com.example 不管"
↓ 返回 null(找不到)
ExtClassLoader 自己找
│ "ext 目录里没有"
↓ 返回 null
AppClassLoader 自己找
│ "classpath 里有"
↓
找到,加载成功
为什么要这样设计?两个根本原因:
- 安全:防止核心类被伪造。即使自己写一个
java.lang.String,也会被 Bootstrap 抢先加载——伪造的核心类根本没机会运行。 - 避免重复加载:同一个类只会被加载一次(同一个 ClassLoader + 同一个类名 = 同一个 Class 对象)。如果每个加载器都自己加载,一个类可能被加载多份,类型系统崩溃(
instanceof都不知道信哪个)。
4.3 打破双亲委派的场景
双亲委派是建议模型,不是强制规则。以下场景会主动打破:
| 场景 | 怎么打破 | 原因 |
|---|---|---|
| Tomcat | 每个 webapp 独立的 WebAppClassLoader,先自己找再问父 | 隔离不同应用的类版本(A 应用用 Spring 4,B 应用用 Spring 5) |
| JDBC SPI | 用线程上下文类加载器(TCCL)反向加载 | Bootstrap 加载 java.sql.Driver 接口,但实现在 classpath 的 mysql-connector 里,父加载器看不到子加载器的类 |
| OSGi | 网状类加载器,每个 bundle 独立 | 模块化 + 热部署 |
| 热部署 | 自定义 ClassLoader,加载新版类后丢弃旧 ClassLoader | 旧 ClassLoader 卸载后旧类才能被 GC |
Spring Framework 在应用容器启动后加载其 Bean 定义、完成实例化与依赖注入的完整流程,见 Spring Framework 核心体系。
Tomcat 的 Web 容器职责、类加载机制与其他 Web 容器的对比,见 Java Web 技术体系。
五、类实例化:从 Class 对象到 new 出来
类加载完,堆里有了 Class<User> 活模板,接下来就能 new User() 实例化对象。
5.1 new 关键字背后做了什么
User user = new User("张三", 28);
这一行代码,JVM 做了 5 件事:
- 类加载检查:检查
User类是否已加载、初始化。没有则先触发类加载(走前面的五阶段) - 分配内存:在堆上为新对象分配内存。大小在类加载后就能确定(每个字段的类型固定)
- 初始化零值:把分配的内存空间清零(不是赋默认值,是物理清零),保证字段在不被显式赋值时也能用零值(int=0、引用=null)
- 设置对象头:设置 Mark Word(hash、GC 分代年龄、锁状态)和类指针(指向
Class<User>) - 执行构造方法
<init>:执行字段赋值 + 构造方法体
前 4 步是 JVM 的"机械动作",第 5 步才是业务代码真正执行的地方。
5.2 内存分配的两种方式
| 方式 | 原理 | 适用 |
|---|---|---|
| 指针碰撞 | 内存规整,指针向空闲方向移动对象大小即可 | Serial / ParNew 收集器 + 带压缩的 GC |
| 空闲列表 | 内存碎片化,维护一个空闲块列表,找够大的块 | CMS 收集器(不压缩) |
并发下的线程安全:分配内存是临界区,JVM 用两种方案解决:
- CAS + 失败重试:所有线程竞争同一指针
- TLAB(Thread Local Allocation Buffer):每个线程在 Eden 区预分配一块私有内存,先在自己的 TLAB 里分配,用完了再 CAS 申请新 TLAB。JVM 默认开启 TLAB。
5.3 对象的内存布局
一个 Java 对象在堆里分三块:
┌─────────────────────────────────────┐
│ 对象头(Object Header) │
│ ├─ Mark Word(8 字节) │ hash、GC 年龄、锁状态、偏向线程 ID
│ └─ 类指针(4 字节,压缩后) │ 指向 Class<User>
├─────────────────────────────────────┤
│ 实例数据(Instance Data) │ 各字段的实际值
│ name = "张三" │
│ age = 28 │
├─────────────────────────────────────┤
│ 对齐填充(Padding) │ 凑成 8 字节整数倍
└─────────────────────────────────────┘
64 位 JVM 上,对象头默认 12 字节(8 字节 Mark Word + 4 字节压缩类指针),最小对象也至少 16 字节(12 + 4 对齐)。空对象
new Object()占 16 字节就是这个原因。
5.4 构造方法的执行顺序
类的实例化涉及多级构造方法调用,顺序是固定的:
class Animal {
String name = "Animal"; // 1. 父类字段赋值
Animal() { // 2. 父类构造方法
System.out.println("Animal 构造");
}
}
class Dog extends Animal {
String name = "Dog"; // 3. 子类字段赋值
Dog() { // 4. 子类构造方法
// 隐式 super() 在这里调用
System.out.println("Dog 构造");
}
}
new Dog();
执行顺序:
1. 分配内存 + 清零 + 设对象头
2. Dog 构造方法开始,先隐式 super() 调用 Animal 构造
3. Animal 构造方法开始,先隐式 super() 调用 Object 构造
4. Object 构造方法返回
5. Animal 字段赋值:name = "Animal"
6. Animal 构造方法体执行:打印 "Animal 构造"
7. Animal 构造方法返回
8. Dog 字段赋值:name = "Dog"
9. Dog 构造方法体执行:打印 "Dog 构造"
10. Dog 构造方法返回,对象创建完成
记忆口诀:父类先于子类、字段赋值先于构造方法体、super() 在构造方法第一行。
为什么字段赋值要在 super() 之后、构造方法体之前? 因为字段赋值可能依赖父类已经初始化好的状态。如果字段赋值跑在 super() 之前,父类还没构造完,子类字段就用不了父类的状态——会有空指针或非法值。
六、字节码怎么被执行:解释器 + JIT
类加载完成后,方法体存在方法区里是一串字节码指令(如 iload_1、invokevirtual)。但 CPU 只认机器码,不认字节码。中间这道鸿沟,靠 JVM 执行引擎来填。
6.1 根本矛盾:跨平台 vs 性能
JVM 面对一个两难选择:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 解释执行:逐条读字节码,翻译成机器码执行 | 启动快、内存占用小、跨平台 | 每次都要翻译,慢 |
| 编译执行:把字节码一次性编译成机器码 | 运行快 | 编译耗时长、启动慢、占内存 |
C/C++ 走的是提前编译(AOT):源码直接编译成机器码,运行时不用翻译。快但失去跨平台。
早期 JVM 走的是纯解释执行:跨平台但慢。
现代 JVM 走的是混合模式:先解释执行,跑得多了再 JIT 编译成机器码。
6.2 混合模式:解释器 + JIT 协作
HotSpot JVM(Oracle JDK / OpenJDK 默认的 JVM)采用混合模式:
方法第一次被调用
│
↓
解释器逐条翻译字节码执行(快启动,慢运行)
│
│ 方法被频繁调用,达到"热点"阈值
↓
JIT 编译器介入
│
├─ Client Compiler(C1):快速编译,简单优化
└─ Server Compiler(C2):慢速编译,深度优化
│
↓
方法被编译成本地机器码,缓存到 CodeCache
│
↓
后续调用直接执行机器码(不再解释)
核心思想:80/20 法则——程序 80% 的时间在跑 20% 的代码(热点)。只对热点编译,冷代码继续解释,兼顾启动速度和运行性能。
6.3 热点探测:怎么判断该编译
JVM 用方法调用计数器和回边计数器(for/while 循环次数)跟踪代码热度。
方法调用计数器:
每次方法被调用,计数器 +1
超过阈值(默认 C2 为 10000,C1 为 1500)→ 触发 JIT 编译
一段时间没达到阈值 → 计数器衰减(防止冷方法长期占用编译资源)
回边计数器:
每次循环回边(for/while 一次循环),计数器 +1
超过阈值 → 触发 OSR(On-Stack Replacement,栈上替换)编译
OSR 的意义:一个长循环跑到一半变热了,JIT 编译完机器码后,在不退出方法的前提下,把当前栈帧从解释模式切换到编译模式继续执行。否则得等循环结束、方法返回、下次调用才能用上编译版本——长循环就享受不到优化了。
6.4 分层编译(Tiered Compilation,JDK 8 默认开启)
JVM 有两个 JIT 编译器,分层编译把它们组合起来:
| 层级 | 编译器 | 特点 |
|---|---|---|
| 第 0 层 | 解释器 | 全程解释,收集 profiling 数据(哪个分支走得多、类型是什么) |
| 第 1 层 | C1(Client Compiler) | 快速编译,简单优化,插入 profiling 探针 |
| 第 2 层 | C1 | 中度优化 |
| 第 3 层 | C1 | 完整 C1 优化 |
| 第 4 层 | C2(Server Compiler) | 深度优化,基于前面收集的 profiling 做激进优化 |
解释执行 ──(热)──→ C1 快速编译(带 profiling)──(更热)──→ C2 深度优化
分层编译的妙处:先用 C1 快速生成还能用的机器码(比纯解释快),同时收集运行数据;等数据够了再让 C2 做深度优化。比"要么不编译、要么直接 C2"启动快得多。
七、JIT 的核心优化手段
JIT 不是简单地把字节码翻成机器码,它会基于运行时 profiling 做激进优化——只要运行时假设不破,优化就生效;假设破了就逆优化(Deoptimization)退回到解释执行。
7.1 方法内联(Inlining)——最重要的优化
把被调用方法的代码直接复制到调用处,消除方法调用开销:
// 原始代码
public int add(int a, int b) { return a + b; }
public int calc() { int x = 1; int y = 2; return add(x, y); }
// JIT 内联后
public int calc() { int x = 1; int y = 2; return x + y; } // add 的方法体被内联进来
为什么内联最重要:
- 消除方法调用开销(压栈、跳转、传参)
- 内联后多个方法的代码连在一起,给后续优化(死代码消除、常量折叠)更大空间
- 是其他优化的前提——不内联,很多优化跨方法做不了
内联的决策依据:方法大小(默认不超过 35 字节码的算"小方法")、调用频率、是否被 final/private 修饰(这类方法肯定可以内联,没有多态)。
这就是为什么提倡"方法小而多"——小方法更容易被内联。 把大方法拆小,每个小方法都可能被内联,整体性能反而更好。
7.2 逃逸分析(Escape Analysis)
分析对象的动态作用域,判断它会不会"逃出"方法/线程:
| 逃逸程度 | 说明 | 优化 |
|---|---|---|
| 不逃逸 | 对象只在方法内用,方法返回前就死了 | 栈上分配(不在堆上 new,避免 GC) |
| 方法逃逸 | 对象被作为返回值或传给其他方法 | 可能标量替换 |
| 线程逃逸 | 对象被其他线程访问(赋值给静态字段、传给其他线程) | 不能优化,正常堆分配 |
标量替换:把一个对象拆成几个基本类型字段,分别放到栈上,对象本身不再存在:
// 原始代码
public int calc() {
Point p = new Point(1, 2); // Point 不逃逸
return p.x + p.y;
}
// 标量替换后
public int calc() {
int x = 1; // Point 被拆开
int y = 2;
return x + y;
}
这就是为什么"new 很多小对象不一定慢"——逃逸分析 + 标量替换后,这些对象根本不会进堆,不增加 GC 压力。
7.3 锁消除(Lock Elision)
基于逃逸分析,如果 synchronized 锁的对象不可能被其他线程访问,直接消除锁:
// StringBuffer 的 append 是 synchronized
public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // sb 不逃逸出方法
sb.append(s1);
sb.append(s2);
return sb.toString();
}
// JIT 锁消除后,sb.append 内部的 synchronized 被去掉
// 等同于用了 StringBuilder
7.4 循环展开(Loop Unrolling)
把循环体复制几次,减少循环判断次数:
// 原始
for (int i = 0; i < 1000; i++) { sum += arr[i]; }
// 循环展开后
for (int i = 0; i < 1000; i += 4) {
sum += arr[i];
sum += arr[i+1];
sum += arr[i+2];
sum += arr[i+3];
}
减少 75% 的循环判断和跳转开销。
7.5 多态内联缓存(Inline Cache)
虚方法调用(obj.method())要查虚方法表(vtable),慢。JIT 在调用点缓存"上次调用的实际方法",下次直接用:
| 缓存类型 | 说明 |
|---|---|
| 单态(Monomorphic) | 调用点 100% 调同一个方法,直接跳转,无虚分派开销 |
| 双态(Bimorphic) | 两个方法交替,缓存两个 |
| 多态(Megamorphic) | 三个以上方法,退化成查 vtable |
实际中 90% 的虚方法调用点是单态的——JIT 把它们优化得和直接调用一样快。
7.6 逆优化(Deoptimization)
JIT 的优化基于运行时假设,假设破了就逆优化:
// 假设:obj 一直是 ConcreteType,JIT 内联了 ConcreteType.method
obj.method();
// 突然来一个别的子类实例
obj = new AnotherType();
obj.method(); // 假设破了,JIT 把编译代码丢弃,退回解释执行
逆优化不是 bug,是正常机制——保证优化的代码永远和语义一致。
八、AOT 编译:JDK 9+ 的新选项
JIT 是运行时编译,AOT(Ahead-Of-Time)是运行前编译:
# JDK 9-17 的 jaotc 工具(实验性)
jaotc --output libmyapp.so --jar app.jar
java -XX:AOTLibrary=./libmyapp.so -jar app.jar
AOT 的优劣:
| 维度 | JIT | AOT |
|---|---|---|
| 启动速度 | 慢(要等热点探测 + 编译) | 快(提前编好了) |
| 峰值性能 | 高(基于运行时 profiling 深度优化) | 中(没有 profiling,优化保守) |
| 内存占用 | 高(CodeCache + profiling) | 低 |
| 跨平台 | 字节码跨平台,JIT 各平台各编译 | 必须为每个目标平台单独编译 |
AOT 适合短生命周期进程(CLI 工具、Serverless 函数),启动速度最重要。JIT 适合长生命周期服务(Web 服务、数据库),峰值性能最重要。
JDK 17 引入的 GraalVM Native Image 是 AOT 的工业级实现,把 Java 应用编译成原生可执行文件,启动毫秒级、内存占用极低。
九、观察 JIT 行为
# 打印 JIT 编译日志
java -XX:+PrintCompilation -jar app.jar
# 输出:每个方法被编译的时机、层级、是否内联
# 打印内联详情(需要 unlock)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -jar app.jar
# 查看 CodeCache 使用情况
jcmd 12345 Compiler.codecache
# JIT 编译统计
jcmd 12345 Compiler.statistics
jstat -compiler 12345
# 禁用 JIT(纯解释,调试用)
java -Xint -jar app.jar # 纯解释执行
java -Xcomp -jar app.jar # 纯编译执行(启动极慢,不推荐)
java -XX:TieredStopAtLevel=1 ... # 只用 C1,不用 C2
十、整合:从源码到执行的完整链路
把类加载、类实例化、JIT 串起来,是一条完整的链路:
User.java(源码)
│
│ javac 编译
↓
User.class(字节码,磁盘上的图纸)
│
│ 运行期,类加载五阶段
↓
Class<User> 对象(方法区里的活模板)
│
│ new User() 实例化
↓
堆里的 User 实例(对象头 + 实例数据)
│
│ 方法被调用
↓
方法字节码进解释器逐条执行
│
│ 方法变热(达到阈值)
↓
JIT 编译成本地机器码,缓存到 CodeCache
│
│ 后续调用直接执行机器码
↓
CPU 执行(带内联、逃逸分析、锁消除等优化)
十一、常见误解澄清
11.1 误解 1:"类加载就是执行 static 块"
不准确。类加载分五步,static 块只在最后一步——初始化阶段执行。前面的加载、验证、准备、解析都不执行业务代码。
11.2 误解 2:"new 的时候才触发类加载"
错误。new 只是 6 种主动使用之一。访问静态字段、调用静态方法、反射、初始化子类时父类未初始化、main 主类启动,都会触发类加载。
11.3 误解 3:"静态字段的初值就是代码里写的值"
错误。准备阶段静态字段先赋零值,初始化阶段才赋真实值。这意味着静态字段在两个阶段之间是"零值状态"——static int count = 42 在准备阶段是 0,初始化后才变 42。
11.4 误解 4:"类加载器加载类时自己先找"
错误。双亲委派模型下,先委托父加载器,父加载器找不到才自己找。这是为了安全和避免重复加载。
11.5 误解 5:"构造方法里 super() 可以不写"
错误。如果父类有无参构造方法,编译器会隐式插入 super()。但如果父类只有有参构造方法,必须显式写 super(args),否则编译报错。
11.6 误解 6:"类加载完成后就可以无限使用"
部分正确。类加载完成后确实可以无限次实例化。但类本身可以被卸载(GC),条件苛刻:
- 该类所有实例都已被 GC
- 加载该类的 ClassLoader 已被 GC
- 该类的 Class 对象没有任何引用
这意味着自定义 ClassLoader 是实现热部署的关键——丢弃旧 ClassLoader,旧类才能被卸载,新类才能重新加载。
11.7 误解 7:"Java 是解释执行,所以慢"
过时。现代 JVM 是混合模式,热点代码被 JIT 编译成本地机器码,性能接近 C/C++。SPECjbb 等基准测试中,Java 服务端性能和 C++ 差距通常在 10% 以内。
11.8 误解 8:"方法越小越好,方便 JIT 优化"
部分正确。小方法容易内联,但过度拆分(每个方法一两行)反而增加方法调用开销、降低可读性。合理大小是 5-30 行。
11.9 误解 9:"JIT 编译完就不会再变"
错误。JIT 会逆优化——运行时假设破了(如多态新增子类),编译代码会被丢弃退回解释,等下次再编译。
11.10 误解 10:"AOT 会取代 JIT"
不对。两者互补:
- AOT 启动快、内存低,适合短生命周期
- JIT 峰值性能高、自适应,适合长生命周期
- GraalVM 等新技术在探索两者结合,不是取代关系
十二、学习路径建议
| 阶段 | 内容 | 掌握标准 |
|---|---|---|
| 第一阶段:类加载基础 | 五阶段流程、主动使用 vs 被动使用 | 能判断某段代码是否触发类加载,能解释 static 块执行顺序 |
| 第二阶段:类加载器 | 三层类加载器、双亲委派、打破场景 | 能解释为什么自定义 java.lang.String 加载不了,Tomcat 为什么打破双亲委派 |
| 第三阶段:类实例化 | new 的五步、对象内存布局、构造方法顺序 | 能画出对象内存布局图,能分析多级继承的构造顺序 |
| 第四阶段:JIT 执行引擎 | 混合模式、热点探测、分层编译、五大优化 | 能解释为什么 Java 不慢、方法内联的价值、逃逸分析对性能的影响 |
| 第五阶段:诊断与调优 | -XX:+PrintCompilation、jcmd Compiler.*、GraalVM | 能观察 JIT 行为,能判断方法是否被内联,能用 AOT 优化启动速度 |
十三、总结
从源码到执行,Java 经历三层转换:
- 编译期:
javac把.java编译成.class字节码(跨平台的中介)- 类加载期:
.class被加载进 JVM,变成Class对象(活模板)- 执行期:方法字节码先由解释器逐条翻译,变热后由 JIT 编译成本地机器码缓存执行
类加载解决"静态图纸 → 活模板",类实例化解决"活模板 → 具体对象",JIT 解决"字节码 → 高效机器码"。
类加载的五阶段(加载/验证/准备/解析/初始化)有严格顺序,准备阶段赋零值、初始化阶段才赋真实值——这是理解静态变量中间状态的关键。双亲委派模型保证了核心类安全和类型唯一性,但 Tomcat、JDBC SPI、热部署等场景需要主动打破。
类实例化的五步(类加载检查/分配内存/清零/设对象头/执行
<init>)中,前四步是 JVM 机械动作,第五步才是业务代码执行。构造方法的固定顺序:父类先于子类、字段赋值先于构造体、super() 在第一行。JIT 的核心价值不是翻译,而是基于运行时 profiling 的激进优化:方法内联(最重要的优化)、逃逸分析(栈上分配 + 标量替换 + 锁消除)、循环展开、多态内联缓存。这些优化让 Java 接近 C/C++ 的性能,同时保留跨平台和动态加载的优势。
一句话记忆:javac 编译成字节码,类加载变活模板,解释器先跑,JIT 把热点编译成机器码——三层转换,性能靠 JIT 激进优化。