【面经】面试官:讲讲类的加载、链接和初始化?

写在前面

在前几年面试Java高级程序员的时候,只要是会一点JVM的基础知识,基本就都能够面试通过了。最近几年,对Java工程师的要求越来越严格,对于中级Java工程师来说,也需要掌握JVM相关的知识了。这不,一名读者出去面试Java中级岗位,就被问及了JVM相关的类的加载、链接和初始化的问题。

概述

本文我们一起讨论Java类的加载、链接和初始化。 Java字节代码的表现形式是字节数组(byte[]),而Java类在JVM中的表现形式是 java.lang.Class类 的对象。一个Java类从字节代码到能够在JVM中被使用,需要经过加载、链接和初始化这三个步骤。这三个步骤中,对开发人员直接可见的是Java类的加 载,通过使用Java类加载器(class loader)可以在运行时刻动态的加载一个Java类;而链接和初始化则是在使用Java类之前会发生的动作。本文会详细介绍Java类的加载、链接和 初始化的过程。

Java 类的加载

Java类的加载是由类加载器来完成的。

一般来说,类加载器分成两类:启动类加载器(bootstrap)和用户自定义的类加载器(user-defined)。

两者的区别在于启动类加载器是由JVM的原生代码实现的,而用户自定义的类加载器都继承自Java中的 java.lang.ClassLoader类。在用户自定义类加载器的部分,一般JVM都会提供一些基本实现。应用程序的开发人员也可以根据需要编写自己的类加载器。 JVM中最常使用的是系统类加载器(system),它用来启动 Java应用程序的加载。通过java.lang.ClassLoader的 getSystemClassLoader()方法可以获取到该类加载器对象。

类加载器需要完成的最终功能是定义一个Java类,即把Java字节代码转换成JVM中的java.lang.Class类的对象。但是类加载的过程并不是这么简单。

Java类加载器有两个比较重要的特征:层次组织结构和代理模式。

层次组织结构指的是每个类加载器都有一个父类加载器,通过 getParent()方法可以获取到。类加载器通过这种父亲-后代的方式组织在一起,形成树状层次结构。代理模式则指的是一个类加载器既可以自己完成Java类的定义工作,也可以代理给其它的类加载器来完成。由于代理模式的存在,启动一个类的加载过程的类加载器和最终定义这个类的类加载器可能并不是一个。前者称为初始类加载器, 而后者称为定义类加载器。

两者的关联在于:一个Java类的定义类加载器是该类所导入的其它Java类的初始类加载器。比如类A通过import导入了类 B,那么由类A的定义类加载器负责启动类B的加载过程。一般的类加载器在尝试自己去加载某个Java类之前,会首先代理给其父类加载器。当父类加载器找不到的时候,才会尝试自己加载。这个逻辑是封装在java.lang.ClassLoader类的 loadClass()方法中的。一般来说,父类优先的策略就足够好了。在某些情况下,可能需要采取相反的策略,即先尝试自己加载,找不到的时候再代理给父类加载器。这种做法在Java的Web容器中比较常见,也是 Servlet规范推荐的做法。比如,Apache Tomcat为每个Web应用都提供一个独立的类加载器,使用的就是自己优先加载的策略。 IBM WebSphere Application Server则允许Web应用选择。

类加载器使用的策略

类加载器的一个重要用途是在JVM中为相同名称的Java类创建隔离空间。在JVM中,判断两个类是否相同,不仅是根据该类的二进制名称 ,还需要根据两个类的定义类加载器。只有两者完全一样,才认为两个类是相同的。因此,即便是同样的Java字节代码,被两个不同的类加载器定义之后,所得到的Java类也是不同的。如果试图在两个类的对象之间进行赋值操作,会抛出 java.lang.ClassCastException。这个特性为同样名称的Java类在JVM中共存创造了条件。在实际的应用中,可能会要求同一名称的Java类的不同版本在JVM中可以同时存在。通过类加载器就可以满足这种需求。这种技术在 OSGi中得到了广泛的应用

