JVM 类加载机制

232 阅读18分钟

从 JDK 8 开始,方法区(Method Area)  被废弃,取而代之的是 元空间(Metaspace) 。元空间是 JVM 的一部分,用于存储类的元信息(如类的结构、方法、字段等)。元空间与方法区的主要区别在于,元空间使用的是 本地内存(Native Memory) ,而不是堆内存。

以下是基于最新 JVM(如 JDK 8+)的 Java 类加载过程 的详细介绍。


1. 什么是类加载过程?

  • 类加载过程 是指 JVM 将类的字节码加载到内存中,并将其转换为可以被程序使用的 Class 对象的过程。
  • 类加载是 Java 的动态特性之一,类的加载、链接和初始化都是在运行时完成的。

2. 类加载的主要阶段

Java 类加载过程分为以下几个阶段:

2.1 加载(Loading)
  • JVM 根据类的全限定名(如 com.example.MyClass)找到对应的 .class 文件,并将其字节码加载到内存中。
  • 加载完成后,JVM 会在方法区(JDK 8+ 为元空间)中生成一个 Class 对象,用于表示该类的运行时数据结构。
加载的来源
  • 类的字节码可以来自以下几种来源:

    1. 文件系统:从 .class 文件加载。
    2. 网络:通过网络加载(如 Applet)。
    3. 动态生成:通过字节码生成工具(如 ASM、Javassist)动态生成类。
加载的实现
  • 类的加载由 类加载器(ClassLoader)  完成。

  • JVM 提供了以下几种类加载器:

    1. 启动类加载器(Bootstrap ClassLoader)

      • 加载 JDK 的核心类(如 java.lang.*)。
      • 使用本地代码实现,无法直接访问。
    2. 扩展类加载器(Extension ClassLoader)

      • 加载扩展类库(JAVA_HOME/lib/ext)。
    3. 应用类加载器(Application ClassLoader)

      • 加载应用程序的类路径(CLASSPATH)中的类。
    4. 自定义类加载器

      • 用户可以通过继承 ClassLoader 类实现自定义类加载器。

2.2 链接(Linking)

链接是将类的二进制数据合并到 JVM 的运行时环境中的过程,分为以下三个子阶段:

  1. 验证(Verification)

    • 检查类文件的字节码是否符合 JVM 规范,确保安全性。
    • 例如,检查类文件的魔数、版本号、常量池的正确性等。
    • 如果验证失败,会抛出 VerifyError
  2. 准备(Preparation)

    • 为类的静态变量分配内存,并初始化为默认值。
    • 示例:
public static int a = 10;
    • 在准备阶段,a 的值为默认值 0,而不是 10
  1. 解析(Resolution)

    • 将常量池中的符号引用(Symbolic Reference)替换为直接引用(Direct Reference)。
    • 符号引用是指类、方法、字段的名称,而直接引用是指内存地址或偏移量。

2.3 初始化(Initialization)
  • 初始化是类加载的最后阶段,负责执行类的静态初始化块和静态变量的赋值操作。
  • 初始化阶段会执行类的 <clinit> 方法,该方法由编译器自动生成,包含所有静态变量的赋值和静态代码块的内容。
初始化的触发条件

类的初始化会在以下情况下触发:

  1. 创建类的实例
MyClass obj = new MyClass();

访问类的静态变量或静态方法

MyClass.staticMethod();

反射

Class.forName("com.example.MyClass");
  1. 子类初始化

    • 如果初始化子类,会先初始化父类。
延迟加载(Lazy Loading)
  • 类的加载和初始化是延迟的,只有在真正使用时才会加载和初始化。
  • 例如,访问一个类的常量不会触发类的初始化:
System.out.println(MyClass.CONSTANT);

3. JDK 8+ 中的元空间(Metaspace)

3.1 什么是元空间?
  • 元空间(Metaspace)  是 JDK 8 引入的,用于替代方法区。
  • 元空间存储类的元信息(如类的结构、方法、字段等),而类的实例数据仍然存储在堆中。
3.2 元空间的特点
  1. 使用本地内存

    • 元空间使用的是本地内存(Native Memory),而不是堆内存。
    • 这避免了方法区内存不足的问题。
  2. 动态扩展

    • 元空间的大小可以动态扩展,默认情况下只受限于系统的可用内存。
  3. 可配置

    • 可以通过 JVM 参数配置元空间的大小:
-XX:MetaspaceSize=128m       # 初始大小
-XX:MaxMetaspaceSize=512m    # 最大大小

4. 类加载的双亲委派模型

