反射在 JVM 层面的实现原理

反射在 JVM 层面的实现原理


一、在开始之前,先明确几个前置概念

要理解反射在 JVM 层面的实现,我们必须先搞清楚几个基础概念,否则后面的内容会像天书一样。

1. 什么是反射?

正常情况下,我们写 Java 代码时,在编译阶段就已经确定了要调用哪个类、哪个方法。比如:

java 复制代码
String str = new String("hello");
int len = str.length();

编译器在编译这段代码时就知道:你要创建一个 String 对象,然后调用它的 length() 方法。一切都是确定的、写死的。

但反射不同。反射是一种在程序运行时,才去动态地获取类的信息、创建对象、调用方法的能力。比如:

java 复制代码
Class<?> clazz = Class.forName("java.lang.String");
Method method = clazz.getMethod("length");
Object result = method.invoke("hello");

这段代码做的事情和上面一模一样------调用字符串的 length() 方法。但关键区别在于:你可以把 "java.lang.String""length" 换成任何字符串变量,在程序运行时才决定要操作哪个类、哪个方法。这就是"反射"的含义------程序在运行时"反过来"审视自身的结构。

2. 什么是 JVM 的方法区(元空间)和堆?

JVM 运行时,内存被划分为好几个区域,其中两个和我们今天的讨论直接相关:

  • 方法区(在 JDK 8 及之后叫元空间 Metaspace) :这里存放的是类的元数据,也就是关于类本身的描述信息。你可以理解为一张"说明书"------记录着这个类叫什么名字、有哪些字段、有哪些方法、每个方法的参数类型是什么、返回值是什么、有哪些注解等等。
  • 堆(Heap) :这里存放的是对象实例 ,也就是我们用 new 关键字创建出来的东西。

3. 什么是 Class 对象?

每当 JVM 加载了一个类之后,它会在堆中 创建一个 java.lang.Class 类型的对象。这个对象是该类的"代言人"------你想通过反射获取这个类的任何信息,都要通过这个 Class 对象来操作。

打个最直白的说法:方法区里存着原始数据,Class 对象是你访问这些原始数据的"遥控器"


二、第一步:获取 Class 对象时,JVM 做了什么?

当我们执行这样一行代码时:

java 复制代码
Class<?> clazz = Class.forName("com.example.MyClass");

JVM 在底层会触发一个叫做类加载的过程。这个过程分为五个阶段,每个阶段做的事情不同。下面逐一说明。

阶段一:加载(Loading)

这是最直观的一步。JVM 需要找到这个类的字节码文件(.class 文件) ,然后把它的内容读到内存里

具体来说:

  • JVM 根据你给的全限定类名(比如 com.example.MyClass),通过类加载器(ClassLoader)去寻找对应的 .class 文件。这个文件可能在磁盘上、在 JAR 包里、在网络上,甚至可能是运行时动态生成的。
  • 找到后,把字节码的二进制数据读入内存。
  • 堆中创建一个 java.lang.Class 对象,作为后续访问这个类的入口。

你可以把这一步理解为:JVM 把一本书(.class 文件)从书架上拿了下来,翻开了,并且给这本书做了一个索引卡片(Class 对象)。

阶段二:验证(Verification)

字节码文件是可以被人为修改的(比如用十六进制编辑器直接改),也可能在网络传输中损坏。所以 JVM 不会盲目信任读入的字节码,它必须检查这个字节码是否合法、是否安全

验证的内容包括但不限于:

  • 文件格式验证 :.class 文件的开头必须是魔数 0xCAFEBABE(这是 Java 的标志),版本号必须在当前 JVM 支持的范围内。
  • 元数据验证 :类的继承关系是否合理?比如一个类声称继承了一个 final 类,这显然是不合法的。
  • 字节码验证:方法中的指令是否合法?是否有越界访问?是否会导致栈溢出?
  • 符号引用验证:这个类里引用了别的类、别的方法,那些东西是否真的存在?

如果验证不通过,JVM 会直接抛出异常,拒绝加载这个类。

阶段三:准备(Preparation)

验证通过后,JVM 会为这个类的静态变量分配内存 ,并设置默认初始值

注意这里说的是默认初始值,不是你在代码里写的值。比如:

java 复制代码
public class MyClass {
    static int count = 10;
}

在准备阶段,count 会被分配内存,但它的值是 0(int 的默认值),而不是 10。真正赋值为 10 是在后面的初始化阶段才做的事。

阶段四:解析(Resolution)

