jvm(3)--类加载

前言

用户代码经过编译生成符合jvm规范的CLASS文件,将本地机器码转化为字节码。之后在虚拟机启动程序时将CLASS文件中的类加载带到内存当中,供程序使用。本博客结合周志明先生的《深入理解Java虚拟机》对这些知识点进行梳理验证。

类加载时机

类的生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,其中验证、准备解析统称为链接。发生顺序如图1所示。

图1 类生命周期

类的解析阶段开始时间是不确定的,可能在准备之后发生也可能在初始化之后发生,并且各个阶段是交叉进行的。类加载的时机包含5类场景:

  1. 使用new、getstatic、putstatic、invokestatic字节码指令时,若类未初始化,则需要对类进行加载完成类的初始化。对应场景创建新的对象、读取类的静态字段、设置类的静态字段、调用类的静态方法。
  2. reflect包方法对类进行反射调用且类未初始化时。
  3. 初始化子类,父类未初始化时。
  4. 虚拟机启动,用户指定执行的类(包含main()方法的类)。
  5. MethodHandle实例最后解析结果是REF_getstatic、REF_putstatic、REF_invokestatic时。

以上5类情况需要对类进行初始化,并且只有这5种情况能够触发类的初始化。

被动引用不会引起初始化的情况:

  • 子类引用父类的静态变量(子类中没有同名的静态变量)不会初始化子类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SuperClass {
static {
System.out.println("SuperClass init");
}
public static int value = 1;

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

public class SubClass extends SuperClass{

//public static int value = 2;
static {
System.out.println("SubClass init");
}
}

输出结果:

1
2
3
4
5
"E:\Program Files\Java\jdk1.8.0_91\bin\java" "-javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2017.3.4\lib\idea_rt.jar=51870:D:\Program Files\JetBrains\IntelliJ IDEA 2017.3.4\bin" -Dfile.encoding=UTF-8 -classpath ……
SuperClass init
1

Process finished with exit code 0

结果只包含父类的初始化,验证时建议main函数单独放在另一个类中,因为main函数所在的类一定会被初始化,不利于验证被动引用的场景。

  • 数组不会初始化类
1
2
3
4
5
6
7
public class NotInitlization {

public static void main(String[] args) {
//System.out.println(SubClass.value);
SubClass[] sca = new SubClass[10];
}
}

输出结果:

1
2
"E:\Program Files\Java\jdk1.8.0_91\bin\java" ……
Process finished with exit code 0

并没有引起类的初始化操作。

  • 常量不会引起类初始化
1
2
3
4
5
6
7
8
9
10
11
12
public class ConstClass {
public static final String CONST="const";
static {
System.out.println("ConstClass init");
}
}

public class NotInitlization {
public static void main(String[] args) {
System.out.println(ConstClass.CONST);
}
}

输出结果:

1
2
3
4
"E:\Program Files\Java\jdk1.8.0_91\bin\java" ……
const

Process finished with exit code 0

编译阶段已经将常量进入常量池(方法区内),NotInitlization类中的常量池中包含CONST的引用,而没有ConstClass类的引用。故常量池中常量的引用不会引起类的初始化。

类加载过程

类从class文件加载到内存,主要包括上面的加载、验证、准备、解析、初始化等几步,之后就是对类的使用和卸载,这两步时类已经进入内存。

加载

加载主要完成3件事:

  1. 通过类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

类全限定名称指包含包名的类名称,以ConstantClass类的Class文件为例,Class文件时二进制文件,我们可以通过命令行观察类文件结构。执行javap -verbose命令查看文件字节码内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ javap -verbose ConstClass
Compiled from "ConstClass.java"
public class classload.ConstClass
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #24 // ConstClass init
#4 = Methodref #25.#26 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #27 // classload/ConstClass
#6 = Class #28 // java/lang/Object
……

classload/ConstClass就是类的全限定名称,jvm通过类全限定名称将获取类的二进制字节流。二进制字节流中包含类变量、名称、字段名、方法名等相关的静态存储结构,按照虚拟机的方法区规范,将类的静态存储结构转换为方法区的运行时数据结构。之后在内存中实例化一个java.lang.Class对象(在方法区中)代表ConstClass类,之后将这个Class对象作为访问方法区中ConstClass类的一个接口。

验证

类加载到内存之后,首先需要进行验证方可继续使用。验证内容只要包含以下几步:

  • 文件格式验证: 对比Class文件格式要求,验证字节流是否满足需求
  • 元数据验证:是否有Object父类;是否继承final类;子类字段、方法是否矛盾等等
  • 字节码验证:保证操作栈指令能与字节码合作;指令不会跳到方法意外;指令有效
  • 符号引用验证:符号引用的全限定名是否有效;类、字段、方法访问性是否合法

准备

为类变量分配内存以及设置类变量的初始值。首先,什么是类变量?个人认为一个类中变量主要包含三大类:类变量、实例变量、常量。来看下面的类定义

1
2
3
4
5
public class TestVariable{
public static int sv = 1;
public int v = 2;
public static final int cv = 3;
}

上面代码中,v是实例变量、sv是类变量、cv是常量,其中v实例变量只有在类拥有实例对象时,才可通过实例对象访问,不能直接通过类对象进行访问。因为在类拥有实例对象时,实例对象分配在java堆上,此时变量v才被分配内存,并且位于实例对象的内存空间内。对着实例对象内存的回收,实例变量v的内存也被回收,将无法访问。

而sv、cv在类加载完后,就已经在方法区分配内存,不需要类实例对象即可直接进行访问。三种变量的访问方式如下代码所示:

1
2
3
4
5
6
public static void mian(String[] args) {
TestVariable testV = new TestVariable();
int a = testV.v;
int b = TestVariable.sv;
int c = TestVariable.cv;
}

准备阶段在加载阶段之后,类变量(被static修饰的变量)已在方法区中分配内存故可以访问,此时可对类变量设置初始值。设置初始值时,大多数默认情况都是零值,布尔型为false、引用类型为null。除此之外常量初始值会初始化为代码中指定的值,例如上面代码中,准备阶段时,sv被设为0,cv被设为3。

解析

解析阶段的任务是将常量池内的符号引用替换为直接引用。

  • 符号引用:用一组符号描述所引用的目标,能够无歧义的定位到目标。包含三类:
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
  • 直接引用:指向目标的指针、相对便宜量或者是一个能间接定位到目标的句柄。直接引用和内存直接相关,能够找到直接引用那么内存中一定存在直接引用的目标。解析时机:
    • 类加载时
    • 被字节码指令使用时

解析对象主要包括:

  1. 类或接口
  2. 字段
  3. 类方法
  4. 接口方法

初始化

初始化阶段是执行类构造器()方法的过程。()方法是由编译器自动收集类变量的赋值动作和静态语句块中的语句合并产生的。在初始化阶段,这些用户代码中类变量以及静态代码块中的操作才被真正执行。

类加载器

jvm加载类时采用双亲委派模型对类进行加载,那什么是双亲委派模型?为什么要使用双亲委派模型呢?

双亲委派模型

类加载器从上到下包括:

  • 启动类加载器(Bootstrap ClassLoader)
  • 扩展类加载器(Extension ClassLoader)
  • 应用程序类加载器(Application ClassLoader)

类加载器双亲委派模型如图2所示,除顶层启动类加载器外,其余的类加载器都有父类加载器,父子关系通过组合关系来复用父加载器的代码。双亲委派模型是指:当一个类加载器收到类加载请求时,首先不会自己尝试加载这个类,而是把这个请求委派给父加载器去完成,每一个层次的类加载器都是这样,因此所有的加载请求最终都应该传到启动类加载器中,只有当父类加载器无法完成这个加载请求时,子加载器才会去加载。

1561887678364

图2 类加载器双亲委派模型

简而言之,有类加载请求就扔给父类,请求层层上传,当父类无法加载时,子类再尝试去加载。那为什么要这样做呢?为什么不能直接让子类去加载呢?加下来我们看这个例子,利用自定义的类加载器对某个用户类进行加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class UserClassLoader {

public static void main(String[] args) throws Exception{
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
if(name.startsWith("java")){
return super.loadClass(name);
}
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if(is == null) {
return super.loadClass(fileName);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
}catch (IOException e){
throw new ClassNotFoundException(name);
}
}
};

Object obj = myLoader.loadClass("classload.UserClassLoader").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof classload.UserClassLoader);
}
}

