2419 字
约 8 分钟
15
S04-X14 · 反射、代理与 instanceof 的底层实现路径

obj instanceof UserService 为 true,obj.getClass() == UserServiceImpl.class 为 false,而 obj 明明就是那个 UserServiceImpl——只要它被 Spring 用 JDK 动态代理包过一层。同一段代码,换个代理方式结论又变了。

这三个机制在源码层面各写各的,在字节码与运行时层面却互相咬合:instanceof 是一条指令、反射是一次访问检查 + 转发调用、代理是一次运行期类生成。把路径拆开,很多"玄学"就落地了。


1. instanceof 是一条指令,不是一次方法调用

instanceof 有两条硬语义:左边为 null 时恒为 false;比较的是"运行期类型是否可赋值给目标类型",包含继承与接口实现关系。它在字节码里就是一条 instanceof 指令,后跟常量池里的类型引用。

Object o = "abc";
System.out.println(o instanceof String);      // true
System.out.println(null instanceof String);    // false,编译期就折叠成常量

// 编译期还会做"不可能成立"的检查
String s = "x";
// System.out.println(s instanceof Integer);   // 编译错误:inconvertible types
$ javap -c Demo.class
  aload_1
  instanceof java/lang/String     # 单条指令:查常量池类型,沿父类/接口链判定
  ifeq ...

与 getClass() == 的区别是这类 bug 的高发区:

写法 判定依据 子类实例 代理对象 null
x instanceof Base 可赋值性(含继承、接口) true 看代理实现的接口 false
x.getClass() == Base.class 精确类型相等 false false NPE
Base.class.isInstance(x) 与 instanceof 等价 true 同左 false
Base.class.isAssignableFrom(x.getClass()) 类型关系 true 同左 NPE

getClass() == 适合"必须精确类型"的场景(比如判断某个标记类),但只要链路上出现过代理、字节码增强、record 包装,它就会悄悄给 false。

JDK 16 起模式匹配 instanceof 正式可用(JEP 394),JDK 17 是它的第一个 LTS:

if (o instanceof Point p && p.x() > 0) {   // 绑定变量的作用域由流程决定
    System.out.println(p.y());
}

if (!(o instanceof Point p)) return;       // 取反时没有绑定变量,需提前返回
System.out.println(p.x());

模式匹配只是省掉了 (Point) o 这次强制转换——字节码里依旧是 instanceof + checkcast(JIT 通常能把后者消掉),语义没变。


2. 反射:三条入口,三种开销来源

Class<?> clazz = Class.forName("com.example.OrderService");   // 会触发静态初始化
Method m = clazz.getDeclaredMethod("query", long.class);      // 只找本类声明
Object result = m.invoke(instance, 1001L);                    // 参数装箱 + 访问检查
入口 是否初始化类 是否查父类 典型用途
Class.forName(name) 是(initialize=true) — 按配置加载驱动、插件
ClassLoader.loadClass(name) 否 — 只判断类型是否存在
getDeclaredMethod — 否,只本类 拿私有方法(需 setAccessible)
getMethod — 是,只找 public 走接口/父类的公开方法
Method.invoke — — 每次都有装箱与访问检查

setAccessible(true) 在 JDK 9+ 不再是万能的,它要过模块这一关:目标包是否 opens 给调用方。JDK 16 起强封装默认开启、JDK 17 移除了 --illegal-access 开关,反射 java.base 内部包直接抛 InaccessibleObjectException:

Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
// Unable to make field private final byte[] java.lang.String.value accessible
// 需启动参数:--add-opens java.base/java.lang=ALL-UNNAMED

JDK 18 的 JEP 416 把核心反射换到方法句柄上:Method.invoke 不再运行期生成 MethodAccessor 字节码,首次调用开销与预热曲线都更平。但**--illegal-access 的宽容模式在 JDK 17 就已移除**,升级后靠它续命的反射代码会直接失败,这是版本迁移最常见的炸点。


3. 代理:JDK 动态代理与 CGLIB 的两条路

JDK 动态代理基于接口,运行期生成 $Proxy0 类,所有方法调用转发给 InvocationHandler:

UserService target = new UserServiceImpl();
UserService proxy = (UserService) Proxy.newProxyInstance(
        target.getClass().getClassLoader(),
        new Class<?>[]{UserService.class},
        (p, method, args) -> {
            System.out.println("before " + method.getName());
            return method.invoke(target, args);       // 转发给目标
        });

System.out.println(proxy.getClass().getName());        // 典型输出:jdk.proxy1.$Proxy0
System.out.println(proxy instanceof UserService);     // true,实现了接口
System.out.println(proxy instanceof UserServiceImpl); // false,不继承实现类

CGLIB / ByteBuddy 走继承:生成目标类的子类并重写方法,所以代理不了 final 类与 final 方法,private 方法也拦不到。

维度 JDK 动态代理 CGLIB / ByteBuddy
前提 目标实现接口 目标可被继承(非 final)
生成物 $Proxy0 implements 接口 Target$$EnhancerByCGLIB extends Target
拦截范围 接口上声明的方法 可重写的实例方法
final 类/方法 不受影响(代理的是接口) 无法代理
构造器 不调用目标构造器 需实例化子类(常用 Objenesis 绕过)
self-invocation 不经过代理 同样不经过代理

