探秘 JVM:类加载机制

JVM 的类加载机制描述了类数据从字节码文件加载到内存,并对其进行校验、解析、初始化、直至成为能够被 JVM 直接使用的 java 数据类型的过程。

类的整个生命周期包括: 加载、连接(验证、准备、解析)、初始化、使用、卸载 5 个阶段,其中连接又可以细分为验证、准备、解析 3 个阶段。如下图:

image

上述步骤除解析步骤外,对于同一个类而言各个阶段的执行按照图示箭头所指的顺序逐步执行(毗邻阶段的结束和开始不严格按照先后顺序,可能存在重叠),解析阶段在某些情况下会在初始化之后进行,主要是为了支持 java 语言的动态绑定机制。触发整个流程开始的原因 有且只有 5 种,称之为 主动引用,如下:

  1. 遇到 newgetstaticputstaticinvokestatic 指令
  2. 利用反射机制对类进行反射调用
  3. 当初始化一个类的时候,如果其父类没有被初始化,则先初始化其父类
  4. 虚拟机启动时,用户需要指定一个驱动类(包含 main 方法的类),虚拟机会先初始化这个类
  5. 使用 jdk1.7 的动态语言支持

注意: 上述第 3 点对于接口不适用,初始化一个接口或类并不要求其实现的接口全部都完成了初始化,只有一个接口的非常量字段被使用时,才会初始化该接口。

扩展知识点

在 java 中显式的创建一个对象的方式主要分为 4 种:

  1. new 一个对象
  2. 克隆机制
  3. 反射机制
  4. 反序列化

除了显式创建对象外,虚拟机还会隐式的创建对象,比如创建类的 Class 引用,创建常量对象等。

一. 类加载过程

1.1 加载

当我们编写的代码被编译成字节码文件之后,虚拟机必须将其装载到内存中才能执行,而加载通俗地说就是将静态字节码二进制流文件加载到内存中的过程,该阶段主要完成三件事情:

  1. 通过一个类的全限定名获取定义此类的二进制字节流(具体从哪里获取没有要求,可以是文件系统、运行时生成,也可以来自网络等)
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 Class 引用,作为方法区中该类的各种数据访问入口

注意: 一个类的 Class 引用虽然是一个对象,但却存储在方法区,而不是堆内存中。

1.2 连接

如前面所述,连接阶段可以细分为 验证、准备,以及解析 三个阶段,下面就各阶段所执行的工作展开来说明。

1.2.1 验证

验证阶段主要是验证字节码文件中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全,主要从文件格式、元数据、字节码、符号引用 4 个层面进行验证,如果验证不通过则抛出 VerifyError 异常。

验证阶段相对比较耗时,是非常重要却不必要的过程,如果所运行的全部代码都已经被反复使用和验证过,那么可以使用 -Xverify:none 参数关闭大部分的类验证策略,从而缩短虚拟机的类加载时间。

  • 文件格式验证

该阶段主要验证字节流是否符合字节码文件的格式规范,同时保证能够被当前版本的虚拟机处理,这一阶段是整个验证过程的第一阶段,基于字节流进行验证,通过之后字节流才能够进入方法区进行储存,后面 3 个阶段的验证都是基于方法区中的数据进行验证。这一阶段的验证主要包括:

  1. 是否以魔数 0xCAFEBABE (咖啡宝贝) 开头
  2. 主、次版本号是否在当前虚拟机能够处理范围内
  3. 常量池中的常量是否有不被支持的常量类型
  • 元数据验证

该阶段主要是对字节码描述的类信息进行语义分析,保证类定义不存在违反 java 语言规范的元数据信息,主要是对数据类型和方法签名做校验。

  • 字节码验证

该阶段主要是对类的方法体进行校验分析,通过数据流和控制流确定程序的语义是合法且符合逻辑的。这一阶段的验证相当复杂,所以在 jdk 1.6 之后的字节码文件中引入了 StackMapTable 属性,描述了所有基本块开始时本地变量表和操作数栈应有的状态,从而不再需要根据推导来验证这些状态的合法性,只要检查 StackMapTable 即可。

  • 符号引用验证

符号验证发生在虚拟机将符号引用转换化为直接引用的时候,这个动作发生在解析阶段,主要是为了保证引用自身,以及被引用方的正确性,确保解析动作能够正常执行。

  • 符号引用

符号引用以一组符号描述所引用的目标对象,可以是任何形式的字面量,只要使用时能够无歧义定位目标即可。符号引用与虚拟机实现内存布局无关,不同虚拟机接受的符号引用是相同的,引用的目标也不一定已经加载到内存。在字节码文件中以 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info 等类型的常量出现。

  • 直接引用

