总览

从Java源码到JVM执行,大致分为“编译 -> 类加载 -> 字节码验证与链接 -> 解释/编译执行 -> 运行期优化”几个阶段。

.java -> javac -> .class -> 类加载/验证/链接/初始化 -> 解释执行 + JIT -> 运行期优化 + GC
flowchart LR A[Java Source .java] --> B[javac Compile] B --> C[Bytecode .class] subgraph L[Class Loading] D[Loading] E[Verification] F[Preparation] G[Resolution] H[Initialization] D --> E --> F --> G --> H end subgraph R[Runtime Execution] I[Interpreter Execute] J{Hot Method?} K[JIT Compile] L2[Machine Code] M[Runtime Optimization] N[GC + Deoptimization] I --> J J -- No --> I J -- Yes --> K --> L2 --> M --> N end C --> D H --> I

主流程

源码编译(javac)

这一步只是把源码转换为平台无关的字节码,不生成机器码。

一个小示例

public class Hello {
  private static final String NAME = "qtopie";
  private static volatile int cnt = 0;


  public static synchronized void count() {
    ++cnt;
  }

  public static void main(String[] args) {
    System.out.println("Hello, World!");

    System.out.println(NAME);

    count();
    System.out.println(cnt);
  }
}

反编译为汇编查看字节码javap -v Hello

Classfile /home/qtopierw/workspace/projects/qtopie.github.io/posts/Hello.class
  Last modified Feb 10, 2026; size 689 bytes
  SHA-256 checksum af50a475fe0a0608d09466e01af47f0658b67fb73543869e55d4a7756913eb1a
  Compiled from "Hello.java"
public class Hello
  minor version: 0
  major version: 65
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // Hello
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 2, methods: 4, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // Hello.cnt:I
   #8 = Class              #10            // Hello
   #9 = NameAndType        #11:#12        // cnt:I
  #10 = Utf8               Hello
  #11 = Utf8               cnt
  #12 = Utf8               I
  #13 = Fieldref           #14.#15        // java/lang/System.out:Ljava/io/PrintStream;
  #14 = Class              #16            // java/lang/System
  #15 = NameAndType        #17:#18        // out:Ljava/io/PrintStream;
  #16 = Utf8               java/lang/System
  #17 = Utf8               out
  #18 = Utf8               Ljava/io/PrintStream;
  #19 = String             #20            // Hello, World!
  #20 = Utf8               Hello, World!
  #21 = Methodref          #22.#23        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #22 = Class              #24            // java/io/PrintStream
  #23 = NameAndType        #25:#26        // println:(Ljava/lang/String;)V
  #24 = Utf8               java/io/PrintStream
  #25 = Utf8               println
  #26 = Utf8               (Ljava/lang/String;)V
  #27 = String             #28            // qtopie
  #28 = Utf8               qtopie
  #29 = Methodref          #8.#30         // Hello.count:()V
  #30 = NameAndType        #31:#6         // count:()V
  #31 = Utf8               count
  #32 = Methodref          #22.#33        // java/io/PrintStream.println:(I)V
  #33 = NameAndType        #25:#34        // println:(I)V
  #34 = Utf8               (I)V
  #35 = Utf8               NAME
  #36 = Utf8               Ljava/lang/String;
  #37 = Utf8               ConstantValue
  #38 = Utf8               Code
  #39 = Utf8               LineNumberTable
  #40 = Utf8               main
  #41 = Utf8               ([Ljava/lang/String;)V
  #42 = Utf8               <clinit>
  #43 = Utf8               SourceFile
  #44 = Utf8               Hello.java
{
  public Hello();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 2: 0

  public static synchronized void count();
    descriptor: ()V
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #7                  // Field cnt:I
         3: iconst_1
         4: iadd
         5: putstatic     #7                  // Field cnt:I
         8: return
      LineNumberTable:
        line 8: 0
        line 9: 8

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #19                 // String Hello, World!
         5: invokevirtual #21                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
        11: ldc           #27                 // String qtopie
        13: invokevirtual #21                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        16: invokestatic  #29                 // Method count:()V
        19: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
        22: getstatic     #7                  // Field cnt:I
        25: invokevirtual #32                 // Method java/io/PrintStream.println:(I)V
        28: return
      LineNumberTable:
        line 12: 0
        line 14: 8
        line 16: 16
        line 17: 19
        line 18: 28

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_0
         1: putstatic     #7                  // Field cnt:I
         4: return
      LineNumberTable:
        line 4: 0
}
SourceFile: "Hello.java"

