JVM性能优化系列-(3) 虚拟机执行子系统

JVM.jpg

3. 虚拟机执行子系统

3.1 Java跨平台的基础

Java刚诞生的宣传口号:一次编写,到处运行(Write Once, Run Anywhere),其中字节码是构成平台无关的基石,也是语言无关性的基础。

Java虚拟机不和包括Java在内的任何语言绑定,它只与Class文件这种特定的二进制文件格式所关联,这使得任何语言的都可以使用特定的编译器将其源码编译成Class文件,从而在虚拟机上运行。

Screen Shot 2019-12-22 at 4.41.27 PM.png

3.2 Class类的文件结构

任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,Class文件实际上它并不一定以磁盘文件的形式存在。

Class文件是一组以8位字节为基础单位的二进制流。

各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。
Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表

无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。

是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

整个class类的文件结构如下表所示:

占用大小 字段描述 数量
占用大小 字段描述 数量
u4 magic:魔数,用于标识文件类型,对于java来说是0xCAFEBABE 1
u2 minor_version:次版本号 1
u2 major_version:主版本号 1
u2 constant_pool_count:常量池大小,从1开始而不是0。当这个值为0时,表示后面没有常量 1
cp_info constant_pool:#常量池 constant_pool_count-1
u2 access_flags:访问标志,标识这个class是类还是接口、public、abstract、final等 1
u2 this_class:类索引 #类索引查找全限定名的过程 1
u2 super_class:父类索引 1
u2 interfaces_count:接口计数器 1
u2 interfaces:接口索引集合 interfaces_count
u2 fields_count:字段的数量 1
field_info fields:#字段表 fields_count
u2 methods_count:方法数量 1
method_info methods:#方法表 methods_count
u2 attributes_count:属性数量 1
attribute_info attrbutes:#属性表 attributes_count

可以使用javap -verbose输出class文件的字节码内容。

下面按顺序对这些字段进行介绍。

魔数与Class文件的版本

每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(MinorVersion),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

常量池

常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

  • 字面量:比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。
  • 符号引用:则属于编译原理方面的概念,包括了下面三类常量:
    类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。

访问标志

用于识别一些类或者接口层次的访问信息,包括:

  • 这个Class是类还是接口;
  • 是否定义为public类型;
  • 是否定义为abstract类型;
  • 如果是类的话,是否被声明为final等

类索引、父类索引与接口索引集合

这三项数据来确定这个类的继承关系。

  • 类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
  • 由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。
  • 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中

字段表集合

描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量。
而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

方法表集合

描述了方法的定义,但是方法里的Java代码,经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为“Code”的属性里面。

与字段表集合相类似的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”

属性表集合

存储Class文件、字段表、方法表都自己的属性表集合,以用于描述某些场景专有的信息。如方法的代码就存储在Code属性表中。

3.3 字节码指令

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。

由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条。

大多数的指令都包含了其操作所对应的数据类型信息。例如:
iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。

  • l代表long
  • s代表short
  • b代表byte
  • c代表char
  • f代表float
  • d代表double
  • a代表reference

大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。不是每种数据类型和每一种操作都有对应的指令,有一些单独的指令可以在必要的时候用在将一些不支持的类型转换为可被支持的类型。大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。

加载和存储命令

加载和存储指令用于将数据在帧栈中的局部变量表和操作数栈之间来回传递。

  • 将一个局部变量加载到操作栈:iload、iload<\n>、lload、lload<\n>、fload、fload<\n>、dload、dload<\n>、aload、aload_<\n>
  • 将一个数值从操作数栈存储到局部变量表:istore、istore<\n>、lstore、lstore<\n>、fstore、fstore<\n>、dstore、dstore<\n>、astore、astore_<\n>
  • 将一个参数加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconstm1、iconst<\i>、lconst、fconst<\f>、dconst_<\d>
  • 扩充局部变量表的访问索引的指令:wide

上面带尖括号的指令实际上是代表的一组指令,如iload_0、iload_1、iload_2和iload_3。这些指令把操作数隐含在名称内,不需要进行取操作数的动作。

