虚拟机的类加载机制指的是,将Class文件加载到内存,并对数据进行校验、转换解析和初始化最终可以被虚拟机直接使用。
1、类的加载
类从被加载到内存开始,到卸载出内存为止,整个的生命周期包括7个阶段:加载(loading)、验证(verification)、准备(preparation)、解析(resolution)、初始化(initialization)、使用(using)、卸载(unloading)。其中验证、准备、解析统称为连接(linking),这7个阶段的发生顺序如图一:
加载、验证、准备、初始化和卸载这5个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
加载(loading)
在加载阶段,虚拟机需要完成以下3件事情:
1、通过一个类的全限定名来获取定义此类的二进制字节流。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3、在内存中生成一个代表这个类的java.lang.Class对象,作为对方法区这个类的各种数据的访问入口。
相对于类加载的其他阶段,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为此阶段开发人员既可以使用系统提供的类加载器也可以由自己定义的类加载器来完成。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较的特殊,它虽然是对象,但是存放在方法区里面),这样便可以通过该对象访问方法区中的这些类型数据。
验证(verification)
验证是连接阶段的第一步。加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
从整体看,验证阶段大致上要完成4个阶段的校验动作:
1、文件格式验证:验证字节流是否能正确地解析并存储于方法区之内,格式上是否符合Class文件格式的规范。例如:是否以魔数0xCAFEBABE开头;主次版本号是否在当前虚拟机的处理范围之内;常量池中的常量是否有不被支持的常量类型;Class文件中各个部分及文件本身是否有被删除或者附加的信息等等。
2、元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求。例如:这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);这个类的父类是否继承了不允许被继承的类(被final修饰的类)等等。
3、字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,对类的方法体进行校验分析。
4、符号引用验证:确保解析动作能正常执行。例如:符号引用中通过字符串描述的全限定名是否能找到对应的类;符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问等等。
验证阶段是一个非常重要但不是一定必要的阶段(对程序运行期没有影响)。如果所运行的全部代码已经经过反复使用和验证过,那么在实施阶段可以考虑采用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备(preparation)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所用的内存都将在方法区中分配。对于该阶段有以下几点需要注意:
1、这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一块分配在Java堆中。
2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、(short)0、(byte)0、null、false等),而不是被在Java代码中被显式地赋予的值。假设一个类变量的定义为:
public static int value = 5;
那么变量value在准备阶段过后的初始值为0而不是5,因为这时候尚未开始执行任何Java方法,而把value赋值为5的putstatic指令是在程序被编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为5的动作将在初始化阶段才会执行。
3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstantValue属性所指定的值。假设上面的类变量value被定义为:
public static final int value = 5;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为5。
解析(resolution)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
符号引用:符号引用以一组符号来描述所引用的目标,可以是任何字面量,只要使用时能无歧义地定位到目标即可。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化(initialization)
初始化阶段,才真正开始执行类中定义的Java程序代码,也就是字节码。初始化为类的静态变量赋予正确的初始值。
在Java中对类变量进行初始值设定有两种方式:
1、声明类变量时指定初始值
2、使用静态代码块为类变量指定初始值
虚拟机规范严格规定了有且只有5种情况必须立即对类进行初始化。
1、使用new关键字实例化对象时。
2、读取或者设置一个类的静态变量时(被final关键字修饰已经在编译期将值放入常量池的静态常量除外),以及调用一个类的静态方法时。
3、对类进行反射调用时,例如使用java.lang.reflect包的方法。
4、初始化某个类时,则其父类先被初始化。
5、虚拟机启动时被指定为启动类的类
这几种情况称为对一个类的主动引用。其他引用类的方式都不会触发初始化,称为被动引用。
初始化步骤:
1、假如类还没有被加载和连接,则虚拟机先加载并连接该类
2、假如该类的直接父类还没有被初始化,则先初始化其直接父类
3、假如类中有初始化语句,则系统依次执行这些初始化语句
2、类与类加载器
将类加载阶段中“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
任意一个类,都需要由加载它的类加载器和这个类本身一同确立在Java虚拟机中的唯一性。比较两个类是否相等,只有在两个类由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
代码实例1
不同的类加载器对instanceof的结果的影响
public class ClassLoaderTest {
public static void main(String[] args) throws Exception{
ClassLoader customLoader=new ClassLoader(){
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String classname=name.substring(name.lastIndexOf(".")+1)+".class";
InputStream is=getClass().getResourceAsStream(classname);
if(is==null){
return super.loadClass(name);
}
byte[] b= new byte[is.available()];
is.read(b);
return defineClass(name,b,0,b.length);
} catch (IOException e) {
e.printStackTrace();
}
return super.loadClass(name);
}
};
Object obj=customLoader.loadClass("com.whc.demo.classloader.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof com.whc.demo.classloader.ClassLoaderTest);
}
}
运行结果:
class com.whc.demo.classloader.ClassLoaderTest
false
结果反回了false,因为虚拟机中存在了两个ClassLoaderTest类,一个是由系统应用程序类加载器加载的,另一个是由自定义类加载器加载的,虽然都来在同一个Class文件,但仍然是两个独立的类,instanceof(对象所属类型检查) 时结果为false。
注意:最好不要重写loadClass方法,因为这样容易破坏双亲委派模型
3、类加载器
从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader):它使用C++实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分;另一种就是所有其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
从Java开发人员的角度来看,类加载器可以大致划分为以下三类:
1、启动类加载器(Bootstrap ClassLoader):负责加载存放在<JAVA_HOME>\lib目录下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(仅按照文件名识别,如rt.jar,名字不符合的类库即使放到lib目录下也不会被加载)。启动类加载器是无法被Java程序直接引用的。
2、扩展类加载器(Extension ClassLoader):该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
3、应用程序类加载器(Application ClassLoader):该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系,一般如图二:
注意:这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的。
4、类的加载
类加载一般有三种方式:
1、命令行启动应用时,由JVM初始化加载。
2、通过ClassLoader.loadClass()方法动态加载。
3、通过Class.forName()方法动态加载。
代码实例2
public class ClassLoaderModeTest {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
System.out.println(classLoader);
//使用ClassLoader.loadClass()来加载类,不会执行初始化块
classLoader.loadClass("com.whc.demo.classloader.Test");
//使用Class.forName()来加载类,默认会执行初始化块
//Class.forName("com.whc.demo.classloader.Test");
//使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
//Class.forName("com.whc.demo.classloader.Test", false, classLoader);
}
}
//测试类
public class Test {
static{
System.out.println("execute static block");
}
}
切换加载方式,会有不同的输出结果。
Class.forName()和ClassLoader.loadClass()区别
Class.forName(name):除了将类的.class文件加载到jvm中,还会对类进行解释,执行类中的static块;
ClassLoader.loadClass(name):就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
Class.forName(name, initialize, loader):带参数也可控制是否加载static块。
5、双亲委派模型
类加载器之间的层次关系(如上图二),称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父类加载器去完成,每一层次的类加载器都是如此,因此所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(在它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派模型对于保证Java程序安全稳定运行很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中。
代码实例3
ClassLoader的loadClass()分析
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
//如果没有被加载,就向上委托
try {
if (parent != null) {
//如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
//如果父类加载器为null,就由启动类加载器加载
//通过调用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0rNull(name);
}
} catch (ClassNotFoundException e) {
//如果父类加载器抛出ClassNotFoundException
//说明父类加载器无法完成加载请求
}
if(c == null){
//在父类加载器无法加载的时候
//调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
如代码所示,逻辑清晰易懂:先检查是否已经被加载过,若没有则调用父类加载器的loadClass()方法,若父类加载器为空则默认使用启动类加载器作为父加载器。如果父类加载器失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
注意:这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的。
总结
双亲委派模型能很好地解决类加载的统一性问题。对一个 Class 对象来说,如果类加载器不同,即便是同一个字节码文件,生成的 Class 对象也是不相等的。也就是说,类加载器相当于 Class 对象的一个命名空间。双亲委派模型则保证了基类都由相同的类加载器加载,这样就避免了同一个字节码文件被多次加载生成不同的 Class 对象的问题。但双亲委派模型并不是一个强制性的约束模型。近年来,代码热替换(HotSwap)、模块热部署(HotDeployment)等技术都已不遵循这一规则,如 OSGi 技术就采用了一种网状的结构,而非双亲委派模型。
参考
[深入理解Java虚拟机]
jvm系列(一):java类的加载机制