ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java反射核心面试题:Class对象、对象创建、性能与避坑

Java反射核心面试题:Class对象、对象创建、性能与避坑

简介:面向Java求职者与初中级开发者的反射面试题整理,以问答形式梳理11个高频问题,帮助在面试前快速建立知识框架并查漏补缺。内容从“除new之外如何创建对象”切入,比较反射与new的效率差异,说明反射在JDBC驱动加载、Hibernate/Struts/Spring等框架中的典型应用,并系统归纳获取Class对象的四种方式、Class/Field/Constructor/Method等核心API、反射使用步骤及动态创建实例的两种写法。对反射优缺点也有展开:既强调运行时动态获取类信息带来的灵活性,也指出性能开销与封装性破坏风险,并给出setAccessible(true)、实例缓存、ReflectASM字节码加速等优化思路,配有代码片段便于对照理解。包内为1个PDF文件,约569KB,篇幅精简、便于打印或移动端复习。目前已有229人学习,适合需要系统梳理反射面试要点、补充框架底层认知的读者。

1. 十一道反射题拆开看:从「除了 new 还能怎么造对象」开始

面试官问「除了 new 还有什么方式创建对象」,多数人能答出反射两个字;再往下追问Class.newInstance()和Constructor.newInstance()差在哪、setAccessible(true)关掉的到底是什么检查、为什么 JDK 9 之后有些反射代码突然报错,回答就开始含糊了。这份《06-Java反射面试题(11题)-新增.pdf》把这类追问收成了 11 个具体问题:创建对象的替代路径、反射与 new 的效率差、Class 对象的四种获取方式、Field/Method/Constructor 四个核心类的分工,以及反射破坏封装之后有哪些补救手段。它不是入门语法教程,而是一份能逐题落到可运行代码上的问题清单。适合已经写过 Java、但对反射只停留在「知道有这么个东西」阶段的开发者,也适合需要在项目里写通用工具类、序列化组件、依赖注入容器的工程师。每题配一段能在 IDE 里直接跑的验证代码,比通读一遍强得多。

2. 拿到 Class 对象:四种入口在类加载上的真实差别

反射的所有操作都从Class对象开始,而获取它的四种写法经常被当成等价选项背下来。实际写代码时这四种入口的执行时机完全不同,选错了会在静态初始化块、类加载器隔离、异常类型上连翻几次车。

2.1 四种写法分别在类加载的哪个阶段停下

JVM 加载一个类大致分加载、链接、初始化三步,初始化阶段才执行静态代码块和静态字段赋值。四种获取方式的差别就卡在这里:

写法示例是否触发初始化典型用途
类字面量Person.class否编译期已知类型,最安全
Class.forName 单参Class.forName("reflection.Person")是类名来自配置文件的场景
Class.forName 三参Class.forName(name, false, loader)由 initialize 参数决定只想探测类是否存在
实例 getClassperson.getClass()对象已存在,必然已初始化已有实例,反查运行时类型
包装类 TYPEInteger.TYPE否拿基本类型的 Class,如int.class

Person.class这类字面量写法不会触发初始化,这一点很多人没意识到。曾经有人在静态块里写日志做埋点,结果换成Xxx.class方式后日志一直不打印,排查了半天以为是日志框架配置问题,血泪经验。

Class.forName单参版本内部调用的是forName(name, true, currentLoader),第二个参数initialize=true意味着类初始化会同步发生。数据库驱动注册就是靠这个特性工作的:驱动类的静态块里把自己注册进 DriverManager,所以必须用会触发初始化的那个版本去加载。

2.2 一段能跑出差异的验证代码

把下面这个类丢进 IDE,包名按自己的工程改一下,直接运行就能看到两种写法的行为差异。

