反射凭什么认识你的类

某开发者写过这样一段代码,当时觉得平平无奇:

Method method = obj.getClass().getDeclaredMethod("secretLogic", String.class);
method.setAccessible(true);
Object result = method.invoke(obj, "hacked!");

运行完才反应过来——凭什么?一个 Class 对象拿在手里,连 private 方法都能翻出来调用,连参数签名都一清二楚。JVM 到底在背后存了什么东西,让反射可以"看见"一个类的全部内脏?

答案就在 HotSpot 的 Klass 模型 里。

📌 前置知识:本文假设读者知道 .class 文件是 javac 编译产物、JVM 基本内存分区(堆、栈、方法区)的常识。如果不清楚方法区和 Metaspace 的关系,后文有图。


全景先览:JVM 中的类在哪

在深入 Klass 之前,先看一张全局地图——一个类从 .class 文件到进入 JVM 运行时,到底经过了哪些区域。

JVM全景架构

这张图先有一个印象就行——关键记住一点:一个类被加载后,JVM 在 Metaspace 里存了一份"类模板"(InstanceKlass),在 Heap 里放了一个轻量的"Java 镜像"(Class 对象)。 反射读的元信息来自前者,你代码里 getClass() 拿到的是后者。


类模板:HotSpot 眼中的"类"

你写的每一个 Java 类,在 HotSpot 内部都有一个对应的 C++ 对象来描述它——这就是 Klass 模型

Klass 继承链

Klass                          ← 所有类的抽象基类
 ├── InstanceKlass             ← 普通 Java 类(你写的 99% 的类)
 │    ├── InstanceMirrorKlass  ← Class 对象本身(镜子)
 │    └── InstanceRefKlass     ← 引用类型(软/弱/虚引用)
 ├── ArrayKlass                ← 数组类
 │    ├── TypeArrayKlass       ← 基本类型数组(int[])
 │    └── ObjArrayKlass        ← 对象数组(String[])

⚠️ 新手提示:Klass 不是 Class。Klass 是 C++ 层面的数据结构,存在 Metaspace; java.lang.Class 是 Java 层面的对象,存在 Heap。日常说"类模板"通常指 InstanceKlass。

InstanceKlass 里存了什么

InstanceKlass结构

几个关键字段直接决定了反射能做什么:

存储结构反射 API 入口能拿到什么
FieldInfo[]getDeclaredFields()字段名、类型、访问标志
Method[]getDeclaredMethods()方法签名、返回值、异常、字节码
ConstantPoolgetAnnotations() 内部依赖符号引用、字符串常量
AnnotationsgetAnnotations()运行时注解

⚠️ 新手提示: _java_mirror 是 InstanceKlass 里指向 Heap 中 Class 对象的指针,而 Class 对象里也有一个隐藏字段指回 InstanceKlass。这就是 obj.getClass() 能在 O(1) 时间内返回 Class 对象的原因——对象头里的 Mark Word 就存了指向 Klass 的指针。

一个对象怎么找到自己的类

对象到Klass的链路

这条链路解释了反射的两个核心动作:

  1. 查元信息getDeclaredMethods() → Class 对象 → Klass 指针 → InstanceKlass → Method 数组
  2. 执行方法method.invoke(obj) → Method → ConstMethod → 解释执行 / JIT 编译后的 native 入口

类加载器体系:谁把 Klass 造出来的

三层 ClassLoader + 双亲委派

双亲委派模型

双亲委派的本质就一句话:先问爹,爹不行再自己上。这样 java.lang.String 永远由 Bootstrap ClassLoader 加载,不会出现用户自定义的"假的 String 类"覆盖核心 API。

破坏委派的场景

// 场景1:SPI 机制——JDBC 驱动加载
// DriverManager 在核心库(Bootstrap 加载),但具体驱动在 classpath(App 加载)
// 解决:线程上下文类加载器(Thread Context ClassLoader)
ClassLoader contextCL = Thread.currentThread().getContextClassLoader();
// 让 Bootstrap 区域的代码"向下"委托 App ClassLoader 去加载

// 场景2:Tomcat 的 WebappClassLoader
// 每个 Web 应用有自己的类加载器,优先自己加载(不委托父加载器)
// 目的:隔离不同应用的同名类、支持热部署时单独卸载

类加载七阶段:Klass 是怎么填充出来的

类加载七阶段

⚠️ 新手提示:准备阶段的"赋零值"是最容易踩的坑。 static int x = 5 在准备阶段赋 0,初始化阶段才赋 5。如果初始化之前有别的类通过反射读了这个字段,拿到的是 0 而不是 5。

几个高频误区

public class LoadOrderDemo {
    // 准备阶段:counter = 0(不是 100!)
    // 初始化阶段:counter = 100
    public static int counter = 100;          // ①

    // 初始化阶段:执行静态块、调用 print()
    static { print("static block: " + counter); }  // ② 输出 100

    // ③ 常量(static final + 字面量)在编译阶段就写入了常量池
    // 加载这个类之前,别的类引用 MAX_VALUE 不会触发此类的初始化
    public static final int MAX_VALUE = 1000;

    private static void print(String msg) {
        System.out.println(msg);
    }
}

⚠️ 新手提示: static final 基本类型 / String 常量在编译期就内联到调用方的常量池里。改了这个值但没重新编译调用方 → 调用方看到的还是旧值。这就是经典的"改常量不生效"的根因。


