一 概述
虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化。最终形成可以被虚拟机直接使用的java类型的过程就是虚拟机的类加载机制
二 类加载的时机
类从被加载到虚拟机内存到卸出内存为止,整个生命周期如下图所示:
加载,验证,准备,初始化和卸载这5个阶段的顺序是确定的。而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持java语言的运行时绑定。
关于什么情况下需要开始类加载过程的第一个阶段[加载],是由虚拟机的具体实现来把握的。但是对于初始化阶段,《java虚拟机规范》严格规定有且只有六种情况必须立即对类进行“初始化”
- 使用new关键字实例化对象或读取/设置类的静态字段(被final修饰,编译期已把结果放入常量池的静态字段除外)或调用类的静态方法
- 使用reflect对类进行反射调用
- 初始化类的时候,如果父类没有进行过初始化,则先触发父类的初始化
- 虚拟机启动时,需要指定一个执行的主类,虚拟机会先初始化这个主类
- JDK7新加入的动态语言支持
- JDK8中定义了默认方法的接口,如果接口的实现类发生了初始化,那么接口要在其之前被初始化
除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
eg.通过子类引用父类的静态字段,不会导致子类初始化
public class SuperClass { static { System.out.println("SuperClass(父类)被初始化了。。。"); } public static int value = 66; }
public class SubClass extends SuperClass { static { System.out.println("Subclass(子类)被初始化了。。。"); } }
public class NotInitTest1 { public static void main(String[] args) { System.out.println(SubClass.value); } }
----------------------
SuperClass(父类)被初始化了。。。
66
eg.通过数组来定义引用类,不会触发此类的初始化
public class NotInitTest2 { public static void main(String[] args) { SuperClass[] sca = new SuperClass[10]; } }
---------------------
eg.常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
public class ConstClass { static { System.out.println("ConstClass init"); } public static final int value = 66; }
public class ConstClass2 { static { System.out.println("ConstClass init"); } public static int value = 66; }
public class NotInitTest3 {
public static void main(String[] args) {
System.out.println(ConstClass.value);
}
}
---------------------
66
public class NotInitTest4 { public static void main(String[] args) { System.out.println(ConstClass2.value); } }
---------------------
ConstClass init
66
三 类加载的过程
3.1加载
在加载阶段,虚拟机主要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
对数组类而言,情况就不一样了,数组类本身不通过类加载器创建,由虚拟机直接创建,但是又和类加载器有很大的关系。因为数据的元素类型最终要类加载器去创建。
加载完成之后,类就按照虚拟机的格式存储在方法区之中,在内存中实例化一个java.lang.Class类的对象。对于HotSpot而言,这个对象放在方法区中,而不是堆中。
加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
3.2验证
验证是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求。
3.2.1文件格式验证
主要目的是保证输入的字节流能正确的解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过验证后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证全部都是基于方法区的存储结构进行的,不会再直接读取,操作字节流了。
3.2.2元数据验证
主要目的是对类的元数据信息进行语义校验。比如这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了父类或者接口之中要求实现的所有方法;类中的字段,方法是否与父类产生矛盾
3.2.3字节码验证
主要目的是通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的。通俗来说就是对类的方法体进行校验分析,保证方法在运行时不会出现危害虚拟机安全的行为。比如保证方法体中的类型转换总是有效的
3.2.4符号引用验证
发生在虚拟机将符号引用转化为直接引用的时候,目的是为了确保解析动作能正常执行。包括通过字符串描述的全限定名是否能找到对应的类;在指定类中是否存在符号方法的字段描述及简单名称所描述的方法和字段等
3.3准备
准备阶段是为类的变量分配内存,并设置类变量初始值的阶段,这些变量使用的内存,都在方法区中进行分配。这时候进行内存分配的仅包含类变量,而不包含实例变量,实例变量会在对象实例化时随着对象一起分配在堆中。
初始值一般是数据类型的零值,比如public static int value= 123。初始值是0,而不是123,这个时候没有执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放在类构造器<clinit>()方法中,所以在初始化阶段才会赋值成123。
又要注意,一般是数据类型的零值,但是还有特殊情况,比如被final修饰,存在ConstantValue属性,会在准备阶段就会赋值
3.4解析
解析阶段是虚拟机将常量池内的符号引用(以一组符号来描述所引用的目标)替换为直接引用(直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄)的过程.
虚拟机规范中没有规定解析的发生具体时间,所以是否是在加载时就完成符号引用的解析,还是到符号引用被使用时再进行解析,这个根据实际需要决定。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行
3.5初始化
初始化是类加载过程的最后一步,此时虚拟机才开始真正执行类中编写的Java程序代码,将主导权移交给应用程序
进行准备阶段时,变量已经赋过一次系统要求的初始零值。初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()是javac编译器自动生成物,是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,收集顺序是由语句在源文件中出现的顺序决定。静态语句块中只能访问到定义在之前的变量,定义在之后的变量,语句块中可以赋值,但不能访问。
<clinit>()方法与构造函数不同,不需要显式的调用父类构造器。java虚拟机会保证子类的<clinit>()方法执行前,父类的<clinit>()方法已执行完毕。由于父类的<clinit>()方法优先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作.
接口不能使用静态语句块,但是可以有变量初始化赋值操作,因此接口也会生成<clinit>()方法。但是执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。同理,接口的实现类在初始化时也不会执行接口的<clinit>()方法。
class Parent { public static int A = 1; static { A = 2; } } class Sub extends Parent { public static int B = A; } public class InitClass2 { public static void main(String[] args) { System.out.println(Sub.B); } }
-----------------
2
四 类加载器
4.1类与类加载器
Java虚拟机团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
类加载的三种方式:
- 通过命令行启动应用时由JVM初始化加载含有main()方法的主类。
- 通过Class.forName()方法动态加载,会默认执行初始化块(static{}),但是Class.forName(name,initialize,loader)中的initialze可指定是否要执行初始化块。
- 通过ClassLoader.loadClass()方法动态加载,不会执行初始化块。
三种方式的区别:
1、第一种和第二种方式使用的类加载器是相同的,都是当前类加载器。(this.getClass.getClassLoader)。而3由用户指定类加载器。
2、如果需要在当前类路径以外寻找类,则只能采用第3种方式。第3种方式加载的类与当前类分属不同的命名空间。
3、第一种是静态加载,而第二、三种是动态加载。
4.2双亲委派模型
- Bootstrap ClassLoader:主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责将 <JRE_HOME>/lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中。注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)
- Extention ClassLoader:由Java语言实现的,它负责加载<JRE_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。
- Appclass Loader:也称为SystemAppClass。 加载当前应用的classpath的所有类。
双亲委派模型工作流程是:当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,才会尝试执行加载任务。
采用双亲委派的好处是使用不同的类加载器最终得到的都是同样一个Object对象:
可以避免重复加载,父类已经加载了,子类就不需要再次加载
更加安全,很好的解决了各个类加载器的基础类的统一问题,如果不使用该种方式,那么用户可以随意定义类加载器来加载核心api,会带来相关隐患。
从上面的结果可以看出,并没有获取到ExtClassLoader的父Loader,原因是Bootstrap Loader(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null。
4.3自定义类加载器
主要有两种方式
- 遵守双亲委派模型:继承ClassLoader,重写findClass()方法。
- 破坏双亲委派模型:继承ClassLoader,重写loadClass()方法。
破坏双亲委派模型示例:热部署
所谓的热部署就是利用同一个class文件不同的类加载器在内存创建出两个不同的class对象(关于这点的原因前面已分析过,即利用不同的类加载实例),由于JVM在加载类之前会检测请求的类是否已加载过(即在loadClass()方法中调用findLoadedClass()方法),如果被加载过,则直接从缓存获取,不会重新加载。注意同一个类加载器的实例和同一个class文件只能被加载器一次,多次加载将报错,因此我们实现的热部署必须让同一个class文件可以根据不同的类加载器重复加载,以实现所谓的热部署。