更新时间: 2020-05-03 14:59:27       分类: 学习笔记


Java类加载机制总结

本部分整理自《深入理解JVM虚拟机》

类的生命周期与加载时机

  1. 类的生命周期

    一个类从被加载到虚拟机内存中开始,到被卸载出内存为止,整个生命周期包括了 加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中 验证、准备、解析 3部分统称为链接,如下图:

    整个顺序并不是完全固定的,其中解析阶段可以在初始化之后再开始,这样便可以实现Java的运行时绑定(动态绑定)机制。

  2. 类的加载时机

    JVM虚拟机规范并没有对类的加载时机做出严格的要求,只规定了以下五种情况需要立刻触发类的初始化:

    • 遇到new,getstatic,putstatic和invokestatic这四个字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。

    • 使用反射机制对类进行调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

    • 当初始化一个类时,如果其父类还没有进行过初始化,则需要先触发其父类的初始化。

    • 虚拟机启动时,用户需要指定一个要执行的主类(包含main方法),此时会先初始化这个类

    • 使用JDK1.7的动态语言支持时,如果一个MethodHandle实例最后的解析结果包含REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,且这个方法句柄对应的类没有初始化,则需要先对其进行初始化。

    其余条件下,可以由JVM虚拟机自行决定何时去加载一个类。

  3. 主动引用和被动引用

    上面五种条件也被称为对类的主动引用,除此之外其他引用类的方式都不会触发初始化,即类的被动引用,举个例子:

    public class Father {
    	static {
    		System.out.println("father init.");
    	}
    	public static int val = 123;
    }
    
    public class Son extends Father {
    	static {
    		System.out.println("son init.");
    	}
    }
    

    当我们访问Son.val时,会发现并没有输出son init.

    对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类来引用父类的静态字段,子类相当于是被动引用,也就不会被初始化了。

类的加载过程

下面简单的介绍一下整个加载过程中,每个阶段JVM都执行了什么操作:

加载(Loading)

加载过程是Java的一大特点,类的来源可以多种多样,压缩包、网络字节流、运行时动态计算生成(reflect)等等...这也造就了Java语言强大的动态特性。

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

验证(Verification)

这一过程主要是为了确保Class的字节流中包含的信息符合虚拟机标准,以免造成破坏

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证,通过数据流和控制流分析确定程序的语义是合法的
  4. 符号引用验证,确保解析动作能够正常执行

准备(Preparation)

这一阶段将会为类变量分配内存并设置其初始值,注意此时进行内存分配的仅包括类变量(static修饰),并且初始值通常情况下是数据类型的零值而不是设定值,如下例

public static int val = 123;

在这一阶段变量val的赋值是0而不是123,因为此时尚未执行任何Java方法,而对val复制的putstatic指令在初始化阶段后才会执行。

当然也有特殊情况,如下

public static final int val = 123;

加上final关键字修饰后,Java编译时会为val生成ConstantValue属性,这时准备阶段就会根据设置将其值设置为123。

解析(Resolution)

此阶段虚拟机将常量池内的符号替换为直接引用,主要包含以下动作:

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

初始化(Initialization)

这时类加载过程的最后一步,这部分开始真正的执行Java代码,也就是说,这个阶段可以由程序员参与。

此阶段其实就是执行类构造器<clinit>()方法的过程。

类加载器(Class Loder)

类加载器(Class Loader)是Java虚拟机的一大创举,它将“获取类的二进制字节流”这个过程交给了开发人员自己去实现,只要编写不同的Class Loader,应用程序本身就可以用相应的方式来获取自己需要的类。

类与加载器的关系

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在虚拟机中的唯一性。

通俗的讲,就是即便同一个Class文件,被不同的类加载器加载之后,得到也不是同一个“类”(equals方法返回false)。

双亲委派模型

从虚拟机角度讲,只有两种类加载器,一种是启动类加载器(Bootstrap ClassLoader),在hotpot上使用C++实现,属于虚拟机的一部分;另一种则是所有其他类的加载器,这些加载器是独立于虚拟机的,由Java语言实现的,从开发者角度看,可以分为以下两类:

  1. 扩展类加载器(Extension ClassLoader)

  2. 应用程序类加载器(Appliaction ClassLoader)

当然开发人员也可以自己编写类加载器,最终不同的类加载器之间的层次关系如下图所示:

这就是Java中著名的双亲委派模型,它要求除了顶级的BootStrap加载器之外,其他类加载器都必须有父类加载器,工作流程如下:

如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器去完成,只有当父加载器反馈自己无法完成加载请求时,子加载器才会自己去尝试加载这个类。

这样做的好处是,Java类随着它的类加载器一起具备了一种带有优先级的层次关系,这主要是为了防止同名的类出现混乱。举个例子,比如java.lang.Object这个类,无论哪个类加载器加载时,最终都会委派给Bootstrap加载器去加载,这就保证了整个系统运行过程中的Object都是同一个类。

否则,如果用户自己编写了一个java.lang.Object类,并放在程序的classpath中,最终系统将会出现多个不同的Object类,整个Java体系就变得一团混乱了。

顺便一提,在Java中,一个类是由字节码和类加载器所唯一确认的,同样的class文件,如果被不同的Class Loader解析,就会在JVM中生成不同的Class对象。

Java字节码

让我们回顾一下Java类的生成过程

Demo.java(源码)-- javac(编译器)--> Demo.class(字节码)-- ClassLoader --> JVM Class对象

可以看到,Java和其他编译型语言最不同的一点就是,在编译后它并不是生成机器码或汇编,而是生成了一种名为字节码的中间产物,通过Classloader引入JVM后,再真正的去执行。这便是Java语言“一次编译,到处运行”的秘密,无论是通过什么编译器生成的字节码,只要内部不变,在任何平台的JVM上执行,都可以得到同样的效果。

接下来第一步,我们要揭开字节码的神秘面纱

字节码格式