直接引用可以是直接指向目标的指针、相对偏移量,或是一个能间接定位目标的句柄。与虚拟机内存布局相关,不同虚拟机翻译出来的直接引用一般不同,且其引用的目标一定存在于内存。

1.2.2 准备

准备阶段主要是为 类(static)变量 分配内存并初始化为相应的 类型默认值。需要注意的是这里的操作只针对类变量,成员变量会在对象实例化时随着对象一起分配到堆内存中,此外这里的赋值是赋类型默认值,我们在代码中显式指定的默认值将会在调用类初始化方法 <clinit> 时(即初始化阶段)赋值,但是对于常量在这一步已经具备了真实值。

1
2
3
private static int a = 123;
private static final int b = 456;
private static final int c = 123 + 456;

例如上述代码中的两个变量,在当前阶段 a 对应的值是 0,而 b 对应的值是 456,因为 a 是一个类变量,在本阶段会为其分配内存空间,并初始化为类型默认值(int 类型的默认值为 0),而真正赋值为 123 要等到初始化阶段,但是 b 就不一样,因为 b 是一个静态常量,编译器会在编译阶段就将其赋值为 456,对于 c 也同样如此,编译器在编译阶段就会将其赋值为 579。

1.2.3 解析

解析阶段的目的是将常量池中的符号引用替换为直接引用,主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符 7 类符号引用进行解析。

  • 类或接口的解析