4.1 什么是双亲委派模型?
  • 双亲委派模型是类加载器的一种工作机制。
  • 当一个类加载器加载类时,会先将请求委派给父类加载器,只有当父类加载器无法加载时,才会尝试自己加载。
4.2 工作流程
  1. 启动类加载器尝试加载类。
  2. 如果启动类加载器无法加载,则委派给扩展类加载器。
  3. 如果扩展类加载器无法加载,则委派给应用类加载器。
  4. 如果应用类加载器也无法加载,则由当前类加载器尝试加载。
4.3 优势
  • 避免类的重复加载。
  • 确保核心类(如 java.lang.String)不会被自定义类加载器篡改。

5. 类加载过程的示例

以下是一个简单的示例,展示类加载的主要过程:

示例代码
public class ClassLoadingExample {
    static {
        System.out.println("ClassLoadingExample: Static block executed");
    }

    public static final String CONSTANT = "Hello, World!";

    public static void staticMethod() {
        System.out.println("ClassLoadingExample: Static method executed");
    }

    public static void main(String[] args) throws ClassNotFoundException {
        // 1. 访问常量,不会触发类的初始化
        System.out.println(ClassLoadingExample.CONSTANT);

        // 2. 调用静态方法,触发类的初始化
        ClassLoadingExample.staticMethod();

        // 3. 使用反射加载类
        Class.forName("ClassLoadingExample");
    }
}
输出结果
Hello, World!
ClassLoadingExample: Static block executed
ClassLoadingExample: Static method executed

6. 总结

阶段描述
加载(Loading)将类的字节码加载到内存中,生成 Class 对象。
链接(Linking)验证类文件的正确性,分配静态变量内存,解析符号引用。
初始化(Initialization)执行静态初始化块和静态变量的赋值操作。
  • JDK 8+ 的变化

    • 方法区被废弃,改用元空间(Metaspace)。
    • 元空间使用本地内存,避免了方法区内存不足的问题。

类加载时机

类加载的时机 是指 JVM 何时将类的字节码加载到内存中并开始类加载过程(包括加载、链接和初始化)。在 Java 中,类的加载是 延迟加载(Lazy Loading)  的,只有在需要时才会触发类的加载和初始化。

以下是类加载的具体时机和触发条件:


1. 类加载的触发条件

1.1 主动引用(会触发类加载和初始化)

以下情况会触发类的加载和初始化:

  1. 创建类的实例

    • 当通过 new 关键字创建类的实例时,会触发类的加载和初始化。
    • 示例:
MyClass obj = new MyClass(); // 触发 MyClass 的加载和初始化

访问类的静态变量

  • 当访问类的静态变量时,会触发类的加载和初始化。
  • 示例:
System.out.println(MyClass.staticVar); // 触发 MyClass 的加载和初始化

调用类的静态方法

  • 当调用类的静态方法时,会触发类的加载和初始化。
  • 示例:
MyClass.staticMethod(); // 触发 MyClass 的加载和初始化

通过反射操作类

  • 使用 Class.forName() 或其他反射机制加载类时,会触发类的加载和初始化。
  • 示例:
Class.forName("com.example.MyClass"); // 触发 MyClass 的加载和初始化

初始化子类时

  • 当初始化子类时,会先触发父类的加载和初始化。
  • 示例:
class Parent {
    static {
        System.out.println("Parent initialized");
    }
}

class Child extends Parent {
    static {
        System.out.println("Child initialized");
    }
}

public class Test {
    public static void main(String[] args) {
        Child child = new Child(); // 先触发 Parent 的加载和初始化,再触发 Child 的加载和初始化
    }
}
1.2 被动引用(不会触发类加载和初始化)

以下情况不会触发类的加载和初始化:

  1. 访问类的常量

    • 如果访问的是类的常量(static final 修饰的变量),不会触发类的加载和初始化,因为常量在编译期已经被存储到调用类的常量池中。
    • 示例:
public class MyClass {
    public static final String CONSTANT = "Hello, World!";
}

public class Test {
    public static void main(String[] args) {
        System.out.println(MyClass.CONSTANT); // 不会触发 MyClass 的加载和初始化
    }
}

通过数组定义类的引用

  • 定义类的数组不会触发类的加载和初始化。
  • 示例:
MyClass[] array = new MyClass[10]; // 不会触发 MyClass 的加载和初始化

访问类的静态字段,但该字段在父类中定义

  • 如果访问的是父类的静态字段,不会触发子类的加载和初始化。
  • 示例:
class Parent {
    static int staticVar = 10;
}