现在有这么一个demo的java类(Demo.java

public class Demo {
  
  private String name;
  
  private int age;
  
  public Demo() {}
  
  public Demo(String name, int age) {
    this.name = name;
    this.age = age;
  }
  
  public void setName(String name) {this.name = name;}
  
  public String getName() {return this.name;}
  
  public void setAge(int age) {this.age = age;}
  
  public int getAge() {return this.age;}
  
  public String echo() {
    return "I'm " + this.name + " , I'm " + this.age + " years old"; 
  }
}

通过执行编译命令javac Demo.java我们可以得到字节码文件Demo.class,这是一个二进制文件,直接通过文本编辑器打开会有一堆乱码,我们使用vim命令%!xxd可以将其转为16进制显示(右侧会附加显示转换为ASCII字符的结果):

![image-20200427220400411](/Users/lumin/Library/Application Support/typora-user-images/image-20200427220400411.png)

看到这一大串的机器语言确实让人感到头痛,但并不意味着它不可读,现在我们需要一本词典来帮助翻译它,那就是字节码的格式说明:

![image-20200427220849888](/Users/lumin/Library/Application Support/typora-user-images/image-20200427220849888.png)

有了它我们就可以尝试去翻译字节码了,现在把自己想象成JVM程序,一个字节一个字节的去解析这串二进制流:

读取4个字节:魔数(Magic Number)

用于标记这个文件是一个Java的字节码文件,这样做的好处是,即便文件的后缀被改了(不是.class),程序通过读这个魔数依然能够知道它是个字节码文件(常见的jpg等文件也都有类似的原理),字节码文件的魔数用十六进制表示就是CAFE BABE,你没有想起象征着Java的那杯咖啡?

读取4个字节:版本信息(Version)

分别是两个字节的minor_version和两个字节的major_version,通过它我们可以知道这个类是在什么版本的JDK上编译出来的。

读取2 + n个字节:常量池(Constant Pool)

这里的前两个字节声明常量池中有多少个常量,注意该值比实际的常量数多一个,比如这里的数值是0032,转换为十进制是50,那么其实有49个常量,编号分别是1 - 50,0号位被预留了出来,不允许被使用。

那么什么是常量池?可以认为它是Java类的一个“资源库”,这里会将类中的一些字面量和符号引用储存下来。字面量就是类里定义的各种各样的字符串,包括使用final修饰的常量字符串、在代码中直接声明的字符串(比如Demo中的"I'm "等;而符号引用包含了类的全局限定名、字段名、方法名、描述符等使用Java语法定义的元素。

为了能够看懂常量池里的常量,我们还得搬出一张表(注:JDK 1.7之后已经不只11种了):

![image-20200427223200702](/Users/lumin/Library/Application Support/typora-user-images/image-20200427223200702.png)

类型U1表示占用1个字节,U2并表示占用2个字节

有了它我们才能看懂常量池里的16进制代码,可以看到每个常量都有一个tag用于标记其类型,根据类型和数据长度定义,我们能够翻译得出下面这张常量表:

#1  0a 00 0d 00 22
#2  09 00 0c 00 23 
#3  09 00 0c 00 24
#4  07 00 25 
#5  0a 00 04 00 22
#6  08 00 26 
#7  0a 00 04 00 27
#8  08 00 28 
#9  0a 00 04 00 29
#10 08 00 2a 
#11 0a 00 04 00 2b 
#12 07 00 2c 
#13 07 00 2d
#14 01 00 04 6e 61 6d 65
#15 01 00 12 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 
#16 01 00 03 61 67 65
#17 01 00 01 49
#18 01 00 06 3c 69 6e 69 74 3e 
#19 01 00 03 28 29 56 
#20 01 00 04 43 6f 64 65
#21 01 00 0f 4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65
#22 01 00 16 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 49 29 56 
#23 01 00 07 73 65 74 4e 61 6d 65
#24 01 00 15 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 
#25 01 00 07 67 65 74 4e 61 6d 65 
#26 01 00 14 28 29 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 
#27 01 00 06 73 65 74 41 67 65
#28 01 00 04 28 49 29 56
#29 01 00 06 67 65 74 41 67 65
#30 01 00 03 28 29 49
#31 01 00 04 65 63 68 6f
#32 01 00 0a 53 6f 75 72 63 65 46 69 6c 65 
#33 01 00 09 44 65 6d 6f 2e 6a 61 76 61 
#34 0c 00 12 00 13 
#34 0c 00 0e 00 0f 
#35 0c 00 10 00 11
#36 01 00 17 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 42 75 69 6c 64 65 72
#37 01 00 04 49 27 6d 20 0c 00 2e 00 2f
#38 01 00 07 20 2c 20 49 27 6d 20 
#39 0c 00 2e 00 30 
#40 01 00 0a 20 79 65 61 72 73 20 6f 6c 64
#41 0c 00 31 00 1a 
#42 01 00 04 44 65 6d 6f
#43 01 00 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74 
#44 01 00 06 61 70 70 65 6e 64
#45 01 00 2d 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 42 75 69 6c 64 65 72 3b
...(以下省略)

现在让我们试着读取第一个常量:

0a 00 0d 00 22

这是Demo中的第一个常量,tag为0aCONSTANT_Methodref_info,说明它是一个方法引用说明,00 0d是指向声明方法的类描述符的索引项,00 22是指向名称及类型描述符的索引项。你可能在思考索引项又是个什么东东,其实它就是常量的编号,我们目前读取的是第一个常量,其索引项(编号)就是1。

这里我们通过索引项“顺藤摸瓜”,来还原这个常量所表达的含义。000d转换为十进制为13,表示声明该方法的类的基本信息储存在索引项为13的常量中,0022转换为十进制为34,表示该方法的名称和类型信息储存在索引项为34的常量中。

继续看下去,先看编号为13的常量内容是

07 00 2d

tag07,说明是一个类的定义,这个类的全名被记录在编号为2d(#45)的常量里,那么继续看编号为45的常量内容:

01 00 2d 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 42 75 69 6c 64 65 72 3b

这个常量看起来很长,编号01说明它是一个字符串,00 2d表明其长度(字节数),后面2d个字节是用utf-8编码的字符串内容,翻译过来就是java/lang/Object,读到这里一条路到了终点,还需要回头看编号为34的常量内容

0c 00 12 00 13 

tag0c,说明是一个NameAndType类型的变量,00 12(#18)表示指向该字段or方法名称的常量索引,00 13(#19)表示指向该字段or方法描述符的索引,所以我们需要进一步看编号为18和19的常量:

编号18(翻译为字符串是<init>

01 00 06 3c 69 6e 69 74 3e 

编号19(翻译为字符串是()V

01 00 03 28 29 56 

到这里,第一个常量所表达的含义就完全清楚了,它记录的是一个方法,这个方法被java/lang/Object这个方法所定义,方法名是<init>,方法的入参和返回结果是()V

这里有些字面量可能看起来有点费解,用我们更熟悉的语言再表述一遍就是:由java.lang.Object类定义的入参为空,返回值为void的方法名为<init>的方法,没错,就是一个类的无参构造函数

到这里深吸一口气,如果按照这个方式把所有常量都读一遍,那岂不是要累死个人!这时候就要祭出Java官方提供的命令工具javap了,在对应字节码文件的目录下运行javap -verbose <类名>就可以以“可视化”的方式阅读整个字节码文件了,让我们运行javap -verbose Demo看看:

![image-20200502224200390](/Users/lumin/Library/Application Support/typora-user-images/image-20200502224200390.png)

可以看到整个字节码中的常量池都被打印出来,并且以非常清晰的方式展示了其实际含义,除了常量池之外,字节码中的其他内容也会被以这种“可视化”的方式打印出来,我们继续向下看。

读取2个字节:访问控制标记(Access Flag)

在所有常量信息都读取完毕后,我们继续往下读取2个字节:

00 21

这两个字节是当前类的‘访问控制标记“,简单来说,就是类的各种修饰符,是public还是private,是否是abstract,是否是interface,是否有继承等等,具体含义还需要搬出来一张表:

![image-20200502224824829](/Users/lumin/Library/Application Support/typora-user-images/image-20200502224824829.png)

到这里你可能发现了,0021并不在表中,这里就牵扯到位运算这个黑科技了,为了把所有的说明修饰符压缩到2个字节的大小,这些值在二进制上都是精心处理过的,我们实际读到的值,是所有修饰符的或运算的结果,即:

0x0021 = 0x0001 | 0x0020

如果你把这些具体的值翻译回二进制,就会发现它们每一位都是不相同的,这就保证了将任意的Flag组合,或运算得到的最终结果都是唯一的。在机器实际运行过程中,只要使用与运算就可以反推出哪些Flag被声明了,仍然以这个为例:

0x0020 & 0x0021 = 1
0x0001 & 0x0021 = 1

因此可以得知0x0020(ACC_SUPER)和0x0001(ACC_PUBLIC)这两个Flag被声明了,说明当前的类是一个public的公开类,至于ACC_SUPER的含义,和类的动态加载机制有关,有兴趣的话可以自行了解,在这里不做过多赘述了。

读取2个字节:当前类名(This Class Name)

00 0c

指向第12号常量,12号常量又指向44号常量,读取内容为Demo

读取2个字节:父类名(Super Class Name)

00 0d

指向第13号常量,13号常量又指向45号常量,读取内容为java/lang/Object

读取2+n个字节:接口(Interfaces)

00 00

前两个字节表示该类实现了多少个接口,后续的若干个字节是接口表,由于这里类并没有实现任何接口,所以我们读到的数字是0

读取2+n个字节:z字段(Fields)

前两个字节声明类中声明了几个字段,这里是00 02,即2个(name, age)接下来的部分是字段表,主要是描述这些字段的具体含义,格式如下:

![image-20200502231631670](/Users/lumin/Library/Application Support/typora-user-images/image-20200502231631670.png)

这里又引出一个概念是“属性表”,它是为了描述一些专有信息的表,可以有很多种,比如代码行LineNumberTable,Code等,这里考虑到篇幅,不再一一展开了。

读取2+n个字节:方法(Methods)

还是前两个字节表示方法的数量,后面紧跟着方法表,上一下方法表的结构图:

![image-20200502231907824](/Users/lumin/Library/Application Support/typora-user-images/image-20200502231907824.png)

可以看到和字段表的定义是一模一样的,在方法区这里特别提一个属性表,那就是Code,Code属性表就是用来储存实际要执行的代码的,这里我们使用可视化的工具来看一下:

![image-20200502233609081](/Users/lumin/Library/Application Support/typora-user-images/image-20200502233609081.png)

上面是getAge()方法的定义,可以看到有两个属性表,分别是Code和LineNumberTable,Code中的代码是虚拟机的字节码指令,就好像汇编语言里的指令一样,这里也不展开细讲了

最后2+n个字节:属性(Attributes)

该项存放了在该文件中类或接口所定义属性的基本信息,具体不仔细展开了。

到这里,整个字节码的结构就读完了,你也许会开始怀疑作为一个面向高级语言编程的程序员,为什么要去了解这些枯燥无味的底层细节,那么接下来我们就看看字节码的实际应用。

黑科技:字节码增强

字节码增强技术是一类对现有字节码进行修改或者动态生成全新字节码文件的技术,说的具象一点,就是通过某种手段改写类原本的字节码,来增强类原本的功能,我们熟知的Spring AOP就是利用字节码增强来给类动态添加各种功能的。

![image-20200503144353502](/Users/lumin/Library/Application Support/typora-user-images/image-20200503144353502.png)

实现字节码增强的手段或者说工具有很多种,这里我们用最基本,也是最直接的方式,ASM为例来研究如何实现字节码增强。

ASM API


评论

还没有评论