第四篇:类加载机制——从.class到Klass的完整旅程

csdn.net/md/?articleId=159419058)

  1. 第十四篇:JVM参数调优实战------从GC日志到参数调整-96

  2. 第十五篇:OOM排查实战------从一个内存泄漏案例说起-96


前言

在前三篇文章中,我们深入探讨了对象的内存布局,看到了对象头中的Klass指针指向方法区中的InstanceKlass。但有一个根本问题一直没有回答:

方法区中的InstanceKlass是从哪来的?

答案就是类加载机制

当我们写下 new User() 时,JVM是如何找到User类的?.class文件是如何变成方法区中的Klass的?双亲委派模型又是什么?

今天,我们就来揭开类加载的神秘面纱。理解类加载,你将能回答:

  • 静态变量什么时候赋值?
  • 为什么Class.forName()ClassLoader.loadClass()行为不同?
  • Tomcat为什么能实现应用隔离?

下一篇,我们将在此基础上,深入方法区的实现演进------为什么JDK 8要移除永久代?


一、类加载的五个阶段

JVM的类加载过程分为五个阶段

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        类加载的五个阶段                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐
│  │  加载   │ → │  验证   │ → │  准备   │ → │  解析   │ → │  初始化 │
│  │ Loading │    │Verification│    │Preparation│    │Resolution│    │Initialization│
│  └─────────┘    └─────────┘    └─────────┘    └─────────┘    └─────────┘
│                                                                     │
│  └───────────────────────┐                ┌────────────────────────┘ │
│                          ↓                ↓                          │
│                    连接(Linking)阶段                              │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

注意:解析阶段的时机比较特殊,它可以在初始化之后再进行(为了支持动态绑定)。但JVM规范允许实现选择在类加载的解析阶段执行,或首次使用时解析。


二、阶段一:加载(Loading)

2.1 目标

将类的二进制字节流加载到内存中,生成方法区的Klass结构和堆中的Class对象。

java 复制代码
// 加载的三种方式
public class LoadingExample {
    
    // 方式1:通过类的全限定名加载(会触发初始化)
    Class<?> clazz1 = Class.forName("com.example.User");
    
    // 方式2:通过类加载器加载(不会触发初始化)
    ClassLoader loader = Thread.currentThread().getContextClassLoader();
    Class<?> clazz2 = loader.loadClass("com.example.User");
    
    // 方式3:通过对象获取(类已加载)
    User user = new User();
    Class<?> clazz3 = user.getClass();
}

2.2 加载阶段做了什么?

复制代码
步骤1:获取类的二进制字节流
├─ 从.class文件读取
├─ 从JAR包读取
├─ 从网络读取(如Applet)
├─ 动态生成(如CGLib代理、JDK动态代理)
└─ 从数据库读取(较少见)

步骤2:将字节流转换为方法区的运行时数据结构(InstanceKlass)
├─ 类名、父类名、接口名
├─ 字段信息(名称、类型、修饰符、偏移量)
├─ 方法信息(名称、返回值、参数、字节码、异常表)
├─ 常量池
└─ 注解信息

步骤3:在堆中生成java.lang.Class对象
└─ 作为方法区Klass的Java层入口(klass->java_mirror())

2.3 加载阶段的产物

加载完成后,方法区中就有了该类的完整元数据:

复制代码
方法区地址:0x7f6488c00000
┌─────────────────────────────────────────────────────────────┐
│ User类的InstanceKlass                                       │
├─────────────────────────────────────────────────────────────┤
│ 类元数据                                                     │
│   ├─ 类名: "User"                                           │
│   ├─ 父类: Object (0x7f6488b00000)                          │
│   ├─ 字段: id (int, 偏移量12)                               │
│   └─ 字段: name (String, 偏移量16)                          │
├─────────────────────────────────────────────────────────────┤
│ 运行时常量池 (地址: 0x7f6488c00100)                          │
│   ├─ #1: Class Object (0x7f6488b00000)                      │
│   ├─ #2: Field id                                           │
│   └─ #3: Method <init>                                      │
├─────────────────────────────────────────────────────────────┤
│ 方法表 (地址: 0x7f6488c00800)                                │
│   ├─ <init> → Method对象 (0x7f6488c00200)                   │
│   ├─ setName → Method对象 (0x7f6488c00300)                  │
│   └─ getName → Method对象 (0x7f6488c00400)                  │
└─────────────────────────────────────────────────────────────┘

