Skip to content

JVM 执行、类加载、反射与垃圾回收

这篇笔记聚焦 Java 运行时里另一条非常关键的主线:

  1. 类是怎么被加载进 JVM 的
  2. 反射到底是什么,它为什么总和框架放在一起讲
  3. 字节码是怎么执行的
  4. 垃圾回收到底在回收什么
  5. 为什么会出现 Full GC 频繁、内存高、停顿长这类问题

如果说 内存划分详解 重点回答的是“内存怎么分区”,那这篇更关注:

程序到底是怎么跑起来的,运行期怎么拿到类信息,又是怎么把对象回收掉的。


1. 从源码到运行,Java 程序到底经历了什么

可以压缩成一条主线:

  1. 编写 .java 源码
  2. 通过 javac 编译成 .class 字节码
  3. JVM 加载类
  4. 运行期通过 Class 元信息组织执行,也可能被反射访问
  5. JVM 执行字节码
  6. 在运行中分配对象、使用内存、触发垃圾回收

这里的关键点是: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[对象分配与垃圾回收]

这张图最重要的意思是:

  1. 类加载 解决的是“类怎么进入 JVM”
  2. 反射 解决的是“运行期怎么读取类信息、创建对象、调用方法”
  3. JVM 执行 解决的是“字节码怎么真正跑起来”
  4. GC 解决的是“对象不用了之后怎么回收”

2. 类加载到底是什么

类加载 可以理解成 把类的字节码读入 JVM,并把它转换成 JVM 可以使用的运行时结构

它不是简单“把文件读进来”,通常涉及:

  1. 加载
  2. 链接
  3. 初始化

2.1 加载

把类字节码读进来,并在内存中形成类的基础表示。

2.2 链接

主要包括:

  1. 验证
  2. 准备
  3. 解析

它解决的是 类定义是否合法、静态变量如何分配、符号引用如何变成直接引用

2.3 初始化

执行类变量赋值和静态代码块等初始化逻辑。

这里要特别注意:类被加载进 JVM 后,后面的反射能力才真正有了可操作的运行时基础。


3. 双亲委派模型是什么

双亲委派 可以理解成 类加载器在尝试加载某个类时,会把请求交给上层父加载器,只有父加载器处理不了,自己才尝试加载

它解决的是:

  1. 避免重复加载
  2. 保证核心类库加载的一致性
  3. 提升安全性

虽然现代框架场景里会有一些打破或绕开传统委派路径的设计,但“先问上层,再自己加载”的主线理解仍然很重要。


4. 反射到底是什么

反射 可以理解成 程序在运行期拿到类的结构信息,并基于这些信息动态创建对象、访问字段、调用方法的一套机制

如果只看一句更直白的话,它解决的是 代码在编译时并不完全写死具体类型,而是把一部分“要操作哪个类、调用哪个方法”的决定延后到运行期

这也是为什么很多框架都离不开反射。

因为框架经常要做这样的事:

  1. 根据类名加载类
  2. 根据注解扫描类和方法
  3. 根据配置动态创建对象
  4. 在运行期调用某个方法

4.1 反射和 Class 到底是什么关系

很多人学反射时,一上来就记:

  1. Field
  2. Method
  3. Constructor

但真正更该先记住的是:反射的起点通常是 Class 对象。

当一个类被 JVM 加载后,运行期通常会有一个对应的 Class 对象,里面描述了这个类的很多元信息,例如:

  1. 类名
  2. 父类
  3. 接口
  4. 构造方法
  5. 字段
  6. 方法
  7. 注解

看一张关系图:

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 对象。

但它们的使用场景不完全一样:

  1. 类型名.class:编译期就知道类型,最直接
  2. 对象.getClass():手里已经有对象实例
  3. Class.forName(...):类名来自配置、数据库或外部输入,运行期才确定

4.3 反射最常见的几类操作是什么

