GinoBeFunny

《深入理解Java虚拟机》读书笔记5:类加载机制与字节码执行引擎

国内JVM相关书籍NO.1,Java程序员必读。读书笔记第五部分对应原书的第七章至第九章,主要介绍虚拟机的类加载机制、字节码执行引擎,并通过实例和实战加深对虚拟机执行子系统这一部分的理解。

第七章 虚拟机类加载机制

7.1 概述

  • 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
  • 在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成,这虽然增量一些性能开销,但是会为Java应用程序提供高度的灵活性。

7.2 类加载的时机

  • 类的整个生命周期:加载、验证、准备、解析、初始化、使用和卸载;其中验证、准备和解析统称为连接;
  • 虚拟机规范没有强制约束类加载的时机,但严格规定了有且只有5种情况必须立即对类进行初始化:遇到new、getstatic、putstatic和invokestatic指令;对类进行反射调用时如果类没有进行过初始化;初始化时发现父类还没有进行初始化;虚拟机启动指定的主类;动态语言中MethodHandle实例最后解析结果REF_getStatic等的方法句柄对应的类没有初始化时;

7.3 类加载的过程

7.3.1 加载

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

7.3.2 验证

  • 验证是连接阶段的第一步,其目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全;
  • 验证阶段是非常重要的,这个阶段是否严谨决定了Java虚拟机是否能承受恶意代码的攻击;
  • 校验动作:文件格式验证(基于二进制字节流)、元数据验证(对类的元数据语义分析)、字节码验证(对方法体语义分析)、符号引用验证(对类自身以外的信息进行匹配性校验);

7.3.3 准备

  • 正式为变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在这个方法区中进行分配;
  • 需要强调两点:这时候内存分配的仅包括类变量,而不包括类实例变量;这里所说的初始化通常情况下是数据类型的零值,真正的赋值是在初始化阶段,如果是static final的则是直接赋值;

7.3.4 解析

  • 解析阶段是虚拟机将常量池内的符号引用(如CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等7种)替换为直接引用的过程;
  • 符号引用可以是任何形式的字面量,与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中;而直接引用是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,它和虚拟机实现的内存布局相关,引用的目标必定以及在内存中存在;
  • 对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可以对第一次解析的结果进行缓存;