假设当前代码所处的类为 A,现在需要把符号引用 N 解析为对应的类或接口 B 的直接引用,整个解析过程如下:

  1. 如果 B 不是数组类型,那么虚拟机会将代表 N 的全限定名传递给 A 的类加载器去加载整个类 B,加载过程中由于元数据验证、字节码验证的需要,可能会触发其他的类加载操作,一旦出现任何异常则解析阶段失败。
  2. 如果 B 是数组类型,并且数组的元素类型为对象(eg. [Ljava/lang/Integer),则依据 1 中的规则加载数据元素类型,否则由虚拟机生成一个代表此数组维度和元素的数组对象。
  3. 验证 A 是否具备对 B 的访问权限,如果不具备则抛 IllegalAccessError 异常。
  • 字段解析

字段符号解析需要先对字段所属的类或接口的符号引用进行解析,假设这个类或接口是 B,那么字段的解析过程:

  1. 如果 B 本身包含的 简单名称和字段描述符 都与目标相匹配的字段,则返回该字段的直接引用。
  2. 否则,如果 B 实现了接口,将会按照继承关系从下往上递归搜索各个接口,如果发现了相应字段则返回该字段的直接引用。
  3. 否则,如果具备父类(Object 类除外),则递归搜索父类,如果发现了相应字段则返回该字段的直接引用。
  4. 否则,查找失败,抛 NoSuchFieldError

上述过程中对于查找到的字段会进行权限验证,如果不具备访问权限,则抛 IllegalAccessError。

  • 类方法解析

类方法解析也需要先对方法所属的类(不包括接口,类方法和接口方法的解析是分开的)的符号引用进行解析,假设这个类是 B,那么方法的解析过程:

  1. 在 B 中查找是否有 简单名称和描述符 都与目标方法匹配的方法,如果有的话则返回该方法的直接引用
  2. 否则,在父类中递归查找
  3. 否则,在 B 实现的接口中递归查找,如果找到说明 B 是一个抽象类,抛 AbstractMethodError
  4. 否则,查找失败,抛 NoSuchMethodError

上述过程中对于查找到的方法会进行权限验证,如果不具备访问权限则抛 IllegalAccessError。

  • 接口方法解析

接口方法也需要先对方法所属的接口(不包括类)的符号引用进行解析,假设这个接口是 B,那么方法的解析过程:

  1. 在 B 中查找是否有 简单名称和描述符 都与目标方法匹配的方法,如果有的话则返回该方法的直接引用
  2. 否则,在父接口中递归查找
  3. 否则,查找失败,抛 NoSuchMethodError

接口方法的访问权限都是 public,所以不存在访问权限问题。

1.3 初始化

初始化阶段主要是执行类或接口初始化方法 <clinit>,该方法由编译器自动收集类中所有类变量和静态语句块(static{...})中的语句合并而成(如果没有定义类变量和静态语句块,可以不用生成 ),组织顺序按照在源文件中定义的顺序,例如:

1
2
3
4
5
6
7
8
9
public class CLInit {
private static int a = 1;

static {
b = 2;
}

private static int b;
}

编译器会自动收集类变量和静态代码块生成对应的类初始化方法(字节码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static <clinit>()V
L0
LINENUMBER 8 L0
ICONST_1
PUTSTATIC org/zhenchao/jvm/CLInit.a : I
L1
LINENUMBER 11 L1
ICONST_2
PUTSTATIC org/zhenchao/jvm/CLInit.b : I
L2
LINENUMBER 12 L2
RETURN
MAXSTACK = 1
MAXLOCALS = 0

直观的可以理解为在初始化阶段,编译器会自动收集类变量和静态代码块,生成类似于下面这也的类初始化方法:

1
2
3
4
static <clinit>() {
private static int a = 1;
private static int b = 2;
}

类变量在 <clinit> 方法中的组织顺序遵循在代码中定义的顺序,需要注意的是 在静态代码块中只能访问定义在静态语句块之前的变量,定义在之后的变量,在静态语句块中可以赋值但不能访问。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CLInit {
private static int a = 1;

static {
System.out.println(a);
b = 2;
// 下面的语句编译错误,因为 b 在静态代码块之后定义
// System.out.println(b);
System.out.println(CLInit.b); // OK
}

private static int b;
}

类初始化方法 <clinit> 不同于构造方法 <init>,不需要在 <clinit> 中隐式或者显式调用父类的初始化方法,JVM 可以保证在执行子类的 <clinit> 之前,父类的 <clinit> 已经执行完毕,因此父类的静态代码块要先于子类执行

接口中虽然不允许使用静态代码块,但是仍然存在变量赋值操作,所以也会生成 <clinit>,但是与类不同的是,接口中的 <clinit> 方法在执行时不要求父接口的 <clinit> 方法执行完毕,只有当使用到一个接口的静态变量时才会触发执行 <clinit>

<clinit> 方法是线程安全的,可以将其视为一个同步的方法,当一个线程执行该方法时其它线程会被阻塞,所以一般不推荐在其中编写比较耗时的代码逻辑。

扩展知识点:类成员初始化顺序

基本原则: 先静态,后非静态,先父类,后子类

顺序概括:

  1. 父静态属性 → 父静态代码块
  2. 子静态属性 → 子静态代码块
  3. main 函数
  4. 父成员属性 → 父构造代码块 → 父构造方法
  5. 子成员属性 → 子构造代码块 → 子构造方法

类中成员的初始化顺序如下(以 Child 类为例,Child extends Parent):

  1. 当首次创建某个类对象的时候,或者该类的静态方法或静态域首次被访问时,java 解释器必须查找该类的路径,以定位该类的class文件。
  2. 载入父类的 class 文件(创建一个 Class 对象),初始化父类 Parent 中的静态字段,同时执行父类中静态代码块 static{...},顺序按照从上到下执行。
  3. 载入子类的 class 文件,初始化子类 Child 中的静态字段,同时执行子类中的静态代码块 static{...},顺序按照从上到下执行。

    静态初始化只在类首次加载时执行一次,静态域的初始化不必担心非法向前引用(即提前使用了变量,如果在变量尚未初始化就使用了这个变量的值,就是向前引用),编译器会报错。

  4. 进入 main 函数(因为接下去要创建对象,而 main 函数是入口,所以为什么 main 需要用 static 修饰)。
  5. 使用 new 操作符创建对象,首先在堆上为待创建的对象分配足够的存储空间,这块存储空间会被清零,所以自动的将类中所有基本类型数据设置成了默认值(数值和字符是 0,布尔是 false),而引用则被设置成了 null。
  6. 对 Parent 类中的非静态字段和构造代码块按照从上到下进行初始化(执行构造代码块 {...},将属性设为用户指定的值),每一次创建子类的对象都会对父类的非静态字段和构造代码块执行一次。
  7. 执行 Parent 类的构造方法,这里到底执行父类的哪一个构造方法取决于子类在当前被执行的构造方法调用的父类构造方法是哪一个。
  8. 对 Child 类中的非静态字段和构造代码块按照从上到下进行初始化(执行构造代码块 {...},将属性设为用户指定的值)。
  9. 执行 Child 类的构造方法。

需要注意的地方:

  • 在构造方法中调用可以被覆盖的方法

如果某个方法被子类覆盖了,那么在父类的构造方法中调用这个方法的时候会去调用子类中的这个方法,如果这个方法使用了子类的成员变量,由于这时子类的成员变量还未来得及初始化,就会出现向前引用的问题。

建议:不要在构造方法中调用可以让子类覆盖的方法,以避免发生向前引用。正常情况下,在构造方法中应该只调用声明为 private 或者 final 的方法。

  • 满足下面条件的 final 属性会在编译时赋值
  1. 在声明的地方进行初始化
  2. 初始化 x 的表达式(值)是常量表达式(即在编译阶段就能确定表达式的值)

那么这种类型的成员会最先得到初始化,也就是说在 class 文件还未被加载的时候,这个成员的值就已经确定了,但是 final 修饰的成员可以在声明的时候不赋初值,而是延迟到构造方法中进行,这时就没有这种特点了。

(扩展知识点 完)

二. 类加载器

类加载器的作用是用来获取一个全限定名对应的字节流,对于任何一个类都需要由加载它的类加载器和这个类本身一同确立其在 JVM 中的唯一性。例如:

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
30
31
public class ClassLoaderDemo {

public static void main(String[] args) throws Exception {

ClassLoader myClassLoader = new ClassLoader() {

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
InputStream is = this.getClass().getResourceAsStream(
name.substring(name.lastIndexOf(".") + 1) + ".class");
if (null == is) return super.loadClass(name);
byte[] buffer = new byte[is.available()];
is.read(buffer);
return super.defineClass(name, buffer, 0, buffer.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
};

Object obj = myClassLoader.loadClass("org.zhenchao.jvm.ClassLoaderDemo").newInstance();
System.out.println(obj.getClass().getName());
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
Object obj1 = classLoader.loadClass("org.zhenchao.jvm.ClassLoaderDemo").newInstance();
Object obj2 = classLoader.loadClass("org.zhenchao.jvm.ClassLoaderDemo").newInstance();
System.out.println("obj1 equals obj2 : " + obj1.getClass().equals(obj2.getClass()));
System.out.println("obj1 equals obj : " + obj1.getClass().equals(obj.getClass()));
}

}

运行结果:

1
2
3
org.zhenchao.jvm.ClassLoaderDemo
obj1 equals obj2 : true
obj1 equals obj : false

可以看到我们自定义的类加载器与系统类加载器加载得到的对象所对应的 Class 引用并不是同一个,也就是说在方法区中存在两个不同的 ClassLoaderDemo 类的 Class 引用。

2.1 类加载器的类别

站在开发者的角度来看,JVM 类加载器可以分为多层组织,由上到下依次是启动类加载器、扩展类加载器、系统类加载器,以及用户自定义类加载器。

  • 启动类加载器

启动类加载器负责加载 JAVA_HOME/lib 目录下,或者 -Xbootclasspath 参数指定路径下,且能够被 JVM 识别的类库(仅按照文件名进行匹配)。该类加载器采用 C++ 语言编写,且无法被 java 程序直接引用,如果在编写自定义类加载器时希望指定启动类加载器为父类加载器,直接利用 null 代替即可。

  • 扩展类加载器

sun.misc.Launcher$ExtClassLoader 实现,负责加载 JAVA_HOME/lib/ext 目录下,或者被 java.ext.dirs 系统变量所指定路径中的所有类库。

  • 系统类加载器

一般情况下程序都是由默认类加载器加载,由 sun.misc.Launcher$AppClassLoader 实现,上述例子中 ClassLoader.getSystenClassLoader() 返回的即是该加载器,负责加载 CLASSPATH 所指定的类库。

2.2 双亲委派机制

JVM 中类加载器关系如下图所示,各个类加载器之间是一种父子关系,但是 这种父子关系不是以继承来实现,而是使用了组合(装饰者模式)

image

当一个类需要被加载时,JVM 采用双亲委派机制为该类寻找合适的类加载器,该机制描述为:类加载器采用的是装饰模式,即子类加载器会包装父类加载器,所以子类的加载器的功能要比父类更强,但是这样带来了问题,因为除了系统定义的三个加载器以外,还允许用户自定义类加载器,所以就不能保证所有用户都是善意的(比如用户自定义了一个 java.lang.String 类,如果能够覆盖 jdk 自带的 String 类则是一件非常危险的事情),为了保证安全性,JVM 会尽量采用上层类加载器去加载类(因为上层加载器相对下层类加载器更加安全),所以当要加载一个类的时候,当前类加载器就会首先让父类加载器尝试加载,如果父类有能力加载则会继续向上委托,直到某一个类加载器的父类加载器不能加载时即开始真正加载类,我们将这个类加载器称为定义加载器,包括它在内的下层所有加载器称为初始类加载器。所有的类加载器中除了根加载器是由 C++ 实现外,其余的类加载器均由 java 实现。下面从源码层面来看一下双亲委派机制的实现:

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
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
Class<?> c = this.findLoadedClass(name); // 校验输入的类名,并检查类是否已经被加载过
if (c == null) {
try {
// 执行双亲委派机制
if (parent != null) {
// 委托父类加载
c = parent.loadClass(name, false);
} else {
// 启动类加载器没有父类
c = this.findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器没有找到,则抛异常
}

if (c == null) {
// 父类加载器加载失败,执行本地 findClass 逻辑
c = this.findClass(name); // 模板方法,类给子类实现
}
}
// 如果需要,执行解析
if (resolve) {
this.resolveClass(c);
}
return c;
}
}

双亲委派机制的规则本来也不复杂,所以源码实现上也比较简单,具体的过程见上述代码注释,不再多做撰述。我们看到 loadClass 方法在双亲加载失败时会执行 findClass 逻辑,这是一个模板方法,也是我们自定义类加载器推荐覆盖实现的方法,下面我们自定义了一个类加载器 MyClassLoader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MyClassLoader extends ClassLoader {

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] bytes = FileUtils.readFileToByteArray(FileUtils.getFile("/home/zhenchao/dev/workspace/User.class"));
return super.defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("class not found : " + name, e);
}
}