拿到 Class 之后,最常见的反射操作通常包括:

  1. 读取类信息
  2. 创建对象
  3. 访问字段
  4. 调用方法
  5. 读取注解

例如下面这个例子,把几类常见操作串在一起看:

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 件事:

  1. 通过构造器创建对象
  2. 在运行期找到字段
  3. 在运行期修改字段值
  4. 在运行期调用方法

4.4 反射到底解决什么问题

很多初学者会觉得:既然方法和字段名都知道,为什么不直接 new 对象、直接调用方法?

这正是反射最关键的边界。

反射并不是为了替代普通编码方式,而是为了解决:编译时不知道具体类型,但运行时必须根据外部信息、配置或约定来操作对象。

工程里很常见的场景有:

  1. 容器根据类信息创建 Bean
  2. 框架扫描注解,决定哪些类需要注册
  3. 序列化框架根据字段结构读写对象
  4. ORM 根据实体类和字段映射数据库记录
  5. 测试框架动态发现并执行测试方法

所以反射的价值,不在于“写业务代码更快”,而在于:它给框架和基础设施提供了运行期动态处理对象的能力。

4.5 反射和注解、动态代理、类加载分别是什么关系

这几个词经常一起出现,但它们不是一回事。

概念更准确的含义和反射的关系
类加载把类字节码变成 JVM 可用的运行时结构反射通常建立在类已经加载的前提上
反射运行期读取类信息并动态操作对象是运行期访问元信息和调用行为的机制
注解写在类、方法、字段上的元数据框架通常通过反射读取注解
动态代理在运行期生成代理对象并拦截方法调用底层通常会配合反射或字节码增强

可以直接记成:

  1. 类加载解决“类怎么进 JVM”
  2. 反射解决“进 JVM 后怎么在运行期认识和操作这个类”
  3. 注解提供“额外元数据”
  4. 动态代理提供“运行期增强方法调用的能力”

4.6 反射为什么既强大又常被提醒慎用

反射的优势很明显:

  1. 灵活
  2. 动态
  3. 非常适合框架层和基础设施层

但它也有代价。

4.6.1 可读性和可维护性会下降

普通代码里,调用关系通常很直接:userService.save(user)

而反射里,很多调用关系会变成:根据字符串找方法 -> 运行期 invoke

这会让代码更难追踪。

4.6.2 性能通常不如直接调用

反射调用通常比普通直接调用更慢,它的代价主要来自:

  1. 运行期查找和校验
  2. 更难被编译器做静态优化
  3. 某些场景下还会有访问检查开销

不过工程上要注意的是:反射是否出现在高频核心路径里。

如果只是容器启动、初始化、扫描阶段使用,通常问题不大。 如果在超高频热点调用链里大量使用,就需要更加谨慎。

4.6.3 封装边界可能被绕开

setAccessible(true) 这类操作,会突破正常访问控制边界。

这也是为什么反射虽然强大,但更适合:

  1. 框架层
  2. 基础设施层
  3. 测试工具
  4. 对运行期行为有明确控制的场景

而不适合在普通业务代码里到处滥用。

4.7 反射的常见误区

4.7.1 误区一:反射等于可以拿到一切运行期类型信息

不完全对。

例如 Java 泛型有 泛型擦除,很多具体泛型信息在运行期并不会以你直觉中的方式完整保留。

所以反射很强,但它也不是“运行期全知全能”。

4.7.2 误区二:框架用了反射,所以业务代码也应该多用反射

不对。

框架用反射,通常是在解决“通用、动态、未知类型”的问题。

业务代码如果类型关系是明确的,直接编码通常更清晰、更稳定。

4.7.3 误区三:反射慢,所以一定不能用

也不准确。

更关键的问题是:

  1. 用在哪里
  2. 调用频率多高
  3. 有没有更直接的替代方案

很多框架在启动阶段大量使用反射,但运行阶段会通过缓存、代理或字节码增强来降低开销。


