《Java 高级编程》2.1 反射核心:Class、Method 与访问控制

libraryd 的审计日志不想为 Book、Member、Loan 各写一套字段提取代码,反射是把它做成一个通用组件的最小代价。本节从 Class 的三种来源讲到 Method 查找、invoke 调用、构造器创建,再落到 trySetAccessible 与模块边界,最后给出可用于 record 的字段映射实现。

本节目标:用反射把 libraryd 的 record 字段读成审计事件,同时看清 Class、Method、访问控制和异常包装的边界。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。

承接的问题

libraryd 的审计日志要记录「谁在什么时候对哪条记录做了什么」。最小的实现是在每个服务方法里手写字段:

audit.record("borrow", Map.of(
        "isbn", book.isbn(),
        "title", book.title(),
        "author", book.author(),
        "year", book.year()));

三五个方法还能忍,等到 Book、Member、Loan、Reservation 都有审计需求时,这段代码会复制十几遍。每加一个字段,就要改所有复制点;漏掉一个,日志里就少一列。更麻烦的是,Loan 有七个字段,手写 Map.of 很容易超过十个参数或写错键名。

这一节用反射做一个通用的 RecordIntrospector:给它任意 record,返回「字段名 → 值」的映射。它需要回答四个问题:

  • Class 对象从哪里来,Class.forName 和类字面量有什么区别。
  • 怎样按名字和参数类型找到 Method,getMethods 和 getDeclaredMethods 差在哪。
  • Method.invoke 的返回值、异常和访问检查分别怎么处理。
  • 哪些私有成员可以打开,哪些在 JDK 25 的模块边界前必须放弃。

Class 对象的三种来源

反射的入口是 java.lang.Class<T>。常见来源有三种:

Book book = new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008);
Class<?> fromInstance = book.getClass();
Class<?> fromLiteral = Book.class;
Class<?> fromName = Class.forName("com.plume.library.domain.Book");
System.out.println("sameClass=" + (fromInstance == fromLiteral && fromLiteral == fromName));

本机输出:

sameClass=true

三种写法指向同一个 Class 实例,因为一个类在同一个类加载器里只会被加载一次。Book.class 在编译期就能确定类型,不需要抛 ClassNotFoundException,是首选。Class.forName 适合「类名来自配置或插件」的场景,但它默认会初始化类;如果只想加载不初始化,用 Class.forName(name, false, loader)。

getClass() 返回的是运行时类型,不是声明类型。Book 被 SubBook 继承时,subBook.getClass() 是 SubBook.class。审计组件拿到的应该是运行时类型,否则子类新增的字段会被漏掉。

Class 还带着类加载器的身份。两个不同的 PluginClassLoader 各加载一次 com.example.validator.Isbn13Validator,会得到两个不相等的 Class 对象,即使字节码完全一样。第 3 章会展开这个坑;在反射代码里,判断「同一个类」要用 == 比较 Class 对象,不要用类名字符串。

Method 查找、调用与构造器

拿到 Class 之后,按名字和参数类型找方法:

Method title = Book.class.getMethod("title");
System.out.println("title=" + title.invoke(book));
System.out.println("returnType=" + title.getReturnType().getName());

本机输出:

title=Clean Code
returnType=java.lang.String

getMethod 只找 public 方法,并且会沿着父类和接口向上找。getDeclaredMethod 找当前类声明的所有方法,包括 private、protected 和包可见,但不包括继承来的。两个方法都需要精确的参数类型列表;getMethod("findByAuthor", String.class) 不能写成 getMethod("findByAuthor", Object.class)。

record 的访问器是普通方法,反射可以像调用其他方法一样调用。Book 的 getRecordComponents() 还会给出字段顺序和类型:

System.out.println("components=" + Arrays.stream(Book.class.getRecordComponents())
        .map(c -> c.getName() + ":" + c.getType().getSimpleName())
        .toList());

本机输出:

components=[isbn:String, title:String, author:String, year:int]

构造器同样可以反射调用:

Constructor<?> constructor = Book.class.getDeclaredConstructor(
        String.class, String.class, String.class, int.class);
Book copy = (Book) constructor.newInstance(
        "9780134685991", "Effective Java", "Joshua Bloch", 2018);
System.out.println("constructed=" + copy.title());

本机输出:

constructed=Effective Java

getDeclaredConstructor 拿到的是声明在 Book 上的 canonical constructor。record 的构造器是 public,所以 newInstance 直接成功;如果构造器是 private,需要先 setAccessible(true) 或 trySetAccessible()。

invoke 的返回值永远是 Object。返回 void 的方法得到 null;返回基本类型的方法会被装箱,int 变成 Integer。参数也一样:反射调用 fineCents(long) 时,传入的 Long 会被拆箱;如果传 null 给基本类型参数,会抛 IllegalArgumentException,而不是 NullPointerException。

访问控制与模块边界

setAccessible(true) 打开的是「抑制 Java 语言访问检查」,不是「绕过 JVM 的所有保护」。JDK 9 之后,模块系统给这个开关加了边界:只有成员所在的包打开给当前模块,反射才能访问非 public 成员。

先看自己的类:

Method secret = PrivateTarget.class.getDeclaredMethod("secret", String.class);
System.out.println("trySet=" + secret.trySetAccessible());
System.out.println("privateCall=" + secret.invoke(new PrivateTarget(), "id"));

本机输出:

trySet=true
privateCall=id-42

PrivateTarget 在无名模块(classpath)里,包没有显式导出或打开,但无名模块的包默认对反射开放,所以 trySetAccessible() 返回 true。这也是为什么大量老代码在 JDK 25 上仍然能反射自己的私有成员。

再看 JDK 内部:

var value = String.class.getDeclaredField("value");
System.out.println("jdkInternal=" + value.trySetAccessible());

本机输出:

jdkInternal=false

java.base 不把 java.lang 打开给无名模块,trySetAccessible() 返回 false,不会抛异常。如果继续 value.get(...),会得到 InaccessibleObjectException。用 setAccessible(true) 则直接抛 InaccessibleObjectException,因为它是「失败就抛」的版本。新代码应该优先用 trySetAccessible(),把「打不开」当成可处理的分支,而不是让异常穿透。

访问检查还有一个容易忽略的细节:Method 对象自己带着 accessible 标志。对同一个方法查找两次,得到两个不同的 Method 实例;对第一个调用 setAccessible(true),第二个仍然是关闭的。下面的代码会抛 IllegalAccessException:

Method declared = PrivateTarget.class.getDeclaredMethod("secret", String.class);
try {
    declared.invoke(new PrivateTarget(), "x");
} catch (IllegalAccessException e) {
    System.out.println("withoutSetAccessible=" + e.getClass().getSimpleName());
}

本机输出:

withoutSetAccessible=IllegalAccessException

如果确实需要复用,缓存已经打开的那个 Method 实例,不要在每次调用前重新查找。

在 libraryd 里落地

RecordIntrospector 只依赖 java.lang.reflect,放在 libraryd-domain 里,不引入第三方库:

package com.plume.library.domain;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.RecordComponent;
import java.util.LinkedHashMap;
import java.util.Map;

public final class RecordIntrospector {

    private RecordIntrospector() {
    }

    public static Map<String, Object> toMap(Object record) {
        Class<?> type = record.getClass();
        if (!type.isRecord()) {
            throw new IllegalArgumentException(type.getName() + " 不是 record");
        }
        Map<String, Object> values = new LinkedHashMap<>();
        for (RecordComponent component : type.getRecordComponents()) {
            try {
                values.put(component.getName(), component.getAccessor().invoke(record));
            } catch (IllegalAccessException | InvocationTargetException e) {
                throw new IllegalStateException("读取 " + component.getName() + " 失败", e);
            }
        }
        return values;
    }
}

调用方只需要一行:

Book book = new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008);
System.out.println("map=" + RecordIntrospector.toMap(book));

本机输出:

map={isbn=9780132350884, title=Clean Code, author=Robert C. Martin, year=2008}

getRecordComponents() 返回的顺序就是 record 声明字段的顺序,LinkedHashMap 保留这个顺序,审计日志读起来稳定。RecordComponent.getAccessor() 返回的是 Method,不需要按名字再查一次;这也是 record 比普通类更适合做字段提取的原因——组件列表是语言层面固定的。

第二个落点是审计事件。AuditTrail 不再关心具体实体,只接收映射:

import java.util.Map;

public final class AuditTrail {

