反射凭什么认识你的类
某开发者写过这样一段代码,当时觉得平平无奇:
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 在 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 里存了什么
几个关键字段直接决定了反射能做什么:
| 存储结构 | 反射 API 入口 | 能拿到什么 |
|---|---|---|
FieldInfo[] | getDeclaredFields() | 字段名、类型、访问标志 |
Method[] | getDeclaredMethods() | 方法签名、返回值、异常、字节码 |
ConstantPool | getAnnotations() 内部依赖 | 符号引用、字符串常量 |
Annotations | getAnnotations() | 运行时注解 |
⚠️ 新手提示:
_java_mirror是 InstanceKlass 里指向 Heap 中 Class 对象的指针,而 Class 对象里也有一个隐藏字段指回 InstanceKlass。这就是obj.getClass()能在 O(1) 时间内返回 Class 对象的原因——对象头里的 Mark Word 就存了指向 Klass 的指针。
一个对象怎么找到自己的类
这条链路解释了反射的两个核心动作:
- 查元信息:
getDeclaredMethods()→ Class 对象 → Klass 指针 → InstanceKlass → Method 数组 - 执行方法:
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 的映射
反射的性能代价与优化
// ❌ 每次调用都走 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。
日常开发中的常用方法
| 场景 | 反射 API | MethodHandle 替代 |
|---|---|---|
| 调用无参构造 | 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-opensJVM 参数,要么改用java.lang.invoke下公开的 API。
Metaspace:Klass 的家
JDK 8 之前叫永久代(PermGen),JDK 8 起搬到了本地内存,改名 Metaspace。
Metaspace 的关键参数
| 参数 | 含义 | 默认值 |
|---|---|---|
-XX:MetaspaceSize | 触发 GC 的初始阈值 | ~20MB(平台相关) |
-XX:MaxMetaspaceSize | 最大上限 | 无限制(吃光物理内存) |
-XX:MinMetaspaceFreeRatio | GC 后最小空闲比例 | 40% |
-XX:MaxMetaspaceFreeRatio | GC 后最大空闲比例 | 70% |
-XX:CompressedClassSpaceSize | 压缩类空间大小 | 1GB |
Metaspace GC 的触发条件
⚠️ 新手提示:Metaspace 在本地内存,默认不设上限。如果有大量动态生成类的场景(CGLIB 代理、Groovy 脚本、JSP),Metaspace 会不断增长,直到操作系统报 OOM。建议线上环境设
-XX:MaxMetaspaceSize=256m,防止一个失控的类生成器拖垮整个机器。
类加载与反射的关系:完整桥接模型
到现在,把前面所有图串起来——这就是一个类从 .class 文件到被反射调用的完整路径。
总结
这篇文章的核心只有一句话:反射能"看见"的一切,都是因为类加载阶段就把它们存进了 InstanceKlass。
回顾几个关键点:
- Klass ≠ Class。一个在 Metaspace(C++ 结构),一个在 Heap(Java 对象),通过
_java_mirror和 Klass 指针双向绑定。 - 类加载器不只是把字节流读进来。它通过
defineClass将字节码解析为 InstanceKlass、填充常量池、方法表、字段表——这些就是反射的数据源。 - 双亲委派的本质是安全机制 + 避免重复加载。SPI 和 Tomcat 的"破坏"不是 bug,是有意为之的扩展。
- 准备阶段赋零值,初始化阶段才赋真值。反射在初始化之前读静态字段会拿到 0/null,这是排查诡异 bug 的一个方向。
- 反射的代价在 JNI 边界。MethodHandle 把安全检查前移到创建时,后续调用几乎零开销。高频反射场景建议升级到 MethodHandle 或 VarHandle。
- Metaspace 在本地内存,默认无上限。动态生成类的场景(代理、脚本、JSP)必须设
-XX:MaxMetaspaceSize。
类加载和反射,说到底是一体两面。类加载决定了 JVM 知道什么,反射决定了你能用 Java 代码问出什么。 理解了 Klass 模型这个中间层,很多看似"魔法"的行为——比如为什么改 static final 常量不生效、为什么 setAccessible 能绕 private 但绕不过模块系统——就都说得通了。
占位项待替换:无(本文未使用图片/视频)