5. JVM 是怎么执行字节码的

可以这样理解:JVM 会把字节码解释执行,或者在热点代码上进一步编译优化后再执行。

这里常见会提到两个词:

  1. 解释执行
  2. JIT

5.1 解释执行

可以理解成 边读取字节码,边逐条执行

5.2 JIT

JITJust-In-Time Compiler,即时编译器。

它可以理解成 把热点代码在运行时编译成本地机器码,以获得更高执行效率

这也是为什么 Java 虽然不是直接编译成本地二进制,但运行性能依然可以很强。


6. 垃圾回收到底在回收什么

垃圾回收,也就是 GC,可以理解成 把那些已经不再被程序使用、也无法再被访问到的对象所占内存释放出来

它解决的是:

  1. 对象生命周期结束后,内存如何回收
  2. 程序不必手工逐个释放对象

这里要注意:

GC 回收的不是“没用了这个抽象概念”,而是:在可达性分析意义上已经无法再被访问的对象。


7. 可达性分析是什么

可达性分析 可以理解成 从一组被认为始终有效的根对象出发,沿引用关系去判断某个对象是否还能被访问到

如果一个对象从这些根出发已经走不到了,它才更可能被判定为可回收。

这里的 GC Roots 可以理解成 垃圾回收判断对象是否仍然存活时的起点集合

常见会包括:

  1. 栈中的引用
  2. 方法区中的类静态引用
  3. 本地方法引用等

8. 为什么垃圾回收会分代

因为对象生命周期通常并不一样。

更常见的现实是:

  1. 大量对象很快死亡
  2. 少数对象会长期存活

所以 JVM 往往把回收策略拆开:

  1. 新生代偏向高频回收短命对象
  2. 老年代处理更长期存活对象

这就是“分代回收”背后的核心动机。


9. Minor GC、Major GC、Full GC 分别是什么

9.1 Minor GC

通常指新生代上的垃圾回收。

它更常见,也更频繁。

9.2 Major GC

通常更偏向老年代相关回收语境,但不同资料和不同实现里叫法可能不完全统一。

9.3 Full GC

Full GC 可以理解成 一次影响范围更大、通常代价更高的垃圾回收

它经常意味着:

  1. 停顿更明显
  2. 系统吞吐受影响
  3. 需要进一步排查对象分配和内存使用问题

10. 为什么会出现 Full GC 频繁

常见原因包括:

  1. 老年代空间压力大
  2. 大对象分配过多
  3. 对象存活时间过长
  4. 内存泄漏
  5. 参数配置不合理

这里的 内存泄漏 可以理解成 对象实际上已经不再需要,但因为仍被某些引用链持有,导致 GC 无法回收

它不等于“内存溢出”,但长期积累后很容易进一步引发 OOM。


11. Stop-The-World 是什么

Stop-The-World,常缩写成 STW,可以理解成 垃圾回收或某些运行时操作进行时,应用线程被暂时暂停

它解决的是运行时一致性问题,但代价就是:应用会出现停顿。

所以 GC 优化很多时候并不只是关心“回收干净没有”,而是更关心:停顿时间能不能接受。


12. JVM 优化到底在优化什么

很多人一听到 JVM 优化,第一反应就是:

  1. 调堆大小
  2. 改 GC 参数
  3. 换垃圾回收器

这些当然都属于 JVM 优化的一部分,但如果先不分目标就直接改参数,往往会越调越乱。

JVM 优化 真正要优化的通常不是某个单独参数,而是下面几类运行时结果:

  1. 吞吐量:单位时间内能完成多少业务处理
  2. 延迟:单次请求或任务的响应时间是不是稳定
  3. 停顿时间:STW 会不会太长
  4. 内存占用:堆、元空间、直接内存是否合理
  5. 稳定性:是否频繁 Full GC、OOM、线程堆积

