首页 > 技术文章 > 类的加载机制

limingblogs 2020-07-05 15:45 原文

JVM 通过加载 .class 文件,能够将其中的字节码解析成操作系统机器码。那这些文件是怎么加载进来的呢?又有哪些约定?接下来我们就详细介绍 JVM 的类加载机制,同时介绍三个实际的应用场景。

我们首先看几个面试题。

  •  我们能够通过一定的手段,覆盖 HashMap 类的实现么?
  • 有哪些地方打破了 Java 的类加载机制?
  • 如何加载一个远程的 .class 文件?怎样加密 .class 文件?

类加载过程

现实中并不是说,我把一个文件修改成 .class 后缀,就能够被 JVM 识别。类的加载过程非常复杂,主要有这几个过程:加载、验证、准备、解析、初始化。这些术语很多地方都出现过,我们不需要死记硬背,而应该要了解它背后的原理和要做的事情。

 

 

如图所示。大多数情况下,类会按照图中给出的顺序进行加载。下面我们就来分别介绍下这个过程。

1.加载

加载的主要作用是将外部的 .class 文件,加载到 Java 的方法区内,你可以回顾一下我们在上一课时讲的内存区域图。加载阶段主要是找到并加载类的二进制数据,比如从 jar 包里或者 war 包里找到它们。

2.验证

肯定不能任何 .class 文件都能加载,那样太不安全了,容易受到恶意代码的攻击。验证阶段在虚拟机整个类加载过程中占了很大一部分,不符合规范的将抛出 java.lang.VerifyError 错误。像一些低版本的 JVM,是无法加载一些高版本的类库的,就是在这个阶段完成的。

3.准备

从这部分开始,将为一些类变量分配内存,并将其初始化为默认值。此时,实例对象还没有分配内存,所以这些动作是在方法区上进行的。

我们顺便看一道面试题。下面两段代码,code-snippet 1 将会输出 0,而 code-snippet 2 将无法通过编译。

 

code-snippet 1:
     public class A {
         static int a ;
         public static void main(String[] args) {
             System.out.println(a);
         }
     }
 code-snippet 2:
 public class A {
     public static void main(String[] args) {
         int a ;
         System.out.println(a);
     }
 }

为什么会有这种区别呢?

这是因为局部变量不像类变量那样存在准备阶段。类变量有两次赋初始值的过程,一次在准备阶段,赋予初始值(也可以是指定值);另外一次在初始化阶段,赋予程序员定义的值。

因此,即使程序员没有为类变量赋值也没有关系,它仍然有一个默认的初始值。但局部变量就不一样了,如果没有给它赋初始值,是不能使用的。

4.解析

解析在类加载中是非常非常重要的一环,是将符号引用替换为直接引用的过程。这句话非常的拗口,其实理解起来也非常的简单。

符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。

直接引用的对象都存在于内存中,你可以把通讯录里的女友手机号码,类比为符号引用,把面对面和你吃饭的人,类比为直接引用。

解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要。那这个阶段都做了哪些工作呢?大体可以分为:

  • 类或接口的解析
  • 类方法解析
  • 接口方法解析
  • 字段解析

我们来看几个经常发生的异常,就与这个阶段有关。

  • java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。
  • java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。
  • java.lang.NoSuchMethodError 找不到相关方法时的错误。

解析过程保证了相互引用的完整性,把继承与组合推进到运行时。

5.初始化

如果前面的流程一切顺利的话,接下来该初始化成员变量了,到了这一步,才真正开始执行一些字节码。

接下来是另一道面试题,你可以猜想一下,下面的代码,会输出什么?

public class A {
     static int a = 0 ;
     static {
         a = 1;
         b = 1;
     }
     static int b = 0;
 
     public static void main(String[] args) {
         System.out.println(a);
         System.out.println(b);
     }
 }

结果是 1 0。a 和 b 唯一的区别就是它们的 static 代码块的位置。

这就引出一个规则:static 语句块,只能访问到定义在 static 语句块之前的变量。所以下面的代码是无法通过编译的。

static {
         b = b + 1;
 }
 static int b = 0;

我们再来看第二个规则:JVM 会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕。