堆中:
┌─────────────────────────────────────────────────────────────┐
│ java.lang.Class对象 (User.class)                            │
│   ├─ 对象头                                                 │
│   └─ _klass指针 → 0x7f6488c00000 (指向InstanceKlass)       │
└─────────────────────────────────────────────────────────────┘

三、阶段二:验证(Verification)

3.1 目标

确保.class文件的字节流符合JVM规范,不会危害JVM安全。

3.2 验证的四个步骤

java 复制代码
// 验证阶段会检查什么?

// 1. 文件格式验证
//    - 是否以0xCAFEBABE开头(魔数)
//    - 主次版本号是否在当前JVM支持范围内
//    - 常量池是否有不被支持的常量类型
//    - 常量池中的索引是否指向有效位置

// 2. 元数据验证
//    - 类是否有父类(除了Object)
//    - 是否继承了final类
//    - 是否实现了final方法
//    - 字段、方法是否与父类冲突

// 3. 字节码验证(最复杂)
//    - 操作数栈的数据类型是否与指令匹配
//    - 跳转指令是否指向合法位置
//    - 方法调用的参数类型是否正确
//    - 局部变量表是否被正确初始化

// 4. 符号引用验证
//    - 通过符号引用能否找到对应的类、字段、方法
//    - 访问权限是否合法(private、protected等)
//    - 类、字段、方法是否存在

3.3 验证失败的例子

java 复制代码
// 手动修改.class文件,把某个指令的操作数改错
// JVM会抛出 VerifyError
Exception in thread "main" java.lang.VerifyError: 
    Bad type on operand stack

为什么要验证? 防止恶意代码通过篡改.class文件破坏JVM。


四、阶段三:准备(Preparation)

4.1 目标

为类的静态变量分配内存,并设置默认初始值。

java 复制代码
public class PreparationExample {
    // 准备阶段后:value = 0(不是100!)
    public static int value = 100;
    
    // 准备阶段后:flag = false
    public static boolean flag = true;
    
    // 准备阶段后:str = null
    public static String str = "hello";
    
    // 准备阶段后:常量直接赋值(特殊情况)
    public static final int CONSTANT = 100;  // 直接赋值为100
}

4.2 为什么不是代码中的值?

因为赋值指令是putstatic,需要在类初始化阶段(<clinit>)执行。准备阶段只是分配内存并设为默认零值。

类型 默认零值
int 0
long 0L
boolean false
引用类型 null

4.3 final静态变量的特殊性

java 复制代码
public static final int MAX = 100;
// final静态变量在准备阶段就赋值,因为它的值永远不会变
// 编译器会在常量池中直接存储这个值

五、阶段四:解析(Resolution)

5.1 目标

将常量池中的符号引用 替换为直接引用

这是理解类加载的关键环节,我们在前面的文章中详细讲过:

复制代码
符号引用 → 直接引用

示例:
常量池 #2 = "User"           →  #2 = 0x7f6488c00000 (Klass地址)
常量池 #3 = "User.<init>"    →  #3 = 0x7f6488c00200 (Method对象地址)
常量池 #4 = "User.id"        →  #4 = 12 (字段偏移量)

5.2 解析的时机

JVM规范允许两种实现方式:

方式 说明 优点 缺点
加载时解析 在类加载的连接阶段就完成所有解析 提前发现问题 可能解析一些永远不用的符号引用
使用时解析 在首次使用时才解析(延迟解析) 节省内存,支持动态绑定 首次使用时有额外开销

HotSpot采用混合策略:部分解析在加载时完成,部分在首次使用时完成。