🌟 所以 JVM 优化最容易说错的一句话就是:JVM 优化不是把参数调得越大越好,而是让程序的运行时行为更符合当前业务目标。

例如:

  1. 对批处理任务,更关注吞吐量
  2. 对高并发接口服务,更关注延迟和停顿
  3. 对容器化部署,更关注内存边界和稳定性

12.1 JVM 优化为什么不能脱离业务场景

同样一组参数,在不同业务里效果可能完全不同。

因为影响 JVM 行为的,不只是参数本身,还包括:

  1. 对象分配速度
  2. 对象存活时间
  3. 缓存大小
  4. 线程模型
  5. 请求峰值
  6. 容器资源限制

所以工程上更常见的做法不是“先背一套万能参数”,而是先判断系统到底是在吞吐、停顿、内存还是稳定性上出了问题。

12.2 常见的 JVM 优化目标有哪些

优化目标常见现象更该关注什么
降低停顿时间请求偶发抖动、超时尖刺GC pauseSTW、垃圾回收器选择
提升吞吐量CPU 高、任务处理慢热点代码、对象创建频率、GC 开销占比
降低内存压力容器频繁被杀、堆使用率长期过高堆大小、缓存、对象存活时间、内存泄漏
减少 Full GCFull GC 次数多、停顿明显老年代压力、大对象、长生命周期对象
提高稳定性OOM、元空间打满、线程堆积内存边界、类加载、线程池、直接内存

13. 工程上怎么做 JVM 优化

真正有用的 JVM 优化,通常不是“参数大全”,而是“诊断路径 + 调整方向”。

13.1 先判断问题属于哪一类

工程上最常见的几类问题通常是:

  1. 堆内存过高
  2. Full GC 频繁
  3. 单次停顿过长
  4. CPU 持续偏高
  5. OOM
  6. 元空间持续增长

这几类问题虽然都可能和 JVM 有关,但背后的原因并不一样。

例如:

  1. Full GC 频繁,可能是对象晋升过快,也可能是缓存太大
  2. CPU 高,可能是 GC 线程忙,也可能是业务线程空转
  3. 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[压测并继续观察]

这张图最重要的意思是:

  1. 先分清现象,再选分析手段
  2. JVM 参数调整只是最后一段,不是第一步
  3. 很多所谓 JVM 问题,最后其实会回到对象模型、缓存策略、线程池或业务代码

13.3 JVM 优化时最常看的指标

如果想让优化更可落地,至少要先盯住这些指标:

  1. 堆使用率变化趋势
  2. 新生代 / 老年代使用情况
  3. Minor GCFull GC 次数
  4. 单次 GC 停顿时间
  5. GC 总耗时占比
  6. 元空间使用量
  7. 直接内存使用量
  8. 线程数量和线程状态

这里要特别注意:单看“当前内存高不高”通常不够,还要看它是持续上涨,还是周期性涨跌。

因为:

  1. 周期性涨跌,很多时候是正常的分配与回收
  2. 持续上涨且回不去,才更像泄漏或长生命周期对象堆积

13.4 常见的优化方向有哪些

13.4.1 先处理对象分配和存活模式

这往往比直接改参数更重要。

例如:

  1. 避免在高频链路里创建过多临时对象
  2. 避免无边界缓存
  3. 避免大对象频繁创建
  4. 避免对象被不必要的长引用链持有

很多 Full GC 问题,表面看像是 JVM 参数不合理,实际更像:程序制造了太多该死却死不掉的对象。

13.4.2 再看堆大小是不是合理

堆太小,会导致回收过于频繁。

堆太大,也不一定是好事,因为:

  1. 占用更多机器内存
  2. 回收时可能导致更长停顿
  3. 在容器场景里更容易顶到资源上限

所以堆大小更合理的理解不是:尽量大

而是:在可接受停顿和可接受资源占用之间取得平衡。

13.4.3 根据场景选择合适的垃圾回收器