package reflection; public class LoadProbe { // 静态块只在初始化阶段执行,用来观察触发时机 static { System.out.println("[init] LoadProbe 静态块执行"); } public static void main(String[] args) throws Exception { System.out.println("--- 1. 类字面量 ---"); Class<?> c1 = LoadProbe.class; // 只加载,不初始化 System.out.println(c1.getName()); System.out.println("--- 2. forName 单参 ---"); // 包名替换成自己的,第二个参数隐式为 true Class<?> c2 = Class.forName("reflection.LoadProbe"); System.out.println("same class? " + (c2 == c1)); System.out.println("--- 3. forName 双参,initialize=false ---"); Class<?> c3 = Class.forName( "reflection.LoadProbe", false, // 明确不初始化 LoadProbe.class.getClassLoader()); // 指定类加载器 System.out.println("same class? " + (c3 == c1)); System.out.println("--- 4. 实例 getClass ---"); LoadProbe p = new LoadProbe(); // new 必然走初始化 System.out.println("same class? " + (p.getClass() == c1)); } }

代码分四段,每段只做一件事:取 Class、打印是否与第一次拿到的是同一个对象。运行结果里[init]只会出现一次,出现在第 2 段的forName调用处,或者在第 4 段new的时候——取决于你把第 2 段注释掉没有。这说明 JVM 保证类只初始化一次,而Class对象在同一个类加载器下是同一个引用,==比较成立。

关键参数说明:Class.forName(String name, boolean initialize, ClassLoader loader)三个参数分别是全限定类名、是否立即初始化、用哪个类加载器去加载。第三个参数在容器环境和自定义类加载器场景下必须显式传,否则默认用调用方的加载器,容易出现同一个类被加载两次、instanceof判断为 false 的诡异现象。

2.3 什么时候该用 forName,什么时候不该

类名在编译期就固定,直接用Xxx.class。它不抛ClassNotFoundException,编译器还能帮你检查拼写,配合泛型写Class<Person>也比Class<?>干净得多。写框架、写工具方法时优先考虑这种。

类名来自配置文件、注解值、命令行参数,用Class.forName。典型场景是驱动类名、策略实现类名、插件入口类名。这时要接受两件事:一是必须处理ClassNotFoundException,二是必须想清楚要不要触发初始化。如果只是想判断某个类在不在 classpath 上,用forName(name, false, loader)更稳妥,避免加载一个带副作用的静态块。

已经有对象了,getClass()是最省事的,反射里做参数类型匹配、日志打印真实运行时类型都靠它。要注意的是它返回的是运行时类型,不是声明类型,父类引用指向子类实例时拿到的是子类 Class,这在做方法分发时反而是想要的效果。

基本类型的 Class 只能靠Integer.TYPE或int.class拿,反射里匹配参数类型时经常需要它。getMethod传参数类型数组时,int.class和Integer.class是两个完全不同的东西,写错就抛NoSuchMethodException,这个坑后面还会提到。

3. 反射造对象的两条路:newInstance 与 Constructor 的取舍

拿到 Class 之后,创建实例有两种路径:Class.newInstance()和先取Constructor再newInstance(args)。前者看起来简洁,实际在 JDK 9 之后已经被标记为废弃,新代码不应该再用。

3.1 Class.newInstance() 的三个隐式约束

clazz.newInstance()的调用形式很短,代价是把约束藏了起来:

第一,目标类必须有可访问的无参构造器,没有就抛InstantiationException,构造器私有时抛IllegalAccessException。第二,它只能调用无参构造,需要传参的场景直接没戏。第三,也是最容易被忽略的一点:如果构造器内部抛了受检异常,Class.newInstance()会把它原样「偷渡」出来,不包装成InvocationTargetException。这意味着调用方编译器看不到这个受检异常,运行时却可能收到,检查型异常体系被绕过了,这是它被废弃的核心理由。

// 已废弃写法,仅用于理解差异,新代码不要用 Class<?> clazz = Class.forName("reflection.Person"); Person p = (Person) clazz.newInstance(); // 要求无参构造器,且异常语义混乱

这段代码能跑通的前提是Person有一个 public 无参构造。如果构造器里抛了IOException,编译期完全无感,运行时直接冒出来,调用方一脸问号。新项目里看到这种写法,建议直接替换掉。

3.2 getDeclaredConstructor 的精确匹配

带参构造要走Constructor路径。getDeclaredConstructor(Class<?>... parameterTypes)按形参类型精确匹配,newInstance(Object... initargs)传实参。这两步的类型必须一一对上,顺序不能错。

Class<?> clazz = Class.forName("reflection.Person"); // 按形参类型精确匹配三参构造器 Constructor<?> ctor = clazz.getDeclaredConstructor( String.class, // 第一个形参:姓名 String.class, // 第二个形参:性别 int.class // 第三个形参:年龄,注意是 int.class 不是 Integer.class ); // 实参按同样的顺序传入 Object person = ctor.newInstance("user-a", "male", 20); System.out.println(person);

先说getDeclaredConstructor:它的参数是「构造器形参类型的 Class 数组」,int.class和Integer.class是两个不同的匹配目标,写错了直接NoSuchMethodException。再说newInstance:它的参数是「实参数组」,基本类型会自动装箱,所以传20没问题,但形参类型声明那一侧必须是int.class。这两个地方的差异是新手最容易翻车的位置。

如果类里有多个重载构造器,getDeclaredConstructors()能拿到全部,遍历时可以用getParameterCount()和getParameterTypes()做筛选,适合写通用实例化工具。

3.3 私有构造器与 setAccessible 的边界

setAccessible(true)关掉的是 Java 语言的访问检查,不是 JVM 的内存保护。它可以让你调用私有构造器、读写私有字段,这也是反射「破坏封装」说法的来源。单例模式常被拿来做反面教材:

public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} // 私有构造器 public static Singleton getInstance() { return INSTANCE; } }
// 反射打破单例 Class<?> clazz = Class.forName("reflection.Singleton"); Constructor<?> ctor = clazz.getDeclaredConstructor(); ctor.setAccessible(true); // 关掉访问检查 Singleton second = (Singleton) ctor.newInstance(); System.out.println(second == Singleton.getInstance()); // false