5.3 解析的具体内容

cpp 复制代码
// HotSpot源码中的解析过程(简化)
void ConstantPool::resolve_class(int index) {
    // 1. 从常量池获取符号引用
    Symbol* class_name = symbol_at(index);  // "User"
    
    // 2. 通过类加载器加载类
    Klass* klass = class_loader->load_class(class_name);
    
    // 3. 将符号引用替换为直接引用
    klass_at_put(index, klass);  // 存入Klass地址
}

六、阶段五:初始化(Initialization)

6.1 目标

执行类的构造器方法 <clinit>,为静态变量赋值为代码中指定的值,并执行静态代码块。

java 复制代码
public class InitializationExample {
    // 1. 静态变量赋值
    public static int value = 100;  // <clinit>中执行 putstatic
    
    // 2. 静态代码块
    static {
        System.out.println("静态代码块执行");
        value = 200;
    }
    
    // 3. 静态常量(已经在准备阶段完成,不在这里)
    public static final int CONSTANT = 300;
}

6.2 <clinit>方法的生成

编译器会将所有静态变量赋值和静态代码块合并成一个<clinit>方法:

java 复制代码
// 编译后的伪代码
public static void <clinit>() {
    value = 100;
    System.out.println("静态代码块执行");
    value = 200;
}

6.3 触发初始化的条件(主动引用)

场景 示例 说明
new实例化 new User() 创建对象实例
访问静态变量 User.count 读取或赋值(final常量除外)
调用静态方法 User.getCount() 调用静态方法
反射调用 Class.forName("User") 反射获取类
子类初始化触发父类初始化 new Child() 先初始化父类
启动类(main方法所在类) public static void main 程序入口

6.4 被动引用(不会触发初始化)

java 复制代码
public class PassiveReference {
    public static void main(String[] args) {
        // 1. 通过子类访问父类静态变量(只触发父类初始化)
        System.out.println(Child.value);  // 父类初始化,子类不初始化
        
        // 2. 通过数组定义(不触发初始化)
        User[] users = new User[10];  // User类不会被初始化
        
        // 3. 访问静态常量(编译期就放入常量池)
        System.out.println(User.CONSTANT);  // User类不会被初始化
        
        // 4. 通过类字面量获取Class对象(不触发初始化)
        Class<?> clazz = User.class;  // User类不会被初始化
    }
}

七、类加载器(ClassLoader)

7.1 什么是类加载器?

类加载器 是负责执行加载阶段的Java对象。每个类加载器都有一个父类加载器(组合关系,不是继承)。

java 复制代码
// 获取类加载器
public class ClassLoaderDemo {
    public static void main(String[] args) {
        // 1. 启动类加载器(null表示无法获取,因为它是C++实现的)
        System.out.println(String.class.getClassLoader());  // null
        
        // 2. 扩展类加载器(JDK 9+ 改名为PlatformClassLoader)
        System.out.println(com.sun.nio.zipfs.ZipFileSystem.class.getClassLoader());
        // jdk.internal.loader.ClassLoaders$PlatformClassLoader
        
        // 3. 应用类加载器
        System.out.println(ClassLoaderDemo.class.getClassLoader());
        // jdk.internal.loader.ClassLoaders$AppClassLoader
    }
}

7.2 三种内置类加载器

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                      类加载器层级结构                                │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│                    ┌─────────────────────┐                          │
│                    │  启动类加载器        │                          │
│                    │  BootstrapClassLoader│                         │
│                    │  (C++实现,null)     │                          │
│                    └──────────┬──────────┘                          │
│                               │ 父加载器                            │
│                    ┌──────────▼──────────┐                          │
│                    │  扩展类加载器        │                          │
│                    │  PlatformClassLoader│  ← JDK 9+ 改名           │
│                    │  (JDK 8: ExtClassLoader)│                       │
│                    └──────────┬──────────┘                          │
│                               │ 父加载器                            │
│                    ┌──────────▼──────────┐                          │
│                    │  应用类加载器        │                          │
│                    │  AppClassLoader     │                          │
│                    └─────────────────────┘                          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
类加载器 加载路径 实现语言 特点
启动类加载器 JAVA_HOME/lib 核心类库(rt.jar等) C++ JVM启动时创建,无法直接获取
扩展类加载器 JAVA_HOME/lib/extjava.ext.dirs Java JDK 9+改名PlatformClassLoader
应用类加载器 CLASSPATH 环境变量 Java 加载用户类,默认的类加载器

