聊聊这道经典的大厂面试题

程序员鱼皮

共 9590字,需浏览 20分钟

 ·

2022-02-20 11:03


大家好,今天我们来学习一道 Java 常见的面试题:类加载机制。


在工程中我们基本无时无刻都在和对象打交道,那么大家有想过这些这些对象是怎么来的吗,当 new 一个对象的时候到底发生了什么?

没错,就是类加载机制,了解这个机制很重要,这不仅能让我们理解 JVM 的运行机制,更重要的是它还能解释一些我们看起来觉得很奇怪的现象,比如如下懒汉式单例模式

public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }
  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}

乍一看可能会觉得多线程环境下可能会产生多个 Singleton 实例,实际上由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个 Singleton 实例,再问这个线程安全是如何保证的?这就得进一步了解类初始化阶段的 clinit 方法了,所以你看看了解类加载这些底层的机制有多么重要。

本文思维导图如下:

类加载机制简介

类加载整体流程如下图所示,这也是类的生命周期

我们可以看到,字节码文件需要经过加载链接(包括验证,准备,解析),初始化才能转为类,然后才能根据类来创建对象

需要注意的是,图中红框所代表的加载验证准备初始化卸载这五个阶段的顺序是确定的,类加载必须严格按照这五个阶段的顺序来开始,但解析阶段则未必,有可能在初始化之后才开始,主要是为了支持 Java 的动态绑定特性,那么各个阶段主要做了哪些事呢

加载

在加载阶段,虚拟机需要完成以下三件事

  1. 通过一个类的全限定名来获取此类的二进制字节流

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时结构

  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

加载

如上图所示,加载后生成的类对象与对象间的关系如上图所示,什么是类对象呢,比如实例的 getClass() 或 Foo.Class 即为类对象

每个类只有一个对象实例(类对象),多个对象共享类对象,这里有个需要注意的点是类对象是在堆中而不是在方法区(这里针对的是 Java 7 及以后的版本),所有的对象都是分配在堆中的,类对象也是对象,所以也是分配在堆中,这点网上挺多人混淆了,需要注意一下

有人可能会奇怪,只看上面这张图,对象和类对象貌似联系不起来,实际上在虚拟机底层,比如 Java Hotspot 虚拟机,对象和类是以一种被称为 oop-klass 的模型来表示的,每个对象或类都有对应的 C++ 类表示方式,它的底层其实是如下这样来表示的,通过下图可以看到,通过这种方式实例对象和 Class 对象就能联系起来了,我们另一篇讲述对象模型时再详述 oop-klass 对象,这里先按下不表

类元信息也就是类的信息主要分配在方法区,在 Java 8 中方法区是在元空间(metaspace)中实现的,所以类元信息是保存在元空间的。

注意这一阶段虽然名曰加载,但其实在加载阶段是夹杂着一些验证工作的,主要有以下验证

  • 文件格式的验证:比如验证字节码是否是以魔数 0xCAFEBABE 开头,主次版本是否在当前虚拟机可接受范围内等安全校验的操作等,通过这一阶段的验证后加载的字节流才被允许进入 Java 虚拟机内存的方法区中进行存储。

  • 元数据校验:这一阶段主要是对字节码描述的信息进行语义分析,如确保每一个加载的类除了 Object 外都有父类,这也就意味着,一旦某个类被加载,那么它的父类,祖先类。。。等也会被加载(但此时还不会被链接,初始化)

有人可能会困惑,为啥需要做这些校验工作呢,字节码文件难道不安全?字节码文件一般来说是通过正常的 Java 编译器编译而成的,但字节码文件也是可以编辑修改的,也是有可能被篡改注入恶意的字节码的,就会对程序造成不可预知的风险,所以加载阶段的验证是非常有必要的

我们可以在执行 java 程序的时候加上 -verbose:class 或 -XX:+TraceClassLoading 这两个 JVM 参数来观察一下类的加载情况,比如我们写了如下测试类

public class Test {
    public static void main(String[] args) {
    }
}

编译后执行 java -XX:+TraceClassLoading Test

可以看到如下加载过程