这段代码输出的false就是封装被绕过的证据。防御手段是在私有构造器里加计数判断,第二次进入时抛异常。不过要清楚,setAccessible在 JDK 9 引入模块系统之后对 JDK 内部类不再无条件生效,java.lang、java.util里的很多类会抛InaccessibleObjectException,需要启动参数--add-opens才能打开。自己业务代码里的类不受这个限制,因为它们在未命名模块中,默认是开放的。

调用setAccessible之前,稳妥的做法是先判断ctor.isAccessible()(JDK 9 起建议用canAccess),避免重复设置。另外在安全管理器开启的环境里这一步可能被直接拒绝,抛SecurityException,需要提前和运维确认策略文件。

4. 反射的性能账:setAccessible、缓存与 MethodHandle 的三级提速

「反射比 new 慢」这句话本身没错,但慢多少、慢在哪、什么量级下可以忽略,很少有人拆开算。面试里答一句「反射需要解析字节码所以慢」能过关,工程里照这个结论做优化,往往优化错了地方。

4.1 一次反射调用到底慢在哪

把一次method.invoke(target, args)拆开,开销大致来自四处:

第一处是查找。getDeclaredMethod每次调用都要遍历方法表做名称和参数类型匹配,返回的Method对象还是防御性拷贝,不复用的话每次都新建。第二处是访问检查。invoke内部要检查调用方是否有权限访问目标方法,setAccessible(true)把这个检查的缓存标记打开,后续调用可以跳过。第三处是参数打包。可变参数Object...会创建一个Object[],基本类型还要装箱拆箱。第四处是调用本身无法内联,JIT 对反射调用的优化远不如直接调用,热点代码里这一项占比很高。

对照实验里,方法体只有一行赋值时,反射调用耗时是直接调用的几十倍;方法体里有几十毫秒的业务逻辑时,这个差距被完全淹没。所以优化的第一步不是提速,而是判断这段代码是否真的在热点路径上。

4.2 缓存 Method 对象是最划算的一步

查找开销是四处里最容易消掉的。把Class、Method、Constructor缓存进ConcurrentHashMap,一次查找多次复用,剩下的只有访问检查和参数打包。

