# JVM入门

作者:Ethan.Yang
博客:https://blog.ethanyang.cn (opens new window)
相关源码参考: https://github.com/YangYingmeng/_014JVM (opens new window)


从 Java 源码到 JVM,大致经历三段旅程: 1)源码 → 字节码(编译);2)字节码 → 运行时结构(类加载、链接、初始化);3)运行期机制(执行引擎、GC、JNI 等)。 本文先把 1)与 2)讲清楚,并用一个自定义 ClassLoader 作收尾示例。

# 一、编译: Java 源码如何变成 .class

# 示例源码

public class Person {
    private String name = "Jack";
    private int age;
    private final double salary = 100;
    private static String address;
    private final static String hobby = "Programming";
    private static Object obj = new Object();

    public void say() {
        System.out.println("person say...");
    }

    public static int calc(int op1, int op2) {
        op1 = 3;
        int result = op1 + op2;
        Object obj = new Object();
        return result;
    }

    public static void main(String[] args) {
        calc(1, 2);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

使用命令编译:

javac -g:vars ./src/main/java/com/yym/jvm/_01base/Person.java
1

-g:vars:在 .class 中保留局部变量表,便于后续 javap 反编译及调试。

编译后会生成 Person.class

# 用十六进制观察 .class

.class 文件以 魔数 0xCAFEBABE 开头,后面是版本号、常量池、字段/方法表、属性等二进制结构。例如(节选):

cafe babe 0000 0034 003f 0a00 0a00 2b08
002c 0900 0d00 2d06 4059 0000 0000 0000
0900 0d00 2e09 002f 0030 0800 310a 0032
0033 0700 340a 000d 0035 0900 0d00 3607
0037 0100 046e 616d 6501 0012 4c6a 6176
612f 6c61 6e67 2f53 7472 696e 673b 0100
0361 6765 0100 0149 0100 0673 616c 6172
7901 0001 4401 000d 436f 6e73 7461 6e74
1
2
3
4
5
6
7
8

IDE 打开通常是反编译后的 Java 代码,想看原始字节需要 Hex 工具(xxd, hexdump, VSCode Hex Editor 等)。

# javap 验证结构

找到 Person.class 文件所在目录后,执行以下命令进行反编译查看其字节码结构:

javap -v Person.class
1

输出结果如下(节选):

Classfile /D:/gitCode/_014JVM/src/main/java/com/yym/jvm/_01base/Person.class
  Last modified 2025-6-25; size 978 bytes
  MD5 checksum f20624e7ba95809be2b9caa6bc3ceb88

public class com.yym.jvm._01base.Person
  minor version: 0
  major version: 61
  flags: ACC_PUBLIC, ACC_SUPER
1
2
3
4
5
6
7
8

# 常量池

常量池是 class 文件的重要组成部分,记录了类中的各种符号引用(类名、字段名、方法名、字面量等):

  #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
  #7 = String             #8             // Jack
  #15 = Double            100.0d
  #27 = String            #28            // person say...
  ...
1
2
3
4
5

可以看到包括方法引用、字段引用、字符串、基本类型常量等信息。

# 字段与方法结构

每个字段或方法的结构均包含描述符(descriptor)、访问标志(flags)、字节码指令(Code)等信息,例如:

public com.yym.jvm._01base.Person();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    0: aload_0
    1: invokespecial #1                  // 调用父类构造方法 Object.<init>()
    4: aload_0
    5: ldc           #7                  // 加载常量字符串 "Jack"
    7: putfield      #9                  // 设置 name 字段值
    ...
public static int calc(int, int);
  descriptor: (II)I
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    0: iconst_3
    1: istore_0
    2: iload_0
    3: iload_1
    4: iadd
    5: istore_2
    6: new           #2                  // 创建 Object 实例
    ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 二、类加载机制:让 .class 在 JVM 里“活起来”

类加载把 .class 字节码读入内存,构建运行时元数据,并在堆中创建一个 java.lang.Class 作为 Java 层的访问入口。

# 1)装载(Loading)

通过全限定名找到字节码 → 读入内存。

  1. 读取来源可来自:文件、网络、内存、加密容器等;

  2. 将其解析成方法区/元空间里的类元数据结构(HotSpot 中常称 Klass);

  3. 中创建 java.lang.Class 对象,作为 Klass 的 Java 镜像(供反射使用)。

区分:类 vs 实例

Foo 类为例:

  • 类加载阶段
    • JVM 会将 Foo 的字节码文件解析,生成 方法区(或元空间)中的运行时数据结构(Klass 结构),其中包含方法表、字段表等信息。
    • 同时在 堆中 创建一个 java.lang.Class 对象,用于 Java 层通过反射等方式访问类信息, 实际上是klass结构的Java镜像, 方便Java访问类元数据。
  • new Foo() 时
    • JVM 根据字节码中的符号引用找到 方法区中 Foo 的 Klass 结构
    • 然后在堆中开辟内存空间,初始化对象头,将 指向 Klass 结构的指针 写入对象头
    • JVM 后续执行调用、字段访问等操作,都可以通过这个 Klass 指针,直接定位到方法区中的类元数据

# 2)链接(Linking)

包含验证、准备、解析三个子阶段:

  • 验证

    • 文件格式验证:校验 .class 文件格式是否合规

    • 元数据验证:类结构是否合法(如是否继承 final 类)

    • 字节码验证:数据流/控制流分析,保障程序逻辑安全

    • 符号引用验证:类中引用的方法、字段是否存在,权限是否足够

  • 准备

    为类的 静态变量 分配内存,并赋默认值(0、null、false 等)。

    • 编译期常量static final 的基本类型/字符串且常量表达式)会在准备阶段ConstantValue 直接赋值到变量上,而不是等到 <clinit>

