前言
从class文件到内存中的类,需要经过加载、链接以及初始化三个大的步骤。其中,链接过程需要验证;而内存中的类没有经过初始化也同样是不能使用的。那么,又是否所有的Java类都需要经过这三步呢?这也是这篇文章想要弄清楚的。
我们知道Java语言的类型可以分为两大类:基本数据类型和引用数据类型。在上一篇文章JVM系统学习(02):Java的基本类型中,有详细介绍8大基本数据类型及特性,知道了他们是有JVM提前预定义好的。
至于另一大引用数据类型,Java又将其细分为4中:类、接口、数组类和泛型参数。因为泛型参数在编译过程中会被擦除(关于泛型擦除的概念后再后面笔记中专门记录),所以在JVM中实际上只有前3种引用类型。在类、接口和数组类中,数组类是由JVM直接生产的,而类和接口则有对应的字节流。
提到字节流,最常见的便是由Java编译器生成的class文件。处理class文件之外,我们也可以在程序内部直接生产,或者从网络中获取(比如我们都听过的嵌入在网页中的java applet,但是上基本很少甚至压根没这么用过的)字节流。这些不同形式的字节流都会被加载到JVM中,形成类或接口。为了便于描述,我把接口和类用“类”来统称。
无论是直接生成的数组类,还是加载的类,JVM都需要对其进行链接和初始化。下面来看下每一个步骤的细节。
加载
加载,是指查找字节流,并且创建类的过程。前言中有提到数组类没有对应的字节流,而是由JVM直接生成的。对于其他类来说,JVM需要借助类加载器来完成查找字节流的过程。
以村里盖房子为例,村里刘备家需要盖房子,那么按照流程刘备需要先找个建筑设计师,跟他说想要设计一个房型(比如:“3房、2厅、2卫、1厨”)。其实这里的房型就相当于类,而设计师相当于类加载器。可能还不是很理解,继续往下走。
村里有很多的建筑师,他们等级制度森严,但都有共同的祖师爷,叫启动类加载器(bootstrap class loader)。启动类加载器是由C语言实现的,没有对应的Java对象。换句话说祖师爷不喜欢刘备这样的小角色去打扰他,所以村里谁也没有祖师爷的联系方式。
除了启动类加载器之外,其他的类加载器都是java.lang.ClassLoader的子类,所以其他的类加载器都有对应的Java对象。这些类加载器需要先有另外一个类加载器(比如说启动类加载器)加载至JVM中,方能执行类。
村里的建筑师有一个潜规则,就是接到单子,自己不能直接开始干,得先给师傅过目。师傅不接手的情况下,才能自己来。在JVM中这个潜规则有一个特别的名字,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,在由自己尝试去加载。
在Java9之前,启动类加载器负责加载最基础、最为重要的类,比如存在在Jre的lib目前下jar包中的类以及有JVM参数-Xbootclasspath指定的类。处理启动类加载器之外,另外两个重要类加载器是扩展加载器(extension class loader)和应用类加载器(application class loader),均由Java的核心类库提供。
扩展类加载器的父类是启动类加载器。它复杂加载相对次要、且又通用的类,比如存放在JRE/lib/ext目录下jar包中的类以及由系统变量java.ext.dirs指定的类。
应用类加载器的父类是扩展类加载器。它复杂加载应用程序路径下的类(这里的应用程序路径是指虚拟机参数 -cp/-classpath、系统变量java.class.path或环境变量CLASSPATH所指定的路径)。默认情况下,应用程序中包含的类便是由应用类加载器负责加载的。
Java9引入了模块系统,并微弱的更改了上述的类加载器(官方文档: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文件加密,加载时再利用对应的类加载器对其进行解密。
处理加载功能之外,类的唯一性是由类加载器示例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
链接
链接,是指将创建成的类合并到JVM中,使之能够执行的过程。它可以分为验证、准备以及解析三个阶段。
验证阶段的目的,确保被加载的类满足JVM的约束条件。
准备阶段的目的,为被加载类的静态字段分配内存。Java代码中对静态字段的具体初始化,则会在下面的初始化阶段进行。
处理分配内存外,部分Java虚拟机还会在此阶段构造跟类层次先关的数据结构。比如说用来实现虚方法的动态绑定的方法表。
在class文件被加载至JVM之前,这个类无法知道其他类及其方法、方法所对应的的具体地址,甚至不知道自己方法、字段的地址。因此每当需要引入这些成员时,JVM会生成一个符号引用。在运行阶段,这个符号引用能够无歧义的定位到具体的目标上。
举例来说,例如一个方法调用,编译器会生成一个包含目标方法所在类的名称、目标方法的名称、方法入参类型以及返回值类型的符号引用,来指代所要调用的方法。
解析阶段的目的,正是要将这些符号引用解析成实际引用。如果符号引用所指向一个未被加载的类、方法或者字段,那么解析将会触发这个加载。(触发加载,但未必会触发链接和初始化)。
JVM规范并未要求在连接过程中完成解析。它近规范了:如果一写字节码引用了一些符号引用,那么在字节码执行之前必须解析到真实引用。
初始化
在Java代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码中对其赋值。
如果直接赋值的对象被final修饰,并且他的类型是基本类型或者字符串时,那么该字段便会被Java编译器标记为常量值,其初始化直接由Java虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被Java编译器至于同一个方法中,并把它命名为<clinit>.
类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行<clinit>方法的过程。Java虚拟机会通过加锁来保证一个<clinit>只会被执一次。
那么,类的初始化何时被触发呢?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;
}
}
这段代码是著名的单例延迟初始化例子,只有当调用Singleton.getInstance()方法时,程序才会访问LazyHolder.INSTANCE的静态属性,才会去触发LazyHolder的初始化,继而新建一个singleton实例。
总结
这篇笔记中记录了一个class文件的字节流 通过 JVM 转换为 Java类的过程。这个过程分为加载、链接以及初始化3个过程。
加载指查找字节流,并据此创建类的过程。加载需要借助类加载器,在JVM中类加载器使用了双亲委派模型,即接收到改请求是,会将请求先转发给当前类加载器的父类加载器取查找。
链接是指将创建成的类合并到JVM中,使其能够执行的过程。链接还分为验证、准备和解析三个阶段。其中,解析阶段是非必要的。
初始化是为标记为常量的字段进行赋值,以及执行<cliinit>的过程。类的初始化仅被执行一次,这个特征可被用来实现单例的延迟加载。