系列文章:
深入Java虚拟机之 – 总结面试篇
深入Java虚拟机之 — JVM的爱恨情仇
JAVA 垃圾回收机制(一) — 对象回收与算法初识
JAVA 垃圾回收机制(二) — GC回收具体实现
深入Java虚拟机之 – 类文件结构(字节码)
深入Java虚拟机之 – 类加载机制
一、类加载机制
虚拟机把描述类的数据从Class文件 (二进制流) 加载到内存,并对数据结构进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是Java的类加载机制。如下图:
二、类加载的时机
类从加载到虚拟机的内存开始,到卸载出内存结束,中间会经历7个阶段,即加载、验证、准备、解析、初始化、使用和卸载;其中 验证、准备和解析也被称为连接,如下图:
其中,加载、验证、准备、初始化和卸载这五个顺序是固定的,而解析则不一定,因为Java是动态语言,它支持动态绑定,或在初始化后开始;现在对这些状态进行解释分析
2.1 加载
加载过程主要完成以下3件事
- 通过一个类的全限名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表这个类的 java.lang.Class对象,作为方法区这个类的各种数据和访问入口
而获取二进制字节流的方式可以有很多种,不一定是通过类的全限名;比如从 ZIP 读取,网络中读取或者动态代理读取等等。
加载阶段和连接阶段的部分内容,是交叉进行的。
2.2 验证
这一阶段是为了确保 Class 文件中的字节流是否符合当前虚拟机的要求,并且不会伤害到虚拟机自身安全;主要分为4个模块的验证。
2.2.1 文件格式验证
文件格式主要验证 字节流是否符合 Class 的规范,如:
- 是否以魔术 OXCAFEBABE 开头
- 主、次版本号是否在当前虚拟机的处理范围内
- 常量池的常量中是否存在不被支持的类型等等
实际上,验证远远不止上面这些,它的主要目的就是为了把字节流正确的解析并存储在方法区之内,只有通过了这个阶段,字节流才会流进内存的方法区中进行存储,后面的验证都是基于方法区的存储结构进行的,不会再操作字节流。
2.2.2 元数据验证
这个阶段是对字节码描述的信息进行语义分析,比如:
- 是否有父类(除了 Object 类,所有的类都应该有父类)
- 这个类的父类是否继承了不允许被继承的类 (被final修饰过的类)等等
总之,就是验证这个字节码的信息符不符合Java的语义,保证不符合Java语言规范的元数据类型。
2.2.3 字节码验证
该验证主要是分析数据流和控制流,确定语义是合法的,符合逻辑的。在元数据验证验证之后,还需要字节码的验证,比如操作栈放置了一个 int 类型的数据,在使用时,却按 long 类型来加载本地变量表中。实际上,字节码的验证大部分是避免Java代码在运行或者使用时的一些保护措施。
2.2.4 符号引用验证
最后一个阶段的验证是虚拟机将符号引用转化为直接引用的时候,这个转换动作将在连接的第三阶段 – 解析阶段进行。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。通常校验以下内容:
- 符号引用中通过字符串描述的全限定名能否找到对应的类,或者有没有访问权限。
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段等等
总的来说,符号引用验证是确保解析动作能够正常运行的保障之一,如果无法通过符号引用验证,则会抛出NoSuchMethodError 等方法。
2.3 准备
准备阶段正式为类变量分配内存和设置初始值的阶段,这些变量所使用的内存将在方法区中进行分配。需要注意的时候,这里方法区的内存分配仅包含类变量(即被static修饰的变量),而不包括实例变量,实例变量讲在对象初始化的时候被分配在 Java 堆中;然后,这里说的初始值,其实是讲数据类型置为零值,假设
public static int value = 123;
那么它在准备阶段过后的初始值为0而不是123,因为此时并未执行任何Java方法。但如果是用 final 修饰,则准备阶段还是123.
2.4 解析
解析是把符号引用转换为直接引用的过程;在 class 文件格式中,符号引用常常以,CONSTANT_FIELDREF_INFO等等类型的常量出现。然后来理解一下符号引号和直接引用的含义:
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,且引用的目标不一定已经加载到内存中。
- 直接引用:直接引用可以是一个直接指向目标的指针,相对偏移量或是一个能间接访问目标的句柄。
要解析一个符号引号,通过通过以下几种方式:
2.4.1 类或接口的解析
如果当前代码所处的类为D,用N表示一个未被解析过的类或接口 的直接引用,类用C来表示,那虚拟机的解析的过程如下:
- 如果C不是一个数组,那虚拟机在会把它的全限定名传递给D的类加载器;在这个过程中,又会触发其他相关类的加载动作,例如加载它的父类;如果加载过程失败,则解析失败
- 如果C是一个数组,并且数组的元素为对象,,那将会按照第1点的规则加载数组元素类型。
- 如果上面都没有发生异常,则C在虚拟机中,已经成为一个有效的类或接口了。但解析完成之前,还需要进行符号引用验证,确保D对C有访问权限。
2.4.2 字段解析
要解析一个重未被解析过得字段符号引用,首先会先解析字段所属的类或接口的符号引用。如果在解析这个类或接口的符号引用发生异常,则字段解析失败;如果解析成功,则继续校验:
- 如果C本身的简单名称和字段描述符都与目标匹配的字段,则解析结束,返回这个字段的直接引用,查找结束。
- 如果C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,有则返回。
- 如果C不是 Object 类,则也会递归搜索父类,有则返回。
- 否则,抛出 NoSuchFieldError 异常。
以上查找返回直接引用之后,还会对权限进行验证,如果失败,也会抛出IllegalAccessError 的异常。
2.4.3 类方法解析
类方法的解析,也需要先经过类和接口的解析,解析成功才会继续:
- 类方法和接口方法引号引用的常量类型定义是分开的,如果在方法表中发现 class_index 中索引的是C的接口,则直接抛出 IncompatibleClassChangeError 异常
- 经过了第一步,如果C类中,有与之简单名称和描述符都匹配的方法,则返回直接引用。
- 若没有,则在C类中的父类继续查找,有则返回
- 否则,则在 C 类实现的接口列表和父类接口中递归去查找,有,则说明C类是个抽象类,返回直接引用,否则抛异常。
- 若都失败了,则抛出 NoSuchMethodError 异常
以上查找返回直接引用之后,还会对权限进行验证,如果失败,也会抛出IllegalAccessError 的异常。
2.4.3 接口方法解析
接口方法也需要先经过类和接口的解析,解析成功才会继续,如果解析成功,依然用C表示这个接口,接下来才会继续去解析;接口方法先回判断 class_info 中的索引C是个类而不是接口,就会抛出异常,否则则继续在接口C或者它的父接口中去继续查找匹配的简单名称和描述符,符合则返回,否则抛出异常。
2.4 初始化
初始化是类加载的最后一步,也是真正执行类中定义的 Java 程序代码,初始化阶段是执行类构造器 <client>()
方法的过程。<client>()
方法就是我们熟悉的 static{ }
静态语句块,比如初始化一些变量,或加载JNI 库时都是 static 模块中。静态语句块只能访问到定义在它之前的变量,在它之后的,能赋值,但不能访问,如:
static {
i = 0;
System.out.println(i); //编译器报错,
}
static int i = 1;
对于初始化阶段,虚拟机严格规范了有且只有5中情况下,必须对类进行初始化:
- 当遇到 new 、 getstatic、putstatic或invokestatic 这4条语句是,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
- 使用 reflect 反射时,如果类没初始化,需要触发其初始化
- 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化
- 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
- 当使用动态语言时,如果一个 MethodHandle 实例的最后解析结构为 REF_getStatic、REF_putStatic、REF_invokeStatic、的方法句柄,并且这个句柄没有初始化,则需要先触发器初始化
接着我们用代码验证一下:
public class Parent{
static {
System.out.println("Parent");
}
public static int value = 123;
}
public class Child extends Parent{
static {
System.out.println("Child");
}
public static int c_value = 123;
}
//mian 中执行
public static void main(String[] args) {
//记得分别注释运行,不然 Parent 被初始化了,就看不到效果了
System.out.println(Child.value);
System.out.println(Child.c_value);
}
输出分别如下:
// 运行 System.out.println(Child.value);
Parent // 因为 value 是父类的,所以只会触发 Parent 的static 的初始化
123
// System.out.println(Child.c_value);c_value 是子类的,需要先出发父类的初始化,再出发自己的初始化
Parent
Child
123
而如果 value 被 final 修饰,则不会出发初始化,因为 value 已经存在 NotInitialization 常量池中了。
如果使用数组,情况也是不一样的:
public static void main(String[] args) {
Child[] childs = new Child[10];
}
窗口没有打印什么,这说明并没有出发Parent 或者 Child 的初始化。这是因为数组在初始化时,会由虚拟机初自动生成一个不同报名的 Child 全限定名,直接继承 Object 类,创建动作有字节码 newarray 触发。
从上面看,由于父类的 <client>()
先执行,所以父类定义的静态语句块要优先于子类的变量赋值操作。如下,打印为2不是1:
class Parent{
public static int value = 1;
static {
value = 2;
}
}
class Child extends Parent{
public static int B = value ;
}
public static void main(String[] args) {
System.out.println(Child.B);
}
三、类加载器
从上面看到,类加载阶段中,是通过一个 类的全限定名来获取描述此类的二进制字节流 的,这个动作如果放到外部去做,以便程序自己决定如何去获取所需要的类。我们叫做 “类加载器”,我们举个反射的例子来说明从外部去加载这个二进制流的理解:
首先,添加一个自己定义的 classloader:
public class MyClassLoader extends ClassLoader {
public Class loadClass(String name) throws ClassNotFoundException {
// TODO Auto-generated method stub
try {
//这里直接传进来 类的全定限名,然后加后缀 .class
String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
InputStream is = getClass().getResourceAsStream(fileName);
if(is == null){
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (Exception e) {
// TODO: handle exception
}
return super.loadClass(name);
}
}
在自定义 ClassLoader 中,我们只传进来了报名加类名.class ,实际上可以是任意 .class 文件,然后我们在 main 函数中,这样:
public class Demo {
private static int test =23;
public static void main(String[] args) {
// TODO Auto-generated method stub
MyClassLoader my = new MyClassLoader();
try {
Class<?> cl = my.loadClass("com.zhengsr.javademo.Demo");
Field field = cl.getDeclaredField("test");
//如果是写,则开启权限
//field.setAccessible(true);
//访问 test 的值
System.out.println("value: "+field.getInt(cl));
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
四、双亲委派模型
它的工程流程是: 当一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是委派给她的父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载器都会传递到父加载器中;只有父加载器无法完成时,子加载器才会尝试自己去加载,它的模型如下:
- 启动类加载器:这个类主要将 JAVA_HOME\lib ,或者被 -Xbootclasspath 参数所指定路径中的,并且能被虚拟机识别的类库,加载到内存中
- 扩展类加载器:负责加载 JAVA_HOME\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径的所有类库,开发者可以直接使用扩展加载器。
- 应用程序类加载器:这个类加载器有 AppClassLoader 实现的,是 ClassLoader 中的 getSystemClassLoader() 返回值,所以也叫系统类加载器。负责加载用户类路径上所指定的类库。如果用户没有自定义加载器,则都使用这个类加载器,比如反射方法中的 Class.formName
该模型的优点:带有优先级的层次关系,不会因为用户自行编写其他相同名称的类而变得混乱。