Appearance
JVM 执行、类加载、反射与垃圾回收
这篇笔记聚焦 Java 运行时里另一条非常关键的主线:
- 类是怎么被加载进 JVM 的
- 反射到底是什么,它为什么总和框架放在一起讲
- 字节码是怎么执行的
- 垃圾回收到底在回收什么
- 为什么会出现 Full GC 频繁、内存高、停顿长这类问题
如果说 内存划分详解 重点回答的是“内存怎么分区”,那这篇更关注:
程序到底是怎么跑起来的,运行期怎么拿到类信息,又是怎么把对象回收掉的。
1. 从源码到运行,Java 程序到底经历了什么
可以压缩成一条主线:
- 编写
.java源码 - 通过
javac编译成.class字节码 - JVM 加载类
- 运行期通过
Class元信息组织执行,也可能被反射访问 - JVM 执行字节码
- 在运行中分配对象、使用内存、触发垃圾回收
这里的关键点是:Java 程序不是直接由操作系统执行源码,而是先经过字节码和 JVM 这层运行时。
看一张总流程图:
mermaid
flowchart TD
A[Java 源码] --> B[javac 编译]
B --> C[class 字节码]
C --> D[类加载器加载类]
D --> E[JVM 形成 Class 运行时信息]
E --> F[业务代码或框架通过反射访问]
E --> G[JVM 执行字节码]
G --> H[对象分配与垃圾回收]这张图最重要的意思是:
类加载解决的是“类怎么进入 JVM”反射解决的是“运行期怎么读取类信息、创建对象、调用方法”JVM 执行解决的是“字节码怎么真正跑起来”GC解决的是“对象不用了之后怎么回收”
2. 类加载到底是什么
类加载 可以理解成 把类的字节码读入 JVM,并把它转换成 JVM 可以使用的运行时结构。
它不是简单“把文件读进来”,通常涉及:
- 加载
- 链接
- 初始化
2.1 加载
把类字节码读进来,并在内存中形成类的基础表示。
2.2 链接
主要包括:
- 验证
- 准备
- 解析
它解决的是 类定义是否合法、静态变量如何分配、符号引用如何变成直接引用。
2.3 初始化
执行类变量赋值和静态代码块等初始化逻辑。
这里要特别注意:类被加载进 JVM 后,后面的反射能力才真正有了可操作的运行时基础。
3. 双亲委派模型是什么
双亲委派 可以理解成 类加载器在尝试加载某个类时,会把请求交给上层父加载器,只有父加载器处理不了,自己才尝试加载。
它解决的是:
- 避免重复加载
- 保证核心类库加载的一致性
- 提升安全性
虽然现代框架场景里会有一些打破或绕开传统委派路径的设计,但“先问上层,再自己加载”的主线理解仍然很重要。
4. 反射到底是什么
反射 可以理解成 程序在运行期拿到类的结构信息,并基于这些信息动态创建对象、访问字段、调用方法的一套机制。
如果只看一句更直白的话,它解决的是 代码在编译时并不完全写死具体类型,而是把一部分“要操作哪个类、调用哪个方法”的决定延后到运行期。
这也是为什么很多框架都离不开反射。
因为框架经常要做这样的事:
- 根据类名加载类
- 根据注解扫描类和方法
- 根据配置动态创建对象
- 在运行期调用某个方法
4.1 反射和 Class 到底是什么关系
很多人学反射时,一上来就记:
FieldMethodConstructor
但真正更该先记住的是:反射的起点通常是 Class 对象。
当一个类被 JVM 加载后,运行期通常会有一个对应的 Class 对象,里面描述了这个类的很多元信息,例如:
- 类名
- 父类
- 接口
- 构造方法
- 字段
- 方法
- 注解
看一张关系图:
mermaid
flowchart TD
A[类字节码] --> B[类加载器]
B --> C[Class 运行时对象]
C --> D[读取字段信息]
C --> E[读取方法信息]
C --> F[读取构造器信息]
C --> G[读取注解信息]
C --> H[创建对象并调用方法]🌟 所以更具体一点:反射不是凭空操作类,而是通过运行期的 Class 元信息来操作类。
4.2 常见的反射入口有哪些
获取 Class 对象常见有这几种方式:
java
public class ReflectionDemo {
public static void main(String[] args) throws ClassNotFoundException {
Class<String> c1 = String.class;
String text = "hello";
Class<? extends String> c2 = text.getClass();
Class<?> c3 = Class.forName("java.lang.String");
System.out.println(c1 == c2);
System.out.println(c2 == c3);
}
}这 3 种方式的共同点是:最终都会拿到某个类在运行期对应的 Class 对象。
但它们的使用场景不完全一样:
类型名.class:编译期就知道类型,最直接对象.getClass():手里已经有对象实例Class.forName(...):类名来自配置、数据库或外部输入,运行期才确定
4.3 反射最常见的几类操作是什么
拿到 Class 之后,最常见的反射操作通常包括:
- 读取类信息
- 创建对象
- 访问字段
- 调用方法
- 读取注解
例如下面这个例子,把几类常见操作串在一起看:
java
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
public class ReflectionDemo {
public static class User {
private String name;
public User() {
}
public void setName(String name) {
this.name = name;
}
public String greet(String prefix) {
return prefix + name;
}
}
public static void main(String[] args) throws Exception {
Class<User> clazz = User.class;
Constructor<User> constructor = clazz.getDeclaredConstructor();
User user = constructor.newInstance();
Field field = clazz.getDeclaredField("name");
field.setAccessible(true);
field.set(user, "Tom");
Method method = clazz.getDeclaredMethod("greet", String.class);
Object result = method.invoke(user, "Hi, ");
System.out.println(result);
}
}这个例子里,反射做了 4 件事:
- 通过构造器创建对象
- 在运行期找到字段
- 在运行期修改字段值
- 在运行期调用方法
4.4 反射到底解决什么问题
很多初学者会觉得:既然方法和字段名都知道,为什么不直接 new 对象、直接调用方法?
这正是反射最关键的边界。
反射并不是为了替代普通编码方式,而是为了解决:编译时不知道具体类型,但运行时必须根据外部信息、配置或约定来操作对象。
工程里很常见的场景有:
- 容器根据类信息创建 Bean
- 框架扫描注解,决定哪些类需要注册
- 序列化框架根据字段结构读写对象
- ORM 根据实体类和字段映射数据库记录
- 测试框架动态发现并执行测试方法
所以反射的价值,不在于“写业务代码更快”,而在于:它给框架和基础设施提供了运行期动态处理对象的能力。
4.5 反射和注解、动态代理、类加载分别是什么关系
这几个词经常一起出现,但它们不是一回事。
| 概念 | 更准确的含义 | 和反射的关系 |
|---|---|---|
| 类加载 | 把类字节码变成 JVM 可用的运行时结构 | 反射通常建立在类已经加载的前提上 |
| 反射 | 运行期读取类信息并动态操作对象 | 是运行期访问元信息和调用行为的机制 |
| 注解 | 写在类、方法、字段上的元数据 | 框架通常通过反射读取注解 |
| 动态代理 | 在运行期生成代理对象并拦截方法调用 | 底层通常会配合反射或字节码增强 |
可以直接记成:
- 类加载解决“类怎么进 JVM”
- 反射解决“进 JVM 后怎么在运行期认识和操作这个类”
- 注解提供“额外元数据”
- 动态代理提供“运行期增强方法调用的能力”
4.6 反射为什么既强大又常被提醒慎用
反射的优势很明显:
- 灵活
- 动态
- 非常适合框架层和基础设施层
但它也有代价。
4.6.1 可读性和可维护性会下降
普通代码里,调用关系通常很直接:userService.save(user)
而反射里,很多调用关系会变成:根据字符串找方法 -> 运行期 invoke
这会让代码更难追踪。
4.6.2 性能通常不如直接调用
反射调用通常比普通直接调用更慢,它的代价主要来自:
- 运行期查找和校验
- 更难被编译器做静态优化
- 某些场景下还会有访问检查开销
不过工程上要注意的是:反射是否出现在高频核心路径里。
如果只是容器启动、初始化、扫描阶段使用,通常问题不大。 如果在超高频热点调用链里大量使用,就需要更加谨慎。
4.6.3 封装边界可能被绕开
像 setAccessible(true) 这类操作,会突破正常访问控制边界。
这也是为什么反射虽然强大,但更适合:
- 框架层
- 基础设施层
- 测试工具
- 对运行期行为有明确控制的场景
而不适合在普通业务代码里到处滥用。
4.7 反射的常见误区
4.7.1 误区一:反射等于可以拿到一切运行期类型信息
不完全对。
例如 Java 泛型有 泛型擦除,很多具体泛型信息在运行期并不会以你直觉中的方式完整保留。
所以反射很强,但它也不是“运行期全知全能”。
4.7.2 误区二:框架用了反射,所以业务代码也应该多用反射
不对。
框架用反射,通常是在解决“通用、动态、未知类型”的问题。
业务代码如果类型关系是明确的,直接编码通常更清晰、更稳定。
4.7.3 误区三:反射慢,所以一定不能用
也不准确。
更关键的问题是:
- 用在哪里
- 调用频率多高
- 有没有更直接的替代方案
很多框架在启动阶段大量使用反射,但运行阶段会通过缓存、代理或字节码增强来降低开销。
5. JVM 是怎么执行字节码的
可以这样理解:JVM 会把字节码解释执行,或者在热点代码上进一步编译优化后再执行。
这里常见会提到两个词:
解释执行JIT
5.1 解释执行
可以理解成 边读取字节码,边逐条执行。
5.2 JIT
JIT 是 Just-In-Time Compiler,即时编译器。
它可以理解成 把热点代码在运行时编译成本地机器码,以获得更高执行效率。
这也是为什么 Java 虽然不是直接编译成本地二进制,但运行性能依然可以很强。
6. 垃圾回收到底在回收什么
垃圾回收,也就是 GC,可以理解成 把那些已经不再被程序使用、也无法再被访问到的对象所占内存释放出来。
它解决的是:
- 对象生命周期结束后,内存如何回收
- 程序不必手工逐个释放对象
这里要注意:
GC 回收的不是“没用了这个抽象概念”,而是:在可达性分析意义上已经无法再被访问的对象。
7. 可达性分析是什么
可达性分析 可以理解成 从一组被认为始终有效的根对象出发,沿引用关系去判断某个对象是否还能被访问到。
如果一个对象从这些根出发已经走不到了,它才更可能被判定为可回收。
这里的 GC Roots 可以理解成 垃圾回收判断对象是否仍然存活时的起点集合。
常见会包括:
- 栈中的引用
- 方法区中的类静态引用
- 本地方法引用等
8. 为什么垃圾回收会分代
因为对象生命周期通常并不一样。
更常见的现实是:
- 大量对象很快死亡
- 少数对象会长期存活
所以 JVM 往往把回收策略拆开:
- 新生代偏向高频回收短命对象
- 老年代处理更长期存活对象
这就是“分代回收”背后的核心动机。
9. Minor GC、Major GC、Full GC 分别是什么
9.1 Minor GC
通常指新生代上的垃圾回收。
它更常见,也更频繁。
9.2 Major GC
通常更偏向老年代相关回收语境,但不同资料和不同实现里叫法可能不完全统一。
9.3 Full GC
Full GC 可以理解成 一次影响范围更大、通常代价更高的垃圾回收。
它经常意味着:
- 停顿更明显
- 系统吞吐受影响
- 需要进一步排查对象分配和内存使用问题
10. 为什么会出现 Full GC 频繁
常见原因包括:
- 老年代空间压力大
- 大对象分配过多
- 对象存活时间过长
- 内存泄漏
- 参数配置不合理
这里的 内存泄漏 可以理解成 对象实际上已经不再需要,但因为仍被某些引用链持有,导致 GC 无法回收。
它不等于“内存溢出”,但长期积累后很容易进一步引发 OOM。
11. Stop-The-World 是什么
Stop-The-World,常缩写成 STW,可以理解成 垃圾回收或某些运行时操作进行时,应用线程被暂时暂停。
它解决的是运行时一致性问题,但代价就是:应用会出现停顿。
所以 GC 优化很多时候并不只是关心“回收干净没有”,而是更关心:停顿时间能不能接受。
12. JVM 优化到底在优化什么
很多人一听到 JVM 优化,第一反应就是:
- 调堆大小
- 改 GC 参数
- 换垃圾回收器
这些当然都属于 JVM 优化的一部分,但如果先不分目标就直接改参数,往往会越调越乱。
JVM 优化 真正要优化的通常不是某个单独参数,而是下面几类运行时结果:
- 吞吐量:单位时间内能完成多少业务处理
- 延迟:单次请求或任务的响应时间是不是稳定
- 停顿时间:
STW会不会太长 - 内存占用:堆、元空间、直接内存是否合理
- 稳定性:是否频繁
Full GC、OOM、线程堆积
🌟 所以 JVM 优化最容易说错的一句话就是:JVM 优化不是把参数调得越大越好,而是让程序的运行时行为更符合当前业务目标。
例如:
- 对批处理任务,更关注吞吐量
- 对高并发接口服务,更关注延迟和停顿
- 对容器化部署,更关注内存边界和稳定性
12.1 JVM 优化为什么不能脱离业务场景
同样一组参数,在不同业务里效果可能完全不同。
因为影响 JVM 行为的,不只是参数本身,还包括:
- 对象分配速度
- 对象存活时间
- 缓存大小
- 线程模型
- 请求峰值
- 容器资源限制
所以工程上更常见的做法不是“先背一套万能参数”,而是先判断系统到底是在吞吐、停顿、内存还是稳定性上出了问题。
12.2 常见的 JVM 优化目标有哪些
| 优化目标 | 常见现象 | 更该关注什么 |
|---|---|---|
| 降低停顿时间 | 请求偶发抖动、超时尖刺 | GC pause、STW、垃圾回收器选择 |
| 提升吞吐量 | CPU 高、任务处理慢 | 热点代码、对象创建频率、GC 开销占比 |
| 降低内存压力 | 容器频繁被杀、堆使用率长期过高 | 堆大小、缓存、对象存活时间、内存泄漏 |
| 减少 Full GC | Full GC 次数多、停顿明显 | 老年代压力、大对象、长生命周期对象 |
| 提高稳定性 | OOM、元空间打满、线程堆积 | 内存边界、类加载、线程池、直接内存 |
13. 工程上怎么做 JVM 优化
真正有用的 JVM 优化,通常不是“参数大全”,而是“诊断路径 + 调整方向”。
13.1 先判断问题属于哪一类
工程上最常见的几类问题通常是:
- 堆内存过高
Full GC频繁- 单次停顿过长
- CPU 持续偏高
- OOM
- 元空间持续增长
这几类问题虽然都可能和 JVM 有关,但背后的原因并不一样。
例如:
Full GC频繁,可能是对象晋升过快,也可能是缓存太大- CPU 高,可能是 GC 线程忙,也可能是业务线程空转
- OOM,可能是堆不够,也可能是对象根本回收不掉
所以第一步永远不是改参数,而是:把现象分型。
13.2 一条更稳的 JVM 优化排查流程
mermaid
flowchart TD
A[发现系统异常] --> B[看现象类型]
B --> C{主要问题是什么}
C --> D[停顿长或 Full GC 频繁]
C --> E[内存持续上涨或 OOM]
C --> F[CPU 持续偏高]
D --> G[看 GC 日志与回收器行为]
E --> H[看堆分布、对象存活和引用链]
F --> I[区分 GC 线程忙还是业务线程忙]
G --> J[再判断堆大小 代际比例 对象分配模式]
H --> J
I --> J
J --> K[调整参数 代码结构 缓存与线程模型]
K --> L[压测并继续观察]这张图最重要的意思是:
- 先分清现象,再选分析手段
- JVM 参数调整只是最后一段,不是第一步
- 很多所谓 JVM 问题,最后其实会回到对象模型、缓存策略、线程池或业务代码
13.3 JVM 优化时最常看的指标
如果想让优化更可落地,至少要先盯住这些指标:
- 堆使用率变化趋势
- 新生代 / 老年代使用情况
Minor GC与Full GC次数- 单次 GC 停顿时间
- GC 总耗时占比
- 元空间使用量
- 直接内存使用量
- 线程数量和线程状态
这里要特别注意:单看“当前内存高不高”通常不够,还要看它是持续上涨,还是周期性涨跌。
因为:
- 周期性涨跌,很多时候是正常的分配与回收
- 持续上涨且回不去,才更像泄漏或长生命周期对象堆积
13.4 常见的优化方向有哪些
13.4.1 先处理对象分配和存活模式
这往往比直接改参数更重要。
例如:
- 避免在高频链路里创建过多临时对象
- 避免无边界缓存
- 避免大对象频繁创建
- 避免对象被不必要的长引用链持有
很多 Full GC 问题,表面看像是 JVM 参数不合理,实际更像:程序制造了太多该死却死不掉的对象。
13.4.2 再看堆大小是不是合理
堆太小,会导致回收过于频繁。
堆太大,也不一定是好事,因为:
- 占用更多机器内存
- 回收时可能导致更长停顿
- 在容器场景里更容易顶到资源上限
所以堆大小更合理的理解不是:尽量大
而是:在可接受停顿和可接受资源占用之间取得平衡。
13.4.3 根据场景选择合适的垃圾回收器
不同垃圾回收器关注点不同。
可以粗略这样理解:
Serial GC:简单,但更适合小内存或单线程场景Parallel GC:更偏吞吐量G1 GC:很多服务端场景常用,兼顾吞吐与停顿控制- 更低停顿取向的收集器:更适合对延迟敏感的场景
要注意的是:没有哪种 GC 能对所有业务都天然最优。
13.4.4 关注元空间和类加载问题
不是所有 JVM 内存问题都发生在堆里。
例如这些情况,也很常见:
- 动态生成类过多
- 类加载器泄漏
- 代理类不断累积
这类问题更容易把:Metaspace
打满。
所以看到 OOM 时,不能只盯堆。
13.4.5 线程模型也会影响 JVM 表现
很多人会把“线程太多”只理解成 CPU 问题,但它也会影响 JVM 的整体稳定性。
因为线程过多常常会带来:
- 栈内存占用上升
- 上下文切换增加
- 对象分配压力变大
- 请求排队和超时进一步放大
所以 JVM 优化很多时候也会回到:
- 线程池是否合理
- 阻塞点是否过多
- 并发模型是否匹配当前业务
13.5 常用的观察和分析工具
工程上常见的 JVM 观察工具,可以按用途记:
| 工具 | 常见用途 |
|---|---|
jstat | 看 GC 次数、各代内存使用趋势 |
jmap | 导出堆信息、看对象分布 |
jstack | 看线程栈、排查死锁和阻塞 |
jcmd | 更通用的 JVM 诊断命令入口 |
jconsole / visualvm | 图形化观察内存、线程、GC |
arthas | 在线诊断方法热点、类信息、线程和堆问题 |
这些工具真正解决的是:让你别再凭感觉调 JVM,而是拿证据说话。
13.6 一份更实用的 JVM 优化检查清单
如果线上服务出现 GC 抖动、内存高、停顿长,可以按这个顺序自查:
- 最近有没有流量、缓存、线程池、发布行为变化
Minor GC和Full GC的频率有没有异常抬高- 停顿长是集中在 GC,还是集中在业务线程阻塞
- 堆内存是否回收后仍持续上涨
- 老年代压力是不是持续偏高
- 是否存在大对象、长生命周期对象、无边界缓存
- 是否存在类加载器、动态代理、元空间增长问题
- 参数调整后有没有经过压测和再次观察
14. 工程上最常见的几个误区
14.1 误区一:会背内存分区就等于理解 JVM
不够。
还要继续理解类加载、反射、执行模型和 GC 行为。
14.2 误区二:反射就是框架黑魔法,业务里完全不用理解
不对。
很多框架行为,例如:
- Bean 创建
- 注解扫描
- 动态代理
- 序列化映射
背后都离不开反射或和反射相邻的运行期机制。
14.3 误区三:JVM 优化就是调参数
不对。
JVM 参数当然重要,但很多问题最终会落回:
- 对象分配模式
- 缓存策略
- 线程模型
- 类加载行为
如果程序本身一直制造高压力对象,只改参数通常只能延后问题爆发。
14.4 误区四:GC 一定越频繁越差
不完全对。
关键要看:
- 回收发生在哪一代
- 单次停顿多久
- 吞吐和延迟是否受影响
14.5 误区五:出现 OOM 就只改 JVM 参数
参数很重要,但先要搞清楚:到底是对象分配模式有问题,还是对象根本回收不掉。
15. 一句话总结
JVM 执行、类加载、反射与垃圾回收这条主线,本质上是在解决:
Java 程序如何被加载和执行,运行期如何读取和操作类信息,对象如何在内存中存活,那些不再存活的对象最终如何被识别并回收,以及系统出现停顿、内存高、Full GC 频繁时该如何分析和优化。