JVM

JVM 简介

JVM(Java 虚拟机, Java virtual machine), Java 虚拟机,一种能够运行 Java bytecode 的虚拟机,以堆栈结构机器来进行实做。最早由 Sun 微系统所研发并实现第一个实现版本,是 Java 平台的一部分,能够运行以 Java 语言写作的软件程序。 Java 虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。

Java 虚拟机是整个 Java 平台的基石,是 Java 技术用以实现硬件无关与操作系统无关的关键部分,是 Java 语言生成出极小体积的编译代码的运行平台,是保障用户机器免于恶意代码损害的保护屏障。

历史和特性

第一个 Java 虚拟机的原型机是由 Sun Microsystems 公司实现的,它被用在一种类似 PDA(Personal Digital Assistant,俗称掌上电脑)的手持设备上仿真实现 Java 虚拟机指令集。时至今日, Oracle 已有许多 Java 虚拟机实现应用于移动设备、桌面电脑、服务器等领域。

Java 虚拟机并不局限于特定的实现技术、主机硬件和操作系统, Java 虚拟机也不局限于特定的代码执行方式,它不强求使用解释器来执行程序,也可以通过把自己的指令集编译为实际 CPU 的指令来实现,它可以通过微代码(Microcode)来实现,或者甚至直接实现在 CPU 中。

JVM 知识体系总览

JVM 知识体系

jvm知识体系

JVM 结构

  1. 类加载器(Class Loader):加载字节码文件到内存。

  2. 运行时数据区(Runtime Data Area):JVM 核心内存空间结构模型。

  3. 执行引擎(Execution Engine):对 JVM 指令进行解析,翻译成机器码,解析完成后提交到操作系统中。

  4. 本地库接口(Native Interface):供 Java 调用的融合了不同开发语言的原生库。

  5. 本地方法库(Native Libraies):Java 本地方法的具体实现。

jvm结构

JVM 问题分析与定位

jvm问题分析与定位

字节码详解

字节码编译

计算机不能直接运行 java 代码,必须要先把 java 代码文件( .java 文件)编译为字节码文件(.class 文件),接着运行 java 虚拟机,再由 java 虚拟机运行编译后的字节码文件。

Java 代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的 JVM 虚拟机去读取执行,从而实现一次编写,到处运行的目的。

JVM 也不再只支持 Java,由此衍生出了许多基于 JVM 的编程语言,如 Groovy, Scala, Koltin 等等。

字节码文件

class 文件本质上是一个以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在 class 文件中。jvm 根据其特定的规则解析该二进制数据,从而得到相关信息。

Class 文件采用一种伪结构来存储数据,它有两种类型:无符号数和表。

反编译字节码文件

使用到 java 内置的一个反编译工具 javap 可以反编译字节码文件, 用法:

1
javap <options> <classes>

其中<options>选项包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-help  --help  -?        输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示 public 类和成员
-protected 显示 protected/public 类和成员
-package 显示 package/protected/public 类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath <path> 指定查找用户类文件的位置
-cp <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置

例子

下面以一个简单的例子来逐步认识字节码。

java 源文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package test.javap;

/**
* @author C
* @date 2022/7/29
*/
public class Main {
private int m;

public static void main(String[] args) {
}

public int inc() {
return m + 1;
}

public int foo() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
}

生成 class 文件

通过以下命令, 可以在当前所在路径下生成一个 Main.class 文件。

1
javac Main.java

以文本的形式打开生成的 class 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
cafe babe 0000 0034 0025 0a00 0500 1f09
0004 0020 0700 2107 0022 0700 2301 0001
6d01 0001 4901 0006 3c69 6e69 743e 0100
0328 2956 0100 0443 6f64 6501 000f 4c69
6e65 4e75 6d62 6572 5461 626c 6501 0012
4c6f 6361 6c56 6172 6961 626c 6554 6162
6c65 0100 0474 6869 7301 0011 4c74 6573
742f 6a61 7661 702f 4d61 696e 3b01 0004
6d61 696e 0100 1628 5b4c 6a61 7661 2f6c
616e 672f 5374 7269 6e67 3b29 5601 0004
6172 6773 0100 135b 4c6a 6176 612f 6c61
6e67 2f53 7472 696e 673b 0100 104d 6574
686f 6450 6172 616d 6574 6572 7301 0003
696e 6301 0003 2829 4901 0003 666f 6f01
0001 7801 0001 6501 0015 4c6a 6176 612f
6c61 6e67 2f45 7863 6570 7469 6f6e 3b01
000d 5374 6163 6b4d 6170 5461 626c 6507
0021 0700 2401 000a 536f 7572 6365 4669
6c65 0100 094d 6169 6e2e 6a61 7661 0c00
0800 090c 0006 0007 0100 136a 6176 612f
6c61 6e67 2f45 7863 6570 7469 6f6e 0100
0f74 6573 742f 6a61 7661 702f 4d61 696e
0100 106a 6176 612f 6c61 6e67 2f4f 626a
6563 7401 0013 6a61 7661 2f6c 616e 672f
5468 726f 7761 626c 6500 2100 0400 0500
0000 0100 0200 0600 0700 0000 0400 0100
0800 0900 0100 0a00 0000 2f00 0100 0100
0000 052a b700 01b1 0000 0002 000b 0000
0006 0001 0000 0007 000c 0000 000c 0001
0000 0005 000d 000e 0000 0009 000f 0010
0002 000a 0000 002b 0000 0001 0000 0001
b100 0000 0200 0b00 0000 0600 0100 0000
0b00 0c00 0000 0c00 0100 0000 0100 1100
1200 0000 1300 0000 0501 0011 0000 0001
0014 0015 0001 000a 0000 0031 0002 0001
0000 0007 2ab4 0002 0460 ac00 0000 0200
0b00 0000 0600 0100 0000 0e00 0c00 0000
0c00 0100 0000 0700 0d00 0e00 0000 0100
1600 1500 0100 0a00 0000 c200 0100 0500
0000 1804 3c1b 3d06 3c1c ac4d 053c 1b3e
063c 1dac 3a04 063c 1904 bf00 0400 0000
0400 0800 0300 0000 0400 1100 0000 0800
0d00 1100 0000 1100 1300 1100 0000 0300
0b00 0000 2e00 0b00 0000 1400 0200 1500
0400 1a00 0600 1500 0800 1600 0900 1700
0b00 1800 0d00 1a00 0f00 1800 1100 1a00
1500 1b00 0c00 0000 3400 0500 0200 0600
1700 0700 0100 0900 0800 1800 1900 0200
0b00 0600 1700 0700 0100 0000 1800 0d00
0e00 0000 1500 0300 1700 0700 0100 1a00
0000 0a00 0248 0700 1b48 0700 1c00 0100
1d00 0000 0200 1e

反编译 class 文件

输入命令 javap -verbose -p Main.class 查看输出内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
Classfile /D:/Project/test/javap/Main.class
Last modified 2022-7-29; size 823 bytes
MD5 checksum 690a2c3b12765481dadabc27d3967ca7
Compiled from "Main.java"
public class test.javap.Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#31 // java/lang/Object."<init>":()V
#2 = Fieldref #4.#32 // test/javap/Main.m:I
#3 = Class #33 // java/lang/Exception
#4 = Class #34 // test/javap/Main
#5 = Class #35 // java/lang/Object
#6 = Utf8 m
#7 = Utf8 I
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Ltest/javap/Main;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 MethodParameters
#20 = Utf8 inc
#21 = Utf8 ()I
#22 = Utf8 foo
#23 = Utf8 x
#24 = Utf8 e
#25 = Utf8 Ljava/lang/Exception;
#26 = Utf8 StackMapTable
#27 = Class #33 // java/lang/Exception
#28 = Class #36 // java/lang/Throwable
#29 = Utf8 SourceFile
#30 = Utf8 Main.java
#31 = NameAndType #8:#9 // "<init>":()V
#32 = NameAndType #6:#7 // m:I
#33 = Utf8 java/lang/Exception
#34 = Utf8 test/javap/Main
#35 = Utf8 java/lang/Object
#36 = Utf8 java/lang/Throwable
{
private int m;
descriptor: I
flags: ACC_PRIVATE

public test.javap.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltest/javap/Main;

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 11: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 args [Ljava/lang/String;
MethodParameters:
Name Flags
args

public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 14: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Ltest/javap/Main;

public int foo();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=5, args_size=1
0: iconst_1
1: istore_1
2: iload_1
3: istore_2
4: iconst_3
5: istore_1
6: iload_2
7: ireturn
8: astore_2
9: iconst_2
10: istore_1
11: iload_1
12: istore_3
13: iconst_3
14: istore_1
15: iload_3
16: ireturn
17: astore 4
19: iconst_3
20: istore_1
21: aload 4
23: athrow
Exception table:
from to target type
0 4 8 Class java/lang/Exception
0 4 17 any
8 13 17 any
17 19 17 any
LineNumberTable:
line 20: 0
line 21: 2
line 26: 4
line 21: 6
line 22: 8
line 23: 9
line 24: 11
line 26: 13
line 24: 15
line 26: 17
line 27: 21
LocalVariableTable:
Start Length Slot Name Signature
2 6 1 x I
9 8 2 e Ljava/lang/Exception;
11 6 1 x I
0 24 0 this Ltest/javap/Main;
21 3 1 x I
StackMapTable: number_of_entries = 2
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
}
SourceFile: "Main.java"

Class 文件结构

jvm-class结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version; //Class 的小版本号
u2 major_version; //Class 的大版本号
u2 constant_pool_count; //常量池的数量
cp_info constant_pool[constant_pool_count-1]; //常量池
u2 access_flags; //Class 的访问标记
u2 this_class; //当前类
u2 super_class; //父类
u2 interfaces_count; //接口
u2 interfaces[interfaces_count]; //一个类可以实现多个接口
u2 fields_count; //Class 文件的字段属性
field_info fields[fields_count]; //一个类会可以有个字段
u2 methods_count; //Class 文件的方法数量
method_info methods[methods_count]; //一个类可以有个多个方法
u2 attributes_count; //此类的属性表中的属性数
attribute_info attributes[attributes_count]; //属性表集合
}
  1. 每个 Class 文件的头 4 个字节称为魔数(magic),它的值固定为 0xCAFEBABE,唯有以”cafe babe”开头的 class 文件方可被虚拟机所接受,这 4 个字节就是字节码文件的身份识别。
  2. 紧接着 magic 之后的四个字节存储的是 Class 文件的次版本号和主版本号。例如 java 1.8.0_131-b11 对应的 4 个字节是 0x00000034,0000 是编译器 jdk 版本的次版本号 0,0034 转化为十进制是 52,是主版本号。

文件信息

1
2
3
4
5
6
7
8
Classfile /D:/Project/test/javap/Main.class
Last modified 2022-7-29; size 823 bytes
MD5 checksum 690a2c3b12765481dadabc27d3967ca7
Compiled from "Main.java"
public class test.javap.Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER

开头的 7 行信息包括:Class 文件当前所在位置,最后修改时间和文件大小,MD5 值,编译自哪个文件,类的全限定名,jdk 次版本号,主版本号。

紧接着的是该类的访问标志:ACC_PUBLIC, ACC_SUPER, 访问标志的含义如下:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为 Public 类型
ACC_FINAL 0x0010 是否被声明为 final,只有类可以设置
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语义.
ACC_INTERFACE 0x0200 标志这是一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC 0x1000 标志这个类并非由用户代码产生
ACC_ANNOTATION 0x2000 标志这是一个注解
ACC_ENUM 0x4000 标志这是一个枚举

常量池

Constant pool 意为常量池。

常量池可以理解成 Class 文件中的资源仓库。主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量类似于 java 中的常量概念,如文本字符串,final 常量等,而符号引用则属于编译原理方面的概念,包括以下三种:

  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符号(Descriptor)
  • 方法的名称和描述符

对照反编译文件分析:

1
2
3
4
5
6
7
Constant pool:
#1 = Methodref #5.#31 // java/lang/Object."<init>":()V
#5 = Class #35 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#31 = NameAndType #8:#9 // "<init>":()V
#35 = Utf8 java/lang/Object

第一个常量是一个方法定义,指向了第 4 和第 18 个常量。以此类推查看第 4 和第 18 个常量。最后可以拼接成第一个常量右侧的注释内容:

1
java/lang/Object."<init>":()V

这段可以理解为该类的实例构造器的声明,由于 Main 类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了 Main 类的直接父类是 Object。 该方法默认返回值是 V, 也就是 void,无返回值。

第二个常量同理可得:

1
2
3
4
5
6
 #2 = Fieldref           #4.#32         // test/javap/Main.m:I
#4 = Class #34 // test/javap/Main
#6 = Utf8 m
#7 = Utf8 I
#32 = NameAndType #6:#7 // m:I
#34 = Utf8 test/javap/Main

复制代码此处声明了一个字段 m,类型为 I, I 即是 int 类型。

字节码的类型对应

标识字符 含义
B 基本类型 byte
C 基本类型 char
D 基本类型 double
F 基本类型 float
I 基本类型 int
J 基本类型 long
S 基本类型 short
Z 基本类型 boolean
V 特殊类型 void
L 对象类型,以分号结尾,如 Ljava/lang/Object;

对于数组类型,每一位使用一个前置的 [ 字符来描述,如定义一个 java.lang.String[][] 类型的维数组,将被记录为 [[Ljava/lang/String;

方法表集合

在常量池之后的是对类内部的方法描述,在字节码中以表的集合形式表现,暂且不管字节码文件的 16 进制文件内容如何,我们直接看反编译后的内容。

1
2
3
4
// 此处声明了一个私有变量m,类型为int,返回值为int
private int m;
descriptor: I
flags: ACC_PRIVATE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 这里是构造方法:Main(),返回值为void, public方法
public test.javap.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltest/javap/Main;

code 内的主要属性为:

  • stack: 最大操作数栈,JVM 运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为 1
  • locals: 局部变量所需的存储空间,单位为 Slot, Slot 是虚拟机为局部变量分配内存时所使用的最小单位,为 4 个字节大小。方法参数(包括实例方法中的隐藏参数 this),显示异常处理器的参数(try catch 中的 catch 块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals 的大小并不一定等于所有局部变量所占的 Slot 之和,因为局部变量中的 Slot 是可以重用的。
  • args_size: 方法参数的个数,这里是 1,因为每个实例方法都会有一个隐藏参数 this attribute_info: 方法体内容,0,1,4 为字节码”行号”,该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的 java/lang/Object.””:()V, 然后执行返回语句,结束方法。
  • LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。可以使用 -g:none 或-g:lines 选项来取消或要求生成这项信息,如果选择不生成 LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
  • LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars 来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是 arg0, arg1 这样的占位符。start 表示该局部变量在哪一行开始可见,length 表示可见行数,Slot 代表所在帧栈位置,Name 是变量名称,然后是类型签名。

同理可以分析 Main 类中的另一个方法”inc()”:

方法体内的内容是:将 this 入栈,获取字段#2 并置于栈顶, 将 int 类型的 1 入栈,将栈内顶部的两个数值相加,返回一个 int 类型的值。

类名

最后很显然是源码文件:

1
SourceFile: "Main.java"

分析 try-catch-finally

分析上面示例中的方法 foo()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public int foo();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=5, args_size=1
0: iconst_1 //int型1入栈 ->栈顶=1
1: istore_1 //将栈顶的int型数值存入第二个局部变量 ->局部2=1
2: iload_1 //将第二个int型局部变量推送至栈顶 ->栈顶=1
3: istore_2 //!!将栈顶int型数值存入第三个局部变量 ->局部3=1

4: iconst_3 //int型3入栈 ->栈顶=3
5: istore_1 //将栈顶的int型数值存入第二个局部变量 ->局部2=3
6: iload_2 //!!将第三个int型局部变量推送至栈顶 ->栈顶=1
7: ireturn //从当前方法返回栈顶int数值 ->1

8: astore_2 // ->局部3=Exception
9: iconst_2 // ->栈顶=2
10: istore_1 // ->局部2=2
11: iload_1 // ->栈顶=2
12: istore_3 //!! ->局部4=2

13: iconst_3 // ->栈顶=3
14: istore_1 // ->局部1=3
15: iload_3 //!! ->栈顶=2
16: ireturn // -> 2

17: astore 4 //将栈顶引用型数值存入第五个局部变量=any
19: iconst_3 //将int型数值3入栈 -> 栈顶3
20: istore_1 //将栈顶第一个int数值存入第二个局部变量 -> 局部2=3
21: aload 4 //将局部第五个局部变量(引用型)推送至栈顶
23: athrow //将栈顶的异常抛出
Exception table:
from to target type
0 4 8 Class java/lang/Exception //0到4行对应的异常,对应#8中储存的异常
0 4 17 any //Exeption之外的其他异常
8 13 17 any
17 19 17 any
LineNumberTable:
line 20: 0
line 21: 2
line 26: 4
line 21: 6
line 22: 8
line 23: 9
line 24: 11
line 26: 13
line 24: 15
line 26: 17
line 27: 21
LocalVariableTable:
Start Length Slot Name Signature
2 6 1 x I
9 8 2 e Ljava/lang/Exception;
11 6 1 x I
0 24 0 this Ltest/javap/Main;
21 3 1 x I
StackMapTable: number_of_entries = 2
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]

试问当不发生异常和发生异常的情况下,foo()的返回值分别是多少?

在字节码的 4,5,以及 13,14 中执行的是同一个操作,就是将 int 型的 3 入操作数栈顶,并存入第二个局部变量。这正是我们源码在 finally 语句块中内容。也就是说,JVM 在处理异常时,会在每个可能的分支都将 finally 语句重复执行一遍。

通过一步步分析字节码,可以得出最后的运行结果是:

  • 不发生异常时: return 1
  • 发生异常时: return 2
  • 发生非 Exception 及其子类的异常,抛出异常,不返回值

字节码增强技术

字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。

字节码增强技术相当于是一把打开运行时 JVM 的钥匙,利用它可以动态地对运行中的程序做修改,也可以跟踪 JVM 运行中程序的状态。

此外,我们平时使用的动态代理、AOP 也与字节码增强密切相关,它们实质上还是利用各种手段生成符合规范的字节码文件。

综上所述,掌握字节码增强后可以高效地定位并快速修复一些棘手的问题(如线上性能问题、方法出现不可控的出入参需要紧急加日志等问题),也可以在开发中减少冗余代码,大大提高开发效率。

java字节码增强

ASM

对于需要手动操纵字节码的需求,可以使用 ASM,它可以直接生产 .class 字节码文件,也可以在类被加载入 JVM 之前动态修改类行为。

ASM 的应用场景有 AOP(Cglib 就是基于 ASM)、热部署、修改其他 jar 包中的类等。

访问者模式主要用于修改或操作一些数据结构比较稳定的数据,节码文件的结构是由 JVM 固定的,所以很适合利用访问者模式对字节码文件进行修改。

ASM API

核心 API

ASM Core API 可以类比解析 XML 文件中的 SAX 方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。

出于性能考虑,一般情况下编程都使用 Core API。在 Core API 中有以下几个关键类:

  • ClassReader:用于读取已经编译好的.class 文件。
  • ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
  • 各种 Visitor 类:如上所述,CoreAPI 根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的 Visitor,比如用于访问方法的 MethodVisitor、用于访问类变量的 FieldVisitor、用于访问注解的 AnnotationVisitor 等。为了实现 AOP,重点要使用的是 MethodVisitor。
树形 API

ASM Tree API 可以类比解析 XML 文件中的 DOM 方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。

TreeApi 不同于 CoreAPI,TreeAPI 通过各种 Node 类来映射字节码的各个区域,类比 DOM 节点,就可以很好地理解这种编程方式。

直接利用 ASM 实现 AOP

利用 ASM 的 CoreAPI 来增强类。只实现在方法调用前、后增加逻辑,通俗易懂且方便理解。

首先定义需要被增强的 Base 类:其中只包含一个 process()方法,方法内输出一行“process”。增强后,我们期望的是,方法执行前输出“start”,之后输出”end”。

1
2
3
4
5
public class Base {
public void process(){
System.out.println("process");
}
}

为了利用 ASM 实现 AOP,需要定义两个类:

  1. MyClassVisitor 类,用于对字节码的 visit 以及修改;
  2. Generator 类,在这个类中定义 ClassReader 和 ClassWriter,其中的逻辑是,classReader 读取字节码,然后交给 MyClassVisitor 类处理,处理完成后由 ClassWriter 写字节码并将旧的字节码替换掉。
Generator 类

Generator 类较简单,我们先看一下它的实现,如下所示,然后重点解释 MyClassVisitor 类。

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
package test.org.objectweb.asm;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;

import java.io.File;
import java.io.FileOutputStream;

/**
* @author C
* @date 2022/8/2
*/
public class Generator {
public static void main(String[] args) throws Exception {
//读取
ClassReader classReader = new ClassReader("test/org/objectweb/asm/Base");
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
//处理
ClassVisitor classVisitor = new MyClassVisitor(classWriter);
classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
byte[] data = classWriter.toByteArray();
//输出
File f = new File("target/classes/test/org/objectweb/asm/Base.class");
FileOutputStream fout = new FileOutputStream(f);
fout.write(data);
fout.close();
System.out.println("now generator cc success!!!!!");
}
}
MyClassVisitor 类

MyClassVisitor 继承自 ClassVisitor,用于对字节码的观察。它还包含一个内部类 MyMethodVisitor,继承自 MethodVisitor 用于对类内方法的观察,它的整体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package test.org.objectweb.asm;

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class MyClassVisitor extends ClassVisitor implements Opcodes {
public MyClassVisitor(ClassVisitor cv) {
super(ASM5, cv);
}

@Override
public void visit(int version, int access, String name, String signature,
String superName, String[] interfaces) {
cv.visit(version, access, name, signature, superName, interfaces);
}

@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
// Base类中有两个方法:无参构造以及process方法,这里不增强构造方法
if (!name.equals("<init>") && mv != null) {
mv = new MyMethodVisitor(mv);
}
return mv;
}

class MyMethodVisitor extends MethodVisitor implements Opcodes {
public MyMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM5, mv);
}

@Override
public void visitCode() {
super.visitCode();
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("start");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}

@Override
public void visitInsn(int opcode) {
if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
|| opcode == Opcodes.ATHROW) {
// 方法在返回之前,打印"end"
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("end");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
mv.visitInsn(opcode);
}
}
}

利用这个类就可以实现对字节码的修改。详细解读其中的代码,对字节码做修改的步骤是:

  1. 首先通过 MyClassVisitor 类中的 visitMethod 方法,判断当前字节码读到哪一个方法了。跳过构造方法 <init> 后,将需要被增强的方法交给内部类 MyMethodVisitor 来进行处理。
  2. 接下来,进入内部类 MyMethodVisitor 中的 visitCode 方法,它会在 ASM 开始访问某一个方法的 Code 区时被调用,重写 visitCode 方法,将 AOP 中的前置逻辑就放在这里。
  3. MyMethodVisitor 继续读取字节码指令,每当 ASM 访问到无参数指令时,都会调用 MyMethodVisitor 中的 visitInsn 方法。我们判断了当前指令是否为无参数的 “return” 指令,如果是就在它的前面添加一些指令,也就是将 AOP 的后置逻辑放在该方法中。
  4. 综上,重写 MyMethodVisitor 中的两个方法,就可以实现 AOP 了,而重写方法时就需要用 ASM 的写法,手动写入或者修改字节码。通过调用 methodVisitor 的 visitXXXXInsn()方法就可以实现字节码的插入,XXXX 对应相应的操作码助记符类型,比如 mv.visitLdcInsn(“end”)对应的操作码就是 ldc “end”,即将字符串“end”压入栈。
  5. 完成这两个 visitor 类后,运行 Generator 中的 main 方法完成对 Base 类的字节码增强,增强后的结果可以在编译后的 target 文件夹中找到 Base.class 文件进行查看,可以看到反编译后的代码已经改变了。然后写一个测试类 MyTest,在其中 new Base(),并调用 base.process()方法。

运行后的 class 文件反编译后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package test.org.objectweb.asm;

public class Base {
public Base() {
}

public void process() {
System.out.println("start");
System.out.println("process");
System.out.println("end");
}
}

测试输出结果:

1
2
3
start
process
end

ASM 工具

利用 ASM 手写字节码时,需要利用一系列 visitXXXXInsn()方法来写对应的助记符,所以需要先将每一行源代码转化为一个个的助记符,然后通过 ASM 的语法转换为 visitXXXXInsn()这种写法。

所以可以借助 ASM 插件来进行编写,这里举例 idea 插件, idea 2022.1.3 可用的有 Class Decompile (推荐) 和 ASM Bytecode Viewer。另外,ASM Bytecode Outline 已不可用。

Javassist

ASM 是在指令层次上操作字节码的,Javassist 是强调源代码层次操作字节码的框架。

Javassist 的优点就在于编程简单,在实现字节码增强时,可以无须关注字节码刻板的结构。直接使用 java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。

其中最重要的是 ClassPool、CtClass、CtMethod、CtField 这四个类:

  1. CtClass(compile-time class):编译时类信息,它是一个 class 文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个 CtClass 对象,用来表示这个类文件。
  2. ClassPool:从开发视角来看,ClassPool 是一张保存 CtClass 信息的 HashTable,key 为类名,value 为类名对应的 CtClass 对象。当我们需要对某个类进行修改时,就是通过 pool.getCtClass(“className”)方法从 pool 中获取到相应的 CtClass。
  3. CtMethod:对应的是类中的方法。
  4. CtField:对应的是类中的属性。

写一个小 Demo 来展示 Javassist 简单、快速的特点。我们依然是对 Base 中的 process()方法做增强,在方法调用前后分别输出”start”和”end”。

我们需要做的就是从 pool 中获取到相应的 CtClass 对象和其中的方法,然后执行 method.insertBefore 和 insertAfter 方法,参数为要插入的 Java 代码,再以字符串的形式传入即可,实现起来也极为简单。

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package test.javassist;

import javassist.*;

import java.io.IOException;

/**
* @author C
* @date 2022/8/2
*/
public class JavassistTest {
public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException, IOException {
// Base base = new Base(); // 取消注释这一行,运行时会报错,原因:JVM不允许在运行时动态重载一个类
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("test.javassist.Base");
CtMethod m = cc.getDeclaredMethod("process");
// 是否带 {} 没有区别
m.insertBefore("System.out.println(\"start\");");
m.insertAfter("{ System.out.println(\"end\"); }");
cc.writeFile("target/classes");

Class<?> c = cc.toClass();
Base b = (Base) c.newInstance();
b.process();
}
}

运行时类的重载

如果我们在一个 JVM 中,先加载了一个类,然后又对其进行字节码增强并重新加载会发生什么呢?

在上文中 Javassist 的 Demo 中 main()方法的第一行添加 Base b=new Base(),即在增强前就先让 JVM 加载 Base 类,然后在执行到 c.toClass()方法时会抛出错误,在最后调用了 ClassLoader 的 native 方法 defineClass()时报错。

综上,JVM 不允许在运行时动态重载一个类,确切的说是不允许修改已经加载的类,和运行时修改未加载的类然后加载是两个概念。

但是,如果只能在类加载前对类进行强化,那字节码增强技术的使用场景就变得很窄了。

期望:在一个持续运行并已经加载了所有类的 JVM 中,还能利用字节码增强技术对其中的类行为做替换并重新加载。

为了模拟这种情况,我们将 Base 类做改写,在其中编写 main 方法,每五秒调用一次 process()方法,在 process()方法中输出一行“process”。

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
package test.java.lang.instrument;

import java.lang.management.ManagementFactory;

/**
* @author C
* @date 2022/8/2
*/
public class Base {
public static void main(String[] args) {
String name = ManagementFactory.getRuntimeMXBean().getName();
String s = name.split("@")[0];
// 打印当前Pid
System.out.println("pid:"+s);
while (true) {
try {
Thread.sleep(5000L);
} catch (Exception e) {
break;
}
process();
}
}

private static void process(){
System.out.println("process");
}
}

Instrument

instrument 是 JVM 提供的一个可以修改已加载类的类库,专门为 Java 语言编写的插桩服务提供支持。

它需要依赖 JVMTI 的 Attach API 机制实现。在 JDK 1.6 以前,instrument 只能在 JVM 刚启动开始加载类时生效,而在 JDK 1.6 之后,instrument 支持了在运行时对类定义的修改。

要使用 instrument 的类修改功能,我们需要实现它提供的 ClassFileTransformer 接口,定义一个类文件转换器,接口中的 transform()方法会在类文件被加载时调用。在 transform 方法里,可以利用上文中的 ASM 或 Javassist 对传入的字节码进行改写或替换,生成新的字节码数组后返回。

这里定义一个实现了 ClassFileTransformer 接口的类 TestTransformer,依然在其中利用 Javassist 对 Base 类中的 process()方法进行增强,在前后分别打印“start”和“end”,代码如下:

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
package test.java.lang.instrument;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

/**
* @author C
* @date 2022/8/2
*/
public class TransformerTest implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
System.out.println("Transforming " + className);
try {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("test.java.lang.instrument.Base");
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{ System.out.println(\"start\"); }");
m.insertAfter("{ System.out.println(\"end\"); }");
return cc.toBytecode();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

现在有了 Transformer,那么它要如何注入到正在运行的 JVM 呢?还需要定义一个 Agent,借助 Agent 的能力将 Instrument 注入到 JVM 中。

现在要介绍的是 Agent 中用到的另一个类 Instrumentation。在 JDK 1.6 之后,Instrumentation 可以做启动后的 Instrument、本地代码(Native Code)的 Instrument,以及动态改变 Classpath 等等。我们可以向 Instrumentation 中添加上文中定义的 Transformer,并指定要被重加载的类,代码如下所示。这样,当 Agent 被 Attach 到一个 JVM 中时,就会执行类字节码替换并重载入 JVM 的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package test.java.lang.instrument;

import java.lang.instrument.Instrumentation;

/**
* @author C
* @date 2022/8/2
*/
public class AgentTest {
public static void agentmain(String args, Instrumentation inst) {
// 指定自定义的Transformer,在其中利用Javassist做字节码替换
inst.addTransformer(new TransformerTest(), true);
try {
// 重定义类并载入新的字节码
inst.retransformClasses(Base.class);
System.out.println("Agent Load Done.");
} catch (Exception e) {
System.out.println("agent load failed!");
}
}
}

JVMTI & Agent & Attach API

上面给出了 Agent 类的代码,追根溯源需要先介绍 JPDA(Java Platform Debugger Architecture)。如果 JVM 启动时开启了 JPDA,那么类是允许被重新加载的。在这种情况下,已被加载的旧版本类信息可以被卸载,然后重新加载新版本的类。正如 JDPA 名称中的 Debugger,JDPA 其实是一套用于调试 Java 程序的标准,任何 JDK 都必须实现该标准。

JPDA 定义了一整套完整的体系,它将调试体系分为三部分,并规定了三者之间的通信接口。三部分由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试协议(JDWP)以及 Java 调试接口(JDI)

JVMTI

JVMTI(JVM TOOL INTERFACE,JVM 工具接口)是 JVM 提供的一套对 JVM 进行操作的工具接口。通过 JVMTI,可以实现对 JVM 的多种操作,它通过接口注册各种事件勾子,在 JVM 事件触发时,同时触发预定义的勾子,以实现对各个 JVM 事件的响应,事件包括类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、GC 开始和结束、方法调用进入和退出、临界区竞争与等待、VM 启动与退出等等。

Agent

Agent 是 JVMTI 的一种实现,Agent 有两种启动方式,一是随 Java 进程启动而启动,经常见到的 java -agentlib 就是这种方式;二是运行时载入,通过 attach API,将模块(jar 包)动态地 Attach 到指定进程 id 的 Java 进程内。

Attach API

Attach API 的作用是提供 JVM 进程间通信的能力,比如说我们为了让另外一个 JVM 进程把线上服务的线程 Dump 出来,会运行 jstack 或 jmap 的进程,并传递 pid 的参数,告诉它要对哪个进程进行线程 Dump,这就是 Attach API 做的事情。

注入 JVM 示例

下面,我们通过 Attach API 的 loadAgent()方法,将打包好的 Agent jar 包动态 Attach 到目标 JVM 上。具体实现起来的步骤如下:

  1. 定义 Agent,并在其中实现 AgentMain 方法,如上一小节中定义的代码块 7 中的 AgentTest 类;

  2. 将 AgentTest 类打成一个包含 MANIFEST.MF 的 jar 包,其中需要在 MANIFEST.MF 文件中新增 3 行 Agent 属性,同时需要将 Agent-Class 属性指定为 AgentTest 的全限定名,如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Manifest-Version: 1.0
    Implementation-Title: test-project
    Implementation-Version: 1.0.0
    Built-By: C
    Implementation-Vendor-Id: com.test
    Created-By: Apache Maven 3.6.1
    Build-Jdk: 1.8.0_212
    Implementation-URL: http://maven.apache.org/
    // 下面三行为新增的3行
    Agent-Class: test.java.lang.instrument.AgentTest
    Can-Redefine-Classes: true
    Can-Retransform-Classes: true
  3. 最后利用 Attach API,将我们打包好的 jar 包 Attach 到指定的 JVM pid 上,代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    package test.java.lang.instrument;

    import com.sun.tools.attach.AgentInitializationException;
    import com.sun.tools.attach.AgentLoadException;
    import com.sun.tools.attach.AttachNotSupportedException;
    import com.sun.tools.attach.VirtualMachine;

    import java.io.IOException;

    public class AttachTest {
    // 注意: 工程需要引入包: jdk所在目录/lib/tools.jar
    public static void main(String[] args) throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
    // 传入目标 JVM pid
    VirtualMachine vm = VirtualMachine.attach("2276");
    vm.loadAgent("target/test-project-1.0.0.jar");
    }
    }
  4. 由于在 MANIFEST.MF 中指定了 Agent-Class,所以在 Attach 后,目标 JVM 在运行时会走到 TestAgent 类中定义的 agentmain()方法,而在这个方法中,我们利用 Instrumentation,将指定类的字节码通过定义的类转化器 TestTransformer 做了 Base 类的字节码替换(通过 javassist),并完成了类的重新加载。

以下为运行时重新载入类的效果:先运行 Base 中的 main()方法,启动一个 JVM,可以在控制台看到每隔五秒输出一次”process”。接着执行 Attacher 中的 main()方法,并将上一个 JVM 的 pid 传入。此时回到上一个 main()方法的控制台,可以看到现在每隔五秒输出”process”前后会分别输出”start”和”end”,也就是说完成了运行时的字节码增强,并重新载入了这个类。

1
2
3
4
5
6
7
8
9
10
11
pid:2276
process
process
Transforming test/java/lang/instrument/Base
Agent Load Done.
start
process
end
start
process
end
使用场景

至此,字节码增强技术的可使用范围就不再局限于 JVM 加载类前了。通过上述几个类库,我们可以在运行时对 JVM 中的类进行修改并重载了。通过这种手段,可以做的事情就变得很多了:

  • 热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
  • Mock:测试时候对某些服务做 Mock。
  • 性能诊断工具:比如 bTrace 就是利用 Instrument,实现无侵入地跟踪一个正在运行的 JVM,监控到类和方法级别的状态信息。

类加载

类生命周期

类的生命周期包括:加载、链接、初始化、使用和卸载,其中加载、链接、初始化,属于类加载的过程。

java-类加载过程

其中类加载的过程包括了加载、链接、初始化三个阶段。链接阶段又分为验证、准备、解析三个阶段。

在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也成为动态绑定或晚期绑定)。