Java 类的链接

Java类的链接指的是将Java类的二进制代码合并到JVM的运行状态之中的过程。在链接之前,这个类必须被成功加载。类的链接包括验证、准备和解析等几个步骤。验证是用来确保Java类的二进制表示在结构上是完全正确的。如果验证过程出现错误的话,会抛出 java.lang.VerifyError错误。

准备过程则是创建Java类中的静态域,并将这些域的值设为默认值。准备过程并不会执行代码。在一个Java类中会包含对其它类或接口的形式引用,包括它的父类、所实现的接口、方法的形式参数和返回值的Java类等。解析的过程就是确保这些被引用的类能被正确的找到。解析的过程可能会导致其它的 Java类被加载。不同的 JVM 实现可能选择不同的解析策略。

一种做法是在链接的时候,就递归的把所有依赖的形式引用都进行解析。而另外的做法则可能是只在一个形式引用真正需要的时候才进行解析。也就是说如果一个 Java 类只是被引用了,但是并没有被真正用到,那么这个类有可能就不会被解析。考虑下面的代码:

public class LinkTest {
    public static void main(String[] args) {
        ToBeLinked toBeLinked = null;
        System.out.println("Test link.");
    }
}

类LinkTest 引用了类 ToBeLinked,但是并没有真正使用它,只是声明了一个变量,并没有创建该类的实例或是访问其中的静态域。

在 Oracle 的 JDK 6 中,如果把编译好的 ToBeLinked 的 Java 字节代码删除之后,再运行 LinkTest,程序不会抛出错误。这是因为 ToBeLinked 类没有被真正用到,而 Oracle 的 JDK 6 所采用的链接策略使得ToBeLinked 类不会被加载,因此也不会发现 ToBeLinked 的 Java 字节代码实际上是不存在的。如果把代码改成 ToBeLinked toBeLinked = new ToBeLinked();之后,再按照相同的方法运行,就会抛出异常了。因为这个时候 ToBeLinked 这个类被真正使用到了,会需要加载这个类。

Java 类的初始化

当一个 Java 类第一次被真正使用到的时候,JVM 会进行该类的初始化操作。初始化过程的主要操作是执行静态代码块和初始化静态域。在一个类被初始化之前,它的直接父类也需要被初始化。但是,一个接口的初始化,不会引起其父接口的初始化。在初始化的时候,会按照源代码中从上到下的顺序依次执行静态代码块和初始化静态域。考虑下面的代码:

public class StaticTest {
    public static int X = 10;
    public static void main(String[] args) {
        System.out.println(Y); //输出60
    }
    static {
        X = 30;
    }
    public static int Y = X * 2;
}

在上面的代码中,在初始化的时候,静态域的初始化和静态代码块的执行会从上到下依次执行。因此变量 X 的值首先初始化成 10,后来又被赋值成 30;而变量 Y 的值则被初始化成 60。

Java类和接口的初始化时机

Java 类和接口的初始化只有在特定的时机才会发生,这些时机包括:

  • 创建一个 Java 类的实例。如
MyClass obj = new MyClass()
  • 调用一个 Java 类中的静态方法。如
MyClass.sayHello()
  • 给 Java 类或接口中声明的静态域赋值。如
MyClass.value = 10
  • 访问 Java 类或接口中声明的静态域,并且该域不是常值变量。如
int value = MyClass.value
  • 在顶层 Java 类中执行 assert 语句。
assert true;

通过 Java 反射 API 也可能造成类和接口的初始化。需要注意的是,当访问一个 Java类或接口中的静态域的时候,只有真正声明这个域的类或接口才会被初始化。如下面的代码所示。

package io.mykit.binghe.test;
 
class B {
    static int value = 100;
    static {
        System.out.println("Class B is initialized."); // 输出
    }
}
 