这一步是把字节码中的符号引用 替换为直接引用

什么意思呢?在 .class 文件里,当一个类引用另一个类时,用的是文字描述 (符号引用),比如 "java/lang/String"。但 JVM 运行时需要的是内存地址(直接引用)------也就是这个类在内存中到底存在哪里。

解析阶段就是做这个转换:从"名字"转换为"真实地址"。

阶段五:初始化(Initialization)

这是类加载的最后一步。在这一步,JVM 会执行类的初始化代码,具体来说包括:

  • 执行静态变量的赋值语句(这时候 count 才真正被赋值为 10)。
  • 执行静态代码块 static { ... } 中的代码。

这五个阶段全部完成后 ,类的元数据就完整地存在了方法区(元空间)中,Class 对象也已经在堆中准备就绪。你通过 Class.forName() 拿到的就是这个 Class 对象的引用。

加载完成后的内存布局

让我们用一张清晰的图来总结:

复制代码
┌──────────────────────────────────────┐
│            方法区(元空间)              │
│                                      │
│  ┌────────────────────────────────┐  │
│  │    MyClass 的元数据信息          │  │
│  │  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  │  │
│  │  类名: com.example.MyClass     │  │
│  │  父类: java.lang.Object        │  │
│  │  字段: count (int, static)     │  │
│  │  方法: doSomething(String)     │  │
│  │  注解: @Component              │  │
│  │  访问修饰符: public            │  │
│  │  ......                        │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘

┌──────────────────────────────────────┐
│               堆(Heap)              │
│                                      │
│  ┌────────────────────────────────┐  │
│  │  Class<MyClass> 对象            │  │
│  │  (指向方法区中的元数据)         │  │
│  │  (这就是 Class.forName()       │  │
│  │    返回给你的那个对象)          │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘

Class 对象内部持有指向方法区元数据的引用。当你调用 clazz.getMethods()clazz.getFields() 时,Class 对象就会去方法区读取对应的元数据,封装成 MethodField 等对象返回给你。


三、第二步:反射调用方法时,JVM 做了什么?

获取到 Class 对象后,我们通常会进一步操作,比如调用方法:

java 复制代码
Method method = clazz.getMethod("doSomething", String.class);
Object result = method.invoke(myObject, "参数");

method.invoke() 这一行看起来简单,但 JVM 在底层的实现其实经历了一个精心设计的演进过程。它有两种实现方式,并且会根据调用次数自动切换。

方式一:Native 方式(本地方法调用)

这是默认的实现方式,也就是反射调用在最初使用的方式。

什么是 Native 方法?

Java 是运行在 JVM 上的高级语言,但 JVM 本身是用 C/C++ 写的。有些操作用 Java 自身无法完成(比如直接操作操作系统资源),就需要调用 C/C++ 编写的底层代码。这种从 Java 代码调用非 Java 代码的机制叫做 JNI(Java Native Interface,Java 本地接口)

native 关键字修饰的 Java 方法,其方法体不是用 Java 写的,而是在外部的 C/C++ 代码中实现的。

Native 反射调用的过程

当你第一次通过 method.invoke() 进行反射调用时,底层的执行过程大致如下:

复制代码
你的 Java 代码
    │
    ▼
Method.invoke()
    │
    ▼
DelegatingMethodAccessorImpl(委托类)
    │
    ▼
NativeMethodAccessorImpl(Native 实现)
    │
    ▼
通过 JNI 调用 C++ 代码
    │
    ▼
C++ 代码找到目标方法的内存地址,执行调用
    │
    ▼
将结果返回给 Java 层

我们来详细解释这个链条中的每个角色:

  1. Method.invoke() :这是你直接调用的方法。它内部会把实际工作委托给一个叫 MethodAccessor 的接口的实现类。

  2. DelegatingMethodAccessorImpl :这是一个"委托者"。它本身不做任何事,只是持有一个实际的 MethodAccessor 引用,然后把调用转发过去。它存在的意义是:方便后续切换底层实现。当 JVM 决定从 Native 方式切换到字节码方式时,只需要换掉这个委托者内部持有的引用即可,上层代码不需要任何改动。

  3. NativeMethodAccessorImpl:这是真正干活的。它通过 JNI 调用 C++ 层面的代码来完成反射调用。

