JVM 在 new 一个对象的时候,会先查看对象所属的类有没有被加载到内存。如果没有的话,就通过类的全限定名来加载,等类加载完成之后,再创建对象。总的来说, new 一个对象可以分为两个过程:类加载和创建对象。
类加载
虚拟机把描述类的数据从 Class 文件(不一定存在于磁盘中,这里所说的 Class 文件应当是一串二进制的字节流,无论以何种形式存在都可以)加载到内存,并对数据进行连接和初始化,最终形成可以被虚拟机直接使用的 Class 对象。
与那些在编译时需要进行连接工作的语言不同,在 Java 语言里,Class 的加载、连接和初始化都是在程序运行期间完成的,这种策略虽然令类加载时增加一些性能开销,但是会给 Java 应用程序带来高度的灵活性。
例如,如果编写一个面向接口的应用程序,可以等到运行时再指定其具体的实现类;用户可以通过 Java 预定义或自定义的类加载器,让一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于 Java 程序之中。从最基础的 Applet、JSP 到相对复杂的 OSGi 技术,都使用了 Java 语言运行期加载类的特性。
类加载的时机
类的生命周期
从加载到内存中开始,到卸载出内存为止,类的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载 7 个阶段。其中验证、准备、解析这 3 个阶段统称为连接。
类的生命周期
加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类加载的过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定。注意,这里笔者写的是按部就班地『开始』,而不是按部就班地『进行』或『完成』,强调这点是因为这些阶段通常都是互相交叉进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。
类的初始化条件
什么情况下需要开始类加载过程的第一个阶段?Java 虚拟机规范中并没有进行强制约束,这由具体的虚拟机实现自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了以下 5 种情况必须立即对类进行『初始化』(加载、验证、准备自然需要在此之前开始):
- 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main 方法的那个类),虚拟机会先初始化这个主类。
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果包含 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
对于这 5 种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:『有且仅有』,这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。下面举 3 个例子来说明何为被动引用:
代码示例 1-1
1 | public class SuperClass { |
运行结果
类加载的过程
接下来我们详细讲解一下 Java 虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化这 5 个阶段所执行的具体动作。
加载
加载是类加载过程的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,虚拟机需要完成以下 3 件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在方法区中生成一个代表这个类的 java.lang.Class 对象,作为这个类的访问入口。
虚拟机规范的这 3 点要求其实并不算具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如『通过一个类的全限定名来获取定义此类的二进制字节流』这条,它没有指明二进制字节流要从一个 Class 文件中获取,准确地说是根本没有指明要从哪里获取、怎样获取。虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的舞台,Java 发展历程中,充满创造力的开发人员则在这个舞台上玩出了各种花样,许多举足轻重的 Java 技术都建立在这一基础之上,例如:
- 从 ZIP 包中读取,这很常见,最终成为 JAR、EAR、WAR 格式的基础。
- 从网络中获取,这种场景最典型的应用就是 Applet。
- 运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为 $Proxy 的代理类的二进制字节流。
- 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
验证
验证阶段是连接的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java 语言本身是相对安全的语言(相对于 C/C++ 来说),使用纯粹的 Java 代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。但前面已经说过,Class 文件并不一定要求用 Java 源码编译而来,可以使用任何途径产生,甚至可以用十六进制编辑器直接编写 Class 文件。在字节码语言层面上,上述 Java 代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证 Class 文件是虚拟机对自身的一种保护工作。
验证又大致分为 4 个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证
第一阶段要验证字节流是否符合 Class 文件格式 的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
- 是否以魔数 0xCAFEBABE 开头。
- 主、次版本号是否在当前虚拟机处理范围之内。
- 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据。
…
元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
- 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流,确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中。
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
…
如果一个方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。 即使字节码验证之中进行了大量的检查,也不能保证这一点。这里涉及了离散数学中一个很著名的问题 停机问题。
符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合字段描述符所描述的方法和字段。
- 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。
…
符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个 java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。
对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些类变量所使用的内存都将在方法区中进行分配。这里有两点需要强调一下:
- 这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
- 这里所说的初始值『通常情况』下是数据类型的零值。
假设一个类变量的定义为:
1 | public static int value=123; |
那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法。把 value 赋值为 123 的 putstatic 指令存放于类构造器方法 <clinit>()
之中,所以把 value 赋值为 123 的动作将在初始化阶段执行。下表列出了 Java 中所有基本数据类型的零值。
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0 L | float | 0.0 f |
short | (short) 0 | double | 0.0 d |
char | ‘\u0000’ | reference | null |
byte | (byte) 0 |
上面提到,在『通常情况』下初始值是零值,那么也存在一些『特殊情况』。如果将类变量 value 的定义改为:
1 | public static final int value=123; |
那么在准备阶段时,虚拟机会直接将 value 赋值为 123。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
初始化阶段是类加载过程的最后一步,在前面的类加载过程中,除了加载阶段用户应用程序可以通过自定义的类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。 或者可以从另外一个角度来表达:初始化阶段是执行类构造器方法 <clinit>()
的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如代码示例 1-2 所示。
代码示例 1-2
1 | public class Test { |
类加载机制
虚拟机设计团队把加载阶段中『通过一个类的全限定名来获取定义此类的二进制字节流』这个动作放到了 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为类加载器。
类加载器可以说是 Java 语言的一项创新,也是 Java 语言流行的重要原因之一,它最初是为了满足 Java Applet 的需求而开发出来的。虽然目前 Java Applet 技术基本上已经死掉,但类加载器却在类层次划分、OSGi、热部署、代码加密等领域大放异彩,成为了 Java 技术体系中一块重要的基石,可谓是失之东隅,收之桑榆。
类与类加载器
类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远远不限于加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否『相等』,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的『相等』,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属类型的判定。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果。代码示例 1-3 演示了不同的类加载器对 instanceof 关键字运算结果的影响。
代码示例 1-3
1 | /** |
运行结果
代码清单 1-3 中构造了一个简单的类加载器。它可以加载与自己在同一路径下的 Class 文件。我们使用这个类加载器去加载了一个名为 jvm.clazz.ClassLoaderTest 的类,并实例化了这个类的对象。两行输出结果中,从第一行可以看出,这个对象确实是类 jvm.clazz.ClassLoaderTest 实例化出来的对象,但从第二行可以发现,这个对象与类 jvm.clazz.ClassLoaderTest 做所属类型检查的时候却返回了 false。这是因为虚拟机中存在了两个 jvm.clazz.ClassLoaderTest 类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自同一个 Class 文件,但依然是两个独立的类,做对象所属类型检查时结果自然为 false。
双亲委派模型
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:
- 一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分。
- 另一种就是所有其它的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
从 Java 开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分 Java 程序都会使用到以下 3 种系统提供的类加载器:
- 启动类加载器(Bootstrap ClassLoader)
- 这个类将器负责将存放在
<JAVA_HOME>/jre/lib
目录中的,并且是虚拟机识别的(按照文件名识别,如 rt.jar)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。
- 这个类将器负责将存放在
- 扩展类加载器(Extension ClassLoader)
- 这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载
<JAVA_HOME>/lib
目录中的所有类库,开发者可以直接使用扩展类加载器。
- 这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载
- 应用程序类加载器(Application ClassLoader)
- 这个类加载器由 sun.misc.Launcher$AppClassLoader 实现。由于这个类加载器是 ClassLoader 中 getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这 3 种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如下图所示。
类加载器双亲委派模型
图中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
类加载器的双亲委派模型在 JDK 1.2 期间被引入并被广泛应用于之后几乎所有的 Java 程序中,但它并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它不会首先加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时(它的搜索范围中没有找到对应的类),子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object 类,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。如果读者有兴趣的话,可以尝试去编写一个与 rt.jar 类库中已有类重名的 Java 类,将会发现可以正常编译,但永远无法被加载运行。
破坏双亲委派模型
上文提到过双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的类加载器实现方式。在 Java 的世界中大部分的类加载器都遵循这个模型,但也有例外,到目前为止,双亲委派模型主要出现过 3 较大规模的『被破坏』情况。
第一次被破坏
双亲委派模型的第一次被破坏其实发生在双亲委派模型出现之前——即 JDK 1.2 发布之前。由于双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK 1.0 时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java 设计者引入双亲委派模型时不得不做出一些妥协。
为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader 添加了一个新的 findClass() 方法,在此之前,用户去继承 java.lang.ClassLoader 的唯一目的就是重写 loadClass() 方法。因为双亲委派的具体逻辑就实现在 loadClass() 方法中,JDK 1.2 之后已不提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载逻辑写到新增的 findClass() 方法。在 loadClass() 方法的逻辑里,如果父类加载失败,则会调用 findClass() 方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。
java.lang.ClassLoader 中的 loadClass() 方法
第二次被破坏
双亲委派模型的第二次被破坏是由这个模型自身的缺陷所导致的。双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的类加载器进行加载),基础类之所以称为『基础』,是因为它们总是作为被用户代码调用的 API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?
这并非是不可能的事情,一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器去加载(在 JDK 1.3 时放进了 rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能认识这些代码啊!那该怎么办?
为了解决这个问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文类加载器,就可以做一些『舞弊』的事情了,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。
第三次被破坏
双亲委派模型的第三次被破坏是由于用户对程序动态性的追求而导致的。这里所说的『动态性』指的是当前一些非常热门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等,说白了就是希望应用程序能像我们的计算机外设那样,接上鼠标、U 盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用停机也不用重启。对于个人计算机来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是企业级软件开发者具有很大的吸引力。
Sun 公司所提出的 JSR-294、JSR-277 规范在与 JCP 组织的模块化规范之争中落败给 JSR-291(即 OSGi R4.2),虽然 Sun 不甘失去 Java 模块化的主导权,独立在发展 Jigsaw 项目,但目前 OSGi 已经成为了业界『事实上』的 Java 模块化标准,而 OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。
虽然使用了『被破坏』这个词来形容上述不符合双亲委派模型原则的行为,但这里『被破坏』并不带有贬义的感情色彩。只要有足够意义和理由,突破已有的原则就可认为是一种创新。 正如 OSGi 中的类加载器并不符合传统的双亲委派的类加载器,并且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但在 Java 程序员中基本有一个共识:OSGi 中对类加载器的使用是很值得学习的,弄懂了 OSGi 的实现,就可以算是掌握了类加载器的精髓。
创建对象
创建对象可分为 3 个步骤:
- 在堆区分配对象需要的内存
- 分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量。
- 对所有实例变量赋零值
- 将方法区内对实例变量的定义拷贝一份到堆区,然后赋零值。
- 执行实例初始化代码
- 初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法。
- 如果有类似于 Child c = new Child() 形式的 c 引用的话,在栈区定义 Child 类型引用变量 c,然后将堆区对象的地址赋值给它。
- 需要注意的是,每个子类对象持有父类对象的引用,可在内部通过 super 关键字来调用父类对象,但在外部不可访问。
通过实例引用调用实例方法时,先从方法区中对象的类型信息中找,找不到的话再去父类的类型信息中找。如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要经过很多次查找。这时候大多系统会采用一种称为 虚方法表 的方式来优化调用的效率。