    public void record(String action, Object entity) {
        Map<String, Object> fields = RecordIntrospector.toMap(entity);
        System.out.println(action + " " + fields);
    }
}

这样新增 Reservation 时,只要它是 record,审计代码不用改。反过来说,如果某个实体的字段需要脱敏(比如会员手机号),不能把 toMap 的结果直接写日志,要在实体或调用方做白名单过滤。反射让「拿到全部字段」变得容易,也让「不小心记录敏感字段」变得容易。

第三个落点是错误包装。Method.invoke 抛出的异常总是 InvocationTargetException,真正的原因在 getCause() 里。审计组件不应该把 InvocationTargetException 直接抛给业务层:

static Object invokeAndUnwrap(Method method, Object target, Object... args) {
    try {
        return method.invoke(target, args);
    } catch (InvocationTargetException e) {
        Throwable cause = e.getCause();
        if (cause instanceof RuntimeException runtime) {
            throw runtime;
        }
        throw new IllegalStateException("调用 " + method.getName() + " 失败", cause);
    } catch (ReflectiveOperationException e) {
        throw new IllegalStateException("无法调用 " + method.getName(), e);
    }
}

ReflectiveOperationException 是 NoSuchMethodException、IllegalAccessException、InvocationTargetException 的共同父类。分开捕获 InvocationTargetException 并拆 cause,可以让业务异常保持原来的类型;否则上层只能看到一层反射壳。

常见错误与边界

「用 getMethods 找私有方法。」 getMethods() 只返回 public 成员,私有方法必须用 getDeclaredMethod。反过来,getDeclaredMethods() 不含继承方法;要同时覆盖两者,需要沿 getSuperclass() 逐层查找。大多数业务代码只需要 getDeclaredMethod 加精确参数类型。

「按参数个数找重载。」 getMethod(name, parameterTypes) 需要精确类型。findByAuthor(String) 和 findByAuthor(Object) 是两个方法,传 Object.class 找到的是后者。如果参数类型来自运行时值,用 getClass() 可能拿到子类(比如 String 的匿名子类不存在,但集合实现类很多),最好用声明类型或显式白名单。

「把 InvocationTargetException 当成业务异常。」 反射调用的业务方法抛 LibraryException 时,invoke 抛的是 InvocationTargetException,getCause() 才是 LibraryException。不拆 cause 的话,上层按 LibraryException 捕获会失败,日志里也看不到真实错误码。

「在热路径上反复查找 Method。」 getMethod 每次都会遍历方法表,invoke 还有参数装箱和访问检查。审计、序列化这类每秒几千次的操作,应该把 Method 缓存在 Class 为键的 ConcurrentHashMap 里;更高频的路径不应该用反射,应该用代码生成或 MethodHandle。

「以为 setAccessible 能打开一切。」 JDK 内部的 java.lang、java.util 私有成员在 JDK 25 默认不打开,trySetAccessible() 返回 false。依赖 String.value、ArrayList.elementData 这类字段的代码,升级 JDK 时会直接失效。需要访问 JDK 内部结构时,优先找公开 API;找不到就说明方案本身依赖了实现细节。

小结

  • Class 可以从实例、类字面量或 Class.forName 获得;同一个类加载器里它们是同一个对象,跨加载器则不相等。
  • getMethod 找 public 及继承方法,getDeclaredMethod 找当前类所有声明方法;重载必须给精确参数类型。
  • Method.invoke 返回 Object,基本类型会装箱;业务异常包在 InvocationTargetException 里,要拆 cause。
  • trySetAccessible() 返回布尔值,适合处理「可能打不开」;JDK 内部包默认不打开,String.value 在 JDK 25 上返回 false。
  • record 的 getRecordComponents() 提供稳定的字段顺序和访问器,是审计字段提取的理想入口。
  • 反射适合低频、通用的基础设施;高频路径要缓存 Method,更高频时改用 MethodHandle 或代码生成。

反射让我们能在运行时读方法、调方法,但每次都要手写「查找 + 调用 + 异常处理」。下一节把「什么时候重试、什么时候记审计」写进注解,让这些规则跟着方法声明走。

阅读导航:上一节:1.3 类型擦除与类型令牌:TypeReference 与反射补偿 · 下一节:2.2 注解与运行时元数据:@Audit 与 @Retry 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发