类加载(Class Loading)

当类第一次被“主动使用”时,类加载器会把.class读取进JVM,并生成java.lang.Class对象。主要步骤:

类加载器模型:Bootstrap -> Platform -> Application(自定义类加载器可打破双亲委派)。

解析(Resolution)就是把常量池里的“符号引用”(名字+描述信息)转换成“直接引用”(JVM能直接定位到的运行时结构指针/句柄)。

符号引用:比如类名、接口名、字段名、方法名、描述符等,和具体内存地址无关。 直接引用:指向已加载类的运行时数据结构,或可直接定位方法入口的引用(例如方法表中的索引或指针)。 解析发生时机可能是链接阶段(类加载时)或首次使用(惰性解析)。解析完成后,后续调用就不必再通过名字查找,性能更高,也确保访问合法性(权限检查、存在性检查等)。

解释执行与JIT编译

JVM最初会通过解释器执行字节码;当方法被频繁调用(热点方法)时,JIT会把字节码编译成本地机器码,提升性能。

运行期优化(JIT优化)

JIT会基于运行时数据做优化,例如:

这些优化是“有条件”的,如果运行时假设不成立,JVM会触发反优化并回退到解释执行。

执行时内存与GC

程序运行时主要在堆和方法区(元空间)分配对象和类元数据,GC负责回收无用对象。常见阶段:

关键细节

类加载触发条件(主动使用)

JVM只会在类被“主动使用”时初始化,常见触发场景:

被动引用不会触发初始化,如:

字节码验证与链接细节

验证主要为了安全和类型一致性,分为四类检查:

链接阶段包括:

执行与优化

C1/C2与分层编译

常用参数(仅示意):

常见JIT优化案例

反优化(Deoptimization)

JIT依赖运行时假设(比如类型单态)。当假设失效:

CPU与调度视角

从CPU视角理解执行流程

从CPU的角度看,核心是“取指 -> 译码 -> 执行”,JVM做的是把字节码尽快变成CPU能执行的本地指令,并利用硬件特性提升吞吐。

一句话理解:JVM在运行时把高层字节码不断“降级/升级”为CPU更容易高效执行的指令序列。

JVM层面的CPU调度视角

需要区分两类线程模型:

JVM内部还有一些“系统线程”,它们也会参与CPU竞争:

关键调度时刻:

安全点是什么 安全点(Safepoint)是 Java 程序执行过程中的一些特殊位置。在这些位置,线程的状态是确定的,JVM 可以安全地暂停所有线程,来执行一些“破坏性”的操作。

最常见的比喻是:高速公路上的服务区。汽车(线程)不能在路中间随时停下,必须开到指定的服务区(安全点)才能停下来休息或加油(GC)。

为什么需要安全点

  • GC 需要做“可达性分析”,线程持续改引用会让分析失真。
  • 反优化(Deoptimization)需要在可控点切换执行模式。
  • 偏向锁撤销、线程转储等全局操作也需要一致状态。

安全点通常放在哪里

  • 循环末尾(避免长循环迟迟不进安全点)。
  • 方法返回前或调用之后。
  • 抛出异常的位置。

线程如何进入安全点

  • JVM 设置全局“安全点标志位”(常见做法是让特定内存页不可读)。
  • 线程在轮询点检查标志位,发现已设置就主动挂起。