[Opened /Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
[Loaded java.lang.Object from /Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
[Loaded java.lang.CharSequence from /Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
[Loaded java.lang.String from /Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
... // 省略号表示加载了很多 lib/rt.jar 下的类
[Loaded Test from file:/Users/ronaldo/practice/]
...

注意看倒数第二行,可以看到 Test 类被加载了,这可以理解,因为执行了 Test 的 main 方法,Test 会被初始化,也就会被加载(之后会讲述初始化条件), 但上述有挺多加载 lib/rt.jar 下的类又是怎么回事呢?

要回答这个问题,我们必须得先搞清楚一个问题:我们说的类加载到底是由谁执行的?

双亲委派模式

类加载必须由类加载器(classloader)来完成,类加载器+类的全限定名(包名+类名)唯一确定一个类,看到这有人可能会问了,类加载器难道会有多个?

猜得没错!类加载器的确会有多个,为啥会有多个呢,主要有两个目的:安全性责任分离

首先说安全性,试想如果只有一个类加载器会出现什么情况,我们可能会定义一个java.lang.virus 的类,这样的话由于此类与 Java.lang.String 等核心类处于同一个包名下,那么此类就具有访问这些核心类 package 方法的权限,此外如果用户自定义一个 java.lang.String 类,如果类加载器加载了这个类,有可能把原本的  String 类给替换掉,这显然会造成极大的安全隐患

再来说责任分离,像 rt.jar 包下的核心类等没有什么特殊的要求显然可以直接加载,而且由于是核心类,程序一启动就会被加载,也可以进一步优化来提升加载速度,而有些字节码文件由于反编译等原因可能需要加密,此时类加载器就需要在加载字节码文件时对其进行解密,再比如实现热部署也需要类加载器从指定的目录中加载文件,这些功能如果都在一个类加载器里实现,会导致类加载器的功能很重,所以解决办法就是定义多个类加载器,各自负责加载指定路径下的字节码文件,从而针对指定路径下的类文件加载做相关的操作,达到责任分离的目的

在 JVM 中有哪些类加载器呢

主要有以下三类加载器

  1. 启动类加载器(BootstrapClassLoader):,负责加载\lib 下的 rt.jar,resources.jar 等核心类库或者 -Xbootclasspath 指定的文件

  2. 扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库。

  3. 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

类加载器的主要作用就是负责加载字节码二进制流,将其最终转成方法区中的类对象

现在我们知道了有以上几个种类的类加载器,那么这里有三个问题需要回答:

  1. 怎么指定类由指定的类加载器加载的呢?

  2. 类加载器是如何保证类的一致性的,由以上可知类加载器+类的全限定名唯一确定一个类,那怎么避免一个类被多个类加载器加载呢,毕竟你无法想象工程中有两个 Object 类,那岂不乱套了

  3. 类加载器(java.lang.ClassLoader)是用来加载类的,但其本身也是类,那么类加载器又是被谁加载的呢

为了解决上述问题,类加载器采用了双亲委派模型模式来设计类加载器的层次结构

什么是双亲委派模式

先来看一下双亲委派模式的整体设计架构图

可以看到,程序默认是由 AppClassLoader 加载的,每个类被相应的加载器加载后都会被缓存起来,这样下次再碰到相关的类直接从缓存里获取即可,避免了重复的加载,同时每个类由于只会被相应的类加载器加载,确保了类的唯一性,比如 java.lang.Object 只会被 BootstrapClassLoader 加载,保证了 Object 的唯一性

类加载器是如何加载类的呢?

  1. 当类首次被加载时(假设此类为 ArrayList),AppClassLoader 并不会马上就加载它,而是会向上委托给它的 parent,即 ExtClassLoader,查看是否已加载了这个类,如果没有则继续向上委托给 BootsrapClassLoader 让其加载,此时 BootsrapClassLoader 就会从 lib/rt.jar 加载此类生成类对象并缓存起来,然后 BootsrapClassLoader 会把此类对象返回给 ExtClassLoader,ExtClassLoader 再把此类对象返回给 AppClassLoader,然后就可以基于此类对象来创建类的实例对象了

  2. 当再次调用 new ArrayList() 时,也会触发 ArrayList 的加载,此时 AppClassLoader 也会首先往上层层委托给 BootsrapClassLoader 给加载,由于其缓存里已经有此类对象了,所以直接在缓存里查找后递归返回给 AppClassLoader 即可。

再来看上述问题 3,类加载器是被谁加载的?

实际上 AppClassLoader 和 ExClassLoader 都是 java.lang.ClassLoader 的子类,它们都是在应用启动时是由 BootstrapClassLoader 加载的,毕竟其它类要由这三个类加载器加载,所以这三个类加载器必须先存在,那么谁来加载 BootstrapClassLoader 呢,如果还是由另一个类加载器加载,那么还要设计一个类加载器来加载它,。。。,就陷入了无限循环之中,所以 BootstrapClassLoader 在 JVM 中本身是以 C++ 形式实现的,是 JVM 的一部分,在应用启动时就存在了,所以它本身可以说是 JVM 自身创建的,不需要由另外的加载器加载,所以它也被称为根加载器

java.lang 下的一些核心类如 Object,String,Class 等核心类本身非常重要也很常用,所以在应用启动时 BootstrapClassLoader 也会提前把它们加载好,另外在加载 AppClassLoader 和 ExClassLoader 时在这两个类中也会遇到使用 List 等核心类的情况,所以也会把 rt.jar 中的这些核心类也一起加载了,这就是为什么我们在上文看到 Test 类被加载前也看到了这些核心类被加载的原因

类加载都要遵循双亲委派机制吗

不是的,一个典型的应用场景就是 Tomcat 的类加载,由于 Tomcat 可能会加载多个 web 应用,而多个应用很有可能出现包名+类名都一样的类,最典型的比如两个应用采用了同样的第三方类库,但是它们的版本不同,这种情况下如果按双亲委派来加载,只会有一个类对象,显然有问题,这种情况要能区分各个应用的类,就得破坏双亲委派机制,如下:

绿色部分是 java 项目在打 war 包的时候, tomcat 自动生成的类加载器, 也就是说 , 每一个项目打成一个war包, tomcat都会自动生成一个类加载器, 专门用来加载这个 war 包,当加载 war 包中的类时,首先由 webappClassLoader 加载,而不是首先委托给上一级类加载器加载,这样的话由于加载每一个 war 包的 webappClassLoader 都不一样,每个 war 包被加载的类最终生成的类对象也必然不一样!就达到了应用程序间类的隔离

最后有一个需要注意的点是并不是所有的类都需要通过类加载器来加载创建,比如数组类就比较特殊,它是由 Java 虚拟机直接在内存中动态构造出来的,但由于类的特性(类加载器+类的全限定名惟一确定一个类),数组类依然最终会会被标识在某个加载器的命名空间下,到底标识在哪个类加载器的命名空间下,取决于数组的组件类型(比如 int[] 数组组件类型为 int,String[] 数组组件类型为 String),如果组件类型为 int 等基本类型,会标识在启动类加载器 bootstrapclassloader 下,如果为其它的引用类型(比如自定义的类 Test,数组为 Test)则标识为最终加载此类的类加载器下

花了这么大的笔墨终于把加载阶段讲完了,这个阶段真的很重要,不仅是因为它是类加载的第一个阶段,还因为其中涉及到双亲委派等原理,如果没有搞明白的,建议多看几遍,应该都讲得比较清楚了。

接下来我们再来看另外两个阶段:链接初始化,首先需要明白的是,加载阶段完成后并不会马上就做之后的链接,初始化的操作,比如如果我有一个类 Test,在方法中定义了一个 Test[] list = new Test[10]; 这样的数组变量,此时会触发 Test 类的加载,但并不会触发 Test 类的链接,初始化。那么什么是链接和初始化呢

链接

链接包括三个阶段:

验证准备解析,其中验证又包括字节码验证符号引用验证

这里的验证主要有两种字节码验证符号引用验证

字节码验证

这个阶段主要是对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法不会在运行时做出危害虚拟机安全的行为,比如:

  • 保证任何跳转指令不会跳转到方法体以外的字节码指令上

  • 保证类的转换是有效的,比如可以把子类对象赋值给父类变量,反之则不行

符号引用验证

这个验证其实是在解析阶段发生,符号引用可以看作是对类自身以外(常用池引用中的各种符合引用)的各类信息进行匹配性的验证,我们知道在字节码方法中如果调用了或者说引用了某个类,那么这个类是在字节码中是以符号引用的形式存在的,所以就要确保真正用到此类的时候能找到此类,如果找不到就会报错,举个简单的例子,假设有以下两个类,显然编译时都能通过,但在编译后如果我把 B.class 删掉,A.class 保留着 B 类的符号引用,如果执行 A 的 main 方法需要加载 B 类,由于 B.class 文件缺失导致无法加载 B 类,就会报错

// B.java
public class B {
}

// A.java
public class A {
    public static void main(String[] args) {
        B b = new B();
    }
}

符号引用验证不光验证类,还会验证方法,字段等

注意,类的验证并不是必须的,如果你能确保你的 class 文件是绝对安全的,那么可以开启 -Xverify:none 来关闭类的验证,这样可以缩短类的加载时间以达到加快类加载的目的。

准备

准备阶段的主要目的有两个

  1. 为了给被加载类的静态字段分配内存,并为其赋默认初始值,如 int 类型的静态变量默认赋值为 0

  2. 部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。

解析

如前所述,这一阶段会进行符号引用验证,主要作用是在运行时把字节码类中的常量池符号引用解析成为能定位到内存方法区中对应类信息的直接引用(内存中的具体地址),以上述的代码为例

// B.java

public class B {

}

// A.java

public class A {
    public static void main(String[] args) {
            B b = new B();
    }
}

在编译后,A 类的字节码文件 A.class 包括 B 的符号引用,那么在执行 main 方法后,由于碰到了 new B(),此时就会将 B 的符号引用转为指向 B 的类对象的直接引用,由于 B 未加载,所以,所以此时也会触发 B 的加载生成 B 的类对象,这样符号引用就可以转成直接引用了,这里是以类的解析举例,但实际上,常量,方法,字段等符号引用也都会被解析

但需要注意的是这一阶段有可能发生在初始化之后,因为只有真正用到了比如需要调用某个类的方法时才需要去解析,如果在初始化时此方法还没有被用到,那解析自然也完全没有必要了

初始化

这一阶段主要做两件事

  1. 初始化静态变量,为其赋值

  2. 执行静态代码块内容

无论是初始化静态变量还是执行静态代码块,java 编译器编译后, 它们都会被一起置于一个被称为 clinit 的方法中,并且 JVM 会对其加锁以保证此方法只会被执行一次,只有在初始化完成之后,类才真正成为可执行状态,另外需要注意的,在子类的 clinit 完成之前,JVM 会确保父类的 clinit 也已经完成了,这从继承的角度也容易理解,子类毕竟继承着父类,只有父类初始化可用了,子类才能放心继承或者说使用父类的方法等。

这里有一个需要注意的点是如果是 final 的静态变量,且其类型是基本类型或字符串时,该字段会被标记为常量值,其初始化由 JVM 完成,而不会被放入 clinit,比如如下类静态变量

public class Test {
    private static final int field = 1;
}

这个 field 由于是常量值,所以并不会放入 clinit,而是由 JVM 来完成初始化

那么什么时候会执行初始化呢,《Java 虚拟机规范》规定了六种情况必须立即对类进行初始化

1、 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令的时候,如果类没有进行初始化,则需要先触发其初始化.
生成这四条指令的最常见的java代码场景是:

  • 使用 new 关键字实例化对象的时候

  • 读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候

    需要特别强调这一条,这里针对的是类读取或设置本类的静态字节,如果子类读取父类的静态字段,父类会初始化,但子类不会,比如有以下代码

    public class SuperClass{
      static {
          System.out.println("SuperClass init");
      }
      public static int value = 10;
    }

    public class SubClass extends SuperClass {
      static {
          System.out.println("SubClass init");
      }
    }

    public class NotInitialization {
      public static void main(String[] args) {
          System.out.println(SubClass.value);
      }
    }

    则执行的输出为

    SuperClass init
    10

    可以看到子类获取子父类的静态变量会让父类初始化,但子类自身并不会被初始化

  • 调用一个类的静态方法的时候

2、使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化

3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

4、当虚拟机启动的时候,用户需要指定一个要执行的主类(包含 main 方法的那个类),虚拟机会先初始化这个主类

5、当使用 jdk7 新加入的动态语言支持的时候,如果一个 java,lang.invoke.MethodHandler 实例的最后解析结果是REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial 四种类的方法句柄,并且这个方法句柄对应的类没有进行过初始化,那么需要先触发其初始化.

6、(新)当一个接口中定义了 JDK8 新加入的默认方法(被default关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那么该接口要在其之前初始化

7、 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

这六种场景的行为称为对一个类型的主动引用.除此之外,所有的引用类型的方式都不会触发其初始化,称为被动引用

看完这些相信你能回答开头的单例模式为啥是安全可行的

简单地作个总结

怎么来更通俗地理解加载链接初始化这些阶段呢,其实我之前常说要理解技术概念,代入生活中的场景会更容易理解,比如我们要盖房子,你总要图纸吧(字节码文件),按图纸建筑加工(加载)后成了一座房子(类对象),但此时的房子还只是毛坯房,还不能住人,如果这个房子盖了没人住,那之后的装修等过程就没必要做了,这就是为什么上文定义了Test[] list = new Test[10]; 这样的数组变量只是加载的原因,因为你没有调用 Test 相关的方法等操作,后续的步骤就没有必要做了,但如果房子盖好了之后你要入住,那首先这是个毛坯房,总得找人验下房(链接中的验证)吧,不然要是出现一些状况(比如把承重墙敲了成为了危房)这房子根本就不符合验收标准总得拒收吧,好了,验收通过之后那就可以开始装修了,为沙发,电视等预留好空间(准备),此时你只是在相应的地方标记了一下,A 位置留出来给电视,B 位置留出现给沙发,此时就相当只是做了一个符号引用,但你真正要看电视的时候,此时没有,那么你就得去买来装到对应的位置上,这就是解析,当把房子装修完成之后(即初始化完成),此时的房子才是可用状态(即类处于可用状态),才可以交付给人入住。另外不难看出,解析这一步是可以放到初始化之后的,就就好比,虽然你为电视预留了位置,但你不看不买电视也照样能够入驻。

本文作者:码海

浏览 45
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报