另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

加载

在加载阶段,虚拟机需要完成以下三件事情:

  1. 通过类的全限定名(包名 + 类名),获取到该类的.class 文件的二进制字节流

  2. 将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构

  3. 在 Java 堆中生成一个代表该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

总结:加载二进制数据到内存 —> 映射成 jvm 能识别的结构 —> 在内存中生成 class 对象。

加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM 规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class 文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError 错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

加载.class 文件的方式

  • 从本地系统中直接加载
  • 通过网络下载.class 文件
  • 从 zip,jar 等归档文件中加载.class 文件
  • 从专有数据库中提取.class 文件
  • 将 Java 源文件动态编译为.class 文件

链接

验证

确保被加载的类的正确性,符合当前虚拟机的要求,不会危害到虚拟机的安全。

验证阶段大致会完成 4 个阶段的检验动作:

  1. 文件格式验证: 验证字节流是否符合 Class 文件格式的规范;例如: 是否以 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  2. 元数据验证: 对字节码描述的信息进行语义分析(注意: 对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如: 这个类是否有父类,除了 java.lang.Object 之外。
  3. 字节码验证: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证: 确保解析动作能正确执行。

注意

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

为类中的静态字段分配内存,并设置默认的初始值,这些内存都将在方法区中分配。

对于该阶段有以下几点需要注意:

  1. 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。
    假设一个类变量的定义为: public static int value = 3; 那么变量 value 在准备阶段过后的初始值为 0, 而不是 3, 因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 put static 指令是在程序编译后,存放于类构造器 <clinit>() 方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
  3. 如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。(ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,用 final 修饰的实例变量,编译成 class 文件的时候,对应的字段表也有可能会加上 ConstantValue 属性,但是只有被 static 关键字修饰的类变量才可以使用这项属性。)
    假设上面的类变量 value 被定义为: public static final int value = 3; 编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 3 。我们可以理解为 static final 常量在编译期就将其结果放入了调用它的类的常量池中。

解析

把类中的符号引用转换为直接引用

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对 接口字段类方法接口方法方法类型方法句柄调用点 限定符 7 类符号引用进行。

符号引用就是一组符号来描述目标,可以是任何字面量。

直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化

初始化,为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值
  2. 使用静态代码块为类变量指定初始值

JVM 初始化步骤

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机

只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

  1. 使用 new 关键字实例化对象。
  2. 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)。
  3. 调用类的静态方法
  4. 反射(如 Class.forName(“com.pdai.jvm.Test”))
  5. 初始化某个类的子类,则其父类也会被初始化
  6. Java 虚拟机启动时被标明为启动类的类(Java Test 或包含 main()方法的那个类)

以下几种情况不会执行类初始化

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  2. 定义对象数组,不会触发该类的初始化。
  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  4. 通过类名获取 Class 对象,不会触发类的初始化。
  5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

使用

类访问方法区内的数据结构的接口,对象是 Heap 区的数据。

卸载

Java 虚拟机将结束生命周期的几种情况

  • 执行了 System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致 Java 虚拟机进程终止

类加载机制

类加载器

从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:

一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++语言实现,是虚拟机自身的一部分;

另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度来看,类加载器还可以划分得更细致一些:

  1. 启动类加载器(Bootstrap ClassLoader)
  2. 扩展类加载器(Extension ClassLoader)
  3. 应用程序类加载器(Application ClassLoader)
  4. 自定义加载器(User ClassLoader)

启动类加载器(Bootstrap ClassLoader)

这个类加载器使用 C/C++语言实现的,嵌套在 JVM 内部,java 程序无法直接操作这个类。

它用来加载 Java 核心类库,如:JAVA_HOME/jre/lib/rt.jar、resources.jar、sun.boot.class.path 路径下的包,用于提供 jvm 运行所需的包。

并不是继承自 java.lang.ClassLoader,它没有父类加载器

它加载扩展类加载器和应用程序类加载器,并成为他们的父类加载器

出于安全考虑,启动类只加载包名为:java、javax、sun 开头的类

扩展类加载器(Extension ClassLoader)

Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现,我们可以用 Java 程序操作这个加载器

扩展类加载器派生继承自 java.lang.ClassLoader,父类加载器为启动类加载器

从系统属性:java.ext.dirs 目录中加载类库,或者从 JDK 安装目录:jre/lib/ext 目录下加载类库。我们就可以将我们自己的包放在以上目录下,就会自动加载进来了

应用程序类加载器(Application ClassLoader)

Java 语言编写,由 sun.misc.Launcher$AppClassLoader 实现

应用程序类加载器派生继承自 java.lang.ClassLoader,父类加载器为启动类加载器

它负责加载环境变量 classpath 或者系统属性 java.class.path 指定路径下的类库

它是程序中默认的类加载器,我们 Java 程序中的类,都是由它加载完成

我们可以通过 ClassLoader#getSystemClassLoader()获取并操作这个加载器

自定义加载器(User ClassLoader)

一般情况下,以上 3 种加载器能满足我们日常的开发工作,不满足时,我们还可以自定义加载器

比如用网络加载 Java 类,为了保证传输中的安全性,采用了加密操作,那么以上 3 种加载器就无法加载这个类,这时候就需要自定义加载器

自定义加载器实现步骤

  1. 继承 java.lang.ClassLoader 类,重写 findClass()方法
  2. 如果没有太复杂的需求,可以直接继承 URLClassLoader 类,重写 loadClass 方法,具体可参考 AppClassLoader 和 ExtClassLoader。

获取 ClassLoader 几种方式

1
2
3
4
5
6
7
8
// 方式一:获取当前类的 ClassLoader
clazz.getClassLoader()
// 方式二:获取当前线程上下文的 ClassLoader
Thread.currentThread().getContextClassLoader()
// 方式三:获取系统的 ClassLoader
ClassLoader.getSystemClassLoader()
// 方式四:获取调用者的 ClassLoader
DriverManager.getCallerClassLoader()

类加载机制

classloader 加载类用的是 全盘负责 缓存 委托机制

全盘负责

当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入

缓存机制

缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区。这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效

父类委托(双亲委派机制)

先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

双亲委派机制,是把请求交给父类处理的一种任务委派模式。

工作原理

  1. 如果一个类加载器接收到了类加载的请求,它自己不会先去加载,会把这个请求委托给父类加载器去执行
  2. 如果父类还存在父类加载器,则继续向上委托,一直委托到启动类加载器:Bootstrap ClassLoader
  3. 如果父类加载器可以完成加载任务,就返回成功结果,如果父类加载失败,就由子类自己去尝试加载,如果子类加载失败就会抛出 ClassNotFoundException 异常,这就是双亲委派模式

反向委派机制

第三方包加载方式通常使用反向委派机制。

在 Java 应用中存在着很多服务提供者接口(Service Provider Interface,SPI),这些接口允许第三方为它们提供实现,如常见的 SPI 有 JDBC、JNDI 等,这些 SPI 的接口属于 Java 核心库,一般存在 rt.jar 包中,由 Bootstrap 类加载器加载。

Bootstrap 类加载器无法直接加载 SPI 的实现类,同时由于双亲委派模式的存在,Bootstrap 类加载器也无法反向委托 AppClassLoader 加载器 SPI 的实现类。

所以就需要一种特殊的类加载器来加载第三方的类库,而线程上下文类加载器(双亲委派模型的破坏者)就是很好的选择。

加载流程

jvm-classloader-反向委派机制

沙箱安全机制

Java 安全模型的核心就是 Java 沙箱(sandbox)。沙箱机制就是将 Java 代码限定只能在虚 JVM 虚拟机中特定的运行范围,并且严格限制代码对本地系统资源访问,通过这样的方式来保证对 Java 代码的有效隔离,防止对本地操作系统造成破坏。

沙箱主要限制系统资源(CPU、内存、文件系统、网络)的访问。不同级别的沙箱对系统资源访问的限制也有差异。

安全模型

JDK1.8

引入了域 (Domain) 的概念,JVM 虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源系统进行交互,而每个应用域部分则通过系统域的部分代理来对各种需要的资源进行精细划分然后可以进行访问。

JVM 虚拟机中不同的受保护域 (Protected Domain)对应不一样的权限 (Permission)。

存在于不同域中的类文件就拥有了它所包含应用域所有可访问资源之和。

组件

字节码校验器(bytecode verifier)

确保 lava 类文件遵循 lava 语言规范。这样可以帮助 Java 程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。

类装载器(class loader)

防止恶意代码去干涉善意的代码,比如:双亲委派机制

守护了被信任的类库边界;

将代码归入保护域,确定了代码的权限范围可以进行哪些资源操作

存取控制器(access controller)

存取控制器可以控制核心 API 对操作系统的存取权限,用户可以设定控制策略。

安全管理器(security manager)

安全管理器主要是核心 API 和操作系统之间的主要接口。比如实现权限控制,比存取控制器优先级高。

在 JDK 17 中被标记为废弃且即将移除。

安全软件包(security package)

java.security 下的类和扩展包下的类,允许用户为应用增加所需要安全特性:安全提供者、消息摘要、数字签名 keytools、加密、鉴别。

JVM 内存结构

内存结构划分

java-jvm-jvm内存结构大纲

JVM 的内存结构大概分为:

  • 堆(Heap):线程共享。所有的对象实例以及数组都要在堆上分配。回收器主要管理的对象。
  • 方法区(Method Area):线程共享。存储类信息、常量、静态变量、即时编译器编译后的代码。
  • 方法栈(JVM Stack):线程私有。存储局部变量表、操作栈、动态链接、方法出口,对象指针。
  • 本地方法栈(Native Method Stack):线程私有。为虚拟机使用到的 Native 方法服务。如 Java 使用 c 或者 c++编写的接口服务时,代码在此区运行。
  • 程序计数器(Program Counter Register):线程私有。有些文章也翻译成 PC 寄存器(PC Register),同一个东西。它可以看作是当前线程所执行的字节码的行号指示器。指向下一条要执行的指令。

《深入理解 Java 虚拟机(第 2 版)》中的描述是下面这个样子的:

java-jvm-jvm内存结构1

JVM 内存结构的布局和相应的控制参数:

java-jvm-jvm内存参数

变量存储位置

  • 一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
  • 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
  • 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量仍然存放在线程栈上,即使这些方法所属的对象存放在堆上。
  • 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
  • 静态成员变量跟随着类定义一起也存放在堆上。
  • 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

java-jvm-jvm内存结构2

Java 内存模型(JMM)

Java 虚拟机规范中定义了 Java 内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。 从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。 本地内存是 JMM 的一个抽象概念,并不真实存在。 它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。 Java 内存模型的抽象示意图如下:

java-jvm-jmm结构1

从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

  1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
  2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

java-jvm-jmm结构2

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

JMM 内存操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下 8 种操作来完成:

  1. lock(锁定) : 作用于主内存的变量,把一个变量标识为一条线程独占状态。
  2. unlock(解锁) : 作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取) : 作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
  4. load(载入) : 作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用) : 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值) : 作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储) : 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。
  8. write(写入) : 作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中。

Java 内存模型还规定了在执行上述 8 种基本操作时,必须满足如下规则:

  1. 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。 但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  2. 不允许 read 和 load、store 和 write 操作之一单独出现
  3. 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  4. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  5. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  6. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现
  7. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
  8. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  9. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

JMM 模型解决的问题

当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。 Java 内存模型建立所围绕的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性(多线程竞争 race condition)。

多线程读同步与可见性

可见性(共享对象可见性) : 线程对共享变量修改的可见性。当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改

线程缓存导致的可见性问题

如果两个或者更多的线程在没有使用 volatile 声明或者非同步的情况下共享一个对象,一个线程更新这个共享对象可能对其它线程来说是不可见的:共享对象被初始化在主存中。 跑在 CPU 上的一个线程将这个共享对象读到 CPU 缓存中,然后修改了这个对象。 只要 CPU 缓存没有被刷新回主存,对象修改后的版本对跑在其它 CPU 上的线程都是不可见的。 这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的 CPU 缓存中。