运算指令

运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶,可分为整型数据和浮点型数据指令。byte、short、char和boolean类型的算术指令使用int类型的指令代替。

  • 加法指令:iadd、ladd、fadd、dadd
  • 减法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求余指令:irem、lrem、frem、drem
  • 取反指令:ineg、lneg、fneg、dneg
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
  • 或指令:ior、lor
  • 与指令:iand、land
  • 异或指令:ixor、lxor
  • 局部变量自增指令:iinc
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

类型转换指令

可以将两种不同的数值类型进行相互转换,

  • Java虚拟机直接支持以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):
  1. int类型到long、float或者double类型。
  2. long类型到float、double类型。
  3. float类型到double类型。
  • 处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

对象创建与访问指令

  • 创建类实例的指令:new
  • 创建数组的指令:newarray、anewarray、multianewarray
  • 访问类字段和实例字段的实例:getfield、putfield、getstatic、putstatic
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
  • 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fasotre、dastore、aastore
  • 取数组长度的指令:arraylength
  • 检查类实例类型的指令:instanceof、checkcast

操作数栈管理指令

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
  • 将栈最顶端的两个数值互换:swap

控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。控制转移指令如下。

  • 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
  • 复合条件分支:tableswitch、lookupswitch。
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret。

方法调用指令

  • invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
  • invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  • invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic指令用于调用类方法(static方法)。
  • invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
  • 方法调用指令与数据类型无关。

方法返回指令

是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)lreturnfreturndreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。

异常处理指令

在java程序中,显式抛出异常的操作都由athrow指令来实现。而在java虚拟机中,处理异常不是由字节码指令来实现的,而是采用异常表来完成的

同步指令

java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。方法级的同步是隐式的,利用方法表结构中的ACC_SYNCHRONIZED访问标志得知。指令序列的同步是由monitorenter和monitorexit两条指令支持。

3.4 类加载机制

典型面试题:类加载过程?什么是双亲委派?

这是一个非常典型的面试题,标准回答如下:

一般来说,我们把 Java 的类加载过程分为三个主要步骤:加载、链接、初始化。

1. 加载(Loading)

此阶段中Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。 加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。

2. 链接(Linking)

这是核心的步骤,简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:

  • 验证(Verification),这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。
  • 准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令。
  • 解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。

3. 初始化(initialization)

这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑

双亲委派模型:

简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。

概述

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verificatio)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)。

class lifeCycle.png

于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

关于静态变量的初始化,必须要注意以下三种情况下是不会触发类的初始化的:

  1. 只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
  2. 通过数组定义来引用类,不会触发此类的初始化。
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

下面是测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("Subclass init!");
}
}
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD_STRING = "hello world";
}

以下是对三种情况的测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NotInitialization {
public static void main(String[] args) {
// 1. 只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
// Result: SuperClass init! 123
System.out.println(SubClass.value);
// 2. 通过数组定义来引用类,不会触发此类的初始化
SuperClass[] superClasses = new SubClass[10];
// 3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
// Result: hello world
System.out.println(ConstClass.HELLOWORLD_STRING);
}
}

加载

在加载阶段,虚拟机需要完成下列3件事:

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

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面4个阶段的检验动作:

  • 文件格式验证:第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。主要目的是保证输入的字节流能正确解析并存储于方法区内,格式上符合描述一个java类型信息的要求。这个阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会存储到方法区中,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流
  • 元数据验证:第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求。主要目的是对元数据信息进行语义校验,保证不存在不符合java语言规范的元数据信息
  • 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的
  • 符号引用验证:最后一个阶段的验证发生在虚拟机符号引用转化为直接引用的时候,这个转化动作将在连接的解析阶段中发生,可以看做是对类自身以外的信息进行匹配性校验。目的是确保解析动作能正常执行

准备阶段

是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

public static int value=123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。

表7-1列出了Java中所有基本数据类型的零值:

Java的数据类型.png

假设上面类变量value的定义变为:public static final int value=123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

解析阶段

是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用是能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受地符号引用必须是一致的,因为符号引用地字面量形式明确定义在java虚拟机规范地Class文件格式中。

  • 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能直接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

初始化

类初始化是类加载过程的最后一步,在这个阶段才真正开始执行类中的字节码。初始化阶段是执行类构造器<clinit>()方法的过程。

  • <clinit>()方法与类的构造函数(<init>()方法)不同,它不需要显式调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
  • 由于父类的<clinit>()方法先执行,因此父类中定义的静态语句块要先于子类执行。
  • <clinit>()方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句块,但仍然由变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法,但与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步。

3.5 类加载器

类与类加载器

类加载器虽然只用于实现类的加载动作,但在java程序中起到的作用却远不止类加载阶段。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类命名空间。当一个Class文件被不同的类加载器加载时,加载生成的两个类必定不相等(equals()、isAssignableFrom()、isInstance()、instanceof关键字的结果为false)。

双亲委派机制

从java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用c++实现,是虚拟机的一部分;另一种是所有其他的类加载器,这些类加载器都由java实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。java提供的类加载器主要分以下三种:

  • 启动类加载器(Bootstrap ClassLoader):这个类负责将存放在\lib目录中,或者被-Xbootclasspath参数所指定的路径中的类库加载到虚拟机内存中。
  • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载\lib\ext目录中或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称为系统类加载器,负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器。

Screen Shot 2019-12-23 at 9.18.17 AM.png

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

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

自定义类加载器

首先看一下实现双亲委派模型的代码,逻辑就是先检查类是否已经被加载,如果没有则调用父加载器的loadClass()方法,如果父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

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
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 先从缓存查找该class对象,找到就不用重新加载
Class<?> c = findLoadedClass(name);
if (c == null) {
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) {
// If still not found, then invoke findClass in order
// 如果都没有找到,则通过自定义实现的findClass去查找并加载
c = findClass(name);
}
}
if (resolve) {//是否需要在加载时进行解析
resolveClass(c);
}
return c;
}
}

在实现自己的类加载器时,通常有两种做法,一种是重写loadClass方法,另一种是重写findClass方法。其实这两种方法本质上差不多,毕竟loadClass也会调用findClass,但是最好不要直接修改loadClass的内部逻辑,以免破坏双亲委派的逻辑。推荐的做法是只在findClass里重写自定义类的加载方法。

下面例子实现了文件系统类加载器,

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
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}

Class.forName和ClassLoader.loadClass

Class.forName是Class类的方法public static Class<?> forName(String className) throws ClassNotFoundException

ClassLoader.loadClass是ClassLoader类的方法public Class<?> loadClass(String name) throws ClassNotFoundException

Class.forName和ClassLoader.loadClass都可以用来进行类型加载,而在Java进行类型加载的时刻,一般会有多个ClassLoader可以使用,并可以使用多种方式进行类型加载。

1
2
3
4
5
class A {
public void m() {
A.class.getClassLoader().loadClass(“B”);
}
}

A.class.getClassLoader().loadClass(“B”);代码执行B的加载过程时,一般会有三个概念上的ClassLoader提供使用。

  • CurrentClassLoader,称之为当前类加载器,简称CCL,在代码中对应的就是类型A的类加载器。
  • SpecificClassLoader,称之为指定类加载器,简称SCL,在代码中对应的是 A.class.getClassLoader(),如果使用任意的ClassLoader进行加载,这个ClassLoader都可以称之为SCL。
  • ThreadContextClassLoader,称之为线程上下文类加载器,简称TCCL,每个线程都会拥有一个ClassLoader引用,而且可以通过Thread.currentThread().setContextClassLoader(ClassLoader classLoader)进行切换。

SCL和TCCL可以理解为在代码中使用ClassLoader的引用进行类加载,而CCL却无法获取到其引用,虽然在代码中CCL == A.class.getClassLoader() == SCL。CCL的加载过程是由JVM运行时来控制的,是无法通过Java编程来更改的。

