1.引言
java源文件经过编译后生成字节码class文件,需要经过虚拟机加载并转换成汇编指令才能执行,那么虚拟机是如何一步步加载这些class文件的对于java程序员是完全透明的,本文尝试全面分析jvm类加载机制。
2.思考
开始之前我们来简单思考一下,如果让你来写虚拟机类加载你觉得要怎么做?
首先,肯定有一个加载过程,虚拟机要读取class字节码。
其次,为了保证虚拟机的安全性,需要对输入做校验,只有校验通过了才能继续执行,程序设计总是这样,才能保证系统安全稳定。
再次,校验通过后将字节码转换成类对象。
最后,将类对象建立全局索引方便引用。
如果把类加载也当成一个工程子模块,从逻辑上看,我们上面的分析没有什么问题,但工程实践经验表明,实际情况肯定要复杂一些,因为随着深入,总有新问题产生,至于复杂多少需要我们继续深入分析。
3.类的生命周期
类从被加载到jvm内存开始,到卸载出内存需要经过7个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。
类生命周期的七个阶段并非串行执行,比如在进行验证时与此同时准备阶段或解析阶段已经开始了,阶段是相互嵌套并行执行的,只是按照逻辑分类可以这样进行区分。又比如正常情况下解析阶段过后是初始化阶段,但为了支持java语言的运行时绑定(也成为动态绑定或晚期绑定),在初始化阶段之后才开始解析阶段。
类的主动引用和被动引用
什么情况下开始类加载呢,一般下面四种情况必须立即进行类初始化工作,如果类加载没做自然也必须立马做:
- 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。
- 对类进行反射调用时。
- 当初始化一个类时,如果父类还没初始化,则需要先初始化父类。
- 包含main方法的主类,当虚拟机启动时需要初始化该主类。
以上4种情况称为对类的主动引用。
除了以上4种情况,其他对类的引用被称为被动引用,
case1:子类引用父类的的静态字段,只会触发父类的初始化而不会触发子类初始化。
public class SuperClass {public static int value = 123; static {System.out.println("SuperClass init!");}
}
public class SubClass extends SuperClass {static {System.out.println("SubClass init!");}
}
public class NotInitialization {public static void main(String[] args) {System.out.println(SubClass.value);}
}
上述代码运行之后,最后输出的是“SuperClass init!”。
case2:通过数组定义来引用类,不会触发此类的初始化
public class NotInitialization {public static void main(String[] args) {SuperClass[] superArray = new SuperClass[10];}
}
这段代码并不会触发SuperClass初始化,即不会输出“SuperClass init!”。
注:这段代码会触发[LSuperClass初始化,这个类代表SuperClass一维数组,相对c/c++,java对一维数组的封装抱枕了安全性,当数组发生越界时,将抛出java.lang.ArrayIndexOutOfBoundsException。
case3:常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
public class ConstClass {public static final String HELLOWORLD = "Hello World"; static {System.out.println("ConstClass init!");}
}
public class NotInitialization {public static void main(String[] args) {System.out.println(ConstClass.HELLOWORLD);}
}
对常量ConstClass.HELLOWORLD的引用实际被转换成NotInitialization类自身常量池的引用。
接口加载过程差异点
接口和类加载过程略有不同,根本差异点在于:当一个类初始化时,要求其父类全部已经初始化过了,但在一个接口初始化时,并不要求其父接口全部初始化,只有在真正使用到父接口时才会初始化。
4.类加载过程
类加载过程包含5个阶段:加载、验证、准备、解析和初始化。下面来分析一下这5个阶段JVM都做了什么。
加载阶段
加载阶段需要完成3个事:
- 通过一个类的全限定名来获得定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转换成方法区的运行时数据结构。
- 在堆中生成一个代表这个类的java.lang.Class对象,作为方法区数据的访问入口。
虚拟机规范并没有要求二进制字节流要从哪里获取,也就是说,在加载阶段是开了口子的,很开放的,富有创造性的程序员在这个舞台玩出了各种花样,比如字节流获取可以从:
- 从ZIP获取,最终形成了jar、war等格式。
- 从网络中获取,典型场景是Applet。
- 从其他文件生成,比如jsp。
- 运行时计算,典型场景就是动态代理技术:用ProxyGenerator.generateProxyClass来为特定接口生成*$Proxy代理类的二进制字节流。
还有其他方式,只有你想不到的。
相对于类加载其他阶段的透明性,加载阶段是程序员可控性最强的阶段,因为加载阶段可以使用系统提供的类加载器,也可以用户自定义类加载器,我们在下文还会详细说明Java的类加载器。
验证阶段
验证阶段属于连接阶段的第一步,是出于虚拟机自身安全考虑,确保二进制字节流包含的信息符合虚拟机的要求。这也说明了Java语言是相对安全的语言,使用纯粹的Java代码无法做到诸如访问数据边界以外的数据,将一个对象转换成一个未知类型,跳转到不存在的代码行之类的行为。
验证阶段一般需要完成4个阶段的校验过程:文件格式校验、元数据校验、字节码校验和符号引用校验。
文件格式校验
文件格式校验主要是完成语法校验,即检查二进制字节流是否符合Class文件格式规范,目标是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述Java类型信息的要求。
校验项具体包含:
- 是否以魔数0xCAFEBABE开头。
- 主次版本号是否在虚拟机处理范围。
- 常量池常量是否有不被支持的常量类型(即检查常量tag标志)。
- CONSTANT_Utf8_info型常量中是否有不符合UTF8编码的数据。
- Class文件中各个部分和文件本身是否有被删除的或被附加的其他信息。
- ...
经过了这层验证,字节流才会进入方法区内存储。我们也可以看到验证阶段其实是和加载阶段交织在一起的。
元数据校验
元数据校验阶段是对字节码信息进行语义分析,以保证数据符合Java语言规范,比如是否继承了一个不允许被继承的父类。具体验证点包含:
- 这个类是否有父类,因为除了java.lang.Object外,所有类都有父类。
- 这个类是否继承了final修饰的类。
- 如果这个类不是抽象类,那么,是否实现了其父类和接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生了矛盾:比如是否覆盖了父类的final方法,出现了不符合规范的方法重载等等。
- ...
元数据阶段主要是完成数据类型校验。
字节码校验
字节码验证阶段将对类的方法体(数据流和控制流)进行验证分析,是整个验证阶段最复杂的阶段。这个阶段保证方法在运行时不会出现危害虚拟机安全的行为。比如需要做:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,比如不会出现操作数栈放置了一个int类型的数据,使用时却按long类型来来载入本地变量表中。
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体内的类型转换是有效的,比如允许把子类型赋值给父类数据类型,但不允许把父类对象类型赋值给子类数据类型。
- ...
但方法体内逻辑校验无法做到绝对可靠,即不能指望校验程序准确地检查出程序能否在有限时间之内结束运行。
符号引用校验
符号引用校验可以看做是对类自身以外的信息进行匹配性校验,发生时机是虚拟机将符号引用转换成直接引用时。校验内容包含:
- 符号引用中通过字符串描述的全限定名能否找到对应的类。
- 在指定类中是否存在符号方法的字段描述符以及简单名称所描述的方法、字段。
- 符号引用中类、字段和方法的访问性是否允许当前类的访问。
- ...
符号引用验证的目的是保证解析动作能正常执行,如果没有通过符号引用验证将抛出java.lang.IncompatibleClassChangeError异常的子类,比如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
以上是验证阶段校验的内容。
准备阶段
准备阶段正式为类变量分配内存并设置类变量的初始值,这些变量将在方法区中进行分配,也就是准备阶段分配的是类变量,并非实例变量。
准备阶段初始化类变量零值,以下是基本类型的零值:
举个例子,假设一个类变量定义如下:
public static int value = 123;
那么value在准备阶段初始化value = 0而非123,那什么时候会变成123呢,把value变成123的是putstatic指令是存放在类构造器<clinit>()方法中,而类构造器方法在初始化阶段才会执行。
但也存在特殊情况类变量赋值不是0的情况,比如在类中定义常量,如果类字段的字段表属性表中存在ConstantValue属性,则在准备阶段就会赋值。
public static final int value = 123;
解析阶段
解析阶段是虚拟机将常量池内的符号引用(Symbolic References)替换为直接引用(Direct References)的过程。符号引用在符号引用验证中提到过,它以CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info及CONSTANT_InterfaceMethodref_info等类型的常量出现。符号引用和直接引用的差别在于:
- 符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内部布局无关,引用的目标不一定已经加载到内存中。
- 直接引用是可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
解析什么时候发生呢,虚拟机并未明确指定,一般虚拟机实现会根据需要来判断,解析可能发生在类被加载器加载时就对常量池中的符号引用进行解析,亦或等到一个符号引用将要使用前才去解析。
此外,对同一个符号的多次解析请求是很常见的,为了避免重复解析,虚拟机实现可能会对第一次解析的结果缓存,即在运行时常量池中记录直接引用,并把常量标识为已解析状态。
解析动作主要针对类、接口、字段、类方法、接口方法四类符号引用。
类或接口的解析过程
假设当前代码所处的类是D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包含以下3个步骤:
- 如果C不是数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C,在加载过程中,由于元数据、字节码验证的需要,可能触发其他类的加载过程。
- 如果C是数组类型,并且数组元素是一个对象类型(以[Ljava.lang.Integer为例),那将会按照第一点的规则加载数组元素类型。虚拟机会生成一个代表此数组维度和元素的数组对象。
- 如果上述步骤没有出现异常,那么C在虚拟机中已经成为了一个有效的类或接口了。在解析前还需要检验D是否具备对C的访问权限,如果没有访问权限将抛出java.lang.IllegalAccessError。
字段解析
解析一个未被解析过的字段前,首先需要对字段表内class_index(即字段的类索引)的CONSTANT_Class_info符号引用进行解析,也就是或在进行字段解析之前,需要先完成类或接口的符号解析。
假设一个需要解析的字段所属的类或接口为C,虚拟机规范要求按如下步骤对C后序字段进行搜索:
- 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 如果在C中实现了接口,将会按照继承关系从上到下递归搜索各个接口和它的父接口,如果接口中包含简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 如果C不是java.lang.Object,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
如果在父类和接口中存在同名字段会发生什么呢?如果是这样情况,编译器将拒绝编译,比如下面这种情况:
public class FieldResolution {interface Interface0 {int a = 0;} interface Interface1 extends Interface0 {int a = 1;} interface Interface2 {int a = 2;}static class Parent implements Interface1 {public static int a = 3;} static class Sub extends Parent implements Interface2 {public static int a = 4;} public static void main(String[] args) {System.out.println(Sub.a);}
}
若将Sub静态成员变量publc static int a = 4注释掉,编译器将返回“The field Sub.A is ambiguous”。
下面我们将类方法解析和接口方法解析,两者是分开的。
类方法解析
类解析和字段解析一样,需要先解析出类方法表的class_index索引所属类或接口的符号引用。如果类或接口符号引用接口成功,我们依然用C来表示类或接口,接下来将按如下步骤进行搜索:
- 如果发现C是一个接口,将抛出java.lang.IncompatibleClassChangeError异常。
- 在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回方法的直接引用,查找结束。
- 在类C的接口列表和它们的父接口中递归查找是否有简单名称和描述符和目标相匹配的方法,如果查找到,则说明类C是一个抽象类,将抛出java.lang.AbstractMethodError异常。
- 若以上未查询到,则宣告查找失败,抛出java.lang.NoSuchMethodError。
查找结束若成功返回,还需要对方法权限进行校验。
接口方法解析
与类方法解析类似,步骤如下:
- C是否是类,如果是则抛出java.lang.IncompatibleClassChangeError异常。
- 在接口中查找。
- 在接口的父接口中递归查找。
- 若以上未查询到,则宣告查找失败,抛出java.lang.NoSuchMethodError。
以上是解析阶段所做的工作。
初始化阶段
类初始化阶段是类加载过程的最后一步,到了初始化阶段,才真正执行类中定义的字节码。在准备阶段,变量已经赋过一次初始值,初始化阶段将执行类构造器<clinit>()方法的过程。
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}块中的语句结合产生的。编译器收集的顺序是由语句在源文件中出现的顺序决定的,比如静态语句块只能访问定义在静态语句块之前的变量。
- <clinit>()方法和类的构造方法不同,并不需要显示的调用父类构造器,虚拟机保证在子类<clinit>()方法执行之前,父类构造器先执行,因此,在虚拟机中第一个执行类构造器的类为java.lang.Object。
- <clinit>()方法对于类或接口来说并不是必须的。
- 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步。
5.类加载器(ClassLoader)
上一节我们在一开始就聊到虚拟机允许用户自定义类加载器,这就给java语言带来了很大的灵活性,类加载器可以说是Java语言的一项创新,也是Java语言流行的重要原因之一。类加载器在类层次划分、OSGi、热部署、代码加密等领域大放异彩,成为Java技术体系的一块重要基石。
类和类加载器
类的唯一性是有类加载器决定的,比较2各类是否相等除了比较类本身还需要比较类加载器。如果一个类被2个不同的类加载器加载,那么加载的类是2个不同的类。如下所示:
public class ClassLoaderTest {public static void main(String[] args) {ClassLoader myLoader = new ClassLoader() {@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {try {String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";InputStream is = this.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 (IOException e) {throw new ClassNotFoundException(name);}}};Object obj = myLoader.loadClass("ClassLoaderTest").newInstance();System.out.println(obj instanceof ClassLoaderTest);}
}
运行结果:false。
类加载器的层次结构
“横看成岭侧成峰”,站在Java虚拟机角度看,只存在2种不同类型的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器是C++实现的,是虚拟机自身一部分;另外一种是其他类加载器,这种类加载器都是虚拟机外部的,由Java实现,并且全部继承自抽象类java.lang.ClassLoader。站在Java程序员角度看,可以分成以下3种:
- 启动类加载器,上面刚提到过,负载加载存放在JAVA_HOME/lib路径中的类或者被-Xbootclasspath参数所指定路径下的类。启动类加载器无法被Java程序直接引用。
- 扩展类加载器(Extension ClassLoader),这个加载器由sum.misc.Launcher$ExtClassLoader实现,负责加载JAVA_HOME/lib/ext路径下的类,或者由java.ext.dirs系统变量所指定下的类库。
- 应用程序类加载器(Application ClassLoader),这个类加载器由sum.misc.Launcher$AppClassLoader来实现。由于这个类加载器可以由ClassLoader.getSystemClassLoader()获取,因此也叫系统类加载器。AppClassLoader复杂加载用户类路径(ClassPath)上指定的类库,开发者可以直接使用。
以下是类加载器的层次结构
双亲委派模型
类加载器双亲委派模型是JDK1.2引入被应用于几乎所有的Java程序中。但它并不是一个强制性的约束模型,二是Java设计者推荐的一种类加载方式。
双亲委派有他的适用场景(它能够适用于绝大多数场景),模型可以保证Java程序的稳定运行,防止重复加载和任意修改。那具体是如何做到的呢?
双亲委派模型的工作过程如下:如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把请求委派给父类加载器去完成,如上图所示,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有父类反馈自己无法完成这个加载请求时(即它的搜索范围没有找到指定的类),字加载器才会尝试自己加载。
比如java.lang.Object,无论哪个类加载器需要加载这个类,最终都由Bootstrap ClassLoader加载,因此Object在程序的各个类加载器环境都是同一个类。相反,如果如果不用双亲委派模型进行加载,用户自定义了一个Object类并放置在类路径下,最终可能会引发程序混乱。
双亲委派模型很好地解决了基础类的统一问题,保证了虚拟机的安全性。
非双亲委派模型
线程上下文类加载器
双亲委派模型适用于大部分场景,但也有它自身的缺陷,假设基础类由Bootstrap类加载器加载,但是基础类需要回调用户的代码,基础代码却是由应用类加载器加载,这个时候该怎么办呢?
JNDI(Java Naming and Directory Interface)服务就是上面描述的这种场景,JNDI是Java的标准服务,它自身的代码由Bootstrap类加载器加载,由于JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现的JNDI接口提供者(SPI)的代码,独立厂商提供的代码jar包放置在ClassPath下,如果使用双亲委派模型加载类的方式是搞不定的,怎么办呢?
为了解决这个困境,Java设计团队引入了线程上下文类加载器(Thread Context ClassLoader),虽然它确实不太优雅,但解决问题啊。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程还未设置,它将从父线程中继承一个,如果在应用程序的全局范围内都没有设置过,那么这个类加载器就是AppClassLoader。有了线程上下文类加载器,JNDI服务就可以加载所需要的SPI代码,即父类加载器可以请求子类加载器完成类加载动作,这其实是违反了双亲委派原则的。实际上JNDI、JDBC、JCE、JAXB、JBI等所有涉及SPI加载动作的基本都采取的这种方式。
总结:线程上下文加载器之所以打破双亲委派模型是因为双亲委派模型依赖的单一方向的,并不能解决父类加载器去依赖子类加载器这种逆方向需求。
Tomcat类加载器
实际上,不只是Driver驱动的实现是这样,只要有需要,在双亲委派机制无法满足需求前提下,在tomcat、spring等等的容器框架也是通过一些手段绕过双亲委派机制。
双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。如下图所示
从图中的委派关系中可以看出:
- CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用。
- CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。
- WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。
总结:tomcat之所以破坏双亲委派模型,我想主要在于双亲委派模型只看到了共享性,没有看到隔离性需求,即共享是有条件的共享。
OSGI类加载器
非双亲委派模型的另一种需求来自程序动态性追求。比如代码热替换(HotSwap)、模块热部署(Hot Deployment)。可以哪USB热插拔技术来做比方。热部署对生产系统来说具有很大吸引力,不用停机就能完成部署效率啊。
OSGI是Java模块化标准,OSGI实现模块热部署的关键是它自定义的类加载器,每一个模块都有一个自己定义的类加载器,当需要更换一个Bundle时,则把Bundle连同类加载器一同替换以实现热替换。
在OSGI环境下,类加载器不再是树型结构的双亲委派模型,而是网状结构,当收到类加载请求时,OSGI是按照下面顺序进行类搜索的:
- 以“java.*”开头的类,委派给父类加载器加载。
- 将委派列表名单内的类,委派给父类加载器加载。
- 将Import列表中的类,委派给Export这个类的Boundle的类加载器加载。
- 查找当前Boundle的ClassPath,使用自己的类加载器加载。
- 查找类是否在Fragment Boundle中,如果在,则委派给Fragment Boundle类加载器加载。
- 查找Dynamic Import列表的Boundle,委派给对应的Boundle类加载器加载。
如果以上都未查询到,则查找失败。
上面的搜索顺序除了1,2两点和双亲委派类似,其余都是平级类加载过程。
总结:OSGI Boundle类加载器提供了类加载的另一种机制,加载器结构不一定非得是树型结构,也可以是网状结构。
全文总结
本文较全面的分析了jvm的类加载机制,分析了类加载的5个阶段,包含:加载、验证、准备、解析、初始化,最后总结了类加载器加载类的几种模型:双亲委派模型、SPI的类加载模型、tomcat类加载模型以及OSGI类加载模型。
The end.
转载请注明来源,否则严禁转载。