Java 类的加载机制
概述
Java 中的每一个类或者接口【.java 】文件,在经过编译器编译之后,会生成对应的 .class 文件
类的加载机制指的是:将这些 .class 文件中大的二进制数据读入到内存中,并对数据进行校验,解析和初始化。最终,每一个类都会在方法区中保存一份他的元数据,在堆中创建一个与之对应的 class 对象。
类的生命周期
加载、验证、准备、解析、初始化、使用、卸载
类的加载过程
加载、验证、准备、解析、初始化
类加载的时机
严格意义上来说,加载和初始化,是类生命周期的两个阶段。
绝大多数情况下,遵循“什么时候初始化”来进行加载
当符合一下条件时【包括但不限于】,虚拟机内存中灭有找到对应类型信息,则必须对类进行 “初始化” 操作。:
- 使用 new 实例化对象时、读取或者设置一个类的静态字段或方法时
- 反射调用时,例如 Class.forName(“com.xxx.MyTest”)
- 初始化一个类的子类,会首先初始化子类的父类
- Java 虚拟机启动时标明的启动类
- jdk8 之后,接口中存在 default 方法,这个接口的实现类初始化时,接口会进行初始化
【初始化阶段之前,必须经过:加载、验证、准备、解析】
类的加载过程
类的加载过程包括 5 个阶段,其中【验证、准备、解析】被称为 “连接”阶段
这 5 个阶段,并不是严格意思上的按照顺序完成,在类的加载过程中,这些阶段互相混合,交叉运行,最终完成类的加载和初始化。
eg:
- 在加载阶段,需要使用验证的能力去校验字节码的正确性。
- 在解析阶段,需要使用验证的能力去校验符号引用的正确性。
- 在加载阶段,生成 Class 对象时,需要解析阶段符号应用转直接引用的能力
- ……
加载
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成一下三个事情:
- 通过一个类的全限定名取找到其对应的 .class 文件
- 将这个 .class 文件内的而精致数据读取出来,转化为方法区的运行时数据结构
- 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为堆方法区中这些数据的访问入口
注意:
- Java 虚拟机并没有规定类的字节流必须从 .class 文件中加载,在加载阶段,程序员可以通过自定义的类加载器,自定义读取的位置【网络、数据库等】
验证
.class 文件中的内容是字节码【可以由任何途径产出】,验证阶段的目的是保证文件内容的字节流符合 Java 虚拟机的规范,且这些内容信息运行后不会危害虚拟机自身的安全。
验证阶段会完成一下校验:
文件格式验证:
验证字节流是否符合 class 文件格式规范
eg:
是否以 0xCAFEBABE 开头
主次版本号是否在当前虚拟机的处理范围内
常量池中的常量是否有不被支持的类型
省略号
元数据验证:
对字节码描述的元数据信息进行语义分析,要符合 Java 语言规范。
eg:
是否继承了不允许继承的类【被 final 修饰的类】
类中的字段、方法是否和父类产生矛盾等
……
字节码验证:
对类的方法进行校验分析,确保这些方法在运行室是合法的、符合逻辑等。
符号引用验证:
发生在解析阶段,符号引用转为直接引用的时候。
eg:
确保符号引用的全限定名能找到对应的类
符号引用中的类、字段、方法允许被当前类访问
……
验证阶段非常重要,但不是必须的。 Java 虚拟机允许程序员主动取消这个阶段【缩短类的加载时间】。可以根据自身需求,使用 -Xverify:none 参数来关闭大部分的类验证措施。
准备
在这个阶段,类的静态字段信息【使用 static 修饰的变量】会得到内存分配,并且设置初始值。
- 内存分配仅包括 static 修饰的变量,不包括实例变量,实例变量等到实例化对象时分配内存。
- 初始值指的是:变量数据类型的默认值,而不是 Java 代码中的被显式赋予的值。但是,当字段信息被 final 修饰成常量时【ConstanrValue】时,这个初始值就是 Java 代码中显式赋予的值。
eg:
public static int value = 3; /** * 类变量 value 在准备阶段设置的初始值是 0,不是 3。将 value 赋值为 3 的 * putstatic 指令是在程序编译后,存放于类构造器 <clinit>() 方法中的 * 所以把 value 赋值为 3 的动作将在初始化阶段才会执行。 * 当使用 final 修饰后:public static final int value = 3 * 类变量 value 在准备阶段设置的初始值是 3,不是 0。 * /
- jdk8 取消永久代 (PermGen) 后,方法区变成了一个逻辑上的区域,这些类变量的内存实际上是分配在 Java 堆中的。
解析
在这个阶段,虚拟机会把 class 文件中,常量池内的符号引用转换为直接引用。主要解析的是类或者接口、字段、类方法、接口方法、方法类型、方法句柄等符号引用。
可以将解析阶段中,符号引用转换为直接引用的过程理解为当前加载的这个类,和他所引用的类,正式进行 “连接” 的过程。
什么是符号引用
Java 代码在编译期间,并不知道最终引用的类型,具体指向内存中哪个位置,此时会使用一个符号引用,来表示具体引用的目标是谁,符号引用可以是任意值,只要能通过这个值定位到目标。
什么是直接引用
直接引用就是可以直接或间接指向目标内存位置的指针货句柄。
引用的类型,还未加载初始化怎么办?
现这种情况,会触发这个引用对应类型的加载和初始化。
初始化
类加载的最后一个步骤,初始化的过程,就是执行类构造器 () 方法的过程。
当初始化完成后,类中 static 修饰的变量会赋予程序员实际定义的 “值”,同时类中如果存在 static 代码块,也会执行这个静态代码块里面的代码。
() 方法的作用
在准备阶段,已经对类中的 static 变量赋予了初始值【这里赋予的烈性的默认值】,() 方法的作用是给这些变量赋予程序员实际定义的 “值”。同时类中如果存在 static 代码块,也会执行这个静态代码块里面的代码。
() 方法是什么
() 方法 和 方法是不同的,它们一个是“类构造器”,一个是实例构造器。
Java 虚拟机会保证子类 () 执行强,父类的 () 已经执行完毕。而 方法则需要显式的调用父类构造器。
() 方法有编译器自动生成,但不是必须生成的,只有这个类存在 static 修饰的变量或者或者类中存在静态代码块时,才会自动生成 () 方法。
加载过程总结
当一个符合 Java 虚拟机规范的字节流文件,经过**【加载、验证、准备、解析 、初始化】**这些阶段相互协作执行完成之后,加载阶段读取到的 class 字节流信息,会按照虚拟机规定的格式,在方法区保存一份,然后在 Java 堆中,创建一个 java.lang.Class 类对象,这个对象买哦输了这个类所有信息,也提供了这个类在方法区的访问入口。
方法区中,使用同一加载器时,每个类只会有一份 class 字节流信息
Java 堆中,使用同一加载器时,每个类只有一份 java.lang.Class 类的对象
类加载器
类加载器就是用来实现通过类的全限定名,获取类字节流数据。
三层类加载器介绍
启动类加载器(Bootstrap Class Loader):
负责加载 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数制定的路径,例如 jre/lib/rt.jar 里所有的 class 文件。由 C++ 实现,不是 ClassLoader 子类。
**拓展类加载器(Extension Class Loader):**负责加载 Java 平台中扩展功能的一些 jar 包,包括 <JAVA_HOME>\lib\ext 目录中 或 java.ext.dirs 指定目录下的 jar 包。由 Java 代码实现。
**应用程序类加载器(Application Class Loader):**用户开发的应用程序,就是由它进行加载的,负责加载 ClassPath 路径下所有jar包。
双亲委派模型
任何一个类加载器在接到一个类的加载请求时,都会让其父类进行加载,只有父类无法加载(没有父类)的情况下,才尝试自己加载。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 首先要保证线程安全 synchronized (getClassLoadingLock(name)) { // 先判断这个类是否被加载过 Class<?> c = findLoadedClass(name); if (c == null) { try { // 有父类,优先交给父类尝试加载 if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父类加载失败,这里捕获异常,但不需要做任何处理 } if (c == null) { // 没有父类,或者父类无法加载,尝试自己加载 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } }
双亲委派模型的执行流程:
- 当加载一个类时,会先从应用程序类加载器的缓存里查找相应的类,如果能找到就返回对象,如果找不到就执行下面流程;
- 在扩展加载器缓存中查找相应的类,如果能找到就返回对象,如果找不到就继续下面流程;
- 在启动类加载器中查询相应的类,如果找到就返回对象,如果找不到就继续下面流程;
- 在扩展加载器中查找并加载类,如果能找到就返回对象,并将对象加入到缓存中,如果找不到就继续下面流程;
- 在应用程序类加载器中查找并加载类,如果能找到就返回对象,并将对象加入到缓存中,如果找不到就返回 ClassNotFound 异常。
- 如果某个类加载器加载了某个类,那么它会将该类的定义缓存起来,以便以后再次请求加载该类时可以直接返回缓存中的定义。
双亲委派模型好处
避免类的重复加载
当一个类加载器需要加载某个类时,会先委托给他的父类加载器去加载,如果福利加载器也找不到该类,才会自己尝试加载,避免重复加载一个类。
确保类的安全性
通过使用双亲委派模型,可以确保核心类库的安全性,同时也可以防止应用程序自己编写的类替换核心类库中的类,避免恶意代码的潜在危险。
eg: jre 里面已经提供了 String 类在启动类加载时加载,那么用户自定义一个不安全的 String 类时,按照双亲委派模型就不会加载用户自定义的不安全的 String 类,这样就可以避免非安全问题的发生了。
缺点
- 由于加载范围的限制,顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类。
打破双亲委派模型方法
重写
loadClass()
方法打破双亲委派模型原因:
类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。