所以,JVM 第一个被执行的类初始化方法一定是 java.lang.Object。另外,也意味着父类中定义的 static 语句块要优先于子类的。

 

<cinit>与<init>

说到这里,不得不再说一个面试题:<cinit> 方法和 <init> 方法有什么区别?

主要是为了让你弄明白类的初始化和对象的初始化之间的差别。

public class A {
     static {
         System.out.println("1");
     }
     public A(){
         System.out.println("2");
         }
     }
 
     public class B extends A {
         static{
         System.out.println("a");
     }
     public B(){
         System.out.println("b");
     }
 
     public static void main(String[] args){
         A ab new B();
         ab new B();
     }
 }

输出结果:

1
a
2
b
2
b

你可以看下这张图。其中 static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以上面的 static 代码块只会执行一次,它对应的是 <cinit> 方法。

而对象初始化就不一样了。通常,我们在 new 一个新对象的时候,都会调用它的构造方法,就是 <init>,用来初始化对象的属性。每次新建对象的时候,都会执行。

 

 

所以,上面代码的 static 代码块只会执行一次,对象的构造方法执行两次。再加上继承关系的先后原则,不难分析出正确结果。

 

类加载器

整个类加载过程任务非常繁重,虽然这活儿很累,但总得有人干。类加载器做的就是上面 5 个步骤的事。

如果你在项目代码里,写一个 java.lang 的包,然后改写 String 类的一些行为,编译后,发现并不能生效。JRE 的类当然不能轻易被覆盖,否则会被别有用心的人利用,这就太危险了。

那类加载器是如何保证这个过程的安全性呢?其实,它是有着严格的等级制度的。

 

几个类加载器

首先,我们介绍几个不同等级的类加载器。

1.Bootstrap ClassLoader

这是加载器中的大 Boss,任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。

这个加载器是 C++ 编写的,随着 JVM 启动。

2.Extention ClassLoader

扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。

这个加载器是个 Java 类,继承自 URLClassLoader。

3.App ClassLoader

这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码,会首先尝试使用这个类加载器进行加载。

4.Custom ClassLoader

自定义加载器,支持一些个性化的扩展功能。

 

双亲委派机制

关于双亲委派机制的问题面试中经常会被问到,你可能已经倒背如流了。

双亲委派机制的意思是除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。

打个比方。有一个家族,都是一些听话的孩子。孙子想要买一块棒棒糖,最终都要经过爷爷过问,如果力所能及,爷爷就直接帮孙子买了。

但你有没有想过,“类加载的双亲委派机制,双亲在哪里?明明都是单亲?”

我们还是用一张图来讲解。可以看到,除了启动类加载器,每一个加载器都有一个parent,并没有所谓的双亲。但是由于翻译的问题,这个叫法已经非常普遍了,一定要注意背后的差别。

 

 

我们可以翻阅 JDK 代码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和我们描述的一样,它首先使用 parent 尝试进行类加载,parent 失败后才轮到自己。同时,我们也注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效。

 

 

这个模型的好处在于 Java 类有了一种优先级的层次划分关系。比如 Object 类,这个毫无疑问应该交给最上层的加载器进行加载,即使是你覆盖了它,最终也是由系统默认的加载器进行加载的。

如果没有双亲委派模型,就会出现很多个不同的 Object 类,应用程序会一片混乱。

 

那么,如何替换 JDK 中的类?比如,我们现在就拿 HashMap为例。

当 Java 的原生 API 不能满足需求时,比如我们要修改 HashMap 类,就必须要使用到 Java 的 endorsed 技术。我们需要将自己的 HashMap 类,打包成一个 jar 包,然后放到 -Djava.endorsed.dirs 指定的目录中。注意类名和包名,应该和 JDK 自带的是一样的。但是,java.lang 包下面的类除外,因为这些都是特殊保护的。

因为我们上面提到的双亲委派机制,是无法直接在应用中替换 JDK 的原生类的。但是,有时候又不得不进行一下增强、替换,比如你想要调试一段代码,或者比 Java 团队早发现了一个 Bug。所以,Java 提供了 endorsed 技术,用于替换这些类。这个目录下的 jar 包,会比 rt.jar 中的文件,优先级更高,可以被最先加载到。

 

————来自拉勾教育笔记

推荐阅读