ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度解析Java反射:从JVM底层原理到框架设计实战

深度解析Java反射:从JVM底层原理到框架设计实战 先说一个很多人在面试里被问懵的问题Spring往容器里注册Bean时它根本不知道你定义的类长什么样它凭什么能把你的类实例化出来答案可能简单得让人意外——反射。你在类上标注的各种注解为什么框架能读到你写的接口方法MyBatis为什么能把它和SQL自动绑定到一起底层还是反射。可以说Java世界里一半的“魔法”拆开看都是反射在干活。这篇文章我不打算只停留在“反射能拿到类的信息”这个层面而是从JVM类加载聊到动态代理从性能损耗聊到框架设计从异常排查聊到面试考点争取让即便没怎么用过反射的读者读完之后也能在项目里写出一段像样的反射代码并且知道哪些地方该用、哪些地方千万别硬上。1. 反射到底是什么藏在Class对象里的秘密1.1 从JVM角度理解反射的工作原理很多教程一上来就讲“反射就是运行时获取类信息”这句话没错但它把最关键的环节隐藏了。要先理解反射得先理解类在JVM里是怎么“活”起来的。Java源码经过编译变成.class字节码字节码在JVM里加载后被描述成一个对象这个对象的类型是java.lang.Class。每个类在JVM中只有唯一一个对应的Class对象。它相当于类的“户口本”记录了类的全限定名、修饰符、父类、接口、字段列表、方法列表、注解信息等全部元数据。常规创建对象的写法是new User()这是编译期就把类名绑定死了JVM在加载User类后直接通过字节码指令创建实例。反射则走了另一条路它在运行时阶段才去Class对象里“翻户口本”先找到构造器是什么、参数有哪些再通过构造器对象去创建实例。这个过程不用编译期写死类名甚至可以只用一个字符串去加载任意类。关键在于反射的入口就是那个唯一的Class对象。你可以通过三种方式拿到它// 方式一类名.class编译期就确定不触发静态代码块 ClassUser clazz1 User.class; // 方式二已创建实例的 getClass()运行时获取 User user new User(); Class? extends User clazz2 user.getClass(); // 方式三Class.forName()运行时按全限定名加载会触发静态代码块 Class? clazz3 Class.forName(com.example.User);三种方式有细微差别这个差异在面试里经常被追问。User.class在编译期就能确定Class对象类还没被加载到JVM时它就存在了而且不会触发类的静态初始化块。getClass()拿的是运行时实例的实际类型哪怕你的变量声明的是父类返回的也是子类的Class对象。Class.forName()最特殊它要求JVM立即加载并初始化类也就是说静态代码块、静态变量赋值都会执行。典型的JDBC驱动加载就是靠这个特性实现的Class.forName(com.mysql.cj.jdbc.Driver);不加这个驱动类根本不会被主动加载后续DriverManager就拿不到驱动实例。1.2 反射的能力边界拿到Class对象只是开始真正的重头戏是它能做什么。我整理了一个反射能力清单你可以对照感受一下能力涉及的API典型用途获取类的基础信息getName()、getModifiers()、getSuperclass()、getInterfaces()框架做类型判断、父子关系解析操作字段getFields()、getDeclaredFields()、getField()ORM把数据库字段映射到对象属性操作方法getMethods()、getDeclaredMethods()、getMethod()动态调用任意方法操作构造器getConstructors()、getDeclaredConstructor()绕过new关键字创建实例操作注解getAnnotation()、getAnnotations()Spring读到Component等注解获取泛型信息getGenericType()把ListUser里的User解析出来动态代理Proxy.newProxyInstance()AOP、RPC框架的底层核心模块系统信息getModule()Java 9之后特有的模块访问判断这里有个细节值得多说一句getFields()只能拿到public字段而且包含继承来的getDeclaredFields()能拿到本类声明的所有字段不管什么访问修饰符但不包含父类的。这俩的区别是所有反射初学者最容易踩的坑。我在博客里经常看到有人用getDeclaredFields()去找父类字段结果返回空数组然后一脸疑惑地发帖求助——其实不是代码写错了是API语义没分清楚。2. 核心API深度拆解Constructor、Method、Field的实际用法2.1 构造器对象的获取与实例创建反射创建实例最常见的姿势是Class.newInstance()但这个方法在Java 9之后已经被标记为过时了原因是它只支持无参构造器有参构造器会直接抛异常。更推荐的姿势是拿到Constructor对象再调用newInstance()。假设有个User类public class User { private Long id; private String name; private Integer age; public User() {} public User(String name) { this.name name; } // getter/setter 省略 }反射创建有参实例的完整写法Class? userClass Class.forName(com.example.User); Constructor? constructor userClass.getConstructor(String.class); Object user constructor.newInstance(张三);getConstructor()的参数是参数类型的Class对象必须和构造器签名完全一致。这里要留意的是getConstructor()只能拿public构造器如果需要拿私有构造器就得用getDeclaredConstructor()再配合setAccessible(true)。setAccessible(true)这个操作我多说几句。很多刚接触反射的人以为这是“魔法开关”打开之后什么都能碰了。它本质上是关闭了Java语言层面的访问权限检查让JVM允许你越过private的限制。从反射对象创建、字段写入到方法调用只要过了这个门槛一切私有成员都能触碰。这带来的问题是安全风险一些过度使用反射的代码会让原本封装良好的类变成“透明玻璃盒子”。所以业务代码里要谨慎能用访问器就先用访问器。2.2 字段操作与私有属性读写字段反射最常见的使用场景是对象属性拷贝和ORM框架。一个简单的字段反射示例Class? userClass Class.forName(com.example.User); Object user userClass.getDeclaredConstructor().newInstance(); Field nameField userClass.getDeclaredField(name); nameField.setAccessible(true); nameField.set(user, 李四); Object name nameField.get(user); System.out.println(name); // 输出李四从这段代码能看到反射的威力你在外部直接改了私有字段的值。这在正常的Java语法里是不可想象的。但要注意setAccessible(true)不是免费的它和JVM的安全机制是有冲突的。在SecurityManager环境下虽然现在很少见了这个调用可能会被拦截。另外从Java 17开始默认强封装JDK内部API反射无法修改JDK内部类的字段这也是为什么要保持JDK版本和框架版本兼容的原因之一。字段反射还有一个高频问题基本类型和包装类型的对应。nameField.set(user, 李四)传字符串没问题但如果字段类型是int而你传入的是IntegerJVM会自动拆箱如果传的是Long就会因为类型不匹配直接报IllegalArgumentException。反射没有编译期类型检查所有类型错误都要到运行时才暴露写代码时一定要反复确认字段的真实类型。2.3 方法调用与参数匹配的那些坑方法反射的套路和字段类似先getMethod或getDeclaredMethod再invoke。这里有几个坑我逐个说第一个坑getMethod对方法名和参数列表是精确匹配的。如果方法有重载参数类型不一致就会NoSuchMethodException。比如方法是setName(String name)你需要用getMethod(setName, String.class)写成getMethod(setName, Object.class)就会找不到方法。第二个坑getMethod只能拿public方法包含继承的getDeclaredMethod只能拿本类声明的方法不包含继承。实际开发中经常遇到的场景是想调用父类的private方法用getDeclaredMethod在自己的类上找是找不到的必须先getSuperclass()拿到父类Class再在父类上找。第三个坑invoke方法的第一个参数是实例对象。调用静态方法时这个参数可以传null。调用实例方法时如果传了null会抛NullPointerException底层会包成InvocationTargetException。很多人在静态方法上犯了迷糊把第一参数传成空对象结果找不到实例去调用。一个完整的方法反射示例Class? userClass Class.forName(com.example.User); Object user userClass.getDeclaredConstructor().newInstance(); Method setNameMethod userClass.getMethod(setName, String.class); setNameMethod.invoke(user, 王五); Method getNameMethod userClass.getMethod(getName); Object result getNameMethod.invoke(user); System.out.println(result); // 输出王五invoke返回的对象是Object类型实际类型需要强转。这是反射代码里ClassCastException的重灾区。比如getName()返回的是Stringinvoke返回的却是Object等你强转的时候才知道类型不匹配。要避免这个问题可以在泛型上做文章比如用Method配合泛型方法签名但说实话到了反射这个层面泛型已经形同虚设基本靠编译期或者测试兜底。3. 动态代理深度解析反射的杀手级应用3.1 从一个接口开始说起反射最精彩的用法不是读字段、调方法而是动态代理。动态代理是什么它在运行时动态生成一个代理类这个代理类实现了你指定的接口代理类里每个方法的调用都会转发给InvocationHandler的invoke方法统一处理。这个机制是Spring AOP、MyBatis Mapper代理、各种RPC框架的基石。先看JDK动态代理的标准模板public interface UserService { void save(String name); } public class UserServiceImpl implements UserService { Override public void save(String name) { System.out.println(保存用户 name); } } // 调用处理器 public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(方法开始 method.getName()); Object result method.invoke(target, args); System.out.println(方法结束 method.getName()); return result; } }代理对象的生成方式UserService userService new UserServiceImpl(); InvocationHandler handler new LogInvocationHandler(userService); UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, handler ); proxy.save(张三); // 输出 // 方法开始save // 保存用户张三 // 方法结束save这段代码的逻辑很清晰Proxy.newProxyInstance接收三个参数——类加载器、接口列表、调用处理器。JVM在运行时动态构造代理类并返回实例。此后调用proxy.save()实际执行的是handler.invoke()再在invoke里通过反射调用真实对象的方法。这个过程中方法名、参数类型、返回值类型全都靠反射解析没有反射动态代理就是空中楼阁。3.2 JDK动态代理为什么只支持接口这是面试里高频追问的问题。答案的关键在于动态代理生成的类已经是java.lang.reflect.Proxy的子类了Java只支持单继承而接口天生支持多实现所以代理类只能通过实现接口来扩展能力。如果我们要代理一个普通类JDK动态代理就无能为力了。那Spring是怎么解决这个问题的答案在CGLIB。CGLIB通过字节码技术直接生成目标类的子类通过重写父类方法实现代理效果所以它不要求目标类实现接口。但CGLIB有它自己的限制目标类不能是final的被代理的方法也不能是final的private方法也无法被代理——因为子类根本看不到父类的私有方法。Spring在选择代理方式时有一个默认策略目标类实现了接口就优先用JDK动态代理没有实现接口才回退到CGLIB。这个策略在Spring Boot 2.x之后有了调整默认用CGLIB因为CGLIB代理对接口实现的依赖更少处理起来更统一。平时如果只需要给接口加日志、加事务JDK动态代理就完全够用如果需要代理一个没有接口的遗留类CGLIB是唯一选择。两种方式各有优劣理解原理比背诵结论重要得多。3.3 动态代理的实际应用场景在我接触过的项目里动态代理用得最多的场景大概是这几种日志切片。在方法前后自动打日志不用在每个业务方法里手动写。用动态代理包一层invoke里记录开始时间、结束时间、参数序列化结果一劳永逸。事务管理。Spring的Transactional就是靠动态代理在方法前后开启、提交、回滚事务的。代理对象拦截到方法调用先检查有没有Transactional注解有就开启事务调用完再判断提交还是回滚。接口幂等处理。在invoke里先判断参数哈希如果同一参数的请求已经处理过就直接返回缓存结果避免重复提交。MyBatis Mapper。MyBatis启动时会扫描Mapper接口为每个方法创建代理对象。你在接口上写Select(select * from user)MyBatis的MapperProxy在invoke时解析注解里的SQL走JDBC执行然后把结果集映射成对象返回。这些场景的共同特点是你无法在编译期预知有哪些方法会被调用但你有统一的拦截逻辑。动态代理把编译期的未知转化为运行期的统一处理这是它的核心哲学。4. 反射在框架设计中的应用没有反射的Java世界会怎样4.1 Spring的IoC容器就是反射的集大成者很多人刚开始学Spring时觉得bean配置和Component注解很神奇——框架到底是怎么把对象创建出来并塞进容器的答案就是反射。Spring容器在启动时会做这么几件事扫描指定路径下的类文件读取类的注解信息根据Component、Service、Repository等注解判断是否要注册为Bean。接着通过构造器反射创建实例再通过字段反射执行依赖注入把Autowired标注的字段赋值成容器里已有的Bean。最后如果目标类需要AOP就通过动态代理生成代理对象放入容器。这个过程一步都离不开反射。如果不用反射你需要手动用new创建所有对象、手动维护对象之间的依赖关系、手动在代码里写死所有配置。Spring的出现让Java开发从“硬编码对象”变成了“声明式装配”底层靠的就是反射对类信息的动态读取能力。我自己在写框架类组件时也常常利用反射做一个通用处理比如参数校验。写一个注解NotBlank然后在框架代码里遍历对象的所有字段哪个字段上标注了这个注解就用反射读取字段值为空就抛异常。这样业务代码只需要加注解不用写一堆重复的if判断。4.2 注解解析与反射的黄金搭档注解本身就像标签贴在类、方法、字段上。但只有通过反射这些标签才会被读取并产生行为。一个自定义注解从定义到生效走的就是反射解析的流程Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface Column { String name(); }public class User { Column(name user_name) private String name; }在读取的时候for (Field field : clazz.getDeclaredFields()) { Column column field.getAnnotation(Column.class); if (column ! null) { String columnName column.name(); // 拿到映射关系后做SQL拼接、字段映射等操作 } }这套模式在ORM框架里被用得淋漓尽致。Table标识表名、Column标识列名、Id标识主键框架启动时通过反射扫描实体类的所有字段把Java对象和数据库表结构建立映射。你写的实体类越规范框架需要写的配置就越少。有个容易忽略的细节自定义注解必须加上Retention(RetentionPolicy.RUNTIME)否则反射读不到它。为什么因为注解默认的保留策略是CLASS只存在字节码文件里JVM运行时不会保留它们。反射能读到的注解必须在运行时依然存在因此必须显式声明为RUNTIME。这个细节在面试题里出现频率很高但在实际项目里踩过坑的人真不少。4.3 反射与BeanUtils拷贝的恩怨写业务代码时我们经常用到BeanUtils.copyProperties()把一个对象的属性值拷贝到另一个对象。这个方法底层的逻辑就是获取源对象的所有getter方法获取目标对象的所有setter方法通过反射把值搬过去。如果你手动实现一个属性拷贝工具大概也是这个思路。这里有个性能相关的注意事项反射调用的开销远高于直接方法调用。如果在一个循环里拷贝几千个对象性能差异会很明显。Spring的BeanUtils在内部有一些缓存优化反射对象会被缓存起来但即便如此在大数据量的批量处理场景还是建议考虑手写属性映射或者直接用MapStruct这类编译期生成代码的工具。这是个典型的“框架方便但性能要权衡”的例子。5. 反射的性能问题与优化策略5.1 反射慢在哪关于反射性能老的资料经常会说“反射比直接调用慢十倍以上”。这个结论在Java 8之前基本成立但现在JVM做了不少优化实际差距已经缩小了但并没有完全消失。反射慢的原因主要有几个第一类型安全检查。每次反射调用JVM都要对访问权限、类型匹配做校验这比普通的虚方法调用要多好几道工序。第二方法查找。getMethod()在类的方法表里做搜索匹配参数类型要一一比对找到之后才能生成Method对象。第三强制装拆箱。反射方法调用传参时基本类型要装箱成包装类型返回值如果是基本类型又要拆箱这个过程有额外的对象创建开销。第四JIT优化受阻。编译器对热点代码的优化依赖类型信息而反射调用绕过了常规的静态类型系统JIT能做的内联、逃逸分析等优化策略常常失效。我做个简单的性能对比测试连续调用100万次一个空方法调用方式耗时参考值直接调用5ms左右反射调用缓存Method对象50ms左右反射调用每次getMethod800ms左右这个数据在不同的JDK版本、不同机器上会有差异但趋势很稳定方法查找的开销远大于反射调用本身。所以第一条优化原则就是把Method对象缓存起来不要在循环里反复getMethod()。5.2 实用优化手段结合我自己的项目经验反射优化的核心手段可以归纳为四条缓存Class对象和Method对象。反射对象一旦创建不会再变化完全可以放进Map缓存。Spring容器就是靠这种缓存机制才能做到启动时扫描所有Bean类信息而不必每次加载都重新反射。优先使用setAccessible(true)。关闭访问权限检查之后反射调用的性能会明显提升。虽然这有安全方面的副作用但在明确的框架场景里收益大于风险。避免在热路径上使用反射。如果在高性能要求的循环里处理大量对象优先考虑手写映射、MapStruct等编译期方案或者用MethodHandle替代反射。MethodHandle是Java 7引入的它比反射调用更快原因是它不需要做那么多安全检查。合理利用Class.forName的初始化语义。如果只需要拿到Class对象做类型判断不要用forName直接用类名.class如果需要加载驱动、执行静态代码块再用forName。5.3 现代JVM对反射的优化Inflation机制有一个面试官很喜欢问的点JDK反射调用的底层实现是怎么优化的答案是Inflation机制。JVM在反射调用次数较少时采用native方法实现速度较慢但开销低当调用次数超过阈值默认15次后会切换到动态生成的字节码版本速度大幅提升。这个阈值可以设置成-Dsun.reflect.inflationThreshold15。要验证这一点可以做个小实验连续调用反射方法1万次和100万次后者每次调用的平均耗时会更低。这不是缓存的原因而是JIT和动态字节码生成的共同作用。明白了这个机制你就知道单纯说“反射慢”其实不够准确更准确的说法是“冷反射慢热反射没那么慢”。6. 高频异常与排查思路实录6.1 ClassNotFoundException与NoClassDefFoundError这两个异常经常混在一起但本质不同。ClassNotFoundException是Class.forName()或ClassLoader.loadClass()在类路径里找不到指定类时抛出的它是受检异常代码里必须处理。NoClassDefFoundError则是JVM在运行时加载依赖类失败时报的错它是Error不是Exception常见于某个类在编译期存在、运行期缺失的场景比如打包时漏了依赖。排查思路很直接先用javap或者IDE查看类路径再用jar tf确认打出来的包里有没有对应的.class文件最后检查依赖冲突看是不是版本不对导致类改名了。如果是Web应用还要确认WEB-INF/lib下的jar包是否完整。6.2 NoSuchMethodException与方法签名不匹配这个异常的高发场景是反射调用方法时你传入的参数类型与实际方法签名不一致。比如一个方法写的是setAge(Integer age)你用getMethod(setAge, int.class)去查就会找不到方法。因为int.class和Integer.class在反射看来是两种完全不同的类型。排查时要先确认方法签名用getDeclaredMethods()把类所有方法打印出来对比方法的参数类型。另一个容易忽略的点是方法的返回值类型不参与方法查找getMethod只关心方法名和参数列表。6.3 IllegalAccessException与访问权限控制IllegalAccessException意味着你没有权限去访问这个方法或字段。最常见的原因有两类目标成员是private的而你用的是getMethod()只能拿public或者拿到了private成员但没调用setAccessible(true)。一个远程调用框架的调试案例我记得很清楚对方定义的接口方法不是public我在代理类里反射调用时抛了一堆IllegalAccessException。后来把setAccessible(true)加上问题就解决了。但要注意如果你拿到的类是从第三方jar包加载的而JVM开启了强封装Java 17默认开启setAccessible(true)也可能抛InaccessibleObjectException。这时需要在启动参数里加--add-opens或者老老实实走公开API。6.4 InvocationTargetException被包裹的真实异常这是反射里最坑的一个异常我把它放在最后说。反射调用目标方法时如果目标方法内部抛了异常反射框架会把这个异常包裹在InvocationTargetException里抛出来。你直接看到的是InvocationTargetException根本不知道业务方法里到底出了什么问题。排查的关键是用getCause()把原始异常挖出来try { method.invoke(user, args); } catch (InvocationTargetException e) { Throwable cause e.getCause(); cause.printStackTrace(); // 这里才是真正的根因 }很多同学在反射调用数据库操作时报错光看外层异常一头雾水一旦getCause()就发现是SQL语法错误或者空指针。记住这个习惯遇到InvocationTargetException第一步永远是getCause()。7. 反射相关面试题速查与高频考点7.1 八股文经典题目盘点结合我自己面试和被面试的经验反射相关的面试题大概可以归成几类问题核心回答要点什么是反射运行时动态获取类信息并操作类成员的能力入口是Class对象获取Class对象的方式.class、getClass()、Class.forName()区别在初始化和重载时机getFields和getDeclaredFields的区别前者只拿public包含继承后者拿本类全部但不包含继承setAccessible(true)的作用关闭访问权限检查可操作private成员有安全风险JDK动态代理和CGLIB的区别前者基于接口、反射实现后者基于继承、字节码技术实现反射的性能问题类型检查、方法查找、装箱拆箱、JIT受阻缓存setAccessible优化反射在框架中的应用Spring IoC/DI、MyBatis Mapper、Spring AOP、注解解析为什么Class.forName用于加载JDBC驱动需要执行静态代码块完成驱动注册这个表格基本覆盖了初级到中级面试的反射考点。再往上问可能会深入到MethodHandle、VarHandle、模块系统对反射的影响这些属于加分项但基础扎实了再展开。7.2 面试官追问的“高级点”除了基础题我遇到过几个比较扎心的高级追问第一个是“反射和new创建对象的区别”。表面答案是反射灵活、new简单但深入一点应该提到new是编译期类型检查的反射绕过编译期new创建对象时JVM直接根据字节码指令分配内存反射要经过方法查找和权限校验反射可以创建任意类甚至可以穿透模块限制在允许的情况下。第二个是“怎么让反射调用更快”。除了缓存Method和setAccessible(true)还可以提一下MethodHandle和LambdaMetafactory。MethodHandle比反射调用更贴近底层性能更好如果是在编译期就知道目标方法的签名可以用LambdaMetafactory直接生成调用接口性能和直接调用几乎一致。第三个是“为什么高并发下反射调用会出现性能瓶颈”。要答出两点一是反射调用栈更深JIT优化的机会少二是并发环境下sun.reflect内部有inflate机制的锁竞争问题。虽然现在的实现已经优化了不少但批量调用时依然建议评估性能。7.3 学习路径建议反射这块内容我的建议是分三步走第一步掌握基础API。写一个小工具类实现在任意对象上打印字段名和字段值遍历它所有的public和非public字段理解getFields和getDeclaredFields的区别。第二步跟框架源码结合。打开Spring的AnnotationConfigApplicationContext源码找到ClassPathScanningCandidateComponentProvider看看它怎么扫描类的注解再看MyBatis的MapperProxy体会动态代理在框架中的实际应用。第三步造一个小轮子。实现一个简化版本的IoC容器支持Component扫描、构造器注入、字段注入再给几个方法加上Log注解通过动态代理自动打印日志。这一步做完你会发现反射不再是一堆陌生API而是构建灵活框架的核心工具。8. 从项目角度再看反射什么时候该用、什么时候该躲8.1 适合使用反射的场景总结一下适合使用反射的几个特征通用性、动态性、低复杂度。通用性指的是做一个和具体类型无关的处理逻辑。比如参数校验框架它要校验的对象类型是无限的不可能为每个类型写一套校验代码反射遍历字段就成了唯一合理的实现方式。动态性指的是类信息在编译期不可预知。比如插件系统运行时外部传入一个类名你根本不知道这个类长什么样只能通过反射加载并调用。低复杂度指的是要操作的结构比较简单。如果一个对象内部嵌套了多层集合、泛型、继承链路反射处理起来会非常痛苦容易写出又臭又长且充满类型转换的代码这时候不如老老实实手写映射逻辑。8.2 谨慎使用反射的场景反射的代价明显性能下降、代码可读性下降、编译期类型安全消失。所以遇到下面这些情况我会尽量躲开第一追求高性能的核心链路。比如网关的请求转发、大流量的数据转换这些环节用反射做了一遍又一遍性能损耗会被无限放大。第二复杂继承体系的深度操作。反射对父类私有字段、多层泛型类型的读取非常繁琐稍有不慎就踩坑排查成本高。第三第三方SDK的内部类操作。这类类可能经过混淆字段名和方法名都不稳定反射去操作它们一旦SDK升级代码立刻失效。反射是一种“能力”但它解决问题的方式绕过了Java的封装和安全模型。用得恰当它是框架设计的灵魂用得泛滥它就是代码里的定时炸弹。我在代码评审里经常提醒同事如果不用反射也能实现需求而且代码不会因此变得重复臃肿那就不要用反射。8.3 一个反射注解的实用小封装分享一个我自己在项目里常用的封装思路利用反射注解实现一个通用的“敏感字段脱敏”工具。场景很常见接口返回用户信息时手机号、身份证号需要脱敏。先定义一个脱敏注解Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface Sensitive { SensitiveType type(); } public enum SensitiveType { MOBILE, ID_CARD }业务对象的字段打上注解public class UserVO { private String name; Sensitive(type SensitiveType.MOBILE) private String mobile; Sensitive(type SensitiveType.ID_CARD) private String idCard; }脱敏工具类核心逻辑public static void desensitize(Object obj) throws Exception { Class? clazz obj.getClass(); for (Field field : clazz.getDeclaredFields()) { Sensitive sensitive field.getAnnotation(Sensitive.class); if (sensitive null) { continue; } field.setAccessible(true); String value (String) field.get(obj); if (value null || value.isEmpty()) { continue; } if (sensitive.type() SensitiveType.MOBILE) { field.set(obj, value.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2)); } else if (sensitive.type() SensitiveType.ID_CARD) { field.set(obj, value.replaceAll((\\d{4})\\d{10}(\\w{4}), $1**********$2)); } } }调用的时候一行就搞定UserVO userVO userService.getUserById(1L); DesensitizeUtils.desensitize(userVO); return userVO;如果以后要新增脱敏类型只需要扩展枚举和工具方法业务代码一行都不用改。这种不对业务代码侵入太深的反射注解组合我觉得是反射在实际项目中比较优雅的用法。9. 聊点踩坑经验反射在真实项目里的那些教训9.1 一次线上问题类加载器不同惹的祸之前遇到过一个诡异的问题同一个类在两个地方加载通过反射创建的对象强转时抛了ClassCastException。查了很久才发现原因是同一个类被不同的类加载器加载了两次Class对象不同类型自然不兼容。这个问题在Web应用里特别常见。多个war包部署在同一个Tomcat里每个war包有自己的类加载器如果某个共享库里的代码用错误的类加载器去加载类就会造成类的不一致。排查方法是打印类的加载器System.out.println(obj.getClass().getClassLoader());一旦发现同一个类的加载器不同基本上就能锁定问题区域。解决思路统一类加载器、避免copy类到多个lib目录、使用上下文类加载器加载共享资源。9.2 反序列化与反射的纠缠还有一个容易踩的点是反序列化。Java原生序列化机制底层也大量使用了反射来重建对象但它绕过了构造器直接从字节流里恢复字段状态。这带来一个安全问题一个经过精心构造的恶意序列化流可以触发危险类的敏感方法执行。比如Runtime类本身不可序列化但攻击者可以构造一条调用链让反序列化时通过反射调用Runtime.getRuntime().exec()执行系统命令。这就是很多反序列化漏洞的核心。所以项目里如果用了反序列化一定要做类白名单校验严格控制哪些类可以被反序列化不要轻易信任外部输入。9.3 泛型擦除后反射还能读到类型吗这是很多人的认知盲区。泛型在编译后会擦除ListUser在运行时实际是ListgetGenericType()却可以在字段级别拿到完整的泛型信息。为什么因为字段的泛型签名会以属性签名Signature的形式存储在class文件的常量池里运行时通过getGenericType()读取这份签名。这个特性特别有用。比如你要写一个通用的JSON反序列化工具接收Type参数就是靠这种方法解析出集合里元素的真正类型。Gson的TypeToken、Jackson的TypeReference都是这个原理。不过需要说明的是方法局部变量的泛型是拿不到的只有字段、方法参数、返回值的泛型信息才保留在class文件中。10. 最后的最后反射这个东西学了基础API只是拿到了钥匙真正理解它是件需要靠项目经验慢慢沉淀的事。我在实际开发中最大的体会是反射解决的最核心问题就是把“什么时候调用、调用哪个类”这道选择题从编译期移交到运行期。编译期定死的代码是最稳妥的也是僵化的反射让代码拥有了一点“因势利导”的智能但这份智能是有代价的。我见过不少把反射当成炫技手段的代码方法里套方法、绕来绕去最后维护的人痛不欲生。我也见过把反射用得很克制的框架代码核心处用反射解决通用性问题外围再用缓存、编译期工具做辅助既有通用性又有性能。这两种境界之间隔着的是对代价的理解。如果要我给一个学习上的最终建议去造一个小型IoC容器让对象注册、依赖注入、代理增强都走一遍自己的代码这套流程跑通了你对反射的理解会超越绝大多数只背八股文的人。之后再回到框架源码你会发现那些曾经看起来像魔法的地方每一步都是标记好的路。反射不神秘它只是一个让Java可以在运行时自我审视和动态调整的工具而工具的价值永远取决于使用者的判断力。
RELATED READING

延伸阅读

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