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
最后,树还是那棵树,只是节点变得更丰满了
- 注解处理
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。
第二阶段:Class 文件与字节码
学习目标
学会阅读 JVM 指令。
需要掌握
JVM指令
例如
操作数栈
实践
观察:
分别生成什么字节码。
第三阶段:类加载机制(Class Loading)
学习目标
理解 JVM 如何把 .class 加载到内存。
需要掌握
类生命周期
ClassLoader
双亲委派模型
static
理解:
第四阶段:运行时数据区(Runtime Data Area)
学习目标
知道程序运行时,数据都存放在哪里。
需要掌握
程序计数器(Program Counter Register)
Java虚拟机栈(JVM Stack)
本地方法栈(Native Method Stack)
堆(Heap)
方法区(Method Area)
第五阶段:对象创建
学习目标
理解 new 到底发生了什么。
需要掌握
执行:
Person p = new Person();
发生什么:
对象内存布局
引用
理解:
第六阶段:方法调用
学习目标
理解方法是如何执行的。
需要掌握
栈帧
每个方法调用都会创建:
参数传递
理解:
方法调用指令
第七阶段:Java 内存模型(JMM)
注意:这里的 JMM(Java Memory Model)不是 JVM 内存结构。
学习目标
理解多线程为什么可见、为什么会乱序。
需要掌握
可见性
原子性
有序性
第八阶段:垃圾回收(GC)
学习目标
理解对象什么时候会被回收。
需要掌握
判断垃圾
引用类型
垃圾回收算法
GC收集器
第九阶段:JIT 编译
学习目标
理解 JVM 为什么越来越快。
需要掌握
第十阶段:JVM 调优
学习目标
能够分析线上 JVM 问题。
JVM参数
常用工具
常见问题
第十一阶段:源码阅读(进阶)
推荐阅读顺序
推荐学习实践
每学习一个知识点,都自己写一个最小示例,然后:
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 的主体知识已经真正串联起来了。