You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

156 lines
12 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 03 | Java虚拟机是如何加载Java类的?
听我的意大利同事说,他们那边有个习俗,就是父亲要帮儿子盖栋房子。
这事要放在以前还挺简单,亲朋好友搭把手,盖个小砖房就可以住人了。现在呢,整个过程要耗费好久的时间。首先你要请建筑师出个方案,然后去市政部门报备、验证,通过后才可以开始盖房子。盖好房子还要装修,之后才能住人。
盖房子这个事和Java虚拟机中的类加载还是挺像的。从class文件到内存中的类按先后顺序需要经过加载、链接以及初始化三大步骤。其中链接过程中同样需要验证而内存中的类没有经过初始化同样不能使用。那么是否所有的Java类都需要经过这几步呢
我们知道Java语言的类型可以分为两大类基本类型primitive types和引用类型reference types。在上一篇中我已经详细介绍过了Java的基本类型它们是由Java虚拟机预先定义好的。
至于另一大类引用类型Java将其细分为四种类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除我会在专栏的第二部分详细介绍因此Java虚拟机实际上只有前三种。在类、接口和数组类中数组类是由Java虚拟机直接生成的其他两种则有对应的字节流。
说到字节流最常见的形式要属由Java编译器生成的class文件。除此之外我们也可以在程序内部直接生成或者从网络中获取例如网页中内嵌的小程序Java applet字节流。这些不同形式的字节流都会被加载到Java虚拟机中成为类或接口。为了叙述方便下面我就用“类”来统称它们。
无论是直接生成的数组类还是加载的类Java虚拟机都需要对其进行链接和初始化。接下来我会详细给你介绍一下每个步骤具体都在干些什么。
## 加载
加载是指查找字节流并且据此创建类的过程。前面提到对于数组类来说它并没有对应的字节流而是由Java虚拟机直接生成的。对于其他的类来说Java虚拟机则需要借助类加载器来完成查找字节流的过程。
以盖房子为例村里的Tony要盖个房子那么按照流程他得先找个建筑师跟他说想要设计一个房型比如说“一房、一厅、四卫”。你或许已经听出来了这里的房型相当于类而建筑师就相当于类加载器。
村里有许多建筑师他们等级森严但有着共同的祖师爷叫启动类加载器bootstrap class loader。启动类加载器是由C++实现的没有对应的Java对象因此在Java中只能用null来指代。换句话说祖师爷不喜欢像Tony这样的小角色来打扰他所以谁也没有祖师爷的联系方式。
除了启动类加载器之外其他的类加载器都是java.lang.ClassLoader的子类因此有对应的Java对象。这些类加载器需要先由另一个类加载器比如说启动类加载器加载至Java虚拟机中方能执行类加载。
村里的建筑师有一个潜规则就是接到单子自己不能着手干得先给师傅过过目。师傅不接手的情况下才能自己来。在Java虚拟机中这个潜规则有个特别的名字叫双亲委派模型。每当一个类加载器接收到加载请求时它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下该类加载器才会尝试去加载。
在Java 9之前启动类加载器负责加载最为基础、最为重要的类比如存放在JRE的lib目录下jar包中的类以及由虚拟机参数-Xbootclasspath指定的类。除了启动类加载器之外另外两个重要的类加载器是扩展类加载器extension class loader和应用类加载器application class loader均由Java核心类库提供。
扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类比如存放在JRE的lib/ext目录下jar包中的类以及由系统变量java.ext.dirs指定的类
应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数-cp/-classpath、系统变量java.class.path或环境变量CLASSPATH所指定的路径。默认情况下应用程序中包含的类便是由应用类加载器加载的。
Java 9引入了模块系统并且略微更改了上述的类加载器[1](https://docs.oracle.com/javase/9/migrate/toc.htm#JSMIG-GUID-A868D0B9-026F-4D46-B979-901834343F9E)。扩展类加载器被改名为平台类加载器platform class loader。Java SE中除了少数几个关键模块比如说java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
除了由Java核心类库提供的类加载器外我们还可以加入自定义的类加载器来实现特殊的加载方式。举例来说我们可以对class文件进行加密加载时再利用自定义的类加载器对其解密。
除了加载功能之外,类加载器还提供了命名空间的作用。这个很好理解,打个比方,咱们这个村不讲究版权,如果你剽窃了另一个建筑师的设计作品,那么只要你标上自己的名字,这两个房型就是不同的。
在Java虚拟机中类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流经由不同的类加载器加载也会得到两个不同的类。在大型应用中我们往往借助这一特性来运行同一个类的不同版本。
## 链接
链接是指将创建成的类合并至Java虚拟机中使之能够执行的过程。它可分为验证、准备以及解析三个阶段。
验证阶段的目的在于确保被加载类能够满足Java虚拟机的约束条件。这就好比Tony需要将设计好的房型提交给市政部门审核。只有当审核通过才能继续下面的建造工作。
通常而言Java编译器生成的类文件必然满足Java虚拟机的约束条件。因此这部分我留到讲解字节码注入时再详细介绍。
准备阶段的目的则是为被加载类的静态字段分配内存。Java代码中对静态字段的具体初始化则会在稍后的初始化阶段中进行。过了这个阶段咱们算是盖好了毛坯房。虽然结构已经完整但是在没有装修之前是不能住人的。
除了分配内存外部分Java虚拟机还会在此阶段构造其他跟类层次相关的数据结构比如说用来实现虚方法的动态绑定的方法表。
在class文件被加载至Java虚拟机之前这个类无法知道其他类及其方法、字段所对应的具体地址甚至不知道自己方法、字段的地址。因此每当需要引用这些成员时Java编译器会生成一个符号引用。在运行阶段这个符号引用一般都能够无歧义地定位到具体目标上。
举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
如果将这段话放在盖房子的语境下那么符号引用就好比“Tony的房子”这种说法不管它存在不存在我们都可以用这种说法来指代Tony的房子。实际引用则好比实际的通讯地址如果我们想要与Tony通信则需要启动盖房子的过程。
Java虚拟机规范并没有要求在链接过程中完成解析。它仅规定了如果某些字节码使用了符号引用那么在执行这些字节码之前需要完成对这些符号引用的解析。
## 初始化
在Java代码中如果要初始化一个静态字段我们可以在声明时直接赋值也可以在静态代码块中对其赋值。
如果直接赋值的静态字段被final所修饰并且它的类型是基本类型或字符串时那么该字段便会被Java编译器标记成常量值ConstantValue其初始化直接由Java虚拟机完成。除此之外的直接赋值操作以及所有静态代码块中的代码则会被Java编译器置于同一方法中并把它命名为< clinit >
类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行< clinit >方法的过程。Java虚拟机会通过加锁来确保类的< clinit >方法仅被执行一次。
只有当初始化完成之后类才正式成为可执行的状态。这放在我们盖房子的例子中就是只有当房子装修过后Tony才能真正地住进去。
那么类的初始化何时会被触发呢JVM规范枚举了下述多种触发情况
1. 当虚拟机启动时,初始化用户指定的主类;
2. 当遇到用以新建目标类实例的new指令时初始化new指令的目标类
3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
5. 子类的初始化会触发父类的初始化;
6. 如果一个接口定义了default方法那么直接实现或者间接实现该接口的类的初始化会触发该接口的初始化
7. 使用反射API对某个类进行反射调用时初始化这个类
8. 当初次调用MethodHandle实例时初始化该MethodHandle指向的方法所在的类。
```
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
```
我在文章中贴了一段代码,这段代码是在著名的单例延迟初始化例子中[2](https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom)只有当调用Singleton.getInstance时程序才会访问LazyHolder.INSTANCE才会触发对LazyHolder的初始化对应第4种情况继而新建一个Singleton的实例。
由于类初始化是线程安全的并且仅被执行一次因此程序可以确保多线程环境下有且仅有一个Singleton实例。
## 总结与实践
今天我介绍了Java虚拟机将字节流转化为Java类的过程。这个过程可分为加载、链接以及初始化三大步骤。
加载是指查找字节流并且据此创建类的过程。加载需要借助类加载器在Java虚拟机中类加载器使用了双亲委派模型即接收到加载请求时会先将请求转发给父类加载器。
链接是指将创建成的类合并至Java虚拟机中使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中解析阶段为非必须的。
初始化,则是为标记为常量值的字段赋值,以及执行< clinit >方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。
今天的实践环节,你可以来验证一下本篇中的理论知识。
通过JVM参数-verbose:class来打印类加载的先后顺序并且在LazyHolder的初始化方法中打印特定字样。在命令行中运行下述指令不包含提示符$
```
$ echo '
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
static {
System.out.println("LazyHolder.<clinit>");
}
}
public static Object getInstance(boolean flag) {
if (flag) return new LazyHolder[2];
return LazyHolder.INSTANCE;
}
public static void main(String[] args) {
getInstance(true);
System.out.println("----");
getInstance(false);
}
}' > Singleton.java
$ javac Singleton.java
$ java -verbose:class Singleton
```
问题1新建数组第11行会导致LazyHolder的加载吗会导致它的初始化吗
在命令行中运行下述指令(不包含提示符$
```
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Singleton\$LazyHolder.class > Singleton\$LazyHolder.jasm.1
$ awk 'NR==1,/stack 1/{sub(/stack 1/, "stack 0")} 1' Singleton\$LazyHolder.jasm.1 > Singleton\$LazyHolder.jasm
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Singleton\$LazyHolder.jasm
$ java -verbose:class Singleton
```
问题2新建数组会导致LazyHolder的链接吗