解决这个内存可见性问题的方案

  1. Java 中的 volatile 关键字:volatile 关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与 volatile 变量的区别是:volatile 的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用 volatile 变量前都立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
  2. Java 中的 synchronized 关键字:同步块的可见性是由 “如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值”、“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store 和 write 操作)” 这两条规则获得的。
  3. Java 中的 final 关键字:final 关键字的可见性是指,被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 “this” 的引用传递出去(this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程就能看见 final 字段的值(无须同步)

final 多线程可见性原理

在多线程情况下,final 关键字在 Java 中可以提供一定的可见性保证。

当一个线程将一个变量声明为 final 并初始化后,其他线程在访问该变量时能够看到它的最新值。 这是因为在 Java 内存模型中,对于 final 域的写入操作具有 happens-before 关系。

具体而言,当一个线程对 final 域进行写操作时,它所做的修改对于其他线程是可见的。 这是因为在写入 final 域之后,JVM 会禁止指令重排序和线程缓存刷新操作,从而确保其他线程在访问该 final 域时能够获取到最新的值。

需要注意的是,这种可见性保证仅适用于 final 域本身的可见性,而不涉及其引用的对象的可见性。 如果一个 final 域引用了一个可变对象,那么该对象的状态仍然可以被修改,但是对于其他线程来说,final 域引用的对象是可见的,只是该对象本身的状态是不可变的。

除了 final 关键字之外,还可以使用其他的同步机制,如锁、volatile 关键字和并发容器等,来实现更严格的可见性和线程安全性保证。

总结起来,在多线程情况下,final 关键字可以提供一定的可见性保证,确保其他线程在访问 final 域时能够获取到最新的值。然而,对于引用的对象本身的可见性仍需考虑其他同步机制的使用。

happens-before

在 Java 并发编程中,happens-before 是一个重要的概念,用于描述并发操作之间的顺序关系和可见性保证。

happens-before 规则确保在多线程环境中对共享变量的读写操作能够按照一定的顺序进行,使得程序的执行结果符合预期。

具体而言,happens-before 规则定义了以下几种情况:

  • 程序顺序规则 (Program Order Rule):在单个线程中,按照代码的顺序,前面的操作 happens-before 后面的操作。
  • volatile 变量规则 (Volatile Variable Rule):对一个 volatile 变量的写操作 happens-before 该变量的后续读操作。这意味着对于一个 volatile 变量的写入操作,其他线程可以立即看到最新值。
  • 锁规则 (Lock Rule):释放锁操作 happens-before 后续获取同一个锁的操作。 这确保了锁的释放状态对于后续获取锁的线程是可见的。
  • 线程启动规则 (Thread Start Rule):一个线程的启动 happens-before 该线程的任意操作。
  • 线程终止规则 (Thread Termination Rule):一个线程的任意操作 happens-before 其他线程检测到该线程的终止。
  • 中断规则 (Interruption Rule):对线程的中断操作 happens-before 后续对该线程的检测到中断。
  • 传递性规则 (Transitive Rule):如果操作 A happens-before 操作 B,且操作 B happens-before 操作 C,则操作 A happens-before 操作 C。

这些规则确保了程序中的操作在多线程环境中的顺序性和可见性。 通过遵循 happens-before 规则,我们可以安全地编写并发程序,避免出现数据竞争和不确定的行为。

需要注意的是,happens-before 规则只适用于满足 happens-before 关系的操作,对于没有 happens-before 关系的操作,它们的执行顺序是不确定的。

总结起来,happens-before 规则是用于描述并发操作之间顺序关系和可见性保证的规则集合。 它确保了对共享变量的读写操作按照一定的顺序进行,使得程序的执行结果是可预测和符合预期的。

this 引用逃逸

“this” 引用逃逸是指在对象的构造过程中,当对象尚未完全构造完成时,就将 “this” 引用传递给其他对象或线程,从而导致其他对象或线程可能访问到不完整或不稳定的对象。

当一个对象正在被构造时,它可能处于一个不稳定的状态,其中某些字段尚未初始化或设置。在这种情况下,如果 “this” 引用逃逸到其他对象或线程中,这些对象或线程可能会访问到不完整的对象,导致不一致的状态或潜在的错误。

以下是一个示例,展示了 “this” 引用逃逸的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class EscapeExample {
private int value;

public EscapeExample() {
new Thread(new Runnable() {
@Override
public void run() {
// 在构造过程中,将 "this" 引用逃逸到新线程中
// 其他线程可能会访问到不完整的对象
System.out.println("Value: " + EscapeExample.this.value);
}
}).start();

// 构造过程中的其他初始化操作...
this.value = 10;
}
}

在上述示例中,当构造 EscapeExample 对象时,它创建了一个新的线程,并将当前对象的 “this” 引用传递给新线程。由于线程的启动是异步的,新线程可能在构造过程中访问到未完全初始化的 value 字段,导致不稳定的行为。

为避免 “this” 引用逃逸,应注意以下几点:

  1. 避免在构造过程中将 “this” 引用传递给其他对象或线程。
  2. 不要在构造方法中启动新线程或注册回调。
  3. 在多线程环境下,使用适当的同步机制来保护对象的访问,确保对象的完整初始化。

通过遵循上述准则,可以减少 “this” 引用逃逸的风险,确保对象的正确构造和稳定性。

Java 对象在 JVM 中的内存布局,以及内存对齐和压缩指针的原理及应用

Java 对象内存布局

java-jvm-jvm内存结构-java对象内存布局

如图所示,Java 对象在 JVM 中是用 instanceOopDesc 结构表示而 Java 对象在 JVM 堆中的内存布局可以分为三部分:

  1. 对象头(Header)
  2. 实例数据(Instance Data)
  3. 对齐填充(padding)

对象头(Header)

每个 Java 对象都包含一个对象头,对象头中包含了 MarkWord、 类型指针,数组对象还包含数组长度:

  • MarkWord: 在 JVM 中用 markOopDesc 结构表示用于存储对象自身运行时的数据。比如:hashcode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。在 32 位操作系统和 64 位操作系统中 MarkWord 分别占用 4B 和 8B 大小的内存;
  • 类型指针: JVM 中的类型指针封装在 klassOopDesc 结构中,类型指针指向了 InstanceKclass 对象,Java 类在 JVM 中是用 InstanceKclass 对象封装的,里边包含了 Java 类的元信息,比如继承结构、方法、静态变量、构造函数等。
    • 在不开启指针压缩的情况下(-XX:-UseCompressedOops)。在 32 位操作系统和 64 位操作系统中类型指针分别占用 4B 和 8B 大小的内存。
    • 在开启指针压缩的情况下(-XX:+UseCompressedOops)。在 32 位操作系统和 64 位操作系统中类型指针分别占用 4B 和 4B 大小的内存。
  • 数组长度: 如果 Java 对象是一个数组类型的话,那么在数组对象的对象头中还会包含一个 4B 大小的用于记录数组长度的属性。

由于在对象头中用于记录数组长度大小的属性只占 4B 的内存,所以 Java 数组可以申请的最大长度为:2^32。

实例数据(Instance Data)

Java 对象在内存中的实例数据区用来存储 Java 类中定义的实例字段,包括所有父类中的实例字段。 也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存。

Java 对象中的字段类型分为两大类:

  • 基础类型:Java 类中实例字段定义的基础类型在实例数据区的内存占用如下:
    • long、double 占用 8 个字节。
    • int、float 占用 4 个字节。
    • short、char 占用 2 个字节。
    • byte、boolean 占用 1 个字节。
  • 引用类型:Java 类中实例字段的引用类型在实例数据区内存占用分为两种情况:
    • 不开启指针压缩(-XX:-UseCompressedOops):在 32 位操作系统中引用类型的内存占用为 4 个字节。在 64 位操作系统中引用类型的内存占用为 8 个字节。
    • 开启指针压缩(-XX:+UseCompressedOops):在 64 为操作系统下,引用类型内存占用则变为为 4 个字节,32 位操作系统中引用类型的内存占用继续为 4 个字节。

为什么 32 位操作系统的引用类型占 4 个字节,而 64 位操作系统引用类型占 8 字节?

在 Java 中,引用类型所保存的是被引用对象的内存地址。在 32 位操作系统中内存地址是由 32 个 bit 表示,因此需要 4 个字节来记录内存地址,能够记录的虚拟地址空间是 2^32 大小,也就是只能够表示 4G 大小的内存。

而在 64 位操作系统中内存地址是由 64 个 bit 表示,因此需要 8 个字节来记录内存地址,但在 64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 2^48 大小,能够表示 256T 大小的内存,其中低 128T 的空间划分为用户空间,高 128T 划分为内核空间,可以说是非常大了。

对齐填充(padding)

为了内存对齐需要进行对齐填充,对齐填充不仅会出现在对象头与字段、字段与字段之间,还会出现在对象与对象之间。

在我们从整体上介绍完 Java 对象在 JVM 中的内存布局之后,下面我们来看下 Java 对象中定义的这些实例字段在实例数据区是如何排列布局的。

Java 虚拟机堆中对象之间的内存地址需要对齐至 8N(8 的倍数),如果一个对象占用内存不到 8N 个字节,那么就必须在对象后填充一些不必要的字节对齐至 8N 个字节。
字段对齐规则:
字段重排列规则

虚拟机中内存对齐的选项为 -XX:ObjectAlignmentInBytes,默认为 8。也就是说对象与对象之间的内存地址需要对齐至多少倍,是由这个 JVM 参数控制的。

为什么需要内存对齐???

详见下面介绍。

字段重排列

其实我们在编写 Java 源代码文件的时候,定义的那些实例字段的顺序会被 JVM 重新分配排列。这样做的目的其实是为了内存对齐,那么什么是内存对齐,为什么要进行内存对齐?

字段重排列规则

JVM 重新分配字段的排列顺序受 -XX:FieldsAllocationStyle 参数的影响,默认值为 1。

实例字段的重新分配策略遵循以下规则:

  1. 如果一个字段占用 X 个字节,那么这个字段的偏移量 OFFSET 需要对齐至 NX;

    偏移量是指字段的内存地址与 Java 对象的起始内存地址之间的差值。比如 long 类型的字段,它内存占用 8 个字节,那么它的 OFFSET 应该是 8 的倍数 8N。不足 8N 的需要填充字节。

  2. 在开启了压缩指针的 64 位 JVM 中,Java 类中的第一个字段的 OFFSET 需要对齐至 4N,在关闭压缩指针的情况下类中第一个字段的 OFFSET 需要对齐至 8N;
  3. JVM 默认分配字段的顺序为:long / double,int / float,short / char,byte / boolean,oops(Ordianry Object Point 引用类型指针),并且父类中定义的实例变量会出现在子类实例变量之前。当设置 JVM 参数 -XX +CompactFields 时(默认),占用内存小于 long / double 的字段会被允许插入到对象中第一个 long / double 字段之前的间隙中,以避免不必要的内存填充。

CompactFields 选项参数在 JDK14 中以被标记为过期了,并在将来的版本中很可能被删除。详细细节可查看 issue: https://bugs.openjdk.java.net/browse/JDK-8228750

假设现在我们有这样一个类定义:

1
2
public class Parent { long l; int i;}
public class Child extends Parent { long l; int i;}
  • 根据上面介绍的规则 3 我们知道父类中的变量是出现在子类变量之前的,并且字段分配顺序应该是 long 型字段 l,应该在 int 型字段 i 之前。

    如果 JVM 设置了 -XX +CompactFields 时,int 型字段是可以插入对象中的第一个 long 型字段(也就是 Parent.l 字段)之前的空隙中的。
    如果 JVM 设置了 -XX -CompactFields 则 int 型字段的这种插入行为是不被允许的。

  • 根据规则 1 我们知道 long 型字段在实例数据区的 OFFSET 需要对齐至 8N,而 int 型字段的 OFFSET 需要对齐至 4N。
  • 根据规则 2 我们知道如果开启压缩指针 -XX:+UseCompressedOops,Child 对象的第一个字段的 OFFSET 需要对齐至 4N,关闭压缩指针时 -XX:-UseCompressedOops,Child 对象的第一个字段的 OFFSET 需要对齐至 8N。

由于 JVM 参数 UseCompressedOopsCompactFields 的存在,导致 Child 对象在实例数据区字段的排列顺序分为四种情况,下面我们结合前边提炼出的这三点规则来看下字段排列顺序在这四种情况下的表现。

-XX:+UseCompressedOops -XX -CompactFields 开启压缩指针,关闭字段压缩

java-jvm-对象布局-字段重排1

  • 偏移量 OFFSET = 8 的位置存放的是类型指针,由于开启了压缩指针所以占用 4 个字节。对象头总共占用 12 个字节:MarkWord(8 字节) + 类型指针(4 字节);
  • 根据规则 3:父类 Parent 中的字段是要出现在子类 Child 的字段之前的并且 long 型字段在 int 型字段之前;
  • 根据规则 2:在开启压缩指针的情况下,Child 对象中的第一个字段需要对齐至 4N。这里 Parent.l 字段的 OFFSET 可以是 12 也可以是 16;
  • 根据规则 1:long 型字段在实例数据区的 OFFSE T 需要对齐至 8N,所以这里 Parent.l 字段的 OFFSET 只能是 16,因此 OFFSET = 12 的位置就需要被填充;Child.l 字段只能在 OFFSET = 32 处存储,不能够使用 OFFSET = 28 位置,因为 28 的位置不是 8 的倍数无法对齐 8N,因此 OFFSET = 28 的位置被填充了 4 个字节。

规则 1 也规定了 int 型字段的 OFFSET 需要对齐至 4N,所以 Parent.i 与 Child.i 分别存储以 OFFSET = 24 和 OFFSET = 40 的位置。

因为 JVM 中的内存对齐除了存在于字段与字段之间还存在于对象与对象之间,Java 对象之间的内存地址需要对齐至 8N。
所以 Child 对象的末尾处被填充了 4 个字节,对象大小由开始的 44 字节被填充到 48 字节。

-XX:+UseCompressedOops -XX +CompactFields 开启压缩指针,开启字段压缩

java-jvm-对象布局-字段重排2

  • 在第一种情况的分析基础上,我们开启了 -XX +CompactFields 压缩字段,所以导致 int 型的 Parent.i 字段可以插入到 OFFSET = 12 的位置处,以避免不必要的字节填充;
  • 根据规则 2:Child 对象的第一个字段需要对齐至 4N,这里我们看到 int 型的 Parent.i 字段是符合这个规则的;
  • 根据规则 1:Child 对象的所有 long 型字段都对齐至 8N,所有的 int 型字段都对齐至 4N。

最终得到 Child 对象大小为 36 字节,由于 Java 对象与对象之间的内存地址需要对齐至 8N,所以最后 Child 对象的末尾又被填充了 4 个字节最终变为 40 字节。

这里我们可以看到在开启字段压缩 -XX +CompactFields 的情况下,Child 对象的大小由 48 字节变成了 40 字节。

-XX:-UseCompressedOops -XX -CompactFields 关闭压缩指针,关闭字段压缩

java-jvm-对象布局-字段重排3

首先,在关闭压缩指针 -UseCompressedOops 的情况下,对象头中的类型指针占用字节变成了 8 字节。导致对象头的大小在这种情况下变为了 16 字节。

  • 根据规则 1:long 型的变量 OFFSET 需要对齐至 8N。根据 规则 2:在关闭压缩指针的情况下,Child 对象的第一个字段 Parent.l 需要对齐至 8N。所以这里的 Parent.l 字段的 OFFSET = 16;
  • 由于 long 型的变量 OFFSET 需要对齐至 8N,所以 Child.l 字段的 OFFSET 需要是 32,因此 OFFSET = 28 的位置被填充了 4 个字节。

这样计算出来的 Child 对象大小为 44 字节。但是考虑到 Java 对象与对象的内存地址需要对齐至 8N,于是又在对象末尾处填充了 4 个字节,最终 Child 对象的内存占用为 48 字节。

-XX:-UseCompressedOops -XX +CompactFields 关闭压缩指针,开启字段压缩

在第三种情况的分析基础上,我们来看下第四种情况的字段排列情况:

java-jvm-对象布局-字段重排4

由于在关闭指针压缩的情况下类型指针的大小变为了 8 个字节,所以导致 Child 对象中第一个字段 Parent.l 前边并没有空隙,刚好对齐 8N,并不需要 int 型变量的插入。所以即使开启了字段压缩 -XX +CompactFields,字段的总体排列顺序还是不变的。

默认情况下指针压缩 -XX:+UseCompressedOops 以及字段压缩 -XX +CompactFields 都是开启的。

数组对象的内存布局

基本类型数组的内存布局

java-jvm-数组-基本类型内存布局

上图表示的是基本类型数组在内存中的布局,基本类型数组在 JVM 中用 typeArrayOop 结构体表示,基本类型数组类型元信息用 TypeArrayKlass 结构体表示。

数组的内存布局大体上和普通对象的内存布局差不多,唯一不同的是在数组类型对象头中多出了 4 个字节用来表示数组长度的部分。

我们还是分别以开启指针压缩和关闭指针压缩两种情况,通过下面的例子来进行说明:

1
long[] longArrayLayout = new long[1];

-XX:+UseCompressedOops 开启指针压缩

java-jvm-数组-基本类型开启指针压缩

红框部分即为数组类型对象头中多出来一个 4 字节大小用来表示数组长度的部分。

因为我们示例中的 long 型数组只有一个元素,所以实例数据区的大小只有 8 字节。 如果我们示例中的 long 型数组变为两个元素,那么实例数据区的大小就会变为 16 字节,以此类推……

-XX:-UseCompressedOops 关闭指针压缩

java-jvm-数组-基本类型关闭指针压缩

当关闭了指针压缩时,对象头中的 MarkWord 还是占用 8 个字节,但是类型指针从 4 个字节变为了 8 个字节。数组长度属性还是不变保持 4 个字节。

这里我们发现是实例数据区与对象头之间发生了对齐填充。大家还记得这是为什么吗?

我们前边在字段重排列小节介绍了三种字段排列规则在这里继续适用:

  1. 规则 1:如果一个字段占用 X 个字节,那么这个字段的偏移量 OFFSET 需要对齐至 NX。
  2. 规则 2:在开启了压缩指针的 64 位 JVM 中,Java 类中的第一个字段的 OFFSET 需要对齐至 4N,在关闭压缩指针的情况下类中第一个字段的 OFFSET 需要对齐至 8N。

这里基本数组类型的实例数据区中是 long 型,在关闭指针压缩的情况下,根据规则 1 和规则 2 需要对齐至 8 的倍数,所以要在其与对象头之间填充 4 个字节,达到内存对齐的目的,起始地址变为 24。

引用类型数组的内存布局

java-jvm-数组-引用类型内存布局

上图表示的是引用类型数组在内存中的布局,引用类型数组在 JVM 中用 objArrayOop 结构体表示,基本类型数组类型元信息用 ObjArrayKlass 结构体表示。

同样在引用类型数组的对象头中也会有一个 4 字节大小用来表示数组长度的部分。

我们还是分别以开启指针压缩和关闭指针压缩两种情况,通过下面的例子来进行说明:

1
2
public class ReferenceArrayLayout { char a; int b; short c;}
ReferenceArrayLayout[] referenceArrayLayout = new ReferenceArrayLayout[1];

-XX:+UseCompressedOops 开启指针压缩

java-jvm-数组-引用类型开启指针压缩

引用数组类型内存布局与基础数组类型内存布局最大的不同在于它们的实例数据区。 由于开启了压缩指针,所以对象引用占用内存大小为 4 个字节,而我们示例中引用数组只包含一个引用元素,所以这里实例数据区中只有 4 个字节。 相同道理,如果示例中的引用数组包含的元素变为两个引用元素,那么实例数据区就会变为 8 个字节,以此类推……

最后由于 Java 对象需要内存对齐至 8 的倍数,所以在该引用数组的实例数据区后填充了 4 个字节。

-XX:-UseCompressedOops 关闭指针压缩

java-jvm-数组-引用类型关闭指针压缩

当关闭压缩指针时,对象引用占用内存大小变为了 8 个字节,所以引用数组类型的实例数据区占用了 8 个字节。

根据字段重排列规则 2,在引用数组类型对象头与实例数据区中间需要填充 4 个字节以保证内存对齐的目的。

对齐填充的应用

为什么我们要进行对齐填充,是要解决什么问题吗?

对齐填充:解决 False Sharing(伪共享)问题

除了以上介绍的两种对齐填充的场景(字段与字段之间,对象与对象之间),在 Java 中还有一种对齐填充的场景,那就是通过对齐填充的方式来解决 False Sharing(伪共享)的问题。

在介绍 False Sharing(伪共享)之前,先来介绍下 CPU 读取内存中数据的方式。

CPU 内存读取

CPU 缓存

根据摩尔定律:芯片中的晶体管数量每隔 18 个月就会翻一番。 导致 CPU 的性能和处理速度变得越来越快,而提升 CPU 的运行速度比提升内存的运行速度要容易和便宜的多,所以就导致了 CPU 与内存之间的速度差距越来越大。

为了弥补 CPU 与内存之间巨大的速度差异,提高 CPU 的处理效率和吞吐,于是人们引入了 L1,L2,L3 高速缓存集成到 CPU 中。当然还有 L0 也就是寄存器,寄存器离 CPU 最近,访问速度也最快,基本没有时延。

java-jvm-cpu缓存结构

一个 CPU 里面包含多个核心,我们在购买电脑的时候经常会看到这样的处理器配置,比如 4 核 8 线程。意思是这个 CPU 包含 4 个物理核心 8 个逻辑核心。4 个物理核心表示在同一时间可以允许 4 个线程并行执行,8 个逻辑核心表示处理器利用超线程的技术将一个物理核心模拟出了两个逻辑核心,一个物理核心在同一时间只会执行一个线程,而超线程芯片可以做到线程之间快速切换,当一个线程在访问内存的空隙,超线程芯片可以马上切换去执行另外一个线程。因为切换速度非常快,所以在效果上看到是 8 个线程在同时执行。

图中的 CPU 核心指的是物理核心。

从图中我们可以看到 L1Cache 是离 CPU 核心最近的高速缓存,紧接着就是 L2Cache、L3Cache、内存。离 CPU 核心越近的缓存访问速度也越快,造价也就越高,当然容量也就越小。

其中 L1Cache 和 L2Cache 是 CPU 物理核心私有的(注意:这里是物理核心不是逻辑核心),而 L3Cache 是整个 CPU 所有物理核心共享的。

CPU 逻辑核心共享其所属物理核心的 L1Cache 和 L2Cache。

L1Cache

L1Cache 离 CPU 是最近的,它的访问速度最快,容量也最小。

从图中我们看到 L1Cache 分为两个部分,分别是 Data Cache 和 Instruction Cache。它们一个是存储数据的,一个是存储代码指令的。

我们可以通过 cd /sys/devices/system/cpu/ 来查看 Linux 机器上的 CPU 信息。

java-jvm-cpu核心查看

/sys/devices/system/cpu/ 目录里,我们可以看到 CPU 的核心数,当然这里指的是逻辑核心。

下面我们进入其中一颗 CPU 核心(cpu0)中去看下 L1Cache 的情况:

CPU 缓存的情况在 /sys/devices/system/cpu/cpu0/cache 目录下查看:

java-jvm-cpu核心cache

index0 描述的是 L1Cache 中 DataCache 的情况:

java-jvm-cpu核心L1cache

  • level :表示该 cache 信息属于哪一级,1 表示 L1Cache;
  • type :表示属于 L1Cache 的 DataCache;
  • size :表示 DataCache 的大小为 32K;
  • shared_cpu_list :之前我们提到 L1Cache 和 L2Cache 是 CPU 物理核所私有的,而由物理核模拟出来的逻辑核是共享 L1Cache 和 L2Cache 的,/sys/devices/system/cpu/ 目录下描述的信息是逻辑核。 shared_cpu_list 描述的正是哪些逻辑核共享这个物理核。

index1 描述的是 L1Cache 中 Instruction Cache 的情况:

java-jvm-cpu核心L1cache2

我们看到 L1Cache 中的 Instruction Cache 大小也是 32K。

L2Cache

L2Cache 的信息存储在 index2 目录下:

java-jvm-cpu核心L2cache

L2Cache 的大小为 256K,比 L1Cache 要大些。

L3Cache

L3Cache 的信息存储在 index3 目录下:

java-jvm-cpu核心L3cache

到这里我们可以看到 L1Cache 中的 DataCache 和 InstructionCache 大小一样都是 32K 而 L2Cache 的大小为 256K,L3Cache 的大小为 6M。

当然这些数值在不同的 CPU 配置上会是不同的,但是总体上来说 L1Cache 的量级是几十 KB,L2Cache 的量级是几百 KB ~ 几 MB,L3Cache 的量级是几 MB ~ 十几 MB。

CPU 缓存行

前边我们介绍了 CPU 的高速缓存结构,引入高速缓存的目的在于消除 CPU 与内存之间的速度差距,根据程序的局部性原理我们知道,CPU 的高速缓存肯定是用来存放热点数据的。

程序局部性原理表现为:时间局部性和空间局部性。
时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。
空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

那么在高速缓存中存取数据的基本单位又是什么呢?

事实上热点数据在 CPU 高速缓存中的存取并不是我们想象中的以单独的变量或者单独的指针为单位存取的。

CPU 高速缓存中存取数据的基本单位叫做缓存行 cache line。 缓存行存取字节的大小为 2 的倍数,在不同的机器上,缓存行的大小范围在 32 字节到 128 字节之间。 目前所有主流的处理器中缓存行的大小均为 64 字节(注意:这里的单位是字节)。

java-jvm-cpu缓存行

从图中我们可以看到 L1Cache, L2Cache, L3Cache 中缓存行的大小都是 64 字节。

这也就意味着每次 CPU 从内存中获取数据或者写入数据的大小为 64 个字节,即使你只读一个 bit,CPU 也会从内存中加载 64 字节数据进来。 同样的道理,CPU 从高速缓存中同步数据到内存也是按照 64 字节的单位来进行。

比如你访问一个 long 型数组,当 CPU 去加载数组中第一个元素时也会同时将后边的 7 个元素一起加载进缓存中。这样一来就加快了遍历数组的效率

long 类型在 Java 中占用 8 个字节,一个缓存行可以存放 8 个 long 型变量。

事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构,如果你的数据结构中的项在内存中不是彼此相邻的(比如链表),这样就无法利用 CPU 缓存的优势。由于数据在内存中不是连续存放的,所以在这些数据结构中的每一个项都可能会出现缓存行未命中(程序局部性原理)的情况。

Netty 利用数组实现的自定义 SelectedSelectionKeySet 类型替换掉了 JDK 利用 HashSet 类型实现的 sun.nio.ch.SelectorImpl#selectedKeys。
目的就是利用 CPU 缓存的优势来提高 IO 活跃的 SelectionKeys 集合的遍历性能。

False Sharing(伪共享)

定义一个示例类 FalseSharding,类中有两个 long 型的 volatile 字段 a,b。

1
public class FalseSharding { volatile long a; volatile long b;}

字段 a,b 之间逻辑上是独立的,它们之间一点关系也没有,分别用来存储不同的数据,数据之间也没有关联。

FalseSharding 类中字段之间的内存布局如下:

java-jvm-FalseSharding类

FalseSharding 类中的字段 a, b 在内存中是相邻存储,分别占用 8 个字节。 如果恰好字段 a,b 被 CPU 读进了同一个缓存行,而此时有两个线程,线程 a 用来修改字段 a,同时线程 b 用来读取字段 b。 过程如下:

java-jvm-FalseSharing过程1

在这种场景下,会对线程 b 的读取操作造成什么影响呢?

我们知道声明了 volatile 关键字的变量可以在多线程处理环境下,确保内存的可见性。计算机硬件层会保证对被 volatile 关键字修饰的共享变量进行写操作后的内存可见性,而这种内存可见性是由 Lock 前缀指令以及缓存一致性协议(MESI 控制协议)共同保证的。

  • Lock 前缀指令可以使修改线程所在的处理器中的相应缓存行数据被修改后立马刷新回内存中,并同时锁定所有处理器核心中缓存了该修改变量的缓存行,防止多个处理器核心并发修改同一缓存行;
  • 缓存一致性协议主要是用来维护多个处理器核心之间的 CPU 缓存一致性以及与内存数据的一致性。每个处理器会在总线上嗅探其他处理器准备写入的内存地址,如果这个内存地址在自己的处理器中被缓存的话,就会将自己处理器中对应的缓存行置为无效,下次需要读取的该缓存行中的数据的时候,就需要访问内存获取。

第一种影响:

java-jvm-FalseSharing影响1

  • 当线程 a 在处理器 core0 中对字段 a 进行修改时,Lock 前缀指令会将所有处理器中缓存了字段 a 的对应缓存行进行锁定,这样就会导致线程 b 在处理器 core1 中无法读取和修改自己缓存行的字段 b;
  • 处理器 core0 将修改后的字段 a 所在的缓存行刷新回内存中。

从图中我们可以看到此时字段 a 的值在处理器 core0 的缓存行中以及在内存中已经发生变化了。但是处理器 core1 中字段 a 的值还没有变化,并且 core1 中字段 a 所在的缓存行处于锁定状态,无法读取也无法写入字段 b。

从上述过程中我们可以看出即使字段 a,b 之间逻辑上是独立的,它们之间一点关系也没有,但是线程 a 对字段 a 的修改,导致了线程 b 无法读取字段 b。

第二种影响:

java-jvm-FalseSharing影响2

当处理器 core0 将字段 a 所在的缓存行刷新回内存的时候,处理器 core1 会在总线上嗅探到字段 a 的内存地址正在被其他处理器修改,所以将自己的缓存行置为失效。当线程 b 在处理器 core1 中读取字段 b 的值时,发现缓存行已被置为失效,core1 需要重新从内存中读取字段 b 的值即使字段 b 没有发生任何变化。

从以上两种影响我们看到字段 a 与字段 b 实际上并不存在共享,它们之间也没有相互关联关系,理论上线程 a 对字段 a 的任何操作,都不应该影响线程 b 对字段 b 的读取或者写入。

但事实上线程 a 对字段 a 的修改导致了字段 b 在 core1 中的缓存行被锁定(Lock 前缀指令),进而使得线程 b 无法读取字段 b。

线程 a 所在处理器 core0 将字段 a 所在缓存行同步刷新回内存后,导致字段 b 在 core1 中的缓存行被置为失效(缓存一致性协议),进而导致线程 b 需要重新回到内存读取字段 b 的值无法利用 CPU 缓存的优势。

由于字段 a 和字段 b 在同一个缓存行中,导致了字段 a 和字段 b 事实上的共享(原本是不应该被共享的)。这种现象就叫做 False Sharing(伪共享)。

在高并发的场景下,这种伪共享的问题,会对程序性能造成非常大的影响。

如果线程 a 对字段 a 进行修改,与此同时线程 b 对字段 b 也进行修改,这种情况对性能的影响更大,因为这会导致 core0 和 core1 中相应的缓存行相互失效。

False Sharing 解决方案

导致 False Sharing 出现的原因是字段 a 和字段 b 在同一个缓存行导致的,那么我们就要想办法让字段 a 和字段 b 不在一个缓存行中。

那么我们怎么做才能够使得字段 a 和字段 b 一定不会被分配到同一个缓存行中呢?

答案是: 对齐填充

在 Java8 之前我们通常会在字段 a 和字段 b 前后分别填充 7 个 long 型变量(缓存行大小 64 字节),目的是让字段 a 和字段 b 各自独占一个缓存行避免 False Sharing。

比如我们将一开始的实例代码修改成这个这样子,就可以保证字段 a 和字段 b 各自独占一个缓存行了。

1
2
3
4
5
6
7
public class FalseSharding {
long p1,p2,p3,p4,p5,p6,p7;
volatile long a;
long p8,p9,p10,p11,p12,p13,p14;
volatile long b;
long p15,p16,p17,p18,p19,p20,p21;
}

修改后的对象在内存中布局如下:

java-jvm-FalseSharding类2

可以看到为了解决 False Sharing 问题,我们将原本占用 32 字节的 FalseSharding 示例对象硬生生的填充到了 200 字节。 这对内存的消耗是非常可观的。 通常为了极致的性能,我们会在一些高并发框架或者 JDK 的源码中看到 False Sharing 的解决场景。 因为在高并发场景中,任何微小的性能损失比如 False Sharing,都会被无限放大。

但解决 False Sharing 的同时又会带来巨大的内存消耗,所以即使在高并发框架比如 disrupter 或者 JDK 中也只是针对那些在多线程场景下被频繁写入的共享变量。

不能因为自己手里拿着锤子,就满眼都是钉子。

@Contended 注解

在 Java8 中引入了一个新注解 @Contended,用于解决 False Sharing 的问题,同时这个注解也会影响到 Java 对象中的字段排列。

在上一小节的内容介绍中,我们通过手段填充字段的方式解决了 False Sharing 的问题,但是这里也有一个问题,因为我们在手动填充字段的时候还需要考虑 CPU 缓存行的大小,因为虽然现在所有主流的处理器缓存行大小均为 64 字节,但是也还是有处理器的缓存行大小为 32 字节,有的甚至是 128 字节。我们需要考虑很多硬件的限制因素。

Java8 中通过引入 @Contended 注解帮我们解决了这个问题,我们不在需要去手动填充字段了。下面我们就来看下 @Contended 注解是如何帮助我们来解决这个问题的。

上小节介绍的手动填充字节是在共享变量前后填充 64 字节大小的空间,这样只能确保程序在缓存行大小为 32 字节或者 64 字节的 CPU 下独占缓存行。但是如果 CPU 的缓存行大小为 128 字节,这样依然存在 False Sharing 的问题。

引入 @Contended 注解可以使我们忽略底层硬件设备的差异性,做到 Java 语言的初衷:平台无关性。

@Contended 注解默认只是在 JDK 内部起作用,如果我们的程序代码中需要使用到 @Contended 注解,那么需要开启 JVM 参数 -XX:-RestrictContended 才会生效。

1
2
3
4
5
6
7
8
9
10
11
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
/**
* The (optional) contention group tag.
* This tag is only meaningful for field level annotations.
*
* @return contention group tag.
*/
String value() default "";
}
  • @Contended 标注在类上表示该类对象中的实例数据整体需要独占缓存行。不能与其他实例数据共享缓存行;
  • @Contended 标注在类中的字段上表示该字段需要独占缓存行;