双亲委派机制的破坏

为什么需要破坏双亲委派?

因为在某些情况下父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,这里仅仅是举了破坏双亲委派的其中一个情况。

Tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委托。

如何破坏?

  1. JDK1.2之前,classLoader类中没有定义findClass,当用户继承该类并且修改loadClass的实现时,就可能破坏双亲委派。
  2. 线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader方法进行设置。如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过多的话,那这个类加载器默认即使应用程序类加载器。有了线程上下文加载器,JNDI服务使用这个线程上下文加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则。Java中所有涉及SPI的加载动作基本胜都采用这种方式。例如JNDI,JDBC,JCE,JAXB,JBI等。
  3. 为了实现热插拔,热部署,模块化,意思是添加一个功能或减去一个功能不用重启,只需要把这模块连同类加载器一起换掉就实现了代码的热替换。OSGI实现模块化热部署的关键则是它自定义类加载器机制的实现。

Tomcat类加载器

Tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委托

Tomcat是个web容器, 那么它要解决什么问题:

  1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,这是扯淡的。
  3. web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
  4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启。

Tomcat 如果使用默认的类加载机制行不行

答案是不行的。为什么?

第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的累加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。
第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。
第三个问题和第一个问题一样。
第四个问题,我们要怎么实现jsp文件的热修改(楼主起的名字),jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

Tomcat 如何实现自己独特的类加载机制?

Screen Shot 2019-12-23 at 11.21.18 AM.png