class Child extends Parent {}

public class Test {
    public static void main(String[] args) {
        System.out.println(Child.staticVar); // 只会触发 Parent 的加载和初始化,不会触发 Child 的加载和初始化
    }
}

2. 类加载的时机

2.1 JVM 启动时
  • 当 JVM 启动时,会加载包含 main() 方法的类。
  • 示例:
public class MainClass {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}
    • 在运行 java MainClass 时,JVM 会加载 MainClass 并执行其 main() 方法。
2.2 类的主动使用时
  • 当类被主动引用时(如创建实例、访问静态变量、调用静态方法等),会触发类的加载和初始化。
2.3 动态加载时
  • 使用反射(如 Class.forName())或动态代理时,会触发类的加载和初始化。
2.4 类加载器加载时
  • 当类加载器加载类时(如自定义类加载器),会触发类的加载。

3. 类加载的延迟性(Lazy Loading)

Java 的类加载是延迟加载的,只有在需要时才会加载类。这种机制可以提高程序的启动速度和内存使用效率。

示例:延迟加载
public class LazyLoadingExample {
    static {
        System.out.println("Class loaded");
    }

    public static void main(String[] args) {
        System.out.println("Main method executed");
        // 此时 LazyClass 尚未加载
        LazyClass obj = null;
        // 只有在真正使用 LazyClass 时才会加载
        obj = new LazyClass();
    }
}

class LazyClass {
    static {
        System.out.println("LazyClass loaded");
    }
}

输出结果

Main method executed
LazyClass loaded

4. 类加载的双亲委派模型

4.1 什么是双亲委派模型?
  • 双亲委派模型是类加载器的一种工作机制。
  • 当一个类加载器加载类时,会先将请求委派给父类加载器,只有当父类加载器无法加载时,才会尝试自己加载。
4.2 工作流程
  1. 启动类加载器尝试加载类。
  2. 如果启动类加载器无法加载,则委派给扩展类加载器。
  3. 如果扩展类加载器无法加载,则委派给应用类加载器。
  4. 如果应用类加载器也无法加载,则由当前类加载器尝试加载。
4.3 优势
  • 避免类的重复加载。
  • 确保核心类(如 java.lang.String)不会被自定义类加载器篡改。

5. 总结