Spring AOP 的选择依据是有没有接口,以及 proxyTargetClass:

条件 行为
目标实现至少一个接口 JDK 代理(Spring Framework 默认),只能按接口注入
无接口 CGLIB,可按实现类注入
proxyTargetClass = true 强制 CGLIB
Spring Boot 2.x 起 默认 proxy-target-class=true,即 CGLIB
final 类需代理 只能接口化或去 final

getClass() 踩空就在这里:注入的是 $Proxy0 或 UserServiceImpl$$EnhancerBySpringCGLIB,getClass() == UserServiceImpl.class 必然为 false。要拿真实类型用 AopUtils.getTargetClass(bean) 或 AopProxyUtils.ultimateTargetClass(bean),别自己 getClass()。

同理,instanceof 也只在"代理实现了该接口"时成立:JDK 代理对接口成立、对实现类不成立;CGLIB 代理对实现类成立(子类),对接口也成立。


4. 反射为什么慢,以及方法句柄怎么救

Method.invoke 的开销集中在三处,与"反射是解释执行"这个说法并不完全一致:

开销来源 具体动作 能否消除
参数装箱 invoke(Object, Object...) 要求把 int 装箱、把变参打成数组 用 MethodHandle 的精确签名可消除
访问检查 每次调用校验模块导出、成员可见性 检查结果会被缓存,但仍有分支
无法内联 调用目标对 JIT 不透明,热点路径难优化 MethodHandle 可被内联
结果装箱 返回值统一为 Object,出参也要拆箱 同上

旧实现的"通胀"机制(调用超过阈值后生成 MethodAccessor 字节码)本身也是一次类生成成本;JEP 416 之后这条路径改由方法句柄承担,首次调用与稳态表现都更接近直接调用,但仍不等于直接调用——尤其参数装箱那部分,反射 API 的签名决定了它消除不掉。

MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle mh = lookup.findVirtual(OrderService.class, "query",
        MethodType.methodType(Order.class, long.class));
Order o = (Order) mh.invokeExact(service, 1001L);   // 签名精确,无装箱

invokeExact 要求调用点静态类型与 MethodType 完全一致,JIT 能把它编成近似直接调用。LambdaMetafactory 更进一步,把方法句柄"烧"成函数式接口实例:

MethodHandle mh = lookup.findVirtual(OrderService.class, "query",
        MethodType.methodType(Order.class, long.class));
Function<Long, Order> fn = (Function<Long, Order>) LambdaMetafactory.metafactory(
        lookup, "apply", MethodType.methodType(Function.class),
        mh.type(), mh, mh.type()).getTarget().invokeExact();

Order o = fn.apply(1001L);   // 稳态接近直接调用

这就是框架"启动时反射、运行期用句柄"的原因:一次性成本留在启动阶段,稳态走可内联路径。


5. 排错:五个反射与代理的现场

现象 根因 处理
InaccessibleObjectException 目标包未 opens 给调用方 加 --add-opens m/p=ALL-UNNAMED,或改调公开 API
IllegalArgumentException: object is not an instance of declaring class invoke 接收者类型不对 核对声明类与实例类型,静态方法传 null
NoSuchMethodException 用 getMethod 找私有方法 改 getDeclaredMethod 并逐层向上找
getClass() == X.class 为 false 拿到的是代理对象 用 AopUtils.getTargetClass 取真实类型
CGLIB 代理下 final 方法不生效 无法重写 去 final,或改接口 + JDK 代理

还有一条容易漏:Class.newInstance() 在 JDK 9 已废弃,且会吞掉构造器抛出的受检异常;要拿私有构造器必须 getDeclaredConstructor() + setAccessible(true),否则抛 IllegalAccessException。


6. 边界:这些机制不该被用在哪

场景 更合适的做法 理由
业务代码里按类型分支 多态或 switch 模式匹配 反射分支无法被编译器检查
高频调用的插件入口 启动时绑定 MethodHandle / 函数式接口 每次 invoke 的装箱成本白付
需要拦截同类内部调用 重构拆分或自注入代理 self-invocation 不走代理
代理 final 类 接口化 继承路线走不通
反射 JDK 内部字段 换公开 API 强封装下必然失败

一条实践结论:instanceof 判接口是稳的,判实现类不稳——实现类随时会被代理替换,而接口是代理必然实现的契约。


7. 自测

  1. null instanceof X 的结果是什么?x.getClass() == X.class 在 x 为 null 时又怎样?
  2. instanceof 与 Class.isInstance 的判定依据是否一致?与 getClass() == 差在哪?
  3. JDK 16 的模式匹配 instanceof 改变了语义吗?字节码里还会出现 checkcast 吗?
  4. Class.forName 与 ClassLoader.loadClass 的关键差别是什么?各自适合什么场景?
  5. setAccessible(true) 在 JDK 17 上为什么会失败?两种解决路径分别是什么?
  6. JDK 动态代理与 CGLIB 的生成物分别是什么?为什么前者对实现类做 instanceof 会失败?
S04-X14 · 反射、代理与 instanceof 的底层实现路径
https://www.clxhxhhr.top/posts/5760/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。