除此之外 @Contended 还提供了分组的概念,注解中的 value 属性表示 contention group 。属于统一分组下的变量,它们在内存中是连续存放的,可以允许共享缓存行。不同分组之间不允许共享缓存行。

下面我们来分别看下 @Contended 注解在这三种使用场景下是怎样影响字段之间的排列的。

@Contended 标注在类上
1
2
3
4
5
6
7
@Contendedpublic
class FalseSharding {
volatile long a;
volatile long b;
volatile int c;
volatile int d;
}

当 @Contended 标注在 FalseSharding 示例类上时,表示 FalseSharding 示例对象中的整个实例数据区需要独占缓存行,不能与其他对象或者变量共享缓存行。

java-jvm-FalseSharding类3

如图中所示,FalseSharding 示例类被标注了 @Contended 之后,JVM 会在 FalseSharding 示例对象的实例数据区前后填充 128 个字节,保证实例数据区内的字段之间内存是连续的,并且保证整个实例数据区独占缓存行,不会与实例数据区之外的数据共享缓存行。

之前不是提到缓存行的大小为 64 字节吗?为什么这里会填充 128 字节呢? 而且之前介绍的手动填充也是填充的 64 字节,为什么 @Contended 注解会采用两倍的缓存行大小来填充呢?

有两个原因:

  1. 目前大部分主流的 CPU 缓存行是 64 字节,但是也有部分 CPU 缓存行是 32 字节或者 128 字节,如果只填充 64 字节的话,在缓存行大小为 32 字节和 64 字节的 CPU 中是可以做到独占缓存行从而避免 FalseSharding 的,但在缓存行大小为 128 字节的 CPU 中还是会出现 FalseSharding 问题,这里 Java 采用了悲观的一种做法,默认都是填充 128 字节,虽然对于大部分情况下比较浪费,但是屏蔽了底层硬件的差异。
  2. 更核心的一个原因,主要是为了防止 CPU Adjacent Sector Prefetch(CPU 相邻扇区预取)特性所带来的 FalseSharding 问题。

@Contended 注解填充字节的大小我们可以通过 JVM 参数 -XX:ContendedPaddingWidth 指定,有效值范围 0 - 8192,默认为 128。
CPU Adjacent Sector Prefetch:https://www.techarp.com/bios-guide/cpu-adjacent-sector-prefetch/

CPU Adjacent Sector Prefetch 是 Intel 处理器特有的 BIOS 功能特性,默认是 enabled。 主要作用就是利用程序局部性原理,当 CPU 从内存中请求数据,并读取当前请求数据所在缓存行时,会进一步预取与当前缓存行相邻的下一个缓存行,这样当我们的程序在顺序处理数据时,会提高 CPU 处理效率。 这一点也体现了程序局部性原理中的空间局部性特征。

