字节码与类的加载

Class文件

JVM基本组成-基于Hotspot虚拟机

学习的过程 - 从上到下

字节码文件的跨平台性

Java 语言

  • 当Java源代码成功编译成字节码之后,如果想在不同的平台上运行,不需要再次编译。
  • 这个优势不是很吸引人了,因为Python、PHP、Perl、Ruby、Lisp等语言都有强大的解释器
  • 跨平台已经几乎称为一门语言的必选特性

Java 虚拟机

  • Java虚拟机不和包含Java在内的任何语言进行绑定,它只和 “.class” 文件这种特定的二进制文件格式关联
  • 无论是哪一种语言,只要能够将源文件编译成正确的Class文件,那么这种语言就可以在虚拟机上运行。可以说,统一而强大的Class文件结构,就是Java虚拟机的基石和桥梁。

Class字节码的规范性

Java官方文档

  • 所有的JVM都遵循Java虚拟机规范,也就是说所有的JVM环境都是一样的,这样一来字节码文件可以在各种JVM中运行
  • 想要一个Java程序正确的运行在JVM中,Java源码就必须要被编译为符合JVM规范的字节码
  • 前端编译器的主要任务就是负责将符合Java语法规范的Java代码编译成符合JVM规范的字节码
  • javac是一个能够将Java源码编译为字节码的前端编译器
  • javac编译器将Java源代码编译为一个有效的字节码文件过程中经历了4个步骤,分别是:词法解析、语法解析、语义解析以及生成字节码

  • Oracle 发布的JDK软件包括2部分内容
    • 一部分是将Java源码编译成Java虚拟机的指令集的编译器
    • 一部分是用于实现Java虚拟机的运行时环境

Java的前端编译器

  • javac 就是JDK提供的前端编译器

  • Hotspot VM没有强制要求前端编译器只能使用javac来实现编译字节码,其实只要编译结果符合JVM规范即可被JVM识别

  • 在Java的前端编译器领域,除了javac之外,还有一种经常使用的前端编译器,那就是Eclipse中的ECJ(Eclipse Compiler for Java)编译器 ,和javac不同的是,ECJ是一种增量编译器

  • 在Eclipse中,当开发人员编写完代码之后,使用 “ctrl + s”,保存的时候,ECJ采用的是局部编译,也就是把没有编译的地方重编译,而非每次都全量编译

  • ECJ不仅仅是Eclipse的默认内置前端编译器,Tomcat中也是使用ECJ进行编译JSP文件

  • 默认情况下,IDEA还是使用的是javac编译器

透过字节码指令看代码执行细节举例

BAT面试题

  • 类文件结构有几个部分?
  • 知道字节码吗?字节码都有哪些? Integer x = 5; int y = 5; 比较 x == y 都有哪些步骤?(class是字节码文件)

代码举例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class IntegerTest {
public static void main(String[] args) {
Integer x = 5;
int y = 5;

System.out.println(x == y);

Integer i1 = 10;
Integer i2 = 10;
System.out.println(i1 == i2);


Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);

}
}

代码举例2

1
2
3
4
5
6
7
8
9
public class StringTest {
public static void main(String[] args) {
String str = new String("hello") +
new String("world");
String str2 = "helloword";
System.out.println(str2 == str);

}
}

代码举例3

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
/**
* 非静态成员变量的初始化过程:
* 1. 默认初始化
* 2. 显示初始化 / 代码块中初始化
* 3. 构造器初始化
* 4. 有了对象之后,可以 对象.setter方法 或者 对象.属性的方式赋值
*
*/
class Father {
int x = 10;

public Father() {
this.print();
x = 20;
}

public void print() {
System.out.println("Father.x = " + x);
}
}

class Son extends Father {
int x = 30;

public Son() {
this.print();
x = 40;
}

public void print() {
System.out.println("Son.x = " + x);
}
}

public class SonTest {
public static void main(String[] args) {
Father f = new Son();
System.out.println(f.x);
}
}

/**
* Son.x = 0
* Son.x = 30
* 20
*/

Class文件到底存储的是什么

  • 源代码经过编译器编译之后就会生成字节码文件,字节码是一种二进制的类文件。它的内容是JVM指令,而不像C、C++经由编译器直接生成机器码

什么是字节码指令(Byte Code)

  • Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随器后的零至多个代表此操作数的所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码,如下:操作码+操作数

  • 如何解读供虚拟机执行的二进制字节码?

  • 解读Class文件的三种方式

    • 方式一:一个一个二进制的看,使用Binary Viewer
    • 方式二:使用idea安装 jclasslib bytecode viewer 插件实现阅读
    • 方式三:安装Jclasslibib客户端
    • 方式四:使用自带的javap指令

Class文件本质和内部数据类型

  • 官方文档地址: https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
  • Class类的本质:任何一个Class文件都对应着唯一一个类或者接口的定义信息,但是反过来说,Class文件实际上它并不一定以磁盘文件的形式存在,Class文件是一组8字节为基础单位的二进制流
  • Class文件的格式:Class不像XML等描述语言,由于它没有任何分隔符号。所以存在其中的数据项,无论是字节顺序还是数量,都是被严格限定的,哪个字节代表啥意思,长度是多少,先后顺序如何,都不允许改变
  • Class文件格式采用类似C语言结构体的方式进行存储数据,这种结构只有两种数据类型:无符号数
    • 无符号数属于基本的数据类型,以u1、u2、u4、u8 来分别代表1个字节、2个字节、4个字节、和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成的字符串值
    • 表是由多个无符号数或者其他表作为数据项构成的符合数据类型,所有表都习惯性以”_info” 结尾,用于描述有层次关系的符合结构的数据,整个Class文件本质上就是一张表。由于表没有固定长度,所以通常会在前面加个数说明

Class文件的结构

  • Class文件的结构并不是一成不变的,随着Java虚拟机的不断发展,总是不可避免的对Class文件做出一些调整,但是其基本结构和框架是非常稳定的
  • Class文件的总体结构如下
    • 魔数
    • Class文件版本
    • 常量池
    • 访问标志
    • 类索引、父类索引、接口索引集合
    • 字段表集合
    • 方法表集合
    • 属性表集合
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
  • 举例
1
2
3
4
5
6
7
8
9
// Java文件
public class Demo {
private int num = 1;

public int add() {
num = num + 2;
return num;
}
}

Demo字节码的解析(非标识版本)Excel版本

Demo字节码的解析(标识版本)Excel版本

1
2
3
4
5
6
7
8
9
10
11
12
13
// 字节码文件
public class Demo {
private int num = 1;

public Demo() {
}

public int add() {
this.num += 2;
return this.num;
}
}

Class字节码文件结构

类型 名称 说明 长度 数量
u4 magic 魔数,识别Class文件格式 4个字节 1
u2 minor_version 副版本号(小版本) 2个字节 1
u2 major_version 主版本号(大版本) 2个字节 1
u2 constant_pool_count 常量池计数器 2个字节 1
cp_info constant_pool 常量池表 n个字节 constant_pool_count-1
u2 access_flags 访问标识 2个字节 1
u2 this_class 类索引 2个字节 1
u2 super_class 父类索引 2个字节 1
u2 interfaces_count 接口计数器 2个字节 1
u2 interfaces 接口索引集合 2个字节 interfaces_count
u2 fields_count 字段计数器 2个字节 1
field_info fields 字段表 n个字节 fields_count
u2 methods_count 方法计数器 2个字节 1
method_info methods 方法表 n个字节 methods_count
u2 attributes_count 属性计数器 2个字节 1
attribute_info attributes 属性表 n个字节 attributes_count

Class文件版本号和平台的对应

主版本(十进制) 副版本(十进制) 编译器版本
45 3 1.1
46 0 1.2
47 0 1.3
48 0 1.4
49 0 1.5
50 0 1.6
51 0 1.7
52 0 1.8
53 0 1.9
54 0 1.10
55 0 1.11

Class文件数据类型

数据类型 定义 说明
无符号数 无符号数可以用来描述数字、索引引用、数量值或按照utf-8编码构成的字符串值。 其中无符号数属于基本的数据类型。 以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节
表是由多个无符号数或其他表构成的复合数据结构。 所有的表都以“_info”结尾。 由于表没有固定长度,所以通常会在其前面加上个数说明。

类型描述符

标志符 含义
B 基本数据类型byte
C 基本数据类型char
D 基本数据类型double
F 基本数据类型float
I 基本数据类型int
J 基本数据类型long
S 基本数据类型short
Z 基本数据类型boolean
V 代表void类型
L 对象类型,比如:Ljava/lang/Object;
[ 数组类型,代表一维数组。比如:double[][][] is [[[D

常量类型和结构

类型 标志(或标识) 描述
CONSTANT_utf8_info 1 UTF-8编码的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的符号引用
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_MethodType_info 16 标志方法类型
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点

常量类型和结构细节

常量类型和结构源文件

访问标志

标志名称 标志值 含义
ACC_PUBLIC 0x0001 标志为public类型
ACC_FINAL 0x0010 标志被声明为final,只有类可以设置
ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
ACC_INTERFACE 0x0200 标志这是一个接口
ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
ACC_ANNOTATION 0x2000 标志这是一个注解
ACC_ENUM 0x4000 标志这是一个枚举

字段表访问标志

标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否为public
ACC_PRIVATE 0x0002 字段是否为private
ACC_PROTECTED 0x0004 字段是否为protected
ACC_STATIC 0x0008 字段是否为static
ACC_FINAL 0x0010 字段是否为final
ACC_VOLATILE 0x0040 字段是否为volatile
ACC_TRANSTENT 0x0080 字段是否为transient
ACC_SYNCHETIC 0x1000 字段是否为由编译器自动产生
ACC_ENUM 0x4000 字段是否为enum

类索引、父类索引、接口索引

长度 含义
u2 this_class
u2 super_class
u2 interfaces_count
u2 interfaces[interfaces_count]

属性的通用格式

类型 名称 数量 含义
u2 attribute_name_index 1 属性名索引
u4 attribute_length 1 属性长度
u1 info attribute_length 属性表

数据类型和默认初始值对应

类型 默认初始值
byte (byte)0
short (short)0
int 0
long 0L
float 0.0f
double 0.0
char \u0000
boolean false
reference null

分别解释其作用

魔数 Magic Number

  • 每个Class文件开头4个字节的无符号整数称为魔数(Magic Number)
  • 它的唯一作用就是确定这个文件能否作为一个被虚拟机接受的有效合法的Class文件,也就是说:魔数是Class文件的标识符
  • 魔数值固定为:0xCAFEBABE 不会改变
  • 如果一个Class文件不是以 0xCAFEBABE 开头,虚拟机会抛出以下错误
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java.lang.ClassFormatError: Incompatible magic value 2026569402 in class file com/xiao/learning/jvm/chapter18/Demo
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main"
Process finished with exit code 1

  • 使用魔数而不是拓展名来进行识别主要是基于安全方面的考虑,因为拓展名可以被随便的改动

Class文件的版本号

  • 紧接着魔数的4个字节存储的是Class文件的版本号。同样也是4个字节。第5个和第6个字节所代表的含义是编译的副版本号 minor_version ,而第7个和第8个字节就是编译的主版本号 major_version
  • 它们共同构成了Class文件的格式版本号,比如某个Class文件的主版本号为M。副本版号为m,那么这个Class文件的格式版本号就确定为M.m
  • 版本号和Java编译器的关系在下表
主版本(十进制) 副版本(十进制) 编译器版本
45 3 1.1
46 0 1.2
47 0 1.3
48 0 1.4
49 0 1.5
50 0 1.6
51 0 1.7
52 0 1.8
53 0 1.9
54 0 1.10
55 0 1.11
  • Java的版本号是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号向上加1
  • 不同版本的Java编译器的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虚拟机不能执行由高版本编译器生成的Class文件,否则JVM会抛出java.lang.UnsupportedClassVersionError异常
  • java.lang.UnsupportedClassVersionError: cn/icanci/java/StringTest : Unsupported major.minor version 52.0
  • 在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生,因此,需要在开发的时候,注意统一版本号
  • Java虚拟机JDK版本为1.k(k>=2)时,对应的Class文件格式版本号范围为 45.0 ~ 44+k.0 (包含2端)

常量池

  • 常量池是Class文件中最为丰富的区域之一。常量池对于Class文件中的字段和方法解析也有着至关重要的作用
  • 随着Java虚拟机的不断发展,常量池的内容也日渐丰富。可以说,常量池是整个Class文件的基石

  • 在版本号之后,紧跟着的就是常量池的数量,以及若干个常量池的池表项

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

  • 由上表可知,Class文件使用了一个前置的容量计数器(constant_pool_count-1)加若干个连续的数据项(constant_pool)的形式来描述常量池的内容。我们把这一系列连续常量池称为常量池集合

  • 常量池表项中,用于存放编译时期生成的各种字面量符合引用,这部分内容将类加载后进入方法区的运行时常量池中进行存放

    类型 标志(或标识) 描述
    CONSTANT_utf8_info 1 UTF-8编码的字符串
    CONSTANT_Integer_info 3 整型字面量
    CONSTANT_Float_info 4 浮点型字面量
    CONSTANT_Long_info 5 长整型字面量
    CONSTANT_Double_info 6 双精度浮点型字面量
    CONSTANT_Class_info 7 类或接口的符号引用
    CONSTANT_String_info 8 字符串类型字面量
    CONSTANT_Fieldref_info 9 字段的符号引用
    CONSTANT_Methodref_info 10 类中方法的符号引用
    CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
    CONSTANT_NameAndType_info 12 字段或方法的符号引用
    CONSTANT_MethodHandle_info 15 表示方法句柄
    CONSTANT_MethodType_info 16 标志方法类型
    CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点

常量池计数器

  • constant_pool_count (常量池计数器)
  • 由于常量池的数量的不固定,时长时短,所以需要放置两个字节来标识常量池容量的计数值
  • 常量池容量计数值(u2类型),从1开始,表示常量池中有多少项常量,即constant_pool_count=1 表示常量池中有0个常量项
  • Demo的值为:

  • 其值为0x0016,也就是22
  • 需要注意的是,这实际上只有21项常量,索引范围为1-21.为什么?
  • 通常我们写代码都是从0开始的,但是这里常量池从1开始,是因为它把第0项常量池空出来了 ,这是为了满足后面某些指向常量池的索引值的数据在指定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况下可以用索引值0来表示

常量池表

  • constant_pool 是一种表结构,以 1~constant_pool_count-1 为索引,表明了后面又多少个常量项

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

  • 它包含了Class文件结构以及其子结构中引用的所有的字符串常量、类或者接口名、字段名和其他常量。常量池中的每一项都具备相同的特征。第1个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte (标记字节、标签字节)

    类型 标志(或标识) 描述
    CONSTANT_utf8_info 1 UTF-8编码的字符串
    CONSTANT_Integer_info 3 整型字面量
    CONSTANT_Float_info 4 浮点型字面量
    CONSTANT_Long_info 5 长整型字面量
    CONSTANT_Double_info 6 双精度浮点型字面量
    CONSTANT_Class_info 7 类或接口的符号引用
    CONSTANT_String_info 8 字符串类型字面量
    CONSTANT_Fieldref_info 9 字段的符号引用
    CONSTANT_Methodref_info 10 类中方法的符号引用
    CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
    CONSTANT_NameAndType_info 12 字段或方法的符号引用
    CONSTANT_MethodHandle_info 15 表示方法句柄
    CONSTANT_MethodType_info 16 标志方法类型
    CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点
  • 15、16、18 是JDK7出现的,表示支持动态调用

字面量和符号引用

  • 在对常量进行解读之前,我们需要搞清楚几个概念

  • 常量池主要存放两大类变量:字面量(Literal)和符号引用(Symbolic References)如下表

    常量 具体的常量
    字面量 文本字符串
    声明为final的常量值
    符号引用 类和接口的全限定名
    字段的名称和描述符
    方法的名称和描述符
  • 全限定类名 com/xiao/learning/jvm/Demo 这个就是类的全限定名,仅仅是把包名的”.”替换为”/“,为了使得连续的多个全限定名之间不产生混淆,在使用时候最后一般会加一个”;”,表示全限定名结束

  • 简单名称 简单名称指的是没有类型和参数修饰的方法或者字段名称,上面例子中的add() 方法和num字段的简单名称都是add和num

  • 描述符 描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。 根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示 (数据类型:基本数据类型、引用数据类型),详见下表:

标志符 含义
B 基本数据类型byte
C 基本数据类型char
D 基本数据类型double
F 基本数据类型float
I 基本数据类型int
J 基本数据类型long
S 基本数据类型short
Z 基本数据类型boolean
V 代表void类型
L 对象类型,比如:Ljava/lang/Object;
[ 数组类型,代表一维数组。比如:double[][][] is [[[D
  • 用描述符描述方法的时候,按照先参数列表,后返回值的顺序描述,参数列表按照参数列表的严格顺序放在一组小括号“()”里面,如方法 java.lang.String.toString() 的描述符为 ()Ljava/lang/String; 方法int adc(int[] x ,int y) 的描述符为([II]) I
  • 补充说明
    • 虚拟机在加载Class文件的才会进行动态连接,也就是说,Class文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换时无法被虚拟机使用的。当虚拟机运行的时候,需要从常量池中获得对应的符号引用,再在类加载的过程中的解析阶段,将其替换为直接引用,并翻译到具体的内存地址中去
    • 符号引用和直接引用的区别和关联
      • 符号引用:符号引用以 一组符号 ** 来描述所引用目标,符号可以时任何形式的字面量,只要使用时能无歧义的定位到目标即可,符号引用与虚拟机的内存布局无关**,引用的目标不一定已经加载到了内存中
      • 直接引用:直接引用可以是直接 指向目标的指针、相对偏移量或者是一个能直接定位到目标的句柄,直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那就说明引用的目标必定已经存在与内存中了

解析常量池中所有的数据

总结

  • 这14种表(或者常量项结构)的共同特点是:表一开始的第一位是一个u1类型的标志位(tag),代表当前这个常量项使用的是哪种结构,即哪种常量类型
  • 在常量池列表种,CONSTANT_Utf8_info 常量项是一种改进过的UTF-8编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息
  • 这14个常量结构还有一个特点是,其中13个常量项占用的字节固定,只有CONSTANT_Utf8_info 占用字节不固定,其大小由length决定,为什么呢?因为常量池存储的内容可知,其存放的是字面量和符号引用,最终这些内容都是会是一个字符串,这些字符串的大小是在编写程序时才确定,比如定义一个类,类名可以取长取短,所以在没有编译之前,大小不固定,编译之后,通过utf-8编码,就可以知道其长度。

  • 常量池:可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面很多数据类型都会指向此处,也是占用Class文件空间最大的数据项目之一)
  • 常量池中为什么要包含这些内容:Java代码在进行javac编译的时候,并不像C、C++那样有连接这一步骤,而是在虚拟机加载Class文件的时候进行动态链接。也就是说,在Class文件中不会保存各个方法,字段的最终内存信息,因此这些字段、方法的符号引用不经过运行期间转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。 当虚拟机运行的时候,需要从常量池中获得对应的符号引用,再在类创建的时候或者运行时候解析、翻译到具体的内存地址中。关于类的创建和动态链接内容,在虚拟机类加载过程中再进行详细解释。

访问标识

  • 访问标识(access_flag、访问标记、访问标志)
  • 在常量池后,紧跟着访问标记,该标记使用两个字节标识,用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型,是否声明为 fianl等,各种访问标记如下:
标志名称 标志值 含义
ACC_PUBLIC 0x0001 标志为public类型
ACC_FINAL 0x0010 标志被声明为final,只有类可以设置
ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
ACC_INTERFACE 0x0200 标志这是一个接口
ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
ACC_ANNOTATION 0x2000 标志这是一个注解
ACC_ENUM 0x4000 标志这是一个枚举
  • 类的访问权限通常以 ACC_ 开头的常量

  • 每一种类型都表示通过设置访问标志的32位中的特定位来实现的,比如:如果是 public final 的类,则该标记为 ACC_PUBLIC | ACC_FINAL

  • 使用ACC_SUPER 可以让类更准确的定位到父类的方法 super.method(),现代编译器都会设置并且使用这个标记

    Flag Name Value Interpretation
    ACC_PUBLIC 0x0001 Declared public; may be accessed from outside its package.
    ACC_FINAL 0x0010 Declared final; no subclasses allowed.
    ACC_SUPER 0x0020 Treat superclass methods specially when invoked by the invokespecial instruction.
    ACC_INTERFACE 0x0200 Is an interface, not a class.
    ACC_ABSTRACT 0x0400 Declared abstract; must not be instantiated.
    ACC_SYNTHETIC 0x1000 Declared synthetic; not present in the source code.
    ACC_ANNOTATION 0x2000 Declared as an annotation type.
    ACC_ENUM 0x4000 Declared as an enum type.
  • 带有ACC_INTERFACE标志的class文件表示的是接口而不是类,反之则表示的是类而不是接口

    • 如果有一个class文件被设置了 ACC_INTERFACE 标志,那么同时也得设置 ACC_ABSTRACT 标志,同时它不能再设置为 ACC_FINAL、ACC_SUPER、ACC_ENUM 标志
    • 如果没有设置ACC_INTERFACE标志,那么这个class文件可以具有上表除了ACC_ANNOTATION 外的其他所有标志,当然ACC_FINAL 和 ACC_ABSTRACT 着类互斥的标志除外,这两个标志不得同时设置
  • ACC_SUPER 标志用于确定类或者接口里面的 invokespecial 指令使用的是哪一种执行语义。针对Java虚拟机指令集的编译器都应该设置这个标志。对于JavaSE 8 以及后续版本来说,无论Class文件中这个类的标志的实际值是什么,也不管Class文件的版本号是多少,JVM为每个Class文件都设置了ACC_SUPER标志

  • ACC_SUPER 标志是为了向后兼容的由旧的Java编译器所编译的代码而设计的。目前的ACC_SUPER标志是由JDK1.0.2 之前的编译器生成的 access_flags 中是没有确定含义的,如果设置了该标志,那么 Oracle 的Java虚拟机实现会将其忽略

  • ACC_SYNTHETIC 标志意味着该类或者该接口是由编译器生成的,而不是由源代码生成的

  • 注解类型必须设置 ACC_ANNOTATION 标志,如果设置了ACC_ANNOTATION标志,那么也必须设置ACC_INTERFACE标志

  • ACC_ENUM标志表名该类或者父类为枚举类型

  • 表中没有使用access_flags 标志是为未来拓展而预留的,预留的标记在编译器中设置为0,Java虚拟机实现也应该忽略他们。

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

  • 在访问标记后,会指定该类的类别、父类类别以及实现的接口,格式如下:

    长度 含义
    u2 this_class
    u2 super_class
    u2 interfaces_count
    u2 interfaces[interfaces_count]
  • 这三项数据来确定这个类的继承关系

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

    • 2字节无符号整数,指向常量池的索引。它提供了类的全限定名,如 cn/icanci/java/Demo。this_class 的值必须是对常量池中的某项的一个有效的索引值。常量池在这个索引处的成员必须是 CONSTANT_Class_info 类结构体,该结构体表示这个class文件所定义的类或者接口。
  • super_class (父类索引)

    • 2字节无符号整数,指向常量池的索引,它提供了当前类的父类全限定名。如果没有继承任何类,其默认继承的就是java/lang/Object 类,同时,由于Java不支持多继承,所以其父类只有一个。
    • super_class 指向的父类不能为final
  • interfaces

    • 指向常量池索引集合,它提供了一个符号引用到所有已经实现的接口
    • 由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class (当然这里就必须是接口,不能是类)
  • interfaces_count (接口计数器)

    • interfaces_count 项的值表示当前类或者接口的直接接口的数量
  • interfaces[interfaces_count](接口索引集合)

    • interfaces [] 中每个成员的值必须是常量池表中某项有效的索引值,它的长度为interfaces_count,每个成员interfaces[i] 必须为CONSTANT_Class_info 结构,其中 0<=i < interfaces_count
    • 在interfaces[] 中,各个成员所表示的接口顺序和对应的源代码中给定的接口顺序是一致的(从左到右),也就是说 interfaces[0] 对应的是源代码中最左边的接口。

字段表集合

  • fields
    • 用于描述接口或者类中声明的变量。字段(field)包括类级别变量和实例级变量,但是不包括方法内部、代码块内部声明的局部变量
    • 字段叫什么名字、字段定义的数据类型,这些都是无法固定的,只能引用常量池中的常量来描述
    • 它指向常量池索引几个,它描述了每个字段的完整信息。比如字段的标识符、访问修饰符(public 、private、protected)、是类变量还是实例变量(static修饰符)、是否是常量(final修饰)
    • 注意事项
      • 字段表集合中不会列出对外部类或者实现的接口中继承而来的字段,但有可能列出原本Java代码中不存在的字段。比如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段
      • 在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是不是相同。都必须使用不一样的名称,但是对于字节码来说,如果两个字段的描述符不一致,那么字段重名就是合法的。

字段计数器

  • fields_count (字段计数器)
    • fields_count 的值表示当前class文件fields表的成员个数。使用2个字节表示
    • fields表中的每一个成员都是一个 field_info 结构,用于表示该类或者接口所声明的所有类或者实例字段,不包括方法内部声明的变量,也不包括从父类或者父接口继承的那些字段

字段表

  • fields[] 字段表

    • fields 表中的每个成员都必须是一个 fields_info 结构的数据项,用于表示当前类或者接口中某个字段的完整描述

    • 一个字段的信息包括如下这些信息。在这些信息中,各个修饰符都是布尔值,要么有,要么没有

      • 作用域(public、private、proteced修饰符)
      • 是实例变量还是类变量(static修饰符)
      • 可变性(final)
      • 并发可见性(volatile修饰符,是否强制从主内存读写)
      • 可否序列化(transient 修饰符)
      • 字段数据类型(基本数据类型、对象、数组)
      • 字段名称
    • 字段表结构:字段作为一个表,有其自己的结构

      类型 名称 含义 数量
      u2 access_flags 访问标志 1
      u2 name_index 字段名索引 1
      u2 descriptor_index 描述符索引 1
      u2 attributes_count 属性计数器 1
      attribute_info attributes 属性集合 attributes_count
    • 字段表访问标识:一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected)、static修饰符、final修饰符、volatile修饰符。因此,可以像类一样,使用一些标记来标记字段。字段的访问标记有如下这些:

      标志名称 标志值 含义
      ACC_PUBLIC 0x0001 字符是否为public
      ACC_PRIVATE 0x0002 字符是否为private
      ACC_PROTECTED 0x0004 字符是否为protected
      ACC_STATIC 0x0008 字符是否为static
      ACC_FINAL 0x0010 字符是否为final
      ACC_VOLATILE 0x0040 字符是否为volatile
      ACC_TRANSIENT 0x0080 字符是否为transient
      ACC_SYNTHETIC 0x1000 字段是否为由编辑器自动产生的
      ACC_ENUM 0x4000 字符是否为enum.
    • 字段名索引:根据字段名索引的值,查询常量池中指定索引项即可

    • 描述符索引: 描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。 根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示 (数据类型:基本数据类型、引用数据类型),详见下表:

      标志符 含义
      B 基本数据类型byte
      C 基本数据类型char
      D 基本数据类型double
      F 基本数据类型float
      I 基本数据类型int
      J 基本数据类型long
      S 基本数据类型short
      Z 基本数据类型boolean
      V 代表void类型
      L 对象类型,比如:Ljava/lang/Object;
      [ 数组类型,代表一维数组。比如:double[][][] is [[[D
    • 属性表集合:一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值、一些注释信息等。属性个数存在attribute_count 中,属性具体内容存放在 attributes数组中。

    • 对于常量属性而言,attribute_length 值恒为2

方法表集合

  • methods:指向常量池索引集合,它完整描述了每个方法的签名
    • 在字节码文件中,每一个method_info 项都对应着一个类或者接口中的方法信息。比如方法的访问修饰符(public、private、protected),方法的返回值类型以及方法的参数信息等
    • 如果这个方法不是抽象的或者不是native的,那么字节码中会体现出来
    • 一方面,methods表只描述当前类火接口中声明的方法,不包括从父类或者父接口继承的方法。另一方面,methods表可能会出现由编译器自动添加的方法,最典型的就是编译器产生的方法信息(比如:类(接口) 初始化方法<client> 和实例初始化方法 <init>()
  • 使用注意事项
    • 在 Java 语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。
    • 但在 Class 文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个 Class 文件中。
    • 也就是说,尽管 Java 语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和 Java 语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同

方法计数器

  • methods_count (方法计数器)
    • methods_count 的值表示当前class文件的methods表的成员个数。使用两个字节表示
    • methods 表中每个成员都是一个method_info 结构

方法表

  • methods 表中的每个成员都必须是一个 method_info 结构,用于表示当前类或接口中某个方法的完整描述。如果某个 method_info 结构的 access_flags 项既没有设置 ACC_NATIVE 标志也没有设置 ACC_ABSTRACT 标志,那么该结构中也应包含实现这个方法所有的 Java 虚拟机指令

  • method_info 结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法

  • 方法表的结构实际跟字段表是一样的,方法表结构如下

    类型 名称 含义 数量
    u2 access_flags 访问标志 1
    u2 name_index 字段名索引 1
    u2 descriptor_index 描述符索引 1
    u2 attributes_count 属性计数器 1
    attribute_info attributes 属性集合 attributes_count
  • 方法表访问标志: 跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:

    标志名称 标志值 说明
    ACC_PUBLIC 0x0001 public,方法可以从包外访问
    ACC_PRIVATE 0x0002 private,方法只能本类中访问
    ACC_PROTECTED 0x0004 protected,方法在自身和子类可以访问
    ACC_STATIC 0x0008 static,静态方法

属性表集合

  • 方法表集合之后的属性表集合,指的是 Class 文件所携带的辅助信息,比如该 Class 文件的源文件的名称。以及任何带有 RetentionPolicy.CLASS 或者 RetentionPolicy.RUNTIME 的注解。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试,一般无需深入了解

  • 此外,字段表、方法表都可以有自己的属性表。用于描述某些场景专有的信息

  • 属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但 Java 虚拟机运行时会忽略掉它不认识的属性

attributes_count(属性计数器)

  • attributes_count 的值表示当前 Class 文件属性表的成员个数。属性表中每一项都是一个 attribute_info 结构

attributes[] (属性表)

  • 属性表的每个项的值必须是 attribute_info 结构。属性表的结构比较灵活,各种不同的属性只要满足以下结构即可

  • 属性的通用格式

    类型 名称 数量 含义
    u2 attribute_name_index 1 属性名索引
    u4 attribute_length 1 属性长度
    u1 info attribute_length 属性表
  • 即只需说明属性的名称以及占用位数的长度即可,属性表具体的结构可以去自定义

  • 属性类型

  • 属性表实际上可以有很多类型,上面看到的 Code 属性只是其中一种, Java 8 里面定义了23种属性,具体点击此处,下面这些是虚拟机中预定义的属性:

    属性名称 属性位置 含义
    Code 方法表 Java代码编译成的字节码指令
    ConstantValue 字段表 final关键字定义的常量池
    Deprecated 类、方法、字段表 被声明为deprecated的方法和字段
    Exceptions 方法表 方法抛出的异常
    InnerClasses 类文件 内部类列表
    LineNumberTable Code属性 Java源代码的行号与字节码指令对应的关系
    LocalVariableTable Code属性 方法的局部变量描述
    StackMapTable Code属性 JDK1.6中新增的属性,供新的类型检测检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配
    Signature 类、方法、字段表 用于支持泛型情况下的方法签名
    SourceFile 类文件 记录源文件的名称
    SourceDebugExtension 类文件 用于存储额外的调试信息
    Synthetic 类、方法、字段表 标志方法或字段为编辑器自动生成的
    LocalVariableTypeTable 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
    RuntimeVisibleAnnotations 类、方法、字段表 为动态注解提供支持
    RuntimeInvisibleAnnotations 类、方法、字段表 用于指明哪些注解运行时是不可见的

部分属性详解

  • ConstantValue 属性

  • ConstantValue 属性表示一个常量字段的值。位于 field_info 结构的属性表中

  • ```java
    ConstantValue_attribute {

    u2 attribute_name_index;
    u4 attribute_length;
    u2 constantvalue_index; //字段值在常量池中的索引,常量池在该索引处的项给出该属性表示的常量值。(例如,值是 long 型的,在常量池中便是 CONSTANT_Long)
    

    }

    1
    2
    3
    4
    5
    6
    7
    8

    - Deprecated 属性

    - ```java
    Deprecated_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    }
  • Code 属性

  • Code 属性就是存放方法体里面的代码,但是,并非所有方法表都有 Code 属性,像接口或者抽象方法,他们没有具体的方法体,因此也就不会有 Code 属性了

  • Code 属性表的结构,如下:

    类型 名称 数量 含义
    u2 attribute_name_index 1 属性索引名
    u4 attribute_index 1 属性长度
    u2 max_stack 1 操作数栈深度的最大值
    u2 max_locals 1 局部变量表所需的存续空间
    u4 code_length 1 字节码指令的长度
    u1 code code_length 存储字节码指令
    u2 exception_talbe_length 1 异常表长度
    exception_info exception_talbe exception_length 异常表
    u2 attributes_count 1 属性集合计数器
    attribute_info attributes attributes_count 属性集合
  • 可以看到:Code 属性表的前两项跟属性表是一致的,即 Code 属性表遵循属性表的结构,后面那些则是他自定义的结构

  • InnerClasses 属性

  • 为了方便说明特别定义一个表示类或接口的 Class 格式为 C。如果 C 的常量池中包含某个 CONSTANT_Class_info 成员,且这个成员所表示的类或接口不属于任何一个包,那么 C 的 ClassFile 结构的属性表中就必须含有对应的 InnerClasses 属性。InnerClasses 属性是在 JDK 1.1 中为了支持内部类和内部接口而引入的,位于 ClassFile 结构的属性表

  • LineNumberTable 属性

  • LineNumberTable 属性是可选变长属性,位于 Code 结构的属性表

  • LineNumberTable 属性是用来描述 Java 源码行号与字节码行号之间的对应关系,这个属性可以用来在调试的时候定位代码执行的行数

    • start_pc,即字节码行号;line_number,即 Java 源代码行号
  • 在 Code 属性的属性表中,LineNumberTable 属性可以按照任意顺序出现,此外,多个 LineNumberTable 属性可以共同表示一个行号在源文件中表示的内容,即 LineNumberTable 属性不需要与源文件的行一一对应

  • LineNumberTable 属性表结构:

1
2
3
4
5
6
7
8
9
LineNumberTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{
u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
  • LocalVariableTable 属性

  • LocalVariableTable 是可选变长属性,位于 Code 属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息。在 Code 属性的属性中,LocalVariableTable 属性可以按照任意顺序出现。Code 属性中的每个局部变量最多只能有一个 LocalVariableTable 属性。

    • start_pc + length 表示这个变量在字节码中的生命周期起始和结束的偏移位置(this 生命周期从头0到结尾10)
    • index 就是这个变量在局部变量表中的槽位(槽位可复用)
    • name 就是变量名称
    • Descriptor 表示局部变量类型描述
  • LocalVariableTable 属性表结构:

1
2
3
4
5
6
7
8
9
10
11
12
LocalVariableTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{
u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}
  • Signature 属性

  • Signature 属性是可选的定长属性,位于 ClassFile,field_info 或 method_info 结构的属性表中。在 Java 语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则 Signature 属性会为它记录泛型签名信息

  • SourceFile 属性

  • SourceFile 属性结构

    类型 名称 数量 含义
    u2 attribute_name_index 1 属性名索引
    u4 attribute_length 1 属性长度
    u2 sourcefile_index 1 源码文件索引
  • 可以看到,其长度总是固定的8个字节

  • 其他属性

  • Java 虚拟机中预定义的属性有20多个,这里就不一一介绍了,通过上面几个属性的介绍,只要领会其精髓,其他属性的解读也是易如反掌

小结

  • 本章主要介绍了 Class 文件的基本格式

  • 随着 Java 平台的不断发展,在将来,Class 文件的内容也一定会做进一步的扩充,但是其基本的格式和结构不会做重大调整

  • 从 Java 虚拟机的角度看,通过 Class 文件,可以让更多的计算机语言支持 Java 虚拟机平台。因此,Class 文件结构不仅仅是 Java 虚拟机的执行入口,更是 Java 生态圈的基础和核心

使用 javap 指令解析 Class 文件

  • 自己分析类文件结构太麻烦了!Oracle 提供了 javap 工具

  • 当然这些信息中,有些信息(如本地变量表、指令和代码行偏移量映射表、常量池中方法的参数名称等等)需要在使用 javac 编译成 Class 文件时,指定参数才能输出,比如,你直接 javac xx.java,就不会再生成对应的局部变量表等信息,如果你使用 javac -g xx.java 就可以生成所有相关信息了。如果你使用的是 Eclipse,则默认情况下,Eclipse 在编译时会帮你生成局部变量表、指令和代码行盘一辆映射表等信息

  • 通过反编译生成的汇编代码,我们可以深入的了解 Java 代码的工作机制。比如我们看到的 i++,这行代码实际运行时是先获取变量 i 的值,然后将这个值加1,最后再将加1后的值赋值给变量 i

解析字节码的作用

  • 通过反编译生成的字节码文件,我们可以深入的了解 Java 代码的工作机制。但是,自己分析类文件结构太麻烦了,除了使用第三方的 jclasslib 工具之外,Oracle 官方也提供了工具:javap

  • javap 是 JDK 自带的反解析工具。它的作用就是根据 Class 字节码文件,反解析出当前类对应的 Code 区(字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息

  • 通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以看到槽位复用等信息

javac -g 操作

  • 解析字节码文件得到的信息中,有些信息(如局部变量表、指令和代码行偏移量映射表、常量池中方法的参数名称等等)需要在使用 javac 编译成 Class 文件时,指定参数才能输出

  • 比如,你直接 javac xx.java,就不会在生成对应的局部变量表等信息,如果你使用 javac -g xx.java 就可以生成所有相关信息了。如果你使用的 Eclipse 或 IDEA,则默认情况下,Eclipse、IDEA 在编译时会帮你生成局部变量表、指令和代码行偏移量映射表等信息

javap 的用法

  • javap 的用法格式:javap

  • 其中,classes 就是你要反编译的 Class 文件

  • 在命令行中直接输入 javap 或 javap -help 可以看到 javap 的 options 有如下选项:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    PS C:\Users\hua-cloud> javap --help
    用法: javap <options> <classes>
    其中, 可能的选项包括:
    -help --help -? 输出此用法消息
    -version 版本信息
    -v -verbose 输出附加信息
    -l 输出行号和本地变量表
    -public 仅显示公共类和成员
    -protected 显示受保护的/公共类和成员
    -package 显示程序包/受保护的/公共类
    和成员 (默认)
    -p -private 显示所有类和成员
    -c 对代码进行反汇编
    -s 输出内部类型签名
    -sysinfo 显示正在处理的类的
    系统信息 (路径, 大小, 日期, MD5 散列)
    -constants 显示最终常量
    -classpath <path> 指定查找用户类文件的位置
    -cp <path> 指定查找用户类文件的位置
    -bootclasspath <path> 覆盖引导类文件的位置
  • 一般常用的是 -v -l -c 三个选项

  • javap -l 会输出行号和本地变量表信息

  • javap -c 会对当前 Class 字节码进行反编译生成汇编代码

  • javap -v classxx 除了包含 -c 内容外,还会输出行号、局部变量表信息、常量池等信息

总结

  • 通过 javap 命令可以查看一个 Java 类反汇编得到的 Class 文件版本号、常量池、访问标识、变量表、指令代码行号表等信息。不显式类索引、父类索引、接口索引集合、()、()等结构

  • 通过对前面的例子代码反汇编文件的简单分析,可以发现,一个方法的执行通常会涉及下面几块内存的操作

  • Java 栈中:局部变量表、操作数栈

  • Java 堆: 通过对象的地址引用去操作

  • 常量池

  • 其他如帧数据区、方法区的剩余部分等情况,测试中没有显示出来,这里说明一下

  • 平常,我们比较关注的是 Java 类中每个方法的反汇编中的指令操作过程,这些指令都是顺序执行的,可以参考官方文档查看每个指令含义

    字节码指令集与解析举例

概述

  • Java字节码对于虚拟机,就好像汇编语言对于计算机,属于基本执行指令。
  • Java虚拟机的指令由 一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于Java虚拟机采用面向操作数栈而不是面向寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码
  • 由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条
  • 官方文档: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html
  • 熟悉虚拟机的指令对于动态字节码生成、反编译 Class 文件、 Class 文件修补都有着非常重要的价值。因此,阅读字节码作为了解 Java 虚拟机的基础技能,需要熟练掌握常见指令

执行模型

  • 如果不考虑异常处理的话,那么 Java 虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解
1
2
3
4
5
6
do {
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置,从字节码流中取出操作码;
if(字节码存在操作数) 从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码长度>0)

字节码与数据类型

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

  • 对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:

    • i 代表对 int 类型的数据操作
    • l 代表 long
    • s 代表 short
    • b 代表 byte
    • c 代表 char
    • f 代表 float
    • d 代表 double
  • 也有一些指令的助记符中没有明确地指明操作类型的字母,如 arraylength 指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象

  • 还有另一些指令,如无条件跳转指令 goto 则是与数据类型无关的

  • 大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。编译器会在编译器或运行期将 byte 和short 类型的数据带符号扩展(Sign-Extend)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据。与之类似,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 init 类型的字节码指令来处理。因此,大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型

指令分类

  • 由于完全介绍和学习这些指令需要花费大量时间,为了让能够更快地熟悉和了解这些基本指令,这里将 JVM 中的字节码指令集按用途大致分成9类:

    • 加载与存储指令
    • 算术指令
    • 类型转换指令
    • 对象的创建与访问指令
    • 方法调用与返回指令
    • 操作数栈管理指令
    • 比较控制指令
    • 异常处理指令
    • 同步控制指令
  • 在做值相关操作时:

    • 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用等中取得数据,这些数据(可能是值,可能是对象的引用)被压入操作数栈
    • 一个指令,也可以从操作数栈中取出一到多个值(pop 多次),完成赋值、加减乘除、方法传参、系统调用等操作

加载与存储指令

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

  • 常用指令

    • 「局部变量压栈指令」将一个局部变量加载到操作数栈:xload、xload_(其中 x 为 i、l、f、d、a,n 为 0 到 3);xaload、xaload(其 x 为 i、l、f、d、a、b、c、s,n 为 0 到 3)
    • 「常量入栈指令」将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、iconst_、fconst_、dconst_
    • 「出栈装入局部变量表指令」将一个数值从操作数栈存储到局部变量表:xstore、xstore_(其中 x 为 i、l、f、d、a,n 为 0 到 3); xastore(其中 x 为 i、l、f、d、a、b、c、s)
    • 扩充局部变量表的访问索引的指令:wide
  • 上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如iload_)。这些指令助记符实际上代表了一组指令(例如iload_代表了iload_0、iload_1、iload_2和iload_3这几个指令)。这几组指令都是某个带有一个操作数的通用指令(例如 iload)的特殊形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作数都隐含在指令中

  • 除此之外,它们的语义与原生的通用指令完全一致(例如 iload_0的语义与操作数为0时的iload指令语义完全一致)。在尖括号之间的字母指定了指令隐含操作数的数据类型,<n>代表非负的整数,<i>代表是 int 类型数据,<l>代表 long 类型,<f>代表 float 类型,<d>代表 double 类型

  • 操作byte、char、short 和 boolean 类型数据时,经常用 int 类型的指令来表示

再谈操作数栈和局部变量表

操作数栈(Operand Stacks)

  • 我们知道,Java 字节码是 Java 虚拟机所使用的指令集。因此,它与 Java 虚拟机基于栈的计算模型是密不可分的

  • 在解释执行过程中,每当为 Java 方法分配栈帧时,Java 虚拟机往往需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果

  • 具体来说便是:执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java 虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中

  • 以加法指令 iadd 为例。假设在执行该指令之前,栈顶的两个元素分别为 int 值 1 和 int 值 2,那么 iadd 指令将弹出这两个 int,并将求得的和 int 值为 3 压入栈中

  • 由于 iadd 指令只消耗栈顶的两个元素,因此,对于离栈顶距离为 2 的元素,即图中的问号,iadd 指令并不关心它是否存在,更加不会对其进行修改

局部变量表(Local Variables)

  • Java 方法栈帧的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中

  • 实际上,Java 虚拟机将局部变量区当成一个数组,依次存放 this 指针(仅非静态方法),所传入的参数,以及字节码中的局部变量。

  • 和操作数栈一样,long 类型以及 double 类型的值将占据两个单元,其余类型仅占据一个单元

  • 举例:
1
2
3
4
5
6
7
8
public void foo(long l, float f) {
{
int i = 0;
}
{
String s = "Hello, World"
}
}
  • 对应得图示:

1605618948854

  • 变量llong类型,八个字节,占两个槽位
  • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
  • 在方法执行时,虚拟机使用局部变量表完成方法的传递
  • is代表的是槽的复用

局部变量压栈指令

  • 局部变量压栈指令将给定的局部变量表中的数据压入操作数栈
  • 这类指令大体可以分为:
    • xload_(xi、l、f、d、a,n为 0 到 3)
    • xload(xi、l、f、d、a)
  • 说明:在这里,x的取值表示数据类型
  • 指令xload_n表示将第n个局部变量压入操作数栈,比如iload_1、fload_0、aload_0等指令。其中aload_n表示将一个对象引用压栈
  • 指令xload通过指定参数的形式,把局部变量压入操作数栈,当使用这个命令时,表示局部变量的数量可能超过了4个,比如指令iload、fload
1
2
3
4
5
6
7
8
9
10
public class LoadAndStoreTest {
// 局部变量压栈指令
public void load(int num, Object obj, long count, boolean flag, short[] arr) {
System.out.println(num);
System.out.println(obj);
System.out.println(count);
System.out.println(flag);
System.out.println(arr);
}
}

常量入栈指令

  • 常量入栈指令的功能是将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为 const 系列、push 系列和 ldc 指令
  • 指令 const 系列:用于对特定的常量入栈,入栈的常量隐含在指令本身里。指令有:iconst_(i从-1到5)、lconst_(l从0或1)、fconst_(f从0到2)、dconst_(d从0或1)、aconst_null
  • 比如:
    • iconst_m1将-1压入操作数栈
    • iconst_x(x为0到5)将 x 压入栈
    • lconst_0、lconst_1 分别将长整数0和1压入栈
    • fconst_0、fconst_1、fconst_2 分别将浮点数0、1、2压入栈
    • dconst_0 和 dconst_1 分别将 double 型0和1压入栈
    • aconst_null 将 null 压入操作数栈
  • 从指令的命名上不难找出规律,指令助记符的第一个字符总是喜欢表示数据类型,i 表示整数,l 表示长整型,f 表示浮点数,d 表示双精度浮点,习惯上用 a 表示对象引用。如果指令隐含操作的参数,会以下划线形式给出
  • 指令 push 系列:主要包括 bipush 和 sipush,它们的区别在于接受数据类型的不同,bipush 接收8位整数作为参数,sipush 接收16位整数,它们都将参数压入栈
  • 指令 ldc 系列:如果以上指令都不能满足需求,那么可以使用万能的 ldc 指令,它可以接收一个8位的参数,该参数指向常量池中的 int、float 或者 String 的索引,将指定的内容压入堆栈
  • 类似的还有 ldc_w,它接收两个8位参数,能支持的索引范围大于 ldc
  • 如果要压入的元素是 long 或者 double 类型的,则使用 ldc2_w 指令,使用方式都是类似的
  • 总结如下:

出栈装入局部变量表

  • 出栈装入局部变量表指令用于将操作数栈中栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值
  • 这类指令主要以 store 的形式存在,比如 xstore (x 为 i、l、f、d、a)、xstore_n(x 为 i、l、f、d、a,n 为0至3)和 pasture(x 为 i、l、f、d、a、b、c、s)
    • 其中,指令 istore_n 将从操作数栈中弹出一个整数,并把它赋值给局部变量索引n的位置
    • 指令xstore由于没有隐含参数信息,故需要提供一个byte类型的参数类指定目标局部变量表的位置
    • xastore 则专门针对数组操作,以 iastore 为例,它用于给一个 int 数组的给定索引赋值。在 iastore 执行前,操作数栈顶需要以此准备3个元素:值、索引、数组引用,iastore 会弹出这3个值,并将值赋给数组中指定索引的位置
  • 一般说来,类似像 store 这样的命令需要带一个参数,用来指明将弹出的元素放在局部变量表的第几个位置。但是,为了尽可能压缩指令大小,使用专门的 istore_1 指令表示将弹出的元素放置在局部变量表第1个位置。类似的还有 istore_0、istore_2、istore_3,它们分别表示从操作数栈顶弹出一个元素,存放在局部变量表第0、2、3个位置
  • 由于局部变量表前几个位置总是非常常用,因此这种做法虽然增加了指令数量,但是可以大大压缩生成的字节码的体积。如果局部变量表很大,需要存储的槽位大于3,那么可以使用 istore 指令,外加一个参数,用来表示需要存放的槽位位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 // [n]---n代表操作数栈索引的位置
0 iload_1 // 将局部变量表索引为1的int类型的变量的值加入操作数栈中 [0]
1 iconst_2 // 将局部变量表索引为2的double类型的变量的值加入操作数栈中 [1]
2 iadd // 将操作数栈的两个值弹出栈顶进行相加放入操作数栈中 [0]
3 istore 4 // 将操作数栈求和出来的数据弹出放入到局部变量表索引为4的位置
5 ldc2_w #13 <12> // 从常量池索引为13的位置加载数据放入操作数栈中 [0]
8 istore 5 // 将操作数栈中加入的变量12弹出放入到局部变量表索引为5的位置
10 ldc #15 <who are you,i am where> // 从常量池索引为15的位置加载数据放入操作数栈中 [0]
12 astore 7 // 将操作数栈中从常量池加载的数据从栈顶弹出放入局部变量表索引为7的位置
14 ldc #16 <10.0> // 从常量池索引为16的位置加载数据放入操作数栈中 [0]
16 fstore 8 // 将操作数栈中从常量池加载的数据从栈顶弹出放入局部变量表索引为8的位置
18 ldc2_w #17 <10.0> // 从常量池索引为17的位置加载数据放入操作数栈中 [0]
21 dstore_2 // 将操作数栈中从常量池加载的数据从栈顶弹出放入局部变量表索引为2的位置
22 return // 方法返回,类型为void
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
// 演示代码
public class LoadAndStoreTest {

// 局部变量压栈指令
public void load(int num, Object obj, long count, boolean flag, short[] arr) {
System.out.println(num);
System.out.println(obj);
System.out.println(count);
System.out.println(flag);
System.out.println(arr);
}

// 常量入栈指令
public void pushConstLdc() {
int a = 5;
int b = 6;
int c = 127;
int d = 128;
int e = 1234567;
}

public void constLdc() {
long a1 = 1;
long a2 = 2;
float b1 = 2;
float b2 = 3;
double c1 = 1;
double c2 = 2;
Date d = null;
}

// 出栈装入局部变量表指令
public void store(int k, double d) {
int m = k + 2;
long l = 12;
String str = "atguigu";
float f = 10.0F;
d = 10;
}

public void foo(long l, float f) {
{
int i = 0;
}
{
String s = "hello world";
}
}
}

算术运算符指令

  • 作用:算术指令用于对两个操作数栈上的值进行某种特定的运算,并把结果重新压入操作数栈

  • 分类:大体上算术指令可以分为两种:对 整型数据 进行运算的指令与对 浮点类型数据 进行运算的指令

  • byte、short、char和boolean类型说明

    • 在每一大类中,都有针对Java虚拟机具体数据类型的专用算术指令。但是没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都是用int类型的指令来处理。此外,在处理boolean、byte、short和char类型的数组的时候,也会转换成对应的int类型的字节码指令进行处理。

  • 运算时的溢出

    • 数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能只是一个负数。其实Java虚拟机规范并没有明确规定整型数据溢出的结果,仅仅规定了在处理整型数据的时候,只有除法指令以及求余指令中出现除数为0的情况下,会导致虚拟机抛出 ArithmetciException 异常
  • 运算模式

    • 向最接近数舍入模式:JVM 要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的。
    • 向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果。
  • NaN的使用

    • 当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN 值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN(not a number)不确定的一个数值

所有的运算符指令

  • 所有的运算符指令包括

  • 加法指令:iadd、ladd、fadd、dadd

  • 减法指令:isub、lsub、fsub、dsub

  • 乘法指令:imul、lmul、fmul、dmul

  • 除法指令:idiv、ldiv、fdiv、ddiv

  • 求余指令:irem、lrem、frem、drem(remainder:余数)

  • 取反指令:ineg、lneg、fneg、dneg(negation:取反)

  • 自增指令:iinc

  • 位运算指令,又可分为:

    • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
    • 按位或指令:ior、lor
    • 按位与指令:iand、land
    • 按位异或指令:ixor、lxor
  • 比较指令:dcmpg、dcmlp、fcmpg、fcmpl、lcmp

  • 关于++操作的理解
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 IAdd {
public void m1() {
int i = 10;
i++;
}

public void m2() {
int i = 10;
++i;
}

public void m3() {
int i = 10;
int a = i++;

int j = 20;
int b = ++j;
}

public void m4() {
int i = 10;
i = i++;
System.out.println(i);
}
}
  • 对于不参与运算的情况下,i++ 和 ++i 的字节码操作是一样的

  • 参与运算的情况下

  • i = 1++ 的情况下

比较指令的说明

  • 比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈

  • 比较指令有:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

    • 与前面讲解的指令类似,首字符 d 表示 double 类型,f 表示 float,l 表示 long
  • 对于 double 和 float 类型的数字,由于 NaN 的存在,各有两个版本的比较指令,以 float 为例,有 fcmpg 和 fcmpl 两个指令,它们的区别在于在数字比较时,若遇到 NaN 值,处理结果不同

  • 指令 dcmpl 和 dcmpg 也是类似的,根据其命名可以推测其含义,在此不再赘述

  • 指令 lcmp 针对 long 型整数,由于 long 型整数没有 NaN 值,故无需准备两套指令

  • 举例:

  • 指令 fcmpg 和 fcmpl 都从栈中弹出两个操作数,并将它们做比较,设栈顶的元素为 v2, 栈顶顺位第2位元素为 v1,若 v1 = v2,则压入0;若 v1 > v2 则压入1;若 v1 < v2 则压入-1

  • 两个指令的不同之处在于,如果遇到 NaN 值,fcmpg 会压入1,而 fcmpl 会压入-1

  • 数值类型的数据才可以谈大小,boolean、引用数据类型不能比较大小

类型转换指令

  • 类型转换指令说明
    • 类型转换指令可以将两种不同的数值类型进行相互转换
    • 这些转换操作一般用于实现用户代码中的 显式类型转换操作, 或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题

宽化类型转换(Widening Numeric Conversions)

  • 转换规则
  • Java 虚拟机直接支持以下数值的宽化类型转换(Widening Numeric Conversion,小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括:
    • 从 int 类型到 long、float 或者 double 类型,对应的指令为:i2l、i2f、i2d
    • 从 long 类型到 float、double 类型。对应的指令为:l2f、l2d
    • 从 flaot 类型到 double 类型。对应的指令为:f2d
    • 简化为:int –> long –> float –> double
  • 精度损失问题
    • 宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从 int 转换到 long,或者从 int 转换到 double,都不会丢失任何信息,转换前后的值是精确相等的
    • 从 int、long 类型数值转换到 float,或者 long 类型树脂转换到 double 时,将可能发生丢失精度——可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据 IEEE754 最接近舍入模式所得到的正确整数数值。尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致 Java 虚拟机抛出运行时异常
    • 从 byte、char 和 short 类型到 int 类型的宽化类型转换实际上是不存在的,对于 byte 类型转换为 int,虚拟机并没有做实质性的转化处理,知识简单地通过操作数栈交换了两个数据。而 byte 转为 long 时,使用的是 i2l,可以看到在内部 byte 在这里已经等同于 int 类型处理,类似的还有 short 类型,这种处理方式有两个特点 :
      • 一方面可以减少实际的数据类型,如果为 short 和 byte 都准备一套指令,那么指令的数量就会大增,而虚拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过256个,为了节省指令资源,将 short 和 byte 当作 int 处理也是情理之中
      • 另一方面,由于局部变量表中的槽位固定为32位,无论是 byte 或者 short 存入局部变量表,都会占用32位空间。从这个角度来说,也没有必要特意区分这几种数据类型

窄化类型转换(Narrowing Numeric Conversion)

  • 转换规则

    • Java 虚拟机也直接支持以下窄化类型转换:
      • 从 int 类型至 byte、short 或者 char 类型。对应的指令有:i2b、i2c、i2s
      • 从 long 类型到 int 类型。对应的指令有:l2i
      • 从 float 类型到 int 或者 long 类型。对应的指令有:f2i、f2l
      • 从 double 类型到 int、long 或者 float 类型。对应的指令有:d2i、d2l、d2f
  • 精度损失问题

    • 窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度
    • 尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是 Java 虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常
  • 补充说明

    • 当将一个浮点值窄化转换为整数类型T(T限于int类型或者long类型之一)的时候,将遵循以下转换原则

      • 如果浮点为NaN,那么转换结果就是int 或者long类型的0
      • 如果浮点值不是无穷大的话,浮点值使用 IEEE 754 的向零舍入模式取整,获得整数值v,如果v在目标类型T(int 或者 long)的表示范围之内,那转换的结果就是v。否则,将根据v的符号,转换T所能表示的最大或者最小正数。
    • 当将一个double类型窄化转换为float类型的时候,将遵循以下转换规则,通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断。

      • 如果转换结果的绝对值太小而无法使用float表示,将返回float类型的正负0

      • 如果转换结果的绝对值太大而无法使用float表示,将返回float类型的正负无穷大

        对于double类型的NaN值将按规定转换为float类型的NaN值

对象的创建和访问指令

  • Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。有一系列指令专门用于对象操作,可进一步细分为创建指令、字段访问指令、数组操作指令、类型检查指令

创建指令

  • 虽然类实例和数组都是对象。但是Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令
  • 类创建实例的指令
    • new
    • 它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成之后,将对象的引用压入栈
  • 创建数组的指令
    • newarray、anewarray、multiannewarray
    • newarray:创建基本数据类型数组
    • anewarray:创建引用数据类型数组
    • multiannewarray:创建多维数组
  • 上述指令可以用于创建对象或者数组,由于对象和数组再Java中的广泛使用,这些指令的使用频率也非常高。
    1
    2
    3
    4
    5
    6
    7
    public class NewInstance {
    public void newInstance() {
    Object obj = new Object();

    File file = new File("gc.log");
    }
    }
1
2
3
4
5
6
7
8
9
10
 0 new #2 <java/lang/Object> // 操作数栈中存储的具体的内存地址 [0]
3 dup // 操作数栈复制一份内存地址 [1]
4 invokespecial #1 <java/lang/Object.<init> : ()V> // 此时操作数栈栈顶的地址[1]就被出栈
7 astore_1 // 将操作数栈栈顶元素[0]放入局部变量表索引为1的位置
8 new #3 <java/io/File> // 操作数栈中存储的具体的内存地址 [0]
11 dup // 操作数栈复制一份内存地址 [1]
12 ldc #4 <gc.log> // 将常量池中索引为4的数据加入操作数栈中[2]
14 invokespecial #5 <java/io/File.<init> : (Ljava/lang/String;)V> // 此时操作数栈栈顶的地址[1]、[2]就被出栈
17 astore_2 // 将操作数栈栈顶元素[1]放入局部变量表索引为1的位置
18 return

字段访问指令

  • 对象创建之后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素

    • 访问类字段(static字段,或者称为类变量)的指令:getstatic、putstatic
    • 访问类实例字段(费static字段,或者称为实例变量)的指令:getfield、purfield
  • 举例一:

    • 以getstatic指令为例,它含有一个操作数,为指向常量池的 Fielref 的索引,它的作用就是获取Fieldref指定的对象的值,并将其压入操作数栈
    1
    2
    3
    public void sayHello() {
    System.out.println("hello");
    }
    • 对应的字节码指令
    1
    2
    3
    4
    0 getstatic #8 <java/lang/System.out> // 从常量池索引为8的位置获取System.out加入操作数栈 [0]
    3 ldc #9 <Hello> // 从常量池索引为9的位置获取数据加入到操作数栈中 [1]
    5 invokevirtual #10 <java/io/PrintStream.println> // 调用PrintStream.println方法,同时操作数栈[0]、[1]出栈
    8 return
  • 举例二:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public void setOrderId() {
    Order order = new Order();
    order.id = 1000;
    System.out.println(order.id);

    Order.name = "ORDER";
    System.out.println(Order.name);
    }
    class Order {
    int id;
    static String name;

    对应的字节码指令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
     0 new #9 <com/xiao/learning/jvm/chapter18/Order> // 将Order对象在堆内存中的地址存储在操作数栈中 [0]
    3 dup // 将操作数栈索引[0]的地址复制一份 [1]
    4 invokespecial #10 <com/xiao/learning/jvm/chapter18/Order.<init> : ()V> // 将操作数栈索引为[1]的元素出栈并调用对象的init(构造)方法
    7 astore_1 // 将操作数栈索引为[0]的元素出栈,添加到局部变量表索引为1的位置
    8 aload_1 // 将局部变量表索引为1的位置的元素加入到操作数栈中 [0]
    9 sipush 1000 // 将1000加入到操作数栈中 [1]
    12 putfield #11 <com/xiao/learning/jvm/chapter18/Order.id : I> // 将操作数栈索引为[0]、[1]的元素出栈,给Order.id赋值
    15 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
    18 aload_1
    19 getfield #11 <com/xiao/learning/jvm/chapter18/Order.id : I>
    22 invokevirtual #12 <java/io/PrintStream.println : (I)V>
    25 ldc #13 <ORDER> // 从常量池中索引为13的位置的数据加载到操作数栈中
    27 putstatic #14 <com/xiao/learning/jvm/chapter18/Order.name : Ljava/lang/String;> // 将操作数栈索引为[0]的元素出栈,给Order.name赋值
    30 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
    33 getstatic #14 <com/xiao/learning/jvm/chapter18/Order.name : Ljava/lang/String;>
    36 invokevirtual #8 <java/io/PrintStream.println : (Ljava/lang/String;)V>
    39 return

数组操作指令

  • 数组操作指令主要有:xastore和xaload指令。具体为:

    • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload

    • 将一个操作数栈的值存储在数组元素中的指令:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore

      数组类型 加载指令 存储指令
      byte(boolean) baload bastore
      char caload castore
      short saload sastore
      int iastore iastore
      long faload fastore
      float faload fastore
      double daload dastore
      reference aaload aastore
    • 取数组长度的指令:arraylength

      • 该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈
  • 说明

    • 指令xaload表示将数组的元素压栈,比如saload、caload分别表示压入short数组和char数组。指令xoload在执行的时候,要求操作数中的栈顶元素为数组索引i,栈顶顺位第2个元素为数组引用a,该指令会弹出栈顶这两个元素,并将a[i]重新压入堆栈
    • xastore则专门针对数组操作,以iastore为例,它用于给一个int数组的给定索引赋值。在iastore执行前,操作数栈顶需要以此准备3个元素:值、索引、数组引用iastore会弹出这3个值,并且将值赋予给数组中指定索引位置。
  • 举例

    1
    2
    3
    4
    5
    public void setArray(){
    int[] intArray = new int[10];
    intArray[3] = 20;
    System.out.println(intArray[1]);
    }

    字节码指令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
     0 bipush 10 // 将常量10加入到操作数栈中 [0]
    2 newarray 10 (int) // 将操作数栈索引为0的元素弹出,将堆空间的数组地址放到操作数栈中 [0]
    4 astore_1 // 将操作数栈索引为0的元素弹出放到局部变量表索引为1的位置
    5 aload_1 // 将局部变量表索引为1的元素加入到操作数栈中 [0]
    6 iconst_3 // 将常量3加载到操作数栈中 [1]
    7 bipush 20 // 将常量20加入到操作数栈中 [2]
    9 iastore // 当我们调用这个指令的时候,操作数栈顶会弹出:值、索引、数组引用进行赋值,此时操作数栈没有元素
    10 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;> // 从常量池索引为6的位置获取System.out加入操作数栈 [0]
    13 aload_1 // 将局部变量表索引为1的位置加载到操作数栈中[1]
    14 iconst_1 // 将常量1压入到操作数栈中 [2]
    15 iaload // 当我们调用这个指令的时候,操作数栈顶的元素数组索引,栈顶顺位第2个元素为数组引用弹出,并将新的值重新压入堆栈(此处的新值为0) [1]
    16 invokevirtual #12 <java/io/PrintStream.println : (I)V> // 调用PrintStream.println方法,同时操作数栈[0]、[1]出栈
    19 return

类型检查指令

  • 检查类实例或数组类型的指令:instanceof、checkcast

    • 指令 checkcast 用于检查类型强制转换是否可以进行。如果可以进行,那么 checkcast 指令不会改变操作数栈,否则它会抛出 ClassCastException 异常
    • 指令 instanceof 用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈
  • 举例

    1
    2
    3
    4
    5
    6
    7
    public String checkCast(Object obj) {
    if (obj instanceof String) {
    return (String) obj;
    } else {
    return null;
    }
    }

    字节码指令

    1
    2
    3
    4
    5
    6
    7
    8
     0 aload_1 // 将局部变量表索引为1的数据(变量obj)加入到操作数栈中
    1 instanceof #15 <java/lang/String> // 判断加载进来的实例(obj)是否是String类型的
    4 ifeq 12 (+8) // 如果是就往下走不是就走到指令12,同时操作数栈弹出
    7 aload_1 // 将局部变量表索引为1的数据(变量obj)加入到操作数栈中
    8 checkcast #15 <java/lang/String> // 强转成为String类型
    11 areturn // 返回的类型是引用类型
    12 aconst_null
    13 areturn

方法调用与返回值

方法调用指令

  • invokevirtual、invokeinterface、invokespecial、invokestatic、invokedynamic

  • 以下5条指令用于方法调用

    • invokevirtual:指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态。这也是Java语言中 最常见的方法分派方式
    • invokeinterface:指令用于调用接口方法,它会在运行时候搜索由特定对象所实现的这个接口的方法,并找出适合的方法进行调用
    • invokespecial:指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用的时候进行动态分发。
    • invokestatic:指令用于调用命名中类中的类方法(staic方法)。这是静态绑定的
    • invokedynamic:调用动态绑定的方法,这个是动态绑定的方法,是JDK1.7后新加入的指令。用于在运行的时候解析调用点限定符的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
  • 举例

    • invokespecial

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      public class MethodInvokeReturnTest {

      // 方法调用指令invokespecial静态分派
      public void invokeSpecial(){
      // 情况1:类实例构造方法:<init>()
      Date date = new Date();

      // 情况2:父类的方法
      super.toString();

      // 情况3:私有方法
      methodPrivate();
      }

      private void methodPrivate() {

      }
      }

      字节码指令

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
       0 new #2 <java/util/Date>
      3 dup
      4 invokespecial #3 <java/util/Date.<init> : ()V>
      7 astore_1
      8 aload_0
      9 invokespecial #4 <java/lang/Object.toString : ()Ljava/lang/String;>
      12 pop
      13 aload_0
      14 invokespecial #5 <com/xiao/learning/jvm/chapter18/MethodInvokeReturnTest.methodPrivate : ()V>
      17 return
    • invokestatic

      1
      2
      3
      4
      5
      6
      7
      8
      9
      public class MethodInvokeReturnTest {
      // 方法调用指令invokestatic静态分派
      public void invokeStatic(){
      methodPrivate();
      }
      private static void methodStatic() {

      }
      }

      字节码指令

      1
      2
      3
      0 aload_0
      1 invokespecial #5 <com/xiao/learning/jvm/chapter18/MethodInvokeReturnTest.methodPrivate : ()V>
      4 return
    • invokeinterface

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public class MethodInvokeReturnTest {
      // 方法调用指令invokeinterface
      public void invokeInterface(){
      Thread thread = new Thread();
      ((Runnable)thread).run();

      Comparable<Integer> com = null;
      com.compareTo(123);
      }
      }

      字节码指令

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
       0 new #6 <java/lang/Thread>
      3 dup
      4 invokespecial #7 <java/lang/Thread.<init> : ()V>
      7 astore_1
      8 aload_1
      9 invokeinterface #8 <java/lang/Runnable.run : ()V> count 1
      14 aconst_null
      15 astore_2
      16 aload_2
      17 bipush 123
      19 invokestatic #9 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
      22 invokeinterface #10 <java/lang/Comparable.compareTo : (Ljava/lang/Object;)I> count 2
      27 pop
      28 return
    • invokevirtual

      1
      2
      3
      4
      5
      6
      7
      public class MethodInvokeReturnTest {
      // 方法调用指令invokevirtual
      private static void invokeVirtual() {
      Thread thread = null;
      thread.run();
      }
      }

      字节码指令

      1
      2
      3
      4
      5
      0 aconst_null
      1 astore_0
      2 aload_0
      3 invokevirtual #11 <java/lang/Thread.run : ()V>
      6 return

方法返回指令

  • 方法结束调用之前,需要进行返回。方法返回指令是 根据返回值的类型区分的

    • 包括 ireturn(当返回值是 boolean、byte、char、short和int类型的时候使用)、lreturn、freturn、dreturn、和areturn
    • 另外还有一条 return 指令声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用
    返回类型 返回指令
    void return
    int(boolean、byte、char、short) ireturn
    long lreturn
    float freturn
    double dreturn
    reference areturn
    • 举例:

    • 通过 ireturn 指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃

    • 如果当前返回的是 synchronized 方法,那么还会执行一个隐含的 monitorexit 指令,退出临界区

    • 最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者

操作数栈管理指令

  • 如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令

  • 这类指令包括以下内容:

    • 将一个或两个元素从栈顶弹出,并且直接废弃pop、pop2

    • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2

    • 栈最顶端的两个Solt数值的位置交换swap。Java虚拟机没有提供交换2个64位数据类型(long、double)数值的指令

    • 指令nop,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等。

    • 这些指令属于通用型,堆栈的压入或者弹出无需指明数据类型

  • 说明

    • 不带_x的指令是复制栈顶数据并压入栈顶。包括两个指令,dup 和 dup2,dup 的系数代表要复制的 Slot 个数

      • dup 开头的指令用于复制1个 Slot 的数据。例如1个 int 或1个 reference 类型数据
      • dup2 开头的指令用于复制2个 Slot 的数据。例如1个 long,或2个 int,或1个 int 加1个 float 类型数据
    • 带_x的指令是复制栈顶数据并插入栈顶以下的某个位置。共有4个指令,dup_x1、dup2_x1、dup_x2、dup2_x2。对于带 _x 的复制插入指令,只要将指令的 dup 和 x 的系数相加,结果即为需要插入的位置。因此

      • dup_x1插入位置:1+1=2,即栈顶2个Slot下面
      • dup_x2插入位置:1+2=3,即栈顶3个Slot下面
      • dup2_x1插入位置:2+1=3,即栈顶3个Slot下面
      • dup2_x2插入位置:2+2=4,即栈顶4个Slot下面
    • pop:将栈顶的1个Slot数值出栈。例如1个 short 类型数值

    • pop2:将栈顶的2个Slot数值出栈。例如1个 double 类型数值,或者2个 int 类型数值

控制转移指令

  • 程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为比较指令(在之前的算术指令)、条件跳转指令、比较条件跳转指令、多条件分支跳转指令、无条件跳转指令

条件跳转指令

  • 条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转

  • 条件跳转指令有:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull。这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的 offset)

  • 它们的统一含义为:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置

  • 具体说明

    指令名称 指令描述
    ifeq 当栈顶int类型数值等于0时跳转
    idne 当栈顶int类型数值不等于0时跳转
    iflt 当栈顶int类型数值小于0时跳转
    ifle 当栈顶int类型数值小于等于0时跳转
    ifgt 当栈顶int类型数值大于0时跳转
    ifge 当栈顶int类型数值大于等于0时跳转
    ifnull 为null时跳转
    ifnotnull 不为null时跳转
  • 注意
    • 与前面运算规则一致
    • 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成
    • 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行 int 类型的条件分支比较操作来完成整个分支跳转
    • 由于各类型的比较最终都会转为 int 类型的比较操作,所以 Java 虚拟机提供的 int 类型的条件分支指令是最为丰富和强大的

比较条件跳转指令

  • 比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一

  • 这类指令有:if_icmped、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmped 和 if_acmpne

  • 其中指令助记符加上 “if_” 后,以字符 “i” 开头的指令针对 int 型整数操作(也包括 short 和 byte 类型),以字符 “a” 开头的指令表示对象引用的比较

  • 具体说明

    指令名称 指令描述
    if_icmpeq 比较栈顶两int类型数值大小,当前者等于后者时跳转
    if_icmpne 比较栈顶两int类型数值大小,当前者不等于后者时跳转
    if_icmplt 比较栈顶两int类型数值大小,当前者小于后者时跳转
    if_icmple 比较栈顶两int类型数值大小,当前者小于等于后者时跳转
    if_icmpgt 比较栈顶两int类型数值大小,当前者大于后者时跳转
    if_icmpge 比较栈顶两int类型数值大小,当前者大于等于后者时跳转
    if_icmpeq 比较栈顶两引用类型数值,当结果相等时跳转
    if_icmpne 比较栈顶两引用类型数值,当结果不相等时跳转
  • 这些指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较。指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句

多条件分支跳转

  • 多条件分支跳转指令是专为 switch-case 语句设计的,主要有 tableswitchlookupswitch

    指令名称 指令描述
    tableswitch 用于Switch条件跳转,case值连续
    lookupswitch 用于Switch条件跳转,case值不连续
  • 从助记符上看,两者都是 switch 语句的实现,它们的区别:

    • tableswitch 要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数 index,可以立即定位到跳转偏移量位置,因此效率比较高
    • lookupswitch 内部存放着各个离散的 case-offset 对,每次执行都要搜索全部的 case-offset 对,找到匹配的 case 值,并根据对应的 offset 计算跳转地址,因此效率较低
  • 指令 tableswitch 的示意图如下图所示。由于 tableswitch 的 case 值是连续的,因此只需要记录最低值和最高值,以及每一项对应的 offset 偏移量,根据给定的 index 值通过简单的计算即可直接定位到 offset

  • 指令 lookupswitch 处理的是离散的 case 值,但是出于效率考虑,将 case-offset 对按照 case 值大小排序,给定 index 时,需要查找与 index 相等的 case,获得其 offset,如果找不到则跳转到 default。指令 lookupswitch 如下图所示

无条件跳转

  • 目前主要的无条件跳转指令为 goto,指令 goto 接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处

  • 如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令 goto_w,它和 goto 有相同的作用,但是它接收4个字节的操作数,可以表示更大的地址范围

  • 指令 jsr、jsr_w、ret 虽然也是无条件跳转的,但主要用于 try-finally 语句,且已经被虚拟机逐渐废弃,故不在这里介绍这两个指令

    指令名称 指令描述
    goto 无条件跳转
    goto_w 无条件跳转(宽索引)
    jsr 跳转到指定16位offset位置,并将jsr下一条指令地址压入栈顶
    jsr_w 跳转到指定32位offset位置,并将jsr下一条指令地址压入栈顶
    ret 返回至由指定的局部变量所给出的指令位置(一般与jsr、jsr_w联合使用)

异常处理指令

抛出异常指令

  • athrow 指令
    • 在 Java 程序中显式抛出异常的操作(throw 语句)都是由 athrow 指令来实现的
    • 除了使用 throw 语句显式抛出异常情况之外,JVM 规范还规定了许多运行时一场会在其它 Java 虚拟机指令检测到异常状况时自动抛出。例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在 idiv 或 ldiv 指令中抛出 ArithmeticException 异常
  • 注意
    • 正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java 虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上
  • 异常及异常的处理:
    • 过程一:异常对象的生成过程 —> throw(手动/自动) —> 指令:athrow
    • 过程二:异常的处理:抓抛模型 try-catch-finally —> 使用异常表

异常处理与异常表

  • 处理异常
    • 在Java虚拟机中 ,处理异常(catch 语句)不是由字节码指令来实现的(早期使用 jsr、ret 指令),而是采用异常表来完成的
  • 异常表
  • 如果一个方法定义了一个 try-catch 或者 try-finally 的异常处理,就会创建一个异常表。它包含了每个异常处理或者 finally 块的信息。异常表保存了每个异常处理信息。比如:
    • 起始位置
    • 结束位置
    • 程序计数器记录的代码处理的偏移地址
    • 被捕获的异常类在常量池中的索引
  • 当一个异常被抛出时,JVM 会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个县城将终止。如果这个异常在最后一个非守护线程里抛出,将会导致 JVM 自己终止,比如这个线程是个 main 线程
  • 不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下, 如果方法结束后没有抛出异常,仍然执行 finally 块,在 return 前,它直接跳到 finally 块来完成目标

同步控制指令

  • Java 虚拟机支持两种同步结构:方法级同步方法内部一段指令序列的同步,这两种同步都是使用 monitor 来支持的

方法级的同步

  • 方法级的同步:是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法

  • 当调用方法时,调用指令将会检查方法的 ACC_SYNCHRONIZED访问标志是否设置

    • 如果设置了,执行线程将先持有同步锁,然后执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁
    • 在方法执行期间,执行线程持有了同步锁,其它任何线程都无法再获得同一个锁
    • 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放
  • 举例:

1
2
3
4
private int i = 0;
public synchronized void add() {
i++;
}
  • 对应字节码:
1
2
3
4
5
6
7
 0 aload_0
1 dup
2 getfield #2 <com/xiao/learning/jvm/chapter18/IAdd.i : I>
5 iconst_1
6 iadd
7 putfield #2 <com/xiao/learning/jvm/chapter18/IAdd.i : I>
10 return
  • 说明:
  • 这段代码和普通的无同步操作的代码没有什么不同,没有使用 monitorenter 和 monitorexit 进行同步区控制。这是因为,对于同步方法而言,当虚拟机通过方法的访问标识符判断是一个同步方法时,会自动在方法调用前进行加锁,当同步方法执行完毕后,不管方法是正常结束还是有异常抛出,均会由虚拟机释放这个锁。因此,对于同步方法而言,monitorenter 和 monitorexit 指令是隐式存在的,并未直接出现在字节码中

方法内指定指令序列的同步

  • 同步一段指令集序列:通常是由 Java 中的 synchronized 语句块来表示的。JVM 的指令集有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义

  • 当一个线程进入同步代码块时,它使用 monitorenter 指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,知道对象的监视器计数器为0,才会被允许进入同步块

  • 当线程退出同步块时,需要使用 monitorexit 声明退出。在 Java 虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态

  • 指令 monitorenter 和 monitorexit 在执行时,都需要在操作数栈顶压入对象,之后 monitorenter 和 monitorexit 的锁定和释放都是针对这个对象的监视器进行的

  • 编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须执行其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束

  • 为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令

    类的加载过程详解

概述

  • 在 Java 中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载

  • 按照 Java 虚拟机规范,从 Class 文件到加载到内存中的类,到类卸载出内存位置,它的整个生命周期包括如下七个阶段:

类的生命周期

  • 其中验证、准备、解析3个部分统称为链接(Linking)

  • 从程序中使用类的过程来看

大厂面试题

  • 蚂蚁金服:

    • 描述一下JVM加载Class文件的原理机制?
    • 一面:类加载过程
  • 百度:

    • 类加载的机制

    • Java类加载过程?

    • 简述Java类加载机制?

  • 腾讯:

    • JVM中类加载机制,类加载过程?
  • 滴滴:

    • JVM类加载机制
  • 美团:

    • Java类加载过程
    • 描述一下 JVM 加载 Class 文件的原理机制

过程一:Loading(加载阶段)

加载完成的操作

  • 加载的理解
    • 所谓的加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。所谓类模板对象。其实就是Java类在JVM内存中的一个快照,JVM将从字节码中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期间就能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。
    • 反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。
  • 加载完成的操作
    • 加载阶段,简言之,查找字节码文件并加载类的二进制数据,生成Class实例
    • 在加载类的时候, JVM必须完成以下3个事情
      • 通过类的全名,获取类的二进制数据流。
      • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
      • 创建java.lang.Class实例,表示该类型。作为方法区这个类的各种数据的访问入口

二进制流获取的方式

  • 对于类的二进制数据流,虚拟机可以通过多种途径产生或者获得。(只要所读取的字节码符合JVM的规范即可)
    • 虚拟机可能通过文件系统读入一个class后缀的文件(最常见
    • 读入jar、zip等归档数据包,提取类文件
    • 事先存放在数据库中的类的二进制数据
    • 使用类似HTTP值类的协议通过网络进行加载
    • 在运行时生成一段Class的二进制信息等
  • 在获取到类的二进制信息之后,Java虚拟机就会处理这些数据,并最终转成一个 java.lang.Class 的实例
  • 如果输入数据不是ClassFile的结构,就会抛出 ClassFormatError

类模型与Class实例的位置

  • 类模型的位置
    • 加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8之后:元空间)
  • Class实例的位置
    • 类将 .class 文件加载至原空间之后,会在堆中创建一个 java.lang.Class 对象,用来封装类位于方法区内的数据结构,该Class对象时在加载类的过程中创建的,每个类都对应有一个Class类型的对象
  • 图示

  • 外部可以通过访问代表Order类的Class对象来获取Order的类数据结构

  • 再说明

    • Class类的构造方法是私有的,只有JVM能创建

    • java.lang.Class 实例时访问类型元数据的接口,也是实现反射的关键数据、入口。通过Class类提供的接口,可以获得目标类所关联的 .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
      public class LoadingTest {
      public static void main(String[] args) throws ClassNotFoundException {
      Class<?> clazz = Class.forName("java.lang.String");
      // 获取当前运行时声明类的所有方法
      Method[] methods = clazz.getDeclaredMethods();
      for (Method method : methods) {
      // 获取所有类的修饰符
      String modifier = Modifier.toString(method.getModifiers());
      System.out.print(modifier + " ");
      // 获取方法的返回值类型
      String returnTypeName = method.getReturnType().getSimpleName();
      System.out.print(returnTypeName + " ");
      // 获取方法
      System.out.print(method.getName() + "(");
      // 获取方法参数列表
      Class<?>[] parameterTypes = method.getParameterTypes();
      if (parameterTypes.length == 0) System.out.print(")");
      for (int i = 0; i < parameterTypes.length; i++) {
      char end = (i == parameterTypes.length - 1) ? ')' : ',';
      // 获取参数的类型
      System.out.print(parameterTypes[i].getSimpleName() + end);
      }
      System.out.println();
      }
      }
      }

数组类的加载

  • 创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但是数组的元素类型仍然需要依靠类加载器去创建。创建数组类(下述简称A)的过程
    • 如果数组的元素类型时引用数据类型,那么就要遵循定义的加载过程递归加载和创建数组A的元素类型
    • JVM使用指定的元素类型和数组维度来创建新的数组类
  • 如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为 public

过程二:Linking(链接)阶段

链接阶段之Verification(验证)

  • 验证阶段(Verification)

    • 当类加载到系统之后,就开始链接操作,验证是链接操作的第一步
    • 它的目的是保证加载的字节码是合法、合理并且符合规范的
    • 验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查,如图

    • 整体说明
      • 验证的内容则涵盖了类数据信息的格式验证、语义检查、字节码验证,以及符号引用验证等
        • 其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中
        • 格式验证之外的验证操作将会在方法区中进行
      • 链接阶段的验证虽然拖慢了加载速度,但是它避免了在字节码运行时还需要进行各种检查
    • 具体说明
      • 格式验证:是否以魔数0xCAFEBABE开头,主版本和副版本号是否在当前 Java 虚拟机的支持范围内,数据中每一个项是否都拥有正确的长度等
      • Java 虚拟机会进行字节码的语义检查,但凡在语义上不符合规范的,虚拟机也不会给予验证通过。比如:
        • 是否所有的类都有父类的存在(在 Java 里,除了 Object 外,其他类都应该有父类)
        • 是否一些被定义为 final 的方法或者类被重写或继承了
        • 非抽象类是否实现了所有抽象方法或者接口方法
        • 是否存在不兼容的方法(比如方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度;absract 情况下的方法,就不能是final 的了)
      • Java 虚拟机还会进行字节码验证,字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:
        • 在字节码的执行过程中,是否会跳转到一条不存在的指令
        • 函数的调用是否传递了正确类型的参数
        • 变量的赋值是不是给了正确的数据类型等
      • 栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虚拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的
      • 在前面3次检查中,已经排除了文件格式错误、语义错误以及字节码的不正确性。但是依然不能确保类是没有问题的
      • 校验器还将进行符号引用的验证。Class 文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出 NoClassDefFoundError,如果一个方法无法被找到,则会抛出 NoSuchMethdError (此阶段在解析环节 才会执行)

链接阶段之Preparation(准备)

  • (准备阶段)Preparation,简而言之,为类的静态变量分配内存,并将其初始化为默认值
  • 当一个类验证通过的时候,虚拟机就会进入准备阶段。在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值。Java虚拟机为各类型变量默认的初始值如下表所示
类型 初始默认值
byte (byte)0
short (short)0
int 0
long 0L
float 0.0f
double 0.0
char \u0000
boolean flase
reference null
  • 注意:Java并不支持 boolean 类型,对于boolean类型,内部实现是int,由于int的默认值为0,所以对应的,boolean的默认值就是false
  • 注意:
    • 这里不包含基本数据类型的字段用 static final 修饰的情况,因为 final 在编译的时候就会分配了,准备阶段会显式赋值
    • 注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象一起分配到Java堆中
    • 在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 基本数据类型:非 final 修饰的变量,在准备环节进行默认初始化赋值
* final 修饰以后,在准备环节直接进行显式赋值
*
* 拓展:如果使用字面量的方式定义一个字符串的常量的话,也是在准备环节直接进行显式赋值
*/
public class LinkingTest {
private static long id;
private static final int num = 1;

public static final String constStr = "CONST";
public static final String constStr1 = new String("CONST");
}

链接阶段之Resolution(解析)

  • 解析阶段(Resolution),简而言之,就是将类、接口、字段和方法的符号引用转为直接引用

  • 具体描述

    • 符号引用就是一些字面量的引用,和虚拟机的内部数据结构和内存布局无关。比较容易理解的就是在Class文件中,通过常量池进行了大量的符号引用。但是在程序实际运行的时候,只有符号引用是不够用的,比如当下 println() 方法被调用的时候,系统需要明确知道该方法的位置。
  • 举例:输出操作 System.out.println() 对应的字节码

    • invokevirtual #24 <java/io/PrintStream.println>

    • 以方法为例,Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法的位置,从而使得方法被调用成功
  • 小结

    • 所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。
    • 不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在Hotspot VM 中,加载、验证、准备和初始化会按照顺序有条不紊的执行,但是链接阶段中的解析操作往往会伴随着JVM在执行完初始化操作之后再执行。
  • 字符串的复习

    • 最后,再来看一下 CONSTANT_String 的解析。由于字符串在程序开发中有着重要的作用,因此,读者有必要了解一下 String 在 Java 虚拟机中的处理。当在 Java 代码中直接使用字符串常量时,就会在类中出现 CONSTANT_String,它表示字符串常量,并且会引用一个 CONSTANT_UTF8 的常量项。在 Java 虚拟机内部运行中的常量池,会维护一张字符串拘留表(intern),它会保存所有出现过的字符串常量,并且没有重复项。只要以 CONSTANT_String 形式出现的字符串也都会在这张表中。使用 String.intern() 方法可以得到一个字符串在拘留表中的引用,因为该表中没有重复项,所以任何字面相同的字符串的 String.intern() 方法返回总是相等的 。

过程三:Initialization(初始化)阶段

  • 初始化阶段, 简言之,为类的静态变量赋予正确的初始值
  • 具体描述
    • 类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时,类才会开始执行 Java 字节码。(即:到了初始化阶段,才真正开始执行类中定义的 Java 程序代码)
    • 初始化阶段的重要工作是执行类的初始化方法:() 方法
      • 该方法仅能由 Java 编译器生成并由 JVM 调用,程序开发者无法自定义一个同名的方法,更无法直接在 Java 程序中调用该方法,虽然该方法也是由字节码指令所组成
      • 它是类静态成员的赋值语句以及 static 语句块合并产生的
  • 说明
    • 在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的<client>总在子类<client>之前被调用。也就是说,父类的static块优先于子类
      • 由父及子,静态先行。
    • Java编译器并不会为所有的类都产生<client>() 初始化方法。哪些类在编译成字节码文件后,字节码文件中将不包含<clinet>() 方法?
      • 一个类中并没有声明任何的类变量,也没有静态代码块的时候
      • 一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作的时候
      • 一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式
      • 一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时
      • 一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式

static 与 final 的搭配问题

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
/**
*
* 哪些场景下,Java 编译器就不会生成<clinit>()方法
*/
public class InitializationTest1 {
//场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
public int num = 1;
//场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
public static int num1;
//场景3:比如对于声明为 static final 的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
public static final int num2 = 1;
}

/**
*
* 说明:使用 static + final 修饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?
* 情况1:在链接阶段的准备环节赋值
* 情况2:在初始化阶段<clinit>()中赋值
*
* 结论:
* 在链接阶段的准备环节赋值的情况:
* 1. 对于基本数据类型的字段来说,如果使用 static final 修饰,则显式赋值(直接赋值常量,而非调用方法)通常是在链接阶段的准备环节进行
* 2. 对于 String 来说,如果使用字面量的方式赋值,使用 static final 修饰的话,则显式赋值通常是在链接阶段的准备环节进行
*
* 在初始化阶段<clinit>()中赋值的情况
* 排除上述的在准备环节赋值的情况之外的情况
*
* 最终结论:使用 static + final 修饰,且显示赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值,是在链接阶段的准备环节进行
*/
public class InitializationTest2 {
public static int a = 1; //在初始化阶段<clinit>()中赋值
public static final int INT_CONSTANT = 10; //在链接阶段的准备环节赋值

public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100); //在初始化阶段<clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000); //在初始化阶段<clinit>()中赋值

public static final String s0 = "helloworld0"; //在链接阶段的准备环节赋值
public static final String s1 = new String("helloworld1"); //在初始化阶段<clinit>()中赋值

}

< client >() 的线程安全性问题

  • 对于<client>()方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性
  • 虚拟机会保证一个类的 <client>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <client>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <client>() 方法完毕
  • 正是因为函数<client>() 带锁线程安全的,因此,如果一个在类的 <client>() 方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息
  • 如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行 <client>() 方法了。那么,当需要使用这个类时,虚拟机会直接返回给它已经准备好的信息

类的初始化情况:主动使用VS被动使用

  • Java程序对类的使用分为两种:主动使用和被动使用

  • 主动使用:Class 只有在必须要首次使用的时候才会被装载,Java 虚拟机不会无条件地装载 Class 类型。Java 虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的”使用”,是指主动使用,主动使用只有下列几种情况:(即:如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成)

    1. 当创建一个类的实例时,比如使用 new 关键字,或者通过反射、克隆、反序列化
    2. 当调用类的静态方法时,即当使用了字节码 invokestatic 指令
    3. 当使用类、接口的静态字段时(final 修饰特殊考虑),比如,使用getstatic或者putsttic指令。(对应访问变量、赋值变量操作)
    4. 当使用java.lang.reflect包中的方法反射类的方法时。比如:Class.forname("java.lang.String")
    5. 当初始化子类时,如果发现其分类还没有进行过初始化,则需要先触发其父类的初始化
    6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化
    7. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类
    8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。(涉及解析 REF_getStatic、REF_putStatic、REF_invokeStatic 方法句柄对应的类)
  • 针对5,补充说明:

    • 当 Java 虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口

      • 在初始化一个类时,并不会先初始化它所实现的接口
      • 在初始化一个接口时,并不会先初始化它的父接口
    • 因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化,只有当程序首次使用特定接口的静态字段时,才会导致该接口的初始化

  • 针对7,补充说明:

    • JVM 启动的时候通过引导类加载器加载一个初始类。这个类在调用 public static void main(String[]) 方法之前被链接和初始化。这个方法的执行将依次导致所需的类的加载、链接和初始化
  • 被动使用:除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化,也就是说:并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化

    1. 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化
    2. 当通过子类引用父类的静态变量,不会导致子类初始化
    3. 通过数组定义类引用,不会触发此类的初始化
    4. 引用变量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了
    5. 调用 ClassLoader 类的 loadClass() 方法加载一个类,并不是对类的主动使用,不会导致类的初始化
  • 如果针对代码,设置参数-XX:+TraceClassLoading,可以追踪类的加载信息并打印出来

过程四:类的Using(使用)

  • 任何一个类型在使用之前都必须经历过完整的加载、链接和初始化3个类加载步骤。一旦一个类型成功经历过这3个步骤之后,便“万事俱备,只欠东风”,就等着开发者使用了

  • 开发人员可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静态方法),或者使用 new 关键字为其创建对象实例

过程五:类的Unloading(卸载)

  • 类、类的加载器、类的实例之间的引用关系

    • 在类加载器的内部实现中,用一个 Java 集合来存放所加载类的引用。另一方面,一个 Class 对象总是会引用它的类加载器,调用 Class 对象的 getClassLoader() 方法,就能获得它的类加载器。由此可见,代表某个类的 Class 实例与其类的加载器之间为双向关联关系
    • 一个类的实例总是引用代表这个类的 Class 对象。在 Object 类中定义了 getClass() 方法,这个方法返回代表对象所属类的 Class 对象的引用。此外,所有的 Java 类都有一个静态属性 Class,它引用代表这个类的 Class 对象
  • 类的生命周期

    • 当Simple类被加载、链接和初始化之后,它的生命周期就开始了。当代表Sample类的Class对象不再被引用,即不可触及的时候,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。
    • 一个类何是结束生命周期,取决于代表它的Class对象何是结束生命周期。
  • 具体例子

    • Loader1变量和obj变量间接应用代表Sample类的Class对象,而objClass变量则直接引用它
    • 如果程序运行过程中,将上图左侧三个引用变量都置为null,此时Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据被卸载
    • 当再次有需要时,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载;如果不存在 Sample类会被重新加载,在Java虚拟机的堆区会生成一个新的代表Sample类的Class实例(可以通过哈希码查看是否是同一个实例)
  • 类的卸载

    • 启动类加载器加载的类型在整个运行期间是不可能被卸载的(JVM 和 JSL 规范)

    • 被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到 unreachable 的可能性极小

    • 被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到。可以预想,稍微复杂点的应用场景(比如:很多时候用户在开发自定义类的加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)

    • 综合以上三点,一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能

回顾:方法区的垃圾回收

  • 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量不再使用的类型

  • HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

  • 判定一个常量是否”废弃”还是相对简单,而要判定一个类型是否属于”不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

    • 该类所有的实例都已经被回收。也就是 Java 堆中不存在该类及其任何派生子类的实例
    • 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGI、JSP 的重加载等,否则通常是很难达成的
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是”被允许”,而并不是和对象一样,没有引用了就必然会回收

    再谈类的加载器

概述

  • 类加载器是JVM执行类加载机制的前提
  • ClassLoader的作用:ClassLoader 是 Java 的核心组件,所有的Class都是由 ClassLoader 进行加载的,ClassLoader 负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机尽心链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过 ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine决定

  • 类加载器最早出现在 Java 1.0 版本中,那个时候只是单纯地为了满足 Java Applet 应用而被研发出来,但如今类加载器却在 OSGI、字节码加解密领域大放异彩。这主要归功于 Java 虚拟机的设计者们当初在设计类加载器的时候,并没有考虑将它绑定在 JVM 内部,这样做的好处就是能够更加灵活和动态地执行类加载操作

大厂面试题

  • 蚂蚁金服:

    • 深入分析 ClassLoader,双亲委派机制

    • 类加载器的双亲委派模型是什么?

    • 一面:双亲委派机制及使用原因

  • 百度:

    • 都有哪些类加载器,这些类加载器都加载哪些文件?

    • 手写一个类加载器 Demo

    • Class 的 forName(“java.lang.String”) 和 Class 的 getClassLoader() 的 loadClass(“java.lang.String”)有什么区别?

  • 腾讯:

    • 什么是双亲委派模型?

    • 类加载器有哪些?

  • 小米:

    • 双亲委派模型介绍一下
  • 滴滴:

    • 简单说说你了解的类加载器

    • 一面:讲一下双亲委派模型,以及其优点

  • 字节跳动

    • 什么事类加载器,类加载器有哪些?
  • 京东:

    • 类加载器的双亲委派模型是什么?

    • 双亲委派机制可以打破吗?为什么?

类加载器的分类

  • 分类:显式加载 VS 隐式加载
  • Class文件的显式加载与隐式加载的方式是指JVM加载Class文件到内存的方式
    • 显式加载指的是在代码中通过调用ClassLoader加载Class对象,如直接使用Class.forName(name)或者this.getClass().getClassLoader().loadClass()加载Class对象
    • 隐式加载则是不直接在代码中调用ClassLoader的方法加载Class对象,而是通过虚拟机字段加载到内存中,如在加载某个类的class文件的时候,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中。
  • 日常开发中以上两种方式一般会混合使用。

类加载器的必要性

  • 一般情况下,Java 开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。从以下几个方面说:
    • 避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDeFoundError异常时手足无措。只有了解类加载器的加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题
    • 需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了
    • 开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑

命名空间

  • 何为类的唯一性?
    • 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个 Class 文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等
  • 命名空间
    • 每个类加载器都有自己的命名空间,命名空间由该加载器所有的父加载器所加载的类组成
    • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
    • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类
  • 在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本

类加载机制的基本特征

  • 通常类加载机制有三个基本特征:
    • 双亲委派模型。但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如 JDK 内部的 ServiceProvider/ServiceLoader 机制,用户可以在标准 API 框架上,提供自己的实现,JDK 也需要提供些默认的参考实现。例如,Java 中 JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器
    • 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑
    • 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器”邻居”间,同一类型仍然可以被加载多次,因为相互并不可见

复习:类的加载器的分类

  • JVM 支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
  • 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是 Java 虚拟机规范却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:

类的加载器

  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的”父类”加载器
  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ClassLoader {
ClassLoader parent; //父类加载器

public ClassLoader(ClassLoader parent) {
this.parent = parent;
}
}

class ParentClassLoader extends ClassLoader {
public ParentClassLoader(ClassLoader parent) {
super(parent);
}
}

class ChildClassLoader extends ClassLoader {
public ChildClassLoader(ClassLoader parent) {
//parent = new ParentClassLoader();
super(parent);
}
}

引导类加载器

  • 启动类加载器(引导类加载器 Bootstrap ClassLoader)
    • 这个类加载使用 C/C++ 语言实现的,嵌套在 JVM 内部
    • 它用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar 或 sun.boot.class.path 路径下的内容)。用于提供 JVM 自身需要的类
    • 并不继承自 java.lang.ClassLoader,没有父加载器
    • 出于安全考虑,Bootstrap 启动类加载器之加载包名为 java、javax、sun 等开头的类
    • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器

  • 使用 -XX:+TraceClassLoading 参数得到

  • 启动类加载器使用C++编写的?对的!

  • C/C++:指针函数和函数指针、C++支持多继承、更加高效

  • Java:由C++演变而来,(C++)– 版本,单继承

扩展类加载器

  • 扩展类加载器(Extension ClassLoader)
    • Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现
    • 继承于ClassLoader
    • 父类加载器为启动类加载器
    • java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载

系统类加载器

  • 应用程序类加载器(系统类加载器,AppClassLoader)
    • Java 语言编写,由sun.misc.Launcher$AppClassLoader实现
    • 继承于ClassLoader
    • 父类加载器为扩展类加载器
    • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
    • 应用程序中的类加载器默认是系统类加载器
    • 它是用户自定义类加载器的默认父加载器
    • 通过ClassLoadergetSystemClassLoader()方法可以获取到该类加载器

用户自定义类加载器

  • 在 Java 的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式
  • 体现 Java 语言强大生命力和巨大魅力的关键因素之一便是,Java 开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的 JAR 包,也可以是网络上的远程资源
  • 通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例不胜枚举。例如,著名的OSGI组件框架,再如 Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无需重新打包发布应用程序就能实现
  • 同时,自定义加载器能够实现应用隔离,例如 Tomcat、Spring 等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比 C/C++ 程序要好太多,想不修改 C/C++ 程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡所有美好的设想
  • 自定义类加载器通常需要继承于 ClassLoader

测试不同的类加载器

  • 每个 Class 对象都会包含一个定义它的 ClassLoader 的一个引用

  • 获取 ClassLoader 的途径

1
2
3
4
5
6
7
8
获取当前类的 ClassLoader
clazz.getClassLoader();

获得当前线程上下文的 ClassLoader
Thread.currentThread().getContextClassLoader();

获得系统的 ClassLoader
ClassLoader.getSystemClassLoader();
  • 说明:

  • 站在程序的角度看,引导类加载器与另外两种类加载器(系统类加载器和扩展类加载器)并不是同一个层次意义上的加载器,引导类加载器是使用 C++ 语言编写而成的,而另外两种类加载器则是使用Java语言编写的。由于引导类加载器压根儿就不是一个Java类,因此在Java程序中只能打印出空值

  • 数组类的Class对象,不是由类加载器去创建的,而是在Java运行期JVM根据需要自动创建的。对于数组类的类加载器来说,是通过Class.getClassLoader() 返回的,与数组当中元素类型的类加载器是一样的:如果数组当中的元素类型是基本数据类型,数组类是没有类加载器的

1
2
3
4
5
6
7
8
9
10
11
String[] strArr = new String[6];
System.out.println(strArr.getClass().getClassLoader());
//运行结果:null

ClassLoaderTest[] test = new ClassLoaderTest[1];
System.out.println(test.getClass().getClassLoader());
//运行结果:sun.misc.Launcher$AppClassLoader@18b4aac2

int[] inst = new int[2];
System.out.println(inst.getClass().getClassLoader());
//运行结果:null 基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载

ClassLoader源码解析

  • ClassLoader与现有类加载器的关系

  • 除了以上虚拟机自带的加载器之外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader,所有的用户自定义的类加载器都应该继承自 ClassLoader类

ClassLoader的主要方法

  • 抽象类 ClassLoader 的主要方法:(内部没有抽象方法)

  • public final ClassLoader getParent()

    • 返回该类加载器的超类加载器
  • public Class<?> loadClass(String name) throws ClassNotFoundException

    • 加载名称为 name 的类,返回结果为 java.lang.Class 类的实例。如果找不到类,则返回 ClassNotFountException 异常。该方法中的逻辑就是双亲委派模式的实现
  • protected Class<?> findClass(String name) throws ClassNotFoundException

    • 查找二进制名称为 name 的类,返回结果为 java.lang.Class 类的实例。这是一个受保护的方法,JVM 鼓励我们重写此方法,需要自定义加载器遵循双亲委派机制,该方法会在检查完父类加载器之后被 loadClass() 方法调用
    • 在 JDK 1.2 之前,在自定义类加载时,总会去继承 ClassLoader 类并重写 loadClass 方法,从而实现自定义的类加载类。但是在 JDK 1.2 之后已不再建议用户去覆盖 loadClass() 方法,而是建议把自定义的类加载逻辑写在 find Class() 方法中,从前面的分析可知,findClass() 方法是在 loadClass() 方法中被调用的,当 loadClass() 方法中父加载器加载失败后,则会调用自己的 findClass() 方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委派机制
    • 需要注意的是 ClassLoader 类中并没有实现 findClass() 方法的具体代码逻辑,取而代之的是抛出 ClassNotFoundException 异常,同时应该知道的是 findClass() 方法通常是和 defineClass() 方法一起使用的。一般情况下,在自定义类加载器时,会直接覆盖 ClassLoader 的 findClass() 方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用 defineClass() 方法生成类的 Class 对象
  • protected final Class<?> defineClass(String name, byte[] b, int off, int len)

    • 根据给定的字节数组 b 转换为 Class 的实例,off 和 len 参数表示实际 Class 信息在 byte 数组中的位置和长度,其中 byte 数组 b 是 ClassLoader 从外部获取的。这是受保护的方法,只有在自定义 ClassLoader 子类中可以使用
    • defineClass() 方法是用来将 byte 字节流解析成 JVM 能够识别的 Class 对象(ClassLoader 中已实现该方法逻辑),通过这个方法不仅能够通过 Class 文件实例化 Class 对象,也可以通过其它方式实例化 Class 对象,如通过网络中接收一个类的字节码,然后转换为 byte 字节流创建对应的 Class 对象
    • defineClass() 方法通常与 findClass() 方法一起使用,一般情况下,在自定义类加载器时,会直接覆盖 ClassLoader 的 findClass() 方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用 defineClass() 方法生成类的 Class 对象
  • 简单举例:

1
2
3
4
5
6
7
8
9
10
protected Class<?> findClass(String name) throws ClassNotFoundException {
//获取类的字节数组
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
//使用 defineClass 生成 Class 对象
return defineClass(name, classData, 0, classData.length);
}
}
  • protected final void resolveClass(Class<?> c)

    • 链接指定的一个 Java 类。使用该方法可以使用类的 Class 对象创建完成的同时也被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用
  • protected final Class<?> findLoadedClass(String name)

    • 查找名称为 name 的已经被加载过的类,返回结果为 java.lang.Class 类的实例。这个方法是 final 方法,无法被修改
  • private final ClassLoader parent;

    • 它也是一个 ClassLoader 的实例,这个字段所表示的 ClassLoader 也称为这个 ClassLoader 的双亲。在类加载的过程中,ClassLoader 可能会将某些请求交予自己的双亲处理

loadClass()的剖析

  • 测试代码:

  • ClassLoader.getSystemClassLoader().loadClass(“cn.xiao.java.User”)

  • 涉及到对如下方法的调用:``

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
// resolve:true 的时候,加载class的时候同时进行解析操作
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
}

// 当前类的加载器的父类加载器为加载此类 or 当前类加载器未加载
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 调用当前ClassLoader的findClass() 方法
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
// 是否进行解析操作
if (resolve) {
resolveClass(c);
}
return c;
}
}

SecureClassLoader 与 URLClassLoader

  • 接着SecureClassLoader扩展了ClassLoader,新增了几个与使用相关的代码源(对代码源的位置及其证书的验证)和权限定义类验证(主要针对 Class 源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多的是与它的子类 URLClassLoader 有所关联

  • 前面说过,ClassLoader是一个抽象类,很多方法是空的没有实现,比如findClass()findResource() 等。而 URLClassLoader这个实现类为这些方法提供了具体的实现。并新增了URLClassPath类协助取得Class字节码流等功能。在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URLClassLoader 类,这样就可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

ExtClassLoader 与 AppClassLoader

  • ExtClassLoader 并没有重写 loadClass() 方法,这足以说明其遵循双亲委派模式,而 AppClassLoader 重载了 loadClass() 方法,但最终调用的还是父类 loadClass() 方法,因此依然遵循双亲委派模式

Class.forName() 与 ClassLoader.loadClass()

  • Class.forName():是一个静态方法,最常用的是Class.forName(String className);根据传入的类的权限定名返回一个 Class对象。该方法在将 Class 文件加载到内存的同时,会执行类的初始化。如:Class.forName("cn.xiao.java.HelloWorld");

  • ClassLoader.loadClass() 这是一个实例方法,需要一个ClassLoader对象来调用该方法。该方法将Class文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化。该方法因为需要得到一个 ClassLoader 对象,所以可以根据需要指定使用哪个类加载器,如:ClassLoader c1 = .....; c1.loadClass("cn.xiao.java.HelloWorld");

双亲委派模型

定义与本质

  • 类加载器用来把类加载到 Java 虚拟机中。从JDK1.2版本开始,类的加载过程采用双亲委派机制,这种机制能更好地保证Java平台的安全

  • 定义

    • 如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载
  • 本质

    • 规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载

优势与劣势

  • 优势

    • 避免类的重复加载,确保一个类的全局唯一性

    • Java 类随着它的类加载器一起具备了一种带有优先级的层级关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次

    • 保护程序安全,防止核心API被随意篡改

  • 代码支持

    • 双亲委派机制在java.lang.ClassLoader.loadClass(String, boolean)接口中体现。该接口的逻辑如下:
      1. 先在当前加载器的缓存中查找有无目标类,如果有,直接返回
      2. 判断当前加载器的父加载器是否为空,如果不为空,则调用 parent.loadClass(name, false) 接口进行加载
      3. 反之,如果当前加载器的父类加载器为空,则调用 findBootstrapClassOrNull(name) 接口,让引导类加载器进行加载
      4. 如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。该接口最终会调用 java.lang.ClassLoader接口的defineClass系列的 native 接口加载目标 Java 类
    • 双亲委派的模型就隐藏在第2和第3步中
  • 举例

    • 假设当前加载的是java.lang.Object这个类,很显然,该类属于JDK中核心的不能再核心的一个类,因此一定只能由引导类加载器进行加载。当JVM准备加载java.lang.Object 时,JVM默认会使用系统类加载器去加载,按照上面4步加载的逻辑,在第1步从系统类的缓存中肯定查找不到该类,于是进入第2步。由于从系统类加载器的父类加载器是扩展类加载器,于是扩展类加载器继续从第1步开始重复。由于扩展类加载器的缓存中也一定查找不到该类,因此进入第2步。扩展类的父加载器是null,因此系统调用 findClass(String),最终通过引导类加载器进行加载
  • 思考

    • 如果在自定义的类加载器中重写 java.lang.ClassLoader.loadClass(String) 或 java.lang.ClassLoader.loadClass(String, boolean) 方法,抹去其中的双亲委派机制,仅保留上面这4步中的第1步和第4步,那么是不是就能够加载核心类库了呢?
    • 这也不行!因为 JDK 还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用java.lang.ClassLoader.defineClass(String, byte[], int, int,ProtectionDomain)方法,而该方法会执行 preDefineClass() 接口,该接口中提供了对 JDK 核心类库的保护
  • 双亲委派模式的弊端

    • 检查类是否加载的委派过程是单向的,这个方式虽然从结构上说比较清晰,使各个 ClassLoader 的职责非常明确,但是同时会带来一个问题,即顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类
    • 通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题
  • 结论

    • 由于 Java 虚拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而已。比如Tomcat中,类加载器所采用的加载机制就和传统的双亲委派模型有一定区别,当缺省的类加载器接收到一个类的加载任务时,首先会由它自行加载,当它加载失败时,才会将类的加载任务委派给它的超类加载器去执行,这同时也是Servlet规范推荐的一种做法

破坏双亲委派机制

  • 破坏双亲委派机制1

    • 双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式

    • 在Java的世界中大部分的类加载器都遵循这个模型,但也有例外情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模”被破坏”的情况

    • 第一次破坏双亲委派机制:

    • 双亲委派模型的第一次”被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的”远古”时代

    • 由于双亲委派模型在 JDK 1.2 之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader 则在 Java 的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java 设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有的代码,无法再以技术手段避免 loadClass() 被子类覆盖的可能性,只能在 JDK 1.2 之后的 java.lang.ClassLoader 中添加一个新的 protected 方法 findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass() 中编写代码。上节我们已经分析过 loadClass() 方法,双亲委派的具体逻辑就实现在这里面,按照 loadClass() 方法的逻辑,如果父类加载失败,会自动调用自己的 findClass() 方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的

  • 破坏双亲委派机制2

    • 第二次破坏双亲委派机制:线程上下文类加载器
    • 双亲委派模型的第二次”被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为”基础”,是因为它们总是作为被用户代码继承、调用的 API 存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户代码,那该怎么办?
    • 这并非是不可能出现的事情,一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器来完成加载(在 JDK 1.3 时加入到 rt.jar),肯定属于 Java 中很基础的类型了。但 JNDI 存在的目的就是对资源进行查找和集中管理,它需要调用由其它厂商实现并部署在应用程序的 ClassPath 下的 JNDI 服务提供者接口(Service Provider Interface. SPI) 的代码,现在问题来了,启动类加载器时绝对不可能认识、加载这些代码的,那该怎么办?(SPI:在 Java 平台中,通常把核心类 rt.jar 中提供外部服务、可由应用层自行实现的接口称为 SPI)
    • 为了解决这个困境,Java 的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器
    • 有了线程上下文类加载器,程序就可以做一些”舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的 SPI服务代码。这是一种负累加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java 中涉及 SPI 的加载基本上都采用这种方式来完成,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。不过,当 SPI 的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的方式,在JDK6时,JDK 提供了java.util.ServiceLoader类,以META-INF/Services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案

    • 默认上下文加载器就是应用类加载器,这样以上下文加载器为中介,使得启动类加载器中的代码也可以访问应用类加载器中的类
  • 破坏双亲委派机制3

    • 第三次破坏双亲委派机制:

      • 双亲委派模型的第三次”被破坏”是由于用户对程序动态性的追求而导致的。如:**代码热替换(Hot Swap)模块热部署(Hot Deployment)**等

      • IBM 公司主导的 JSR-291(即 OSGI R4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每个程序模块(OSGI中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGI环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构

      • 当收到类加载请求时,OSGI 将按照下面的顺序进行类搜索:

        • 将以 java.* 开头的类,委派给父类加载器加载
        • 否则,将委派列表名单内的类,委派给父类加载器加载
        • 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载
        • 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
        • 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载
        • 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载
        • 否则,类查找失败
      • 说明:只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的

    • 小结:

      • 这里,我们使用了”被破坏”这个词来形容上述不符合双亲委派模型原则的行为,但这里”被破坏”并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新
      • 正如:OSGI中的类加载器的设计不符合传统的双亲委派的类加载器架构,且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但对这方面有了解的技术人员基本还是能达成一个共识,认为OSGI中对类加载器的运用是值得学习的,完全弄懂了OSGI的实现,就算是掌握了类加载器的精髓

热替换的实现

  • 热替换是指在程序运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中。基本上大部分脚本语言都是天生支持热替换的,比如:PHP,只要替换了 PHP 源文件,这种改动就会立即生效,而无需重启 Web 服务器
  • 但对Java来说,热替换并非天生就支持,如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来加载并重定义这个类。因此,在 Java 中实现这一功能的一个可行的方法就是灵活运ClassLoader
  • 注意:由不同ClassLoader加载的同名类属于不同的类型,不能相互转换和兼容。即两个不同的ClassLoader加载同一个类,在虚拟机内部,会认为这2个类是完全不同的
  • 根据这个特点,可以用来模拟热替换的实现,基本思路如下图所示:

沙箱安全机制

  • 保护程序安全
  • 保护 Java 原生的 JDK 代码
  • **Java 安全模型的核心就是 Java 沙箱(Sandbox)**,什么是沙箱?沙箱就是一个限制程序运行的环境
  • 沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏
  • 沙箱主要限制系统资源访问,那系统资源包括什么?CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样
  • 所有的 Java 程序运行都可以指定沙箱,可以定制安全策略

JDK1.0时期

  • 在 Java 中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的 Java 实现中,安全依赖于沙箱(Sandbox)机制。如下图所示 JDK 1.0 安全模型

JDK1.0

JDK1.1时期

  • JDK 1.0 中如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现
  • 因此在后续的 JDK 1.1 版本中,针对安全机制做了改进,增加了安全策略。允许用户指定代码对本地资源的访问权限
  • 如下图所示 JDK 1.1 安全模型

JDK1.1

JDK1.2时期

  • 在 JDK 1.2 版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示 JDK 1.2 安全模型:

JDK1.2

JDK1.6时期

  • 当前最新的安全机制实现,则引入了**域(Domain)**的概念
  • 虚拟机会把所有代码加载到不同的系统域和应用域。系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示,最新的安全模型(JDK 1.6)

JDK1.6

自定义类加载器

  • 为什么要自定义类加载器?

    • 隔离加载类
    • 在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的 jar 包不会影响到中间件运行时使用的 jar 包。再比如:Tomcat 这类 Web 应用服务器,内部自定义了好几种类加载器,用于隔离同一个 Web 应用服务器上的不同应用程序。(类的仲裁 –> 类冲突)
  • 修改类加载的方式

    • 类的加载模型并非强制,除 Bootstrap 外,其他的加载并非一定要引入,或者根据实际情况在某个时间点按需进行动态加载
  • 扩展加载源

    • 比如从数据库、网络、甚至是电视机机顶盒进行加载
  • 防止源码泄露

    • Java 代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码
  • 常见的场景

    • 实现类似进程内隔离,类加载器实际上用作不同的命名空间,以提供类似容器、模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器加载,就可以互不干扰。这个方面的集大成者是 Java EE 和 OSGI、JPMS 等框架
    • 应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。或者是需要自己操纵字节码,动态修改或者生成类型
  • 注意

    • 在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性。但是,如果涉及 Java 类型转换,则加载器反而容易产生不美好的事情。在做 Java 类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常

实现方式

  • 用户通过定制自己的类加载器,这样可以重新定义类的加载规则,以便实现一些自定义的处理逻辑

  • 实现方式

    • Java 提供了抽象类 java.lang.ClassLoader,所有用户自定义的类加载器都应该继承 ClassLoader 类

    • 在自定义 ClassLoader 的子类时候,我们常见的会有两种做法:

      • 方式一:重写 loadClass() 方法
      • 方式二:重写 findClass() 方法 –> 推荐使用
    • 对比

      • 这两种方法本质上差不多,毕竟 loadClass() 也会调用 findClass(),但是从逻辑上讲我们最好不要直接修改 loadClass() 的内部逻辑。建议的做法是只在 findClass() 里重写自定义类的加载方法,根据参数指定类的名字,返回对应的 Class 对象的引用
    • loadClass() 这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写 loadClass() 方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择

    • 当编写好自定义类加载器后,便可以在程序中调用 loadClass() 方法来实现类加载操作

  • 说明

    • 其父类加载器是系统类加载器
    • JVM 中的所有类加载都会使用 java.lang.ClassLoader.loadClass(String) 接口(自定义类加载器并重写 java.lang.ClassLoader.loadClass(String) 接口的除外),连JDK的核心类库也不能例外
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
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;

/**
* @Author XiaoYu
* @Description 自定义类加载器
* @Datetime 2022-05-18 16:38:56
*/
public class CustomClassLoader extends ClassLoader {
private String byteCodePath;

public CustomClassLoader(String byteCodePath) {
this.byteCodePath = byteCodePath;
}

public CustomClassLoader(ClassLoader parent, String byteCodePath) {
super(parent);
this.byteCodePath = byteCodePath;
}

@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
// 获取字节码文件的完整路径
String fileName = byteCodePath + className + ".class";
// 获取文件的字节流
byte[] byteCodes = IoUtil.readBytes(FileUtil.getInputStream(fileName));
// 调用defineClass()将字节数组的数组转换为Class的实例
return defineClass(null, byteCodes, 0, byteCodes.length);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import cn.hutool.core.lang.Console;
import lombok.SneakyThrows;

/**
* @Author XiaoYu
* @Description
* @Datetime 2022-05-18 16:39:33
*/
public class CustomClassLoaderTest {
@SneakyThrows
public static void main(String[] args) {
CustomClassLoader loader = new CustomClassLoader("D:/");
Class<?> clazz = loader.findClass("User");
Console.log("加载此类的类加载器为:{},加载的类为:{}", clazz.getClassLoader().getClass().getName(), clazz.getName());

Console.log("当前加载User类的类的加载器的父类加载器为:{}", clazz.getClassLoader().getParent().getClass().getName());
}
}
1
2
3
// 输出
加载此类的类加载器为:com.xiao.learning.jvm.chapter20.CustomClassLoader,加载的类为:com.xiao.learning.jvm.chapter20.User
当前加载User类的类的加载器的父类加载器为:sun.misc.Launcher$AppClassLoader

JDK9的新特性

  • 为了保证兼容性,JDK 9没有从根本上改变三层类加载器架构和双亲委派模型,但为了模块化系统的顺利运行,仍然发生了一些值得被注意的变动
  • 扩展机制被移除,扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(Platform Class Loader)。可以通过ClassLoader的新方法getPlatformClassLoader() 来获取
  • JDK 9 时基于模块化进行构建(原来的 rt.jar 和 tools.jar 被拆分成数十个 JMOD 文件),其中的 Java 类库就已天然地满足了可扩展的需求,那自然无需再保留 \lib\ext 目录,此前使用这个目录或者 java.ext.dirs 系统变量来扩展 JDK 功能的机制已经没有继续存在的价值了
  • 平台类加载器和应用程序类加载器都不再继承自 java.net.URLClassLoader
  • 现在启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader

JDK9 ClassLoader

  • 如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK 9及更高版本的 JDK 中崩溃

  • 在 Java 9 中,类加载器有了名称。该名称在构造方法中指定,可以通过 getName() 方法来获取。平台类加载器的名称是 Platform,应用类加载器的名称是 App。类加载器的名称在调试与类加载器相关的问题时会非常有用

  • 启动类加载器现在是在 JVM 内部和 Java 类库共同协作实现的类加载器(以前是 C++ 实现),但为了与之前代码兼容,在获取启动类加载器的场景中仍然会返回 null,而不会得到 BootClassLoader 实例

  • 类加载的委派关系也发生了变动

  • 当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责哪个模块的加载器完成加载

  • 双亲委派模式示意图 (左侧是JDK9之前的双亲委派机制,JDK9之后的双亲委派机制)

  • 附加

    • 在Java模块化系统明确规定了三个类加载器负责各自加载的模块:
  • 启动类加载器负责加载的模块

1
2
3
4
5
6
7
8
9
10
java.base                                   java.security.sasl
java.datatransfer java.xml
java.desktop jdk.httpserver
java.instrument jdk.internal.vm.ci
java.logging jdk.management
java.management jdk.management.agent
java.management.rmi jdk.naming.rmi
java.naming jdk.net
java.prefs jdk.sctp
java.rmi jdk.unsupported
  • 平台类加载器负责加载的模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java.activation*                    jdk.accessibility
java.compiler* jdk.charsets
java.corba* jdk.crypto.cryptoki
java.scripting jdk.crypto.ec
java.se jdk.dynalink
java.se.se jdk.incubator.httpclient
java.security.jgss jdk.internal.vm.compiler*
java.smartcardio jdk.jsobject
java.sql jdk.localedata
java.sql.rowset jdk.naming.dns
java.transaction* jdk.scripting.nashorn
java.xml.bind* jdk.security.auth
java.xml.crypto jdk.security.jgss
java.xml.ws* jdk.xml.dom
java.xml.ws.annotation* jdk.zipfs
  • 应用程序类加载器负责加载的模块
1
2
3
4
5
6
jdk.aot                                     jdk.jdeps
jdk.attach jdk.jdi
jdk.compiler jdk.jdwp.agent
jdk.editpad jdk.jlink
jdk.hotspot.agent jdk.jshell
jdk.internal.ed jdk.jstatd

字节码与类的加载
https://xiaoyu72.com/articles/9b73f810/
Author
XiaoYu
Posted on
April 20, 2022
Updated on
August 28, 2023
Licensed under