public final class MethodCache { // key 用 类名#方法名#参数个数 近似唯一标识,生产环境建议带上完整参数类型 private static final ConcurrentHashMap<String, Method> CACHE = new ConcurrentHashMap<>(); public static Method get(Class<?> clazz, String name, Class<?>... paramTypes) { String key = clazz.getName() + "#" + name + "#" + paramTypes.length; return CACHE.computeIfAbsent(key, k -> { try { Method m = clazz.getDeclaredMethod(name, paramTypes); m.setAccessible(true); // 缓存前一次性关掉访问检查 return m; } catch (NoSuchMethodException e) { throw new IllegalArgumentException("方法不存在: " + k, e); } }); } }

这段代码的关键在computeIfAbsent里的setAccessible(true):它在对象进入缓存之前执行一次,后续所有调用都复用这个已关闭检查的Method,省掉了每次调用前的权限判断。key 用字符串拼接是简化写法,参数类型只用了个数,遇到同名同参数个数的重载会误命中;生产环境应该把paramTypes的完整类名数组也拼进 key,或者用自定义的不可变 key 对象。

参数说明:getDeclaredMethod只找本类声明的方法,父类继承来的方法找不到;要找继承链上的 public 方法用getMethod。这两个方法名只差一个词,语义差很远,是后面避坑章节的一条。

4.3 从反射到 MethodHandle 的过渡

JDK 7 引入的MethodHandle提供了另一条路,它在查找阶段就完成访问检查,调用时更接近直接调用,JIT 对它的优化也更友好。

MethodHandles.Lookup lookup = MethodHandles.lookup(); MethodType type = MethodType.methodType(String.class, String.class); // 返回值 + 参数类型 MethodHandle mh = lookup.findVirtual(Person.class, "greet", type); Person p = new Person("user-a", "male", 20); String result = (String) mh.invokeExact(p, "hello"); // invokeExact 要求类型严格匹配 System.out.println(result);

findVirtual用于实例方法,findStatic用于静态方法,findConstructor用于构造器。MethodType的声明顺序是「返回值类型在前,参数类型在后」,和直觉相反,第一次写基本都会搞错。invokeExact要求实参类型与MethodType完全一致,多一个少一个、装箱类型不对都会抛WrongMethodTypeException,灵活度不如invoke,但性能更好。

再往上走是LambdaMetafactory把方法句柄转成函数式接口,接近「反射的完全替代品」,代价是初始化复杂、生成的代理类难以调试。第三方字节码工具(如输入材料里提到的 ReflectASM 思路)走的是另一条路:启动时生成一个直接调用的字节码类,绕开反射。这类方案适合固定几个方法、调用量极大的场景,引入之前掂量一下依赖成本和维护成本。

方案查找开销调用开销适用场景
直接调用无最低能编译期确定就别用反射
反射 + 缓存 + setAccessible一次性中通用工具类、框架内部
MethodHandle一次性较低高频调用、类型稳定
字节码生成一次性,较贵接近直接调用极热路径、方法集合固定

提示:不要凭感觉做反射性能优化。先跑一次粗测,把直接调用和反射调用的耗时打出来,量级差距小于一个数量级时,优先考虑可读性和维护成本。

5. 避坑与排查:反射翻车的五个现场

反射的报错信息普遍不友好,异常类型多、堆栈深、根因藏在cause里。下面五条是实际写代码时最常撞上的现场,按「现象 → 原因 → 解决」拆开写。

5.1 五个高频现场

现场一:明明有这个方法,却抛NoSuchMethodException。

现象是父类里定义的 public 方法,子类上调用getDeclaredMethod死活找不到。原因是getDeclaredMethod只搜索当前类声明的方法,不往父类走,而getMethod才会沿继承链向上找,但只返回 public 的。解决方式是根据意图选方法:找继承来的 public 方法用getMethod,找本类的私有方法用getDeclaredMethod,实在不确定就先getDeclaredMethod失败再getMethod兜底。另一个同类原因是参数类型没写对,int.class写成了Integer.class,报错信息完全一样。

现场二:setAccessible(true)抛InaccessibleObjectException。

现象是操作 JDK 内部类(比如往ArrayList的elementData里塞东西)时,JDK 9 以上直接报这个异常。原因是模块系统对 JDK 内部包做了强封装,默认不开放反射访问。解决方式是启动参数加--add-opens java.base/java.util=ALL-UNNAMED,把目标包对未命名模块开放。业务代码里的类不受影响,因为它们在未命名模块中默认开放。升级 JDK 时这类问题会集中爆发,属于必须提前排查的兼容性项。

现场三:反射调用明明目标方法抛了异常,堆栈里看到的却是InvocationTargetException。

现象是 catch 到的是InvocationTargetException,真正的业务异常被包了一层。原因是Method.invoke把所有目标方法抛出的异常统一包装,用来区分「发射过程出错」和「目标方法出错」。解决方式是 catch 之后取getCause()再抛或再判断,日志里也优先打 cause,否则排查时看到的堆栈指不到真正的出错行。这一条几乎每个写反射工具类的人都会踩一次。

现场四:想用反射改static final常量,改了没生效。

现象是Field.set执行成功没报错,但读出来还是旧值。原因是编译期常量(字面量赋值的static final)在编译阶段就被内联进调用方字节码,运行时改字段值影响不到已经内联的副本。解决方式有两种:一是别改常量,改成方法返回值或配置项;二是只对非编译期常量(比如new出来的对象引用)做反射替换,并且清楚这只对未内联的读取生效。这件事没有后悔药,设计阶段就该避开。

现场五:泛型方法的参数类型匹配不上。

现象是用getDeclaredMethod("save", List.class)找不到save(List<String> list)。原因是泛型擦除后形参类型就是List,这本来是对的;真正出问题的是反过来用getGenericParameterTypes()拿到ParameterizedType去和List.class比较,类型不相等。解决方式是把「擦除后的类型」用于方法查找,把「泛型类型」用于序列化和参数校验,两套 API 各管各的:getParameterTypes()返回擦除后的 Class 数组,getGenericParameterTypes()返回带泛型信息的 Type 数组。

5.2 排查顺序:从异常类型倒推

反射出问题时按固定顺序看,比盲目改代码快得多:

  1. 看异常类型。ClassNotFoundException是类名或类加载器问题;NoSuchMethodException/NoSuchFieldException是名称或参数类型问题;IllegalAccessException是访问权限没打开;InvocationTargetException是目标方法自己抛的;InstantiationException是抽象类、接口或缺少无参构造。
  2. 看cause。凡是InvocationTargetException,先printStackTrace打全链,根因一定在 cause 里。
  3. 打印实际查找用的签名。把clazz.getName()、方法名、Arrays.toString(paramTypes)一起打出来,和getDeclaredMethods()遍历的结果逐个比对,签名不一致的问题立刻现形。
  4. 确认类加载器。同一个类名被两个加载器加载会得到两个不同的 Class,缓存 key 里带上加载器标识能避免串味。

注意:反射相关的IllegalAccessException有一个容易误判的来源——方法本身是 public,但声明它的类不是 public。这时invoke一样会被拒绝,解法同样是setAccessible(true),而不是去改方法的修饰符。

6. 把反射收进工具类:参数匹配与一次线上验证

零散的反射调用写多了必然重复,最后都会收敛到一个ReflectUtils上。真正难写的不是调用本身,而是「按实参自动匹配重载方法」这段逻辑,写不好会在重载场景下静默调错方法。

一个够用的最小实现是这样的:

public final class ReflectUtils { private ReflectUtils() {} public static Object invoke(Object target, String methodName, Object... args) throws Exception { Method matched = null; for (Method m : target.getClass().getMethods()) { // 只找 public,含继承 if (!m.getName().equals(methodName)) continue; if (!assignable(m.getParameterTypes(), args)) continue; if (matched != null) { throw new IllegalStateException("重载方法无法唯一匹配: " + methodName); } matched = m; } if (matched == null) throw new NoSuchMethodException(methodName); matched.setAccessible(true); try { return matched.invoke(target, args); } catch (InvocationTargetException e) { throw (Exception) e.getCause(); // 还原业务异常 } } private static boolean assignable(Class<?>[] types, Object[] args) { int len = (args == null) ? 0 : args.length; if (types.length != len) return false; for (int i = 0; i < len; i++) { if (args[i] == null) { if (types[i].isPrimitive()) return false; // 基本类型不接受 null continue; } if (!box(types[i]).isInstance(args[i])) return false; } return true; } private static Class<?> box(Class<?> c) { if (!c.isPrimitive()) return c; if (c == int.class) return Integer.class; if (c == long.class) return Long.class; if (c == boolean.class) return Boolean.class; if (c == double.class) return Double.class; return c; // 其余基本类型按需补全 } }

匹配规则可以整理成一张对照表,比自己记更可靠:

形参类型实参情况是否匹配
intInteger20匹配,需装箱转换
Integernull匹配
intnull不匹配,基本类型不能为 null
Object任意非 null匹配,isInstance走继承判断
StringObject类型的实例不匹配,方向不能反

这段逻辑有两个刻意的设计。一是遇到多个重载都能匹配时直接抛异常,而不是取第一个——静默调错方法的排查成本远高于启动时失败。二是把InvocationTargetException拆掉再抛,让调用方拿到的异常和直接调用一致,日志里的堆栈也能指到目标方法内部。

验证方法很简单:造一个带重载方法的类,分别传20、null、"text"三种实参,看是否命中预期的方法、异常类型是否符合表格。我在一次线上问题里吃过亏:某个通用调用工具对重载只取了第一个匹配,参数是null时命中了一个String版本,结果本该走Integer版本的分支被静默跳过,业务数据错了一整天才被发现。从那以后我每次写反射工具类,都强制先跑一遍这三个实参的用例,再补一个「目标方法主动抛异常」的用例确认 cause 被正确还原。这套小验证花不了十分钟,能省掉的排查时间是以天计的。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进