Native 方式的特点
  • 优点:启动快。不需要任何前期准备工作,拿起来就能用。对于只调用一两次的反射操作,这是最划算的方式。
  • 缺点:每次调用都需要经过 JNI 这个"桥梁",而 JNI 调用本身有固定的性能开销------需要在 Java 栈帧和 Native 栈帧之间切换上下文,这个切换成本不低。如果一个反射方法被调用成千上万次,这个开销就非常可观了。

方式二:动态生成字节码方式

为了解决 Native 方式在高频调用场景下的性能问题,JVM 提供了第二种实现方式。

核心思想

既然反射调用的本质就是"调用一个方法",那能不能在运行时动态生成一段 Java 字节码,这段字节码里直接包含对目标方法的调用?这样就把"反射调用"变成了"正常调用",不再需要经过 JNI。

具体过程

假设我们反射调用的是 MyClass.doSomething(String arg) 这个方法。当调用次数达到阈值后,JVM 会在运行时动态生成一个类,这个类大致等价于:

java 复制代码
// 这个类是 JVM 在运行时动态生成的,不是你手写的
public class GeneratedMethodAccessor1 implements MethodAccessor {
    
    @Override
    public Object invoke(Object obj, Object[] args) {
        // 直接调用目标方法,和普通 Java 代码一模一样
        return ((MyClass) obj).doSomething((String) args[0]);
    }
}

看到了吗?这个动态生成的类里面,是直接用正常的 Java 调用方式去调用目标方法的。没有 JNI,没有复杂的查找过程,就是最朴素的方法调用。

生成这个类之后,DelegatingMethodAccessorImpl 内部持有的引用就从 NativeMethodAccessorImpl 切换为这个新生成的 GeneratedMethodAccessor1。后续所有的反射调用都走这条路径:

复制代码
你的 Java 代码
    │
    ▼
Method.invoke()
    │
    ▼
DelegatingMethodAccessorImpl(委托类)
    │
    ▼
GeneratedMethodAccessor1(动态生成的实现)
    │
    ▼
直接调用 MyClass.doSomething()   ← 和普通调用一样了!
动态字节码方式的特点
  • 优点:后续调用的性能非常高,接近直接调用。因为生成的字节码就是普通的 Java 方法调用,JIT 编译器还可以对它进行进一步优化。
  • 缺点:首次生成字节码需要时间和内存开销。JVM 需要在运行时创建一个新的类,这涉及到字节码的组装、类的加载和验证等一系列工作。如果一个反射方法只被调用少数几次就不再使用了,那这个生成字节码的成本就浪费了。

四、Inflation(膨胀)机制:两种方式的智能切换

JVM 不会一上来就使用动态字节码方式,也不会永远使用 Native 方式。它采用了一个叫做 Inflation(膨胀) 的机制来在两者之间智能切换。

切换逻辑

规则非常简单:

  • 调用次数 ≤ 15 次:使用 Native 方式(通过 JNI 调用)。
  • 调用次数 > 15 次:切换到动态生成字节码方式。

这个阈值 15 是由 JVM 参数 sun.reflect.inflationThreshold 控制的,你可以通过启动参数修改它:

复制代码
-Dsun.reflect.inflationThreshold=30  // 改为30次后才切换

你也可以彻底禁用 Inflation 机制:

复制代码
-Dsun.reflect.noInflation=true  // 从一开始就使用动态字节码方式

为什么设定为 15 次?

这是一个工程上的权衡(trade-off)

  • 如果阈值设得太低(比如 1 次),那大量只调用一两次的反射操作都会触发字节码生成,白白浪费时间和内存。
  • 如果阈值设得太高(比如 10000 次),那很多频繁使用反射的场景得不到优化,性能一直受 JNI 拖累。

15 次是 JVM 开发团队经过大量实际测试后选定的一个折中值。它的含义是:如果一个反射方法被调用超过 15 次,那它大概率会被继续频繁调用,值得投入成本做优化

让我们用一张时间线来描绘这个过程

复制代码
反射调用次数:  1   2   3   ...  14   15  │  16   17   18  ...  10000
              │                         │  │
              ├─── Native 方式 (JNI) ───┤  ├─── 动态字节码方式 ──────→
              │   每次都有 JNI 开销      │  │   性能接近直接调用
              │   但启动零成本           │  │   但第16次有字节码生成开销
                                        │
                                   Inflation!
                                  (膨胀/切换)

DelegatingMethodAccessorImpl 的关键作用

现在你应该能理解 DelegatingMethodAccessorImpl 这个委托类为什么存在了。它就是为了让这个切换过程对上层透明

