1. 为什么要把 JSON 和反射放在一起聊
1.1 两者在 Java 开发中的真实位置
做 Java 开发的人,几乎每天都逃不开两样东西:一个是 JSON,一个是反射。JSON 是你跟前端、跟第三方系统、跟数据库之间传递数据的通用语言;反射则是 Java 这个语言里最“反直觉”但又最强大的一套机制,很多框架底层都在靠它干活。我刚入行时一直把它们当两个独立的知识点学,直到后来自己动手写过序列化工具、研究过 Spring 和 MyBatis 的源码,才意识到这两者的关系远比想象中紧密。
先说 JSON。现在的后端服务,接口返回的是 JSON,调用第三方接口收发的是 JSON,Redis 里缓存的数据经常也是 JSON,Kafka 消息体同样以 JSON 为主。可以说,一个 Java 工程师跟数据打交道的方式,基本都是“Java 对象转 JSON、JSON 转 Java 对象”这两个动作的反复循环。你写的每一行 Controller 代码、每一个 RPC 调用,背后都有序列化框架在默默工作。
再说明反射。反射给了程序在运行时“检查自己”的能力:一个类有哪些字段、哪些方法、哪些注解,在编译期可能完全不知道,但运行期通过反射可以获得。Spring 依赖注入靠它扫描 Bean 的字段和方法,MyBatis 靠它把数据库列映射到实体属性,Jackson 和 Gson 靠它读出对象的所有字段来完成序列化。换句话说,JSON 工具能做“对象转 JSON”这件事,本质上就是反射在支撑。
所以这两者放在一起学,不是生拉硬拽,而是本来就纠缠在一起。你理解了反射,才能真正理解 JSON 库底层干了什么;你理解了 JSON 序列化的需求,才知道反射机制在实际工程里是怎么被大规模使用的。这篇文章就把它们拆开揉碎,从底层原理讲到实战套路,再讲到那些文档里不会写的坑。
1.2 面试中绕不开的连环问
我在带团队面试时,特别喜欢把 JSON 和反射串起来问候选人。比如这样一连串问题:
- JSON 序列化框架是怎么知道你的对象有哪几个字段的?
- 它为什么能访问到 private 修饰的字段?
- 反序列化的时候,为什么有些类必须要有无参构造器?
- 为什么用泛型 List 接收 JSON 时,有时候拿到的是 List edhashmap> ?
- 反射的性能开销到底有多大?框架为什么还能用?
这些问题每一道都同时涉及 JSON 和反射。如果只背过“反射能拿到类的字段”“Jackson 可以把对象转成 JSON”这种零散结论,是答不连贯的。而把这些知识点串成一个体系之后,你不仅面试能过关,日常开发排查问题也会顺手很多。
2. JSON 在 Java 里的正确打开方式
2.1 主流 JSON 库的选型逻辑
Java 生态里 JSON 库的主流就三个:Jackson、Gson、Fastjson。很多新人上来就问“哪个快”“哪个好用”,我一般建议:默认 Jackson,除非项目里已有强约束。
Jackson 是目前生态整合最深的库。Spring Boot 的 web 模块直接用它做 HttpMessageConverter,Spring Cloud 的很多组件也基于 Jackson 进行配置序列化。它的 API 设计完整,支持注解、支持泛型反序列化、支持自定义序列化器,还有成熟的 Jackson-databind、Jackson-datatype-jsr310 等配套模块。从长期维护角度看,Jackson 出品方是 FasterXML 社区,版本迭代稳定,也没有闹过特别大的安全事故。
Gson 是 Google 家出的,特点是 API 极其精简,两三行代码就能完成序列化反序列化。如果你的项目是轻量级工具类、Android 客户端或者不想引入太多依赖,Gson 是个好选择。但它对复杂类型、日期时间类型、多态序列化的支持相对弱一些,遇到高级需求经常要写自定义适配器。
Fastjson 我个人的态度是谨慎。它在性能测试里经常能跑出很好的数据,API 用起来也顺手,但历史上爆出过多个反序列化远程代码执行漏洞。每次升级版本都得盯着安全公告,这在企业级项目里是挺头疼的事情。如果你在做新项目,没有特别理由的话,不建议作为首选。
顺带说一句,像“全电发票点了 pdf 却是 json”这种场景,本质是接口返回的内容类型和文件解析器不匹配,后端返回了 JSON 数据但浏览器或客户端按 PDF 的方式去处理了。排查思路很简单:先用浏览器或 Postman 直接请求接口看返回头里的 Content-Type,如果是 application/json,那说明文件生成服务没把 Content-Type 改成 application/pdf,而不是 JSON 库本身的问题。
2.2 ObjectMapper 的核心用法与序列化原理
Jackson 的核心类是 ObjectMapper,几乎所有操作都围着它转。一个最简单的序列化流程长这样:
ObjectMapper mapper = new ObjectMapper(); // 对象转 JSON 字符串 User user = new User("张三", 25, "admin@example.com"); String json = mapper.writeValueAsString(user); System.out.println(json); // 输出:{"name":"张三","age":25,"email":"admin@example.com"} // JSON 字符串转对象 User parsed = mapper.readValue(json, User.class);这两行代码的背后,Jackson 做了非常多的事情。序列化时,它先通过反射拿到 User 类的所有属性信息,包括字段名、字段类型、getter 方法、类上的注解;然后根据这些信息决定 JSON 里出现哪些 key、每个 key 对应什么值;最后把值按类型写出。反序列化时,Jackson 会查找目标类的构造器或工厂方法,创建实例,再按 JSON 里的 key 找到对应的 setter 或字段,把值设置进去。
这里有一个非常关键的点:默认情况下,Jackson 依赖的是 getter 和 setter,而不只是字段。如果你的类里有一个字段叫 name,但没有 getName() 方法,序列化结果里很可能不会出现 name。反过来,如果类里有一个 getFullName() 方法但背后没有 fullName 字段,JSON 里反而会出现 fullName 这个 key。这个特性坑过很多人,尤其是用 Lombok 又手写了部分 getter/setter 的时候。
在实际开发里,我建议给 ObjectMapper 做统一的配置,而不是每个地方 new 一个。最常见的配置包括:反序列化时忽略未知属性、序列化时把 Date 类型格式化成指定字符串、启用 Java 时间模块。比如:
ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);这样配置之后,接口返回的 JSON 里多一个前端后来新增的字段,后端不会直接报错;LocalDateTime 类型的字段也不会被序列化成数字数组,而是按 ISO 8601 字符串输出。这些看起来都是小事,但线上故障排查时往往能省一晚上。
3. 反射机制到底在干什么
3.1 反射的底层视图:Class 对象与类型信息
要理解反射,先理解 Java 里一个类在运行期的存在形式。当你写了一个 User 类,编译成 User.class 文件后,JVM 加载这个类的时候,会在堆内存里创建一份 Class 对象。这个 Class 对象不是一个普通实例,它是这个类的“元信息载体”,记录了类的名字、父类、接口、字段列表、方法列表、注解、构造器等所有结构信息。
获取 Class 对象有四种常见方式:
// 方式一:类名.class Class<?> clazz1 = User.class; // 方式二:实例.getClass() User user = new User(); Class<?> clazz2 = user.getClass(); // 方式三:Class.forName Class<?> clazz3 = Class.forName("com.example.User"); // 方式四:类加载器 Class<?> clazz4 = Thread.currentThread().getContextClassLoader().loadClass("com.example.User");拿到 Class 对象之后,就能调用它的各种方法获取结构信息。比如 getDeclaredFields() 返回所有声明的字段,包括 private;getDeclaredMethods() 返回所有方法;getDeclaredConstructor() 返回构造器。这里值得注意的是,getFields() 和 getDeclaredFields() 有区别,前者只能拿到 public 字段且包含继承来的,后者能拿到当前类声明的所有字段但不包含父类字段。写框架代码时,这两个方法经常要组合使用,先拿本类,再不断往上遍历父类。
这种“动态查看”的能力,就是 Java 反射的核心。它让程序不再局限于编译期已知的类型,而是可以在运行期根据字符串类名、根据配置文件的描述,去加载类、创建对象、调用方法。Spring 里那种优雅的依赖注入,本质上就是“扫描包路径得到类名列表,用反射实例化并注入属性”的重复操作。
3.2 反射的常用 API 与经典应用场景
反射 API 的常用套路可以总结为三件事:操作字段、调用方法、创建对象。
操作字段的典型代码:
Class<?> clazz = user.getClass(); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); // 绕过 private 访问限制 Object value = field.get(user); System.out.println(field.getName() + " = " + value); }调用方法的典型代码:
Class<?> clazz = user.getClass(); Method method = clazz.getMethod("getAge"); Object result = method.invoke(user);创建对象的典型代码:
Class<?> clazz = Class.forName("com.example.User"); Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class); constructor.setAccessible(true); Object instance = constructor.newInstance("李四", 30);这套 API 简单到朴实,但工程价值非常大。我列几个最常见的应用场景:
第一,ORM 框架。MyBatis 在把查询结果映射到实体对象时,需要根据结果集的列名找到实体类对应的字段或 setter,然后反射赋值。MyBatis-Plus 更进一步,直接用反射读取实体类的字段和注解,动态拼出查询列、插入列、条件列,这也是它能“根据实体类自动生成 SQL”的原因。
第二,依赖注入容器。Spring 扫描到带 @Component 注解的类之后,通过反射拿到类的构造器或字段,再递归创建依赖对象并注入。没有反射,Spring 的“约定优于配置”就是空谈。
第三,动态代理。JDK 动态代理生成代理类时,要基于接口的方法列表反射生成对应的方法实现;代理方法被调用时,InvocationHandler 里的 method.invoke() 就是真正转到目标对象的关键调用。
第四,通用工具类。BeanUtils.copyProperties、MapStruct 之外的运行时对象拷贝工具,基本都是反射遍历字段后逐个赋值。序列化框架更是反射的重度用户,这就是它和 JSON 关系的开关。
4. 把反射和 JSON 真正打通
4.1 手写一个最小可用的 JSON 序列化工具
纸上谈兵没有用,我建议每个学反射的人,都亲手写一个简化版 JSON 序列化工具。不用支持嵌套泛型,不用处理循环引用,只要能把一个普通对象的字段输出成 JSON 格式,你对“框架底层”的理解就会立刻上一个台阶。
核心代码大概长这样:
public class MiniJsonSerializer { public static String serialize(Object obj) throws IllegalAccessException { if (obj == null) { return "null"; } if (isPrimitiveOrWrapper(obj.getClass()) || obj instanceof String) { return obj instanceof String ? "\"" + obj + "\"" : obj.toString(); } Class<?> clazz = obj.getClass(); Field[] fields = clazz.getDeclaredFields(); StringBuilder sb = new StringBuilder(); sb.append("{"); boolean first = true; for (Field field : fields) { field.setAccessible(true); Object value = field.get(obj); if (first) { first = false; } else { sb.append(","); } sb.append("\"").append(field.getName()).append("\":"); if (value == null) { sb.append("null"); } else if (value instanceof String) { sb.append("\"").append(value).append("\""); } else { // 这里简化处理:数字、布尔直接用 toString sb.append(value.toString()); } } sb.append("}"); return sb.toString(); } private static boolean isPrimitiveOrWrapper(Class<?> clazz) { return clazz.isPrimitive() || clazz == Integer.class || clazz == Long.class || clazz == Double.class || clazz == Boolean.class || clazz == Byte.class || clazz == Short.class || clazz == Float.class || clazz == Character.class; } }这段代码只有四五十行,但它展现了一个核心思想:序列化框架的工作就是遍历对象的字段图,逐个字段取名字、取值,再按类型规则输出。Jackson 做的事情本质上一样,只不过它做得更复杂:缓存字段元数据、支持注解、处理循环引用、处理泛型、支持自定义序列化器。
当你真的写了这样一段代码,再去查 Jackson 的源码,会发现自己能看懂很多方法了。比如它内部有个 POJOPropertiesCollector 类,专门负责收集一个类的所有可序列化属性,这个收集过程用的就是反射。又比如它有个 BeanSerializerFactory,负责决定某个属性该用哪种序列化方式,这里仍然围绕着字段和方法做文章。
4.2 泛型擦除是最大的坑:TypeReference 的意义
反射 + JSON 组合里最容易踩的坑,就是泛型擦除。
Java 的泛型是编译期类型,运行期会被擦除。这意味着你写 List ,编译时知道元素类型是 User,但到了 JVM 里,这个类型信息就丢了。那么当 Jackson 要把 JSON 数组反序列化成 List 时,它怎么知道数组里的每个元素要转成 User 而不是别的类型呢?
如果你只是这样写:
List<User> userList = mapper.readValue(json, List.class);Jackson 拿到的是 List 的 Class 对象,看不到任何泛型信息,它只能用默认的方式反序列化——结果就是得到一个 List edhashmap> ,每个元素是 Map,根本不是 User。这时候你的代码里如果直接 userList.get(0).getName(),编译不会报错,但运行时会抛 ClassCastException。
正确的做法是借助一个泛型超类来保留类型信息。Jackson 提供了 TypeReference:
List<User> userList = mapper.readValue(json, new TypeReference<List<User>>() {});这段代码里,匿名子类 TypeReference<List > 的父类泛型参数在运行期可以通过反射被获取到。Jackson 内部用 getGenericSuperclass() 拿到这个带泛型的 Type,从而知道了元素类型是 User。Gson 那边的对应物是 TypeToken:
Type type = new TypeToken<List<User>>() {}.getType(); List<User> userList = new Gson().fromJson(json, type);理解了这一点,你就理解了为什么很多工具类要花那么大力气维护 TypeReference 缓存。我建议每个项目里针对常用泛型类型建一个静态的 TypeReference 实例,避免每次反序列化都 new 一个匿名类,既减少 GC 压力也提升一点性能。
4.3 实战对照:MyBatis-Plus 如何用反射把实体类变成建表 SQL
热门搜索词里有一条叫“mybatisplus根据java实体类生成创建表的sql语句”,这个需求现实中也经常遇到。虽然 MyBatis-Plus 本身没有把实体类直接生成 CREATE TABLE 语句的内置能力,但它的核心设计思想——用反射读取实体类字段和注解来拼 SQL——和这类工具是一致的。我们可以理解成:MyBatis-Plus 会根据实体类上的 @TableName、@TableId、@TableField 注解,反射构建表名、主键和字段列表,再进行增删改查的 SQL 组装。
如果要自己写一个从实体类生成建表 SQL 的工具,思路也很直接:
- 通过 clazz.getDeclaredFields() 拿到所有字段;
- 读取字段上的注解,比如 @TableId、@TableField("column_name");
- 把字段名转换成列名,把 Java 类型映射成 MySQL 类型;
- 拼接 CREATE TABLE 语句。
这段映射逻辑里反射用的相当密集。你要拿类名转表名,要遍历字段,要判断每个字段上的注解,有时候还要处理父类字段。这种工具在项目里作为开发辅助很有用,尤其是数据库表比较多、需要快速生成初始化脚本的时候。
更重要的是,理解了 MyBatis-Plus 这类框架的“反射驱动 SQL 生成”模式之后,你再看它的源码,就不会觉得神秘了。TableInfoHelper 这个类就是干这个活的,它会把实体类的元信息缓存起来,避免每次都做反射扫描——这个缓存设计也正好呼应了下一节要讲的性能问题。
5. 日常开发中最容易踩的反射与 JSON 的坑
5.1 性能开销到底该不该担心
关于反射性能,网上说法很多,有人说反射很慢要少用,也有人说现代 JVM 已经优化好了。我个人的结论是:反射确实有开销,但没有到“不能碰”的程度,关键是看使用频率和场景。
反射慢在哪里?第一,getDeclaredFields() 这类方法每次调用都可能要做一次类的元数据遍历,成本比直接访问字段高很多;第二,setAccessible(true) 需要做安全检查,JDK 高版本还要处理模块访问限制;第三,Method.invoke() 内部参数是 Object[],存在装箱拆箱,还会被 JVM 看做未知调用点,影响内联优化。
对于高频调用的代码,最优实践是“把反射元数据缓存下来”。比如 Jackson 之所以性能不错,就是因为一个类的字段、方法、注解信息只收集一次,之后都走缓存的序列化器。我们自己写代码也是一样的道理,凡是可能被多次调用的反射逻辑,都应该做静态缓存:
public class FieldCache { private static final Map<Class<?>, Field[]> CACHE = new ConcurrentHashMap<>(); public static Field[] getFields(Class<?> clazz) { return CACHE.computeIfAbsent(clazz, c -> { List<Field> fields = new ArrayList<>(); Class<?> current = c; while (current != null && current != Object.class) { Field[] declared = current.getDeclaredFields(); for (Field field : declared) { field.setAccessible(true); fields.add(field); } current = current.getSuperclass(); } return fields.toArray(new Field[0]); }); } }如果追求极致性能,还可以用 MethodHandle 替代 Method.invoke()。MethodHandle 在 HotSpot 上经过多次调用后会做内联优化,性能明显优于传统反射调用。但它的 API 理解成本高一些,一般业务代码不需要这么较真。真正的结论是:反射不是不能用,而是要用得聪明。
5.2 私有字段、继承关系与偶发的安全异常
反射相关的异常里,最常见的有三类:InaccessibleObjectException、IllegalAccessException、NoSuchMethodException。
InaccessibleObjectException 在高版本 JDK 上经常出现,尤其是 JDK 17 之后模块系统强制生效时。如果代码里去反射访问某个 JDK 内部类的字段,没有加 --add-opens 参数,就会直接抛异常。解决办法有两个:一是不要在业务代码里碰 JDK 内部类,二是确实需要访问时,给启动参数加上 --add-opens。日常开发里我们访问的大多是自己的类,控制在模块内或同一个 unnamed module,通常没问题。
继承关系导致的字段漏读也是高频坑。getDeclaredFields() 只拿当前类声明的字段,不包含父类的。如果一个实体类继承了 BaseEntity,而 BaseEntity 里有 id、createTime,你只扫描子类,这些字段就全丢了。这就是为什么写反射工具时,通常要写一个循环,往父类一层层找,直到 Object 为止。上面示例代码里的 FieldCache 已经演示了这个模式。
还有一个很容易被忽略的细节:getMethod() 和 getDeclaredMethod() 的区别。getMethod() 只能拿到 public 方法且包含继承来的;getDeclaredMethod() 能拿到当前类声明的所有方法,包括 private,但不包含继承的。如果你要在子类上用反射调用父类的受保护方法,两个方法都找不到,应该先去父类的 Class 对象上用 getDeclaredMethod()。这类坑在写框架代码时能用一整晚。
5.3 排查思路速查表
我平时排查 JSON 和反射相关问题时,习惯按照下面这张表走,效率很高:
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| JSON 里缺少某个字段 | 没有对应 getter;字段没被 Jackson 识别;字段为 null 且配置了忽略 | 先看类里有没有 getter,再检查 @JsonIgnore 或全局配置 |
| 反序列化报 InvalidDefinitionException | 类没有无参构造器;构造器参数无法匹配 JSON 字段 | 加无参构造器,或加 @JsonCreator 标注工厂方法 |
| 拿到了 LinkedHashMap 而不是实体类 | 用了不带 TypeReference 的 readValue | 改用 TypeReference<List<具体类型>> |
| 循环引用导致 StackOverflowError | 对象存在双向引用,序列化时无限递归 | 在对应字段加 @JsonIgnore 或 @JsonManagedReference/@JsonBackReference |
| 反射访问私有字段报 InaccessibleObjectException | JDK 模块系统限制 | 加 --add-opens 启动参数,或改用 setAccessible 前先判断 |
| 扫描实体类字段时少了父类字段 | getDeclaredFields 不包含父类字段 | 写循环遍历父类,到 Object 为止 |
| JSON 日期输出为乱码或时间戳 | 没有配置日期序列化方式 | 禁用 WRITE_DATES_AS_TIMESTAMPS,注册 JavaTimeModule |
最后分享一个我自己的经验:但凡碰到“对象转 JSON 结果和预期不一致”的问题,先不要怀疑 JSON 库有 bug,大概率是字段可见性、getter/setter 设计、或泛型类型信息丢失的问题。用 debug 模式看一遍 ObjectMapper 序列化器里收集到的属性列表,往往一眼就能定位问题所在。JSON 和反射这两个知识点,说白了就是“结构信息”和“运行时动态行为”的组合,理解了这个组合,你的 Java 功底会往前迈一大步。