当 CPU Adjacent Sector Prefetch 特性被 disabled 禁用时,CPU 就只会获取当前请求数据所在的缓存行,不会预取下一个缓存行。

所以在当 CPU Adjacent Sector Prefetch 启用(enabled)的时候,CPU 其实同时处理的是两个缓存行,在这种情况下,就需要填充两倍缓存行大小(128 字节)来避免 CPU Adjacent Sector Prefetch 所带来的的 FalseSharding 问题。

@Contended 标注在字段上
1
2
3
4
5
6
7
8
public class FalseSharding {
@Contended
volatile long a;
@Contended
volatile long b;
volatile int c;
volatile long d;
}

java-jvm-FalseSharding类4

这次我们将 @Contended 注解标注在了 FalseSharding 示例类中的字段 a 和字段 b 上,这样带来的效果是字段 a 和字段 b 各自独占缓存行。 从内存布局上看,字段 a 和字段 b 前后分别被填充了 128 个字节,来确保字段 a 和字段 b 不与任何数据共享缓存行。

@Contended 分组
1
2
3
4
5
6
7
8
9
10
public class FalseSharding {
@Contended("group1")
volatile int a;
@Contended("group1")
volatile long b;
@Contended("group2")
volatile long c;
@Contended("group2")
volatile long d;
}

java-jvm-FalseSharding类5

这次我们将字段 a 与字段 b 放在同一 content group 下,字段 c 与字段 d 放在另一个 content group 下。

这样处在同一分组 group1 下的字段 a 与字段 b 在内存中是连续存储的,可以共享缓存行。

同理处在同一分组 group2 下的字段 c 与字段 d 在内存中也是连续存储的,也允许共享缓存行。

但是分组之间是不能共享缓存行的,所以在字段分组的前后各填充 128 字节,来保证分组之间的变量不能共享缓存行。

内存对齐

通过以上内容我们了解到 Java 对象中的实例数据区字段需要进行内存对齐而导致在 JVM 中会被重排列以及通过填充缓存行避免 false sharding 的目的所带来的字节对齐填充。

我们也了解到内存对齐不仅发生在对象与对象之间,也发生在对象中的字段之间。

那么本小节会介绍什么是内存对齐,在本节的内容开始之前先抛出两个问题:

为什么要进行内存对齐?如果就是头比较铁,就是不内存对齐,会产生什么样的后果?

Java 虚拟机堆中对象的起始地址为什么需要对齐至 8 的倍数?为什么不对齐至 4 的倍数或 16 的倍数或 32 的倍数呢?

带着这两个问题,下面我们正式开始本节的内容。

内存结构

我们平时所称的内存也叫随机访问存储器(random-access memory)也叫 RAM。而 RAM 分为两类:

一类是静态 RAM(SRAM),这类 SRAM 用于前边介绍的 CPU 高速缓存 L1Cache、L2Cache、L3Cache。其特点是访问速度快,访问速度为 1 - 30 个时钟周期,但是容量小、造价高。
另一类则是动态 RAM(DRAM),这类 DRAM 用于我们常说的主存上,其特点的是访问速度慢(相对高速缓存),访问速度为 50 - 200 个时钟周期,但是容量大,造价便宜些(相对高速缓存)。