无法主动轮询的线程

  • Sleep 或 Block:已经处于安全状态,醒来前会先检查 GC 是否结束。
  • JNI:Native 代码不操作 Java 对象,但返回 Java 前必须先进入安全点等待。

安全区域(Safe Region)

  • 指一段引用关系不会变化的代码片段。
  • 线程在安全区域内可直接被视为“已进入安全点”。

一句话理解:JVM自身不“直接调度CPU”,更多是通过线程模型与安全点机制协调执行,真正的CPU时间片分配主要由OS完成;虚拟线程是JVM层“主动调度”的典型例外。

运行期监控与排查建议

相关知识

被动引用不会初始化与懒加载实现

简单来说,JVM 对类初始化的原则是:不到万不得已,绝不进行初始化。

之所以被称为“被动引用”,是因为它们在 JVM 规范中并未达到触发 <clinit> 方法执行的特定阈值。

以下是被动引用不会初始化深层逻辑的拆解:

通过数组引用类 (SomeClass[])

当你创建一个数组对象时,例如 SomeClass[] arr = new SomeClass[10];,真正被初始化的类并不是 SomeClass,而是由 JVM 自动生成的、代表数组维度的一个特殊类(通常以 [L... 开头)。

这个以 [ 开头的类,实际上是 Java 虚拟机在运行期间动态生成的一个“影子类”。

它是 JVM 用来管理数组的一种特殊类型。

数组类的命名规则

  • 数组类名由 JVM 内部规则生成,不是源码里的普通类名。
  • [ 表示数组,L 表示引用类型元素,; 结束类名。
  • 例子:
    • String[] -> [Ljava.lang.String;
    • int[] -> [I(基本类型有专门缩写)
    • Object[][] -> [[Ljava.lang.Object;

为什么不会触发 SomeClass 初始化 当执行 SomeClass[] arr = new SomeClass[10]; 时,JVM 的动作是:

  • 确认类型:需要一个存储 SomeClass 引用的容器。
  • 创建数组类:动态创建 [LSomeClass;,该类继承 Object 并实现 CloneableSerializable
  • 分配空间:在堆中为引用分配连续空间。
  • 初始化默认值:所有位置设为 null

关键点:JVM 只是“量尺寸”和“划地盘”,不需要了解 SomeClass 的内部实现,因此不会触发它的静态初始化。

这个“特殊类”里有什么

  • 隐藏的 length 字段。
  • 数组相关字节码指令支持,如 aaloadaastore
  • 所有数组类的直接父类都是 java.lang.Object

有趣的事实:打印 new SomeClass[10].getClass().getName(),可以看到 [L... 这样的名字。

什么时候 SomeClass 才会初始化 只有当你真正触碰数组内部内容时:

arr[0] = new SomeClass(); // 实例化触发初始化

SomeClass.staticMethod(); // 调用静态方法触发

System.out.println(SomeClass.staticField); // 访问非编译期常量触发

访问 static final 编译期常量

这里的关键点在于编译期(Compile Time)

注意:如果 static final 的值需要运行时才能确定(例如 public static final int RANDOM = new Random().nextInt();),那么这种情况下依然触发初始化。

仅获取 Class 对象(如 SomeClass.class

使用 SomeClass.class 获取字面量常量,或者使用某些特定的反射方法(如 ClassLoader.loadClass())时,不会触发初始化。

总结对照表

触发动作涉及阶段是否执行 <clinit>
new SomeClass()加载 -> 验证 -> 准备 -> 解析 -> 初始化
SomeClass.class加载 -> 验证 -> 准备
SomeClass[] arr初始化数组类,不涉及 SomeClass
访问编译期常量直接从常量池取值

你可以把初始化想象成一个“开业典礼”。获取 Class 对象只是“看了一眼招牌”,创建数组只是“租下了地皮”,访问常量则是“买了店里印在外面的宣传单”。只有当你想进店消费(实例化)或者找店长谈话(调用静态方法)时,开业典礼才会正式举行。

简单来说,类的初始化是实现“懒加载”(Lazy Loading)的核心底层机制。

在 Java 中,懒加载的本质就是:将对象的创建或某些逻辑的执行,推迟到相关类被“主动引用”而触发初始化那一刻。

以下是它们之间深度绑定的逻辑:

初始化的“被动性”支撑了懒加载

正如我们之前讨论的,JVM 只有在“万不得已”时才会初始化一个类。这种特性天然地支持了懒加载模式。

经典案例:静态内部类单例模式

这是利用类初始化机制实现懒加载最优雅的方案。

public class Singleton {
    private Singleton() {}

    // 静态内部类
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE; // 只有执行到这里,Holder 类才会被初始化
    }
}

初始化与懒加载的权衡

虽然懒加载能优化启动性能,但也带了一些副作用:

维度立即初始化 (Eager)懒加载 (Lazy)
启动速度较慢(启动时加载所有资源)快(按需加载)
内存占用较高(预先占坑)较低(不用不占)
响应性运行时调用飞快(已就绪)第一次调用时可能有微小延迟(需初始化)
风险错误在启动时就能发现错误可能在运行到一半时才抛出(如 NoClassDefFoundError

反射与懒加载的冲突

反射通常会打破懒加载的优雅性。例如,如果你使用 Class.forName("SomeClass"),默认情况下它会强制触发 SomeClass 的初始化(除非你手动指定 initialize = false)。这就意味着,即使你还没准备好使用这个类,反射也会把它“强行唤醒”。


双亲委派(parents delegate)

Java 11 仍然采用“类加载器 + 双亲委派”的经典模型,但需要注意模块化后的变化与实现细节。核心要点如下:

类加载器层次(Java 11)

双亲委派模型

加载流程是“先委派给父加载器,父加载器找不到再由子加载器尝试”。

模块化(JPMS)带来的变化

Java 11 支持模块系统,类加载路径分为:

模块系统让“可见性”不再仅由类加载器决定,还受模块依赖关系约束。

在 JDK 8 时代,我们可以通过往 jre/lib/ext 挪动 JAR 包来扩展 Java 的功能,但在 JDK 11/23 这种现代 Java 中,扩展机制(Extension Mechanism)已经被彻底移除。

为什么不再允许扩展 lib/modules?主要有三个核心原因:

  • 性能优化:lib/modules 是以 jimage 格式存储的。JVM 启动时使用预计算哈希进行快速定位,随意塞文件会破坏这一优化。
  • 强封装性:模块化强调边界清晰,外部 JAR 混入核心镜像可能绕过安全检查。
  • 可靠性:避免“Classpath 地狱”,所有依赖需在启动前通过 --module-path 明确声明。

推荐方式:--module-path

  • 把 JAR 包放在任意目录下,启动时显式指定 module path。
  • 这些类由 AppClassLoader 加载,地位与 lib/modules 内类平等,但物理位置分离。
sequenceDiagram autonumber participant App as Application ClassLoader participant Platform as Platform ClassLoader participant Boot as Bootstrap Loader participant MP as Module Path participant CP as Class Path App->>Platform: loadClass(name) Platform->>Boot: loadClass(name) Boot-->>Platform: not found Platform-->>App: not found App->>MP: findClass(name) alt module exists and readable MP-->>App: class bytes App-->>App: defineClass else fall back App->>CP: findClass(name) CP-->>App: class bytes App-->>App: defineClass end

解析与链接时机

类加载包含:加载(Loading)-> 验证(Verification)-> 准备(Preparation)-> 解析(Resolution)-> 初始化(Initialization)。

常见“打破委派”的场景

参考 https://docs.osgi.org/javadoc/r4v43/core/org/osgi/framework/Bundle.html

上下文类加载器(TCCL)

为了解决“父加载器看不到子加载器资源”的问题,Java 引入了线程上下文类加载器:

类卸载与元空间

Java 11 常见观察手段