public static void main(String[] args) throws Exception {
Class clazz = new MyClassLoader().loadClass("org.zhenchao.jvm.loader.User");
Object obj = clazz.newInstance(); // 这里作强转会抛 ClassNotFoundException,因为两个类对应的 Class 对象不一致
System.out.println(obj.getClass().getClassLoader().toString());
}
}

// 输出:org.zhenchao.jvm.loader.MyClassLoader@eed1f14

MyClassLoader 的逻辑就是尝试加载我们自定义的 User 类,通过覆盖实现 findClass 方法,我们定义了从本地文件系统加载 User.class 字节码文件,并调用已有的 defineClass 方法由字节数组得到类的 Class 对象,从而完成了自定义类加载逻辑。这里我们加载的 class 文件位于 /home/zhenchao/dev/workspace 下,由前面的讲解我们知道引导类加载器会加载 JAVA_HOME/lib 路径或 -Xbootstrapclasspath 参数指定路径下的类,而扩展类加载器会加载 JAVA_HOME/lib/ext 路径下或 java.ext.dirs 指定路径下的类,而系统类加载器则会加载 CLASSPATH 路径下的类。一般来说,即使我们自定义了类加载器加载 User 类,但是按照双亲委派原则该类也会被系统类加载器所加载,因为 User 类位于 CLASSPATH 路径下,为了让 MyClassLoader 加载该类,这里特意将 User.class 放置在了一个系统类加载器找不到的地方,也就达到了我们的目的。

