系列文章:
深入Java虚拟机之 – 总结面试篇
深入Java虚拟机之 — JVM的爱恨情仇
JAVA 垃圾回收机制(一) — 对象回收与算法初识
JAVA 垃圾回收机制(二) — GC回收具体实现
深入Java虚拟机之 – 类文件结构(字节码)
深入Java虚拟机之 – 类加载机制
虚拟机把描述类的数据从Class文件 (二进制流) 加载到内存,并对数据结构进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是Java的类加载机制。如下图:
类从加载到虚拟机的内存开始,到卸载出内存结束,中间会经历7个阶段,即加载、验证、准备、解析、初始化、使用和卸载;其中 验证、准备和解析也被称为连接,如下图:
其中,加载、验证、准备、初始化和卸载这五个顺序是固定的,而解析则不一定,因为Java是动态语言,它支持动态绑定,或在初始化后开始;现在对这些状态进行解释分析
加载过程主要完成以下3件事
而获取二进制字节流的方式可以有很多种,不一定是通过类的全限名;比如从 ZIP 读取,网络中读取或者动态代理读取等等。
加载阶段和连接阶段的部分内容,是交叉进行的。
这一阶段是为了确保 Class 文件中的字节流是否符合当前虚拟机的要求,并且不会伤害到虚拟机自身安全;主要分为4个模块的验证。
文件格式主要验证 字节流是否符合 Class 的规范,如:
实际上,验证远远不止上面这些,它的主要目的就是为了把字节流正确的解析并存储在方法区之内,只有通过了这个阶段,字节流才会流进内存的方法区中进行存储,后面的验证都是基于方法区的存储结构进行的,不会再操作字节流。
这个阶段是对字节码描述的信息进行语义分析,比如:
总之,就是验证这个字节码的信息符不符合Java的语义,保证不符合Java语言规范的元数据类型。
该验证主要是分析数据流和控制流,确定语义是合法的,符合逻辑的。在元数据验证验证之后,还需要字节码的验证,比如操作栈放置了一个 int 类型的数据,在使用时,却按 long 类型来加载本地变量表中。实际上,字节码的验证大部分是避免Java代码在运行或者使用时的一些保护措施。
最后一个阶段的验证是虚拟机将符号引用转化为直接引用的时候,这个转换动作将在连接的第三阶段 – 解析阶段进行。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。通常校验以下内容:
准备阶段正式为类变量分配内存和设置初始值的阶段,这些变量所使用的内存将在方法区中进行分配。需要注意的时候,这里方法区的内存分配仅包含类变量(即被static修饰的变量),而不包括实例变量,实例变量讲在对象初始化的时候被分配在 Java 堆中;然后,这里说的初始值,其实是讲数据类型置为零值,假设
public static int value = 123;
那么它在准备阶段过后的初始值为0而不是123,因为此时并未执行任何Java方法。但如果是用 final 修饰,则准备阶段还是123.
解析是把符号引用转换为直接引用的过程;在 class 文件格式中,符号引用常常以,CONSTANT_FIELDREF_INFO等等类型的常量出现。然后来理解一下符号引号和直接引用的含义:
要解析一个符号引号,通过通过以下几种方式:
如果当前代码所处的类为D,用N表示一个未被解析过的类或接口 的直接引用,类用C来表示,那虚拟机的解析的过程如下:
要解析一个重未被解析过得字段符号引用,首先会先解析字段所属的类或接口的符号引用。如果在解析这个类或接口的符号引用发生异常,则字段解析失败;如果解析成功,则继续校验:
以上查找返回直接引用之后,还会对权限进行验证,如果失败,也会抛出IllegalAccessError 的异常。
类方法的解析,也需要先经过类和接口的解析,解析成功才会继续:
接口方法也需要先经过类和接口的解析,解析成功才会继续,如果解析成功,依然用C表示这个接口,接下来才会继续去解析;接口方法先回判断 class_info 中的索引C是个类而不是接口,就会抛出异常,否则则继续在接口C或者它的父接口中去继续查找匹配的简单名称和描述符,符合则返回,否则抛出异常。
初始化是类加载的最后一步,也是真正执行类中定义的 Java 程序代码,初始化阶段是执行类构造器
方法的过程。
方法就是我们熟悉的 static{ }
静态语句块,比如初始化一些变量,或加载JNI 库时都是 static 模块中。静态语句块只能访问到定义在它之前的变量,在它之后的,能赋值,但不能访问,如:
static {
i = 0;
System.out.println(i); //编译器报错,
}
static int i = 1;
对于初始化阶段,虚拟机严格规范了有且只有5中情况下,必须对类进行初始化:
接着我们用代码验证一下:
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 触发。
从上面看,由于父类的
先执行,所以父类定义的静态语句块要优先于子类的变量赋值操作。如下,打印为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();
}
}
}
它的工程流程是: 当一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是委派给她的父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载器都会传递到父加载器中;只有父加载器无法完成时,子加载器才会尝试自己去加载,它的模型如下:
该模型的优点:带有优先级的层次关系,不会因为用户自行编写其他相同名称的类而变得混乱。