不同垃圾回收器关注点不同。

可以粗略这样理解:

  1. Serial GC:简单,但更适合小内存或单线程场景
  2. Parallel GC:更偏吞吐量
  3. G1 GC:很多服务端场景常用,兼顾吞吐与停顿控制
  4. 更低停顿取向的收集器:更适合对延迟敏感的场景

要注意的是:没有哪种 GC 能对所有业务都天然最优。

13.4.4 关注元空间和类加载问题

不是所有 JVM 内存问题都发生在堆里。

例如这些情况,也很常见:

  1. 动态生成类过多
  2. 类加载器泄漏
  3. 代理类不断累积

这类问题更容易把:Metaspace

打满。

所以看到 OOM 时,不能只盯堆。

13.4.5 线程模型也会影响 JVM 表现

很多人会把“线程太多”只理解成 CPU 问题,但它也会影响 JVM 的整体稳定性。

因为线程过多常常会带来:

  1. 栈内存占用上升
  2. 上下文切换增加
  3. 对象分配压力变大
  4. 请求排队和超时进一步放大

所以 JVM 优化很多时候也会回到:

  1. 线程池是否合理
  2. 阻塞点是否过多
  3. 并发模型是否匹配当前业务

13.5 常用的观察和分析工具

工程上常见的 JVM 观察工具,可以按用途记:

工具常见用途
jstat看 GC 次数、各代内存使用趋势
jmap导出堆信息、看对象分布
jstack看线程栈、排查死锁和阻塞
jcmd更通用的 JVM 诊断命令入口
jconsole / visualvm图形化观察内存、线程、GC
arthas在线诊断方法热点、类信息、线程和堆问题

这些工具真正解决的是:让你别再凭感觉调 JVM,而是拿证据说话。

13.6 一份更实用的 JVM 优化检查清单

如果线上服务出现 GC 抖动、内存高、停顿长,可以按这个顺序自查:

  1. 最近有没有流量、缓存、线程池、发布行为变化
  2. Minor GCFull GC 的频率有没有异常抬高
  3. 停顿长是集中在 GC,还是集中在业务线程阻塞
  4. 堆内存是否回收后仍持续上涨
  5. 老年代压力是不是持续偏高
  6. 是否存在大对象、长生命周期对象、无边界缓存
  7. 是否存在类加载器、动态代理、元空间增长问题
  8. 参数调整后有没有经过压测和再次观察

14. 工程上最常见的几个误区

14.1 误区一:会背内存分区就等于理解 JVM

不够。

还要继续理解类加载、反射、执行模型和 GC 行为。

14.2 误区二:反射就是框架黑魔法,业务里完全不用理解

不对。

很多框架行为,例如:

  1. Bean 创建
  2. 注解扫描
  3. 动态代理
  4. 序列化映射

背后都离不开反射或和反射相邻的运行期机制。

14.3 误区三:JVM 优化就是调参数

不对。

JVM 参数当然重要,但很多问题最终会落回:

  1. 对象分配模式
  2. 缓存策略
  3. 线程模型
  4. 类加载行为

如果程序本身一直制造高压力对象,只改参数通常只能延后问题爆发。

14.4 误区四:GC 一定越频繁越差

不完全对。

关键要看:

  1. 回收发生在哪一代
  2. 单次停顿多久
  3. 吞吐和延迟是否受影响

14.5 误区五:出现 OOM 就只改 JVM 参数

参数很重要,但先要搞清楚:到底是对象分配模式有问题,还是对象根本回收不掉。


15. 一句话总结

JVM 执行、类加载、反射与垃圾回收这条主线,本质上是在解决:

Java 程序如何被加载和执行,运行期如何读取和操作类信息,对象如何在内存中存活,那些不再存活的对象最终如何被识别并回收,以及系统出现停顿、内存高、Full GC 频繁时该如何分析和优化。

基于 VitePress 构建的个人技术笔记。