class A extends B {
    static {
        System.out.println("Class A is initialized."); // 不会输出
    }
}
 
public class InitTest {
    public static void main(String[] args) {
        System.out.println(A.value); // 输出100
    }
}

在上述代码中,类 InitTest 通过 A.value 引用了类 B 中声明的静态域 value。由于 value是在类 B 中声明的,只有类 B 会被初始化,而类 A 则不会被初始化。

创建自己的类加载器

在 Java 应用开发过程中,可能会需要创建应用自己的类加载器。典型的场景包括实现特定的 Java 字节代码查找方式、对字节代码进行加密/解密以及实现同名 Java 类的隔离等 。创建 自己的 类加载 器并不 是 一件复杂 的事情 ,只需要继承自java.lang.ClassLoader 类并覆写对应的方法即可。 java.lang.ClassLoader 中提供的方法有不少,下面介绍几个创建类加载器时需要考虑的:

  • defineClass():这个方法用来完成从Java字节代码的字节数组到java.lang.Class的转换。这个方法是不能被覆写的,一般是用原生代码来实现的。
  • findLoadedClass():这个方法用来根据名称查找已经加载过的Java类。一个类加载器不会重复加载同一名称的类。
  • findClass():这个方法用来根据名称查找并加载Java类。
  • loadClass():这个方法用来根据名称加载Java类。
  • resolveClass():这个方法用来链接一个Java类。

这里比较 容易混淆的是 findClass()方法和 loadClass()方法的作用。前面提到过,在Java 类的链接过程中,会需要对 Java 类进行解析,而解析可能会导致当前 Java 类所引用的其它 Java 类被加载。在这个时候,JVM 就是通过调用当前类的定义类加载器的 loadClass()方法来加载其它类的。 findClass()方法则是应用创建的类加载器的扩展点。应用自己的类加载器应该覆写 findClass()方法来添加自定义的类加载逻辑。loadClass()方法的默认实现会负责调用 findClass()方法。前面提到,类加载器的代理模式默认使用的是父类优先的策略。这个策略的实现是封装在 loadClass()方法中的。如果希望修改此策略,就需要覆写 loadClass()方法。

下面的代码给出了自定义的类加载的常见实现模式

public class MyClassLoader extends ClassLoader {
    protected Class findClass(String name) throws ClassNotFoundException {
        byte[] b = null; //查找或生成Java类的字节代码
        return defineClass(name, b, 0, b.length);
    }
}

好了,今天就到这儿吧,我是冰河,我们下期见!!

重磅福利

微信搜一搜【冰河技术】微信公众号,关注这个有深度的程序员,每天阅读超硬核技术干货,公众号内回复【PDF】有我准备的一线大厂面试资料和我原创的超硬核PDF技术文档,以及我为大家精心准备的多套简历模板(不断更新中),希望大家都能找到心仪的工作,学习是一条时而郁郁寡欢,时而开怀大笑的路,加油。如果你通过努力成功进入到了心仪的公司,一定不要懈怠放松,职场成长和新技术学习一样,不进则退。如果有幸我们江湖再见!

另外,我开源的各个PDF,后续我都会持续更新和维护,感谢大家长期以来对冰河的支持!!

写在最后

如果你觉得冰河写的还不错,请微信搜索并关注「 冰河技术 」微信公众号,跟冰河学习高并发、分布式、微服务、大数据、互联网和云原生技术,「 冰河技术 」微信公众号更新了大量技术专题,每一篇技术文章干货满满!不少读者已经通过阅读「 冰河技术 」微信公众号文章,吊打面试官,成功跳槽到大厂;也有不少读者实现了技术上的飞跃,成为公司的技术骨干!如果你也想像他们一样提升自己的能力,实现技术能力的飞跃,进大厂,升职加薪,那就关注「 冰河技术 」微信公众号吧,每天更新超硬核技术干货,让你对如何提升技术能力不再迷茫!

你可能感兴趣的