JVM

📅 2026/7/14 22:54:58 👁️ 阅读次数 📝 编程学习
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)的字节码

  1. package(包声明),位于第一行。作用是告诉编译器这个类属于哪个包,决定了编译后的目录结构
  2. import(导入)
import java.util.List;
import java.util.ArrayList;

告诉编译器:后面写 List 时,其实是 java.util.List

否则必须写:

java.util.List<String> list;
  1. 注释:编译后注释全部消失,不保留注释。.class文件中没有注释
  2. Type(类型定义)
    Java中的类型包括
Type
├── class 普通class
├── interface  接口
├── enum  枚举
├── record(Java14+)  记录类,快速定义一个只用于存储数据的类。编译后会变成普通的class类
├── @interface  定义注解。真正理解注解的是 Spring,不是 JVM。JVM 只负责把注解信息保存下来,并通过反射 API 提供给框架读取。

从 JVM 的角度看,它们最终都会被编译成 .class 文件,并由类加载器加载。区别主要体现在 Class 文件中的标志位(Access Flags)和元数据

  • class:普通类标志
  • interface:带有 ACC_INTERFACE
  • enum:带有 ACC_ENUM
  • record:带有 ACC_RECORD(Java 16 起)
  • @interface:本质上是一个特殊的接口,同时带有 ACC_ANNOTATIONACC_INTERFACE
    Class的内部又可以继续拆
public class Student {...
}

里面通常包括

  1. 字段(Field):保存在对象中
private String name;
private int age;
  1. 常量
public static final int MAX = 100;
  1. 构造方法(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的作用?

javacJava Compiler(Java 编译器),它的作用就是:

将人类编写的 .java 源代码编译成 JVM 能够执行的 .class 字节码文件。

javac负责.java->.class。java负责.class->JVM运行

javac的编译流程?

              .java│▼① Lexer(词法分析)│▼② Parser(语法分析)│▼AST│▼③ Semantic(语义分析)│▼④ Annotation Processing│▼⑤ 生成 .class

javac编译时都做了什么?

  1. 词法分析
int a = 3 + 5 * 2;

拆成Token

int
a
=
3
+
5
*
2
;

这时编译器只是把它当做字符。根本不知道

  • 哪个是变量?
  • 哪个先计算?
  • 哪个后计算?
张三 → 一个名字
打 → 一个动词
李四 → 一个名字
  1. 语法分析
    根据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
├──主语:张三
├──谓语:打
└──宾语:李四

已经知道:张三打李四。
而不是:李四打张三。

  1. 语义分析
    语义分析关注这句话有没有实际意义?
    例如
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

语义分析做的是:

  • 建立符号表
  • 绑定变量
  • 解析类型
  • 推导表达式类型
  • 检查访问权限(privateprotected 等)
  • 方法重载解析
  • 泛型类型检查
  • 自动装箱/拆箱处理
  • 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

最后,树还是那棵树,只是节点变得更丰满了

  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都不会处理(小部分除外),如@Service Autowired等,因为该注解的设计并不是为了在编译时的注解处理期运行的。而是会交给程序运行期的SpringBoot去处理。

其中最特殊的是Lombok,上面说了,大部分调用Processor的注解处理会生成新的.java源码文件,这也是官方推荐的方式。但是Lombok会直接调用底层接口,修改AST。这样的设计是由其功能的特殊性而不得不采用的方式。这种方式往往会引起Lombok对不同的Java版本的通用性不高(因Java的内部结构可能会改变)。所以在使用Lombok时要挑选对应的版本。

  1. 字节码生成
    在进行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 的主体知识已经真正串联起来了。