内存由一个一个的存储器模块(memory module)组成,它们插在主板的扩展槽上。常见的存储器模块通常以 64 位为单位(8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。

DRAM 芯片就包装在存储器模块中,每个存储器模块中包含 8 个 DRAM 芯片,依次编号为 0 - 7。

每一个 DRAM 芯片的存储结构是一个二维矩阵,二维矩阵中存储的元素我们称为超单元(supercell),每个 supercell 大小为一个字节(8 bit)。每个 supercell 都由一个坐标地址(i,j)。

i 表示二维矩阵中的行地址,在计算机中行地址称为 RAS(row access strobe,行访问选通脉冲)。j 表示二维矩阵中的列地址,在计算机中列地址称为 CAS(column access strobe,列访问选通脉冲)。

下图中的 supercell 的 RAS = 2,CAS = 2。

java-jvm-DRAM结构

DRAM 芯片中的信息通过引脚流入流出 DRAM 芯片。每个引脚携带 1 bit 的信号。

图中 DRAM 芯片包含了两个地址引脚(addr),因为我们要通过 RAS、CAS 来定位要获取的 supercell。还有 8 个数据引脚(data),因为 DRAM 芯片的 IO 单位为一个字节(8 bit),所以需要 8 个 data 引脚从 DRAM 芯片传入传出数据。

注意: 这里只是为了解释地址引脚和数据引脚的概念,实际硬件中的引脚数量是不一定的。

DRAM 芯片的访问

以读取上图中坐标地址为(2,2)的 supercell 为例,来说明访问 DRAM 芯片的过程。

java-jvm-DRAM芯片访问

  1. 首先存储控制器将行地址 RAS = 2 通过地址引脚发送给 DRAM 芯片;
  2. DRAM 芯片根据 RAS = 2 将二维矩阵中的第二行的全部内容拷贝到内部行缓冲区中;
  3. 接下来存储控制器会通过地址引脚发送 CAS = 2 到 DRAM 芯片中;
  4. DRAM 芯片从内部行缓冲区中根据 CAS = 2 拷贝出第二列的 supercell 并通过数据引脚发送给存储控制器。

DRAM 芯片的 IO 单位为一个 supercell,也就是一个字节(8 bit)。

CPU 如何读写主存

前边我们介绍了内存的物理结构,以及如何访问内存中的 DRAM 芯片获取 supercell 中存储的数据(一个字节)。本小节我们来介绍下 CPU 是如何访问内存的。

java-jvm-CPU与内存之间的总线结构

关于 CPU 芯片的内部结构我们在介绍 false sharding 的时候已经详细的介绍过了,这里我们主要聚焦在 CPU 与内存之间的总线架构上。

总线结构

CPU 与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列的步骤完成的,这些步骤称为总线事务(bus transaction)。

其中数据从内存传送到 CPU 称之为读事务(read transaction),数据从 CPU 传送到内存称之为写事务(write transaction)。

总线上传输的信号包括:地址信号,数据信号,控制信号。其中控制总线上传输的控制信号可以同步事务,并能够标识出当前正在被执行的事务信息:

  • 当前这个事务是到内存的?还是到磁盘的?或者是到其他 IO 设备的?
  • 这个事务是读还是写?
  • 总线上传输的地址信号(内存地址),还是数据信号(数据)?

还记得我们前边讲到的 MESI 缓存一致性协议吗? 当 core0 修改字段 a 的值时,其他 CPU 核心会在总线上嗅探字段 a 的内存地址,如果嗅探到总线上出现字段 a 的内存地址,说明有人在修改字段 a,这样其他 CPU 核心就会失效自己缓存字段 a 所在的 cache line。

如上图所示,其中系统总线是连接 CPU 与 IO bridge 的,存储总线是来连接 IO bridge 和主存的。

IO bridge 负责将系统总线上的电子信号转换成存储总线上的电子信号。IO bridge 也会将系统总线和存储总线连接到 IO 总线(磁盘等 IO 设备)上。 这里我们看到 IO bridge 其实起的作用就是转换不同总线上的电子信号。

CPU 从内存读取数据过程

假设 CPU 现在要将内存地址为 A 的内容加载到寄存器中进行运算。

java-jvm-cpu读取内存

首先 CPU 芯片中的总线接口会在总线上发起读事务(read transaction)。该读事务分为以下步骤进行:

  1. CPU 将内存地址 A 放到系统总线上。随后 IO bridge 将信号传递到存储总线上;
  2. 主存感受到存储总线上的地址信号并通过存储控制器将存储总线上的内存地址 A 读取出来;
  3. 存储控制器通过内存地址 A 定位到具体的存储器模块,从 DRAM 芯片中取出内存地址 A 对应的数据 X;
  4. 存储控制器将读取到的数据 X 放到存储总线上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递;
  5. CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。

以上就是 CPU 读取内存数据到寄存器中的完整过程。

但是其中还涉及到一个重要的过程,这里我们还是需要摊开来介绍一下,那就是存储控制器如何通过内存地址 A 从主存中读取出对应的数据 X 的?

接下来我们结合前边介绍的内存结构以及从 DRAM 芯片读取数据的过程,来总体介绍下如何从主存中读取数据。

如何根据内存地址从主存中读取数据

前边介绍到,当主存中的存储控制器感受到了存储总线上的地址信号时,会将内存地址从存储总线上读取出来。

随后会通过内存地址定位到具体的存储器模块。还记得内存结构中的存储器模块吗?

每个存储器模块中包含了 8 个 DRAM 芯片,编号从 0 - 7

存储控制器会将内存地址转换为 DRAM 芯片中 supercell 在二维矩阵中的坐标地址(RAS,CAS)。 并将这个坐标地址发送给对应的存储器模块。 随后存储器模块会将 RAS 和 CAS 广播到存储器模块中的所有 DRAM 芯片。 依次通过 (RAS,CAS) 从 DRAM0 到 DRAM7 读取到相应的 supercell。

我们知道一个 supercell 存储了 8 bit 数据,这里我们从 DRAM0 到 DRAM7 依次读取到了 8 个 supercell 也就是 8 个字节,然后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。

CPU 总是以 word size 为单位从内存中读取数据,在 64 位处理器中的 word size 为 8 个字节。64 位的内存也只能每次吞吐 8 个字节。

CPU 每次会向内存读写一个 cache line 大小的数据(64 个字节),但是内存一次只能吞吐 8 个字节。

所以在内存地址对应的存储器模块中,DRAM0 芯片存储第一个低位字节(supercell),DRAM1 芯片存储第二个字节……依次类推 DRAM7 芯片存储最后一个高位字节。

内存一次读取和写入的单位是 8 个字节。而且在程序员眼里连续的内存地址实际上在物理上是不连续的。 因为这连续的 8 个字节其实是存储于不同的 DRAM 芯片上的。 每个 DRAM 芯片存储一个字节(supercell)。

java-jvm-cpu读取存储器模块数据

CPU 向内存写入数据过程

我们现在假设 CPU 要将寄存器中的数据 X 写到内存地址 A 中。同样的道理,CPU 芯片中的总线接口会向总线发起写事务(write transaction)。写事务步骤如下:

  1. CPU 将要写入的内存地址 A 放入系统总线上。
  2. 通过 IO bridge 的信号转换,将内存地址 A 传递到存储总线上。
  3. 存储控制器感受到存储总线上的地址信号,将内存地址 A 从存储总线上读取出来,并等待数据的到达。
  4. CPU 将寄存器中的数据拷贝到系统总线上,通过 IO bridge 的信号转换,将数据传递到存储总线上。
  5. 存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来。
  6. 存储控制器通过内存地址 A 定位到具体的存储器模块,最后将数据写入存储器模块中的 8 个 DRAM 芯片中。

为什么要内存对齐?

在了解内存结构以及 CPU 读写内存的过程之后,现在我们回过头来讨论下前面的问题:为什么要内存对齐?

原因如下:

  1. 速度
  2. 原子性
  3. 尽量分配在一个缓存行中

速度

CPU 读取数据的单位是根据 word size 来的,在 64 位处理器中 word size = 8 字节,所以 CPU 向内存读写数据的单位为 8 字节。

在 64 位内存中,内存 IO 单位为 8 个字节,我们前边也提到内存结构中的存储器模块通常以 64 位为单位(8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。因为每次内存 IO 读取数据都是从数据所在具体的存储器模块中包含的这 8 个 DRAM 芯片中以相同的(RAM、CAS)依次读取一个字节,然后在存储控制器中聚合成 8 个字节返回给 CPU。

由于存储器模块中这种由 8 个 DRAM 芯片组成的物理存储结构的限制,内存读取数据只能是按照地址顺序 8 个字节的依次读取 —— 8 个字节 8 个字节地来读取数据。

  • 假设我们现在读取 0x0000 - 0x0007 这段连续内存地址上的 8 个字节。由于内存读取是按照 8 个字节为单位依次顺序读取的,而我们要读取的这段内存地址的起始地址是 0(8 的倍数),所以 0x0000 - 0x0007 中每个地址的坐标都是相同的(RAS、CAS)。所以他可以在 8 个 DRAM 芯片中通过相同的(RAS、CAS)一次性读取出来。
  • 如果我们现在读取 0x0008 - 0x0015 这段连续内存上的 8 个字节也是一样的,因为内存段起始地址为 8(8 的倍数),所以这段内存上的每个内存地址在 DREAM 芯片中的坐标地址(RAS、CAS)也是相同的,我们也可以一次性读取出来。
  • 但如果我们现在读取 0x0007 - 0x0014 这段连续内存上的 8 个字节情况就不一样了,由于起始地址 0x0007 在 DRAM 芯片中的(RAS、CAS)与后边地址 0x0008 - 0x0014 的(RAS、CAS)不相同,所以 CPU 只能先从 0x0000 - 0x0007 读取 8 个字节出来先放入结果寄存器中并左移 7 个字节(目的是只获取 0x0007),然后 CPU 在从 0x0008 - 0x0015 读取 8 个字节出来放入临时寄存器中并右移 1 个字节(目的是获取 0x0008 - 0x0014)最后与结果寄存器或运算。最终得到 0x0007 - 0x0014 地址段上的 8 个字节。

注意:0x0000 - 0x0007 内存段中的坐标地址(RAS、CAS)与 0x0008 - 0x0015 内存段中的坐标地址(RAS、CAS)是不相同的。

从以上分析过程来看,当 CPU 访问内存对齐的地址时,比如 0x0000 和 0x0008 这两个起始地址都是对齐至 8 的倍数。CPU 可以通过一次 read transaction 读取出来。

但是当 CPU 访问内存没有对齐的地址时,比如 0x0007 这个起始地址就没有对齐至 8 的倍数。CPU 就需要两次 read transaction 才能将数据读取出来。

还记得开头提出的问题吗? Java 虚拟机堆中对象的起始地址为什么需要对齐至 8 的倍数?为什么不对齐至 4 的倍数或 16 的倍数或 32 的倍数呢?

答案就在上面

原子性

CPU 可以原子地操作一个对齐的 word size memory。64 位处理器中 word size = 8 字节。

尽量分配在一个缓存行中

前边在介绍 false sharding 的时候我们提到目前主流处理器中的 cache line 大小为 64 字节,堆中对象的起始地址通过内存对齐至 8 的倍数,可以让对象尽可能的分配到一个缓存行中。一个内存起始地址未对齐的对象可能会跨缓存行存储,这样会导致 CPU 的执行效率慢 2 倍。

其中对象中字段内存对齐的其中一个重要原因也是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。

另外在 字段重排列规则 这一小节介绍的三种字段对齐规则,是保证在字段内存对齐的基础上使得实例数据区占用内存尽可能的小。

压缩指针

在介绍完关于内存对齐的相关内容之后,我们来介绍下前边经常提到的压缩指针。 可以通过 JVM 参数 XX:+UseCompressedOops 开启,默认是开启的。

为什么要使用压缩指针?

假设我们现在正在准备将 32 位系统切换到 64 位系统,起初我们可能会期望系统性能会立马得到提升,但现实情况可能并不是这样的。

在 JVM 中导致性能下降的最主要原因就是 64 位系统中的对象引用。在前边我们也提到过,64 位系统中对象的引用以及类型指针占用 64 bit 也就是 8 个字节。

这就导致了在 64 位系统中的对象引用占用的内存空间是 32 位系统中的两倍大小,因此间接的导致了在 64 位系统中更多的内存消耗以及更频繁的 GC 发生,GC 占用的 CPU 时间越多,那么我们的应用程序占用 CPU 的时间就越少。

另外一个就是对象的引用变大了,那么 CPU 可缓存的对象相对就少了,增加了对内存的访问。综合以上几点从而导致了系统性能的下降。

从另一方面来说,在 64 位系统中内存的寻址空间为 2^48 = 256T,在现实情况中我们真的需要这么大的寻址空间吗?

于是我们就有了新的想法:那么我们是否应该切换回 32 位系统呢?

如果我们切换回 32 位系统,我们怎么解决在 32 位系统中拥有超过 4G 的内存寻址空间呢? 因为现在 4G 的内存大小对于现在的应用来说明显是不够的。

我想以上的这些问题,也是当初 JVM 的开发者需要面对和解决的,当然他们也交出了非常完美的答卷,那就是使用压缩指针可以在 64 位系统中利用 32 位的对象引用获得超过 4G 的内存寻址空间。

压缩指针原理

在 Java 虚拟机堆中对象的起始地址必须对齐至 8 的倍数

由于堆中对象的起始地址均是对齐至 8 的倍数,所以对象引用在开启压缩指针情况下的 32 位二进制的后三位始终是 0(因为它们始终可以被 8 整除)。

既然 JVM 已经知道了这些对象的内存地址后三位始终是 0,那么这些无意义的 0 就没必要在堆中继续存储。相反,我们可以利用存储 0 的这 3 位 bit 存储一些有意义的信息,这样我们就多出 3 位 bit 的寻址空间。

这样在存储的时候,JVM 还是按照 32 位来存储,只不过后三位原本用来存储 0 的 bit 现在被我们用来存放有意义的地址空间信息。

当寻址的时候,JVM 将这 32 位的对象引用左移 3 位(后三位补 0)。 这就导致了在开启压缩指针的情况下,我们原本 32 位的内存寻址空间一下变成了 35 位。可寻址的内存空间变为 2^32 * 2^3 = 32G

java-jvm-压缩指针

这样一来,JVM 虽然额外的执行了一些位运算但是极大的提高了寻址空间,并且将对象引用占用内存大小降低了一半,节省了大量空间。况且这些位运算对于 CPU 来说是非常容易且轻量的操作,通过压缩指针的原理我挖掘到了内存对齐的另一个重要原因就是:

通过内存对齐至 8 的倍数,我们可以在 64 位系统中使用压缩指针通过 32 位的对象引用将寻址空间提升至 32G。

从 Java7 开始,当 maximum heap size 小于 32G 的时候,压缩指针是默认开启的。但是当 maximum heap size 大于 32G 的时候,压缩指针就会关闭。

那么我们如何在压缩指针开启的情况下进一步扩大寻址空间呢?

如何进一步扩大寻址空间

前边提到我们在 Java 虚拟机堆中对象起始地址均需要对其至 8 的倍数,不过这个数值我们可以通过 JVM 参数 -XX:ObjectAlignmentInBytes 来改变(默认值为 8)。当然这个数值的必须是 2 的次幂,数值范围需要在 8 - 256 之间。

正是因为对象地址对齐至 8 的倍数,才会多出 3 位 bit 让我们存储额外的地址信息,进而将 4G 的寻址空间提升至 32G。

同样的道理,如果我们将 ObjectAlignmentInBytes 的数值设置为 16 呢?

对象地址均对齐至 16 的倍数,那么就会多出 4 位 bit 让我们存储额外的地址信息。寻址空间变为 2^32 * 2^4 = 64G。

通过以上规律,我们就能知道,在 64 位系统中开启压缩指针的情况,寻址范围的计算公式:4G * ObjectAlignmentInBytes = 寻址范围。

但并不建议大家贸然这样做,因为增大了 ObjectAlignmentInBytes 虽然能扩大寻址范围,但是这同时也可能增加了对象之间的字节填充,导致压缩指针没有达到原本节省空间的效果。

小结

进行内存对齐的四个原因:

  • CPU 访问性能 :当 CPU 访问内存对齐的地址时,可以通过一个 read transaction 读取一个字长(word size)大小的数据出来。否则就需要两个 read transaction;
  • 原子性 :CPU 可以原子地操作一个对齐的 word size memory;
  • 尽可能利用 CPU 缓存 :内存对齐可以使对象或者字段尽可能的被分配到一个缓存行中,避免跨缓存行存储,导致 CPU 执行效率减半;
  • 提升压缩指针的内存寻址空间 : 对象与对象之间的内存对齐,可以使我们在 64 位系统中利用 32 位对象引用将内存寻址空间提升至 32G。既降低了对象引用的内存占用,又提升了内存寻址空间。

GC 详解

JVM 中 GC 小节(GC 算法、回收器、内存泄露排查等等)

GC 简介

垃圾回收(Garbage Collection)是 Java 虚拟机(JVM)垃圾回收器提供的一种用于在的对象所占据的内存空间的一种机制。

判断对象是否存活的算法

  1. 引用计数算法(Reference Counting)

给对象中添加一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为 1。
每当有一个地方引用它时,计数器值就加 1(a = b, b 被引用,则 b 引用的对象计数+1)。
当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减 1。任何引用计数为 0 的对象就是不可能再被使用的。
缺点:对象循环引用时不可被回收。例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class A {
public Object instance = null;
}

public class TestA {
public static void main(String[] args) {
A a1 = new A();
A a2 = new A();
a1.instance = a2;
a2.instance = a1;

a1 = null;
a2 = null;
}
}

  1. 可达性分析算法(Reachability Analysis)

通过一系列名为”GC Roots”的对象作为起始点向下搜索,搜索走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。

在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:

(1) 如果对象在进行根搜索后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法(可看作析构函数,类似于 OC 中的 dealloc,Swift 中的 deinit)。当对象没有覆盖 finalize()方法,或 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。

(2) 如果该对象被判定为有必要执行 finalize()方法,那么这个对象将会被放置在一个名为 F-Queue 队列中,并在稍后由一条由虚拟机自动建立的、低优先级的 Finalizer 线程去执行 finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的 finalize()方法最多只会被系统自动调用一次),稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果要在 finalize()方法中成功拯救自己,只要在 finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

GC Roots:

  1. 虚拟机栈中引用的对象
  2. 方法区中常量引用的对象
  3. 方法区中静态属性引用的变量
  4. 本地方法栈 JNI(一般指 naive 方法)中引用的对象

也可以理解为下面几种(引用于Help - Eclipse Platform):

  • System Class - 由初始、系统类加载器(bootstrap/system class loader)加载的对象
  • JNI Local - native 代码中的 local 变量
  • JNI Global - native 代码中的 global 变量
  • Thread Block - 活动的线程块引用的对象
  • Thread - 活着的线程
  • Busy Monitor - 调用了 wait()、notify()方法的对象,同步的对象
  • Java Local - local 变量
  • Native Stack - native 代码中 in 或 out 参数,例如:用户定义的 JNI 代码或 JVM 内部代码。通常是这种情况,因为许多方法具有 native 部分,并且作为方法参数处理的对象成为 GC 根。
  • Finalizable - 等待 finalizer 运行的队列中的对象
  • Unfinalized - 具有 finalize 方法但尚未终止且不在 finalizer 队列中的对象
  • Unreachable - 从任何其他根无法访问的对象,但为了保留改对象已被 MAT 标记为根,否则不会包含在分析中的对象
  • Java Stack Frame - java 栈帧(持有本地变量)。仅在使用首选项集解析转储时生成,以将 Java 堆栈帧视为对象。
  • Unknown - 未知的根类型对象。某些转储(例如 IBM Portable Heap Dump 文件)没有根信息。对于这些转储,MAT 解析器标记没有入站引用或无法从任何其他根作为此类型的根进行访问的对象。这可确保 MAT 保留转储中的所有对象。

引用类型

  1. 强引用(Strong Reference)
    如“Object obj = new Object()”,这类引用是 Java 程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
  2. 软引用(Soft Reference)
    它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2 之后提供了 SoftReference 类来实现软引用。
  3. 弱引用(Weak Reference)
    它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK1.2 之后,提供了 WeakReference 类来实现弱引用。
  4. 虚引用(Phantom Reference)
    最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2 之后提供了 PhantomReference 类来实现虚引用。
引用类型 GC 回收时间 用途 生存时间
强引用 Never - JVM 停止运行时
软引用 内存不足时 对象缓存 内存不足时
弱引用 GC 时 对象缓存 下一次 GC
虚引用 - - -

回收算法

标记—复制算法

定义:将可用内存划分成相等大小两块,每次只使用其中一块,当这一块用完后将还存活的对象复制到另一块,然后将已使用过的内存一次清理。
适用:存活对象较少的垃圾回收,一般用于新生代(新生代内存默认按照 8:1:1 分为 Eden,From Survivor,To Survivor 三个空间,每次浪费 10%)。
缺点:内存缩小为原来的一半。
优点:每次对整个半区进行内存回收,不用考虑内存碎片问题,只要移动堆顶指针,按顺序分配内存即可;实现简单,运行高效。
标记复制回收前后状态

标记—整理算法

定义:先标记要回收的对象,将存活对象移至一端,最后清理端边界以外的内存
适用:一般用于年老代
标记整理回收前后状态

标记—清除算法

定义:先标记要回收的对象,然后统一回收
适用:存活对象较多的垃圾回收
缺点:会产生大量不连续的内存碎片,需要定期整理
标记清除回收前后状态

垃圾收集器(之前版本)

常见的垃圾收集器有七种,以下是关系图,连线表示可以搭配使用:
垃圾回收器分类图.png

Serial

算法:标记—复制
用于:新生代
特性:单线程,在进行垃圾收集时必须暂停其他所有的工作线程(”Stop the World”)。虚拟机运行在 Client 模式下的默认新生代收集器。

ParNew

算法:标记—复制
用于:新生代
特性:并发收集,Serial 收集器的多线程版本。一般是 Server 模式下的虚拟机中首选的新生代收集器。

Parallel Scavenge

算法:标记—复制
用于:新生代
特性:并发收集,关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目的则是达到一个可控制的吞吐量(Throughput, 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,吞吐量就是 99%。

Serial Old

算法:标记—整理
用于:年老代
特性:单线程,Serial 收集器的年老代版本。主要被 Client 模式下的虚拟机使用。

Parallel Old

算法:标记—整理
用于:年老代
特性:并发收集,Parallel Scavenge 收集器的年老代版本,在注重吞吐量及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge + Parallel Old 收集器。

CMS (Concurrent Mark Sweep)

算法:标记—清除
用于:年老代
特性:并发收集、低停顿。以获取最短回收停顿时间为目的。当需要服务的响应速度块、系统停顿时间短,可以优先考虑 ParNew + CMS 收集器。
过程:

  • 初始标记(CMS initial mark):需要”Stop The World”,标记 GC Roots 能直接关联到的对象,速度快。
  • 并发标记(CMS concurrent mark):进行 GC Roots Tracing 过程。
  • 重新标记(CMS remark):需要”Stop The World”,修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。停顿时间:初始标记<重新标记<<并发标记。
  • 并发清除(CMS concurrent sweep):清除标记后要回收的对象,时间较长。

G1 (Garbage First)

算法:基于“标记-整理”
用于:独自管理整个 Java 堆内存,不需要和其他收集器配合使用
特性:

  • 并发收集
  • 可预测的停顿时间
  • 压缩空间方面有优势
  • 内部依然区分新生代和年老代,但是 Eden, Survivor, Old 区不再固定、在内存使用效率上来说更灵活。
  • 将整个 Java 堆(包括新生代、年老代)划分为多个大小固定的独立区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是 Garbage First 名称的由来)。区域划分、有优先级的区域回收。
    过程:
  • 初始标记(CMS initial mark):需要”Stop The World”,标记 GC Roots 能直接关联到的对象,速度快。
  • 并发标记(CMS concurrent mark):进行 GC Roots Tracing 过程,此过程之间可以进行年轻代收集。
  • 重新标记(CMS remark):完全标记堆中的活跃对象,使用一个叫作 snapshot-at-the-beginning(SATB)的比 CMS 收集器的更快的算法。此过程需要一个暂停的时间,去处理剩下的 SATB 日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算,这个阶段也是并发执行的。
  • 选择回收(CMS concurrent sweep):根据允许的收集时间,优先回收垃圾最多的区域。

G1 (Garbage First)

G1(Garbage-First)垃圾收集器是一种分代回收的垃圾收集器。虽然 G1 是一种基于区域(Region-Based)的垃圾收集器,但它依然采用了分代回收的思想。

在 Java 堆内存中,通常将内存分为年轻代(Young Generation)、老年代(Old Generation)和永久代(或元数据区,Permanent Generation/Metaspace)。年轻代用于存放新创建的对象,而老年代用于存放生命周期较长的对象。永久代或元数据区(取决于 Java 版本)用于存放类信息和常量池等数据。

G1 收集器将堆内存进一步划分为多个大小相等的区域,每个区域被称为一个 “Region”,并且这些 Region 是不连续的。G1 的目标是在有限的时间内,尽可能地收集垃圾,同时实现可预测的 GC 暂停时间。

G1 垃圾收集器主要通过以下两个阶段来进行垃圾回收:

  1. 年轻代收集 : G1 将年轻代划分为多个年轻代区域(Young Region),在这些年轻代区域中执行 Young GC,使用复制算法进行垃圾回收。当年轻代区域被填满时,执行 Young GC 将幸存对象复制到其他年轻代区域。
  2. 混合收集 : 当垃圾堆积到一定程度时,G1 会触发一次混合收集(Mixed GC)。混合收集是指同时对年轻代和部分老年代进行回收。G1 会根据堆内存的回收目标,选择哪些 Region 需要进行回收,并将这些 Region 进行回收。

在 G1 中,对于部分老年代的回收,它是根据 Region 的回收价值来进行选择的,即选择回收价值最大的 Region。因此,G1 垃圾收集器也被称为 Garbage-First(G1),即优先回收垃圾最多的 Region。

总体来说,G1 是一种分代回收的垃圾收集器,结合了年轻代和老年代的收集策略,并且使用区域划分的方法来提供可预测的 GC 暂停时间和高效的垃圾回收。

ZGC (The Z Garbage Collector)

ZGC (The Z Garbage Collector) 是 JDK 11 中推出的一款低延迟垃圾回收器,它的设计目标包括:

  • 停顿时间不超过 10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持 8MB~4TB 级别的堆(未来支持 16TB)。

从设计目标来看,我们知道 ZGC 适用于大内存低延迟服务的内存管理和回收。本小节主要介绍 ZGC 在低延时场景中的应用和卓越表现,文章内容主要分为四部分:

  • GC 之痛: 介绍实际业务中遇到的 GC 痛点,并分析 CMS 收集器和 G1 收集器停顿时间瓶颈;
  • ZGC 原理: 分析 ZGC 停顿时间比 G1 或 CMS 更短的本质原因,以及背后的技术原理;
  • ZGC 调优实践: 重点分享对 ZGC 调优的理解,并分析若干个实际调优案例;
  • 升级 ZGC 效果: 展示在生产环境应用 ZGC 取得的效果。

GC 之痛

很多低延迟高可用 Java 服务的系统可用性经常受 GC 停顿的困扰。GC 停顿指垃圾回收期间 STW(Stop The World),当 STW 时,所有应用线程停止活动,等待 GC 停顿结束。

以美团风控服务为例,部分上游业务要求风控服务 65ms 内返回结果,并且可用性要达到 99.99%。但因为 GC 停顿,我们未能达到上述可用性目标。当时使用的是 CMS 垃圾回收器,单次 Young GC 40ms,一分钟 10 次,接口平均响应时间 30ms。通过计算可知,有 (40ms + 30ms) * 10 次 / 60000ms = 1.12% 的请求的响应时间会增加 0 ~ 40ms 不等,其中 30ms * 10 次 / 60000ms = 0.5% 的请求响应时间会增加 40ms。可见,GC 停顿对响应时间的影响较大。为了降低 GC 停顿对系统可用性的影响,我们从降低单次 GC 时间和降低 GC 频率两个角度出发进行了调优,还测试过 G1 垃圾回收器,但这三项措施均未能降低 GC 对服务可用性的影响。

CMS 与 G1 停顿时间瓶颈

在介绍 ZGC 之前,首先回顾一下 CMS 和 G1 的 GC 过程以及停顿时间的瓶颈。CMS 新生代的 Young GC、G1 和 ZGC 都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。

标记-复制算法应用在 CMS 新生代(ParNew 是 CMS 默认的新生代垃圾回收器)和 G1 垃圾回收器中。标记-复制算法可以分为三个阶段:

  • 标记阶段,即从 GC Roots 集合开始,标记活跃对象;
  • 转移阶段,即把活跃对象复制到新的内存地址上;
  • 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。

下面以 G1 为例,通过 G1 中标记-复制算法过程(G1 的 Young GC 和 Mixed GC 均采用该算法),分析 G1 停顿耗时的主要瓶颈。G1 垃圾回收周期如下图所示:

java-jvm-zgc-1

G1 的混合回收过程可以分为标记阶段、清理阶段和复制阶段。

  1. 标记阶段停顿分析
    • 初始标记阶段:初始标记阶段是指从 GC Roots 出发标记全部直接子节点的过程,该阶段是 STW 的。由于 GC Roots 数量不多,通常该阶段耗时非常短。
    • 并发标记阶段:并发标记阶段是指从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和 GC 线程可以同时活动。并发标记耗时相对长很多,但因为不是 STW,所以我们不太关心该阶段耗时的长短。
    • 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是 STW 的。
  2. 清理阶段停顿分析
    • 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是 STW 的。
  3. 复制阶段停顿分析
    • 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是 STW 的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。

四个 STW 过程中,初始标记因为只标记 GC Roots,耗时较短。再标记因为对象数少,耗时也较短。 清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。 因此,G1 停顿时间的瓶颈主要是标记-复制中的转移阶段 STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是 G1 未能解决转移过程中准确定位对象地址的问题。

G1 的 Young GC 和 CMS 的 Young GC,其标记-复制全过程 STW,这里不再详细阐述。

ZGC 原理

全并发的 ZGC

与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进:ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于 10ms 目标的最关键原因。

ZGC 垃圾回收周期如下图所示:

java-jvm-zgc-2

ZGC 只有三个 STW 阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;再标记阶段 STW 时间很短,最多 1ms,超过 1ms 则再次进入并发标记阶段。即,ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与 ZGC 对比,G1 的转移阶段完全 STW 的,且停顿时间随存活对象的大小增加而增加。

ZGC 关键技术

ZGC 通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着 GC 线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在 ZGC 中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM 是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。

着色指针

着色指针是一种将信息存储在指针中的技术。

ZGC 仅支持 64 位系统,它把 64 位虚拟地址空间划分为多个子空间,如下图所示:

java-jvm-zgc-3

其中, [0~4TB) 对应 Java 堆, [4TB ~ 8TB) 称为 M0 地址空间, [8TB ~ 12TB) 称为 M1 地址空间, [12TB ~ 16TB) 预留未使用, [16TB ~ 20TB) 称为 Remapped 空间。

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC 同时会为该对象在 M0、M1 和 Remapped 地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC 之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低 GC 停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。

与上述地址空间划分相对应,ZGC 实际仅使用 64 位地址空间的第 041 位,而第 4245 位存储元数据,第 47~63 位固定为 0。

java-jvm-zgc-4

ZGC 将对象存活信息存储在 42~45 位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

读屏障

读屏障是 JVM 向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

读屏障示例:

1
2
3
4
5
Object o = obj.FieldA   // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用

ZGC 中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。

ZGC 并发处理演示

接下来详细介绍 ZGC 一次垃圾回收周期中地址视图的切换过程:

  • 初始化:ZGC 初始化之后,整个内存空间的地址视图被设置为 Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
  • 并发标记阶段:第一次进入标记阶段时视图为 M0,如果对象被 GC 标记线程或者应用线程访问过,那么就将对象的地址视图从 Remapped 调整为 M0。所以,在标记阶段结束之后,对象的地址要么是 M0 视图,要么是 Remapped。如果对象的地址是 M0 视图,那么说明对象是活跃的;如果对象的地址是 Remapped 视图,说明对象是不活跃的。
  • 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为 Remapped。(如果对象被 GC 转移线程或者应用线程访问过,那么就将对象的地址视图从 M0 调整为 Remapped ,并转移该对象。 这期间会有在本轮 GC 中标记为活跃对象 M0 ,但是未被转移标记为 Remapped 的对象还停留在原处。

其实,在标记阶段存在两个地址视图 M0 和 M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为 M1,而非 M0。

着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在 ZGC 中,只需要设置指针地址的第 42~45 位即可,并且因为是寄存器访问,所以速度比访问内存更快。

java-jvm-zgc-5

ZGC 调优实践

ZGC 不是“银弹”,需要根据服务的具体特点进行调优。网络上能搜索到实战经验较少,调优理论需自行摸索,我们在此阶段也耗费了不少时间,最终才达到理想的性能。本文的一个目的是列举一些使用 ZGC 时常见的问题,帮助大家使用 ZGC 提高服务可用性。 ———— 摘自美团技术团队文章

调优基础知识

理解 ZGC 重要配置参数

以我们服务在生产环境中 ZGC 参数配置为例,说明各个参数的作用:

重要参数配置样例:

1
2
3
4
5
6
7
-Xms10G -Xmx10G
-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
  • -Xms -Xmx:堆的最大内存和最小内存,这里都设置为 10G,程序的堆内存将保持 10G 不变。
  • -XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize:设置 CodeCache 的大小, JIT 编译的代码都放在 CodeCache 中,一般服务 64m 或 128m 就已经足够。我们的服务因为有一定特殊性,所以设置的较大,后面会详细介绍。
  • -XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用 ZGC 的配置。
  • -XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的 12.5%,8 核 CPU 默认是 1。调大后 GC 变快,但会占用程序运行时的 CPU 资源,吞吐会受到影响。如果 GC 间隔时间大于 GC 周期,不建议调整该参数。
  • -XX:ParallelGCThreads:STW 阶段使用线程数,默认是总核数的 60%。
  • -XX:ZCollectionInterval:ZGC 发生的最小时间间隔,单位秒。
  • -XX:ZAllocationSpikeTolerance:ZGC 触发自适应算法的修正系数,默认 2,数值越大,越早的触发 ZGC。
  • -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。
  • -Xlog:设置 GC 日志中的内容、格式、位置以及每个日志的大小。
理解 ZGC 触发时机

相比于 CMS 和 G1 的 GC 触发机制,ZGC 的 GC 触发机制有很大不同。ZGC 的核心特点是并发,GC 过程中一直有新的对象产生。如何保证在 GC 完成之前,新产生的对象不会将堆占满,是 ZGC 参数调优的第一大目标。因为在 ZGC 中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。

ZGC 有多种 GC 触发机制,总结如下:

  • 阻塞内存分配请求触发: 当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
  • 基于分配速率的自适应算法: 最主要的 GC 触发方式,其算法原理可简单描述为”ZGC 根据近期的对象分配速率以及 GC 时间,计算出当内存占用达到什么阈值时触发下一次 GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器 ZGC 设计与实现》一书中的内容。通过 ZAllocationSpikeTolerance 参数控制阈值大小,该参数默认 2,数值越大,越早的触发 GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
  • 基于固定时间间隔: 通过 ZCollectionInterval 控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到 95%以上才触发 GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
  • 主动触发规则: 类似于固定间隔规则,但时间间隔不固定,是 ZGC 自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive 参数将该功能关闭,以免 GC 频繁,影响服务可用性。 日志中关键字是“Proactive”。
  • 预热规则: 服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
  • 外部触发: 代码中显式调用 System.gc()触发。 日志中关键字是“System.gc()”。
  • 元数据分配触发: 元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。
理解 ZGC 日志

一次完整的 GC 过程,需要注意的点已在图中标出。

java-jvm-zgc-6

注意:该日志过滤了进入安全点的信息。正常情况,在一次 GC 过程中还穿插着进入安全点的操作。

GC 日志中每一行都注明了 GC 过程中的信息,关键信息如下:

  • Start:开始 GC,并标明的 GC 触发的原因。上图中触发原因是自适应算法。
  • Phase-Pause Mark Start:初始标记,会 STW。
  • Phase-Pause Mark End:再次标记,会 STW。
  • Phase-Pause Relocate Start:初始转移,会 STW。
  • Heap 信息:记录了 GC 过程中 Mark、Relocate 前后的堆大小变化状况。High 和 Low 记录了其中的最大值和最小值,我们一般关注 High 中 Used 的值,如果达到 100%,在 GC 过程中一定存在内存分配不足的情况,需要调整 GC 的触发时机,更早或者更快地进行 GC。
  • GC 信息统计:可以定时的打印垃圾收集信息,观察 10 秒内、10 分钟内、10 个小时内,从启动到现在的所有统计信息。利用这些统计信息,可以排查定位一些异常点。

日志中内容较多,关键点已用红线标出,含义较好理解,更详细的解释大家可以自行在网上查阅资料。

java-jvm-zgc-7

理解 ZGC 停顿原因

我们在实战过程中共发现了 6 种使程序停顿的场景,分别如下:

  • GC 时,初始标记:日志中 Pause Mark Start。
  • GC 时,再标记:日志中 Pause Mark End。
  • GC 时,初始转移:日志中 Pause Relocate Start。
  • 内存分配阻塞:当内存不足时线程会阻塞等待 GC 完成,关键字是”Allocation Stall”。

java-jvm-zgc-8

  • 安全点:所有线程进入到安全点后才能进行 GC,ZGC 定期进入安全点判断是否需要 GC。先进入安全点的线程需要等待后进入安全点的线程直到所有线程挂起。
  • dump 线程、内存:比如 jstack、jmap 命令。

java-jvm-zgc-9

java-jvm-zgc-10

调优案例

案例摘自美团技术团队文章

我们维护的服务名叫 Zeus,它是美团的规则平台,常用于风控场景中的规则管理。规则运行是基于开源的表达式执行引擎 Aviator。Aviator 内部将每一条表达式转化成 Java 的一个类,通过调用该类的接口实现表达式逻辑。

Zeus 服务内的规则数量超过万条,且每台机器每天的请求量几百万。这些客观条件导致 Aviator 生成的类和方法会产生很多的 ClassLoader 和 CodeCache,这些在使用 ZGC 时都成为过 GC 的性能瓶颈。接下来介绍两类调优案例。

第一类:内存分配阻塞,系统停顿可达到秒级
案例一:秒杀活动中流量突增,出现性能毛刺

日志信息:对比出现性能毛刺时间点的 GC 日志和业务日志,发现 JVM 停顿了较长时间,且停顿时 GC 日志中有大量的“Allocation Stall”日志。

分析:这种案例多出现在“自适应算法”为主要 GC 触发机制的场景中。ZGC 是一款并发的垃圾回收器,GC 线程和应用线程同时活动,在 GC 过程中,还会产生新的对象。GC 完成之前,新产生的对象将堆占满,那么应用线程可能因为申请内存失败而导致线程阻塞。当秒杀活动开始,大量请求打入系统,但自适应算法计算的 GC 触发间隔较长,导致 GC 触发不及时,引起了内存分配阻塞,导致停顿。

解决方法:

(1)开启”基于固定时间间隔“的 GC 触发机制:-XX:ZCollectionInterval。比如调整为 5 秒,甚至更短。 (2)增大修正系数-XX:ZAllocationSpikeTolerance,更早触发 GC。ZGC 采用正态分布模型预测内存分配速率,模型修正系数 ZAllocationSpikeTolerance 默认值为 2,值越大,越早的触发 GC,Zeus 中所有集群设置的是 5。

案例二:压测时,流量逐渐增大到一定程度后,出现性能毛刺

日志信息:平均 1 秒 GC 一次,两次 GC 之间几乎没有间隔。

分析:GC 触发及时,但内存标记和回收速度过慢,引起内存分配阻塞,导致停顿。

解决方法:增大-XX:ConcGCThreads, 加快并发标记和回收速度。ConcGCThreads 默认值是核数的 1/8,8 核机器,默认值是 1。该参数影响系统吞吐,如果 GC 间隔时间大于 GC 周期,不建议调整该参数。

第二类:GC Roots 数量大,单次 GC 停顿时间长
案例三: 单次 GC 停顿时间 30ms,与预期停顿 10ms 左右有较大差距

日志信息:观察 ZGC 日志信息统计,“Pause Roots ClassLoaderDataGraph”一项耗时较长。

分析:dump 内存文件,发现系统中有上万个 ClassLoader 实例。我们知道 ClassLoader 属于 GC Roots 一部分,且 ZGC 停顿时间与 GC Roots 成正比,GC Roots 数量越大,停顿时间越久。再进一步分析,ClassLoader 的类名表明,这些 ClassLoader 均由 Aviator 组件生成。分析 Aviator 源码,发现 Aviator 对每一个表达式新生成类时,会创建一个 ClassLoader,这导致了 ClassLoader 数量巨大的问题。在更高 Aviator 版本中,该问题已经被修复,即仅创建一个 ClassLoader 为所有表达式生成类。

解决方法:升级 Aviator 组件版本,避免生成多余的 ClassLoader。

案例四:服务启动后,运行时间越长,单次 GC 时间越长,重启后恢复

日志信息:观察 ZGC 日志信息统计,“Pause Roots CodeCache”的耗时会随着服务运行时间逐渐增长。

分析:CodeCache 空间用于存放 Java 热点代码的 JIT 编译结果,而 CodeCache 也属于 GC Roots 一部分。通过添加-XX:+PrintCodeCacheOnCompilation 参数,打印 CodeCache 中的被优化的方法,发现大量的 Aviator 表达式代码。定位到根本原因,每个表达式都是一个类中一个方法。随着运行时间越长,执行次数增加,这些方法会被 JIT 优化编译进入到 Code Cache 中,导致 CodeCache 越来越大。

解决方法:JIT 有一些参数配置可以调整 JIT 编译的条件,但对于我们的问题都不太适用。我们最终通过业务优化解决,删除不需要执行的 Aviator 表达式,从而避免了大量 Aviator 方法进入 CodeCache 中。

值得一提的是,我们并不是在所有这些问题都解决后才全量部署所有集群。即使开始有各种各样的毛刺,但计算后发现,有各种问题的 ZGC 也比之前的 CMS 对服务可用性影响小。所以从开始准备使用 ZGC 到全量部署,大概用了 2 周的时间。在之后的 3 个月时间里,我们边做业务需求,边跟进这些问题,最终逐个解决了上述问题,从而使 ZGC 在各个集群上达到了一个更好表现。

升级 ZGC 效果

延迟降低

TP(Top Percentile)是一项衡量系统延迟的指标:TP999 表示 99.9%请求都能被响应的最小耗时;TP99 表示 99%请求都能被响应的最小耗时。

在 Zeus 服务不同集群中,ZGC 在低延迟(TP999 < 200ms)场景中收益较大:

  • TP999:下降 12142ms,下降幅度 18%74%。
  • TP99:下降 528ms,下降幅度 10%47%。

超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是 GC,而是外部依赖的性能。

吞吐下降

对吞吐量优先的场景,ZGC 可能并不适合。例如,Zeus 某离线集群原先使用 CMS,升级 ZGC 后,系统吞吐量明显降低。究其原因有二:第一,ZGC 是单代垃圾回收器,而 CMS 是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费 CPU 资源;第二,ZGC 使用读屏障,读屏障操作需耗费额外的计算资源。

ZGC 总结

ZGC 作为下一代垃圾回收器,性能非常优秀。ZGC 垃圾回收过程几乎全部是并发,实际 STW 停顿时间极短,不到 10ms。这得益于其采用的着色指针和读屏障技术。

Zeus 在升级 JDK 11+ZGC 中,通过将风险和问题分类,然后各个击破,最终顺利实现了升级目标,GC 停顿也几乎不再影响系统可用性。

最后推荐大家升级 ZGC,Zeus 系统因为业务特点,遇到了较多问题,而风控其他团队在升级时都非常顺利。

开发过程中可能遇到的问题

内存泄露

一般来说,内存泄露的发生都是因为使用不当导致的。

使用不当又分为:

  1. 自己写出来的死循环。
  2. 框架使用不当。例如:Disruptor 中的 RingBuffer、Netty 中的 ByteBuf
  3. 使用的开源库中存在内存泄露问题。

解决方案:

  1. 用 jstack、jstat、jmap 查看线程堆栈信息,并转写出堆转储文件。
  2. 放到 win 系统中用IBM HeapAnalyzer等类似工具进行分析。

如何使用新技术

在生产环境升级 JDK,使用 ZGC 或者其他更高版本的特性,大家最关心的可能不是效果怎么样,而是这个新版本用的人少,网上实践也少,靠不靠谱,稳不稳定。 其次是升级成本会不会很大,万一不成功岂不是白白浪费时间。 所以,在使用新技术前,首先要做的是评估收益、成本和风险。

评估收益

对于 JDK 这种世界关注的程序,大版本升级所引入的新技术一般已经在理论上经过验证。我们要做的事情就是确定当前系统的瓶颈是否是新版本 JDK 可解决的问题,切忌问题未诊断清楚就采取措施。评估完收益之后再评估成本和风险,收益过大或者过小,其他两项影响权重就会小很多。

以本文前面提到的 GC 案例为例,假设 GC 次数不变 (10 次/分钟),且单次 GC 时间从 40ms 降低 10ms。通过计算,一分钟内有 100/60000 = 0.17% 的时间在进行 GC,且期间所有请求仅停顿 10ms,GC 期间影响的请求数和因 GC 增加的延迟都有所减少。

评估成本

这里主要指升级所需要的人力成本。此项相对比较成熟,根据新技术的使用手册判断改动点。跟做其他项目区别不大,不再具体细说。

在上面 GC 案例的实践中,两周时间完成线上部署,达到安全稳定运行的状态。后续持续迭代 3 个月,根据业务场景对 ZGC 进行了更契合的优化适配。

评估风险

升级 JDK 的风险可以分为三类:

  • 兼容性风险: Java 程序 JAR 包依赖很多,升级 JDK 版本后程序是否能运行起来。例如我们的服务是从 JDK 7 升级到 JDK 11,需要解决较多 JAR 包不兼容的问题。
  • 功能风险: 运行起来后,是否会有一些组件逻辑变更,影响现有功能的逻辑。
  • 性能风险: 功能如果没有问题,性能是否稳定,能稳定的在线上运行。

经过分类后,每类风险的应对转化成了常见的测试问题,不再属于未知风险。风险是指不确定的事情,如果不确定的事情都能转化成可确定的事情,意味着风险已消除。

参考资料

  1. ♥JVM 相关知识体系详解 ♥
  2. JVM 基础 - 类字节码详解
  3. JVM 基础 - 字节码的增强技术
  4. JVM 基础 - Java 类加载机制
  5. JVM 基础 - JVM 内存结构
  6. JVM 基础 - Java 内存模型引入
  7. JVM 基础 - Java 内存模型详解
  8. 关于 JVM 类加载机制,看这一篇就够了
  9. 一文讲清 JVM 内存结构 | 极客视点
  10. JVM 系列之沙箱安全机制笔记
  11. JVM 内存结构和 Java 内存模型
  12. 一文聊透对象在 JVM 中的内存布局,以及内存对齐和压缩指针的原理及应用
  13. 新一代垃圾回收器 ZGC 的探索与实践