那么双亲委派机制是否可以被破坏呢?答案是肯定的。典型的破坏双亲委派机制的地方就是 Tomcat 的类加载机制,这个我们留到后面再说,下面我们自定义一个类 HackClassLoader 来破坏这一机制,我们需要做的就是覆盖 loadClass 方法。HackClassLoader 的实现中会判定当前类的全限定名,如果是 org.zhenchao.jvm 包下面的类就采用 HackClassLoader 进行加载,对于其它类则继续执行双亲委派。具体实现如下:

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
public class HackClassLoader extends ClassLoader {

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (null != name && name.startsWith("org.zhenchao.jvm")) {
try {
String filename = "basic/target/classes/" + name.replaceAll("\\.", File.separator);
File file = FileUtils.getFile(filename + ".class");
byte[] bytes = FileUtils.readFileToByteArray(file);
return super.defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("class not found : " + name, e);
}
}
return super.loadClass(name, resolve);
}

public static void main(String[] args) throws Exception {
Class clazz = Class.forName("org.zhenchao.jvm.loader.User", true, new HackClassLoader());
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader().toString());
}
}

// 输出:org.zhenchao.jvm.loader.HackClassLoader@5b2133b1

最后我们一起来探究一下 Tomcat 对于双亲委派机制的 “破坏”,那么 Tomcat 为什么要自定义类加载器呢,我觉得主要有两方面的原因:

  1. 一个 tomcat 实例下可以运行多个 web 应用,需要保证应用之间的依赖不相互干扰。
  2. 提供自动装载的能力,当 WEB-INF/classes 或 WEB-INF/lib 目录下的类发生变化时,WEB 应用程序可以重新载入这些类,而不需要重启服务器。Tomcat 通过设置一个单独的线程来不断检查这些类的时间戳,以便及时载入。
image

Tomcat 中类加载器结构定义如上图所示,各个类加载器的职责还是比较清楚的,先不展开探讨,留待以后用专门的篇幅进行说明。


转载声明 : 版权所有,商业转载请联系作者,非商业转载请注明出处
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议
Powered by hexo & Theme by hiero   Copyright © 2015-2019 浙ICP备 16010916  号,指 · 间 All Rights Reserved.