一、根本矛盾:编译期的"模板" 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 种主动使用场景,只有这些才会触发类加载(更准确地说是触发"初始化"阶段):

  1. new 创建对象实例
  2. 读写类的静态字段(非 final 的)
  3. 调用类的静态方法
  4. 反射调用(Class.forName
  5. 初始化子类时,父类如果还没初始化,先初始化父类
  6. 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 常量)就不加载。


三、类加载的完整过程:五个阶段

类加载是一个流水线,从 .classClass 对象,分五个阶段:

.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 里有"
    ↓
找到,加载成功

为什么要这样设计?两个根本原因:

  1. 安全:防止核心类被伪造。即使自己写一个 java.lang.String,也会被 Bootstrap 抢先加载——伪造的核心类根本没机会运行。
  2. 避免重复加载:同一个类只会被加载一次(同一个 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 件事:

  1. 类加载检查:检查 User 类是否已加载、初始化。没有则先触发类加载(走前面的五阶段)
  2. 分配内存:在堆上为新对象分配内存。大小在类加载后就能确定(每个字段的类型固定)
  3. 初始化零值:把分配的内存空间清零(不是赋默认值,是物理清零),保证字段在不被显式赋值时也能用零值(int=0、引用=null)
  4. 设置对象头:设置 Mark Word(hash、GC 分代年龄、锁状态)和类指针(指向 Class<User>
  5. 执行构造方法 <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_1invokevirtual)。但 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 经历三层转换:

  1. 编译期javac.java 编译成 .class 字节码(跨平台的中介)
  2. 类加载期.class 被加载进 JVM,变成 Class 对象(活模板)
  3. 执行期:方法字节码先由解释器逐条翻译,变热后由 JIT 编译成本地机器码缓存执行

类加载解决"静态图纸 → 活模板",类实例化解决"活模板 → 具体对象",JIT 解决"字节码 → 高效机器码"。

类加载的五阶段(加载/验证/准备/解析/初始化)有严格顺序,准备阶段赋零值、初始化阶段才赋真实值——这是理解静态变量中间状态的关键。双亲委派模型保证了核心类安全和类型唯一性,但 Tomcat、JDBC SPI、热部署等场景需要主动打破。

类实例化的五步(类加载检查/分配内存/清零/设对象头/执行 <init>)中,前四步是 JVM 机械动作,第五步才是业务代码执行。构造方法的固定顺序:父类先于子类、字段赋值先于构造体、super() 在第一行。

JIT 的核心价值不是翻译,而是基于运行时 profiling 的激进优化:方法内联(最重要的优化)、逃逸分析(栈上分配 + 标量替换 + 锁消除)、循环展开、多态内联缓存。这些优化让 Java 接近 C/C++ 的性能,同时保留跨平台和动态加载的优势。

一句话记忆:javac 编译成字节码,类加载变活模板,解释器先跑,JIT 把热点编译成机器码——三层转换,性能靠 JIT 激进优化。