反射:Klass 模型的 Java 层窗口

前面讲的 Klass 模型都在 C++ 层,Java 代码怎么访问它?答案是通过 JNI 桥接—— java.lang.Class 的 native 方法直接读取 InstanceKlass 的内存。

反射 API 到 Klass 的映射

反射API到Klass映射

反射的性能代价与优化

// ❌ 每次调用都走 Method.invoke 的 native 链路:
//    Java → JNI → 访问检查 → 参数装箱拆箱 → 方法查找 → 解释执行
//    比直接调用慢 10~100 倍(取决于是否有 JIT 优化)

// ✅ 首次反射调用后,JVM 会生成 MethodAccessor 加速:
//    NativeMethodAccessorImpl(解释)
//    → 调用超过 15 次(-Dsun.reflect.inflationThreshold)
//    → 自动升级为 GeneratedMethodAccessor(字节码直接调用,接近原生性能)

// ✅ JDK 7+ 的 MethodHandle 更极致:
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle handle = lookup.findVirtual(MyClass.class, "myMethod",
        MethodType.methodType(void.class, String.class));
// MethodHandle 在创建时就完成了权限检查,后续调用开销远小于反射

⚠️ 新手提示: setAccessible(true) 本身也有开销——它会触发一次安全检查。如果方法会被反复调用,建议在外面 set 一次,不要每次都 set。

日常开发中的常用方法

场景反射 APIMethodHandle 替代
调用无参构造clz.getDeclaredConstructor().newInstance()lookup.findConstructor(clz, methodType(void.class)).invoke()
调用私有方法m.setAccessible(true); m.invoke(obj, args)lookup.findVirtual(clz, name, type)(不能绕过模块访问限制)
读私有字段f.setAccessible(true); f.get(obj)lookup.findVarHandle(clz, name, type).get(obj)
获取泛型信息((ParameterizedType) f.getGenericType()).getActualTypeArguments()不支持,MethodHandle 不暴露签名信息
获取注解f.getAnnotation(MyAnnotation.class)不支持
动态代理Proxy.newProxyInstance(cl, interfaces, handler)ByteBuddy / cglib (MethodHandle 不能创建新类)

⚠️ 新手提示:反射能绕过 private 但绕不过模块系统的 open 限制。JDK 9+ 中 java.base 模块对很多内部类做了封装, setAccessible(true) 会直接抛 InaccessibleObjectException 。这时候要么加 --add-opens JVM 参数,要么改用 java.lang.invoke 下公开的 API。


Metaspace:Klass 的家

JDK 8 之前叫永久代(PermGen),JDK 8 起搬到了本地内存,改名 Metaspace。

Metaspace结构

Metaspace 的关键参数

参数含义默认值
-XX:MetaspaceSize触发 GC 的初始阈值~20MB(平台相关)
-XX:MaxMetaspaceSize最大上限无限制(吃光物理内存)
-XX:MinMetaspaceFreeRatioGC 后最小空闲比例40%
-XX:MaxMetaspaceFreeRatioGC 后最大空闲比例70%
-XX:CompressedClassSpaceSize压缩类空间大小1GB

Metaspace GC 的触发条件

Metaspace GC触发

⚠️ 新手提示:Metaspace 在本地内存,默认不设上限。如果有大量动态生成类的场景(CGLIB 代理、Groovy 脚本、JSP),Metaspace 会不断增长,直到操作系统报 OOM。建议线上环境设 -XX:MaxMetaspaceSize=256m ,防止一个失控的类生成器拖垮整个机器。


类加载与反射的关系:完整桥接模型

到现在,把前面所有图串起来——这就是一个类从 .class 文件到被反射调用的完整路径。

完整桥接模型


总结

这篇文章的核心只有一句话:反射能"看见"的一切,都是因为类加载阶段就把它们存进了 InstanceKlass。

回顾几个关键点:

  1. Klass ≠ Class。一个在 Metaspace(C++ 结构),一个在 Heap(Java 对象),通过 _java_mirror 和 Klass 指针双向绑定。
  2. 类加载器不只是把字节流读进来。它通过 defineClass 将字节码解析为 InstanceKlass、填充常量池、方法表、字段表——这些就是反射的数据源。
  3. 双亲委派的本质是安全机制 + 避免重复加载。SPI 和 Tomcat 的"破坏"不是 bug,是有意为之的扩展。
  4. 准备阶段赋零值,初始化阶段才赋真值。反射在初始化之前读静态字段会拿到 0/null,这是排查诡异 bug 的一个方向。
  5. 反射的代价在 JNI 边界。MethodHandle 把安全检查前移到创建时,后续调用几乎零开销。高频反射场景建议升级到 MethodHandle 或 VarHandle。
  6. Metaspace 在本地内存,默认无上限。动态生成类的场景(代理、脚本、JSP)必须设 -XX:MaxMetaspaceSize

类加载和反射,说到底是一体两面。类加载决定了 JVM 知道什么,反射决定了你能用 Java 代码问出什么。 理解了 Klass 模型这个中间层,很多看似"魔法"的行为——比如为什么改 static final 常量不生效、为什么 setAccessible 能绕 private 但绕不过模块系统——就都说得通了。


占位项待替换:无(本文未使用图片/视频)