7.3.5 初始化

  • 是类加载过程的最后一步,真正开始执行类中定义的Java程序代码(或者说是字节码);
  • 初始化阶段是执行类构造器方法的过程,该方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的;
  • 方法与类的构造函数(或者说是实例构造器方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的方法执行之前,父类的方法已执行完毕;
  • 执行接口的方法不需要先执行父接口的方法,只有当父接口中定义的变量使用时父接口才会初始化,接口的实现类在初始化时也一样不会执行接口的方法;
  • 方法初始化是加锁阻塞等待的,应当避免在方法中有耗时很长的操作;

7.4 类加载器

  • 虚拟机设计团队把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到虚拟机外部去实现,实现这个动作的代码模块称为类加载器;
  • 这时Java语言的一项创新,也是Java语言流行的重要原因,在类层次划分、OSGI、热部署、代码加密等领域大放异彩;

7.4.1 类与类加载器

  • 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机的唯一性,每一个类加载器都拥有一个独立的类名称空间;
  • 比较两个类是否相等(如Class对象的equals方法、isAssignableFrom方法、isInstance方法),只有在这两个类是由同一个类加载器加载的前提下才有意义;

7.4.2 双亲委派模型

双亲委派模型

  • 三种系统提供的类加载器:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader);
  • 双亲委派模型要求除了顶层的启动类加载器外,其他的类加载器都应当有自己的父类加载器,这里一般不会以继承的关系来实现,而是使用组合的关系来复用父加载器的代码;
  • 其工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,只有父类加载器反馈自己无法完成这个加载请求时(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载;
  • 这样的好处是Java类随着它的类加载器具备了一种带有优先级的层次关系,对保证Java程序的稳定运作很重要;
  • 实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass方法中,逻辑清晰易懂;

7.4.3 破坏双亲委派模型

  • 上一小节的双亲委派模型是Java设计者推荐给开发者的类加载器实现方法,但不是一个强制性的约束模型;
  • 典型的两种情况:为了解决JNI接口提供者(SPI)引入的线程上下文类加载器;为了程序动态性加强的OSGI的Bundle类加载器;

7.5 本章小结

本章介绍了类加载过程的加载、验证、准备、解析和初始化五个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。下一章将一起看看虚拟机如果执行定义在Class文件里的字节码。

第八章 虚拟机字节码执行引擎

8.1 概述

  • 执行引擎是Java虚拟机最核心的组成部分之一,区别于物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,虚拟机的执行引擎是自己实现的,可以自行制定指令集与执行引擎的结构体系,并且能够执行哪些不被硬件直接支持的指令集格式;
  • 在虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,该模型成为各种虚拟机执行引擎的统一外观;
  • 在不同的虚拟机实现里面,执行引擎在执行Java代码时可能会有解释执行和编译执行两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎,但从外观来说是一致的:输入的都是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

8.2 运行时栈帧结构

运行时栈帧结构

  • 栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素;
  • 栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程;
  • 栈帧需要分配多少内存在编译时就完全确定并写入到方法表的Code属性之中了,不会受到程序运行期变量数据的影响;
  • 对于执行引擎来说,在活动线程中只有位于栈顶的栈帧才算有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

8.2.1 局部变量表

  • 是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,Code属性的max_locals确定了该方法所需要分配的局部变量表的最大容量;
  • 其容量以变量槽(Variable Slot)为最小单位,虚拟机规范允许Slot的长度随处理器、操作系统或虚拟机的不同而发生变化;
  • 一个Slot可以存放一个32位以内的数据类型,包括boolean、byte、char。short、int、float、reference和returnAddress这八种类型;对于64位的数据类型(long和double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间;

8.2.2 操作数栈

  • 也常称为操作栈,它是一个后入先出栈;Code属性的max_stacks确定了其最大深度;
  • 比如整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈;
  • 操作数栈中元素的类型必须与字节码指令的序列严格匹配;
  • Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈;

8.2.3 动态连接

  • 每个栈帧都包含一个执行运行时常量池中该栈帧所属方法引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking);
  • Class文件的常量池的符号引用,有一部分在类加载阶段或者第一次使用时就转换为直接引用,这种称为静态解析,而另外一部分在每一次运行期间转换为直接引用,这部分称为动态连接;

8.2.4 方法返回地址

  • 退出方法的方式:正常完成出口和异常完成出口;
  • 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能只需的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数中,调整PC计数器的值以只需方法调用指令后面的一套指令等;

8.2.5 附加信息

  • 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分完成取决于具体的虚拟机实现;

方法调用

  • 方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本即调用哪一个方法,暂时还不涉及方法内部的具体运行过程;
  • Class文件的编译过程中不报警传统编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂;

8.3.1 解析

  • 方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,这类方法的调用称为解析;
  • 在Java语言中符合编译器可知、运行期不可变这个要求的方法,主要包括静态方法和私有方法两大类;
  • 五条方法调用字节码指令:invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic;
  • 解析调用是一个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用;而分派调用则可能是静态的也可能是动态的;

8.3.2 分派

  • 静态分派:“Human man = new Man();”语句中Human称为变量的静态类型,后面的Man称为变量的实际类型;静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型的变化在运行期才确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么;编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的;所有根据静态类型来定位方法执行版本的分派动作称为静态分派,其典型应用是方法重载;
  • 动态分派:invokevirtual指令执行的第一步就是在运行期间确定接收者的实际类型,所以两次调用中invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质;我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派;
  • 单分派与多分派:方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派分为单分派(根据一个宗量对目标方法进行选择)与多分派(根据多于一个宗量对目标方法进行选择)两种;今天的Java语言是一门静态多分派、动态单分派的语言;
  • 虚拟机动态分派的实现:在方法区中建立一个虚方法表(Virtual Method Table),使用虚方法表索引来代替元数据查找以提高性能;方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化值后,虚拟机会把该类的方法表也初始化完毕;

8.3.3 动态类型语言支持

  • JDK 1.7发布增加的invokedynamic指令实现了“动态类型语言”支持,也是为JDK 1.8顺利实现Lambda表达式做技术准备;
  • 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译器,比如JavaScript、Python等;
  • Java语言在编译期间就将方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class文件中;这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息;而在ECMAScript等动态语言中,变量本身是没有类型的,变量的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型;变量无类型而变量值才有类型,这个特点也是动态类型语言的一个重要特征;
  • JDK 1.7实现了JSR-292,新加入的java.lang.invoke包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法外,提供一种新的动态确定目标方法的机制,称为MethodHandle;
  • 从本质上讲,Reflection(反射)和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用,前者是重量级,而后者是轻量级;另外前者只为Java语言服务,后者可服务于所有Java虚拟机之上的语言;
  • 每一处含有invokedynamic指令的位置都称为“动态调用点(Dynamic Call Site)”,这条指令的第一个参数不再是代表符号引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量(可以得到引导方法、方法类型和名称);
  • invokedynamic指令与其他invoke指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定的;

8.4 基于栈的字节码解释执行引擎

上节主要讲虚拟机是如何调用方法的,这节探讨虚拟机是如何执行方法中的字节码指令的。

8.4.1 解释执行

  • 只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才比较确切;
  • Java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程;因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现;

8.4.2 基于栈的指令集与基于寄存器的指令集

  • Java编译器输出的指令集,基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,它们依赖操作数栈进行工作;
  • 基于栈的指令集主要的优点是可移植性,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束;主要缺点是执行速度相对来说会稍慢一点;

8.4.3 基于栈的解释器执行过程

一段简单的算法代码

1
2
3
4
5
6
public int calc(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}

上述代码的字节码表示

public int calc();
Code:
Stack=2, Locals=4, Args_size=1
0:bipush 100
2:istore_1
3:sipush 200
6:istore_2
7:sipush 300
10:istore_3
11:iload_1
12:iload_2
13:iadd
14:iload_3
15:imul
16:ireturn

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,作者根据这些信息画了示意图来说明执行过程中的变化情况:

执行偏移地址为0的指令
执行偏移地址为0的指令

执行偏移地址为2的指令
执行偏移地址为2的指令

执行偏移地址为11的指令
执行偏移地址为11的指令

执行偏移地址为12的指令
执行偏移地址为12的指令

执行偏移地址为13的指令
执行偏移地址为13的指令

执行偏移地址为14的指令
执行偏移地址为14的指令

执行偏移地址为16的指令
执行偏移地址为16的指令

注:上面的执行过程仅仅是一种概念模型,虚拟机中解析器和即时编译器会对输入的字节码进行优化。

8.5 本章小结

本章分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法内的字节码以及执行代码时涉及的内存结构。这第六、七、八三章中,我们针对Java程序是如何存储的、如何载入的以及如何执行的问题进行了讲解,下一章一起看看这些理论知识在具体开发中的经典应用。

第九章 类加载及执行子系统的案例与实战

9.1 概述

  • 在Class文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不多;
  • 能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路;

9.2 案例分析

9.2.1 Tomcat:正统的类加载器架构

Tomcat服务器的类加载架构

  • Java Web服务器:部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离又要可以互相共享;尽可能保证自身的安全不受部署的Web应用程序影响;要支持JSP生成类的热替换;
  • 上图中,灰色背景的三个类加载器是JDK默认提供的类加载器,而CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader是Tomcat自己定义的类加载器,分别加载/common/(可被Tomcat和Web应用共用)、/server/(可被Tomcat使用)、/shared/(可被Web应用使用)和/WebApp/WEB-INF/(可被当前Web应用使用)中的Java类库,Tomcat 6.x把前面三个目录默认合并到一起变成一个/lib目录(作用同原先的common目录);

9.2.2 OSGI:灵活的类加载架构

OSGI的类加载架构

  • OSGI的每个模块称为Bundle,可以声明它所依赖的Java Package(通过Import-Package描述),也可以声明它允许导出发布的Java Package(通过Export-Package描述);
  • 除了更精确的模块划分和可见性控制外,引入OSGI的另外一个重要理由是基于OSGI的程序很可能可以实现模块级的热插拔功能;
  • OSGI的类加载器之间只有规则,没有固定的委派关系;加载器之间的关系更为复杂、运行时才能确定的网状结构,提供灵活性的同时,可能会产生许多的隐患;

9.2.3 字节码生成技术与动态代理的实现

  • 在Java里面除了javac和字节码类库外,使用字节码生成的例子还有Web服务器中的JSP编译器、编译时植入的AOP框架和很常用的动态代理技术等,这里选择其中相对简单的动态代理来看看字节码生成技术是如何影响程序运作的;
  • 动态代理的优势在于实现了在原始类和接口还未知的时候就确定类的代理行为,可以很灵活地重用于不同的应用场景之中;
  • 以下的例子中生成的代理类“$Proxy0.class”文件可以看到代理为传入接口的每一个方法统一调用了InvocationHandler对象的invoke方法;其生成代理类的字节码大致过程其实就是根据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
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("Hello world");
}
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
// add this property to generate proxy class file
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}