前面3个类加载和默认的一致,CommonClassLoaderCatalinaClassLoaderSharedClassLoaderWebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*/server/*/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见;

从图中的委派关系中可以看出:

CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。

WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

下图展示了Tomcat的类加载流程:

Screen Shot 2019-12-23 at 11.28.25 AM.png

当tomcat启动时,会创建几种类加载器:

1. Bootstrap 引导类加载器

加载JVM启动所需的类,以及标准扩展类(位于jre/lib/ext下)

2. System 系统类加载器

加载tomcat启动的类,比如bootstrap.jar,通常在catalina.bat或者catalina.sh中指定。位于CATALINA_HOME/bin下。

3. Common 通用类加载器

加载tomcat使用以及应用通用的一些类,位于CATALINA_HOME/lib下,比如servlet-api.jar

4. webapp 应用类加载器

每个应用在部署后,都会创建一个唯一的类加载器。该类加载器会加载位于 WEB-INF/lib下的jar文件中的class 和 WEB-INF/classes下的class文件。

典型面试题

tomcat 违背了java 推荐的双亲委派模型了吗?

违背了,双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。

如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,该怎么办?

可以使用线程上下文类加载器实现,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。


参考:

3.6 运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。典型栈帧结构:

Screen Shot 2019-12-23 at 11.49.57 AM.png

下面对各个部分进行仔细介绍:

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指定一个Slot应占用的内存空间大小,只是规定每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这样可以屏蔽32位跟64位虚拟机在内存空间上的差异。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0到最大Slot数量,索引n对应第n个Slot。局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,即this。

为了尽可能的节省栈帧空间,局部变量表中的Slot是可以重用的,同时这也影响了垃圾收集行为。即对已使用完毕的变量,局部变量表仍持有该对象的引用,导致对象无法被GC回收,占用大量内存。这也是“不使用的对象应手动赋值为null”这条推荐编码规则的原因。不过从执行角度使用赋null值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上,代码在经过编译器优化后才是虚拟机真正需要执行的代码,这时赋null值会被消除掉,因此更优雅的解决办法是以恰当的变量作用域来控制变量回收时间。

操作数栈

操作数栈(Operand Stack)也常称操作栈,它是一个后入先出(Last In First Out,LIFO)栈。方法在执行过程中,通过各种字节码指令对栈进行操作,出栈/入栈。java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了执行方法调用过程中的动态连接(Dynamic Linking)。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令,这个时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),这种退出方式称为正常完成出口(Normal Method Invocation Completion)。

  2. 方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion),这时不会给它的上层调用者产生任何返回值。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈。
  • 把返回值(如果有)压入调用者栈帧的操作数栈。
  • 调整PC计数器的值以指向方法调用指令后面的一条指定等。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,称之为栈帧信息。

3.7 方法调用

方法调用并不等同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本,即调用哪一个方法,暂时还不涉及方法内部的具体运行过程,就是类加载过程中的类方法解析。

解析

解析就是将Class的常量池中的符号引用转化为直接引用(内存布局中的入口地址)。

在java虚拟机中提供了5条方法调用字节码指令:

  • invokestatic:调用静态方法
1
2
3
4
5
System.exit(1);
==>编译
iconst_1 ;将1放入栈内
;执行System.exit()
invokestatic java/lang/System/exit(I)V
  • invokespecial:调用实例构造器方法、私有方法和父类方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//<init>方法
new StringBuffer()
==>编译
new java/lang/StringBuffer ;创建一个StringBuffer对象
dup ;将对象弹出栈顶
;执行<init>()来初始化对象
invokespecial java/lang/StringBuffer/<init>()V
//父类方法
super.equals(x);
==>编译
aload_0 ;将this入栈
aload_1 ;将第一个参数入栈
;执行Object的equals()方法
invokespecial java/lang/Object/equals(Ljava/lang/Object;)Z
//私有方法
与父类方法类似
  • invokevirtual:调用所有的虚方法。
1
2
3
4
5
6
7
8
X x;
...
x.equals("abc");
==>编译
aload_1 ;将x入栈
ldc "abc" ;将“abc”入栈
;执行equals()方法
invokevirtual X/equals(Ljava/lang/Object;)Z
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
1
2
3
4
5
6
7
List x;
...
x.toString();
==>编译
aload_1 ;将x入栈
;执行toString()方法
invokeinterface java/util/List/toString()Z
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

在编译阶段就可以确定唯一调用版本的方法有:静态方法(类名)私有方法实例构造器(父类方法(super)final方法。其它统称为虚方法,在编译阶段无法确定调用版本,需要在运行期通过分派将符号引用转变为直接引用。

3.8 分派

静态分派

指在运行时对类内相同名称的方法根据描述符来确定执行版本的分派,多见于方法的重载。

下面的例子中,输出结果均为hello guy

“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

Picture1.png

动态分派

指对于相同方法签名的方法根据实际执行对象来确定执行版本的分派。编译器是根据引用类型来判断方法是否可执行,真正执行的是实际对象方法。多见于类多态的实现。

动态分配的实现,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。PPT图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

Screen Shot 2019-12-23 at 12.28.21 PM.png

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

Java语言经常被人们定位为“解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念。

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

基于栈的指令集:指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

基于寄存器的指令集:最典型的就是X86的地址指令集,通俗一点,就是现在我们主流的PC机中直接支持的指令集架构,这些指令集依赖寄存器工作。

举个简单例子,分别使用这两种指令计算1+1的结果,基于栈的指令集会是这个样子:

1
2
3
4
iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后将结果放回栈顶,最后istore_0把栈顶的值放到局部变量表中的第0个Slot中。

如果基于寄存器的指令集,那程序可能会是这个样子:

1
2
mov eax, 1
add eax, 1

mov指令把EAX寄存器的值设置为1,然后add指令再把这个值加1,将结果就保存在EAX寄存器里面。

基于栈的指令集:

优点:可移植、代码相对更紧凑、编译器实现更简单等
缺点:执行速度慢、完成相同功能的指令数量更多、栈位于内存中

基于寄存器的指令集:

优点:速度快
缺点:与硬件结合紧密


参考链接:


本文由『后端精进之路』原创,首发于博客 http://teckee.github.io/ , 转载请注明出处

搜索『后端精进之路』关注公众号,立刻获取最新文章和价值2000元的BATJ精品面试课程

后端精进之路.png