      public class _02PrepareTest {
      
          private static int i; // 类变量:准备阶段赋默认值0
          private int j = 100;  // 实例变量:随对象在堆上分配
      
          public static void main(String[] args) {
              // ✅ 正常打印:i 在准备阶段已赋默认值 0
              System.out.println(i);
      
              // ❌ 编译错误:局部变量必须显式初始化
              int b;
              System.out.println(b); // 编译错误:变量 b 可能尚未初始化
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
    • ConstantValue属性

      当静态变量在定义时使用了编译期常量赋值,例如:

      private static final int A = 10;
      
      1

      编译器会在 .class 文件的 常量池 中生成一个 ConstantValue 属性,该属性用于通知 JVM:

      初始化阶段,直接将这个常量值赋给变量 A,而非在 <clinit> 方法中执行赋值语句。

  • 解析

    将类中的 符号引用 转为 直接引用(地址/句柄/偏移量等):

    • 符号引用:类名、字段名等符号信息(编译期存在)
    • 直接引用:运行时内存地址(运行期使用)

# 3)初始化(Initialization)

执行类变量的显式赋值与静态代码块,实质是运行 <clinit>

规则:

  1. 触发时若类未加载/未链接,要先完成前置阶段;
  2. 若父类未初始化,先初始化父类;
  3. 按源文件顺序执行 static 变量赋值与 static {}

触发时机

  1. 主动引用

    • new 对象

    • 调用 / 赋值静态变量

    • 调用静态方法

  2. 被动引用

    • 子类访问父类静态字段,只触发父类初始化

# 4)卸载(Unloading)

类加载后,满足以下条件时才可被卸载:

  • 该类所有实例已被 GC
  • 加载该类的 ClassLoader 被回收
  • java.lang.Class 对象无引用(反射无法再访问)

一般发生在自定义 ClassLoader 加载的类被动态卸载的场景,如热部署等。


# 三、类加载器体系与行为

# 1)类加载器的两个关键事实

  • JVM 中“类”的唯一性:全限定名 + 定义它的类加载器。同名类由不同加载器加载,视为不同类型(==/instanceof 都可能失败)。
  • 类加载器的职责:把字节码查找并转换成 Class 对象。

# 2)分层结构

为什么分层?

  • 安全:防止用户伪造核心类(比如自定义 java.lang.String);

  • 隔离:不同来源的类互不干扰(容器/插件)。

# 3)三条行为准则

  • 全盘负责

    当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

  • 双亲委派

    1. 子加载器先判断类是否已加载;
    2. 没有加载则委托给父加载器;
    3. 父加载器层层向上委托,直到 Bootstrap;
    4. 若父加载器未找到,才由当前类加载器尝试加载;
    5. 若仍未找到,抛出 ClassNotFoundException

    好处:防止核心类被重复加载或被用户伪造(如防止伪造 java.lang.String)。

    代码的体现loadClass方法内部会递归的parent.loadClass(name, false); 如果查找不到再调用当前类的findClass(name)

  • 缓存机制

    同一个类在一个类加载器中只加载一次(JVM 使用缓存)。再次加载相同全限定名的类将直接返回缓存的 Class 对象。

# 4)何时“打破”双亲委派:SPI 机制

场景DriverManager(由 Bootstrap 加载)需要发现第三方 JDBC Driver(通常是 AppClassLoader 路径里的类)。若严格委派,父加载器看不到子加载器的类路径,会失败。

解决:JDK 提供 SPI / ServiceLoader,使用**线程上下文类加载器(TCCL)**来加载实现类:

SPI(Service Provider Interface)机制

  • 接口由 JDK 定义;
  • 实现由厂商提供;
  • 通过 ServiceLoader 统一动态加载这些实现类。

实现流程:

  1. META-INF/services/ 目录下创建文件,文件名为接口的全限定名;
  2. 文件内容为实现类的全限定名;
  3. 使用 ServiceLoader.load(MyInterface.class) 加载实现类;
  4. 使用反射生成实例。

需要注意 jdk只是通过接口调用对应的实现, 是多态的体现, 由子类加载器加载的类的类型对父类加载器不可见 SPI是jdk提供核心接口, 在实现这个接口时, 相当于该接口持有了对应子实现的引用, 就算父类加载器看不到具体的子实现, 也可以通过接口上的引用去调用对应的子实现, 多态, 具体选择哪个子实现一般由SPI自定义或者像spring一样使用@Primary指定

SPI 的源码追踪

ServiceLoader.load(MyInterface.class);

public static <S> ServiceLoader<S> load(Class<S> service) {
    // 使用的是线程上下文类加载器进行类的加载
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

// 只是返回一个ServiceLoader的实例, 延迟加载的思想
public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
    return new ServiceLoader<>(service, loader);
}

private ServiceLoader(Class<S> svc, ClassLoader cl) {
    service = Objects.requireNonNull(svc, "Service interface cannot be null");
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    reload();
}

public void reload() {
    providers.clear();
    lookupIterator = new LazyIterator(service, loader);
}

public final class ServiceLoader<S> implements Iterable<S> {
    // 简化代码
    private LazyIterator lookupIterator;

    public Iterator<S> iterator() {
        return new Iterator<>() {
            public boolean hasNext() {
                return lookupIterator.hasNext();
            }

            public S next() {
                return lookupIterator.next();
            }
        };
    }
}
// 最终加载的代码 可以发现并没有走双亲委派, 直接由当前的线程上下文类加载器加载class对象
public S next() {
    String className = nextName;
    // 将类文件转化为.class对象;  初始化类 执行 <clinit>() 方法(也就是静态代码块 + 静态变量初始化)。
    Class<?> clazz = Class.forName(className, false, loader);
    S service = service.cast(clazz.getDeclaredConstructor().newInstance());
    return service;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

为什么要打破双亲委派?

因为发起类加载请求的是 Bootstrap 加载器(如 DriverManager),它无法感知子类加载器(如 AppClassLoader),即使子加载器中存在对应的实现类也无法加载。

所以必须人为指定一个合适的类加载器 —— 通常是线程上下文类加载器(TCCL)—— 来加载服务实现,从而打破双亲委派限制,实现灵活的插件式加载。

# 四、自定义类加载器(从 0 到 1)

目标:从文件系统指定根目录读取 .class,定义为可用的 Class

public class _03CustomClassLoader extends ClassLoader {

    private String root;

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String className) {
        String fileName = root +
                File.separatorChar + className.replace('.', File.separatorChar)
                + ".class";
        try {
            InputStream ins = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 1024;
            byte[] buffer = new byte[bufferSize];
            int length = 0;
            while ((length = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, length);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    public String getRoot() {
        return root;
    }

    public void setRoot(String root) {
        this.root = root;
    }

    public static void main(String[] args) {
        _03CustomClassLoader classLoader = new _03CustomClassLoader();
        classLoader.setRoot("E:\\temp");
        Class<?> testClass = null;
        try {
            testClass = classLoader.loadClass("com.neo.classloader.Test2");
            Object object = testClass.newInstance();
            System.out.println(object.getClass().getClassLoader());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59