触发条件是否触发类加载和初始化
创建类的实例
调用类的静态方法
访问类的静态变量
使用反射加载类
初始化子类父类会被加载和初始化
访问类的常量(static final
定义类的数组
访问父类的静态字段(通过子类)只加载父类,不加载子类

常量和静态变量

常量静态变量是 Java 中两种不同的变量类型,它们在定义方式、存储位置、生命周期和使用场景上有显著区别。以下是详细的对比和解释:


1. 定义方式

常量
  • 使用 final 关键字修饰的变量,表示值一旦初始化后就不能再更改。
  • 常量通常是 static final 的,因为它们的值是固定的,且与类相关。
  • 示例:
public static final int MAX_VALUE = 100;
静态变量
  • 使用 static 关键字修饰的变量,表示它属于类,而不是某个实例。
  • 静态变量的值可以被修改。
  • 示例:
public static int counter = 0;

2. 存储位置

常量
  • 如果常量是 static final,它的值会在编译期被直接替换到字节码中(常量池)。
  • 常量不会存储在堆或栈中,而是存储在 方法区(JDK 8+ 为元空间)或直接内联到代码中。
静态变量
  • 静态变量存储在 方法区(JDK 8+ 为元空间)中,属于类的运行时数据结构的一部分。
  • 静态变量的值在运行时存储在内存中,并且可以被修改。

3. 生命周期

常量
  • 常量的生命周期与类的生命周期一致。
  • 常量在类加载时初始化,并且在类卸载时销毁。
静态变量
  • 静态变量的生命周期也与类的生命周期一致。
  • 静态变量在类加载时初始化,并且在类卸载时销毁。

4. 可变性

常量
  • 常量的值是不可变的,一旦赋值后就不能再修改。
  • 如果尝试修改常量的值,编译器会报错。
静态变量
  • 静态变量的值是可变的,可以在程序运行时被修改。

5. 编译期行为

常量
  • 如果常量是 static final,它的值会在编译期被直接替换到字节码中。
  • 示例:
public static final int MAX_VALUE = 100;

public static void main(String[] args) {
    System.out.println(MAX_VALUE);
}
  • 编译后,System.out.println(MAX_VALUE) 会被替换为 System.out.println(100)
静态变量
  • 静态变量的值不会在编译期被替换,而是在运行时通过类的加载器加载。
  • 示例:
public static int counter = 0;

public static void main(String[] args) {
    System.out.println(counter);
}
  • 编译后,counter 的值仍然需要在运行时通过类加载器获取。

6. 使用场景

常量
  • 用于定义不会改变的值,例如数学常量、配置参数等。
  • 示例:
public static final double PI = 3.14159;
public static final String APP_NAME = "MyApplication";
静态变量
  • 用于存储需要在类的所有实例之间共享的数据。
  • 示例:
public static int instanceCount = 0;

public MyClass() {
    instanceCount++;
}

7. 示例代码对比

常量示例
public class Constants {
    public static final int MAX_USERS = 100;

    public static void main(String[] args) {
        System.out.println("Max users allowed: " + MAX_USERS);
        // MAX_USERS = 200; // 编译错误,常量的值不能修改
    }
}
静态变量示例
public class StaticVariableExample {
    public static int counter = 0;

    public StaticVariableExample() {
        counter++;
    }

    public static void main(String[] args) {
        System.out.println("Initial counter: " + counter);
        new StaticVariableExample();
        new StaticVariableExample();
        System.out.println("Counter after creating instances: " + counter);
    }
}

输出

Initial counter: 0
Counter after creating instances: 2

8. 总结对比表

特性常量 (final)静态变量 (static)
定义方式使用 final 修饰,通常是 static final使用 static 修饰
存储位置编译期存储在常量池或方法区(元空间)方法区(元空间)
生命周期与类的生命周期一致与类的生命周期一致
可变性不可变可变
编译期行为编译期直接替换为字面值运行时通过类加载器加载
使用场景定义不会改变的值(如常量、配置参数)定义需要在类的所有实例之间共享的值

9. 注意事项

  1. 常量的值在编译期确定

    • 如果常量的值依赖于运行时计算,则不能被替换为字面值。
    • 示例:
public static final int RUNTIME_CONSTANT = new Random().nextInt(100); // 编译错误
  1. 静态变量的线程安全性

    • 静态变量是全局共享的,可能会引发线程安全问题。
    • 如果多个线程同时修改静态变量,需要使用同步机制。
  2. 常量的优化

    • 常量的值会被直接替换到字节码中,因此修改常量的值需要重新编译所有引用该常量的类。

编译期赋值

在 Java 中,编译期被赋值的内容主要是那些在编译时就可以确定其值的变量或表达式。除了常量(static final 修饰的变量),还有以下几种情况:


1. 编译期常量(Compile-Time Constants)

1.1 常量(static final 修饰的变量)
  • 定义static final 修饰的变量,如果其值在编译期可以确定,则会被直接替换为字面值。
  • 示例
public class Constants {
    public static final int MAX_USERS = 100; // 编译期常量
    public static final String APP_NAME = "MyApp"; // 编译期常量
}
    • 在编译时,MAX_USERS 和 APP_NAME 的值会被直接替换到字节码中。

2. 字面量(Literals)

  • 定义:字面量是直接写在代码中的固定值,它们在编译期就可以确定。
  • 示例
int number = 42; // 整数字面量
double pi = 3.14159; // 浮点数字面量
char letter = 'A'; // 字符字面量
String greeting = "Hello, World!"; // 字符串字面量
boolean flag = true; // 布尔字面量

3. 编译期可计算的表达式

  • 定义:如果表达式的所有操作数都是编译期常量,且操作本身可以在编译期计算,则表达式的结果会在编译期确定。
  • 示例
public class CompileTimeExpressions {
    public static final int A = 10;
    public static final int B = 20;
    public static final int SUM = A + B; // 编译期计算
    public static final String MESSAGE = "Hello, " + "World!"; // 编译期拼接
}
    • 在编译时,SUM 的值会被计算为 30MESSAGE 的值会被计算为 "Hello, World!"

4. 枚举常量

  • 定义:枚举类型的每个枚举值在编译期就会被确定。
  • 示例
public enum Color {
    RED, GREEN, BLUE;
}
    • Color.REDColor.GREEN 和 Color.BLUE 是编译期确定的常量。

5. 数组的长度(在某些情况下)

  • 定义:如果数组的长度是由编译期常量指定的,则数组的长度在编译期就可以确定。
  • 示例
public class ArrayExample {
    public static final int SIZE = 5;
    public static final int[] NUMBERS = new int[SIZE]; // 编译期确定数组长度
}

6. switch 语句中的 case 标签

  • 定义switch 语句中的 case 标签必须是编译期常量。
  • 示例
public class SwitchExample {
    public static final int OPTION_ONE = 1;
    public static final int OPTION_TWO = 2;

    public static void main(String[] args) {
        int choice = 1;
        switch (choice) {
            case OPTION_ONE: // 编译期常量
                System.out.println("Option One");
                break;
            case OPTION_TWO: // 编译期常量
                System.out.println("Option Two");
                break;
        }
    }
}

7. 注解中的属性值

  • 定义:注解的属性值必须是编译期常量。
  • 示例
public @interface MyAnnotation {
    String value();
}

@MyAnnotation(value = "Hello") // 编译期常量
public class AnnotatedClass {}

8. 泛型中的类型参数

  • 定义:泛型中的类型参数在编译期被擦除为原始类型(Type Erasure)。
  • 示例
public class GenericExample<T> {
    public void printClass() {
        System.out.println("Class: " + T.class); // 编译期擦除
    }
}
    • 在编译期,T 会被擦除为其上界(默认为 Object)。

9. 静态初始化块中的常量

  • 定义:静态初始化块中的常量在类加载时被初始化,但如果它们是 static final 且值可以在编译期确定,则会直接被替换。
  • 示例
public class StaticBlockExample {
    public static final int VALUE;

    static {
        VALUE = 42; // 编译期确定
    }
}

10. 编译期优化的其他场景

  • 定义:编译器会对某些代码进行优化,将其结果在编译期确定。
  • 示例
public class OptimizationExample {
    public static final int A = 10;
    public static final int B = 20;
    public static final int RESULT = A * B; // 编译期优化
}
    • RESULT 的值会在编译期被计算为 200

总结

类型是否编译期确定示例
常量(static finalpublic static final int MAX = 100;
字面量(Literals)int number = 42;
编译期可计算的表达式public static final int SUM = A + B;
枚举常量Color.RED
数组的长度(固定长度)public static final int[] arr = new int[5];
switch 的 case 标签case OPTION_ONE:
注解中的属性值@MyAnnotation(value = "Hello")
泛型中的类型参数是(类型擦除)T 在编译期被擦除为 Object 或其上界
静态初始化块中的常量是(如果是常量)static { VALUE = 42; }

注意事项

  1. 运行期常量

    • 如果变量的值依赖于运行时计算,则不能在编译期确定。
    • 示例:
public static final int RUNTIME_CONSTANT = new Random().nextInt(100); // 编译错误
  1. 常量折叠(Constant Folding)

    • 编译器会对常量表达式进行优化,将其结果直接替换到字节码中。

非编译期数值赋值常量会编译错误

这行代码:

public static final int RUNTIME_CONSTANT = new Random().nextInt(100);

会导致编译错误,因为 new Random().nextInt(100) 的值是在运行时才能确定的,而编译期常量(static final)要求其值必须在编译时就能确定。


原因分析

1. 编译期常量的要求
  • 在 Java 中,static final 修饰的变量如果被用作 编译期常量,其值必须是 编译时可确定的常量表达式

  • 编译时常量表达式的定义(根据 JLS - Java Language Specification):

    • 只能包含字面量(如 10"Hello")、常量变量(static final 且值已确定)、基本运算符(如 +*)等。
    • 不能包含运行时计算的值(如方法调用、对象实例化等)。
2. 为什么会编译失败?
  • new Random().nextInt(100) 是一个方法调用,其结果只有在运行时才能确定。
  • 因此,编译器无法在编译时为 RUNTIME_CONSTANT 赋值,这违反了编译期常量的要求。
3. 编译器错误信息

如果尝试编译这段代码,编译器会抛出类似以下的错误:

error: constant expression required
public static final int RUNTIME_CONSTANT = new Random().nextInt(100);

如何解决?

1. 如果需要运行时计算
  • 如果变量的值需要在运行时计算,则可以去掉 final 或者不将其用作编译期常量。
  • 示例:
public static final int RUNTIME_CONSTANT;

static {
    RUNTIME_CONSTANT = new Random().nextInt(100); // 运行时赋值
}
2. 如果需要编译期常量
  • 如果变量需要作为编译期常量,则必须使用编译时可确定的值。
  • 示例:
public static final int COMPILE_TIME_CONSTANT = 42; // 编译期常量

总结

  • static final 编译期常量 的值必须在编译时确定。
  • new Random().nextInt(100)  是运行时计算的值,因此不能用作编译期常量。
  • 如果尝试将运行时计算的值赋给编译期常量,编译器会抛出 constant expression required 错误,导致编译失败。