JVM-类加载详情
一、为什么需要“类加载器”
在 Java 语言中,类加载并非在编译期完成,而是延迟到程序运行时进行。这种 “运行时加载” 策略虽会带来轻微的性能开销,但为 Java 应用提供了极高的灵活性,主要体现在:
- 支持面向接口编程的动态实现,可以在运行时才决定具体实现类;
- 扩展类的加载来源:通过自定义类加载器,程序能从网络、本地文件或其他动态来源加载二进制字节流作为代码(这是 Android 插件化、动态更新 APK 的核心基础)。
二、类加载的过程
Java 编译器(或其他语言编译器)会将源代码编译为存储字节码的.class
文件。JVM 并不依赖源码语言,只需.class
文件符合规范即可。类加载机制指的是:JVM 将.class
文件中的字节流加载到内存,经过校验、转换解析、初始化后,最终形成可直接使用的 Java 类型。
类加载的完整生命周期包括:加载(Loading)→ 链接 → 初始化(Initialization)→ 使用(Using)→ 卸载(Unloading)。
其中,链接过程又细分为三步:验证(Verification)→ 准备(Preparation)→ 解析(Resolution)。
注意:加载、验证、准备、初始化、卸载这 5 个阶段的启动顺序是固定的;而解析阶段则可能在初始化之后触发(为支持运行时动态绑定,如接口的实现类仅在调用时才确定)。实际执行中,各阶段常交叉进行(一个阶段可能调用或激活另一个阶段)。
1.加载:(重点)
加载阶段是“类加载机制”中的一个阶段,这个阶段通常也被称作“装载”,主要完成:
- 通过“类全名”来获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口
相对于类加载过程的其他阶段,加载阶段(准备地说,是加载阶段中获取类的二进制字节流的动作)是开发期可控性最强的阶段,因为加载阶段可以使用系统提供的类加载器(ClassLoader)来完成,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。
2.验证:(了解)
验证是链接阶段的第一步,目的是确保.class
文件的字节流符合 JVM 规范,避免危害虚拟机安全。验证过程分为 4 个阶段:
- 文件格式验证:校验
.class
文件的格式合法性,例如:是否以魔数0xCAFEBABE
开头、主 / 次版本号是否在 JVM 支持范围内等。- 元数据验证:对字节码描述的类信息进行语义分析,确保符合 Java 语言规范。例如:除
java.lang.Object
外,所有类必须有父类;子类不能继承被final
修饰的类;抽象类的子类需实现所有抽象方法等。- 字节码验证:通过数据流和控制流分析,确保类的方法体运行时不会危害 JVM。例如:类型转换必须合法(子类对象可赋值给父类,反之则不行);跳转指令不能超出方法体范围等。
- 符号引用验证:校验符号引用的有效性,例如:符号引用描述的类、字段、方法是否存在;访问权限(
private
/protected
/public
/ 默认)是否允许当前类访问等。
3.准备:(了解)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的知识点,首先是这时候进行内存分配的仅包括类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量定义为:
public static int value = 12;
那么变量value在准备阶段过后的初始值为0而不是12,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为12的动作将在初始化阶段才会被执行。
上面所说的“通常情况”下初始值是零值,那相对于一些特殊的情况,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设上面类变量value定义为:
public static final int value = 123;
编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value设置为123。
4.解析:(了解)
解析阶段将常量池中的符号引用替换为直接引用:
- 符号引用:用字符串描述目标(如类名、方法名),与 JVM 内存布局无关,目标不一定已加载。
- 直接引用:指向目标的指针、偏移量或句柄,与内存布局相关,目标必定已存在。
JVM 未规定解析的具体时间,仅要求在执行 13 条特定字节码指令(如new
、getfield
、invokevirtual
等)前完成对应符号引用的解析。解析动作主要针对类 / 接口、字段、类方法、接口方法四类符号引用。
5.初始化:(了解)
初始化是类加载的最后一步,目的是根据程序逻辑初始化类变量和其他资源,本质是执行类构造器<clinit>()
方法的过程。
触发初始化的场景:
- 执行
new
(实例化对象)、getstatic
(读取静态字段)、putstatic
(修改静态字段)、invokestatic
(调用静态方法)字节码指令时(final
修饰且已入常量池的静态字段除外)。- 通过反射(
java.lang.reflect
包)调用类时。- 初始化子类时,若父类未初始化则先初始化父类。
- JVM 启动时,初始化包含
main()
方法的主类。
<clinit>()
方法的特性:
- 由编译器自动收集类中静态变量的赋值动作和静态代码块(
static {}
),按源码顺序合并生成。- 静态代码块只能访问定义在其之前的变量(可赋值给之后的变量,但不能访问)。
- 与实例构造器
<init>()
不同,<clinit>()
无需显式调用父类构造器,JVM 会保证子类<clinit>()
执行前,父类的<clinit>()
已完成(因此java.lang.Object
的<clinit>()
是第一个执行的)。- 接口的
<clinit>()
无需先执行父接口的<clinit>()
,仅当父接口的变量被使用时才初始化;接口的实现类初始化时,接口的<clinit>()
不会执行。- JVM 保证
<clinit>()
在多线程环境中被正确同步:若多个线程同时初始化一个类,仅一个线程执行<clinit>()
,其他线程阻塞等待(若<clinit>()
耗时过长,可能导致线程阻塞)。- 若类中无静态变量赋值和静态代码块,编译器可不生成
<clinit>()
。
三、类加载器
1.类与类加载器
一个类在 JVM 中的唯一性由加载它的类加载器和类本身共同决定。即:两个类若来源于同一.class
文件,但由不同类加载器加载,则视为不同的类(equals()
、isInstance()
等方法返回false
)。
2.双亲委派模型
从虚拟机的角度来说,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),该类加载器使用C++语言实现,属于虚拟机自身的一部分。另外一种就是所有其它的类加载器,这些类加载器是由Java语言实现,独立于JVM外部,并且全部继承自抽象类java.lang.ClassLoader。
- 启动类加载器(Bootstrap ClassLoader):负责加载JAVA_HOME\lib目录中并且能被虚拟机识别的类库到JVM内存中,如果名称不符合的类库即使放在lib目录中也不会被加载。该类加载器无法被Java程序直接引用。
- 扩展类加载器(Extension ClassLoader):该加载器主要是负责加载JAVA_HOME\lib\,该加载器可以被开发者直接使用。
- 应用程序类加载器(Application ClassLoader):该类加载器也称为系统类加载器,它负责加载用户类路径(Classpath)上所指定的类库,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型的工作过程为:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的加载器都是如此,因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法完成该加载请求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。
3.打破双亲委派模型方法
尽管双亲委派机制有着诸多好处,但有时我们也需要打破它,比如在某些场景下需要加载自定义的类库,或者需要实现热部署等功能。这时,我们就需要自定义类加载器,并在其中打破双亲委派机制。
以Java代码为例,我们可以通过继承ClassLoader类并重写loadClass方法来打破双亲委派机制。在loadClass方法中,我们可以根据需要自行加载类文件,并通过defineClass方法将其转换为Class对象。
public class MyClassLoader extends ClassLoader {@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {// 首先检查是否已经加载该类Class<?> clazz = findLoadedClass(name);if (clazz == null) {try {// 尝试委托给父类加载器加载clazz = getParent().loadClass(name);} catch (ClassNotFoundException e) {// 如果父类加载器无法加载,则自行加载该类byte[] classData = getClassData(name);if (classData == null) {throw new ClassNotFoundException();} else {clazz = defineClass(name, classData, 0, classData.length);}}}return clazz;}private byte[] getClassData(String name) {// 从本地磁盘或网络等获取类文件数据// ...}}
4.自定义类加载器
自定义类加载器需继承java.lang.ClassLoader
,推荐重写findClass()
方法(而非loadClass()
),原因是:JDK 1.2 后双亲委派模型已通过loadClass()
实现,重写findClass()
可避免破坏委派逻辑。
/**
* 一、ClassLoader加载类的顺序
* 1.调用 findLoadedClass(String) 来检查是否已经加载类。
* 2.在父类加载器上调用 loadClass 方法。如果父类加载器为 null,则使用虚拟机的内置类加载器。
* 3.调用 findClass(String) 方法查找类。
* 二、实现自己的类加载器
* 1.获取类的class文件的字节数组
* 2.将字节数组转换为Class类的实例
* @author lei 2011-9-1
*/
public class ClassLoaderTest {public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException {//新建一个类加载器MyClassLoader cl = new MyClassLoader("myClassLoader");//加载类,得到Class对象Class<?> clazz = cl.loadClass("classloader.Animal");//得到类的实例Animal animal=(Animal) clazz.newInstance();animal.say();}
}class Animal{public void say(){System.out.println("hello world!");}
}class MyClassLoader extends ClassLoader {//类加载器的名称private String name;//类存放的路径private String path = "E:\\workspace\\Algorithm\\src";MyClassLoader(String name) {this.name = name;}MyClassLoader(ClassLoader parent, String name) {super(parent);this.name = name;}/*** 重写findClass方法*/@Overridepublic Class<?> findClass(String name) {byte[] data = loadClassData(name);return this.defineClass(name, data, 0, data.length);}public byte[] loadClassData(String name) {try {name = name.replace(".", "//");FileInputStream is = new FileInputStream(new File(path + name + ".class"));ByteArrayOutputStream baos = new ByteArrayOutputStream();int b = 0;while ((b = is.read()) != -1) {baos.write(b);}return baos.toByteArray();} catch (Exception e) {e.printStackTrace();}return null;}
}
最后:一道面试题
能不能自己写个类叫java.lang.System?
答案:通常不可以,但可以采取特殊方式达到这个需求。
解释:类加载的双亲委派机制确保父加载器优先加载。java.lang.System
由启动类加载器(Bootstrap ClassLoader
)加载,若用户自定义同名类,由于启动类加载器已优先加载系统自带的System
,自定义类无法被加载。
特殊方式:
自定义类加载器,并重写loadClass()
跳过双亲委派,直接从自定义路径加载java.lang.System
(需确保该类不在启动类加载器的搜索路径中)。但不推荐这么做,可能导致 JVM 运行异常(系统类依赖System
的特定实现)。