Method 对象始终持有的是 DelegatingMethodAccessorImpl 的引用,而 DelegatingMethodAccessorImpl 内部维护着一个可变的 MethodAccessor 引用。切换时只需要把这个内部引用从 NativeMethodAccessorImpl 换成 GeneratedMethodAccessorXxx 即可,Method 对象完全不需要知道底层发生了什么变化。

这是一个经典的委托模式(Delegation Pattern) 的运用。


五、反射调用为什么比直接调用慢?

即使有了 Inflation 机制的优化,反射调用通常还是比直接调用慢。原因涉及多个层面,下面逐一解释。

原因一:无法被 JIT 内联优化

什么是 JIT?

JVM 运行 Java 程序时,最初是逐条解释执行字节码的(解释执行模式)。但 JVM 内置了一个 JIT(Just-In-Time)编译器 ,它会把那些被频繁执行的代码(热点代码)编译成本地机器码,直接运行,大幅提升性能。

什么是内联优化?

内联(Inlining)是 JIT 最重要的优化手段之一。它的意思是:把方法调用替换为方法体本身,消除方法调用的开销。

举个例子,假设有:

java 复制代码
public int add(int a, int b) {
    return a + b;
}

public void compute() {
    int result = add(3, 5);
}

JIT 内联优化后,compute() 方法会变成:

java 复制代码
public void compute() {
    int result = 3 + 5;  // add() 方法的调用被消除了,直接把方法体"内嵌"进来
}

这样就省去了方法调用的栈帧创建、参数传递、返回值处理等开销。

反射为什么阻碍内联?

当你直接调用 myObject.doSomething("hello") 时,JIT 编译器在编译阶段就能确定调用的目标方法是什么,可以放心地进行内联。

但反射调用 method.invoke(myObject, "hello") 时,JIT 看到的调用目标是 Method.invoke(),而 invoke() 内部经过了委托、转发等多层间接调用,最终的目标方法对 JIT 来说是不透明的。JIT 很难(或者不愿意)对这种间接调用链进行内联优化。

结果就是:反射调用每次都要老老实实地走完整个方法调用链,无法享受内联带来的性能提升。

原因二:需要进行可见性检查

Java 有访问控制机制:privateprotectedpublic、包级私有。当你通过反射调用一个方法时,JVM 必须在运行时检查当前调用者是否有权限访问这个方法

这个检查过程包括:

  • 获取调用者的类信息
  • 获取目标方法的访问修饰符
  • 比较两者的关系(同一个类?同一个包?有继承关系?)
  • 判断是否允许访问

每次调用都做这个检查,就是额外的开销。

你可能见过这样的代码:

java 复制代码
method.setAccessible(true);

这行代码的作用就是告诉 JVM:"跳过访问权限检查 "。设置了之后,后续调用这个 method 时就不再进行可见性验证了,能省下一些开销。这也是为什么很多框架(比如 Spring)在使用反射时都会先调用 setAccessible(true)

但要注意,这只是跳过了 Java 层面的检查,并不是说你可以"安全地"访问任何东西------它只是不报错了而已。

原因三:参数的装箱与拆箱

看一下 Method.invoke() 的方法签名:

java 复制代码
public Object invoke(Object obj, Object... args) throws ...

注意参数类型是 Object...,返回值是 Object

如果目标方法的参数和返回值是基本类型 (如 intdoubleboolean),那反射调用时就必须进行装箱(Boxing)拆箱(Unboxing)

java 复制代码
// 直接调用:
int result = calculator.add(3, 5);  // 基本类型,不需要任何转换

// 反射调用:
// 3 和 5 被装箱为 Integer 对象
// 返回的 Integer 又要拆箱为 int
Object result = method.invoke(calculator, 3, 5);
// 实际过程:3 → Integer.valueOf(3), 5 → Integer.valueOf(5)
// 返回:Integer → intValue()

每次装箱都要创建一个包装类对象(IntegerDouble 等),这不仅有对象创建的开销,还会给垃圾回收器(GC)带来额外的压力。如果反射调用非常频繁,这些临时对象会大量产生,影响性能。

原因四:需要进行类型安全检查

反射调用时,JVM 无法在编译期确定参数类型和返回值类型是否正确(因为一切都是 Object),所以必须在运行时进行类型安全检查:

  • 传入的参数个数对不对?
  • 传入的每个参数类型和目标方法声明的参数类型是否匹配?
  • 如果目标方法不是静态方法,传入的对象是不是正确类型的实例?

