JVM虚拟机初探
# 写在前面的话
你是否曾:
- 面对偶发的线上“内存溢出(OOM)”警报束手无策,只能靠“重启大法”临时救急?
- 被复杂的垃圾回收(GC)日志淹没,无法快速定位性能瓶颈的根源?
- 对“类加载机制”、“即时编译器(JIT)”、“字节码执行”这些名词耳熟能详,却始终隔着一层迷雾?
- 好奇于为何一行Java代码能在Windows、Linux、MacOS等不同平台上无缝运行?
这一切奥秘的核心,都隐藏在Java虚拟机(Java Virtual Machine, JVM) 这层看似抽象却又至关重要的“运行引擎”之中。它不仅仅是承载你Java字节码的“沙箱”,更是决定你应用性能、稳定性、资源效率乃至安全性的底层支柱。
如果你认为“不懂JVM也能写好Java程序”,那你就错过了成为真正Java高手的关键阶梯! JVM如同现代汽车的引擎系统——普通驾驶者无需深究其构造也能行驶,但高水平的车手(或赛车工程师)洞悉引擎的每一次燃烧、每一处调校,才能将性能和潜力彻底激发,在关键时刻力挽狂澜。
在这个精心设计的 JVM深度解析专题 中,我们将:
- 拨云见日: 从JVM的核心架构(类加载子系统、运行时数据区、执行引擎、本地方法接口)开始,用清晰的图解和比喻,助你构建坚实的概念地基。
- 庖丁解牛: 深入剖析内存管理的奥秘。堆(Heap)如何划分?栈(Stack)帧的生死流转?方法区(Metaspace)的变奏?GC算法(如Serial, Parallel, CMS, G1, ZGC, Shenandoah)如何各显神通?彻底告别“内存泄露”的恐惧。
- 探秘引擎: 解读字节码的本质,揭秘即时编译器(JIT) 如何将“慢悠悠”的解释执行变身为“风驰电掣”的本地代码优化(逃逸分析、内联、热点探测…)。
- 实战为王: 掌握监控与调优利器(JDK工具如
jstat
,jmap
,jstack
,以及强大的VisualVM
,JConsole
,Arthas
),学会解读GC日志,进行精准的堆、栈、GC策略调优,让你的应用飞驰起来。 - 深入前沿: 探讨现代高性能JVM(如GraalVM)的设计思想与最新发展(如AOT编译、Project Loom的虚拟线程)。
无论你是:
- 初级开发者: 渴望突破技术瓶颈,理解Java程序的“灵魂”。
- 资深工程师/架构师: 需要系统性掌握调优手段,应对大型复杂应用的性能挑战,优化基础设施成本。
- 技术管理者/爱好者: 希望从底层理解Java生态的基石和未来方向。
这个专题都将为你提供一场从原理到实战的深度认知升级之旅。我们将由浅入深,层层递进,结合直观图解、清晰代码示例、真实案例分析和实战调优技巧,力求将复杂的JVM世界条分缕析地展现在你面前。
准备好了吗?让我们一同掀开JVM的神秘面纱,探寻运行在你指尖代码背后的强大引擎,解锁Java应用的终极性能与稳定潜力!
# 1. Java代码的执行过程
Java发展至今,已经远不是一种语言,而是一个标准。只要能够写出满足JVM规范的class文件,就可以加载到JVM虚拟机执行。通过JVM虚拟机,屏蔽了上层各种开发语言的差距,同时也屏蔽了下层各种操作系统的区别。一次编写,多次执行。
在HotSpot虚拟机中,一个Java文件的执行过程,可以整体划分为几个不同的阶段
以下是详细的JVM内存模型图
# 2. Class文件规范
# 2.1 Class文件结构
Java每个版本,都会发布Java语言规范,和Java虚拟机规范。官网地址:https://docs.oracle.com/javase/specs/index.html (opens new window)
我们以JDK8为例,在JDK8的虚拟机规范 (opens new window)的第四章,定义了Class文件的规范 (opens new window)。
只要你能够写出符合规范的class文件,都可以加载到JVM虚拟机中执行,这个规范是和编程语言,操作系统无关的,也是Java跨平台运行的基础。
这个是官方定义的class文件结构,本质上是一个二进制的文件。下面我们写一个最简单的HelloWorld代码,来看一下编译后的class文件内容。
/**
* @author zhangxu
* @date 2025/7/25 11:59
*/
public class Hello {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
2
3
4
5
6
7
8
9
10
如果不是对class文件格式非常熟悉的话,这个内容是根本看不懂的,我们可以借助javap
命令,来将这个class文件,反编译成Java指令。
javap -v Hello.class
反编译后的结果内容:
Classfile /D:/code/idea_workspace/study/tuling/ClassLoadDemo/out/production/OADemo/Hello.class
Last modified 2025-7-25; size 518 bytes
MD5 checksum 9d41dd5ed71aee35126177ede7eb3b66
Compiled from "Hello.java"
public class Hello
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // Hello World
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // Hello
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LHello;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Hello.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 Hello World
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 Hello
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
{
public Hello();
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 5: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LHello;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "Hello.java"
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
还可以使用idea
中的jclasslib
插件,来查看class内容。选中要查看的文件,然后选择View|Show Bytecode With Jclasslib
菜单,就可以直接查看class内容。
一个完整的 .class
文件包含以下内容(按顺序排列):
魔数:
- 位置: 文件最开始的
4
个字节。 - 内容: 固定为
0xCAFEBABE
- 作用: 唯一标识该文件是一个有效的 Java class 文件。
- 位置: 文件最开始的
版本号:
- 位置: 紧接着魔数的
4
个字节。 - 内容: 分为两部分:
- 次版本号: 前
2
个字节。 - 主版本号: 后
2
个字节。
- 次版本号: 前
- 作用: 标识该 class 文件的目标 JVM 最低兼容版本。例如,
0x0037
(主版本 55) 对应 Java 11。版本号决定了 JVM 是否能加载和执行该 class 文件(JVM 只能加载其版本号小于或等于自身支持的最大版本号的 class 文件)。
- 位置: 紧接着魔数的
常量池:
- 位置: 紧接着版本号。
- 内容: Class 文件的 信息仓库 和 连接枢纽,非常重要且内容最丰富。它是一个 大小可变 的表,包含以下各种类型的常量:
CONSTANT_Utf8_info
: 存储字符串字面量(如类名、字段名、方法名、描述符等)。CONSTANT_Integer_info
/CONSTANT_Float_info
: 存储int
和float
常量字面量。CONSTANT_Long_info
/CONSTANT_Double_info
: 存储long
和double
常量字面量(每个常量占常量池中两个位置)。CONSTANT_String_info
: 存储字符串对象常量(指向一个CONSTANT_Utf8_info
)。CONSTANT_Class_info
: 存储类或接口的符号引用(指向一个CONSTANT_Utf8_info
,该Utf8
存储全限定类名)。CONSTANT_NameAndType_info
: 存储字段或方法的名称和类型描述符(分别指向两个CONSTANT_Utf8_info
)。CONSTANT_Fieldref_info
/CONSTANT_Methodref_info
/CONSTANT_InterfaceMethodref_info
: 存储对字段或方法的符号引用。由两部分组成:一个指向CONSTANT_Class_info
(所属类),一个指向CONSTANT_NameAndType_info
(名称和描述符)。CONSTANT_MethodHandle_info
/CONSTANT_MethodType_info
/CONSTANT_InvokeDynamic_info
: (Java 7+引入)用于支持动态语言特性(invokedynamic
)。CONSTANT_Module_info
/CONSTANT_Package_info
: (Java 9+引入) 用于支持模块系统。
- 特点:
- 常量池的入口索引从
1
开始。 CONSTANT_Long_info
和CONSTANT_Double_info
占据常量池表的两个
位置(索引n
和n+1
),其他所有类型只占据一个位置。- 后续的几乎所有内容(类名、字段、方法等)都直接或间接地通过索引引用常量池中的条目。
- 常量池的入口索引从
访问标志:
- 位置: 在常量池之后。
- 内容:
2
个字节的位掩码 (bitmask)。 - 作用: 描述类或接口的访问权限和属性。可用的标志位包括:
ACC_PUBLIC
:是否是 public 类/接口ACC_FINAL
:是否是 final 类ACC_SUPER
:(历史遗留,现代编译器都设置)影响invokespecial
指令的行为ACC_INTERFACE
:是否是接口ACC_ABSTRACT
:是否是抽象类或接口ACC_SYNTHETIC
:是否由编译器生成(非用户源代码)ACC_ANNOTATION
:是否是注解类型ACC_ENUM
:是否是枚举类ACC_MODULE
:(Java 9+) 是否是模块描述符类 (module-info.class)
当前类信息:
- 位置: 紧接着访问标志。
- 内容:
2
个字节(this_class
)。 - 作用: 一个指向常量池中
CONSTANT_Class_info
条目的索引,该条目描述了 这个 class 文件定义的类或接口本身的名称(全限定名)。
父类信息:
- 位置: 紧接着当前类信息。
- 内容:
2
个字节(super_class
)。 - 作用: 一个指向常量池中
CONSTANT_Class_info
条目的索引,该条目描述了 这个 类的直接父类的全限定名。对于java.lang.Object
类本身,这个值为0
(表示没有父类)。对于接口,它总是指向java.lang.Object
。
接口信息:
- 位置: 紧接着父类信息。
- 内容: 两部分:
2
个字节表示 接口计数器 (interfaces_count
) - 该类直接实现的接口数量。- 紧接着是
interfaces_count
个2
字节的 接口索引表。
- 作用: 每个接口索引指向常量池中的一个
CONSTANT_Class_info
条目,该条目描述了一个该类直接实现的接口的全限定名。索引按源代码中implements
或接口声明中extends
(对接口而言)的顺序排列。
字段信息:
- 位置: 紧接着接口信息。
- 内容: 两部分:
2
个字节表示 字段计数器 (fields_count
) - 该类(包括静态和非静态)和其超类/超接口声明的所有字段数量。- 紧接着是
fields_count
个field_info
结构。
field_info
结构包含:- 访问标志 (
access_flags
):2
字节(如ACC_PRIVATE
,ACC_STATIC
,ACC_FINAL
,ACC_VOLATILE
,ACC_TRANSIENT
,ACC_SYNTHETIC
,ACC_ENUM
)。 - 名称索引 (
name_index
):2
字节,指向常量池中的CONSTANT_Utf8_info
,存储字段名称。 - 描述符索引 (
descriptor_index
):2
字节,指向常量池中的CONSTANT_Utf8_info
,存储字段的类型描述符(如I
对应int
,Ljava/lang/String;
对应String
)。 - 属性计数器 (
attributes_count
):2
字节。 - 属性表 (
attributes
):包含attributes_count
个attribute_info
结构。用于存储与该字段相关的额外信息,最常见的是:ConstantValue
:用于静态final
基本类型或String
字段,指定字段的初始化常量值(存储在常量池中)。Synthetic
:如果存在,表示该字段是由编译器生成的。Signature
:(泛型信息)提供字段的泛型签名。Deprecated
:如果存在,表示该字段被@Deprecated
注解标记。RuntimeVisibleAnnotations
/RuntimeInvisibleAnnotations
:存储字段上的注解信息。
- 访问标志 (
方法信息:
- 位置: 紧接着字段信息。
- 内容: 两部分:
2
个字节表示 方法计数器 (methods_count
) - 该类(包括所有方法)和其父类声明的所有方法数量(编译器可能添加<clinit>
类初始化方法和<init>
构造方法)。- 紧接着是
methods_count
个method_info
结构。
method_info
结构包含:- 访问标志 (
access_flags
):2
字节(如ACC_PUBLIC
,ACC_PRIVATE
,ACC_PROTECTED
,ACC_STATIC
,ACC_FINAL
,ACC_SYNCHRONIZED
,ACC_BRIDGE
,ACC_VARARGS
,ACC_NATIVE
,ACC_ABSTRACT
,ACC_STRICT
,ACC_SYNTHETIC
)。 - 名称索引 (
name_index
):2
字节,指向常量池中的CONSTANT_Utf8_info
,存储方法名称(如<init>
,<clinit>
,main
,myMethod
)。 - 描述符索引 (
descriptor_index
):2
字节,指向常量池中的CONSTANT_Utf8_info
,存储方法的参数类型和返回类型的描述符(如()V
对应void m()
,(ID)Ljava/lang/String;
对应String m(int, double)
)。 - 属性计数器 (
attributes_count
):2
字节。 - 属性表 (
attributes
):包含attributes_count
个attribute_info
结构。最重要的属性是:Code
:存放方法编译后的JVM字节码指令的核心属性。(见下面详细说明)Exceptions
:列出方法声明抛出的受检异常(指向常量池中的CONSTANT_Class_info
)。RuntimeVisibleParameterAnnotations
/RuntimeInvisibleParameterAnnotations
/RuntimeVisibleAnnotations
/RuntimeInvisibleAnnotations
:存储方法本身及其参数上的注解信息。Signature
:(泛型信息)提供方法的泛型签名。Deprecated
:如果存在,表示该方法被@Deprecated
注解标记。Synthetic
:如果存在,表示该方法是由编译器生成的。MethodParameters
:(Java 8+)存储编译器保留的形参名称(如果指定了-parameters
编译参数)。
- 访问标志 (
Code
属性 (attribute_info
): 该属性本身也包含结构化信息:attribute_name_index
:指向"Code"
的CONSTANT_Utf8_info
。attribute_length
:4
字节。max_stack
:2
字节,表示操作数栈在执行此方法期间的最大深度(栈帧中操作数栈部分所需的最大空间)。JVM 据此分配栈空间。max_locals
:2
字节,表示局部变量表所需的空间大小(以 Slot 为单位)。包括方法参数(this
指针在实例方法中占第 0 个 Slot)和在方法内部定义的局部变量。基本类型占 1 个 Slot,long
和double
占 2 个 Slot。code_length
:4
字节,表示字节码数组的长度(以字节为单位)。code
:code_length
字节,存储编译后的 JVM 字节码指令序列(opcode + operands
)。exception_table_length
:2
字节。exception_table
:描述try-catch
异常处理区域的信息(start_pc
,end_pc
,handler_pc
,catch_type
)。attributes_count
:2
字节。attributes
:额外的attribute_info
结构,主要包含:LineNumberTable
:将字节码偏移量映射回源代码行号(用于调试器、堆栈跟踪)。LocalVariableTable
/LocalVariableTypeTable
:描述局部变量的作用域范围、名称、类型(LocalVariableTable
)、泛型签名信息(LocalVariableTypeTable
)和栈帧位置。对于调试和反射(Method.getParameters()
)非常重要。StackMapTable
:(Java 6+)用于类文件验证器在字节码验证阶段进行类型检查,确保字节码安全。
属性信息:
- 位置: 整个 Class 文件的最后部分(在方法信息之后)。
- 内容: 两部分:
2
个字节表示 属性计数器 (attributes_count
) - 与该类/接口本身相关的属性的数量。- 紧接着是
attributes_count
个attribute_info
结构。
- 作用: 存储与整个类/接口相关的附加元信息(metadata)。
- 常见的顶级类属性:
SourceFile
:指向一个CONSTANT_Utf8_info
,记录生成此 class 文件的源文件名称(不包括路径,例如MyClass.java
)。InnerClasses
:描述该类的所有内部类(包括其可见性、是否为静态内部类、关联的外层类等)。EnclosingMethod
:(如果类是一个局部内部类或匿名内部类)指向包含它的外层方法(以及该方法所属的类)。BootstrapMethods
:(Java 7+)存储invokedynamic
指令引导方法解析时使用的引导方法信息。Module
/ModulePackages
/ModuleMainClass
:(Java 9+) 描述模块相关的信息(在module-info.class
中使用)。NestHost
/NestMembers
:(Java 11+)用于实现 nests 访问控制(允许同一个nest内的类互相访问私有成员)。Signature
:(泛型信息)提供类本身的泛型签名(如果有)。RuntimeVisibleAnnotations
/RuntimeInvisibleAnnotations
:存储类/接口本身的注解信息。Deprecated
:如果存在,表示整个类/接口被@Deprecated
注解标记。Synthetic
:如果存在,表示整个类/接口是由编译器生成的。
总结来说,.class
文件是一个结构严谨的二进制容器,它包含了 JVM 加载、链接、验证、初始化并最终执行该类所需的所有信息:类的身份标识(魔数、版本)、自身的定义和继承关系信息(类名、访问标志、父类、接口)、常量资源(常量池)、具体的字段和方法定义及其实现(包含编译后的字节码和丰富的调试/元信息),以及各种附加的属性。理解 Class 文件结构是深入理解 Java 字节码、JVM 工作原理、性能调优(如分析字节码大小)、安全加固和利用字节码操作库(如 ASM、Javassist)的关键基础。
# 2.2 理解字节码
我们上面写的Hello.java中,main方法的字节码是这样的:
那么,什么是字节码?
字节码指令 (Bytecode Instruction) 是 Java 虚拟机 (JVM) 能够理解并执行的、平台无关的、基本操作命令。
- 可以把 JVM 想象成一个抽象的、假想的计算机。就像真实计算机有它自己的机器指令集一样,JVM 也有它自己的“指令集”,这就是字节码指令集。
- JVM 的工作就是读取
.class
文件中的字节码指令序列,并一条一条地解释执行它们,或者通过 JIT (Just-In-Time) 编译器将其编译成目标平台的原生机器码后再执行。 - 字节码指令是 JVM 所能执行的最小、最基本的操作单元。
简单来理解,你写的一行一行的代码,经过编译器编译成class文件后,以一条一条字节码指令的形式,保存起来。虚拟机执行的时候,就是通过执行字节码来运行的。
字节码的格式与内容:
- 紧凑的二进制格式: 字节码指令通常由一个或多个 字节 (Byte) 组成。这也是“字节码”名称的由来。
- 操作码 + 操作数:
- 操作码 (Opcode):第一个字节通常代表具体的操作指令,告诉 JVM 要做什么。例如:
iconst_1
(操作码0x04
): 将整型常量1
推送到操作数栈顶。iadd
(操作码0x60
): 从栈顶弹出两个整型值,相加,再将结果推入栈顶。aload_0
(操作码0x2a
): 将局部变量表中索引为0
的引用 (通常是this
) 加载到操作数栈顶。invokevirtual
(操作码0xb6
): 调用一个实例方法 (需带操作数指定具体方法)。
- 操作数 (Operands):许多指令后面会跟着一个或多个字节作为操作数,这些操作数用于提供额外的信息。例如:
- 局部变量的索引位置。
- 常量池中的索引 (指向常量、类名、方法名、字段名等)。
- 跳转的偏移量。
- 操作码 (Opcode):第一个字节通常代表具体的操作指令,告诉 JVM 要做什么。例如:
- 人类可读形式: 虽然实际存储在
.class
文件中是紧凑的二进制数据,但我们通常使用像javap -c
这样的工具将它们反编译成一种人类可读的助记符 (Mnemonics) 形式,如上面的iconst_1
、iadd
等,方便理解和调试。
执行环境:
- 字节码指令主要在 JVM 执行引擎 (Execution Engine) 中被处理。
- 指令的操作(如加载变量、进行计算、调用方法、条件跳转)主要是围绕着 操作数栈 (Operand Stack) 和 局部变量表 (Local Variable Array) 这两个核心运行时数据区域进行的。JVM 规范严格定义了每条指令对这些栈和表的影响。
字节码的指令集:
- JVM 字节码指令集非常丰富,按功能主要分为以下几大类:
- 加载和存储 (Load and Store): 在局部变量表和操作数栈之间传递数据。 (e.g.,
iload
,istore
,aload
,astore
) - 算术运算 (Arithmetic): 加、减、乘、除、取余、取反等基本数学操作。 (e.g.,
iadd
,isub
,imul
,idiv
,irem
,ineg
) - 类型转换 (Type Conversion): 在基本数据类型之间进行转换。 (e.g.,
i2l
,f2d
,l2i
) - 对象操作 (Object Manipulation): 创建对象、访问字段、检查类型、数组操作。 (e.g.,
new
,getfield
,putfield
,instanceof
,checkcast
,newarray
,arraylength
) - 方法调用 (Method Invocation): 调用静态方法、实例方法、构造方法、接口方法、用于在运行时动态解析出调用点限定符所引用的方法(lambda表达式)。 (e.g.,
invokestatic
,invokevirtual
,invokespecial
,invokeinterface
,invokedynamic
) - 操作数栈管理 (Operand Stack Management): 复制、交换、弹出栈顶值。 (e.g.,
pop
,dup
,swap
) - 控制转移 (Control Transfer): 条件分支、无条件跳转、异常抛出。 (e.g.,
ifeq
,if_icmpeq
,goto
,return
,athrow
) - 其他操作: 如获取监视器 (
monitorenter
,monitorexit
用于synchronized
)、线程相关、switch
(tableswitch
,lookupswitch
)等。
- 加载和存储 (Load and Store): 在局部变量表和操作数栈之间传递数据。 (e.g.,
举个简单的例子对比:
Java 源代码:
public int add(int a, int b) {
return a + b;
}
2
3
javap -c
输出的字节码指令 (简化版):
public int add(int, int);
Code:
0: iload_1 // 将第一个参数(局部变量索引1)加载到栈顶 (a)
1: iload_2 // 将第二个参数(局部变量索引2)加载到栈顶 (b)
2: iadd // 将栈顶两个整数弹出、相加,结果压入栈顶
3: ireturn // 将栈顶整数结果返回
2
3
4
5
6
# 2.3 字节码指令解读示例
在Hello.java
文件中,我们写一个calc方法,来简单演示一下:
/**
* @author zhangxu
* @date 2025/7/25 11:59
*/
public class Hello {
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = calc(a, b);
System.out.println(c);
}
public static int calc(int a, int b) {
return a + b;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
文件编译后,使用jclasslib打开main方法,字节码如图:
我们可以通过LineNumberTable,获取这个字节码指令对应的代码行数。其中,起始程序计数器,对应字节码指令的序号,行号,对应源码的行数。
下面,我使用流程图来表示程序运行过程中,操作数栈和局部变量表的变化
- 初始状态
- iconst_1:将常量
1
压入操作数栈
- istore_1:弹出栈顶值
1
,存入局部变量表索引1
- iconst_2:将常量
2
压入操作数栈
- istore_2:弹出栈顶值
2
,存入局部变量表索引2
- iload_1:加载局部变量表索引1的值到操作数栈
- iload_2:加载局部变量表索引2的值到操作数栈
- invokestatic:调用静态方法
Hello.calc(1, 2)
,结果入栈
- istore_3:弹出栈顶值
3
,存入局部变量表索引3
- getstatic:获取静态字段
System.out
- iload_3:加载局部变量表索引3的值到操作数栈
- invokevirtual:调用
PrintStream.println(3)
- return:方法返回
# 2.4 从字节码角度理解try-cache-finally的执行流程
try-catch-finally语法,这是大家初学Java就会接触到的语法。从刚开始接触Java,就会告诉你,在try-catch-finally语法块中,不管有没有异常,finally代码块都一定会执行。但是你有没有好奇过Java是如何实现这种保证的呢?
我们先来看一段代码:
public static int testTryCatch() {
int a = 1;
try {
a = 2;
} catch (Exception e) {
a = 3;
} finally {
a = 4;
}
return a;
}
2
3
4
5
6
7
8
9
10
11
12
这段代码编译后,他的字节码指令如下:
在字节码中,finally块的字节码,被复制到了try和catch的后面,这样就能保证,finally中的代码会被执行到了。
还有一个问题,如果发生异常了,执行引擎是如何知道要跳转到catch中去执行的呢?细心的你可能发现了,在方法的code属性中,除了字节码以外,还有一个重要的信息:异常表。
异常表中,记录了哪段指令,如果执行过程中发生了哪种异常,要跳转到对应的哪段字节码指令。
从图中可以看出,在异常表中,记录了3个异常跳转
- 如果try语句块中出现了属于 Exception 或者其子类的异常,转到catch语句块处理。
- 如果try语句块中出现了不属于 Exception 或其子类的异常,转到finally语句块处理。
- 如果catch语句块中出现了任何异常,转到finally语句块处理。
# 2.5 从字节码角度理解i++和++i
i++代码:
public int add1() {
int i = 1;
int j = i++;
return j;
}
2
3
4
5
j最终的结果是1
++i代码:
public static int add2() {
int i = 1;
int j = ++i;
return j;
}
2
3
4
5
j最终的结果是2
刚开始学Java时,相信这个i++和++i的问题,把不少初学者的脑子都搞乱了,这都是什么鬼,当时不能深入理解这两个到底有什么不同,只是死记硬背,i++是先用i的值,再+1,++i是先+1,再用i的值。其实,这两个操作,在字节码层面就非常好理解了。
# 2.5.1 i++
i++的代码,编译成字节码后,它的指令是:
0 iconst_1
1 istore_1
2 iload_1
3 iinc 1 by 1
6 istore_2
7 iload_2
8 ireturn
2
3
4
5
6
7
我们还是以操作数栈和局部变量表的变化关系,来理解:
iinc 1 by 1:将局部变量表索引位置 1
的整型变量增加 1
从以上可以看出,i++是先执行iload_1
,将值加载到操作数栈,然后才执行的iinc 1 by 1
(+1)操作
# 2.5.2 ++1
++i的代码,编译成字节码后,它的指令是:
0 iconst_1
1 istore_0
2 iinc 0 by 1
5 iload_0
6 istore_1
7 iload_1
8 ireturn
2
3
4
5
6
7
可以看到,它是先执行的iinc 0 by 1
(+1)操作,然后再执行的iload_0
参与运算。
# 2.6 this问题
Java当中,有个神秘的this关键字,通过this关键字,可以访问到当前类,这个你应该很熟悉。但是,你有没有想过this关键字是怎么来的?为什么在不同的类中,使用相同的this关键字,但是this的作用却是不同的呢?这就需要了解字节码的工作方式了。
在 JVM 虚拟机中,会为每个线程构建一个线程私有的内存区域。其中包含的最重要的数据就是程序计数器和虚拟机栈。其中程序计数器主要是记录各个指令的执行进度,用于在 CPU 进行切换时可以还原计算结果。虚拟机栈中则包含了这个线程运行所需要的重要数据。
虚拟机栈是一个先进后出的栈结构,其中会为线程中每一个方法构建一个栈帧。而栈帧先进后出的特性也就对应了我们程序中每个方法的执行顺序。每个栈帧中主要包含五个部分,局部变量表,操作数栈,动态链接库、返回地址、附加信息。
- 操作数栈是一个先进后出的栈结构,主要负责存储计算过程中的中间变量。操作数栈中的每一个元素都可以是包括long型和double在内的任意 Java 数据类型。
- 局部变量表可以认为是一个数组结构,主要负责存储计算结果。存放方法参数和方法内部定义的局部变量。以 Slot 为最小单位,一个Slot存放 Java 虚拟机的基本数据类型,对象引用类型和returnAddress类型
- 动态链接库主要存储一些指向运行时常量池的方法引用。每个栈帧中都会包含一个指向运行时常量池中该栈帧所属方法的应用,持有这个引用是为了支持方法动态调用过程中的动态链接。
- 返回地址存放调用当前方法的指令地址。一个方法有两种退出方式,一种是正常退出,一种是抛异常退出。如果方法正常退出,这个返回地址就记录下一条指令的地址。如果是抛出异常退出,返回地址就会通过异常表来确定。
- 附加信息主要存放一些 HotSpot 虚拟机实现时需要填入的一些补充信息。这部分信息不在 JVM 规范要求之内,由各种虚拟机实现自行决定。
在class文件中,记录了每个方法所需要的局部变量表的大小,我们以上面的i++和++i代码为例:
这样在JVM执行方法之前,就可以提前计算出需要分配多大的内存了。
细心的你可能发现了一个问题,同样的代码,为什么执行i++的add1方法,局部变量最大槽数是3,二执行++i的add2方法,局部变量最大槽数是2?明明两个方法中,使用的局部变量都是2个,难道是JVM的bug?
其实回顾上面的代码,你就会发现,这两个方法内容虽然都差不多,但是方法的定义不同:
i++代码:
public int add1() {
int i = 1;
int j = i++;
return j;
}
2
3
4
5
++i代码:
public static int add2() {
int i = 1;
int j = ++i;
return j;
}
2
3
4
5
add2方法,使用的是static
修饰的
其实,局部变量表是一个数组结构,数据的索引是从0开始的。而对于非静态方法,JVM 默认都会在局部变量表的 0 号索引位置放入this变量,指向对象自身。所以我们可以在代码中用this访问自己的属性。
在jclasslib中,我们可以通过LocalVariableTable
来看局部变量表中的内容:
可以看到,在add1方法中,局部变量表0的序号位置,是一个this指针,指向当前Hello对象。而在add2方法中,不存在这个this指针。
# 3. 类加载机制
class文件有了,接下来自然是将class文件加载到内存当中去执行。但是问题也随之而来了,我可不可以随便写一个文件,以.class
结尾,然后让JVM加载到内存中?可不可以自己写一个java.lang.Object类,去覆盖掉JDK当中的Object类?类在加载过程中,总有先后顺序吧,那如果我有两个类,里面的属性互相引用,在加载其中一个类的时候,另一个还没加载,怎么办?循环引用怎么处理?
再加上,类中是有static静态变量甚至是static静态代码块的,这些代码块需要在类加载的过程中执行,这个时候,还没有线程,也就没有我们上面介绍的那些栈结构。这个时候,这些静态代码块又应该怎么执行呢?
所以,类加载模块看似简单,其实也是一个很复杂的体系。就算是在JDK内部,类加载机制也是在不断更新。尤其在JDK8前后,类加载机制更是发生了很大的变化。
类加载模块也是JVM底层中被面试问到最频繁的部分。因为字节码,执行引擎,GC垃圾回收等这些内容,完全封装在JVM内部,应用几乎接触不到。但是类加载却是应用开发过程中可以实实在在接触到的。例如Tomcat,需要动态加载程序员写的各种各样的代码。Drools框架,可以在程序运行过程中,实时加载外部的规则文件。开发复杂应用时,很多人希望改完java代码后,就直接生效,而不用重新启动整个java应用。这些场景,都需要对类的加载机制进行定制。
这里有一个早期经常会被问到的问题,为什么在Tomcat中修改一个JSP页面,可以即时生效。但是修改一个jar包,却需要重新启动tomcat。为什么会这样呢?
# 3.1 JDK8的类加载体系
关于类加载模块,最为重要的内容总结为三点:
- 每个类加载器对加载过的类保持一个缓存。
- 双亲委派机制,即向上委托查找,向下委托加载。
- 沙箱保护机制。
至于JDK具体如何执行的,不同JDK版本的实现方式是不同的。以下以大家最为熟悉的JDK8进行分析。
# 3.2 双亲委派机制
JDK8中的类加载器都继承于一个统一的抽象类ClassLoader,类加载的核心也在这个父类中。其中,加载类的核心方法如下:
// 类加载器的核心方法
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 1. 检查已加载类
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 2. 委派父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 父为null则交给Bootstrap
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
// 4. 父加载器失败后自行加载
long t1 = System.nanoTime();
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;
}
}
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
这个方法里,就是最为核心的双亲委派机制。虽然从JDK8往后,类加载机制有了很多的调整,但是这段双亲委派的经典代码却没有发生变化。
关于双亲委派的由来
“双亲”这个词确实容易误解。很多人以为是指“两个父母”,但其实英文原词“parent”在计算机领域通常表示“上一级”。当年中文翻译时可能为了突出层次感用了“双亲”,反而造成了歧义。就像操作系统里的“父进程”也不是真有两个爹。
Java 的双亲委派模型(Parent Delegation Model) 得名于其核心工作原理 —— 当一个类加载器需要加载类时,会先将加载请求逐层向上委派给父加载器处理,而不是自行尝试加载。名称中的“双亲” 指的是类加载器之间的层级关系(父子结构),而非字面意义上的“两个父母”。
- “双亲”(Parent): 指类加载器之间的层级依赖关系
- 每个类加载器(除Bootstrap外)都有一个父加载器引用(
parent
字段) - 委派动作沿
父加载器链
向上传递
- 每个类加载器(除Bootstrap外)都有一个父加载器引用(
- “委派”(Delegation):子加载器把加载任务交给父加载器优先处理
关于其他计算机术语的翻译问题:盘点那些坑爹的计算机术语翻译 (opens new window)
loadClass方法,有两个默认的实现,一个是使用public声明的,另一个是用protected声明的。
这意味着,loadClass方法,是可以被子类重写的。为什么一个如此重要的方法,却允许程序员们去修改呢?
另外,在protected loadClass方法中有一个resolve参数,但是,它在另一个重载的public loadClass方法中被写死成false了,也就是说,在调用类加载器时,程序员是没有办法给这个resolve方法主动传值的。那这个resolve参数设置不是多此一举吗?
对于resolve不能传值的补充说明
在Java的类加载机制中,类加载器使用loadClass
方法加载类。这个方法有两个参数:一个是类的全限定名,另一个是一个布尔值resolve
,它表示是否在加载时进行解析(解析阶段是类加载过程中的一个步骤,包括验证、准备和解析(可选))。
当resolve
参数为true
时,表示在加载类后立即进行解析;如果为false
,则只加载类而不解析。解析阶段会将类中的符号引用转换为直接引用。
在类加载器的实现中,通常有一个双亲委派机制。当自定义类加载器时,我们一般重写findClass
方法,而不重写loadClass
方法以保持双亲委派机制。但有时候我们可能重写loadClass
方法以改变类加载行为。
在Java类加载器的设计中,loadClass(name, resolve)
方法的内部实现通常是这样的:
首先,检查类是否已经被加载(通过缓存查找)。
如果没有,则调用父加载器的
loadClass
方法。如果父加载器不为null,则委托父加载器加载;如果父加载器为null,则使用启动类加载器。如果父加载器无法加载,则调用自己的
findClass
方法。最后,如果
resolve
参数为true
,则调用resolveClass(Class<?> c)
方法对类进行解析。
现在,问题在于:为什么在某个上下文中,我们看到调用loadClass(name, false)
而不允许是true
?
这通常是因为在类加载的上下文中,有时需要避免立即解析。解析一个类可能需要加载该类所引用的其他类,这可能会导致递归加载,并且在某些情况下,例如当类加载器正在构建过程中,或者当类之间存在循环依赖时,立即解析可能会导致问题。
特别地,在类加载器的loadClass
方法的默认实现中,为了避免递归加载带来的问题,比如在加载类A时,如果A引用了类B,那么解析A就需要先加载B。如果此时加载B又触发了对A的加载(比如B继承自A),就会形成循环依赖,导致类加载失败(例如ClassCircularityError)。
因此,在类加载过程中,通常的做法是首先加载类(不解析),然后在使用类之前再解析。这样,在加载阶段,我们可以先保证类的基本结构被加载,而解析可以在之后需要时进行。
另外,在双亲委派模型中,父加载器加载类时,通常也是使用resolve=false
,因为父加载器可能还没有加载相关依赖类,需要避免立即解析。所以,在类加载器的默认实现中,通常都是先加载(不解析),然后再在必要时解析。
例如,在java.lang.ClassLoader
的源码中:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先,检查类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 如果有父加载器,则委托父加载器
if (parent != null) {
c = parent.loadClass(name, false); // 注意这里传递的是false
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器找不到,忽略
}
if (c == null) {
// 调用自己的findClass方法
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
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
注意,在父加载器调用loadClass
时,传递的是resolve=false
,这意味着父加载器也只会加载类而不解析。这样做的目的是为了避免在加载的早期阶段进行解析,从而减少因解析而需要加载其他类所导致的复杂性和潜在循环。
因此,在类加载过程中,通常我们会先加载类而不解析,直到真正需要时(例如,在链接阶段或者初始化阶段)再进行解析。这就是为什么在代码中看到调用loadClass(name, false)
而不允许是true
的原因:为了避免在加载过程中过早地解析类,导致不必要的类加载和潜在的循环依赖问题。
# 3.3 沙箱保护机制
双亲委派机制有一个最大的作用就是要保护JDK内部的核心类不会被应用覆盖。而为了保护JDK内部的核心类,JAVA在双亲委派的基础上,还加了一层保险。就是ClassLoader中的下面这个方法:
private ProtectionDomain preDefineClass(String name,
ProtectionDomain pd)
{
if (!checkName(name))
throw new NoClassDefFoundError("IllegalName: " + name);
// Note: Checking logic in java.lang.invoke.MemberName.checkForTypeAlias
// relies on the fact that spoofing is impossible if a class has a name
// of the form "java.*"
if ((name != null) && name.startsWith("java.")) {
throw new SecurityException
("Prohibited package name: " +
name.substring(0, name.lastIndexOf('.')));
}
if (pd == null) {
pd = defaultDomain;
}
if (name != null) checkCerts(name, pd.getCodeSource());
return pd;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这个方法会用在java在内部定义一个类之前。这种简单粗暴的处理方式,当然是有很多时代的因素。也因此在JDK中,你可以看到很多javax开头的包。这个奇怪的包名也是跟这个沙箱保护机制有关系的。
# 3.4 类和对象有什么关系
通过类加载模块,我们写的class文件就可以加载到JVM当中。但是类加载模块针对的都是类,而我们写的java程序都是基于对象来执行。类只是创建对象的模板。那么类和对象倒是什么关系呢?
首先:类 Class 在 JVM 中的作用其实就是一个创建对象的模板。也就是说他的作用更多的体现在创建对象的过程当中。而在程序具体执行的过程中,主要是围绕对象在进行,这时候类的作用就不大了。因此,在 JVM 中,类并不直接保存在最宝贵最核心的堆内存当中,而是挪到了堆内存以外的一部分内存中。这部分内存,在 JDK8 以前被成为永久带PermSpace,而在 JDK8 之后被改为了元空间 MetaSpace。
关于堆和元空间
堆内存可以理解为JVM的客厅,所有重要的事情都在客厅处理。元空间可以理解为JVM的库房,东西扔进去基本上就很少管了。
元空间,逻辑上可以认为是堆内存的一部分,但是他跟堆内存有不同的配置参数,不同的管理方式。因此也可以看成是单独的一块内存。这一块内存就相当于家里的工具间或者地下室,都是放一些用得比较少的东西。最主要就是类的一些相关信息,比如类的元数据、版本信息、注解信息、依赖关系等等。
元空间可以通过-XX:MetaspaceSize
和 -XX:MaxMetaspaceSize
参数设置大小。但是大部分情况下,你是不需要管理元空间大小的,JVM 会动态进行分配。
另外,这个元空间也是会进行 GC 垃圾回收的。如果一个类不再使用了,JVM 就会将这个类信息从元空间中删除。但是,显然,对类的回收效率是很低的。大部分情况下,类是不会被回收的。所以对元空间的垃圾回收基本上是很少有效果的。大部分情况下,我们是不需要管元空间的。除非你的JVM 内存确实非常紧张,这时可以设定 -XX:MaxMetaspaceSize
参数,严格控制元空间大小。
然后:在我们创建的每一个对象中,JVM也会保存对应的类信息。
在堆中,每一个对象的头部,还会保存这个对象的类指针(classpoint),指向元空间中的类。这样我们就可以通过一个对象的getClass方法获取到对象所属的类了。这个类指针,我们也是可以通过一个小工具观察到的。
例如,下面这个 Maven依赖就可以帮我们分析一个对象在堆中保存的信息。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
2
3
4
5
然后可以用以下方法简单查看一下对象的内存信息。
public class JOLDemo {
private String id;
private String name;
private Integer age;
public static void main(String[] args) {
JOLDemo o = new JOLDemo();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
这里KlassPointer 实际上就是一个指向元空间对应类的一个指针,这个指针是被压缩过的。
MarkWord存储的是一些对象的状态信息,包括对象的 HashCode,锁状态,GC分代年龄等等。具体的细节,我们在后续再做详细说明。
# 4. 执行引擎
之前已经看到过,在 Class 文件当中,已经明确的定义清楚程序的完整执行逻辑。而执行引擎就是将这些字节指令转为机器指令去执行。
但是,Java在执行时,是不是简单的把这些指令一个个拿过去执行就完了呢?显然不可能那么简单。相反,这可能是JDK中,最复杂的一部分。
因为Java的虚拟机机制,决定了他会比C/C++这些直接和操作系统打交道的语言要慢一些。但是,对于企业级应用来说,慢,就是一种原罪。如果在语言层面,就比别人慢一拍,那还谈什么高并发,高性能,高可用呢?
# 4.1 解释执行与编译执行
在JVM 中,字节码指令有两种执行方式:
解释执行:相当于是同声传译。JVM 接收一条指令,就将这条指令翻译成机器指令执行。
编译执行:相当于是提前翻译。好比领导发言前就将讲话稿提前翻译成对应的文本,上台讲话时就可以照着念了。编译执行也就是传说中的 JIT。
大部分情况下,使用编译执行的方式显然比解释执行更快,减少了翻译机器指令的性能消耗。而我们常用的 HotSpot 虚拟机,最为核心的实现机制就是这个 HotSpot 热点。他会搜集用户代码中执行最频繁的热点代码,形成CodeCache,放到元空间中,后续再执行就不用编译,直接执行就可以了。
但是编译执行其实也有一个问题,那就是程序预热会比较慢。毕竟作为虚拟机,你不可能提前预知到程序员要写一些什么稀奇古怪的代码,也就不可能把所有代码都提前编译成模板。而将执行频率并不高的代码也编译保存下来,也是得不偿失的。所以,现在JDK 默认采用的就是一种混合执行的方式。他会自己检测采用那种方式执行更快。虽然你可以干预 JDK 的执行方式,但是在绝大部分情况下,都是不需要进行干预的。
JDK默认情况下,使用的是混合模式
我们可以使用-Xint
,来指定使用解释执行模式
使用-Xcomp
,来指定使用编译执行模式
注意
上面演示的是在执行Java程序时,指定运行模式,并不是直接修改了jvm默认的执行模式。jvm参数,是每次执行程序时,都需要加入才会生效的。
HotSpot的命名,其实重点就是发现热点代码,尽量通过JIT实时编译,提升执行性能。但是,如何发现哪些是热点代码呢?对于热点代码要如何尽最大努力提升性能呢?这都是需要不断研究优化的问题。
# 4.2 JIT实时编译
热点代码会触发 JIT 实时编译,而JIT 编译运用了一些经典的编译优化技术来实现代码的优化,可以智能地编译出运行时的最优性能代码。
HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。Graal编译器采用 Java 语言编写,因此生态的活力更强。并由此衍生出了 GraalVM 这样的支持实时编译的产品。也就是绕过 Class 文件,直接将 Java 代码编译成可在操作系统本地执行的应用程序。这也就是 AOT 技术Ahead Of Time。
C1 会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。启动快,占用内存小,执行效率没有server快。默认情况下不进行动态编译,适用于桌面应用程序。
C2 进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。启动慢,占用内存多,执行效率高,适用于服务器端应用。 默认情况下就是使用的 C2 编译器。并且,绝大部分情况下也不建议特意去使用 C1。
由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 第0层:程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
- 第1层:使用C1编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
- 第2层:仍然使用C1编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 第3层:仍然使用C1编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
- 第4层:使用C2编译器将字节码编译为本地代码,相比起C1编译器,C2编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
JDK8 中提供了参数 -XX:TieredStopAtLevel=1
可以指定使用哪一层编译模型。但是,除非你是JVM 的开发者,否则不建议干预 JVM 的编译过程。
关于Graal编译器,也催生了现在Java另外一种效率更快的执行方式,AOT。就是绕过虚拟机,直接将Java程序编译成机器码。这样就彻底不需要JVM做中间的翻译工作了。这种实现方式就是依靠一种新的编译工具,GraalVM (opens new window)。而且,现在GraalVM也已经独立出了新的JDK版本。
关于AOT,虽然执行速度通常更快,但是少了JVM这个中间商来做那些跨平台的活,也必然导致在跨平台的安全性方面要付出一些代价。所以,GraalVM和JDK8往后的各种新版本一样,目前还没有成为业界的主流。不过,确实是一个可以关注的方向。
另外,GraalVM还有另外一个逆天的特性,就是他提供个一个框架,Truffle Language (opens new window)。可以用来自行开发高级语言。也就是说,理论上,你可以很轻松的写出你自己版本的Java语言,也能同样拥有Java的顶尖性能。
# 5. GC 垃圾回收
执行引擎会将class文件扔到JVM的内存当中运行。在运行过程中,需要不断的在内存当中创建并销毁对象。在传统C/C++语言中,这些销毁的对象需要手动进行内存回收,防止内存泄漏。而在Java当中,实现了影响深远的GC垃圾回收机制。
GC 垃圾自动回收,这个可以说是 JVM 最为标志性的功能。不管是做性能调优,还是工作面试,GC 都是 JVM 部分的重中之重。而对于 JVM 本身,GC 也是不断进行设计以及优化的核心。几乎 Java 提出的每个版本都对 GC 有或大或小的改动。这里,就先带大家快速梳理一下 GC 部分的主线。
# 5.1 GC技术的历史演进
- 起源:Lisp语言(1959-1960年)
- 1959年,MIT的研究人员D. Edwards在Lisp语言中首次实现了垃圾回收机制。
- Lisp的动态内存分配要求自动管理堆内存,从而催生了GC技术。
- 1960年,George E. Collins进一步提出了引用计数法(Reference Counting),成为早期GC算法之一。
- 后续发展:Smalltalk语言(1984年)
- Smalltalk首次引入分代垃圾回收(Generational GC),根据对象生命周期划分内存区域,显著提升GC效率。
- Java的贡献(1995年)
- Java于1995年发布,将GC技术推广至主流开发领域,通过JVM实现自动内存管理。
- Java的GC算法(如分代收集、可达性分析)进一步优化了性能,但核心思想均源自早期语言。
# 5.2 GC技术演进时间轴
# 5.3 垃圾回收器是干什么的
垃圾回收器(Garbage Collector, GC)是 Java 虚拟机(JVM)的核心组件之一,主要负责自动管理内存的分配与回收,释放程序中不再使用的对象所占用的内存空间,防止内存泄漏,并优化内存使用效率。
其核心职责可分解为以下三点:
- 内存分配管理
- JVM 在堆内存(Heap)中为新创建的对象分配空间。
- GC 通过分代设计(如 Young/Old Generation)优化分配速度:例如优先在 Eden 区分配对象,提升效率。
- 识别并回收无效对象
- 定位垃圾对象:通过算法(如可达性分析)判断哪些对象是“垃圾”:
- 根可达性:从 GC Roots(如方法栈局部变量、静态变量等)出发,无法访问到的对象即为可回收对象。
- 回收算法:
- 标记-清除(Mark-Sweep):简单但有内存碎片。
- 标记-整理(Mark-Compact):清除后压缩空间,减少碎片。
- 复制算法(Copying):将存活对象复制到新区域(常用于新生代)。
- 定位垃圾对象:通过算法(如可达性分析)判断哪些对象是“垃圾”:
- 内存空间优化
- 压缩内存:整理存活对象,减少碎片(如 Parallel Old、CMS 的并发整理)。
- 分代回收策略:
- 新生代(Young Generation):存放新对象,使用复制算法(Minor GC)。
- 老年代(Old Generation):存放长期存活对象,使用标记-清除/整理算法(Major/Full GC)。
GC 的核心意义:
- 避免手动内存管理错误(如 C++ 中的
delete
遗漏或重复释放)。 - 防止内存泄漏:自动回收无引用对象。
- 提升开发效率:开发者无需关注内存释放逻辑。
- 系统稳定性:通过 Full GC 阻止
OutOfMemoryError
(但频繁 Full GC 会引发性能问题)。
# 5.4 常见 GC 分类
垃圾回收器 | 新生代 | 老年代 | 核心算法 | 特点 | 适用场景 |
---|---|---|---|---|---|
Serial GC | ✔️ (复制算法) | ✔️ (标记-整理) | 单线程串行 | 简单高效,但 STW 停顿长 | 客户端应用、嵌入式系统 |
Parallel GC | ✔️ (Parallel Scavenge) | ✔️ (Parallel Old) | 多线程并行 | 高吞吐量,STW 停顿可控但较长 | 批处理、计算密集型任务 |
ParNew GC | ✔️ (复制算法) | ❌ | 多线程并行 | Serial 的多线程版,需搭配 CMS 使用 | JDK7 及之前与 CMS 组合 |
CMS | ❌ | ✔️ (标记-清除) | 并发标记+清除 | 低延迟,但内存碎片多,可能退化 | 响应敏感的 Web 服务 |
G1 GC | ✔️ (Region分区) | ✔️ (Region分区) | 标记-整理+分区回收 | 可预测停顿(通常 10-200ms),避免碎片 | 大内存(>6GB)、低延迟需求 |
ZGC | ✔️ (无分代)* | ✔️ (无分代)* | 并发标记-整理+染色指针 | 亚毫秒级停顿(<10ms),支持 TB 级堆 | 超低延迟、云原生应用 |
Shenandoah | ✔️ (无分代)* | ✔️ (无分代)* | 并发标记-整理+读屏障 | 类似 ZGC,Red Hat 开发 | 大堆低延迟场景 |
*注:ZGC/Shenandoah 在 JDK15+ 支持分代模式,但默认不分代 。
不同的垃圾回收算法对内存的管理方式是不一样的。分代管理的GC垃圾回收器已经基本上被淘汰了,CMS和Serial、SerialOld,在后续版本都已经被直接废除了。从JDK9以后,Java就采用G1作为默认的垃圾回收器。而G1则是一个物理上分代,但是逻辑上不分代的垃圾回收器。G1也成为了Java的GC垃圾回收器从分代管理向不分代管理过渡的一个重要的算法。
后续的ZGC和Shenandoah则是现在Java最具竞争力的两大产品。其中,ZGC属于Oracle官方根正苗红的最先进垃圾回收器,理论上可以管理高达16TB的内存,并且STW停顿时间理论上可以达到和C/C++等产品相同的量级。Shenandoah则是OpenJDK 中引入的新一代垃圾回收器,与 ZGC 是竞品关系。不过这些GC算法也都在不断发展过程当中。例如在最新发布的JDK24版本中,又在尝试给Shenandoah增加分代模式,优化垃圾回收效率,减少停顿时间,甚至未来有计划将其设置为默认的垃圾回收器。
Epsilon是一个测试用的垃圾回收器,根本不干活。
GC 性能调优关键指标:
- 吞吐量(Throughput):应用运行时间占总时间的比例。
- 停顿时间(Pause Time):GC 导致的程序暂停时长。
- 内存占用(Footprint):GC 自身数据结构消耗的内存。
⚠️ 注意:GC 无法完全阻止
OutOfMemoryError
(如内存泄漏或堆空间过小),需合理配置-Xmx
等参数并结合堆转储(Heap Dump)分析。
示例:触发 Minor GC
public class GCDemo {
public static void main(String[] args) {
byte[] data = new byte[10 * 1024 * 1024]; // 分配 10MB 内存
data = null; // 对象失去引用,成为垃圾
System.gc(); // 建议 JVM 执行 GC(不强制)
}
}
2
3
4
5
6
7
此时新生代空间不足时,JVM 自动触发 Minor GC 回收
data
对象内存。
通过合理选择垃圾回收器和参数配置(如 -XX:+UseG1GC
),可在高并发、大内存场景下平衡吞吐量与延迟,保障系统性能。
# 6. GC 情况分析实例
# 6.1 如何定制GC运行参数
在现阶段,各种GC垃圾回收器都只适合一个特定的场景,因此,我们也需要根据业务场景,定制合理的GC运行参数。
另外,JAVA程序在运行过程中要处理的问题是层出不穷的。项目运行期间会面临各种各样稀奇古怪的问题。比如 CPU 超高,FullGC 过于频繁,时不时的 OOM 异常等等。这些问题大部分情况下都只能凭经验进行深入分析,才能做出针对性的解决。
如何定制JVM运行参数呢?首先我们要知道有哪些参数可以供我们选择。
关于 JVM 的参数,JVM 提供了三类参数
标准参数,以-开头,所有 HotSpot 都支持。例如java -version。这类参数可以使用java -help 或者java -? 全部打印出来
非标准参数,以-X 开头,是特定 HotSpot版本支持的指令。例如java -Xms200M -Xmx200M。这类指令可以用java -X 全部打印出来。
不稳定参数,以-XX 开头,这些参数是跟特定HotSpot版本对应的,很有可能换个版本就没有了。JDK8 中的以下几个指令可以帮助开发者了解 JDK8 中的这一类不稳定参数:
java -XX:+PrintFlagsFinal:所有最终生效的不稳定指令。 java -XX:+PrintFlagsInitial:默认的不稳定指令 java -XX:+PrintCommandLineFlags:当前命令的不稳定指令
1
2
3
小技巧
使用 -XX:+PrintCommandLineFlags
,可以查看到是用的哪种GC,JDK1.8默认用的ParallelGC
# 6.2 打印GC日志
有了手段之后,我们最主要的就是要能快速发现问题。
对 JVM 虚拟机来说,绝大多数的问题往往都跟堆内存的 GC 回收有关。因此下面几个跟 GC 相关的日志打印参数是必须了解的。这通常也是进行 JVM 调优的基础。
-XX:+PrintGC
: 打印GC信息 类似于-verbose:gc-XX:+PrintGCDetails
: 打印GC详细信息,这里主要是用来观察FGC的频率以及内存清理效率。-XX:+PrintGCTimeStamps
配合 -XX:+PrintGC使用。在 GC 中打印时间戳。-XX:PrintHeapAtGC
: 打印GC前后的堆栈信息-Xloggc:filename
: GC日志打印文件。
不同 JDK 版本会有不同的参数。 比如 JDK9 中,就不用分这么多参数了,可以统一使用-Xlog:gc* 通配符打印所有的 GC 日志。
一个简单的示例:
public class GcLogTest {
public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
for (int i = 0; i < 500; i++) {
byte[] arr = new byte[1024 * 100];//100KB
list.add(arr);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在启动时,配置以下jvm参数:
-Xms60m -Xmx60m -XX:SurvivorRatio=8 -XX:+PrintGCDetails
执行后,可以看到类似这样的输出信息:
这里面就记录了三次 MinorGC 和一次 FullGC 的执行效果。另外,在程序执行完成后,也会打印出 Heap 堆区的内存使用情况。
当然,目前这些日志信息只是打印在控制台,你只能凭经验自己强行去看。接下来,就可以添加-Xloggc参数,将日志打印到文件里,然后拿日志文件进行整体分析。
我们在jvm的执行参数上,再加入:-Xloggc:./gc_%t.log
,来将GC日志输出到文件中:
运行完毕后,会在当前项目的根路径下,生成一个GC日志文件
有了这个GC日志文件,我们就可以使用GC分析工具来进行具体的分析调优了。
# 6.3 GC日志分析
这些GC日志隐藏了项目运行非常多隐蔽的问题,要如何发现其中的这些潜在的问题呢?
# 6.3.1 使用gceasy分析
这里推荐一个开源网站 https://www.gceasy.io (opens new window) 这是国外一个开源的GC 日志分析网站。你可以把 GC 日志文件直接上传到这个网站上,他就会分析出日志文件中的详细情况。
将刚刚生成的GC日志,上传到网站上,点击Analyze
开始进行分析
分析完成后,会出分析报告,会有一些调优建议(收费功能):
# 6.3.2 使用AI分析
我们还可以使用DeepSeek或者其他大模型来分析GC日志,这个目前是免费的,AI也会给出一些调优建议,这里我使用腾讯元宝的DeepSeek大模型来演示: