理解Java类加载机制
1、字节码
在聊 Java 类加载机制之前,需要先了解一下 Java 字节码,因为它和类加载机制息息相关。
计算机只认识 0 和 1,所以任何语言编写的程序都需要编译成机器码才能被计算机理解,然后执行,Java 也不例外。
Java 在诞生的时候喊出了一个非常牛逼的口号:“Write Once, Run Anywhere”,为了达成这个目的,Sun 公司发布了许多可以在不同平台(Windows、Linux)上运行的 Java 虚拟机(JVM)——负责载入和执行 Java 编译后的字节码。
到底 Java 字节码是什么样子,我们借助一段简单的代码来看一看。
package org.clazz;
public class TestClazz {
private int m;
public int inc() {
return m + 1;
}
}
使用 javac 将这个文件转换成 Class,然后用十六进制编辑器 WinHex 打开这个 Class 文件
1.1、魔数
每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。
很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如 gif 或者 jpeg 等在文件头中都存有魔数。
文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
我们看到 TestClazz.class
的魔数也就是头 4 个字节为 CA FE BA BE
,用十六进制表示是 0xCAFEBABE
(咖啡宝贝?这个名称也太浪漫了吧)。这也意味着每个 Class 文件的魔数值都必须为 0xCAFEBABE
。
1.2、Class 文件的版本
紧接着魔数的 4 个字节存储的是 Class 文件的版本号:5-6 个字节是次版本号(Minor Version),7-8 个字节是主版本号(Major Version)。Java 的版本号是从 45 开始的,JDK 1.1 之后的每个JDK 大版本发布主版本号加 1,高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过起把那本号的 Class 文件。 Class 文件版本号:
JDK版本号 | 10进制版本号 | 16进制版本号 |
---|---|---|
1.1 | 45.0 | 00 00 00 2D |
1.2 | 46.0 | 00 00 00 2E |
1.3 | 47.0 | 00 00 00 2F |
1.4 | 48.0 | 00 00 00 30 |
1.5 | 49.0 | 00 00 00 31 |
1.6 | 50.0 | 00 00 00 32 |
1.7 | 51.0 | 00 00 00 33 |
1.8 | 52.0 | 00 00 00 34 |
再看看文件对应的值:
好了,点到为止,其他后续的东西,无非就是,代码中常量变量所对应的16进制的值。
我们这里只是抛出字节码内容,更细节的内容就不在这里探讨了,想要详细了解class字节码中的具体内容,可以参考这篇博客:Java字节码
2、类加载过程
了解了 Java 字节码后,我们来聊聊 Java 的类加载过程。
JVM将类加载过程分为三个步骤:装载(Loading),链接(Link)和初始化(Initialization)
链接又分为三个步骤:Verification(验证),Preparation(准备)和Resolution(解析),如下图所示:
一张比较经典的图:
2.1、Loading(加载)
JVM 在该阶段的主要目的是将字节码从不同的数据源(可能是 class 文件、也可能是 jar 包,甚至网络)转化为二进制字节流加载到内存中,并生成一个代表该类的 java.lang.Class 对象。
2.2、Verification(验证)
JVM 会在该阶段对二进制字节流进行校验,只有符合 JVM 字节码规范的才能被 JVM 正确执行。该阶段是保证 JVM 安全的重要屏障,下面是一些主要的检查。
- 确保二进制字节流格式符合预期(比如说是否以 cafe bene 开头)。
- 是否所有方法都遵守访问控制关键字的限定。
- 方法调用的参数个数和类型是否正确。
- 确保变量在使用之前被正确初始化了。
- 检查变量是否被赋予恰当类型的值。
2.3、Preparation(准备)
JVM 会在该阶段对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化(对应数据类型的默认初始值,如 0、0L、null、false 等)。
也就是说,假如有这样一段代码:
public String user1 = "德玛西亚";
public static String user2 = "赵信";
public static final String user3 = "蛮王";
user1 不会被分配内存,而 user2 会;但 user1 的初始值不是赵信
而是 null
。
需要注意的是,static final
修饰的变量被称作为常量,和类变量不同。常量一旦赋值就不会改变了,所以 user3 在准备阶段的值为蛮王
而不是 null
。
2.4、Resolution(解析)
该阶段将常量池中的符号引用转化为直接引用。
what?符号引用,直接引用?
符号引用以一组符号(任何形式的字面量,只要在使用时能够无歧义的定位到目标即可)来描述所引用的目标。
在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 com.yingside.dao.PlayerDaoImpl
类引用了 com.yingside.dao.Player
类,编译时 PlayerDaoImpl
类并不知道 Player
类的实际内存地址,因此只能使用符号 com.yingside.dao.Player
。
直接引用通过对符号引用进行解析,找到引用的实际内存地址。
2.5、Initialization(初始化)
该阶段是类加载过程的最后一步。在准备阶段,类变量已经被赋过默认初始值,而在初始化阶段,类变量将被赋值为代码期望赋的值。换句话说,初始化阶段是执行类构造器方法的过程。
上面这段话说得很抽象,不好理解,举个例子。
String cosPlayer = new String("德玛");
上面这段代码使用了 new 关键字来实例化一个字符串对象,那么这时候,就会调用 String 类的构造方法对 cosPlayer 进行实例化。
上面的步骤中,准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0,然后到解析,到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。
3、类加载器
聊完类加载过程,就不得不聊聊类加载器。
一般来说,Java 程序员并不需要直接同类加载器进行交互。JVM 默认的行为就已经足够满足大多数情况的需求了。不过,如果遇到了需要和类加载器进行交互的情况,而对类加载器的机制又不是很了解的话,就不得不花大量的时间去调试
ClassNotFoundException
和 NoClassDefFoundError
等异常。
对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在 JVM 中的唯一性。也就是说,如果两个类的加载器不同,即使两个类来源于同一个字节码文件,那这两个类就必定不相等(比如两个类的 Class 对象不 equals)。
站在程序员的角度来看,Java 类加载器可以分为三种。
1)启动类加载器(Bootstrap Class-Loader),加载 jre/lib 包下面的 jar 文件,比如说常见的 rt.jar。
2)扩展类加载器(Extension or Ext Class-Loader),加载 jre/lib/ext 包下面的 jar 文件。
3)应用类加载器(Application or App Clas-Loader),根据程序的类路径(classpath)来加载 Java 类。
通过一段简单的代码了解下:
public class Test {
public static void main(String[] args) {
ClassLoader loader = Test.class.getClassLoader();
while (loader != null) {
System.out.println(loader.toString());
loader = loader.getParent();
}
}
}
每个 Java 类都维护着一个指向定义它的类加载器的引用,通过 类名.class.getClassLoader() 可以获取到此引用;然后通过 loader.getParent() 可以获取类加载器的上层类加载器。
这段代码的输出结果如下:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@4554617c
第一行输出为 Test 的类加载器,即应用类加载器,它是 sun.misc.Launcher$AppClassLoader
类的实例;第二行输出为扩展类加载器,是 sun.misc.Launcher$ExtClassLoader
类的实例。那启动类加载器呢?
按理说,扩展类加载器的上层类加载器是启动类加载器,但在我这个版本的 JDK 中, 扩展类加载器的 getParent() 返回 null。所以没有输出。
4、双亲委派机制
4.1、类加载器之间的关系:
应用程序都是由这3种类加载器互相配合进行加载的,如果有必要还可以加入自己定义的类加载器。这些类加载器之间的关系如下图:
这种层次关系被称作为双亲委派模型:如果一个类加载器收到了加载类的请求,它会先把请求委托给上层加载器去完成,上层加载器又会委托上上层加载器,一直到最顶层的类加载器;如果上层加载器无法完成类的加载工作时,当前类加载器才会尝试自己去加载这个类。
下面这个图,可以更好的说明双亲委派模型的意思:
4.2、双亲委派的优势
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object(存放于rt.jar中),是所有类的父类,所以任意一个类启动类加载时,都需要先加载Object类。在类加载器来看,所有的加载Object类的请求,都会逐级委托,最后都委托给Bootstrap根类加载器加载,因此Object类在程序的各种类加载器环境中都是同一个类。(否则,系统中出现的Object类都不尽相同则会出现一片混乱)
Comments