一点一滴探究JVM之类加载机制

@date 2017/11/14
@author stormma


生命不息,奋斗不止


前言

一点一滴探究JVM系列,主要深入探究JVM运行机制。俗话说,知其然知其所以然。如果不懂JVM的运行机制,那么无法了解Java这门语言最核心的东西,
也就谈不上编程之美了,因为你根本不懂得如何使你的代码更优雅。废话不多说,今天的主题就是JVM的类加载机制!

开始之前

在正式开始之前,我们先来看一段小程序!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Singleton {
private static Singleton intsance = new Singleton();
public static int counter1;
public static int counter2 = 0;
private Singleton() {
counter1++;
counter2++;
}
public static Singleton getInstance() {
return instance;
}
}
public class Test {
Singleton instance = Singleton.getInstance();
System.out.println("counter1 = " + Singleton.counter1 + ", counter2 = " + Singleton.counter2);
}

问题
上面的小程序输出counter1counter2的值是多少?

现在我不会告诉你正确的答案,除非你自己在你的电脑上运行了这段小程序!下面我们开始进入正题

类的生命周期

要想搞清楚类加载机制,我们必须事先知道类的生命周期

类的生命周期

注:
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中的验证、准备、
和解析这三部分称为连接。上图中的七个周期,可以简称为加载、连接、初始化。我想你会很好奇这三步究竟发生了什么?

  1. 加载: 查找并加载类的二进制数据
  2. 连接:
    • 验证: 确保被加载类的正确性
    • 准备: 为类的静态变量分配内存,并将其初始化为默认值
    • 解析: 把类的符号引用转换为直接引用
  3. 初始化: 为类的静态变量赋予正确的初始值

可能你还不是很理解👆的某些术语,稍安勿躁,这才只是个开始!

关于类加载

什么情况下,会触发类的加载过程,这确实是一个很难回答的问题,因为Java虚拟机规范中并没有进行强制性的约束,而是交给虚拟机具体实现来把握。Java虚拟机规范允许,类不需要等到被主动使用时候才去加载它,类加载器在预料到某个类将要被使用时,就预先加载它,如果在加载的过程中遇到了.class文件缺失或者莫名其妙的错误,类加载器必须在程序首次主动使用该类的时候才报告错误(LinkagError),如果这个类一直没有被主动使用,那么类加载器就不会报告错误!

我在上面的表述中有三个加粗的字体来着重突出主动使用这个概念,或许你会很好奇什么是主动使用,既然有主动使用,那么是不是也有被动使用呢?不得不说你很聪明,主动使用和被动使用的概念,我不打算在这讲,因为这两个概念和初始化阶段关系密切!

类加载需要完成的事情:

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

注: 获得类的二进制字节流还可以从ZIP包读取、网络中获取、JAR包获取、或者其他文件获取(JSP应用)!

现在你大概清楚了类加载过程完成了哪些操作,那么不得不说说类加载器了

类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()isAssignableFrom()isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

我发誓我不会骗你,因为有代码为证:

Example.java

1
2
3
public class Example {
public static final String NAME = "stormma";
}

InstanceOfTest.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* @author stormma
* @date 2017/11/14
*/
public class InstanceOfTest {
@Test
public void testDifferentLoaderLoadClass() throws ClassNotFoundException, IllegalAccessException, InstantiationException {
ClassLoader classLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
String filaneName = name.substring(name.lastIndexOf('.') + 1) + ".class";
InputStream inputStream = getClass().getResourceAsStream(filaneName);
if (inputStream == null) {
return super.loadClass(name);
}
try {
byte[] b = new byte[inputStream.available()];
inputStream.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Class clazz = classLoader.loadClass("me.stormma.chapter4.Example");
System.out.println(clazz.newInstance().getClass()); // class me.stormma.chapter4.Example
System.out.println(clazz.newInstance() instanceof Example); // false
System.out.println(clazz.getClassLoader()); // me.stormma.chapter4.InstanceOfTest$1@26a7b76d
System.out.println(InstanceOfTest.class.getClassLoader()); // jdk.internal.loader.ClassLoaders$AppClassLoader@4f8e5cde
System.out.println(clazz.equals(Example.class)); // false
System.out.println(clazz.isAssignableFrom(Example.class)); // false
System.out.println(clazz.isInstance(Example.class)); // false
}
@Test
public void testSameLoaderLoadClass() throws IllegalAccessException, InstantiationException, ClassNotFoundException {
Class<?> clazz = InstanceOfTest.class.getClassLoader().loadClass("me.stormma.chapter4.Example");
System.out.println(clazz.newInstance() instanceof Example); //true
System.out.println(InstanceOfTest.class.getClassLoader() == clazz.getClassLoader()); // true
}
}

如果从JVM角度来看,所有的类加载器可以分为:

  • 启动类加载器(Bootstrap ClassLoader,它负责加载存放在$JAVA_HOME/jre/lib下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar)。启动类加载器是无法被Java程序直接引用的。很容易可以验证,执行System.out.println(String.class.getClassLoader())打印结果为null)
  • 扩展类加载器(Extension ClassLoader, 该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载$JAVA_HOME/jre/lib/ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。在jdk1.9中类加载器有所变化!1.9中jdk.internal.loader.ClassLoaders$PlatformClassLoader,称为平台类加载器)
  • 应用程序加载器(Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径ClassPath所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。注意在jdk1.9中,应用程序加载器由jdk.internal.loader.ClassLoaders$AppClassLoader实现)

你的应用程序,其实就是这几种类加载器配合使用进行加载的,如果有必要,你可以实现自己的类加载器!比如Tomcat中就有自己的类加载器的实现!

下面,我要介绍一个更重要的概念!双亲委托机制

双亲委托机制

类加载器的层次关系如下:

类加载器的层次关系

这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。该模型在JDK1.2期间被引入并广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者们推荐给开发者的一种类的加载器实现方式。

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

使用双亲委派模型来组织类加载器之间的关系,有一个很明显的好处,就是Java类随着它的类加载器(说白了,就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证Java程序的稳定运作很重要。例如,类java.lang.Object类存放在$JAVA_HOME/jre/lib下的rt.jar之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,这边保证了Object类在程序中的各种类加载器中都是同一个类。但是试想一下,如果自定义的加载器去加载的话,那么程序中会出现不同的Object类(详细前面测试代码)!那样将是一片混乱。

到这,我想我们应该去看一下ClassLoader这个抽象类的双亲委托机制的实现了!

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
32
33
34
35
36
37
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded,首先,检查这个类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) { // 此处说明父加载器无法加载该类
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) { // 调用自身的findClass来进行类的加载
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

话说,jdk的注释真的好详细!

这段代码是不是很简单很简单!对照我们上面的测试代码自定义的那个类加载器,如果是实现findClass()而没有实现loadClass()方法,那么加载时候先开始判断它的父类加载器(自定义类加载器的上一级是应用程序类加载器,然后根据双亲委托机制一步一步进行判断加载。最后加载都不成功就会调用findClass()方法来加载,jdk1.2之后官方不提倡实现loadClass()!上面的例子,为了测试两个Class对象不相等,强制实现了loadClass(),因为如果只实现findClass(), 就会被应用类加载器所加载)

关于验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求(你可能会有疑问,Java编译之后的class文件,JVM为啥还不相信呢?其实,你也可以伪造一个class文件,让JVM去加载执行,如果这有害,那么肯定会损害JVM,所以说
JVM很”狡猾”),而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。

  • 文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
  • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
  • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
  • 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

关于验证阶段,很多东西都是和class文件字节码相关的(哦,对,这是句废话),深入探究验证阶段的前提是读懂Class文件字节码,后面的文章,我会专门对Class文件字节码进行总结,力争让看完文章的每个人都可以看懂Class文件字节码, Come On! CafeBabe,什么,CafeBabe是啥?这其实就是Class字节码的魔数,每个Class文件字节码都是以CafeBabe开头的,不,不对,是每个符合JVM标准的字节码,程序员是不是很浪漫,哈哈~

关于准备

准备阶段是正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段有两个容易混淆的概念,首先,进行内存分配的仅包括类变量(被static修饰的变量),不包括实例变量,实例变量是在对象实例化的时候分配在
heap区的!看到这,你是不是想回去看看我们开头的那道题目了,别急,还有一些东西你没看到呢!

我刚才说了分配内存之后要设置初始值,对,你没看错,但是这个初始值是初值,默认值,而不是你代码的初始值,这其实就是开头那道题目答案不如你所想的原因!接着看吧!

假如我们定义了一个类变量public static String NAME = "stormma";

那么,在当前所处的准备阶段,给这个变量分配内存之后,初始值是null而不是stormma,而最后的赋值是发生在初始化阶段,关于各种类型的初始值

类型初始值

关于解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。在Class类文件结构一文中已经比较过了符号引用和直接引用的区别和关联,这里不再赘述。前面说解析阶段可能开始于初始化之前,也可能在初始化之后开始,虚拟机会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。

  • 类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
  • 字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束。

对于解析和验证这一块,和读懂Class文件有着密不可分的关系,所以这一块的补充知识会在读懂Class文件字节码之后进行讲解!

初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源。
或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。

  1. <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中(static{})的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。

  2. <clinit>()方法与实例构造器<clinit>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object

  3. <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成()方法。

  4. 接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

  5. 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

看到这,我相信你现在回头看看我们开始的那道题目,你已经可以解释那道题目的答案了!

是的,刚开始触发类加载之后的一系列操作完成之后,开始进行初始化,赋初值, counter1 = counter2 = 0,instance = null,然后开始执行用户的初始化,instance = new Singleton(),然后执行构造器,counter1 = counter2 = 1
然后初始化counter1,因为counter1无用户初始化的值,然后执行counter2 = 0,所有counter2从0变化1再变化到0。

再看个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FinalTest {
public static void main(String[] args) {
System.out.println(FinalT.NAME);
}
}
class FinalT {
public static final java.lang.String NAME = "stormma";
static {
System.out.println("FinalT初始化");
}
}

答案是”stormma”

为啥没有”FinalT初始化”呢,我们先来看一下这个class文件的字节码吧!

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
stormma@stormma:~/coding/java-project/concurrency/target/classes/me/stormma/chapter4$ javap -verbose FinalTest.class
Classfile /Users/stormma/coding/java-project/concurrency/target/classes/me/stormma/chapter4/FinalTest.class
Last modified 2017-11-14; size 598 bytes
MD5 checksum f6a69bfc19f0e693fdc57a9af831cfb8
Compiled from "FinalTest.java"
public class me.stormma.chapter4.FinalTest
minor version: 0
major version: 50
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Class #24 // me/stormma/chapter4/FinalT
#4 = String #25 // stormma
#5 = Methodref #26.#27 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = Class #28 // me/stormma/chapter4/FinalTest
#7 = Class #29 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lme/stormma/chapter4/FinalTest;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 SourceFile
#20 = Utf8 FinalTest.java
#21 = NameAndType #8:#9 // "<init>":()V
#22 = Class #30 // java/lang/System
#23 = NameAndType #31:#32 // out:Ljava/io/PrintStream;
#24 = Utf8 me/stormma/chapter4/FinalT
#25 = Utf8 stormma
#26 = Class #33 // java/io/PrintStream
#27 = NameAndType #34:#35 // println:(Ljava/lang/String;)V
#28 = Utf8 me/stormma/chapter4/FinalTest
#29 = Utf8 java/lang/Object
#30 = Utf8 java/lang/System
#31 = Utf8 out
#32 = Utf8 Ljava/io/PrintStream;
#33 = Utf8 java/io/PrintStream
#34 = Utf8 println
#35 = Utf8 (Ljava/lang/String;)V
{
public me.stormma.chapter4.FinalTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lme/stormma/chapter4/FinalTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String stormma
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 11: 0
line 12: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "FinalTest.java"

其中#4 = String #25 // stormma这个地方,是个常量,所以没有触发类的初始化,所以也不会执行static块中的初始化

对了,差点忘了,有个重要的东西没介绍,前面我们粗体表示的主动使用,以及被动使用

主动使用

主动使用 (这几种第一次发生的情况下,进行类的初始化)

  • 创建类的实例
  • 访问某个类或者接口的静态变量,或者对该静态变量赋值
  • 访问类的静态方法
  • 反射Class.forName();
  • 初始化一个类的子类
  • Jvm启动时被标记为启动类的类

结尾

JVM的类加载机制,深究一篇博文难以概括全,由于本人表达能力欠缺,如果有些东西不懂实属我的疏漏,如果你有疑问,或者有建议,请联系我,github