JVM
JVM 学习路线(适合从零到进阶)
学习目标:始终围绕一个问题——Java 程序是如何运行起来的?
推荐主线:
Java源码 → javac编译 → .class字节码 → 类加载 → JVM内存 → 对象创建 → 方法调用 → 字节码执行 → 垃圾回收 → JVM调优
第一阶段:Java 编译过程(理解 JVM 的输入)
学习目标
理解 JVM 不运行 Java,而是运行 字节码(.class)。
需要掌握
Java源码
javac 编译器
Class 文件
实践
答题区
.java文件是什么?
.java是给JVM生产.class字节码的原材料,JVM并不能理解.java文件,换句话说,.java文件是给程序员,给人看的。.class字节码文件才是JVM应该解析的。
.class实际上是一种二进制格式。开头CA FE BA BE就是Class文件的魔数(Magic Number)
JVM首先检查是不是CA FE BA BE。如果是,则判定为是一个合法的class文件。否则直接ClassFormatError
一个 .class 文件真正开始长这样:
CAFEBABE
0000
003D
......
分别表示:
CAFEBABE
↑
Magic Number(魔数)
0000
↑
Minor Version(次版本号)
003D
↑
Major Version(主版本号)转换成10进制->61,表示Java17
魔数(Magic Number) 是文件开头的一段固定字节,用来标识文件的类型。
ClassFormatError是 JVM 在加载 Class 文件时发现文件格式不合法抛出的错误。
.class 文件
│
▼
【格式检查】
是不是一个合法的 Class 文件?
│
├── 否 → ClassFormatError
▼
【字节码验证】
虽然格式正确,但字节码是否安全、合法?
│
├── 否 → VerifyError
▼
进入 JVM 执行
一个Java文件有哪些组成部分?
从编译器的角度来看,一个Java文件有四个组成部分
.java
│
├── Package(包声明)
├── Import(导入)
├── Type(类型定义)
└── 注释
其中最重要的就是Type(类型定义),因为Java文件最终编译出来的就是各种类型(class)的字节码
- package(包声明),位于第一行。作用是告诉编译器这个类属于哪个包,决定了编译后的目录结构
- import(导入)
import java.util.List;
import java.util.ArrayList;
告诉编译器:后面写 List 时,其实是 java.util.List。
否则必须写:
java.util.List<String> list;
- 注释:编译后注释全部消失,不保留注释。
.class文件中没有注释 - Type(类型定义)
Java中的类型包括
Type
├── class 普通class
├── interface 接口
├── enum 枚举
├── record(Java14+) 记录类,快速定义一个只用于存储数据的类。编译后会变成普通的class类
├── @interface 定义注解。真正理解注解的是 Spring,不是 JVM。JVM 只负责把注解信息保存下来,并通过反射 API 提供给框架读取。
从 JVM 的角度看,它们最终都会被编译成 .class 文件,并由类加载器加载。区别主要体现在 Class 文件中的标志位(Access Flags)和元数据
class:普通类标志interface:带有ACC_INTERFACEenum:带有ACC_ENUMrecord:带有ACC_RECORD(Java 16 起)@interface:本质上是一个特殊的接口,同时带有ACC_ANNOTATION和ACC_INTERFACE
Class的内部又可以继续拆
public class Student {
...
}
里面通常包括
- 字段(Field):保存在对象中
private String name;
private int age;
- 常量
public static final int MAX = 100;
- 构造方法(Constructor)
new Student()
时执行
4. 方法(Method)
public void study() {
}
JVM执行的大部分内容其实就是方法。
5. 静态代码块
static {
System.out.println("加载类");
}
类第一次加载时执行
6. 普通代码块
{
System.out.println("创建对象");
}
每次new都会执行
7. 内部类
class Inner {
}
编译之后
Student.class
Student$Inner.class
public class 与文件名的关系?
JVM既不读取.java文件,也不关心文件名。真正关心文件名的是Java编译器(javac)。
public class必须与文件名一致,是Java编译器(javac) 执行的Java语言规范。
为什么设计成这样?
这是一个Java语言设计哲学的问题。这是Java设计者在可维护性和灵活性之间做出的权衡。
public class与文件名一致,方便开发者维护,能对程序做到见名知意- 方便IDE管理,可以做到输入类名直接跳转到对应文件
为什么一个.java文件中允许存在多个非public的class?
非public的普通class,一般作为辅助类出现,仅为public class这个主类服务。
public class作为主类、以对外唯一接口的身份出现。其他类调用,只能调用这个主类。而无法调用它的辅助类。起到限制可见范围的作用
javac的作用?
javac 是 Java Compiler(Java 编译器),它的作用就是:
将人类编写的 .java 源代码编译成 JVM 能够执行的 .class 字节码文件。
javac负责.java->.class。java负责.class->JVM运行
javac的编译流程?
.java
│
▼
① Lexer(词法分析)
│
▼
② Parser(语法分析)
│
▼
AST
│
▼
③ Semantic(语义分析)
│
▼
④ Annotation Processing
│
▼
⑤ 生成 .class
javac编译时都做了什么?
- 词法分析
将
int a = 3 + 5 * 2;
拆成Token
int
a
=
3
+
5
*
2
;
这时编译器只是把它当做字符。根本不知道
- 哪个是变量?
- 哪个先计算?
- 哪个后计算?
张三 → 一个名字
打 → 一个动词
李四 → 一个名字
- 语法分析
根据java语法抽象生成语法树
=
├── a
└── +
├── 3
└── *
├── 5
└── 2
注意:这里已经体现了
*
优先级高于
+
所以:
不是
(3+5)*2
而是
3+(5*2)
编译器就是根据这棵树知道应该先计算乘法。
注:倒着看
为什么叫"抽象"语法树(Abstract Syntax Tree)?
因为它会忽略很多没有意义的语法细节。
a+b
和
(a+b)
在AST(抽象语法树)中基本都会表示为
+
├── a
└── b
也就是说:
- 空格
- 换行
- 分号
- 注释
这些通常不会出现在 AST 中。
此时,如果有这样的代码
int = a 10;
虽然每一个 Token 都合法,但是排列方式违反了 Java 文法。Parser 报语法错误。
Sentence
├──主语:张三
├──谓语:打
└──宾语:李四
已经知道:张三打李四。
而不是:李四打张三。
- 语义分析
语义分析关注这句话有没有实际意义?
例如
int a = "hello";
完全符合语法规则,javac开始分析它的语义。开始思考能不能把 String 放进 int
答案:不能。所以此阶段报错:Type mismatch
为什么此阶段属于语义分析?
a = b + c
构建AST:
+
├── b
└── c
语义分析开始理解他们分别是什么。到底是
int b;
int c;
属于算数加法,还是
String b;
String c;
属于字符串拼接。
相同的语法可能有不同的语义
假设有
int a;
String name;
语义分析阶段开始建立
a
↓
变量
↓
int
还有
name
↓
变量
↓
String
语义分析做的是:
- 建立符号表
- 绑定变量
- 解析类型
- 推导表达式类型
- 检查访问权限(
private、protected等) - 方法重载解析
- 泛型类型检查
- 自动装箱/拆箱处理
- Lambda 表达式类型推导
- 确定每个方法调用最终调用哪个方法
- 检查错误
张三
↓
人
↓
李四
↓
人
↓
打
↓
人可以打人
如果:
桌子 打 李四
语法还是正确:
主语
谓语
宾语
但是:
语义分析会说:
桌子不会主动打人。
于是就会产生语义错误。
注:Attr 一般不会修改 AST 的结构(Structure),但会修改 AST 节点中保存的属性(Attribute)。
如:
int c = a + b;
Parser生成的AST:
VarDef
├── type : int
├── name : c
└── Binary(+)
├── Ident(a)
└── Ident(b)
Attr会把Ident(a)补充成
Ident(a)
├── Symbol = LocalVariableSymbol
├── Type = int
└── Slot = 1
最后,树还是那棵树,只是节点变得更丰满了
总结:为AST节点绑定Symbol和Type对象,完成名称解析、类型推导、重载解析、可访问性检查等语义分析。
- 注解处理
javac只负责处理编译期注解(Compile-time Annotation)
注解处理就是编译器把AST交给一些插件(如Lombok等),让它们看看有没有需要处理的注解
在正常的情况下,这些插件会读取AST->分析注解->生成新的.java文件。还可以报编译错误,理论上还能修改AST,但出于对编译器稳定的考虑,官方并不推荐这种方式,而是更推荐生成.java源码
编译器每发现一种注解,都会丢给Processor(处理器),由Processor自己决定该怎么处理。
javac怎么知道谁来处理?
Java有一个接口
javax.annotation.processing.Processor
所有注解处理器都实现这个接口,例如
public class MyProcessor extends AbstractProcessor {
}
里面有process(...)方法,因此,编译器每发现一种注解都会
Processor.process(...)
让处理器自己决定怎么办
但是其中注解还分几种情况,并不是所有的注解都会被处理的。
| 注解 | javac 是否处理 | 谁真正处理 |
|---|---|---|
@MyAnnotation(无 Processor) |
解析、保存,其他什么都不做 | 没有人 |
@MyAnnotation(有 Processor) |
调用 Processor | 你的 Processor |
@Service |
解析、保存,通常不处理 | Spring(运行时) |
@Autowired |
解析、保存,通常不处理 | Spring(运行时) |
@GetMapping |
解析、保存,通常不处理 | Spring MVC(运行时) |
@Data |
调用 Lombok | Lombok(编译期) |
解释:用户自己编写的一般注解,javac不会进行处理。
如果用户自己不仅编写了注解,还编写了Processor,那么javac会把该注解丢给Processor,由处理器自己决定怎么处理(因为用户编写了对应的Processor,也就是交给用户自己编写的Processor去处理)
对于SpringBoot的注解,大部分注解javac都不会处理(小部分除外),如@ServiceAutowired等,因为该注解的设计并不是为了在编译时的注解处理期运行的。而是会交给程序运行期的SpringBoot去处理。其中最特殊的是Lombok,上面说了,大部分调用Processor的注解处理会生成新的
.java源码文件,这也是官方推荐的方式。但是Lombok会直接调用底层接口,修改AST。这样的设计是由其功能的特殊性而不得不采用的方式。这种方式往往会引起Lombok对不同的Java版本的通用性不高(因Java的内部结构可能会改变)。所以在使用Lombok时要挑选对应的版本。
- 字节码生成
在进行Attr(语义分析) 后,Gen(Generate)(生成) 前,还要进行Lower(降级) 处理。
Gen翻译的不是Attr语义解析过的树,而是经过Lower降级处理后的树。
Lower的作用是,消解语法糖,消解高级语法。返璞归真
Java语言≠JVM指令集
Java为了提高开发效率,引入了很多高级语法,如:
- 增强 for
- Lambda
- 自动装箱/拆箱
这些JVM根本不认识。因此在生成.class字节码前,必须把这些高级语法拆解开。
也就是说,对Lower输入一棵AST会得到一棵新的,降级处理后的AST
原AST->Lower->新AST
举例:
这是一段增强for语法糖
for (String s : list) {
System.out.println(s);
}
Parser 生成的 AST 中,会有一个专门表示增强 for 的节点:
EnhancedForLoop
├── variable
├── iterable
└── body
Gen不认识这种高级节点,于是Lower把它改写成普通循环,做了一个降级处理
逻辑上相当于:
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
System.out.println(s);
}
对应的新AST:
Block
├── VarDef(iterator)
└── WhileLoop
原来的 EnhancedForLoop 节点已经不存在了。
Lower处理到此讲解结束,下面是Gen(生成)部分,讲解.class是如何生成的
AST 本身不会"变成"字节码,而是编译器采用 Visitor 模式递归遍历 AST,每个节点根据自己的语义向当前方法的 Code 缓冲区追加若干条 JVM 指令。当整棵 AST 遍历完成后,这个 Code 缓冲区就构成了方法的字节码,最后再由 ClassWriter 按照 ClassFile 格式写入 .class 文件。
这里有三个关键词非常重要,也是以后阅读 OpenJDK javac 源码时反复会遇到的:
- Visitor 模式:不同类型的 AST 节点(变量、表达式、方法调用、循环等)都有对应的
visitXXX()方法负责生成字节码。 - 递归遍历:父节点通常先递归处理子节点,再生成自己的指令,因此自然符合 JVM 基于操作数栈的执行模型。
- Code 缓冲区:生成的字节码不是立即写入
.class文件,而是先追加到当前方法的Code对象中,等整个方法处理完后,再由ClassWriter与常量池、字段表、方法表等一起封装成最终的 ClassFile。
什么是字节码?
字节码(Bytecode)就是JVM能直接理解的一套中间指令集。字节码就是javac编译器输出的结果。也是JVM的输入。
字节码只是.class文件的一部分,.class文件主要有以下几个部分组成
.class
│
├── Magic Number
├── 版本号
├── 常量池
├── 字段信息
├── 方法信息
└── Code属性(真正的字节码)
真正意义上的字节码,是 Code 属性里面的 code[] 数组。
method_info
└── Code
├── max_stack
├── max_locals
├── code_length
└── code[]
字节码有哪些特点?
- 面向栈
JVM不像CPU大量使用寄存器,而是主要依赖操作数栈来完成计算 - 一条指令完成一个简单动作
- 与平台无关
具体平台的机器码,由JVM去翻译
总结:字节码(Bytecode)是 Java 编译器
javac将.java源代码转换后生成的一套平台无关的 JVM 指令。它存储在.class文件的Code属性中,由 JVM 在运行时解释执行或通过 JIT 编译成本地机器码执行,是连接 Java 源代码与 CPU 机器码之间的中间表示。
什么是标志位?
标志位其实就是一种位图,即每一位都对应着某种信息。JVM通过标志位来理解"public"、"final"等修饰符。
标志位是由javac编译产生的。编译器会把这些修饰符压缩成一个 16 位整数(u2)。例如:
0000 0000 0001 0001
其中
第0位 = public
第4位 = final
JVM只需要判断
if (第0位 == 1)
说明它是public
if (第4位 == 1)
说明它是final
Class文件中很多位置都会用到标志位
ClassFile
│
├── magic
├── version
├── constant_pool
├── access_flags ← 类的标志位
├── this_class
├── super_class
├── interfaces
├── fields
│ └── access_flags ← 字段标志位
├── methods
│ └── access_flags ← 方法标志位
└── attributes
可以看到整个 Class 文件至少有三种标志位:
- 类标志位
- 字段标志位
- 方法标志位
常见的类标志位
| 标志名称 | 十六进制 | 二进制 | 含义 |
|---|---|---|---|
| ACC_PUBLIC | 0x0001 | 0000 0000 0000 0001 | public 类 |
| ACC_FINAL | 0x0010 | 0000 0000 0001 0000 | final 类 |
| ACC_SUPER | 0x0020 | 0000 0000 0010 0000 | 使用新的 invokespecial 调用语义(现代编译器几乎都会设置) |
| ACC_INTERFACE | 0x0200 | 0000 0010 0000 0000 | interface |
| ACC_ABSTRACT | 0x0400 | 0000 0100 0000 0000 | abstract |
| ACC_SYNTHETIC | 0x1000 | 0001 0000 0000 0000 | 编译器生成 |
| ACC_ANNOTATION | 0x2000 | 0010 0000 0000 0000 | 注解类型 |
| ACC_ENUM | 0x4000 | 0100 0000 0000 0000 | enum |
常见的字段标志位
| 标志名称 | 十六进制 | 二进制 | 含义 |
|---|---|---|---|
| ACC_PUBLIC | 0x0001 | 0000 0000 0000 0001 | public |
| ACC_PRIVATE | 0x0002 | 0000 0000 0000 0010 | private |
| ACC_PROTECTED | 0x0004 | 0000 0000 0000 0100 | protected |
| ACC_STATIC | 0x0008 | 0000 0000 0000 1000 | static |
| ACC_FINAL | 0x0010 | 0000 0000 0001 0000 | final |
| ACC_VOLATILE | 0x0040 | 0000 0000 0100 0000 | volatile |
| ACC_TRANSIENT | 0x0080 | 0000 0000 1000 0000 | transient |
| ACC_SYNTHETIC | 0x1000 | 0001 0000 0000 0000 | 编译器生成 |
| ACC_ENUM | 0x4000 | 0100 0000 0000 0000 | enum 常量 |
常见的方法标志位
| 标志名称 | 十六进制 | 二进制 | 含义 |
|---|---|---|---|
| ACC_PUBLIC | 0x0001 | 0000 0000 0000 0001 | public |
| ACC_PRIVATE | 0x0002 | 0000 0000 0000 0010 | private |
| ACC_PROTECTED | 0x0004 | 0000 0000 0000 0100 | protected |
| ACC_STATIC | 0x0008 | 0000 0000 0000 1000 | static |
| ACC_FINAL | 0x0010 | 0000 0000 0001 0000 | final |
| ACC_SYNCHRONIZED | 0x0020 | 0000 0000 0010 0000 | synchronized |
| ACC_BRIDGE | 0x0040 | 0000 0000 0100 0000 | 编译器生成的桥接方法 |
| ACC_VARARGS | 0x0080 | 0000 0000 1000 0000 | 可变参数方法 |
| ACC_NATIVE | 0x0100 | 0000 0001 0000 0000 | native |
| ACC_ABSTRACT | 0x0400 | 0000 0100 0000 0000 | abstract |
| ACC_STRICT | 0x0800 | 0000 1000 0000 0000 | strictfp(较少使用) |
| ACC_SYNTHETIC | 0x1000 | 0001 0000 0000 0000 | 编译器生成 |
什么是元数据
元数据就是不参与程序计算,而是用来描述程序结构的信息。
元数据不是程序真正执行的内容,而是 JVM 用来理解程序结构的信息。
JVM加载Class文件的时候,首先需要知道
这是哪个类?
继承谁?
有哪些接口?
有哪些字段?
有哪些方法?
方法叫什么?
参数是什么?
返回值是什么?
访问权限是什么?
这些信息全部来自Class 文件中的元数据。
一个.class文件可以分为两大部分
Class 文件
│
├── 元数据(Metadata)
│
└── 字节码(Bytecode)
元数据例如:
ClassFile
│
├── magic
├── version
├── constant_pool
├── access_flags
├── this_class
├── super_class
├── interfaces
├── fields
├── methods
└── attributes
即除了Code的code[]里面存放的是字节码。几乎其他所有内容都属于元数据
元数据解释:
ClassFile(Class文件结构)
│
├── magic
│ 魔数
│ 用于判断当前文件是否是合法的 Java Class 文件
│ 固定值:CAFEBABE
│
├── version
│ Class文件版本号
│ 包含:
│ - minor_version(次版本号)
│ - major_version(主版本号)
│ 用于告诉 JVM 该 class 文件由哪个 Java 版本编译
│
├── constant_pool
│ 常量池
│ 存储类中使用的各种常量和符号引用
│ 包括:
│ - 字符串常量
│ - 类名
│ - 方法名
│ - 字段名
│ - 类型描述符
│ - 方法引用
│
├── access_flags
│ 访问标志位
│ 描述当前类的权限和特征
│ 例如:
│ - public
│ - final
│ - abstract
│ - interface
│
├── this_class
│ 当前类信息
│ 指向常量池中的一个类引用
│ 表示:
│ "当前 class 文件描述的是哪个类"
│
├── super_class
│ 父类信息
│ 指向常量池中的父类引用
│ 表示:
│ "当前类继承自哪个类"
│ (Object除外,Object没有父类)
│
├── interfaces
│ 接口列表
│ 保存当前类实现的接口信息
│ 例如:
│ class User implements Serializable
│
├── fields
│ 字段表
│ 描述类中的成员变量
│ 包括:
│ - 字段名称
│ - 字段类型
│ - 访问权限
│ - static/final/volatile等属性
│
├── methods
│ 方法表
│ 描述类中的方法
│ 包括:
│ - 方法名称
│ - 参数类型
│ - 返回值类型
│ - 访问权限
│ - 方法字节码
│
└── attributes
属性表
保存额外信息
例如:
- SourceFile(源码文件名)
- Code(方法字节码)
- LineNumberTable(源码行号映射)
- LocalVariableTable(局部变量信息)
- RuntimeVisibleAnnotations(运行时注解)
从JVM加载一个类需要什么信息的角度解释:
JVM想认识一个类
│
├── 它叫什么?
│ this_class
│
├── 它是谁的子类?
│ super_class
│
├── 它实现了什么?
│ interfaces
│
├── 它有什么变量?
│ fields
│
├── 它有什么方法?
│ methods
│
├── 它有什么权限?
│ access_flags
│
└── 它里面存什么代码?
Code属性
(属于attributes)
.class的完整文件结构
ClassFile(.class文件)
│
├── magic
│ 魔数
│
├── minor_version
│ 次版本号
│
├── major_version
│ 主版本号
│
├── constant_pool_count
│ 常量池数量
│
├── constant_pool
│ 常量池
│
├── access_flags
│ 类访问标志
│
├── this_class
│ 当前类索引
│
├── super_class
│ 父类索引
│
├── interfaces_count
│ 实现接口数量
│
├── interfaces
│ 接口列表
│
├── fields_count
│ 字段数量
│
├── fields
│ 字段表
│
├── methods_count
│ 方法数量
│
├── methods
│ 方法表
│
├── attributes_count
│ 属性数量
│
└── attributes
属性表
分析一个简单的java程序
package linshi;
public class Main {
public static void main(String[] args) {
int a = 0;
int b = 0;
int c = 0;
a = b+c;
System.out.println("Hello Java!");
}
}
十六进制的class文件
CA FE BA BE 魔数(Magic Number,标识这是一个 Java Class 文件)
00 00 次版本号(minor_version)
00 3D 主版本号(major_version,0x3D = Java 17)
00 1D 常量池数量(constant_pool_count = 29,实际索引 #1 ~ #28)
#1
0A 00 02 00 03
CONSTANT_Methodref
方法引用
class_index = #2
name_and_type_index = #3
#2
07 00 04 CONSTANT_Class
类引用
name_index = #4
#3
0C 00 05 00 06
CONSTANT_NameAndType
名称与类型描述
name_index = #5
descriptor_index = #6
#4
01 00 10
6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74
CONSTANT_Utf8
"java/lang/Object"
#5
01 00 06
3C 69 6E 69 74 3E
CONSTANT_Utf8
"<init>"
构造方法名称
#6
01 00 03
28 29 56
CONSTANT_Utf8
"()V"
方法描述符:
无参数,返回 void
#7
09 00 08 00 09
CONSTANT_Fieldref
字段引用
System.out
#8
07 00 0A CONSTANT_Class
类引用
name_index = #10
#9
0C 00 0B 00 0C
CONSTANT_NameAndType
名称与类型描述
name_index = #11
descriptor_index = #12
#10
01 00 10
6A 61 76 61 2F 6C 61 6E 67 2F 53 79 73 74 65 6D
CONSTANT_Utf8
"java/lang/System"
#11
01 00 03
6F 75 74
CONSTANT_Utf8
"out"
#12
01 00 15
4C 6A 61 76 61 2F 69 6F 2F 50 72 69 6E 74 53 74 72 65 61 6D 3B
CONSTANT_Utf8
"Ljava/io/PrintStream;"
字段类型描述符
#13
01 00 0B
48 65 6C 6C 6F 20 4A 61 76 61
CONSTANT_Utf8
"Hello Java"
#14
08 00 0E
CONSTANT_String
字符串引用
string_index = #14
实际内容:
#13 "Hello Java"
#15
0A 00 10 00 11
CONSTANT_Methodref
方法引用:
java/io/PrintStream.println
#16
07 00 12
CONSTANT_Class
类引用
name_index = #18
#17
0C 00 13 00 14
CONSTANT_NameAndType
方法名称和描述符
#18
01 00 13
6A 61 76 61 2F 69 6F 2F 50 72 69 6E 74 53 74 72 65 61 6D
CONSTANT_Utf8
"java/io/PrintStream"
#19
01 00 07
70 72 69 6E 74 6C 6E
CONSTANT_Utf8
"println"
#20
01 00 15
28 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 29 56
CONSTANT_Utf8
"(Ljava/lang/String;)V"
方法描述符:
参数 String
返回 void
#21
07 00 16
CONSTANT_Class
当前类引用
#22
01 00 0B
6C 69 6E 73 68 69 2F 4D 61 69 6E
CONSTANT_Utf8
"linshi/Main"
#23
01 00 04
43 6F 64 65
CONSTANT_Utf8
"Code"
字节码属性名称
#24
01 00 0F
4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65
CONSTANT_Utf8
"LineNumberTable"
行号映射属性名称
#25
01 00 04
6D 61 69 6E
CONSTANT_Utf8
"main"
#26
01 00 16
28 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 29 56
CONSTANT_Utf8
"([Ljava/lang/String;)V"
方法描述:
main(String[])
返回 void
#27
01 00 0A
53 6F 75 72 63 65 46 69 6C 65
CONSTANT_Utf8
"SourceFile"
#28
01 00 09
4D 61 69 6E 2E 6A 61 76 61
CONSTANT_Utf8
"Main.java"
00 21 access_flags
类访问标志:
ACC_PUBLIC
ACC_SUPER
00 15 this_class
当前类索引:
#21 → linshi/Main
00 02 super_class
父类索引:
#2 → java/lang/Object
00 00 interfaces_count
实现接口数量:
0
00 00 fields_count
字段数量:
0
00 02 methods_count
方法数量:
2
方法1:构造方法 Main()
00 01 access_flags
public
00 05 name_index
#5 → <init>
00 06 descriptor_index
#6 → ()V
00 01 attributes_count
00 17 attribute_name_index
#23 → Code
00 00 00 1D attribute_length
00 01 max_stack
操作数栈最大深度
00 01 max_locals
局部变量表大小
00 00 00 05 code_length
字节码长度 5 注:从这里开始,后面的5位是字节码内容
2A aload_0
加载 this
B7 00 01 invokespecial #1
调用 Object.<init>()
B1 return
返回
方法2:main(String[])
00 09 access_flags
public static
00 19 name_index
#25 → main
00 1A descriptor_index
#26 → ([Ljava/lang/String;)V
00 01 attributes_count
00 17 attribute_name_index
#23 → Code
00 00 00 25 attribute_length
00 02 max_stack
00 01 max_locals
00 00 00 09 code_length
字节码长度 9 注:从这里开始,后面的9位是字节码内容
B2 00 07 getstatic #7
获取 System.out
12 0D ldc #13
加载字符串 "Hello Java"
B6 00 0F invokevirtual #15
调用 PrintStream.println(String)
B1 return
Code内部属性:
00 01 attributes_count
00 18 attribute_name_index
#24 → LineNumberTable
00 00 00 06 attribute_length
00 01 line_number_table_length
00 00 start_pc
00 03 line_number
类属性:
00 01 attributes_count
00 1B attribute_name_index
#27 → SourceFile
00 00 00 02 attribute_length
00 1C sourcefile_index
#28 → Main.java
对应:
.class
├── magic
│ CAFEBABE
│
├── version
│ Java17
│
├── constant_pool
│
├── access_flags
│ public
│
├── this_class
│ linshi/Main
│
├── super_class
│ java/lang/Object
│
├── interfaces
│ 0个
│
├── fields
│ 0个
│
├── methods
│ 2个
│ │
│ ├── Main()
│ │
│ └── main(String[])
│
└── attributes
│
└── SourceFile
|
└── Main.java
每个方法都有自己的
Code属性,也即有自己的字节码
魔数CAFEBABE
在计算机文件格式中,魔数就是写在文件开头的一段固定字节,用来标识文件类型。
魔数的主要作用就是帮助JVM判断这个文件是不是一个合法的.class文件。如果是则继续解析。如果不是,则抛出异常
常见的几种魔数如下:
| 文件类型 | 文件头魔数 |
|---|---|
| Java Class | CA FE BA BE |
| PNG图片 | 89 50 4E 47 |
25 50 44 46 |
|
| ZIP | 50 4B 03 04 |
主版本号、次版本号
主版本号和次版本号的作用是告诉JVM这是哪个java版本,该以哪个版本的方式去运行它。当前JVM支持就继续加载。不支持则抛出异常。
Java的设计理念是向后兼容,所以新版本的JVM往往能兼容旧版本的JVM(的绝大部分功能,因有些库可能过时被删除会受影响),所以JVM可以选择相应版本的运行方式。
Constant Pool(常量池)
Constant Pool 是 class 文件中的一个区域,用来集中保存程序中的常量和符号引用(如字符串、类名、方法名、字段名、类型描述符等),每个条目都有编号。字节码执行时通过这些编号找到对应的信息。
就是给可能需要反复调用的信息,如类名,public等标识符放入常量池,并编号。字节码执行时只需要调用编号就可以了。无需反复存储这些信息。
字段信息
在 JVM 的 .class 文件结构中,字段信息(Field Information)就是描述一个类中成员变量的信息。
字段 = 类里面定义的变量。字段信息就是 JVM 用来描述这些变量的数据。
例如:
public class User {
private String name;
public int age;
static String type = "student";
}
其中以下内容都是字段
name
age
type
JVM 加载类时,会读取字段表,知道:
- 这个类有哪些变量
- 变量叫什么
- 类型是什么
- 权限是什么
- 有没有额外属性
一个字段所包含的信息:
private int age;
编译后,字段信息大概为
Field:
access_flags: 访问标志
ACC_PRIVATE
name_index: 字段名字(其具体内容存在常量池)
#5
descriptor_index: 字段描述符
#6
常见访问标志:
| 标志 | 含义 |
|---|---|
| ACC_PUBLIC | public字段 |
| ACC_PRIVATE | private字段 |
| ACC_PROTECTED | protected字段 |
| ACC_STATIC | static字段 |
| ACC_FINAL | final字段 |
| ACC_VOLATILE | volatile字段 |
| ACC_TRANSIENT | transient字段 |
常见字段描述符
| Java类型 | 描述符 |
|---|---|
| byte | B |
| char | C |
| double | D |
| float | F |
| int | I |
| long | J |
| short | S |
| boolean | Z |
| void | V |
| String(对象) | Ljava/lang/String; |
注:字段信息描述“对象有什么字段”,但字段值属于对象实例。
方法信息
方法信息(Method Information)就是 class 文件中用于描述一个方法的所有元数据,告诉 JVM:这个方法叫什么、参数是什么、返回值是什么、权限如何,以及方法的字节码存放在哪里。
假设有两个方法
public class User {
private int age;
public void login() {
}
public int getAge() {
return age;
}
}
那么 methods 中会有:
Method Table
Method 1
login()
Method 2
getAge()
每一个 Method 就对应一个 method_info 结构。
一个 method_info是什么样的?
public int getAge() {
return age;
}
编译后:
method_info
├── access_flags 标志符
│ ACC_PUBLIC
│
├── name_index 方法名字
│ #6
│ │
│ ▼
│ Utf8 "getAge"
│
├── descriptor_index 类型描述符:告诉 JVM 它的类型或参数、返回值信息。
│ #7
│ │
│ ▼
│ Utf8 "()I" 返回int型
│
├── attributes_count `attributes_count` 表示当前字段、方法或其他结构所包含的属性(Attributes)数量,JVM 会根据这个数量继续读取后面的 `attributes[]`。
│ 1
│
└── attributes
│
▼
Code
│
├── max_stack = 1
│
├── max_locals = 1
│
├── code 这部分才是字节码
│ │
│ ├── aload_0
│ ├── getfield #13
│ └── ireturn
│
├── exception_table
│ 空
│
└── attributes
│
└── LineNumberTable
}
Code的子项说明
| 一级子项 | 含义 | 作用 |
|---|---|---|
| max_stack | 操作数栈的最大深度 | 告诉 JVM 执行该方法时,栈最多需要多少个槽位(slot)。JVM 会一次性为该方法栈帧分配足够大的操作数栈。 |
| max_locals | 局部变量表的大小 | 告诉 JVM 该方法需要多少个局部变量槽位,用来存放 this、参数和局部变量。 |
| code | 真正的 JVM 字节码指令 | JVM 按顺序解释或编译执行这里面的指令,例如 aload_0、getfield、ireturn。 |
| exception_table | 异常处理表 | 记录 try-catch-finally 对应的异常处理范围。当方法抛出异常时,JVM 根据这张表决定跳转到哪个 catch 或 finally。没有 try-catch 时通常为空。 |
| attributes | Code 属性自己的属性表 | 保存与字节码相关的附加信息,例如 LineNumberTable(源码行号)、LocalVariableTable(局部变量名)、StackMapTable(字节码校验)等。 |
属性
Attribute(属性)不是类、字段或方法本身的一部分,而是挂载在它们上的"扩展信息块",JVM 用它来存放各种额外信息,使 .class 文件能够在不修改主体结构的前提下不断扩展新功能。
最大的优点就是:
扩展性。
例如:
Java 5 增加:
Annotation
不用修改:
method_info
只新增:
RuntimeVisibleAnnotations Attribute
Java 8 增加:
MethodParameters
继续新增 Attribute。
Java 14 增加:
Record
继续新增:
Record Attribute
整个 class 文件格式几乎不用改。
第二阶段:Class 文件与字节码
学习目标
学会阅读 JVM 指令。
需要掌握
JVM指令
例如
操作数栈
实践
观察:
分别生成什么字节码。
答题区
算数运算指令
算术运算指令负责对操作数栈中的数据进行计算,并将计算结果重新压回操作数栈。
注意,它们只操作操作数栈,不会直接访问局部变量表。
算术运算常见指令
| int | long | float | double | 含义 |
|---|---|---|---|---|
| iadd | ladd | fadd | dadd | 加法 |
| isub | lsub | fsub | dsub | 减法 |
| imul | lmul | fmul | dmul | 乘法 |
| idiv | ldiv | fdiv | ddiv | 除法 |
| irem | lrem | frem | drem | 取余 |
| ineg | lneg | fneg | dneg | 取负 |
第一步
iload_1
局部变量表
slot1 = 10
↓
操作数栈
10
第二步
iload_2
操作数栈
20 ← 栈顶
10
第三步
iadd
JVM 会:
value2 = pop()
value1 = pop()
push(value1 + value2)
因此:
pop → 20
pop → 10
计算
10 + 20
push 30
结果:
操作数栈
30
对象创建
对象的创建实际上会经过 "申请内存 → 调用构造方法 → 保存引用" 三个阶段,因此通常需要 4~5 条字节码指令 协同完成。
User user = new User();
编译后的字节码为
0: new #2
3: dup
4: invokespecial #3
7: astore_1
执行流程如下:
new User()
│
▼
new 指令申请对象内存
│
▼
操作数栈: object reference
│
▼
dup 复制引用
│
▼
invokespecial 调用构造方法
│
▼
astore 保存到局部变量表
- new指令
假设常量池中:
#2 = Class User
那么 JVM 会知道:
创建一个 User 类型对象
new指令的工作流程:
① 根据常量池找到类
② 如果类还没加载,先执行类加载
JVM 并不会在程序启动时一次性把所有
.class文件都读进内存,而是在第一次真正需要某个类时,才把它加载到内存中。
③ 在堆中申请内存
④ 所有字段赋默认值
例如:
class User{
int age;
String name;
}
刚创建时:
Heap
User Object
age = 0
name = null
注意:这里构造函数还没有执行!
随后 JVM 会把对象引用压入操作数栈。例如:
操作数栈
┌──────────────┐
│ ref(User@01) │
└──────────────┘
这里只是一个引用(可以理解为指向堆对象的地址),不是对象本身。
- dup指令
虽然已经有了一个引用了,但后面的构造方法需要一个对象引用,而最后还要把这个引用赋值给变量。
所以,只有一个引用是不够的,还需要再复制一份。
dup复制的是"对象引用(Object Reference)",也就是指向堆中对象的引用,而不是常量池中的引用。
执行前:
操作数栈
ref
执行:
dup
得到:
操作数栈
ref
ref
现在有两个引用了。
对于绝大多数对象创建场景,两个引用就够用了。 因为整个对象创建过程中,真正需要长期保留引用的地方只有两个:
- 一个给构造方法
<init>使用; - 一个留给后续字节码继续使用(例如赋值、作为表达式结果等)。
注:
<init>会消费(pop)一个对象引用,并且由于<init>返回类型为void,不会将引用重新压回操作数栈,因此需要事先使用dup保留另一份引用供后续指令使用。
在执行init之前,该对象(Object)不能作为普通对象使用。而init因为消费了一份引用,所以可以直接让类进行初始化,即另一份引用因为指向的都是同一份对象,所以状态也会发生改变。变为已初始化状态。
- invokespecial (调用特殊方法:指构造方法)
invokespecial 会消费(pop)操作数栈顶的一份对象引用,将它作为新栈帧中 slot0 的 this 传递给构造方法(或其他实例方法)。方法执行结束后,这份引用不会返回调用方,因此如果后续还需要使用该对象引用,就必须提前保留(如使用 dup)。
关于this的解释:
this是指向当前对象的引用,例如在user.printName()中,方法内部的this就是user这个对象。
例如:
User user = new User("Tom");
user.printName();
JVM 实际上可以理解为:
printName(user);
也就是说:
this
实际上就是:
user
只是 Java 编译器帮你隐藏了这个参数。
- astore 保存到局部变量表
astore 会将操作数栈顶的对象引用弹出(pop),保存到局部变量表的指定槽位(slot)中,以便后续代码可以随时通过 aload 再次取出使用。
对象创建完成后,操作数栈中还剩下一份引用:
操作数栈
ref(User)
但是,操作数栈是临时的。
方法继续执行时,新的指令会不断:
push新数据pop旧数据
如果不把引用保存起来,很快就会丢失。
所以 JVM 需要执行:
astore_1
把它保存到局部变量表。
方法调用
5种invoke指令
| 指令 | 调用对象 | 是否多态 |
|---|---|---|
| invokevirtual | 普通成员方法 | ✔ |
| invokespecial | 构造、private、super | ✘ |
| invokestatic | static方法 | ✘ |
| invokeinterface | 接口方法 | ✔ |
| invokedynamic | 动态调用 | JVM运行时决定 |
方法调用大体可以总结为以下步骤,但其中各个方法调用的细节又各不相同
invoke*
│
▼
解析常量池中的方法符号引用
│
▼
确定真正的方法
│
▼
创建新的栈帧
│
▼
参数复制到局部变量表
│
▼
PC跳转到目标方法
│
▼
执行方法
│
▼
return
│
▼
弹出栈帧
│
▼
恢复调用者
invokevirtual(最常见)普通方法的调用
user.sayHello();
编译后
aload_1 读取对象创建时保存到局部变量区的引用
invokevirtual User.sayHello:()V 实际调用函数。具体调用过程见下图
执行流程:
invokevirtual #18
│
┌──────────────┴──────────────┐
│ │
▼ ▼
读取运行时常量池 读取对象引用(reference)
得到 Methodref │
│ ▼
▼ 去堆中找到对象
方法解析(Resolution) │
Methodref → Method ▼
(Animal.run) 读取 Klass Pointer
│ │
│ ▼
│ 得到真实类型
│ Dog
└──────────────┬──────────────┘
▼
虚方法查找(Virtual Lookup)
根据 (Method + Klass) 找到 Dog.run
│
▼
创建栈帧
│
▼
执行 Code[]
这里最重要的是:
真正执行哪个方法,是运行时决定的。 例如:
Animal a = new Dog();
a.run();
从源码看见的是
Animal.run()
但是运行的时候对象其实是Dog,因此执行Dog.run()
这就是
动态绑定(Virtual Dispatch)
所以叫
invokevirtual
方法解析
示例:
user.sayHello();
class 文件里面实际上只有:
invokevirtual #18
而常量池第18项:
#18 = Methodref
Class User
Name sayHello
Descriptor ()V
没有
- 方法在哪里?
- 方法地址是多少?
只有 - 类名
- 方法名
- 描述符
这就是符号引用(Symbolic Reference)
方法解析,就是根据这个符号引用,去方法区(Metaspace) 找到User.class里面的方法。
User
Method[]
0 main()V
1 sayHello()V 这里就是想要实际调用的目标方法
2 eat()V
JVM就得到了Method*(可以理解成 HotSpot 内部 Method 结构的指针。)这是一个直接引用。
以后就不用再根据符号引用来绕圈子找了
而方法区里面就含有方法的描述与实际可执行的字节码,如:
User
Method sayHello
名字
描述符
访问权限
Code: Code里含有字节码
0:getstatic
3:ldc
5:invokevirtual
8:return
虚方法查找过程
根据虚方法表来查找真实的对象的方法
当前类(指Dog.class)
│
▼
有没有这个方法?
│
├────有────► 返回
│
▼
没有
│
▼
父类
│
├────有────► 返回
│
▼
没有
│
▼
继续父类
虚方法表结构
Dog
vtable
0 ─────► Method(Dog.run)
1 ─────► Method(Animal.eat) 注:这里因为Dog没有eat方法,但是父类Animal有,直接覆盖
2 ─────► Method(Dog.sleep)
创建栈帧过程说明
在 JVM 中,每个线程都有一个 Program Counter Register(程序计数器)。
它的作用只有一个: 记录当前线程下一条将要执行的字节码指令的位置。 值得注意的一点是当前线程
创建栈帧过程如下图示例
main()
PC = 0
↓
PC = 1
↓
PC = 2
↓
...
↓
PC = 6 (invoke) 遇到invoke指令时
──────────── 调用方法 ────────────
创建 add() 栈帧,PC去执行具体方法,执行完后再继续执行主程序
PC = add:0
↓
PC = add:1
↓
PC = add:2
↓
PC = add:3 (return)
──────────── 返回 ────────────
弹出 add() 栈帧
PC = main:9 从main:9开始是因为 invokestatic 这一条指令本身占 3 个字节,和add有几条指令没有关系。add是一个全新的栈了
↓
继续执行 main()
invokespecial
对应private、super方法、构造函数
这一条指令不会发生多态。
例如
class A{
private void test(){}
public A(){}
}
对应都是
invokespecial
包括
new A()
private方法
super.xxx()
例如
super.run();
JVM 不会去找子类。
直接调用父类。
当前类
│
▼
父类的方法
因此它叫
special
意思就是
特殊规则调用。
invokespecial:
JVM 用来执行“明确指定目标”的方法调用,包括构造方法
<init>、父类super调用、private 方法调用。它不会进行多态动态分派,而是直接调用指定的方法。
invokespecial
|
↓
常量池中的方法引用
|
↓
确定的 类 + 方法
|
↓
直接执行
invokevirtual
你说:
找张三的电话
但是有很多张三。
于是:
先看当前人的身份
|
↓
找到对应张三
动态决定。
invokespecial
你说:
我要打给“通讯录第5页第3行的张三”
位置已经确定。
直接拨。
invokestatic
该指令适用于静态方法
例如
Math.max(a,b);
编译后
invokestatic Math.max
静态方法属于类,不是对象,因此没有this,也不用读取对象。直接根据类找到方法。
Math
│
▼
max
速度也最快。
invokeinterface
例如
// 这里的Runnable是接口,MyRunnable是实现了Runnable接口的类
Runnable r = new MyRunnable();
r.run();
源码只知道
Runnable
真正对象
MyRunnable
因此 JVM 要
接口
│
▼
对象真实类型
│
▼
实现类
│
▼
run()
所以
invokeinterface
也属于动态绑定。
invokedynamic
这是 Java 7 新增的。
例如
Runnable r = ()->{
System.out.println("Hello");
};
以前 JVM 并不知道 Lambda 是什么。
后来加入
invokedynamic
第一次执行时:
invokedynamic
│
▼
Bootstrap Method
│
▼
生成真正的方法
│
▼
缓存CallSite
以后再次调用,就直接使用已经链接好的目标方法,而不需要重新建立链接。它也是 Java Lambda、方法引用等现代语言特性的基础。
返回指令
返回指令的核心作用总结:
- 结束当前方法的执行。
- 如果方法有返回值,将返回值从当前栈帧的操作数栈传递到调用者栈帧的操作数栈。
- 销毁当前方法对应的栈帧(局部变量表、操作数栈等随之释放)。
- 恢复调用者的执行现场(包括程序计数器),让调用者从方法调用指令的下一条字节码继续执行。(可以结合上一条方法调用来看)
返回指令会依次完成以下任务
① 当前方法执行结束
│
▼
② 如果有返回值
放入调用者操作数栈
│
▼
③ 当前栈帧出栈
│
▼
④ PC寄存器恢复到调用者
│
▼
⑤ 调用者继续执行下一条字节码
返回指令不仅仅是"return",它真正做的是恢复调用现场。
JVM中返回指令的种类
| 返回值类型 | 字节码 |
|---|---|
| int、boolean、byte、char、short | ireturn |
| long | lreturn |
| float | freturn |
| double | dreturn |
| 引用(Object、String、数组...) 注:这里返回的不是整个对象,而是对象引用 |
areturn |
| void | return |
为什么 int、boolean、char 共用 ireturn?
原因是:在 JVM 内部,它们统一按照 int 处理。
例如
boolean flag = true;
char c = 'A';
short s = 10;
byte b = 2;
进入操作数栈以后实际上都是:
int
返回值是如何传递给调用者的?
示例:
int c = add(3, 5);
执行流程:
add()
计算
↓
8
↓
ireturn
↓
main操作数栈
↓
istore
↓
局部变量表(c)
也就是说,返回指令负责把返回值放回操作数栈,而不是直接写入局部变量表。局部变量表的写入是由istore指令实现的。
为什么JVM采用栈式计算机模型?
JVM 采用栈式计算机模型,主要有三个原因:
- 实现简单。 栈式指令集无需考虑寄存器分配,使
javac生成字节码以及 JVM 解释执行都更加简单。 - 字节码更加紧凑。 栈式指令无需编码寄存器编号,只需描述压栈、出栈和运算,因此字节码通常更加紧凑,
.class文件占用的存储空间更小。 - 跨平台性更好。 不同 CPU 的寄存器数量和指令集各不相同,而栈式字节码只描述运算逻辑,不依赖任何具体硬件。JVM 在不同平台上分别将栈式字节码翻译成本地机器码,从而实现 Java 的"一次编译,到处运行"。
为什么JVM不采用寄存器模型?
JVM 不采用寄存器模型,并不是因为寄存器性能不好,而是因为栈模型更符合 JVM 的设计目标:
- 编译器更简单:无需解决复杂的寄存器分配问题。
- 字节码更紧凑:指令无需携带寄存器编号,便于存储和传输。
- 跨平台性更好:字节码不依赖任何具体 CPU 的寄存器结构,由 JVM 在不同平台完成映射。
- 符合当时的技术背景:1995 年的 Java 以解释执行为主,栈式虚拟机更容易实现。
最后需要强调一点:JVM 字节码采用栈模型,并不意味着运行时真的一直依赖栈。 在 HotSpot 等现代 JVM 中,JIT 编译器会把热点代码优化成高效的本地机器码,并充分利用底层 CPU 的寄存器,因此既保留了栈式字节码的跨平台和简洁优势,又获得了接近原生代码的执行性能。
实践部分
一、if 语句
例如:
if (score >= 60) {
...
}
对应的核心字节码:
iload_1 # 将局部变量表中第1个int变量(score)压入操作数栈
bipush 60 # 将整数60压入操作数栈
if_icmplt 20 # 弹出栈顶两个int进行比较,如果 score < 60,则跳转到字节码20处
...
goto 30 # 无条件跳转到字节码30处
常见 if 比较指令
ifeq # 弹出一个值,为0则跳转
ifne # 弹出一个值,不为0则跳转
iflt # 弹出一个值,小于0则跳转
ifle # 弹出一个值,小于等于0则跳转
ifgt # 弹出一个值,大于0则跳转
ifge # 弹出一个值,大于等于0则跳转
if_icmpeq # 弹出两个int,相等则跳转
if_icmpne # 弹出两个int,不等则跳转
if_icmplt # 弹出两个int,小于则跳转
if_icmple # 弹出两个int,小于等于则跳转
if_icmpgt # 弹出两个int,大于则跳转
if_icmpge # 弹出两个int,大于等于则跳转
goto # 无条件跳转到指定字节码位置
二、while / for 循环
例如:
for (int i = 0; i < 3; i++) {
...
}
对应核心字节码:
iconst_0 # 将整数0压入操作数栈
istore_1 # 将栈顶保存到局部变量表1(i)
iload_1 # 将i压入操作数栈
iconst_3 # 将3压入操作数栈
if_icmpge 40 # 如果 i>=3,则跳出循环
... # 循环体
iinc 1,1 # 将局部变量表1(i)直接加1
goto 4 # 跳回循环判断位置
循环中常见指令
iload # 读取循环变量
istore # 保存循环变量
iinc # 局部变量直接加减(效率最高)
if_icmpxx # 判断是否继续循环
goto # 跳回循环开始继续执行
for 和 while 在 JVM 中本质完全一样,都是「条件判断 + goto 跳转」。
三、switch
例如:
switch(level){
case "A":
...
break;
case "B":
...
break;
default:
...
}
对于整数 switch:
iload_1 # 将switch表达式压入操作数栈
tableswitch # 根据整数值直接跳转到对应case
... case0 ...
goto # break
... case1 ...
goto
default ...
如果 case 不连续,则生成:
lookupswitch # 根据(key,value)查找对应case并跳转
switch 常见指令
tableswitch # 连续整数case,高效索引跳转
lookupswitch # 不连续整数case,采用键值查找跳转
goto # break本质就是goto
一个容易误解的地方
很多人认为 tableswitch 一定比 lookupswitch 快。从理论上讲确实如此,因为它是 O(1) 查找;但在实际 Java 程序中,两者的性能差异通常非常小。javac 已经会根据 case 的分布自动选择更合适的实现,因此几乎没有必要为了性能去刻意调整 switch 的写法。理解它们的区别,更重要的是帮助你阅读和理解 JVM 字节码,而不是手动优化 switch。
四、方法调用
例如:
int result = add(10,20);
对应字节码:
bipush 10 # 把整数10压入操作数栈
bipush 20 # 把整数20压入操作数栈
invokestatic #47 # 调用静态方法add(int,int),弹出栈顶两个参数,执行方法,返回值重新压入操作数栈
istore_3 # 将返回值保存到局部变量表3(result)
第三阶段:类加载机制(Class Loading)
学习目标
理解 JVM 如何把 .class 加载到内存。
需要掌握
类生命周期
ClassLoader
双亲委派模型
static
理解:
答题区
类生命周期
.class 文件
│
▼
┌─────────────────────────────┐
│ 1.Loading(加载) │
│ 将.class加载到JVM │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ 2.Linking(连接) │
│ 让Class变成"可运行状态" │
│ │
│ Verification(验证) │
│ Preparation(准备) │
│ Resolution(解析) │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│3.Initialization(初始化) │
│执行静态变量赋值和static代码块 │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│4.Using(使用) │
│创建对象、调用方法 │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│5.Unloading(卸载) │
│Class对象被GC回收 │
└─────────────────────────────┘
但实际上,更建议理解为:
Class 文件 → JVM中的Class对象 → 可以创建对象 → 被程序使用 → 被JVM回收
Loading(加载)
它的任务只有一句话: 把磁盘中的 .class 文件加载到 JVM 内存中,生成对应的 Class 对象。
例如:
Person p = new Person();
第一次使用 Person 时:
磁盘
┌──────────────┐
│ Person.class │
└──────────────┘
│
│ ClassLoader
▼
JVM
┌─────────────────────┐
│ Person.class信息 │
│ 字段 │
│ 方法 │
│ 常量池 │
│ Class对象 │
└─────────────────────┘
关于ClassLoader的作用下面有详细介绍。简单来说就是告诉JVM去哪里找Class文件
这一阶段主要完成:
- 找到 class 文件
- 读取二进制字节流
- 创建对应的 Class 对象
- 放入方法区(JDK8以后实际存储在 Metaspace)
此时创建的是类的元数据结构
.class文件
|
| ClassLoader
↓
JVM内部Class对象
{
类名: Demo
常量池:
"count"
"name"
字段描述:
static int count
String name
方法:
main()
<init>()
}
注意:这里只是把类读进来了,还没有开始进行初始化
注 :这里只关注static变量和方法。而实例变量则被忽略
例如:
class Person {
String name;
}
注意:
name
不是 static。所以 Preparation 不处理它。因为实例变量属于对象,不是类。真正发生:
new Person();
的时候:
堆:
Person对象
name = null
这个默认值来自:
对象创建过程
而不是类加载过程。
Linking(连接)
加载完成以后,JVM 并不能马上使用这个类。还需要进行"连接(Linking)"。
连接可以理解成:检查一下这个类是否合法,并做好运行前的准备。
连接又分三步。
1.Verification(验证)
这是JVM的第一道安全检查。目的是:确保Class文件没有被篡改,并且符合JVM规范
例如,检查
- 是不是CAFEBABE
- 版本是否受支持
- 当前JVM是否支持
- 字节码是否合法(例)
- 类型是否合法
- 方法是否合法
- 指令是否合法
- 是否越界
- 是否非法访问 private
字节码合法检查示例:
iconst_1
iadd
如果调用iadd指令,但是栈里只有一个整数,就会报如下异常,验证失败。
StackMap Error
在这一阶段如果失败会抛出如下 异常:
java.lang.VerifyError
因此: Verification 的作用就是 保证 JVM 不会执行非法字节码。
2.Preparation(准备)
Preparation(准备)阶段的任务是:为类变量(static变量)分配内存,并赋默认值。
例如:
public class Demo {
static int a = 10;
static String name = "Tom";
}
Preparation 时:
a = 0
name = null
注意:不是
a = 10
因为:
10
属于程序员写的初始化值。
JVM 现在只是:
给变量腾位置。
所以:
static int ->0
static boolean ->false
static long ->0L
引用 ->null
这就是:默认值(Zero Value)
3.Resolution(解析)
这个阶段的任务是:把常量池里的符号引用替换成直接引用。
类里面:
Person p;
字节码中保存的是:
Person
并不是内存地址。
这叫:Symbolic Reference(符号引用)
例如:
java/lang/String
println:(Ljava/lang/String;)V
这些都是名字。
解析阶段:
JVM 去找java/lang/String真正对应哪个Class对象,找到以后Class* 记录下来,以后在调用String就不用再查一次了。
所以Resolution就是把"名字"变成"真正的对象地址"。
Initialization(初始化)
初始化才是真正开始执行 Java 代码。
本阶段的主要任务是:执行类初始化方法 <clinit>()。
注:Initialization 阶段初始化的是 类变量(static变量)和静态代码块。
例如:
class Demo {
static int a = 10;
static {
System.out.println("Hello");
}
}
编译后:
static {
a = 10;
System.out.println("Hello");
}
都会放进:
<clinit>()
JVM 执行:
<clinit>()
于是:
a = 10
然后打印出Hello
所以:Initialization 完成以后a从0变成了10
| 内容 | Initialization处理吗 |
|---|---|
| static变量 | ✅ |
| static代码块 | ✅ |
| static方法 | ❌ |
| 实例变量 | ❌ |
| 构造方法 | ❌ |
Using(使用)
初始化结束以后。
类终于可以正常工作了。
类初始化完成后,可以被主动使用,包括创建对象、调用静态方法、访问静态变量。
例如:
new Demo();
或者:
Demo.test();
或者:
Demo.a
所有:
- 创建对象
- 调用实例方法
- 调用静态方法
- 访问字段
都属于:
Using
这一阶段通常持续整个程序运行期间。
Unloading(卸载)
最后,如果 JVM 发现这个 Class 已经没人用了。
例如:
- ClassLoader 被回收
- Class 对象没有引用
- 所有实例都没有引用
那么:
Class对象
也可以被 GC。
这个过程就是:
Unloading
例如:
PluginClassLoader
│
▼
Plugin.class
插件卸载:
PluginClassLoader = null
GC:
Plugin.class
一起释放。
需要注意的是:
只有类加载器(ClassLoader)可以被回收时,它加载的类才有机会被卸载。
因此,在普通的 Java 应用中,由于应用程序类通常由应用类加载器(Application ClassLoader)加载,而这个类加载器会一直存活到 JVM 退出,所以这些类一般不会被卸载。类卸载更多发生在热部署、OSGi、Tomcat、插件框架等使用自定义类加载器的场景。
ClassLoader
ClassLoader就是负责完成Loading阶段的人。
JVM启动时:
硬盘
Demo.class
JVM
???
JVM还不知道Demo.class在哪里。所以需要ClassLoader告诉 JVM去哪里找Class 文件
然后:
读取class文件
解析class文件
创建Class对象
这整个过程就是Loading
ClassLoader是怎么做的?
Person p = new Person();
第一次执行时JVM发现:
Person.class还没有加载
于是请求:
ClassLoader
ClassLoader:
第一步:
寻找:
Person.class
可能位置:
项目target/classes
JDK rt.jar
第三方jar包
网络
数据库
找到以后,读取:
二进制字节流
例如:
CA FE BA BE
00 00
00 3D
...
然后调用:
JVM内部方法:
defineClass()
生成:
java.lang.Class<Person>
对象。
流程:
Person.class文件
|
|
↓
ClassLoader
|
|
↓
defineClass()
|
|
↓
JVM方法区
|
|
↓
Class<Person>对象
这就是 Loading。
Java默认有3个ClassLoader
1.Bootstrap ClassLoader(启动类加载器)
负责加载Java核心类。
例如:
java.lang.String
java.lang.Object
位置:
JAVA_HOME/lib
例如:
String.class
由它加载。
2.Platform ClassLoader(平台类加载器)
加载JDK平台模块。
例如:
java.sql
java.xml
3.Application ClassLoader(应用类加载器)
加载你自己写的代码。
例如:
target/classes/User.class
你的:
User user = new User();
通常就是它加载。
三者关系
Bootstrap
|
↓
Platform ClassLoader
|
↓
Application ClassLoader
|
↓
User ClassLoader
User ClassLoader不是创建User对象的,它只是负责把 User.class 加载成 JVM 中的 Class 对象。
真正创建 User 对象的是 `new User()`,发生在类加载完成之后。
这种结构叫:
双亲委派模型(Parent Delegation Model)
什么是双亲委派机制?
一个类加载器收到加载请求后,不会自己立即加载,而是先把请求交给自己的父加载器;父加载器无法加载时,子加载器才尝试加载。
为什么需要双亲委派?
先看一个问题。假设你的项目里面有:
package java.lang;
public class String {
}
你自己写了一个假的:
MyProject
|
└── java
|
└── lang
|
└── String.class
然后:
String s = new String();
如果 JVM 允许你的 ClassLoader 直接加载:
那么:
你的String.class
↓
JVM加载
↓
替换java.lang.String
JVM核心类被你的类给替换了。
可能导致:
- 安全漏洞
- 类型混乱
- JVM崩溃
所以 JVM 规定:
核心类必须优先由系统类加载器加载。
这就是双亲委派产生的原因。
Java中的ClassLoader层级
Bootstrap ClassLoader
↑
|
Platform ClassLoader
↑
|
Application ClassLoader
↑
|
自定义 ClassLoader
双亲委派机制的意义
- 防止核心类被替换
- 保证类的唯一性:JVM判断一个类是否相同,不只看类名,而是
类名 + ClassLoader - 避免重复加载:整个JVM只应存在一个
java.lang.Object(举例)如果没有双亲委派机制,可能被三个类加载器同时加载,产生三个Object 类,整个类型系统就会混乱。
加载(Loading) 负责把 .class 文件读入 JVM 并创建 Class 对象;
连接(Linking) 负责验证类的合法性、为静态变量分配内存并将符号引用解析为直接引用,使类达到可运行状态;
初始化(Initialization) 执行 <clinit>() 方法,为静态变量赋程序员指定的初始值并执行静态代码块;
随后进入使用(Using) 阶段,程序可以创建对象、调用方法和访问成员;
最后,当加载该类的 ClassLoader 被回收且类不再被引用时,类进入卸载(Unloading) 阶段,其元数据会被 JVM 回收。
特殊的final static常量
一般的static常量会在Preparation(准备)阶段被创建,并在Initalization(初始化)阶段被赋上初始值。
而final static常量则有所不同,因为编译器知道它永远不会改变,于是就直接把值放入常量池。例如
static final int MAX = 100;
System.out.println(Config.MAX);
编译后类似:
System.out.println(100);
而不是读取Config.MAX。所以运行时根本不需要加载Config类。
这是因为final static具有编译期确定,且永远无法改变的特点。JVM这样设计可以减少运行时的查找
但是,不是所有的final static都是编译期常量
例如:
public static final String NAME = new String("Tom");
public static final int RANDOM = getValue();
static final Integer C = 10;
这些都不是编译期常量,因为值需要在运行期计算
判断一个变量是不是编译期常量,必须满足三个条件
- static final
- 基本类型或者String
int
long
double
boolean
char
String
- 赋值必须编译期确定
static final int A = 10;//✅
static final String S = "hello";//✅
static final int B = new Random().nextInt();//❌
static final Integer C = 10;//❌
第四阶段:运行时数据区(Runtime Data Area)
学习目标
知道程序运行时,数据都存放在哪里。
需要掌握
程序计数器(Program Counter Register)
Java虚拟机栈(JVM Stack)
本地方法栈(Native Method Stack)
堆(Heap)
方法区(Method Area)
答题区
程序计数器
| 问题 | 答案 |
|---|---|
| 程序计数器作用 | 记录当前线程下一条要执行的字节码地址 |
| 为什么需要它 | 支持程序顺序执行和线程切换后恢复 |
| 为什么线程私有 | 每个线程执行位置不同,必须独立保存 |
| 存储内容 | 字节码指令地址 |
| 是否会OOM | 不会 |
| native方法 | PC为空 |
Java虚拟机栈(JVM Stack)
| 组成 | 作用 |
|---|---|
| 栈帧 | 一个方法执行时的内存结构 |
| 局部变量表 | 保存方法参数和局部变量 |
| 操作数栈 | 字节码计算的临时空间 |
| 动态链接 | 将符号引用解析成真实方法地址 |
| 返回地址 | 方法执行结束后回到哪里继续执行 |
注:动态链接在当前语义中不是指动态链接这个过程,而是当前方法对运行时常量池的引用这个数据结构。
返回地址是一个实际的数据,不是过程。
栈帧
栈帧是方法运行时的基本单位,一个方法对应一个栈帧。
栈帧生命周期
创建
由方法调用来创建:
invokevirtual
invokestatic
invokespecial
invokeinterface
执行时:
创建栈帧
↓
压入虚拟机栈
↓
执行方法
销毁
有两种方式销毁栈帧
- 方法正常返回
例如:
return;
ireturn;
其他return指令
执行return指令时,栈帧出栈,并将返回结果(如果有)压入原操作数栈,程序计数器跳转到预定位置,继续执行下一条指令
- 异常返回
例如:
throw new Exception();
没有找到异常处理:
栈帧被异常弹出
线程结束
局部变量表(Local Variable Table)
保存方法中的
- 参数
- 局部变量
- 对象引用
其本质是一个数组结构。用slot保存变量
例如:
public void test(int a,double b){
int c = 10;
}
局部变量表
slot编号
0 this //普通方法隐藏传入this。static方法不传入this
1 a
2-3 b(double)
4 c
在jvm设计中
int
float
reference
boolean
char
byte
short
占1个slot
long
double
占2个slot
注:普通方法隐藏传入this。static方法不传入this
操作数栈(Operand Stack)
JVM执行字节码指令时进行计算的临时工作区。JVM是一种栈式计算机。
动态链接(Dynamic Linking)
不是简单的把Java源代码转换成常量池索引
更准确的说:
- 编译阶段:Java代码中的类、字段、方法引用会被转化成常量池中的符号引用
- 运行阶段:动态链接负责将这些符号引用解析成为实际的内存地址、方法入口或对象偏移
Java源码
↓ javac
字节码 + 常量池符号引用
↓ JVM运行
动态链接
↓
真实内存地址/方法入口
动态链接是运行时解析过程,不是编译过程。
返回地址
保存:方法执行完成后,应该回到哪里继续执行。
例如:
public static void main(){
int a=10;
add();
System.out.println(a);
}
执行:
main()
执行到:
add();
暂停
创建:
add栈帧
返回地址:
main()中println之前的位置
Native Method Stack本地方法栈
线程
|
|---- Java 方法调用
| ↓
| Java虚拟机栈
|
|---- native 方法调用
↓
本地方法栈
什么是Native方法?
Native 方法是由 JVM 之外的代码实现的方法,通常是 C/C++。这个实现可能属于 JVM 本身,也可能属于操作系统或第三方库。
粗略划分为三类
- 第一类:JVM自己实现的 Native 方法(最常见)
- 第二类:调用操作系统能力的 Native 方法(如调用摄像头)
- 第三类:Java调用第三方C/C++库
和普通Java方法对比
普通方法:
public int add(int a,int b){
return a+b;
}
结构:
.class文件
Code属性
字节码:
iload
iadd
ireturn
JVM执行:
字节码解释器/JIT
Native方法:
public native int hashCode();
结构:
.class文件
method_info
ACC_NATIVE
没有Code属性
执行:
JVM发现native
|
↓
查找JNI绑定
|
↓
执行C/C++函数
Native方法不是“计算机系统自身的方法”,也不是“Java实现的方法”,而是Java提供一个入口,让JVM能够调用外部实现的代码。这个外部代码可能是JVM自己的C++代码,也可能是操作系统或第三方库代码。
什么是本地方法栈?
本地方法栈是 JVM 为执行 Native 方法(本地方法)准备的一块内存区域。
简单理解:
Java 虚拟机栈负责执行 Java 方法,本地方法栈负责执行非 Java 方法。
什么是JNI?
本地方法栈是 JVM 为执行 Native 方法(本地方法)准备的一块内存区域。
简单理解:Java 虚拟机栈负责执行 Java 方法,本地方法栈负责执行非 Java 方法。
示例:
public class Hello {
public native void sayHello();
}
这里的native告诉JVM:不要找Java字节码,去找外部实现。
然后C语言:
void Java_Hello_sayHello(){
printf("Hello JNI");
}
运行:
Hello h = new Hello();
h.sayHello();
流程:
Java调用
sayHello()
↓
JVM发现native
↓
JNI寻找对应C函数
↓
执行C代码
↓
返回Java
Native方法、JNI、本地方法栈关系
Native方法
|
| 通过
↓
JNI
|
↓
本地方法栈
|
↓
C/C++代码
对象为什么要放在堆区
1.对象的生命周期普遍无法确定
如果放在栈区中,方法执行结束栈帧就会被销毁。存活时间很短。
但是,返回后可能仍然需要使用这个对象
User u = createUser();
一个对象的生命周期可能是几小时、几天、甚至整个程序运行周期
所以对象不应该跟着方法栈帧被销毁
2.对象需要动态大小
对象所需要的内存空间大小往往不是确定的。
User user = new User();
JVM不知道:
- User对象多大
- 有多少字段
- 是否继承其他类
对象大小运行时才能确定。
而栈空间更适合int、long这些固定大小的数据。
堆区可以在运行时申请空间,动态扩展。更适合对象
3.方便垃圾回收(GC)
将对象都统一存放在一个位置(堆区)也为了方便垃圾回收。
因为对象的生命周期各不相同。那么这时候就要回收掉已经废弃的对象。
对象集中存放在堆区,而GC周期性扫描这里,回收无引用对象
为什么数组在堆区(Heap)
因为数组的本质也是对象
Java中定义一个数组
int[] arr;
实际上类型:
[ I
代表:int数组对象
所有数组都继承:
java.lang.Object
例如:
int[] arr = new int[10];
等价:
创建一个Array对象
所以:
数组 = 特殊对象
因此放Heap。
堆为什么要分新生代老生代?
如果所有对象都混着放在一起,那么每次GC扫描都需要全部扫描,效率很低。
而大多数对象生命周期很短
例如:
for(int i=0;i<100000;i++){
User u = new User();
}
产生100000个对象,但是99999个马上不用。只有少量对象长期存在。
于是提出:分代思想
把对象按照生命周期分类:
Heap
├── 新生代 Young Generation
│
└── 老年代 Old Generation
什么是新生代
新创建的对象默认进入新生代
new User();
Heap(堆)
└── Young Generation(新生代)
|
├── Eden
|
├── Survivor0
|
└── Survivor1
其中:
- Eden:伊甸园区,新对象出生的地方
- Survivor0 / Survivor1:幸存区,保存经过 GC 后仍然存活的对象
为什么有两个 Survivor?
为什么不是:
Eden
|
↓
Survivor
而是:
Eden
|
↓
Survivor0
↔
Survivor1
原因:
JVM 使用复制算法(Copying Algorithm)
复制算法思想
假设只有一个 Survivor:
Survivor
A
B
C
D
下一次 GC需要删除垃圾:
A 存活
B 死亡
C 存活
D 死亡
如果直接删除会产生内存碎片。
A
C
空洞
所以 JVM 使用两个 Survivor:
假设:
Survivor0
A
B
C
D
GC:
复制存活对象:
Survivor1
A
C
然后:
清空 Survivor0
变成:
Survivor0
空
Survivor1
A
C
下一次:
交换角色:
Survivor1
|
↓
Survivor0
所以两个 Survivor 会轮流作为:
- From 区
- To 区
什么是老生代
什么对象进入老年代?
① 长时间存活对象
例如:
User user = new User();
while(true){
use(user);
}
这个对象一直存在。
经历多次Minor GC后仍然存活,晋升 Old Generation
② 大对象
例如:
byte[] data = new byte[100MB];
这种对象年轻代放不下,可能直接进入老年代。
老生代的GC频率低,但是耗时长
常见GC触发时机
| GC类型 | 主要触发条件 |
|---|---|
| Minor GC | Eden空间不足(最常见) |
| Minor GC | 大对象分配失败 |
| Minor GC | Survivor空间不足 |
| Major GC | 老年代空间不足 |
| Full GC | 老年代不足 |
| Full GC | Metaspace不足 |
| Full GC | System.gc()请求 |
| Full GC | JVM退出 |
| 名称 | 范围 |
|---|---|
| Minor GC | 新生代 |
| Major GC | 老年代 |
| Full GC | 整个堆 |
方法区
方法区是 JVM 运行时数据区的一部分,用于存储类级别的信息。
简单理解:
堆存对象,栈存方法执行过程,方法区存类的信息。
例如:
public class User {
private static int count = 0;
private String name;
public void hello(){
System.out.println("hello");
}
}
当 JVM 加载 User.class 时:
User类的信息 → 方法区count静态变量 → 方法区name字段描述 → 方法区hello()方法字节码 → 方法区new User()对象 → 堆
什么是类元信息
类元信息就是描述一个类本身的数据
类元信息来自User.class
加载:
User.class
↓ ClassLoader
方法区
+-----------------------+
| User类元信息 |
| |
| 类名 |
| 父类 |
| 接口 |
| 字段 |
| 方法 |
| 常量池(运行时常量池) |
| 方法字节码 |
+-----------------------+
静态变量(static变量)
public class Counter {
static int count = 10;
}
一般来说,static变量放到方法区保存。因为static属于类,而不是对象
方法区:
static变量
这是历史说法。
实际:JDK7:
方法区
|
+-- static变量
JDK8以后:
HotSpot:
元空间 Metaspace
|
+-- 类元信息
堆
|
+-- static变量引用的对象
例如:
static User user = new User();
实际上:
方法区:
User类信息
堆:
User对象
static变量保存对象地址
所以:
静态变量逻辑上属于方法区,具体实现JDK8后有所变化。
常量池(Constant Pool)
分为两种
- Class文件常量池
- 运行时常量池(Runtime Constant Pool)
Class文件常量池
也叫:
Constant Pool
它存在于:
.class文件
例如:
public class Test {
String name="Tom";
}
编译:
Test.class
里面:
constant_pool:
#1 Test
#2 java/lang/Object
#3 name
#4 Tom
#5 String
...
它保存:
- 类名
例如:
java/lang/String
- 方法名
例如:
println
- 字段名
例如:
username
- 字符串字面量
例如:
"hello"
- 符号引用
例如:
System.out.println()
编译阶段不知道真实地址:
保存:
java/lang/System
out
println
运行时解析。
运行时常量池(Runtime Constant Pool)
运行时常量池(Runtime Constant Pool)就是 JVM 在类加载时,将
.class文件中的常量池信息加载到内存后形成的运行时数据结构。
但是它不是简单复制一份,而是 JVM 解析、转换后的结构。
类加载后:
.class文件
↓
加载
↓
方法区
↓
运行时常量池
例如:
class文件:
Constant Pool
加载后结构:
方法区(Method Area)
+----------------------+
| User类元信息 |
| |
| 类信息 |
| 字段信息 |
| 方法信息 |
| |
| Runtime ConstantPool|
| |
+----------------------+
这里的:
Runtime Constant Pool
就是运行时常量池。
字符串常量池(String Pool)
设计目的:复用相同内容的字符串,减少内存浪费。
字符串常量池专门保存需要被共享的String对象
例如:
String a="hello";
执行:
JVM检查:
字符串常量池
有没有"hello"?
如果没有:
创建:
String对象
"hello"
放入字符串池
例如:
String a="hello";
String b="hello";
System.out.println(a==b);
结果:
true
原因:
字符串池:
+---------+
| hello |
+---------+
↑
|
a---+
b---+
两个变量指向同一个对象。
但是:
String a = new String("hello");
不同
这不是一个需要被共享的String对象,因此不会保存在String常量池
过程:
字符串池:
"hello"
堆:
new String对象
所以:
a=="hello"
结果:
false
第五阶段:对象创建
学习目标
理解 new 到底发生了什么。
需要掌握
执行:
Person p = new Person();
发生什么:
对象内存布局
引用
理解:
答题区
对象创建
在执行一段创建对象的Java代码时
Person p = new Person();
会经过以下过程
1.检查Person类是否已经加载
如果还没有加载,会先调用ClassLoader执行类加载过程。详见: 第三阶段:类加载机制(Class Loading)
注意:
new操作本身会触发类的初始化,但前提是这个类还没有初始化。
如果 Person 已经初始化过,那么后面就不再执行类初始化。
2.JVM在堆中为对象分配一块内存空间
3.JVM将对象的字段初始化为默认值(不是初始值)
4.设置对象头
对象还需要有对象头。
对象头中可能包含:
对象头
├── Mark Word
│ ├── 锁状态
│ ├── GC 信息
│ └── 哈希码等
│
└── Klass Pointer
└── 指向 Person 的类元数据
不同 JVM、不同压缩指针配置下,对象头的具体结构和大小会有所不同。
可以简单理解为:
对象头记录了这个对象自身的一些运行时信息,以及它属于哪个类。
于是对象大概变成:
Person 对象
┌──────────────────────────┐
│ Object Header │
│ │
│ Mark Word │
│ Klass Pointer │
├──────────────────────────┤
│ age = 0 │
├──────────────────────────┤
│ name = null │
└──────────────────────────┘
5.执行构造方法
执行构造方法,这时字段被赋予了初始值。
如
String name = "Tom";
6.返回对象引用
最后:
Person p = new Person();
new Person() 创建并初始化完成对象后,会得到一个对象引用。
然后把这个引用赋值给局部变量 p。
可以理解为:
栈帧
┌───────────────────┐
│ 局部变量表 │
│ │
│ p ────────────────┼──────┐
└───────────────────┘ │
↓
堆中的对象
┌────────────────┐
│ Person │
│ age = 18 │
│ name = "Tom" │
└────────────────┘
这里需要特别注意:
p本身是一个引用变量,它不是对象本身。
对象在内存中的布局结构
一个 Java 对象在内存中
┌──────────────────────────────┐
│ Object Header 对象头 │
│ ├─ Mark Word │
│ └─ Klass Pointer │
├──────────────────────────────┤
│ Instance Data 实例数据 │
│ ├─ int age │
│ ├─ long id │
│ └─ Object name │
├──────────────────────────────┤
│ Padding 对齐填充 │
└──────────────────────────────┘
Object Header 对象头
Object Header 就是对象头,位于对象的最前面。
它主要包含:
Object Header
├── Mark Word
└── Klass Pointer
如果是数组对象,还会额外有:
Array Object
├── Mark Word
├── Klass Pointer
└── Array Length 数组长度
所以可以先记住:
普通对象 = 对象头 + 实例数据 + 对齐填充
数组对象 = (对象头 + 数组长度) + 实例数据 + 对齐填充
Mark Word
Mark Word 是对象头中非常重要的一部分。
它主要用来保存一些与对象运行状态相关的信息。
例如:
- 对象的哈希码
- GC 年龄
- 锁状态
- 偏向锁相关信息(取决于 JVM 版本)
- 线程锁信息
它的一个重要特点是:
Mark Word 的内容会根据对象当前状态动态变化。是JVM 用来记录对象运行时状态的一块空间。
Class Pointer
Klass Pointer 可以理解为:
指向对象所属 Java 类的元数据的指针。
例如:
class Person {
int age;
String name;
}
Person p = new Person();
内存中有:
p
│
▼
Person 对象
┌──────────────────────┐
│ Mark Word │
├──────────────────────┤
│ Klass Pointer ───────┼─────────┐
├──────────────────────┤ │
│ age = 18 │ ▼
├──────────────────────┤ Person Class 元数据
│ name ────────────────┼──► │
└──────────────────────┘ │
│
方法、字段信息等
Klass Pointer 让 JVM 知道:
这个对象到底属于哪个类?
Instance Data 实例数据
Instance Data 就是对象真正保存的实例字段数据。
例如:
class Person {
int age;
long id;
boolean student;
}
创建:
Person p = new Person();
对象内部可以粗略理解为:
Person 对象
┌──────────────────────┐
│ Mark Word │
├──────────────────────┤
│ Klass Pointer │
├──────────────────────┤
│ age │ ← int
├──────────────────────┤
│ id │ ← long
├──────────────────────┤
│ student │ ← boolean
└──────────────────────┘
需要注意:
如果字段是引用类型:
class Person {
String name;
}
对象里面保存的不是整个 String 对象。而是一个引用(reference)
Padding 对齐填充
Padding 就是:为了让对象大小满足 JVM 的内存对齐要求而填充的无意义数据。
例如 JVM 要求对象大小按照:
8 字节对齐
假设一个对象实际占用:
30 字节
那么 JVM 可能填充:
30 + 2 = 32 字节
最终:
┌──────────────────────┐
│ Object Header │
├──────────────────────┤
│ Instance Data │
├──────────────────────┤
│ Padding │
└──────────────────────┘
↓
32 Bytes
Padding 本身不保存业务数据。
它的作用主要是:
让对象的大小满足内存对齐要求,提高内存访问效率,并方便 JVM 管理对象。
对象、引用、地址
对象:真正被创建出来的数据实体。
引用:Java 程序中用于找到、访问对象的抽象变量。
地址:对象在内存中的位置,是底层内存概念。
引用 ≠ 对象;引用 ≠ 必然等于地址。
第六阶段:方法调用
学习目标
理解方法是如何执行的。
需要掌握
栈帧
每个方法调用都会创建:
参数传递
理解:
方法调用指令
答题区
栈帧
每次方法调用时,JVM 会为该次方法执行创建一个栈帧(Stack Frame)。
一个栈帧主要包含
| 栈帧组成 | 作用 |
|---|---|
| 局部变量表 | 保存方法参数和局部变量 |
| 操作数栈 | JVM 执行字节码指令时的临时工作区 |
| 动态链接 | 支持当前方法的字节码中对其他方法、字段的符号引用解析 |
| 方法返回地址 | 方法执行结束后,告诉 JVM 应该回到哪里继续执行 |
参数传递
值传递就是:
调用方法时,把变量中保存的值复制一份,传递给方法的参数。
Java 只有值传递。
对于基本类型,传递的是基本类型值的副本。
对于对象类型,传递的是对象引用值的副本。
因为复制后的引用值仍然指向同一个对象,所以可以通过这个引用修改对象的内容,但无法通过修改参数本身,让调用者的引用变量指向另一个对象。
示例1:传递普通基本类型
public static void change(int x) {
x = 100;
}
public static void main(String[] args) {
int a = 10;
change(a);
System.out.println(a); // 10
}
参数传递过程:
a保存10->change(a)->新建x变量->x保存10->x保存100->x变量销毁->print输出(a) 10
示例2:传递对象
class Person {
String name;
}
public static void change(Person p) {
p.name = "Bob";
}
public static void main(String[] args) {
Person person = new Person();
person.name = "Alice";
change(person);
System.out.println(person.name); // Bob
}
- 新建一个Person类型的变量person,保存对于新对象Person()的引用值(此处记为R1)
- 通过引用值,找到堆中的实际对象,并将其name字段赋值为"Alice"
- 调用change函数,将引用值R1复制出一份R2,将R2赋值给新的变量p
- 在change函数内部,根据引用值R2,找到堆中的实际对象,并修改name字段为"Bob"
- 最终print打印出的是,通过引用值R1找到的堆中实际对象的name字段->修改之后的Bob
核心点:能达到类似引用传递的效果,是因为复制的值恰好是对象的引用。因此R1和R2指向同一个对象,所以看起来可以通过引用传递修改对象
方法调用invoke
第七阶段:Java 内存模型(JMM)
注意:这里的 JMM(Java Memory Model)不是 JVM 内存结构。
学习目标
理解多线程为什么可见、为什么会乱序。
需要掌握
可见性
原子性
有序性
答题区
首先要区分:
- JVM 内存结构:描述 JVM 运行时有哪些内存区域(堆、栈、方法区等)
- JMM(Java Memory Model):描述多线程环境下,线程之间如何访问共享变量
| 问题 | 原因 | 解决 |
|---|---|---|
| 可见性 | 线程缓存不同步 | volatile、synchronized |
| 原子性 | 操作被切割 | synchronized、CAS、Atomic |
| 有序性 | CPU/编译器重排序 | volatile、锁、happens-before |
JMM的核心模型
主内存(Main Memory)
flag=false
/ \
/ \
线程1工作内存 线程2工作内存
flag=false flag=false
每个线程都有自己的:
- 工作内存
- CPU缓存
- 寄存器
共享变量存放在主内存
线程操作变量,不是直接操作主内存。而是:
主内存
↓
读取
↓
线程工作内存
↓
计算
↓
写回
↓
主内存
JMM 解决三个问题
- 可见性:一个线程修改变量后,其他线程能不能立即看到
- 原子性:一个操作能不能被线程切割
- 有序性:代码执行顺序是不是一定按照编写顺序
可见性
int count=0;
//线程A:
count = 100;
//线程B:
System.out.println(count);
那么,此时线程B看到的是count=0还是count=100?
如果看到旧值,就是不可见。
volatile
volatile 是解决可见性的关键。
例如:
volatile boolean flag=false;
加入 volatile 后,线程修改:
线程1
flag=true
↓
立即刷新主内存
其他线程:
读取flag
↓
重新从主内存读取
所以:
线程1修改
↓
所有线程立即可见
voltile 原理
volatile 主要保证:
1. 写操作立即刷新
普通变量:
线程A
flag=true
↓
工作内存
↓
什么时候写回不知道
volatile:
线程A
flag=true
↓
立即刷新主内存
2. 读操作重新读取
普通:
线程B
读取缓存
volatile:
线程B
重新读取主内存
volatile不能保证原子性
例如:
volatile int count=0;
count++;
在这段代码中,其实线程并不安全。因为从表面上看count++是一步。但实际上分三个步骤
1.读取count
2.count+1
3.写回count
当两个线程同时操作时,如果出现了如下情况,就会引发线程安全问题
线程A:读取0
线程B:读取0
线程A:+1;写入1
线程B:+1;写入1
最终结果就是,count先被线程A写入1,然后又被线程B写入的1给覆盖。最后结果为1,而不是预期的2.
原子性
原子性就是不可分割性
例如count++看起来是一步,其实分为
1.读取count
2.count+1
3.写回count
这样的三步。所以不是原子操作。不符合原子性
synchronized
synchronized是原子性最经典的解决方案
例如:
synchronized(this){
count++;
}
含义:
同一时间只能有一个线程进入。
线程A:
获得锁
count++
释放锁
线程B:
等待
获得锁
count++
结果:2
synchronized原理
其底层依靠对象监视器 Monitor
在堆中,一个对象内部有Mark Word,其中包含着对象的描述信息(包含Monitor)。其特点是会随着程序运行而动态改变。因此可以通过Monitor来判断锁的情况
Mark Word
|
|
Monitor
进入:
monitorenter
退出:
monitorexit
CAS
Compare And Swap 比较并交换。
核心思想:我修改之前,确认别人有没有改过。
例如:
当前count=0
线程A:
读取count==0
准备改成1
执行 CAS(0,1)
即:
如果现在还是0,就修改为1
如果现在已经不是0。
失败
重新尝试
CAS非常重要,很多并发工具底层都是CAS:
例如:
- AtomicInteger
- ConcurrentHashMap
- AQS
Atomic类
例如:
AtomicInteger count =
new AtomicInteger(0);
count.incrementAndGet();
内部不是:
count++
而是:
CAS循环
类似:
while(true){
old=count;
newValue=old+1;
if(CAS(old,newValue)){
break;
}
}
有序性
对于一段代码:
int a=1;
int b=2;
int c=a+b;
程序员认为是从上往下依次执行的。但是实际上,编译器和CPU可能会优化代码:
b=2
↓
a=1
↓
c=a+b
这叫指令重排序
为什么允许重排序?
因为CPU的执行速度很快。如果严格按照代码会有很多等待。例如:CPU等待内存:
读取内存
等待1000ms
而在这期间,CPU可以去执行其他的指令。所以CPU会调整顺序。
重排序引发的问题
经典例子
public class Singleton {
private static Singleton instance;
public static Singleton getInstance(){
if(instance==null){
synchronized(Singleton.class){
if(instance==null){
instance=new Singleton();
}
}
}
return instance;
}
}
例子原意解析:
这个例子是双重检查锁单例(Double Check Locking Singleton),它的目的:
在多线程环境下,保证一个类只能创建一个对象,并且尽量减少加锁带来的性能损耗。
1. 首先理解单例是什么
普通创建对象:
Singleton s1 = new Singleton();
Singleton s2 = new Singleton();
会产生:
堆:
对象1 <--- s1
对象2 <--- s2
两个对象。
但是单例要求:
堆:
对象1
↑
|
instance
s1
s2
s3
所有地方都使用同一个对象。
2. 这个变量是什么?
private static Singleton instance;
它表示:
保存唯一对象的引用。
一开始:
方法区(类变量)
instance
|
|
null
因为还没有创建对象。
3. 第一次检查
if(instance==null)
意思:
有没有创建过对象?
第一次调用:
instance=null
所以进入:
synchronized
4. synchronized做什么?
synchronized(Singleton.class)
意思:
给 Singleton.class 这个对象加锁。
假设两个线程同时调用:
线程A 线程B
getInstance() getInstance()
instance==null instance==null
准备创建对象 准备创建对象
↓
抢锁
只能一个线程进入。
例如:
线程A:
获得锁
线程B:
等待
5.为什么里面还要判断一次?
这里是双重检查锁单例的关键点
当线程A和线程B都过了第一个if检查,并且线程A已经获得锁,线程B陷入等待时。
此时,线程A会正常创建对象。
如果没有第二个if检查,等线程A创建对象结束后。线程B因为已经过了第一个if检查,会接着往下执行,也就是会创建第二个对象。此时,就已经破坏了单例的环境
所以必须要进入锁以后再次判断
此时完整流程:
第一次调用:
线程A
instance == null
↓
进入锁
↓
再次判断
↓
new Singleton()
↓
instance指向对象
↓
释放锁
↓
返回对象
第二次调用时:
线程B
instance != null
直接返回
不用加锁
volatile解决有序性
在上面的例子中,instance=new Singleton();实际上分3步执行
1. 分配对象内存
2. 初始化对象
3. 将引用指向对象
正常:
1
2
3
但是重排序:
1
3
2
结果线程B可能看到:
instance != null
但是此时对象还没有初始化。
此时可以通过加上volatile标识符来解决有序性问题
例如:
private volatile static Singleton instance;
volatile不仅保证可见性还保证禁止相关指令重排序
happens-before
这是JMM最核心规则。
意思是如果A happens-before B,那么A操作的结果一定对B可见。
也就是:
A发生在B之前
A的结果一定能被B看到
常见规则
1. 程序顺序规则
同一个线程:
int a=1;
int b=2;
一定是
1 a=1;
2 b=2;
依次执行
2.锁规则
释放锁unlock()之前的操作一定对之后获得锁的线程可见
例如:
线程A:
lock
x = 100
unlock
线程B:
lock
读取x
一定看到
100
3. volatile规则
写:
volatile变量=值
之前的操作:
对之后读取volatile变量的线程可见。
4. 线程启动规则
线程A:
thread.start();
之前修改的数据
线程B:
run()
可见
示例:
public class Test {
static int value = 0;
public static void main(String[] args) {
value = 100; // 主线程修改
Thread t = new Thread(() -> {
System.out.println(value);
});
t.start(); // 启动线程
}
}
因为有该条规则的保障,value在主线程将值改为100,println打印的结果一定是100
因为value值的修改发生在线程t.start()之前
如果没有该条规则,执行过程可能是:
- 主线程
value=100; value的值在主线程工作内存中发生变化,但是没有刷新到主内存- 然后创建线程t,线程t读取
value到自己的工作内存,结果:0
这就是可见性问题。所以 JMM 规定,调用:
start()
之前的数据修改:
必须对新线程可见。
5.线程结束规则
线程执行结束时:
thread.join();
join之后,其他线程能看到它的所有动作。
第八阶段:垃圾回收(GC)
学习目标
理解对象什么时候会被回收。
需要掌握
判断垃圾
引用类型
垃圾回收算法
GC收集器
答题区
什么是垃圾对象
所谓垃圾:
已经无法被程序继续访问的对象。
例如:
public void test(){
Person p = new Person();
}
执行过程:
进入test()
栈:
p ---------> Person对象
方法结束
栈:
p消失
堆:
Person对象没有任何引用指向
↓
垃圾对象
因为:
程序无法再访问它
所以可以回收。
判断垃圾的两种方式
JVM历史上主要有两种判断方式:
- 引用计数法
- 可达性分析法
目前 Java JVM 使用:
可达性分析法
引用计数法(弃用)
思想
给每个对象维护一个计数器,表示:有多少个引用指向这个对象
例如:
Person p = new Person();
对象:
Person对象
引用数量:
1
内存:
栈
p
|
|
↓
堆
Person
count = 1
再增加一个引用:
Person p2 = p;
变成:
p ----\
\
---> Person
/
p2 ---/
count = 2
如果:
p = null;
那么:
p2
|
↓
Person
count = 1
还不能回收。
如果:
p2 = null;
变成:
Person
count = 0
说明:
没有引用指向它。可以回收。
引用计数法最大的问题:无法解决循环引用
例如:
class A{
B b;
}
class B{
A a;
}
依赖关系
a
|
v
A -----> B
^ |
| |
+--------+
现在即使没有任何外部引用时,他们的count仍都为1 。 因为存在互相引用的关系。结果就是,垃圾无法回收,产生内存泄漏
可达性分析(Reachability Analysis)当下采用的方法
核心思想:
从一些特殊对象开始,如果对象能够沿着引用链找到,就不是垃圾。
如果找不到,就是垃圾。
所谓的特殊对象,也就是GC Roots。GC Roots就是垃圾回收的起点。可以理解为,程序认为一定还活着的对象
GC Roots
常见的GC Roots
1.栈中的局部变量
例如:
public void test(){
Person p = new Person();
}
此时,线程栈中存在p,指向Person对象
2.静态变量
例如
class Test{
static Person p = new Person();
}
结构
方法区
Test.class
static p
|
|
v
Person对象
static变量属于 GC Root。
3.常量引用
例如:
static final Person P =
new Person();
常量池中的引用也是 GC Root。
4.正在运行的线程对象
线程本身:
Thread对象
属于 GC Root。
5.JNI引用
例如:
Java 调用 C:
Java对象
|
JNI
|
C代码
JNI持有的对象引用也属于 GC Root。
JNI:
Java Native Interface(Java本地接口)
它是 JVM 提供的一套机制,用来让:
Java代码调用其他语言(主要是 C/C++)写的本地代码。
可达性分析过程
假设堆
GC Roots
|
|
v
Object A
/ \
v v
Object B Object C
Object D
分析:
从 GC Roots 出发:
GC Roots
|
v
A
|
+----B
|
+----C
这些:
A B C
都是可达对象。
而:
D
没有任何路径:
GC Roots
X
D
所以:
D = 垃圾
引用类型
Java 中对象是否能够被 GC 回收,不只取决于“有没有引用”。
Java 设计了 4种引用强度,强度从高到低依次为:
- 强引用(Strong Reference)
- 软引用(Soft Reference)
- 弱引用(Weak Reference)
- 虚引用(Phantom Reference)
它们的区别:
引用越强,对象越不容易被 GC 回收。
| 引用类型 | 是否阻止GC | 什么时候回收 | 用途 |
|---|---|---|---|
| 强引用 | 是 | 没有引用时 | 普通对象 |
| 软引用 | 部分阻止 | 内存不足时 | 缓存 |
| 弱引用 | 不阻止 | GC时 | 缓存、WeakHashMap |
| 虚引用 | 不阻止 | 对象回收前通知 | 资源管理 |
1.强引用
我们平常写的普通引用,就是强引用
例如:
Person p = new Person();
只要p != null,那么对象就是存活状态
即使内存不足,GC发现强引用也不会回收。除非p == null或引用离开作用域
void test(){
Person p = new Person();
}
2.软引用(Soft Reference)
软引用:
内存足够时保留对象,内存不足时回收。
Java提供:
SoftReference<T>
示例:
Person person = new Person();
SoftReference<Person> ref =
new SoftReference<>(person);
内存充足时,GC不会回收。内存不足时,GC会释放软引用对象,腾出空间
使用场景:
- 缓存
3.弱引用(Weak Reference)
弱引用:
只要发生 GC,就会被回收。
WeakReference<T>
示例:
Person p = new Person();
WeakReference<Person> ref =
new WeakReference<>(p);
注意:如果同时还有强引用:
Person p = new Person();
那么:对象不会回收。
使用场景:WeakHashMap
Java提供:
WeakHashMap
例如:
Map<Key,Object> map =
new WeakHashMap<>();
特点:
如果 Key 没有其他引用:
Key对象
↓
GC
↓
自动删除Entry
典型用途:
缓存、监听器管理。
4.虚引用(Phantom Reference)
虚引用是最弱的引用。
Java:
PhantomReference<T>
特点:
无法通过虚引用获得对象。
会在引用对象被回收时向程序发出通知
示例:
Person p = new Person();
PhantomReference<Person> ref =
new PhantomReference<>(
p,
queue
);
你不能:
ref.get();
因为永远返回:
null
用途:
监听对象什么时候被 GC 回收。
例如:
Person对象
|
|
虚引用
|
|
ReferenceQueue
示例:
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
public class PhantomReferenceDemo {
public static void main(String[] args) throws Exception {
// 创建引用队列
ReferenceQueue<Person> queue = new ReferenceQueue<>();
// 创建对象
Person person = new Person("张三");
// 创建虚引用
PhantomReference<Person> phantomReference =
new PhantomReference<>(person, queue);
// 删除强引用
person = null;
// 手动触发GC
System.gc();
// 等待GC通知
Thread.sleep(1000);
// 从队列获取虚引用
PhantomReference<Person> ref =
(PhantomReference<Person>) queue.poll();
if (ref != null) {
System.out.println("对象已经被GC回收");
// 释放其他资源
// free native memory...
} else {
System.out.println("还没有回收");
}
}
}
class Person {
private String name;
public Person(String name) {
this.name = name;
}
@Override
protected void finalize() {
System.out.println("finalize执行");
}
}
主要用于:
- 管理堆外内存
- 资源释放通知
例如:
DirectByteBuffer
Java对象
|
|
v
本地内存(C)
Java对象被回收后:
通知释放 C 内存。
垃圾回收算法
现在的问题是:
找到垃圾以后,JVM 如何把它清理掉?
主要有四种思想:
- 标记-清除(Mark-Sweep)
- 标记-整理(Mark-Compact)
- 复制算法(Copying)
- 分代收集(Generational Collection)
| 算法 | 过程 | 优点 | 缺点 | 适合 |
|---|---|---|---|---|
| 标记-清除 | 标记垃圾→删除 | 简单 | 碎片多 | 老算法 |
| 标记-整理 | 标记→移动存活对象 | 无碎片 | 移动成本高 | 老年代 |
| 复制算法 | 复制存活对象 | 无碎片,快 | 浪费空间 | 新生代 |
| 分代收集 | 不同区域使用不同算法 | 符合对象生命周期 | 实现复杂 | 现代JVM |
标记-清除算法(Mark-Sweep)
分两个阶段:
第一阶段:标记
找到垃圾对象:
堆:
A对象 存活
B对象 垃圾
C对象 存活
D对象 垃圾
标记:
B ✓
D ✓
第二阶段:清除
删除被标记的对象:
GC前:
[A][B][C][D]
清除:
[A][ ][C][ ]
GC后:
[A][C]
优点
- 简单
- 实现成本低
缺点 - 缺点1:产生内存碎片
例如GC前:
|A|垃圾|B|垃圾|C|垃圾|D|
清除:
|A| |B| |C| |D|
如果此时需要:
new BigObject();
需要连续空间:
| BigObject |
虽然总空闲空间够:
空闲:
2KB + 3KB + 5KB
=10KB
但是:
没有连续10KB
仍然无法分配。
- 缺点2:效率低
需要:
遍历所有对象
+
删除垃圾对象
堆越大越慢。
标记-整理算法(Mark-Compact)
标记整理解决碎片问题
第一步:标记
和标记清除一样
|A|垃圾|B|垃圾|C|垃圾|D|
第二步:整理
把存活对象移动到一端
[A][B][C][垃圾][垃圾]
然后清理垃圾所占的空间
优点
- 解决了内存碎片问题
- 分配对象效率高
缺点 - 需要移动对象。移动对象意味着
- 修改引用地址
- 消耗CPU
适用场景:老年代
因为老年代对象存活时间长,移动频率低
复制算法(Copying)
复制算法思想:
不在原空间清理,而是把存活对象复制到另一块空间。
假设两个区域:
From区 To区
[对象A]
[垃圾]
[对象B]
[垃圾]
空
GC:
把存活对象复制:
From区 To区
[对象A] ---> [对象A]
[垃圾]
[对象B] ---> [对象B]
[垃圾]
然后:
清空From区
优点
- 没有空间碎片。因为复制过去的空间自然是连续排列的
缺点 - 浪费空间。需要两块内存
From区和To区。但是实际只能使用一半
适用场景:新生代。
因为新生代对象大部分生命周期很短。
创建很多的新生代对象,GC后大部分都会死亡,只有少部分存活。复制成本低
分代收集算法(Generational Collection)
这是 JVM 实际采用的思想。
注意:
分代收集不是一种新的算法,而是根据对象生命周期不同,组合不同算法。
新生代:采用复制算法
老生代:采用标记-整理或标记-删除
GC收集器
上面描述的几种算法,由GC收集器来做为执行者
GC收集器就是JVM中真正负责垃圾回收的模块
不同收集器:
- 使用不同的垃圾回收算法
- 适合不同场景
- 关注不同目标(吞吐量、低延迟、内存占用)
| 收集器 | 线程 | 目标 | 算法 | 适合 |
|---|---|---|---|---|
| Serial | 单线程 | 简单稳定 | 复制+整理 | 小程序 |
| Parallel | 多线程 | 高吞吐 | 复制+整理 | 批处理服务器 |
| CMS | 并发 | 低停顿 | 标记清除 | 老服务器(已废弃) |
| G1 | 并发+分区 | 可预测低延迟 | 复制+整理 | 现代服务器 |
| ZGC | 高度并发 | 极低延迟 | 染色指针等 | 超大堆 |
Serial 收集器
单线程垃圾收集器。
GC时:
Java线程
|
v
暂停
GC线程
|
v
回收垃圾
这个过程叫:Stop and World(STW)
使用算法
新生代:复制算法
老生代:标记-整理
结构:
Young
|
|-- Serial
Old
|
|-- Serial Old
优点
- 简单
- 没有线程竞争
- 效率稳定
缺点 - 暂停时间长。因为只有一个GC线程
使用场景 - 小内存程序
- 单核机器
- 客户端程序
Parallel 收集器
并行垃圾收集器。
与Serial最大的区别就是
Parallel:
GC线程1
GC线程2
GC线程3
GC线程4
一起GC
结构
JVM
|
v
多个GC线程
/ | | \
GC GC GC GC
目标
Parallel关注吞吐量
吞吐量:业务运行时间/总运行时间
例如:
程序运行100秒
业务执行95秒
GC5秒
则吞吐量:95%
使用算法
新生代:复制算法
老生代:标记-整理
优点
- 吞吐量高
- 适合后台计算任务
缺点 - 暂停时间仍然存在
使用场景
服务器: - 批处理系统
- 大数据计算
- 离线任务
CMS 收集器(了解)
Concurrent Mark Sweep
中文:并发标记清除
它是早期为了降低停顿时间设计的。
核心目标:
让 GC 尽量和用户线程同时运行。
CMS流程
1.初始标记
暂停业务:Stop The World
快速找到GC Roots
GC Roots
|
v
对象
时间短
2.并发标记
用户线程继续
业务线程
---------------->
GC线程
---------------->
扫描对象
二者同时进行
3.重新标记
重新暂停:Stop The World
修正并发标记期间产生的变化
4.并发清除
GC线程清理:垃圾对象删除
用户线程继续
优点
- 低延迟
- 暂停时间短
缺点 - 会产生内存碎片。因为采用了
标记-清除 - CPU占用高。因为GC线程和业务线程同时运行。对CPU压力大
- 浮动垃圾。并发清理期间,业务线程继续创建垃圾
现状
Java9以后,CMS已经废弃,被G1收集器替代
G1收集器(重点)
G1:
Garbage First
中文:
垃圾优先收集器。
它是现在服务器 JVM 常用收集器。
最大变化
以前的堆:
Young
-------------
Eden Survivor
Old
-------------
区域固定。
G1把堆划分成很多小块:
Heap
+---+---+---+
|R1 |R2 |R3 |
+---+---+---+
|R4 |R5 |R6 |
+---+---+---+
|R7 |R8 |R9 |
+---+---+---+
每个 Region可能是:
Eden
Survivor
Old
Humongous
每次 GC优先选择:
垃圾最多的 Region。
例如:
Region A
垃圾90%
Region B
垃圾20%
优先回收:
Region A
G1算法
整体:标记-整理
局部:复制算法
例如,回收一个Region:
Region A
[A][垃圾][B]
复制存活对象
↓
Region C
[A][B]
清空Region A
优点
- 可预测停顿。例如可以设置
-XX:MaxGCPauseMillis=200希望GC暂停不要超过200ms - 大堆友好。适合40GB+。几十GB堆
缺点 - 实现复杂
- CPU开销较高
ZGC(了解)
Z Garbage Collector
目标:极低延迟
特点:暂停时间<10ms,甚至几毫秒
核心特点
- 大堆支持。可以支持
TB级堆 - 几乎并发完成GC
传统:
标记
暂停
清理
暂停
ZGC的大量工作:
GC线程
和
业务线程
同时进行
- 使用染色指针
在对象引用地址中加入GC信息
例如:
普通引用:
0x123456
ZGC:
0xAB123456
里面携带对象状态
缺点
- 实现复杂
- 对硬件和JDK版本要求较高
第九阶段:JIT 编译
学习目标
理解 JVM 为什么越来越快。
需要掌握
答题区
Interpreter(解释器)
解释器是一种一边读取字节码,一边执行字节码的执行方式
Python是其中的典型代表,即边翻译边执行。
与编译器的先整体翻译,再执行的方式有明显区别
| 对比点 | 解释器 | 编译器 |
|---|---|---|
| 英文 | Interpreter | Compiler |
| 执行方式 | 逐条解释 | 整体翻译 |
| 输入 | 字节码 | 字节码/源码 |
| 输出 | 无 | 机器码 |
| 启动速度 | 快 | 慢 |
| 运行速度 | 慢 | 快 |
| 优化能力 | 低 | 高 |
| 适合 | 短程序 | 长期运行程序 |
JIT(Just In Time Compiler)
中文:即时编译器
JIT的思想是,把经常执行的字节码,直接编译成本地机器码
例如:
public void test(){
for(int i=0;i<100000;i++){
test(i);
}
}
刚开始是解释器执行。过了段时间,重复次数超过某个阈值后,JVM发现test()调用次数很多。于是将其编译成机器码:
test()
↓
JIT编译
↓
机器码
编译以后,CPU直接运行机器码,速度大幅提高。
热点代码(Hot Code)
JIT不会把所有代码都编译。因为大部分情况下,80%的时间花在20%的代码上
很多代码都只是执行一次,没必要编译。
所以JVM会寻找经常执行的代码,称为热点代码
热点代码主要分为两种
1.被大量调用的方法
例如:
for(int i=0;i<100000;i++){
test();
}
test()调用了100000次,成为热点。
2.执行次数很多的循环
例如:
while(true){
}
循环体执行次数巨大,也是热点
JVM通过计数器统计,例如
- 方法调用计数器
- 循环回边计数器
一旦达到阈值,就会触发JIT。
回边计数器(Back Edge Counter):
每goto while_start向前跳转一次,计数器增加
C1编译器
C1:Client Compiler
特点
- 快
- 编译时间短
- 优化程度低
适用场景 - 桌面程序
- 启动速度要求高
例如:
启动Java程序
|
v
C1快速编译
|
v
马上获得性能提升
C1优化比较简单,如方法内联。
示例:
add(){
return a+b;
}
main(){
add();
}
优化为
main(){
return a+b;
}
C2编译器
C2:Server Compiler
特点
- 编译慢
- 优化程度高
- 运行性能最好
适用场景 - 服务器程序
- 长时间运行程序
示例:
SpringBoot
启动
↓
解释执行
↓
热点出现
↓
C2优化
↓
长期高速运行
C2优化很多:
1.逃逸分析
判断对象是否出逃方法
例如:
public void test(){
Person p = new Person();
}
如果发现p只在方法内部调用,可能不创建堆对象,直接在栈上分配
2.标量替换
对象
Person{
int age;
String name;
}
拆成
age变量
name变量
减少对象创建。
3.循环优化
例如
for(int i=0;i<100;i++){
}
优化执行方式
- 循环展开(Loop Unrolling)
- 循环不变量外提(Loop Invariant Code Motion)
- 范围检查消除(Range Check Elimination)
- 循环删除(Loop Elimination)
- 循环向量化(Loop Vectorization)
详细内容自己了解,这里不作为重点展开讲解
| 比较点 | C1 | C2 |
|---|---|---|
| 名称 | Client Compiler | Server Compiler |
| 速度 | 快 | 慢 |
| 优化 | 少 | 多 |
| 编译时间 | 短 | 长 |
| 目标 | 快速响应 | 最高性能 |
| 适合 | 客户端 | 服务器 |
| HotSpot | 存在 | 存在 |
C1和C2通常不是2选1,而是分层编译
字节码
|
v
解释器
|
热点发现
|
v
C1编译
|
继续运行
|
v
C2编译
|
v
高性能机器码
OSR(On-Stack Replacement,栈上替换)
他解决的问题是:一个正在执行的长时间运行方法,如何在不退出方法的情况下,从解释执行切换到JIT编译后的机器码执行
public class Test {
public static void main(String[] args) {
long sum = 0;
while (true) {
sum++;
if(sum == 10000000000L){
break;
}
}
}
}
运行一段时间后,JVM就会发现这个while循环执行的次数非常多。于是判断:这是热点代码,然后JIT开始编译,生成while循环对应的机器码。
那么问题来了: 现在程序在哪里?
答:它已经在循环结构里面了。即现在已经执行到一半了,
线程栈
main方法栈帧
----------------
局部变量:
sum = 500000
当前执行位置:
while循环中间
----------------
解释器正在执行
如果没有OSR,JVM就只能退出main方法,重新调用main方法,然后执行JIT代码。
但是如果这样做,局部变量sum、循环位置、程序计数器PC这些执行状态都会丢失。
所以需要在当前栈帧上直接替换执行代码,这就是OSR
OSR的核心思想:
把正在执行的解释器栈帧,替换成对应的JIT编译栈帧。
原来:
解释器版本
|
|
v
+----------------+
| main()栈帧 |
| |
| sum=500000 |
| |
| while循环 |
| |
| 字节码PC位置 |
+----------------+
替换:
JIT版本
+----------------+
| main()栈帧 |
| |
| sum=500000 |
| |
| 机器码执行位置 |
+----------------+
状态保持:
sum没有丢
循环位置没有丢
继续执行
OSR具体实现过程
1.解释器执行
开始:
while(i < 1000000000){
i++;
}
JVM:
解释器
|
v
执行字节码
同时维护:回边计数器(Back Edge Counter)
什么叫回边?就是循环跳转。
字节码:
goto while_start
这种向前跳转:
|
v
while_start
每跳一次,计数器增加:
loop_counter++
例如:
loop_counter = 10000
2.发现热点循环
达到阈值,例如:
loop_counter > 10000
JVM认为:
这个循环值得优化
于是:
触发 JIT。
注意:这里和普通方法编译不同。
普通JIT:
整个方法编译
method()
|
v
机器码
OSR:
只编译当前正在执行的循环入口
例如:
原方法:
void test(){
初始化();
while(){
核心计算();
}
结束();
}
OSR可能只编译:
while部分
因为:
热点在那里。
3. JIT生成OSR入口
普通机器码:
入口:
method入口
例如:
test()
|
v
机器码开始
OSR机器码:
入口:
循环内部
例如:
test()
初始化()
|
|
v
OSR入口
|
|
v
编译后的while循环
也就是说:
OSR代码不是从方法第一行开始。
而是:
从循环当前的位置接管。
4. 最关键:状态迁移
这是OSR最核心的部分。
解释器现在拥有:
解释器状态
局部变量:
i = 500000
sum = 999999
操作数栈:
[123]
程序位置:
while循环第500000次
JIT代码需要:
机器状态
寄存器:
RAX = i
RBX = sum
PC = 循环机器码地址
所以 JVM 要做:变量映射
例如解释器:
局部变量表:
slot0:
i
slot1:
sum
转换:
机器寄存器:
RAX = i
RBX = sum
类似:
解释器世界
i
|
|
v
OSR适配层
|
|
v
机器码世界
RAX
这个过程叫:
栈帧重构(frame reconstruction)
5. 切换瞬间发生什么?
假设:
当前:
解释器执行:
while(i<100000)
i++;
执行到:
i=50000
JVM:
发现:
OSR代码已经准备好了
暂停线程:
线程暂停一下
保存:
i=50000
sum=xxx
当前循环位置
然后:
创建新的栈帧状态:
JIT栈帧:
i=50000
sum=xxx
PC:
机器码循环入口
然后:
继续:
CPU执行机器码
用户代码感觉:
没有暂停
循环继续运行
6. OSR和普通JIT编译区别
| 比较点 | 普通JIT | OSR |
|---|---|---|
| 编译对象 | 整个方法 | 方法中的热点循环 |
| 进入位置 | 方法入口 | 循环中间 |
| 发生时间 | 调用方法时 | 方法已经运行时 |
| 是否退出方法 | 可以重新进入 | 不退出 |
| 目的 | 优化未来调用 | 优化当前执行 |
第十阶段:JVM 调优
学习目标
能够分析线上 JVM 问题。
JVM参数
常用工具
常见问题
答题区
JVM参数
| 参数 | 作用 | 控制对象 |
|---|---|---|
| -Xms | 初始堆大小 | Heap |
| -Xmx | 最大堆大小 | Heap |
| -Xmn | 新生代大小 | Young Generation |
| -Xss | 线程栈大小 | JVM Stack |
| -XX:+PrintGC | 打印GC日志 | GC |
| -XX:+UseG1GC | 使用G1垃圾收集器 | GC算法 |
1. -Xms 初始堆大小
设置 JVM 启动时堆内存大小。
示例:
java -Xms512m -jar app.jar
含义:
JVM启动时:
Heap = 512MB
2. -Xmx 最大堆大小
限制 JVM 最大堆内存。
示例:
java -Xmx2g -jar app.jar
含义:
Heap最大只能增长到:
2GB
3. -Xmn 新生代大小
设置 Young Generation 大小。
示例:
java -Xms4g -Xmx4g -Xmn1g -jar app.jar
表示:
整个堆:
4GB
其中:
Young Generation:
1GB
Old Generation:
约3GB
4. -Xss 线程栈大小
设置每个线程 Stack 大小。
示例:
java -Xss1m -jar app.jar
表示:
每个线程:
JVM Stack = 1MB
例如:
100个线程:
100 × 1MB
≈ 100MB 栈空间
5. -XX:+PrintGC
打印 GC 日志。
示例:
java -XX:+PrintGC -jar app.jar
运行时可能输出:
[GC (Allocation Failure) 1024K->512K(2048K), 0.002 secs]
表示发生了一次 GC。
6. -XX:+UseG1GC
指定使用 G1 垃圾收集器。
示例:
java -XX:+UseG1GC -jar app.jar
表示:
JVM使用:
G1 Garbage Collector
实际生产常见组合
例如一个 Spring Boot 服务:
java \
-Xms4g \
-Xmx4g \
-Xmn2g \
-Xss1m \
-XX:+UseG1GC \
-Xlog:gc* \
-jar app.jar
含义:
堆:
4GB
新生代:
2GB
线程栈:
每线程1MB
垃圾收集器:
G1
GC日志:
开启
查看 JVM 参数是否生效
启动后:
jps
找到 PID:
例如:
12345 app.jar
查看参数:
jcmd 12345 VM.flags
输出类似:
-XX:+UseG1GC
-XX:InitialHeapSize=4294967296
-XX:MaxHeapSize=4294967296
-XX:ThreadStackSize=1024
可以确认 JVM 实际采用的配置。
常用工具
| 问题 | 工具 |
|---|---|
| 查看有哪些 Java 进程 | jps |
| 查看线程运行情况、死锁 | jstack |
| 查看堆内存、对象数量 | jmap |
| 查看 GC、类加载、内存变化 | jstat |
| 综合诊断 JVM | jcmd |
| 图形化监控 JVM | jvisualvm |
| 在线诊断生产环境 | Arthas |
1. jps(Java Process Status)
作用
查看当前机器上的 Java 进程。
类似 Linux:
ps
但是专门针对 JVM。
命令
jps
示例:
12345 BackendApplication
23456 org.apache.catalina.startup.Bootstrap
表示:
| PID | 程序 |
|---|---|
| 12345 | Spring Boot应用 |
| 23456 | Tomcat |
查看详细信息
jps -l
输出:
12345 com.example.backend.BackendApplication
显示完整类名。
常见用途
后面的 JVM 工具都需要 PID:
jstack 12345
jmap 12345
jstat 12345
所以通常第一步:
jps
2. jstack(Java Stack Trace)
作用
查看 JVM 中所有线程的运行状态。
对应 JVM:
线程
|
JVM Stack
|
栈帧
命令
jstack PID
例如:
jstack 12345
输出:
"http-nio-8080-exec-1"
java.lang.Thread.State: WAITING
at java.lang.Object.wait()
at com.example.Service.run()
常见用途
(1)查看死锁
例如:
线程 A:
持有锁1
等待锁2
线程 B:
持有锁2
等待锁1
执行:
jstack 12345
可能发现:
Found one Java-level deadlock
(2)排查 CPU 占用过高
例如:
while(true){
}
导致线程:
RUNNABLE
通过:
jstack PID
找到异常线程。
3. jmap(Java Memory Map)
作用
查看 JVM 堆内存信息。
对应:
Heap
Young Generation
Old Generation
查看堆信息
命令:
jmap -heap PID
示例:
jmap -heap 12345
输出:
Heap Configuration:
MaxHeapSize = 4294967296
Heap Usage:
eden space:
80%
old space:
40%
查看对象数量
命令:
jmap -histo PID
示例:
num instances class
1: 500000 java.lang.String
2: 300000 byte[]
3: 100000 User
可以发现:
- 哪些对象数量最多
- 是否存在异常对象增长
导出堆 Dump 文件
命令:
jmap -dump:format=b,file=heap.hprof PID
生成:
heap.hprof
可以使用:
- Eclipse MAT
- VisualVM
分析对象引用关系。
4. jstat(JVM Statistics Monitoring Tool)
作用
实时查看 JVM 运行状态。
主要用于:
- GC情况
- 新生代变化
- 老年代变化
- 类加载情况
查看 GC 信息
命令:
jstat -gc PID
示例:
jstat -gc 12345
输出:
S0C S1C EC OC
1024 1024 4096 8192
YGC YGCT
100 2.5
常见字段
| 缩写 | 英文全称 | 含义 | 对应JVM区域 |
|---|---|---|---|
| S0C | Survivor 0 Capacity | Survivor区0容量 | 年轻代 |
| S1C | Survivor 1 Capacity | Survivor区1容量 | 年轻代 |
| S0U | Survivor 0 Used | Survivor区0已使用 | 年轻代 |
| S1U | Survivor 1 Used | Survivor区1已使用 | 年轻代 |
| EC | Eden Capacity | Eden区容量 | 年轻代 |
| EU | Eden Used | Eden区已使用 | 年轻代 |
| OC | Old Capacity | 老年代容量 | 老年代 |
| OU | Old Used | 老年代已使用 | 老年代 |
| MC | Metaspace Capacity | 元空间容量 | 方法区 |
| MU | Metaspace Used | 元空间已使用 | 方法区 |
| CCSC | Compressed Class Space Capacity | 压缩类空间容量 | 元空间 |
| CCSU | Compressed Class Space Used | 压缩类空间已使用 | 元空间 |
| YGC | Young GC Count | 年轻代GC次数 | GC统计 |
| YGCT | Young GC Time | 年轻代GC总耗时 | GC统计 |
| FGC | Full GC Count | Full GC次数 | GC统计 |
| FGCT | Full GC Time | Full GC总耗时 | GC统计 |
| CGC | Concurrent GC Count | 并发GC次数 | G1相关 |
| CGCT | Concurrent GC Time | 并发GC总耗时 | G1相关 |
| GCT | GC Time Total | GC总耗时 | GC统计 |
持续监控
每秒刷新一次:
jstat -gc 12345 1000
表示:
PID
每1000ms刷新一次
可以观察:
YGC
100
101
102
判断 Minor GC 是否频繁。
5. jcmd(JVM Command)
作用
JDK 官方推荐的综合诊断工具。
相比:
jstack
jmap
jstat
功能更加统一。
查看 JVM 信息
命令:
jcmd PID VM.info
示例:
jcmd 12345 VM.info
查看 JVM 参数
命令:
jcmd PID VM.flags
输出:
-XX:+UseG1GC
-XX:MaxHeapSize=4294967296
查看线程信息
命令:
jcmd PID Thread.print
类似:
jstack PID
查看堆信息
命令:
jcmd PID GC.heap_info
导出堆文件
命令:
jcmd PID GC.heap_dump heap.hprof
6. jvisualvm(Java VisualVM)
作用
Java 图形化监控工具。
启动:
jvisualvm
打开:
VisualVM窗口
可以查看
(1)CPU
查看:
CPU使用率
(2)内存
查看:
Heap:
Eden
Survivor
Old
(3)GC
查看:
GC次数
GC耗时
(4)线程
查看:
RUNNABLE
WAITING
BLOCKED
(5)堆 Dump 分析
打开:
heap.hprof
查看:
- 对象数量
- 引用关系
- 内存泄漏
7. Arthas(阿里开源 JVM 诊断工具)
官方文档:
作用
在线诊断运行中的 Java 程序。
相比:
jstack
jmap
不需要:
- 重启应用
- 停止服务
- 导出大量文件
启动
命令:
java -jar arthas-boot.jar
选择:
1. BackendApplication
进入:
[arthas@12345]
查看线程
命令:
thread
输出:
ID NAME STATE
23 http-worker RUNNABLE
查看方法耗时
例如:
UserService.login()
执行:
trace com.example.UserService login
结果:
login()
|
|-- mysql 200ms
|
|-- redis 50ms
查看方法参数和返回值
命令:
watch com.example.UserService login "{params,returnObj}"
查看:
params:
abc@qq.com
return:
success
查看类信息
命令:
sc UserService
反编译线上代码
命令:
jad com.example.UserService
8. JVM 问题排查流程
第一步:查找 JVM 进程
jps
获取 PID。
第二步:查看 GC 状态
jstat -gc PID
判断:
- Minor GC 是否频繁
- Old 区是否增长过快
第三步:分析线程问题
jstack PID
排查:
- 死锁
- 阻塞
- CPU异常线程
第四步:分析内存问题
jmap
jcmd
排查:
- 对象增长
- 内存泄漏
- 堆使用异常
第五步:线上深入分析
Arthas
查看:
- 方法调用
- 参数
- 返回值
- 性能瓶颈
总结
| 工具 | 作用 | 常用命令 |
|---|---|---|
| jps | 查看 Java 进程 | jps -l |
| jstack | 线程分析 | jstack PID |
| jmap | 堆内存分析 | jmap -heap PID |
| jstat | GC监控 | jstat -gc PID |
| jcmd | 综合诊断 | jcmd PID VM.info |
| jvisualvm | 图形化监控 | jvisualvm |
| Arthas | 生产在线诊断 | java -jar arthas-boot.jar |
常见问题
1. OOM(Out Of Memory)
作用
OOM 表示:
JVM 可用内存不足,无法继续为对象分配空间。
异常:
java.lang.OutOfMemoryError
常见类型
(1)Java Heap Space
最常见:
java.lang.OutOfMemoryError: Java heap space
原因:
堆空间不足。
例如:
List<Object> list = new ArrayList<>();
while(true){
list.add(new Object());
}
对象不断进入:
Heap
Young
|
Old
持续增长
|
达到 -Xmx
|
OOM
(2)GC Overhead Limit Exceeded
异常:
java.lang.OutOfMemoryError:
GC overhead limit exceeded
表示:
GC 花费大量时间,但是释放空间很少。
例如:
GC:
99% 时间用于回收
但是:
只释放 1% 内存
JVM 判断:
回收已经没有意义
抛出异常。
(3)Metaspace
异常:
java.lang.OutOfMemoryError:
Metaspace
原因:
方法区(Metaspace)空间不足。
常见于:
- 动态生成大量类
- 大量 ClassLoader 泄漏
例如:
while(true){
Class.forName("xxx");
}
排查方法
查看堆:
jmap -heap PID
查看对象:
jmap -histo PID
导出堆:
jmap -dump:format=b,file=heap.hprof PID
分析:
- Eclipse MAT
- VisualVM
- Arthas
2. StackOverflowError
作用
表示:
JVM 栈空间不足。
异常:
java.lang.StackOverflowError
常见原因
(1)无限递归
例如:
public void test(){
test();
}
执行:
test()
|
test()
|
test()
...
不断创建栈帧:
JVM Stack
Frame
Frame
Frame
Frame
最终:
StackOverflowError
(2)递归层级过深
例如:
factorial(100000)
递归调用过多。
解决方式
增加线程栈:
-Xss2m
或者:
修改递归逻辑:
- 使用循环替代递归
- 限制递归深度
排查方法
查看线程:
jstack PID
寻找:
at xxx.method()
at xxx.method()
at xxx.method()
重复调用。
3. Full GC 频繁
作用
Full GC:
对整个 Java 堆进行垃圾回收。
包括:
- Young Generation
- Old Generation
- Metaspace
常见现象
日志:
Full GC (Allocation Failure)
表现:
- CPU 高
- 应用停顿
- 请求变慢
常见原因
(1)老年代空间不足
例如:
Old Generation
对象不断进入
↓
空间不足
↓
Full GC
(2)大对象过多
例如:
byte[] data = new byte[100000000];
大量大对象:
直接进入 Old
导致 Old 快速占满。
(3)内存泄漏
对象无法回收:
GC
↓
对象仍然被引用
↓
Old持续增长
↓
Full GC
排查方法
查看 GC:
jstat -gc PID
查看日志:
-Xlog:gc*
查看对象:
jmap -histo PID
4. 内存泄漏(Memory Leak)
作用
内存泄漏:
对象已经不再使用,但是仍然被引用,导致 GC 无法回收。
正常情况
对象:
创建
↓
使用
↓
没有引用
↓
GC回收
内存泄漏情况
对象:
创建
↓
使用结束
↓
仍然存在引用
↓
GC无法回收
↓
堆不断增长
常见原因
(1)静态集合保存对象
例如:
public static List<Object> list =
new ArrayList<>();
不断:
list.add(object);
但是:
list.clear();
没有执行。
(2)缓存无限增长
例如:
Map<String,Object> cache =
new HashMap<>();
不断添加:
cache.put(key,value);
没有过期机制。
(3)监听器未释放
例如:
addListener()
但是没有:
removeListener()
导致对象一直被引用。
排查方法
导出 Heap Dump:
jmap -dump:format=b,file=heap.hprof PID
分析:
- GC Roots
- 对象引用链
- 大对象
工具:
- Eclipse MAT
- VisualVM
- Arthas
5. CPU 飙高
作用
CPU 飙高:
表示:
某些线程消耗大量 CPU 时间。
常见原因
(1)死循环
例如:
while(true){
}
线程状态:
RUNNABLE
持续运行。
(2)频繁计算
例如:
while(true){
calculate();
}
大量 CPU 运算。
(3)频繁 GC
例如:
创建大量对象
↓
频繁 Minor GC
↓
CPU消耗增加
排查流程
第一步:查看 Java 进程
jps
第二步:查看线程
top -H -p PID
找到高 CPU 线程:
例如:
PID
12345
第三步:转换线程 ID
printf "%x\n" 12345
得到:
3039
第四步:查看线程栈
jstack PID
查找:
nid=0x3039
定位代码。
6. 死锁(Deadlock)
作用
死锁:
多个线程互相等待对方释放锁,导致永久阻塞。
示例
线程 A:
synchronized(lockA){
synchronized(lockB){
}
}
线程 B:
synchronized(lockB){
synchronized(lockA){
}
}
执行过程:
线程A:
持有 lockA
等待 lockB
线程B:
持有 lockB
等待 lockA
形成:
A 等 B
B 等 A
程序无法继续。
死锁表现
线程状态:
BLOCKED
应用:
- 请求超时
- 接口无响应
- CPU可能正常
排查方法
使用:
jstack PID
输出:
Found one Java-level deadlock
查看:
Thread-1 waiting for lock
Thread-2 waiting for lock
解决方式
(1)固定锁顺序
错误:
lockA -> lockB
另一个:
lockB -> lockA
改成:
lockA -> lockB
统一顺序。
(2)减少锁嵌套
避免:
synchronized(A){
synchronized(B){
}
}
(3)使用超时锁
例如:
lock.tryLock(
5,
TimeUnit.SECONDS
);
避免永久等待。
JVM 常见问题排查流程
1. 内存问题
OOM
|
jmap
|
Heap Dump
|
MAT分析
2. GC问题
Full GC频繁
↓
jstat
↓
GC日志
↓
调整堆参数
3. CPU问题
CPU高
↓
top
↓
jstack
↓
定位线程
4. 线程问题
死锁
↓
jstack
↓
查看锁等待关系
总结表
| 问题 | 原因 | 排查工具 |
|---|---|---|
| OOM | 堆/方法区空间不足 | jmap、jcmd、MAT |
| StackOverflowError | 递归过深、栈不足 | jstack |
| Full GC频繁 | Old区不足、对象无法回收 | jstat、GC日志 |
| 内存泄漏 | 对象仍被引用 | Heap Dump、MAT |
| CPU飙高 | 死循环、计算密集、GC | top、jstack |
| 死锁 | 线程互相等待锁 | jstack |
第十一阶段:源码阅读(进阶)
推荐阅读顺序
推荐学习实践
每学习一个知识点,都自己写一个最小示例,然后:
javac Demo.java
javap -c -v Demo
结合字节码和 JVM 原理分析代码执行过程。
例如:
学习主线(牢记)
Java源码
│
▼
javac 编译
│
▼
.class 字节码
│
▼
ClassLoader 类加载
│
▼
运行时数据区
│
▼
对象创建
│
▼
方法调用(栈帧)
│
▼
字节码执行
│
▼
JIT 编译
│
▼
GC 垃圾回收
│
▼
JVM 调优
学习建议:不要孤立地背概念,而是始终围绕一段简单的 Java 代码追问:"这一行代码在 JVM 中发生了什么?"
当你能解释new Person()、person.show()、System.out.println()在 JVM 中的完整执行过程时,JVM 的主体知识已经真正串联起来了。
杂项知识补充
Integer缓存池
public static Integer valueOf(int i) {
if (i >= IntegerCache.low &&
i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
这里有一个非常重要的机制:
Integer缓存池默认为:-128~127
范围内的 Integer 不会重复创建。所以:
Integer integer = 100;
实际过程:
Integer.valueOf(100)
|
|
发现100在缓存范围
|
|
从IntegerCache取对象
|
|
返回已有对象引用
也就是说:
可能根本没有创建新的堆对象。
Integer缓存结构
JVM启动时:
加载Integer类:
Integer.class
|
|
初始化静态变量
|
|
创建IntegerCache
|
|
缓存数组
类似:
static final Integer cache[] =
new Integer[256];
里面:
cache[0]
|
+--> Integer(-128)
cache[1]
|
+--> Integer(-127)
...
cache[228]
|
+--> Integer(100)
所以:
Integer a = 100;
Integer b = 100;
实际上:
a
|
|
+----------+
|
v
Integer对象(100)
^
|
+----------+
|
b
两个引用指向同一个对象。
验证:
System.out.println(a == b);
结果:
true
三种包装类缓存
| 类型 | 创建方式 | 是否缓存 | 对象位置 |
|---|---|---|---|
| Integer | Integer.valueOf() | -128~127缓存 | 堆 |
| Double | new Double() | 无缓存 | 堆 |
| Boolean | Boolean.valueOf() | TRUE/FALSE缓存 | 堆 |
数组与集合的区别
| 对比 | 数组 | 集合 |
|---|---|---|
| 本质 | JVM内置结构 | Java类库对象 |
| 底层 | JVM直接管理 | 通常依赖数组/链表/树 |
| 长度 | 固定 | 动态 |
| 存储 | 数据本身 | 对象引用 |
| 基本类型 | 支持 | 不支持,需要包装类 |
| 性能 | 更高 | 有额外开销 |
| 类型检查 | 强 | 泛型约束 |
| 扩容 | 不支持 | 支持 |
| 灵活性 | 低 | 高 |
数组是 JVM 提供的“原始仓库”,它追求性能;集合是 Java 提供的“高级仓库”,它牺牲一点性能换取灵活性和丰富功能。
从 JVM 内存角度看:
int[]是一个特殊的堆对象,里面直接存 int 数据。ArrayList<Integer>是普通堆对象,里面保存Object[],里面保存Integer对象引用。这个区别也是为什么集合会产生更多对象和 GC 压力。
元空间与堆对象的生命周期区别
| 对比点 | 堆 | 元空间 |
|---|---|---|
| 存储内容 | 对象 | 类信息 |
| 区域 | 年轻代+老年代 | 方法区实现 |
| GC | Minor GC、Major GC、Full GC | 类卸载 |
| 大小限制 | -Xmx | MaxMetaspaceSize |
| 存储位置 | JVM管理内存 | 操作系统本地内存 |
新生代、老年代属于堆(Heap)的区域;元空间(Metaspace)不属于堆,而属于方法区(Method Area)的实现。
JVM 内存大致结构:
JVM运行时数据区
├── 堆 Heap
│ ├── 新生代 Young Generation
│ │ ├── Eden
│ │ ├── Survivor0
│ │ └── Survivor1
│ │
│ └── 老年代 Old Generation
│
├── 方法区 Method Area
│ └── 元空间 Metaspace(JDK8之后 HotSpot实现)
│
├── Java虚拟机栈 Stack
│
├── 程序计数器 PC Register
│
└── 本地方法栈 Native Method Stack
所以:
- 堆:存放 Java 对象
- 元空间:存放类相关信息
元空间(Metaspace)的作用
1. 存储类的元数据(Class Metadata)
Java 程序运行时,并不是只有对象需要保存。
例如:
public class User {
private String name;
public void sayHello(){
System.out.println("hello");
}
}
当 JVM 加载 User.class 时,需要保存:
User类
├── 类名 User
├── 父类 Object
├── 实现接口信息
├── 字段信息
│ └── name:String
├── 方法信息
│ └── sayHello()
├── 方法字节码
├── 常量池
└── 注解信息
这些内容不是对象数据,而是描述这个类的信息。
这些信息存放在哪里?
JDK8之前:
永久代 PermGen
JDK8之后:
元空间 Metaspace
2. 存储 Class 对象相关信息
例如:
User user = new User();
内存中实际存在:
堆:
user对象
-------------
name=null
元空间:
User.class信息
-------------
类结构
方法
字段
字节码
注意:
User对象 和 User.class信息 是两个东西。
3. 存储运行时常量池
例如:
String name = "Tom";
编译后:
class文件中:
Constant Pool
"Tom"
User
sayHello
java/lang/Object
加载进入 JVM 后:
部分常量信息进入运行时常量池。
例如:
System.out.println("hello");
字符串:
"hello"
会进入字符串相关区域。
4. 支撑类加载机制
当 JVM 执行:
User user = new User();
第一次使用 User:
JVM需要:
1. 加载 User.class
↓
2. 元空间创建 User 类信息
↓
3. 初始化静态变量
↓
4. 创建 User对象
↓
5. 堆中分配对象空间
例如:
public class User {
static int count = 10;
}
加载:
元空间:
User类信息
count字段
堆:
暂时没有User对象
初始化:
静态变量count=10
元空间为什么从永久代改成了独立空间?
JDK7及以前:
堆
├── 新生代
├── 老年代
└── 永久代(PermGen)
永久代虽然名字叫代,但实际上属于堆的一部分。
问题:
1. 容易发生 OOM
例如:
大量动态生成类:
while(true){
createNewClass();
}
不断生成:
Class1
Class2
Class3
...
Class100000
永久代空间耗尽:
java.lang.OutOfMemoryError:
PermGen space
2. 永久代大小不好设置
例如:
-Xmx2g
堆最大2G
其中:
年轻代
老年代
永久代
永久代占多少?
不好控制。
如果:
永久代设置小:
类多 → OOM
永久代设置大:
浪费内存
元空间的改进
JDK8:
堆:
年轻代
老年代
堆外:
元空间
元空间使用:
本机内存(Native Memory)
例如:
启动:
java -Xmx4g Demo
表示:
Java Heap最大4G
但是:
Metaspace
默认可以继续向操作系统申请。
可以限制:
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=512m
元空间什么时候释放?
当一个类不再被使用:
例如:
ClassLoader loader = new URLClassLoader(...);
loader.loadClass("Test");
产生:
ClassLoader
↓
Test.class
↓
元空间保存类信息
如果:
ClassLoader被GC回收
那么:
Test.class
相关元数据
也可以被回收
所以:
元空间回收依赖:
类卸载(Class Unloading)
Javac编译含有中文字符的Java程序
需要指定编码为UTF-8
javac -encoding UTF-8 HeapOOM.java
Shallow Heap(浅层堆)与Retained Heap(深层堆)
浅层堆(Shallow Heap)
定义
- 浅层堆 = 一个对象自身占用的内存大小
- 不包括该对象引用的其他对象。
简单理解:
"如果只删除这个对象本身,需要释放多少内存?"
深层堆(Retained Heap)
定义
深层堆表示:
一个对象被 GC 回收后,可以释放的全部内存。
也就是:
对象自身大小
+
只能通过它访问到的对象大小
| 比较 | 浅层堆 Shallow Heap | 深层堆 Retained Heap |
|---|---|---|
| 计算对象自己 | ✅ | ✅ |
| 计算引用对象 | ❌ | ✅ |
| 表示对象本身大小 | ✅ | ❌ |
| 表示删除对象后释放空间 | ❌ | ✅ |
| 排查内存泄漏价值 | 一般 | 非常高 |
在MAT中可以通过
Leak Suupects>>System Overview >> Class Histogram来查看各个类的对象数、浅层堆和深层堆
具体JVM问题排查
OOM
1.创建Java源文件
import java.util.ArrayList;
import java.util.List;
public class HeapOOM {
// 模拟缓存
private static List<byte[]> cache = new ArrayList<>();
public static void main(String[] args) throws Exception {
while (true) {
// 每次申请 1MB byte[] data = new byte[1024 * 1024];
// 放入缓存
cache.add(data);
System.out.println(
"当前缓存数量:" + cache.size()
);
Thread.sleep(100);
}
}
}
2.生成class文件
javac -encoding UTF-8 HeapOOM.java
因为java文件内有中文注释,所以需要指定编码
3.预设定JVM参数并启动程序
这里的`是
powershell格式下的换行符。如果采用其他环境,请使用对应换行符。
在powershell里粘贴多行的快捷键是Ctrl+Shift+V
换行版本:
java `
-Xms100m `
-Xmx100m `
-XX:+HeapDumpOnOutOfMemoryError `
-XX:HeapDumpPath=./heapdump.hprof `
HeapOOM
单行版本:
java -Xms100m -Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof HeapOOM
4.1利用heapdump.hprof文件来排查问题
因为在启动的时候,设置了JVM参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof
表示开启功能
- 当JVM发生OOM时,自动生成Heap Dump
- 指定dump文件的保存路径
在程序运行出现OOM后,把生成的dump文件通过MAT打开
打开之后进入Overview界面查看详细情况

然后点击Leak Suspects。它的作用是帮你自动找出“最可能导致内存泄漏的对象”。帮你缩小排查范围
Leak Suspects主要做三件事
- 找占用大量内存的对象
- 分析对象为什么无法回收——GC要看有没有GC Root引用
- 自动生成泄漏报告
然后分析大内存对象的GC Root、深堆层等参数,判断问题出在哪里
4.2 利用JVM自带的工具排查程序运行期间的问题
启动程序后,先用
jps
找到目标进程。例如
38540 HeapOOM
前面的数字是PID,后面的名称是启动主类类名
然后再通过
jstat -gc 38540
查看堆状态
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
0.0 49152.0 0.0 49152.0 327680.0 69632.0 7217152.0 5780480.0 448.0 251.6 128.0 12.9 34 3.142 0 0.000 10 0.032 3.174
重点看:
- EC:Eden区
- OC:老年代总容量(如果不在启动阶段做限制,JVM可能会随着老年代不断增多,自动扩容其大小。老年代无法再扩容时,才会触发FGC)
- OU:老年代使用量
- YGC:Young GC次数
- FGC:Full GC次数
| 缩写 | 英文全称 | 中文名称 | 含义 |
|---|---|---|---|
| S0C | Survivor 0 Capacity | Survivor区0容量 | 幸存者区0(From区)的总容量 |
| S1C | Survivor 1 Capacity | Survivor区1容量 | 幸存者区1(To区)的总容量 |
| S0U | Survivor 0 Used | Survivor区0已使用 | Survivor0当前使用大小 |
| S1U | Survivor 1 Used | Survivor区1已使用 | Survivor1当前使用大小 |
| EC | Eden Capacity | Eden区容量 | 新生代Eden区总容量 |
| EU | Eden Used | Eden区已使用 | Eden区当前使用大小 |
| OC | Old Capacity | 老年代容量 | 老年代总容量 |
| OU | Old Used | 老年代已使用 | 老年代当前使用大小 |
| MC | Metaspace Capacity | 元空间容量 | 元空间(Metaspace)已申请容量 |
| MU | Metaspace Used | 元空间已使用 | 元空间实际使用大小 |
| CCSC | Compressed Class Space Capacity | 压缩类空间容量 | 存放Class元数据的压缩空间容量 |
| CCSU | Compressed Class Space Used | 压缩类空间已使用 | 压缩类空间实际使用大小 |
| YGC | Young GC Count | 年轻代GC次数 | Minor GC发生次数 |
| YGCT | Young GC Time | 年轻代GC总耗时 | 所有Young GC累计耗时(秒) |
| FGC | Full GC Count | Full GC次数 | 完整垃圾回收次数 |
| FGCT | Full GC Time | Full GC总耗时 | Full GC累计耗时(秒) |
| CGC | Concurrent GC Count | 并发GC次数 | 并发垃圾回收次数(主要用于CMS/G1等) |
| CGCT | Concurrent GC Time | 并发GC总耗时 | 并发GC累计耗时(秒) |
| GCT | Garbage Collection Time | GC总耗时 | 所有GC累计耗时 |
调优案例
metaspace导致频繁FGC问题(Full GC)
问题现象:服务频繁出现FGC
原因分析:反射调用导致创建大量DelegatingClassLoader,占用了较大的元空间内存,同时存在内存碎片化现象,导致元空间利用率不高,从而较快达到阈值,触发FGC。
优化策略:
- 适当调大metaspace的空间大小
- 优化不合理的反射调用。例如最常见的属性拷贝工具类BeanUtils.copyProperties可以使用mapstruct替换
优化效果:频繁FGC问题得到解决
CMS内存碎片化导致FGC问题
问题现象:C端核心业务在高峰期服务器发生FGC,导致部分请求超时报错,影响用户体验
原因分析:CMS使用标记清除算法,不再进行任何压缩和整理的工作,意味着老年代随着应用的运行会变得碎片化;碎片过多会影响大对象的分配,虽然老年代还有很大的剩余空间,但是没有连续的空间来分配大对象。长期如此,最终可能会导致FGC的发生。
优化策略:
业务低峰期显式触发FGC,优化内存碎片并压缩堆,降低业务高峰期发生FGC的概率
System.gc(),没有开启-XX:+DisableExplicitGCjmap -histo:live pid
优化效果:业务高峰期基本没有出现FGC
YGC和OLD GC频繁
问题现象:服务器YGC和OLD GC频繁导致TP999耗时较高,YGC每分钟 50次,每次25毫秒,OLD GC几分钟1次,每次200毫秒。
原因分析:该服务要求低延迟,为了YGC较快完成,年轻代设置较小,但是由于年轻代较小,反而导致YGC次数过多,查看GC日志发现,由于动态年龄的因素,大量对象较早晋升到老年代,从而导致OLD GC频繁
优化策略:扩大新生代内存到原来的3倍
优化效果:单次YGC耗时增加了5%,频率降低了60%,服务TP999降低了10ms+,OLD GC频率降级为几小时1次

浙公网安备 33010602011771号