八、双亲委派模型(Parents Delegation Model)

8.1 定义

双亲委派模型:当一个类加载器收到类加载请求时,它不会自己先尝试加载,而是把请求委派给父加载器去完成。只有当父加载器无法加载时,才尝试自己加载。

java 复制代码
// ClassLoader.loadClass() 源码(简化版)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 1. 检查是否已经加载过
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            // 2. 委派给父加载器
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                // 3. 没有父加载器,交给启动类加载器
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 4. 父加载器加载失败,自己加载
            c = findClass(name);
        }
    }
    return c;
}

8.2 流程图

复制代码
用户请求加载 "com.example.User"
        ↓
应用类加载器 AppClassLoader
        ↓ 委派给父加载器
扩展类加载器 PlatformClassLoader
        ↓ 委派给父加载器
启动类加载器 BootstrapClassLoader
        ↓ 在 JAVA_HOME/lib 中查找
        ├─ 找到了 java.lang.String → 返回
        └─ 没找到 com.example.User
                ↓ 回到扩展类加载器
        扩展类加载器在 lib/ext 中查找
        ├─ 找到了 → 返回
        └─ 没找到
                ↓ 回到应用类加载器
        应用类加载器在 CLASSPATH 中查找
        └─ 找到了 com.example.User → 返回

8.3 为什么要设计双亲委派?

核心目的:保证核心类库的安全和唯一性。

java 复制代码
// 假设没有双亲委派,黑客可以自己写一个 java.lang.String
package java.lang;

public class String {
    public String() {
        // 恶意代码
        System.exit(0);
    }
}

双亲委派如何阻止

  1. 用户代码请求加载 java.lang.String
  2. 应用类加载器委派给父加载器
  3. 最终由启动类加载器加载JVM自带的String
  4. 用户自定义的String永远不会被加载

好处

  • 避免重复加载:同一个类只会被加载一次
  • 安全性:防止核心类库被篡改
  • 隔离性:不同类加载器加载的类属于不同的命名空间

九、打破双亲委派

9.1 什么时候需要打破?

某些场景需要打破双亲委派模型:

场景1:JDBC驱动加载

java 复制代码
// JDBC使用线程上下文类加载器打破双亲委派
public class DriverManager {
    private static final ClassLoader callerClassLoader = 
        Thread.currentThread().getContextClassLoader();
    
    // 使用线程上下文类加载器加载JDBC驱动
    Class<?> driverClass = Class.forName(driverClassName, true, callerClassLoader);
}

为什么需要打破?因为DriverManager由启动类加载器加载,但JDBC驱动在CLASSPATH中(应用类加载器才能加载),如果不打破,启动类加载器找不到驱动。

场景2:Tomcat等Web容器

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        Tomcat类加载器结构                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│                  ┌─────────────────────┐                            │
│                  │  启动类加载器        │                            │
│                  └──────────┬──────────┘                            │
│                             │                                       │
│                  ┌──────────▼──────────┐                            │
│                  │  扩展类加载器        │                            │
│                  └──────────┬──────────┘                            │
│                             │                                       │
│                  ┌──────────▼──────────┐                            │
│                  │  应用类加载器        │                            │
│                  └──────────┬──────────┘                            │
│                             │                                       │
│                  ┌──────────▼──────────┐                            │
│                  │  Common ClassLoader │  ← Tomcat公共类库          │
│                  └──────────┬──────────┘                            │
│              ┌──────────────┼──────────────┐                        │
│              ▼              ▼              ▼                        │
│     ┌────────────┐  ┌────────────┐  ┌────────────┐                 │
│     │ Webapp 1   │  │ Webapp 2   │  │ Webapp 3   │                 │
│     │ ClassLoader│  │ ClassLoader│  │ ClassLoader│                 │
│     └────────────┘  └────────────┘  └────────────┘                 │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

每个Web应用有自己的类加载器,实现应用隔离。一个应用中的com.example.User和另一个应用中的同名类不会冲突。

9.2 如何打破?

重写loadClass方法(而不是findClass):

java 复制代码
public class BreakParentDelegationClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 1. 先检查是否已经加载
        Class<?> c = findLoadedClass(name);
        if (c != null) return c;
        
        // 2. 对于核心类库,还是让父加载器加载
        if (name.startsWith("java.") || name.startsWith("javax.")) {
            return super.loadClass(name);
        }
        
        // 3. 对于其他类,自己先加载(打破双亲委派)
        try {
            c = findClass(name);
            if (c != null) return c;
        } catch (ClassNotFoundException e) {
            // 自己加载失败,再委派给父加载器
            return super.loadClass(name);
        }
        return super.loadClass(name);
    }
}

十、自定义类加载器

10.1 什么时候需要自定义?

  • 加密解密:对.class文件进行加密,防止反编译
  • 从非标准源加载:从数据库、网络等地方加载类
  • 热部署:实现类的动态替换
  • 字节码增强:在加载时修改字节码(如AOP)

10.2 如何自定义?

java 复制代码
public class CustomClassLoader extends ClassLoader {
    
    private String classPath;
    
    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            // 1. 获取.class文件的字节码
            byte[] classData = loadClassData(name);
            if (classData == null) {
                throw new ClassNotFoundException();
            }
            // 2. 调用defineClass将字节码转换为Class对象
            return defineClass(name, classData, 0, classData.length);
        } catch (IOException e) {
            throw new ClassNotFoundException("Failed to load class: " + name, e);
        }
    }
    
    private byte[] loadClassData(String className) throws IOException {
        // 将类名转换为文件路径
        String fileName = className.replace('.', '/') + ".class";
        String fullPath = classPath + "/" + fileName;
        
        // 读取字节码文件
        try (InputStream is = new FileInputStream(fullPath);
             ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                baos.write(buffer, 0, len);
            }
            return baos.toByteArray();
        }
    }
}

10.3 使用自定义类加载器

java 复制代码
public class CustomClassLoaderDemo {
    public static void main(String[] args) throws Exception {
        // 创建自定义类加载器
        CustomClassLoader loader = new CustomClassLoader("/path/to/classes");
        
        // 加载类
        Class<?> clazz = loader.loadClass("com.example.User");
        
        // 创建实例
        Object obj = clazz.getDeclaredConstructor().newInstance();
        
        // 验证类加载器
        System.out.println("类加载器: " + clazz.getClassLoader());
        // 输出: CustomClassLoader@...
        
        // 同一个类被不同加载器加载,不是同一个类
        CustomClassLoader loader2 = new CustomClassLoader("/path/to/classes");
        Class<?> clazz2 = loader2.loadClass("com.example.User");
        System.out.println(clazz == clazz2);  // false
    }
}

十一、常见面试题

Q1:Class.forName()ClassLoader.loadClass() 有什么区别?

方法 是否初始化 是否触发静态代码块 类加载器
Class.forName() ✅ 是 ✅ 执行 当前线程的上下文类加载器
ClassLoader.loadClass() ❌ 否 ❌ 不执行 指定的类加载器
java 复制代码
// Class.forName() 源码
public static Class<?> forName(String className) throws ClassNotFoundException {
    return forName(className, true, getCallerClassLoader());
}
// 第二个参数 initialize=true 表示会执行初始化

Q2:同一个类被不同类加载器加载,是同一个类吗?

:不是。JVM中,类的唯一标识是 类加载器 + 类全限定名