输出结果为:

1
2
3
4
5
"E:\Program Files\Java\jdk1.8.0_91\bin\java" ……
class classload.UserClassLoader
false

Process finished with exit code 0

为什么返回结果为false呢?obj对象确实是classload.UserClassLoader类的对象,但唯一不同的是这个是我们自定义的类加载器创建的对象。而结果为false,说明虚拟机中存在了两个UserClassLoader类,一个是自定义类加载器加载的类,另一个是启动类加载器加载的类。虽然都来自同一个class文件,但是在虚拟机中却是两个类,因此可得出结论:加载类所用的类加载器不同时,则在加载到内存中的类也不是同一个类。

基于上述情况,考虑如下情形,用户利用自定义类加载器对java的基础类String、Object类进行加载,那么在内存中会存在多个Object、String类,我们使用这些类时必然会带来各种各样的问题。为解决此问题,双亲委派模型排上用场。在双亲委派模型中,所有的类都会被启动类加载器进行处理,当启动类加载器无法加载时再交由子类加载器进行处理,这样可以保证在各种类加载器的环境中,Object、String等类都是同一个类。

破坏双亲委派模型

  • 第一次被破坏:历史原因,jdk1.2之前双亲委派模型没有被引入。

类加载器的loadClass()方法可以被重写,但双亲委派模型就是在loadClass()中实现的,故用户复写loadClass()方法会破坏双亲委派模型。

为了即兼容历史的版本又可以支持双亲委派模型,jdk在loadClass()中加入findClass()方法。用户可以将自己的类加载逻辑写道findClass()方法中,loadClass()方法加载失败就会调用findClass()方法完成加载,这样可以保证类加载器符合双亲委派模型。

  • 第二次被破坏:基础类回调用户代码,需要父类加载器请求子类加载器完成类加载动作。

解决方法:线程上下文类加载器(Thread Context ClassLoader)。

  • 第三次被破坏:模块化热部署,为实现热部署,每个程序模块(Bundle)都有一个自己的类加载器。此时类加载器不再时双亲委派模型而是复杂的网状结构。