虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译期执行连接过程的语言不同,Java 中类型的加载、连接、初始化过程均在运行时进行。这种类加载机制会为运行时增加一些性能开销,但也给 Java 提供了高度的灵活性:基于这种运行时动态加载和连接的能力,Java 中天生就可以动态扩展语言的特性。
类的完整生命周期:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析这三个部分被合称为连接。
JVM 规范并没有定义开始加载一个类的时机,因此取决于具体的 JVM 实现。但对于初始化阶段,JVM 规范严格规定了 有且仅有 5 种情况 必须立即对类执行“初始化”(因此初始化的前置阶段必须在之前开始):
java.lang.reflect
中的方法对类进行反射调用。MethodHandle
实例最后的解析结果是 REF_getStatic
、REF_putStatic
、REF_invikeStatic
的方法句柄对应类类没有被初始化。这 5 种场景中的行为成为对一个类进行主动引用。除此之外,所有引用类的行为都不会触发初始化,称为被动引用。
不会导致子类初始化。会引起父类的初始化和子类的加载。
不会触发此类的初始化。
1 | public class NotInitialization{ |
这段代码不会触发 SuperClass 类的初始化,但会触发一个名为 [Lorg.fenixsoft.classloading.SuperClass
类的初始化,这是一个由 JVM 自动生成的、直接继承于 java.lang.Object
的子类,创建动作由字节码指令 newarray 触发。
在 JVM 内部,自动生成了一个类来封装数组数据(因此数组操作在 Java 中比在 C/C++ 中安全,后者是直接移动指针),该类值暴露了共有的 length 属性和 clone 方法。
1 | public class ConstClass{ |
该示例中并不会引起类 ConstClass 的加载。因为在编译阶段通过常量传播优化,已经将此常量的值存储到了类 NotInitialization 常量池中,以后 NotInitialization 对该常量的引用实质上是对自身常量池的引用。即,NotInitialization 的 Class 文件中并没有对类 ConstClass 的符号引用入口,这两个类在编译成 Class 之后便不存在任何关联了。
接口也有初始化过程。在类中一般使用静态块来显示初始化信息,而接口中不能使用静态块,但编译器仍然会为接口生成 <clinit>()
类构造器,用于初始化接口中所定义的成员变量。
接口与类真正有所区别的地方在于上面所述 5 种主动引用情况的第 3 种:当一个类在初始化时,要求其父类全部都已经初始化过了;但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时(如引用父接口中的常量)才会将其初始化。
加载阶段,虚拟机要完成的 3 件事情:
java.lang.Class
对象,作为在方法区中该类的各种信息的访问入口。在 JVM 规范中并没有具体规定一定要从哪获取、怎样获取二进制字节流,因此具有很大的灵活性。基于这一点有很多有意义的实现:
相对于类加载过程的其他阶段,一个非数组类在加载阶段的获取二进制字节流操作是可控性最强的,因为既可以使用系统提供的“引导类加载器”来加载,也可以由用户自定义的类加载器来加载,开发人员可以通过自定义的类加载器来控制字节流的获取方式。
数组类本省不能通过类加载器创建,它是由 JVM 直接创建的。但数组类仍与类加载器有着密切的联系,因为数组类的元素类型最终需要靠类加载器来创建。一个数组类(简称 C)的创建过程遵循以下规则:
加载阶段与连接阶段是交叉进行的(如一部分字节码文件格式校验动作),加载阶段尚未完成,连接阶段可能已经开始,但这些夹杂在加载阶段的动作仍然属于连接阶段,这两个阶段的开始时间依然保持着固定的先后顺序。
连接节点的第一步,为了确保 Class 文件的字节码中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。
Class 文件并不一定由 Java 源码编译而来。在字节码语言层面上,Java 代码无法做到的(比较危险的)事情都是可以实现的。虚拟机如果不检查输入的字节流而对其完全信任的话,很有可能因为载入了有害的字节流而导致系统崩溃。
验证阶段是非常重要的,该阶段是否严谨,决定了 JVM 是否能够承受恶意代码的攻击。从执行性能的角度来看,验证阶段的工作量在类加载子系统中占有相当大一部分。
验证阶段主要可以分为以下 4 个检验动作:
详细细节可以参考虚拟机规范。
正式为类变量(静态变量)分配内存设置初始值(零值),这些变量所使用的内存将被分配在方法区。
假设一个类变量的定义为 public static int value=123;
,那么该字段在准备阶段之后的值仍然为 int 类型的零值,即 0。因为在该阶段尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令会在程序被编译后放在类构造器的 <clinit>()
方法中,并在初始化节点执行。
一种特殊情况:如果类字段的字段属性表存在 ConstantValue 属性,那么在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值。
假设将上面的定义修改为 public static final int value=123;
,这是 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段 JVM 就会根据 ConstantValue 的设置将 value 赋值为 123。
基本数据类型的零值:
将常量池中的符号引用替换为直接引用的过程。
符号引用:以一组符号来描述所引用的目标,符合可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
JVM 规范中并未规定解析阶段发生的具体时间,只要求在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic 这 16 个用于操作符号引用的字节码指令之前,先对其使用的符号引用进行解析。所有虚拟机实现可以根据需要来判断是要在类加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用时再去解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点,这 7 类符号进行,分别对应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 这 7 种常量类型。
这里介绍前 4 种引用的解析过程,后 3 种与动态语言的支持相关,会在第 8 章介绍完 invokedynamic 指令的语义之后再做介绍。
假设当前类为 D,如果要把一个未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,虚拟机要完成的解析过程包含以下 3 个步骤:
[Ljava/lang/Integer
的形式,那将会按照第一步的规则首先加载素组元素的类型。完成后由虚拟机生成一个代表次数组维度和元素的数组对象。java.lang.IllegalAccessError
异常。首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果该过程中出现异常将会导致本解析过程失败;如果解析成功完成,那么将该字段所属的类或接口用 C 表示,虚拟机规范中要求按照如下步骤对 C 进行后续字段的搜索:
java.lang.Object
的话,将会安装继承关系递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。java.lang.NoSuchFieldError
异常。如果查找过程成功返回了引用,将会对该字段进行权限验证,如果发现不具备对该字段的访问权限,将抛出 java.lang.IllegalAccessError
异常。
在实际应用中,虚拟机的编译器实现可能会比上述规范更加严格,如果一个字段同时出现在 C 的接口和父类中,或者同时出现在自己或父类的多个实现接口中,那么编译器可能会直接拒绝编译。
类方法解析的第一个步骤与字段解析一样,也需要解析出类方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用 C 表示该类,接下来虚拟机会按照如下步骤进行后续的类方法搜索:
java.lang.IncompatibleClassChangeError
异常。java.lang.AbstractMethodError
异常。java.lang.NoSuchMethodError
异常。最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出 java.lang.IllegalAccessError
异常。
接口方法也需要解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机会按照如下步骤进行后续的接口方法搜索:
java.lang.IncompatibleClassChangeError
异常。java.lang.Object
类为止(包括该类),查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,结束查找。java.lang.NoSuchMethodError
异常。由于接口中所有的方法默认都是 public 访问权限,因此不存在访问权限问题,即也不会抛出 java.lang.IllegalAccessError
异常。
这是类加载过程的最后一步。前面已介绍的过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才开始真正执行类中定义的 Java 程序字节码。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,收集顺序取决于语句在代码源文件中出现的顺序,静态语句块只能访问到定义在静态语句块之前的变量,但可以为定义在其后的变量赋值而不能访问。
<clinit>()
方法与类的构造函数(类实例构造器) <init>()
方法不同,它不需要显式的调用父类构造器,虚拟机会保证在子类的 <clinit>()
方法执行之前,父类的 <clinit>()
方法已经执行完毕。因此在虚拟机中第一个被执行的 <clinit>()
方法一定属于 java.lang.Object
。
如果一个类中没有静态语句块会对变量的赋值操作,那么编译器就不会为其生成该方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()
方法。但与类不同的是,执行接口的 <clinit>()
方法不需要首先执行父接口的 <clinit>()
方法。因为当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不会执行父接口的 <clinit>()
方法。
虚拟机会保证一个类的 <clinit>()
方法在多线程环境中能够被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行该类的 <clinit>()
方法,其他线程都有阻塞等待,直到活动线程执行 <clinit>()
方法完毕。如果在一个类的 <clinit>()
方法中有耗时很长的操作,就可能会造成多个进程阻塞。
虚拟机设计团队把类加载阶段中:“通过一个类的完全限定名来获取描述此类的二进制字节流”,这样一个动作放到了 JVM 外部来实现,以便让应用程序自己决定如何获取需要的类。实现这个动作的代码模块被称为“类加载器”。
在一个 JVM 实例内,对于任意一个类,都需要由加载它的类加载器和这个类本身来共同确立其唯一性,每个类加载器都有一个独立的类名称空间。
两个相等的类,意味着由同一个虚拟机加载。相等性包括 Class 对象的 equals 方法、isAssignableFrom 方法、isInstance 方法返回的结果,也包括使用 isinstanceof 关键字对对象所属的类进行关系判定等情况。
从 JVM 的角度来看,只有两种不同的类加载器:一种是启动类加载器,由 C++ 语言实现,是 JVM 的一部分;另一种就是所有其他的加载器,均由 Java 语言实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
。
从应用开发的角度看,绝大部分的 Java 程序都会用到以下 3 种类加载器:
启动类加载器:负责将存放在 <JAVA_HOME>/lib
目录中、或者被 -Xbootclasspath
参数指定的路径中的、并且是虚拟机识别的(以文件名识别,如 rt.jar
)类库加载到虚拟机内存中。该加载器无法被 Java 程序直接引用,如果在编写自定义加载器时需要将加载请求委派给引导类加载器,可以直接使用 null 作为自定义加载器的父加载器。
扩展类加载器:由 sun.misc.Launcher$ExtClassLoader
实现,负责加载 <JAVA_HOME>/lib/ext
目录中的、或者被 java.ext.dirs
系统变量指定的路径中的所有类库,可以被开发者直接使用。
应用类加载器:由 sun.misc.Launcher$AppClassloader
实现。是 ClassLoader.getSystemClassLoader
的返回值,一般也称为系统类加载器。负责加载用户类路径(ClassPath)中所有的类库,可以被开发者直接使用。如果应用中没有自定义实现任何加载器,一般情况下就是程序中默认的加载器。
上图展示了这几种加载器之间的关系,称为类加载器的“双亲委派模型”。该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有字节的父类加载器。这里所指的类加载器之间的父子关系不使用类继承形式来实现,而是使用组合关系来将加载请求为派给父类加载器。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,他首先不会自己去尝试执行加载,而是把该请求委派给父加载器去完成加载,每一个层次的加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成该加载请求时(在搜索范围内无法找到需要的类),子加载器才会尝试自己去加载。
这种模型的好处就是,Java 类随着其类加载器一起具备了一种优先级层次关系。比如 Objcet 类,它被存放在 rt.jar
中,无论哪个加载器要加载该类,最终都是委派给处于模型最顶层的启动类加载器进行加载,因此 Object 类在程序的各个类加载器中都是同一个类。如果没有使用双亲委派模型,而是由各个类加载器自己去加载的话,如果用户字节编写了一个名为 java.lang.Object
的类,Java 类型体系中的最基础行为就无法得到保证。
双亲委派模型并非一个强制性的约束模型,而 Java 设计者推荐给开发者的类加载器实现方式。
下面是种对这种模型的“破坏(创新)”形式。
第一次“被破坏”其实发生在双亲委派模型出现之前,即 JDK 1.2 发布之前。由于双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader
在早在 JDK 1.0 时代就已经存在,面对已经存在的用户自定义加载器实现,Java 设计者在引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader
添加了一个新的 protected 方法 findClass。在此之前,用户继承 ClassLoader 时唯一的目的就是重新 loadClass 方法,因为虚拟机在进行类加载时会调用加载器的私有方法 loadClassInternal,而该方法的唯一逻辑就是调用 loadClass 方法。
在 JDK 1.2 之后已经不提倡覆写 loadClass 方法,而应当把自己的类加载逻辑编写在 findClass 方法中,在 loadClass 方法的逻辑里如果父类记载失败,则会调用自己的 findClass 方法来完成加载,这样就可以保证新编写的类加载器都符合双亲委派模型。
双亲委派模型很好的解决了各个加载器对基础类的统一问题(越基础的类越由上层的加载器完成加载),基础类之所以被称为“基础”,是因为它们总是作为被用户代码调用的 API。但有时候基础类也需要调用用户代码。
如 JNDI 服务,它的代码由启动类加载器完成加载(rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序 ClassPath 下的 JNDI 接口提供者(SPI)代码,但启动类加载器并不认识这些代码。
为了解决该问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文加载器。该加载器可以通过 Thread 类的 setContextClassLoader 方法进行设置,如果线程创建时还未设置,它将会从父线程中继承一个加载器,如果在应用程序的全局范围内都没有设置过,那么就使用应用程序类加载器。
JDNI 使用该加载器来加载所需的 SPI 代码,也就是父类加载器请求子类加载器来完成对类的加载操作,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,也违背了双亲委派模型的一般性原则。Java 中的所有 SPI 的加载动作基本都采用这种方式。
这里所说的动态性指的是:代码热替换、模块热部署等。OSGI 已经成为了业界“事实上”的 Java 模块化标准,而 OSGI 实现模块化热部署的关键就是它自已定义的类加载器机制。每个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起替换掉以实现代码的热替换。
在 OSGI 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGI 将按照下面的顺序来搜索类:
java.*
开头的类委派给父类加载器。以上查找顺序中只有开头两点符合双亲委派模型,其余的类查找都在平级的类加载器中机进行。
本章介绍了类加载过程的“加载”、“验证”、“准备”、“解析”和“初始化”5个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。