首发于微信公众号: BaronTalk ,欢迎关注!
上一篇文章我们介绍了 「类文件结构」 ,这一篇我们来看看虚拟机是如何加载类的。
我们的源代码经过编译器编译成字节码之后,最终都需要加载到虚拟机之后才能运行。虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的 类加载机制 。
与编译时需要进行连接工作的语言不同,Java 语言中类的加载、连接和初始化都是在程序运行期间完成的,这种策略虽然会让类加载时增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性,Java 里天生可动态扩展的语言特性就是依赖运行期间动态加载和动态连接的特点实现的。
例如,一个面向接口的应用程序,可以等到运行时再指定实际的实现类;用户可以通过 Java 预定义的和自定义的类加载器,让一个本地的应用程序运行从网络上或其它地方加载一个二进制流作为程序代码的一部分。
类从被虚拟机从加载到卸载,整个生命周期包含:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)。这 7 个阶段的发生顺序如下图:
上图中加载、验证、准备、初始化和卸载 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始「注意,这里说的是按部就班的开始,并不要求前一阶段执行完才能进入下一阶段」,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。
虚拟机规范中对于什么时候开始类加载过程的第一节点「加载」并没有强制约束。但是对于「初始化」阶段,虚拟机则是严格规定了有且只有以下 5 种情况,如果类没有进行初始化,则必须立即对类进行「初始化」(加载、验证、准备自然需要在此之前开始):
「有且只有」以上 5 种场景会触发类的初始化,这 5 种场景中的行为称为对一个类的主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。比如如下几种场景就是被动引用:
这里的「加载」是指「类加载」过程的一个阶段。在加载阶段,虚拟机需要完成以下 3 件事:
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面 4 个阶段的检验动作:
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配。这个阶段中有两个容易产生混淆的概念需要强调下:
public static int value = 123;
那么变量 value
在准备阶段过后的初始值为 0 而不是 123,因为这个时候尚未执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译之后,存放于类构造器
这里提到,在「通常情况」下初始值是零值,那相对的会有一些「特殊情况」:如果类字段的字段属性表中存在 ConstantsValue 属性,那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指的值。假设上面的类变量 value 的定义变为 public static final int value = 123;
,编译时 JavaC 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。前面提到过很多次符号引用和直接引用,那么到底什么是符号引用和直接引用呢?
类初始化阶段是类加载过程中的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全是由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。初始阶段是执行类构造器
虚拟机设计团队把类加载阶段中的「通过一个类的全限定名来获取描述此类的二进制字节流」这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为「类加载器」。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机的唯一性,每个类加载器都拥有一个独立的类名称空间。也就是说:比较两个类是否「相等」,只要在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 来实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 来实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader
。
从 Java 开发者的角度来看,类加载器可以划分为:
sun.misc.Launcher$ExtClassLoader
实现,它负责加载/lib/ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器; sun.misc.Launcher$App-ClassLoader
实现。 getSystemClassLoader()
方法返回的就是这个类加载器,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 我们的应用程序都是由这 3 种类加载器互相配合进行加载的,在必要时还可以自己定义类加载器。它们的关系如下图所示:
上图中所呈现出的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器以外,其余的类加载器都应当有自己的父类加载器。
双亲委派模型的工作过程是这样的:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个类加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
这样做的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基本的行为也就无法保证了。
双亲委派模型对于保证 Java 程序运行的稳定性很重要,但它的实现很简单,实现双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法中,逻辑很清晰:先检查是否已经被加载过,若没有则调用父类加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。
protected 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 { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 如果父类抛出 ClassNotFoundException 说明父类加载器无法完成加载 } if (c == null) { // 如果父类加载器无法加载,则调用自己的 findClass 方法来进行类加载 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }
关于类文件结构和类加载就通过连续的两篇文章介绍到这里了,下一篇我们来聊聊「虚拟机的字节码执行引擎」。
如果你喜欢我的文章,就关注下我的公众号 BaronTalk 、 知乎专栏 或者在 GitHub 上添个 Star 吧!