java 复制代码
ClassLoader loader1 = new CustomClassLoader();
ClassLoader loader2 = new CustomClassLoader();
Class<?> clazz1 = loader1.loadClass("com.example.User");
Class<?> clazz2 = loader2.loadClass("com.example.User");
System.out.println(clazz1 == clazz2);  // false
System.out.println(clazz1.equals(clazz2));  // false

Q3:Tomcat为什么要打破双亲委派?

:Tomcat需要实现应用隔离和热部署:

  • 每个Web应用应该有自己的类加载器
  • 同一个类在不同应用中应该是独立的
  • 如果使用双亲委派,所有应用共享同一个类加载器,无法实现隔离
  • 同时Tomcat需要支持热部署,需要能够卸载和重新加载类

Q4:静态代码块什么时候执行?

:在类初始化阶段执行,且只执行一次。触发条件包括:new实例化、访问静态变量(final常量除外)、调用静态方法、反射调用等。

Q5:<clinit>方法和<init>方法有什么区别?

对比 <clinit> <init>
执行时机 类初始化时 对象实例化时
作用 初始化静态变量、执行静态代码块 初始化实例变量、执行构造方法
执行次数 一次 每次new一次
是否有锁 有(线程安全)

十二、总结

12.1 类加载的五个阶段

阶段 核心任务 产物
加载 从.class文件获取字节流 InstanceKlass + Class对象
验证 校验字节流是否符合JVM规范 确保安全
准备 为静态变量分配内存,设置默认零值 静态变量初始值
解析 将符号引用替换为直接引用 直接引用
初始化 执行<clinit>方法 静态变量赋值为代码中的值

12.2 类加载器与双亲委派

概念 核心内容
启动类加载器 加载核心类库,C++实现,null
扩展类加载器 加载lib/ext下的类,JDK 9+改名PlatformClassLoader
应用类加载器 加载CLASSPATH下的用户类
双亲委派 先委派给父加载器,父加载器失败再自己加载
打破双亲委派 重写loadClass方法,如JDBC、Tomcat

12.3 面试金句

如果面试官问你"类加载机制",你可以这样回答:

"类加载分为加载、验证、准备、解析、初始化五个阶段。加载阶段将.class文件转换为方法区的InstanceKlass结构和堆中的Class对象;验证阶段确保字节码安全;准备阶段为静态变量分配默认值;解析阶段将符号引用转换为直接引用;初始化阶段执行静态变量赋值和静态代码块。类加载器采用双亲委派模型,子加载器先委派给父加载器,父加载器加载失败才自己尝试,这样做是为了保证核心类库的安全和唯一性。Tomcat等容器为了实现应用隔离和热部署,会打破双亲委派。"


下篇预告

理解了类加载的五阶段和双亲委派模型,我们知道类的元数据最终存储在方法区中。但方法区本身也在进化:从JDK 7的永久代到JDK 8的元空间,为什么要变?这一变化带来了什么好处?

下一篇《方法区的进化------永久代到元空间,为什么要变?》将为你揭晓答案。


如果你觉得本文有帮助,欢迎点赞、评论、转发!

相关推荐
2301_818419012 小时前
使用PyTorch构建你的第一个神经网络
jvm·数据库·python
2301_793804693 小时前
Python异步编程入门:Asyncio库的使用
jvm·数据库·python
皙然3 小时前
深度剖析:synchronized 底层实现原理(JVM 视角)
jvm
XiYang-DING3 小时前
【Java SE】JVM字符串常量池:位置、创建流程、对象个数与 `intern()`
java·开发语言·jvm
2301_810160953 小时前
NumPy入门:高性能科学计算的基础
jvm·数据库·python
add45a3 小时前
超越Python:下一步该学什么编程语言?
jvm·数据库·python
qwehjk20083 小时前
使用Seaborn绘制统计图形:更美更简单
jvm·数据库·python
2301_818419013 小时前
Python虚拟环境(venv)完全指南:隔离项目依赖
jvm·数据库·python
2401_873204654 小时前
Python深度学习入门:TensorFlow 2.0/Keras实战
jvm·数据库·python