这些检查在直接调用时是由编译器在编译阶段完成的(如果类型不对,编译就报错了),完全没有运行时开销。但反射把这部分工作搬到了运行时,自然就慢了。

四个原因的总结对比

开销来源 直接调用 反射调用
JIT 内联优化 ✅ 可以内联 ❌ 难以内联
访问权限检查 编译期完成,无运行时开销 每次运行时检查(除非 setAccessible)
参数类型转换 无需装箱拆箱 基本类型必须装箱拆箱
类型安全检查 编译期完成 运行时逐一检查

六、完整调用链路的全景图

最后,让我们把所有知识串起来,画出反射从开始到调用完成的完整链路:

复制代码
第一阶段:获取 Class 对象
━━━━━━━━━━━━━━━━━━━━━━━
Class.forName("com.example.MyClass")
    │
    ▼
类加载过程:加载 → 验证 → 准备 → 解析 → 初始化
    │
    ▼
方法区:存储类的元数据(类名、字段、方法、注解等)
堆:创建 Class<MyClass> 对象
    │
    ▼
返回 Class 对象的引用


第二阶段:获取 Method 对象
━━━━━━━━━━━━━━━━━━━━━━━
clazz.getMethod("doSomething", String.class)
    │
    ▼
Class 对象去方法区查找匹配的方法元数据
    │
    ▼
封装为 Method 对象返回


第三阶段:调用方法(前 15 次)
━━━━━━━━━━━━━━━━━━━━━━━
method.invoke(obj, "参数")
    │
    ▼
Method → DelegatingMethodAccessorImpl → NativeMethodAccessorImpl
    │
    ▼
JNI 调用 C++ 代码 → 执行目标方法 → 返回结果
    │
    ▼
同时,NativeMethodAccessorImpl 内部的计数器 +1


第四阶段:Inflation 切换(第 16 次调用时)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
计数器 > 15,触发 Inflation!
    │
    ▼
JVM 动态生成 GeneratedMethodAccessor1 类的字节码
    │
    ▼
加载这个新类
    │
    ▼
DelegatingMethodAccessorImpl 内部引用切换为 GeneratedMethodAccessor1


第五阶段:调用方法(第 16 次及以后)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
method.invoke(obj, "参数")
    │
    ▼
Method → DelegatingMethodAccessorImpl → GeneratedMethodAccessor1
    │
    ▼
直接调用 ((MyClass)obj).doSomething((String)args[0])
    │
    ▼
性能接近直接调用 ✓

七、核心要点回顾

  1. Class.forName() 触发类加载:经历加载、验证、准备、解析、初始化五个阶段,最终在方法区存储元数据,在堆中创建 Class 对象。

  2. 反射调用方法有两种底层实现:Native 方式(通过 JNI 调用 C++ 代码)和动态生成字节码方式(运行时生成直接调用目标方法的字节码类)。

  3. Inflation 机制实现智能切换 :前 15 次走 Native,第 16 次开始切换为动态字节码。阈值可通过 sun.reflect.inflationThreshold 参数调整。

  4. DelegatingMethodAccessorImpl 是切换的关键:它作为中间层,使得底层实现的切换对上层完全透明。

  5. 反射比直接调用慢的四大原因 :无法被 JIT 内联、需要可见性检查、需要装箱拆箱、需要类型安全检查。其中 setAccessible(true) 可以消除可见性检查的开销。

这就是反射在 JVM 层面从类加载到方法调用的完整实现原理。JVM 通过 Inflation 机制,在"启动速度"和"峰值性能"之间取得了精妙的平衡。

相关推荐
星梦清河2 小时前
Java并发编程
java·开发语言
XiYang-DING2 小时前
【Java SE】sealed关键字
java·开发语言·python
weixin_449290012 小时前
Python vs Go:优缺点对比
网络·python·golang
祈澈菇凉2 小时前
Next.js + OpenAI API 跑通一个带流式输出的聊天机器人
开发语言·javascript·机器人
lsx2024062 小时前
MySQL 删除数据表
开发语言
前端程序猿i2 小时前
纯JS 导出 Excel 工具
开发语言·javascript·excel
沐知全栈开发2 小时前
XML Schema 复合类型 - 仅含元素
开发语言
weixin_408099672 小时前
跨境电商OCR:3秒识别多语言商品标签
开发语言·图像处理·人工智能·后端·ocr·api·文字识别ocr
小樱花的樱花2 小时前
C++引用:高效编程的技巧
开发语言·数据结构·c++·算法