9.2.4 Retrotranslator:跨越JDK版本

  • Retrotranslator的作用是将JDK 1.5编译出来的Class文件转变为可以在JDK 1.4或JDK 1.3部署的版本,它可以很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持JDK 1.5中新增的集合改进、并发包以及对泛型、注解等的反射操作;
  • JDK升级通常包括四种类型:编译器层面的做的改进、Java API的代码增强、需要再字节码中进行支持的活动以及虚拟机内部的改进,Retrotranslator只能模拟前两类,第二类通过独立类库实现,第一类则通过ASM框架直接对字节码进行处理;

9.3 实战:自己动手实现远程执行功能

  • 目标:不依赖JDK版本、不改变原有服务端程序的部署,不依赖任何第三方类库、不侵入原有程序、临时代码的执行结果能返回到客户端;
  • 思路:如何编译提交到服务器的Java代码(客户端编译好上传Class文件而不是Java代码)、如何执行编译之后的Java代码(要能访问其他类库,要能卸载)、如何收集Java代码的执行结果(在执行的类中把System.out的符号引用替换为我们准备的PrintStream的符号引用);
  • 具体实现:HotSwapClassLoader用于实现同一个类的代码可以被多次加载,通过公开父类ClassLoader的defineClass实现;HackSystem是为了替换java.lang.System,它直接修改Class文件格式的byte[]数组中的常量池部分,将常量池中指定内容的CONSTANT_Utf8_info常量替换为新的字符串;ClassModifier涉及对byte[]数组操作的部分,主要是将byte[]与int和String互相转换,以及把对byte[]数据的替换操作封装在ByteUtils类中;经过ClassModifier处理过的byte[]数组才会传给HotSwapClassLoader.loadByte方法进行类加载;而JavaClassExecutor是提供给外部调用的入口;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class JavaClassExecutor {
public static String execute(byte[] classByte) {
HackSystem.clearBuffer();
ClassModifier cm = new ClassModifier(classByte);
byte[] modifiedBytes = cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem");
HotSwapClassLoader hotSwapClassLoader = new HotSwapClassLoader();
Class clazz = hotSwapClassLoader.loadByte(modifiedBytes);
try {
Method method = clazz.getMethod("main", new Class[]{String[].class});
method.invoke(null, new String[]{null});
} catch (Throwable t) {
t.printStackTrace(HackSystem.out);
}
return HackSystem.getBufferString();
}
}

用于测试的JSP

1
2
3
4
5
6
7
8
9
10
11
12
13
<%@page import="java.lang.*" %>
<%@page import="java.io.*" %>
<%@page import="org.fenixsoft.classloading.execute.*" %>
<%
InputStream is = new FileInputStream("c:/TestClass.class");
byte[] b = new byte[is.available()];
is.read(b);
is.close();
out.println(JavaClassExecutor.execute(b));
%>

9.4 本章小结

只有了解虚拟机如何执行程序,才能更好地理解怎样写出优秀的代码。

系列读书笔记

Gino Zhang wechat
扫一扫,关注我的微信公众号