《深入理解Java虚拟机》 类 & 加载
类加载,即把描述类的字节数据(class文件)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。与那些编译时需要链接的语言不同,java中类型的加载、链接和初始化都在运行期间完成,这种策略虽然在类加载时增加了一些性能开销,但是为java应用程序提供了高度的灵活性,Java能够动态扩展的语言特性就是依赖运行期动态加载和动态连接这些特点来实现的。
比如,编写一个面向接口的应用程序,可以等到运行时再指定其实现类。用户可以通过Java预定义或自定义的加载器,让一个本地应用程序在运行时从网络或其它地方加载一个二进制流作为程序代码的一部分。这种组装应用程序的方式已广泛应用于Java程序之中,从最基础的Applet、JSP到相对复杂的OSGI。
1. 类加载时机
在虚拟机规范中,严格定义了只有以下5种情况,才对类进行初始化:
- 遇到
new
,getstatic
,putstatic
,invokestatic
指令时,即当实例化对象、读取或设置静态字段、以及调用类静态方法时; - 使用
java.lang.reflect
包的方法对类进行反射调用时; - 初始化一个类时,其父类没有初始化时;
- 虚拟机启动时,
main
方法所在的主类; - 当使用JDK1.7的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果是REF_getstatic,REF_putstatic,REF_invokestatic的方法句柄,则初始化这些句柄所对应的类;
某些情况下,对应静态字段的引用并不会触发类的初始化,比如下面定义的两个类:
1 | public class SurperClass { |
1 | public class SubClass extends SurperClass { |
若执行
Object obj = SubClass.obj;
,只会初始化SurperClass
,因为对于静态字段,只有直接定义这个字段的类才会被初始化若执行
String str = SubClass.str;
,那么都不会初始化,因为在编译阶段通过常量传播优化,常量的值已经存储到了目标类的常量池中,因此,在实际执行时并没有符号引用指向SurperClass
如果执行
SurperClass[] array = new SurperClass[10];
,则也不会初始化,这里实际初始化的是一个由虚拟机自动生成的数组类,其直接继承于java.lang.Object
的子类,并通过指令newarray
触发
2. 类加载过程
类从被加载到虚拟机内存开始,到卸载出内存为止,其整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,7个阶段。其中验证、准备、解析可以统称为连接
对于整个加载过程,可以如上图所示,但是对于解析,其在某些情况下可以在初始化阶段之后进行,这样做是为了支持Java语言的运行时绑定。另外,上图只是表示这些阶段开始的先后顺序,实际情况可能是交叉混合进行的,比如在一个阶段的执行过程中激活调用了另一个阶段。
2.1. 加载
类加载阶段,将虚拟机外部的class文件按照虚拟机所需的格式存储到方法区,具体需要完成3件事情:
- 通过一个类的全限定名获取定义该类的二进制字节流;
- 将字节流代表的静态存储结构转换为方法区的运行时数据结构;
- 在方法区中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口;
2.2. 验证
验证是连接阶段的第一步,目的是保证Class文件的字节流包含的信息符合当前虚拟机的要求,保证输入的字节流能被正确解析并存储于方法区,主要包括4个阶段:
- 文件格式验证,验证字节流是否符合Class文件的格式规范,并且能当前被虚拟机处理;
- 元数据验证,对类的元数据信息进行语义分析,保证不存在不符合Java语言规范的元数据;
- 字节码验证,通过数据流和控制流分析程序语义是否合法,对类的方法体进行校验,保证方法运行时不会危害虚拟机;
- 符号引用验证,虚拟机将符号引用转换为直接引用的时候,对常量池中各种符号引用进行校验;
2.3. 准备
准备阶段即正式为类变量(即static修饰的变量,不包括实例变量)分配内存并设置类变量初始值的阶段。这些内存在方法区进行分配,这里设置的初始值一般是数据类型的零值,除非变量使用final
修饰
2.4. 解析
解析就是虚拟机将常量池内的符号引用替换为直接引用的过程,可以理解为对符合引用的解析
- 符号引用:以一组符号来描述所引用的目标,符号可以上任何形式的字面量,只要使用时能无歧义地定位到目标即可;
- 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,总之引用的目标必定已经在内存中存在;
虚拟机规范并未对什么时候进行解析有规定,只要求在执行anewarray
、checkcast
、getfield
、getstatic
、instanceof
、invokedynamic
、invokeinterface
、invokespecial
、invokeestatic
、involevirtual
、ldc
、ldc_w
、multianewarray
、new
、putstatic
和putfield
这16个用于操作符号引用的字节码指令之前,先对他们所使用的符号引用解析。
其中,除了invokedynamic
之外,虚拟机可以对指令第一次解析的结果进行缓存,即在运行时常量池中记录直接引用,并标识为已解析状态,从而避免解析动作重复进行。而invokedynamic
的本意用于动态语言支持(多态),必须等到程序实际运行到这条指令时才进行解析
至于具体解析主要针对:类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符,这7类符号引用进行解析
- 类或接口的解析
假设当前代码所处的类为D
,如果想把一个从未解析过的符号引用N
解析到一个类或接口C
的直接引用,可以将过程分为3步:
- 如果
C
不是一个数组类型,虚拟机将把符号引用N
的全限定类名传递给D
的类加载器去加载这个类C
。在加载的过程中由于需要验证,可能又会触发其他类的加载,一旦加载过程出现错误,则解析失败; - 如果
C
是一个数组类型,数组元素也是对象类型的话,那么N
的描述符将会是类似[Ljava/lang/Integer
的形式。那么,会按照第一步的规则加载数组元素类型,接着由虚拟机生成一个代表此数组维度和元素的数组对象; - 如果前面的步骤都没有出现错误,在解析完成前还需要进行符号引用的验证,确认
D
是否具有对C
的访问权限,如果没有,则抛出java.lang.IllegalAccessEroor
异常;
- 字段解析
要解析一个未解析过的字段的符号引用,首先要对字段表内的class_index
项索引的CONSTANT_Class_info
符号引用解析,也就是字段所属的类或接口的符号引用。
如果在解析这个类或接口的符号引用出现异常,则字段解析的失败。如果这个类或接口解析成功,将这个字段所属的类或接口用C
表示,然后对C
进行后续的字段搜索
- 如果
C
本身就包含了简单名称和字段描述符都与目标字段相同的字段,则返回这个字段的直接引用,查找结束; - 否则,如果在
C
中实现了接口,将会按照继承关系从下往上递归搜索每个接口和它的父接口,然后按照步骤1去查找; - 否则,如果
C
不是object
类的话,按照继承关系从下往上递归搜索其父类,然后按照步骤1去查找; - 否则,查找失败,抛出
java.lang.NoSuchFieldError
异常;
- 类方法解析
与字段解析一样,第一步也是先解析出类方法表的class_index
索引,也就是方法所属类或接口的符号引用。如果解析成功,用C
表示这个类,接下来虚拟机按照以下步骤进行类方法的搜索:
- 在类
C
中查找是否有简单名称和描述符都与目标匹配的方法,如果有返回这个方法的直接引用,查找结束; - 否则在类
C
的父类中递归查找; - 否则在类
C
的接口或父接口中查找; - 否则查找失败,抛出
java.lang.NoSuchMethodError
异常;
2.5. 初始化
在类加载过程中,除了在加载阶段可以自定义类加载器参与类的加载过程外,其余的步骤完全由虚拟机主导和控制,直到初始化阶段,才真正开始执行类中定义的Java代码。
在准备阶段,变量已经被赋值为类型的零值,而在初始化阶段,则根据程序制定的主观计划去初始化类变量和其他资源,即初始化阶段是执行类构造器方法<cinit>()
的过程。
<cinit>()
是由编译器自动收集类中的所有类变量的赋值操作和静态语句块static{}
中的所有语句合并而生的,因此,它不是必须的。
静态语句块只能访问到定义在静态语句前的变量,对于定义在它之后的变量,只能赋值而不能访问。
另外,虚拟机会保证在执行子类<cinit>()
之前,父类<cinit>()
已经执行完毕。不过,执行接口的<cinit>()
不需要先执行父接口的<cinit>()
,接口实现类在初始化时也一样不需要执行父接口的<cinit>()
,只有当使用父接口中定义的变量时,父接口才会初始化。
特别的,虚拟机会保证一个类的<cinit>()
在多线程环境中被正确的加锁、同步。如果多线程同时去初始化一个类,那么只会有一个线程执行,其他线程都需要阻塞等待,直到初始化完毕。要注意的是,其他线程虽然会被阻塞,但在执行<cinit>()
的线程退出之后,它们也不会再进入<cinit>()
方法,即同一个类加载器下,一个类型只会初始化一次。
3. 类加载器
虚拟机设计者将类加载过程中的加载动作放到虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块便称为类加载器
因此,类加载器的任务便是根据一个类的全限定名来读取此类的二进制字节流到Jvm中,然后转换为一个与目标类对应的java.lang.Class
对象实例。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中唯一性,每一个类加载器,都拥有一个独立的类名称空间。
虚拟机提供了3种类加载器:
- 启动类加载器(Bootstrap)
启动类加载器主要加载的是Jvm自身需要的类,这个类加载器使用C++语言实现,是虚拟机自身的一部分,它负责将 <JAVA_HOME>/lib
路径下的核心类库或-Xbootclasspath
参数指定的路径下的jar包加载到内存中,注意虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的,另外,出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类。
- 扩展类加载器(ExtClassLoader)
它负责加载<JAVA_HOME>/lib/ext
目录或由-Djava.ext.dir
指定路径中的类库,开发者可以直接使用标准扩展类加载器。
- 应用程序类加载器(AppClassLoader)
它负责加载classpath
路径下的类库,可以通过ClassLoader.getSystemClassLoader()
获取,一般情况下,该类加载器是程序中默认的类加载器
3.1. 双亲委派模型
在一般程序开发中,类的加载都由以上3种类加载器相互配合执行的,当然,也可以自定义类加载器。它们以组合的方式组织成父子关系,称为双亲委派模型
双亲委派模型在Java 1.2后引入,其思路是,如果一个类加载器收到了类加载请求,它并不会自己去加载,而是先把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,最终将到达顶层的启动类加载器。然后,如果父类加载器可以完成类加载任务,就成功返回,而如果父类加载器无法完成此加载任务,则由子加载器尝试自己去加载。
使用双亲委派的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader
再加载一次了。其次考虑到安全因素,java核心api中定义的类也不会被随意替换,假设通过网络传递一个名为java.lang.Integer
的类,通过双亲委托模式传递到启动类加载器,而启动类加载器发现该类已被加载,则直接返回已加载过的Integer.class
,这样便可以防止核心API库被篡改。
- 线程上下文加载器 ContextClassLoader
双亲委派可以很好的解决各个类加载器加载的类的统一问题,但如果父加载器加载的类想去调用子加载器加载的类,那么将出现问题。比如java中提供的一些spi(service provider interface),当调用具体的实现时,如果这些具体实现类的class直接默认使用当前类Caller
的加载器(即spi的加载器BootstrapClassLoader)进行加载,那么肯定失败。
因此,为了解决这个问题,java设计团队引入了一个不太优雅的设计:ContextClassLoader
。可以从currentThread
中获取,java在启动时会将其置为应用类加载器AppClassLoader
,后面子线程在创建时会默认设置为父线程的上下文加载器,所以,spi在调用时便可以通过显示的指定类加载器来解决问题。
其实,使用线程上下文类加载器相当于重置了一下加载器的起点,因为spi自身的加载器起点已经很高了,它只能继续向上寻找,那肯定找不到。因此,如果它希望找到低层加载器加载的类,可以通过ContextClassLoader
来获取到最底层的AppClassLoader,这样就能获取到AppClassLoader
加载的实现类了。
3.2. Launcher
Launcher
是Java应用的入口,由Jvm创建,所以它的类加载器是BootStrapClassLoade
。在它的构造器中,分别创建了ExtClassLoader
和AppClassLoader
,并将AppClassLoader
的parent
置为ExtClassLoader
,以组成父子关系。另外,将线程上下文类加载器赋值为AppClassLoader
1 | //This class is used by the system to launch the main application |
3.2.1 ExtClassLoader
ExtClassLoader
默认加载默认加载JAVA_HOME/jre/lib/ext/
下的包,或者由java.ext.dirs
指定的目录下的包
1 | static class ExtClassLoader extends URLClassLoader { |
3.2.2 AppClassLoader
AppClassLoader
负责加载java.class.path
目录下class,即应用程序的实现
1 | static class AppClassLoader extends URLClassLoader { |
3.3. ClassLoader
上面的AppClassLoader
以及ExtClassLoader
都间接继承了ClassLoader
,其提供了一个类加载的模板方法loadClass
,而思路就是使用的双亲委派模型。
它将findClass
的过程交给子类实现,不过URLClassLoader
就已经提供了find
的实现,并且SecureClassLoader
提供了安全校验的实现,所以上面的AppClassLoader
和ExtClassLoader
只需要指定加载路径即可。
1 | public Class<?> loadClass(String name) throws ClassNotFoundException { |
对于自定义的类加载器,可以直接通过重写findClass
来加载自己的类型信息。另外,为了保证loadClass
中双亲委派方式加载的进行,类加载器要求在初始化时必须指定一个父加载器parent
,如果是无参构造,则默认指定为AppClassLoader
1 | //systemClassLoader即应用程序类加载器 |
4. Class常见异常
- ClassNotFoundException
ClassNotFoundException
通常发生在显示加载类的时候,出现这个异常也很好理解,就是当Jvm加载某个类时发现class文件或字节码不存在。办法就是检查当前classpath
路径下是否存在指定文件,至于classpath
路径可以通过已加载的类来获取:
1 | this.getClass().getClassLoader().getResource("").toString(); |
常见的显示加载一个类的方式有:Class.forName()
、ClassLoader.loadClass()
、或者ClassLoader.findSystemClass()
- NoClassDefFoundError
Jvm规范中描述了出现NoClassDefFoundError
的可能情况是使用new
关键字、属性引用某个类、继承某个接口或类、以及方法的某个参数引用某个类等,其实概括起来就是当引用某个类时发现不存在,解决办法是确保每个类引用的类都在当前classpath
目录下面。
- UnsatisfiedLinkError
UnsatisfiedLinkError
通常是在解析native
标识的方法时,Jvm找不到对应的本机库文件。如果出现的话,通常是在Jvm启动的时候,可能是不小心将Jvm类库中的某个lib
删除了。
- ClassCastException
类型转换错误也很常见,就是不满足子类代替父类的原则,一般可以通过泛型进行编译期检查或者转换前的类型判断来避免,比如
1 | public class Test { |
- ExceptionInInitializerError
类初始化错误就是发生在上面类加载过程的1.5阶段,即给类变量赋值时,或者执行静态语句块时发现的错误,如果错误不是Error或者其某个子类,那么就会创建抛出一个ExceptionInInitializerError,比如
1 | public class Test { |
1 | Exception in thread "main" java.lang.ExceptionInInitializerError |
5. 自定义类加载器
某些场景下可能希望自定义类加载器
- 如果想加载自定义路径下的
class
,即这些class
不在classPath
下面,那么可以自定义ClassLoader
来定义如何找class
下面按照标准双亲委派的模式定义一个简单的加载器,加载指定路径下的class
文件
1 | public class PathClassLoader extends ClassLoader { |
加载入口还是loadClass
,因此如果class已经加载或已经可以由父加载器加载则跳过
1 | PathClassLoader loader = new PathClassLoader("E:/"); |
对于加载网络传输的class,可能希望加密处理,那么可以自定义ClassLoader在加载之前进行解密操作
如果希望重新加载被修改过的class,即实现类的热部署,那么可以自定义ClassLoader,每次创建一个新的ClassLoader实例进行加载
Java应用有一个痛点,就是如果修改一个类,那么必须要重启。所以,是否可以进行类的动态加载而不需要重启Jvm呢,答案是否定的,因为这违反了Jvm的工作机制。 Java的一个优势正是其基于共享对象的机制,通过保存并持有对象的状态而省去类信息的重复创建与回收。一旦对象被创建,则可以被其它对象持有和利用。
如果动态加载一个类到Jvm,并创建其新的实例对象,那么如何做到平滑更新持有这个对象的对象中的引用呢?这几乎是不可能的,因为在Jvm的设计原则中,对象的引用关系只有对象的创建者持有和使用,Jvm无法知道甚至干预对象的引用关系。
如果一定要动态加载,那么可以避免对象状态的保存,就是不让对象被其它地方持有或者引用,对象被创建使用后就被释放掉,下载修改并重写加载后,对象也就是新的了,正如Jsp就是这样实现的。
参考:
- Copyright ©《深入理解java虚拟机》
- 《深入分析 Java Web》