一个订单对象序列化 100 万次,花了 4.2 秒
十月份做对账文件的导出,要把 100 万条订单转成 JSON 写到文件里。我用 Fastjson 1.2.49,代码就一行:
String json = JSON.toJSONString(order);
跑完计时:4.2 秒。我当时觉得挺快了,直到师傅问我:"你知道这一行里面反射被调用了多少次吗?"
我说不上来。他让我自己写个对比测试,于是有了下面这组数据。
先量化:反射到底慢在哪
我用最朴素的方式测的(循环 + System.nanoTime,跑了 5 轮预热再取平均)。场景:调用一个简单 getter 一千万次,JDK 8u171。
public class Order {
private Long orderId;
private String orderNo;
private BigDecimal amount;
// getter/setter
}
| 调用方式 | 单次耗时 | 相对直接调用 |
|---|---|---|
直接 order.getOrderNo() | 2.6 ns | 1x |
反射,每次 getMethod() | 1183 ns | 455x |
| 反射,Method 缓存,不 setAccessible | 347 ns | 133x |
| 反射,Method 缓存 + setAccessible(true) | 43 ns | 16.5x |
| MethodHandle(实例字段,非 static final) | 68 ns | 26x |
| MethodHandle(static final 常量) | 3.1 ns | 1.2x |
几个结论:反射慢的大头不在 invoke 本身,而在查找 Method 和每次的权限检查。缓存 Method 加 setAccessible 之后,从 1183ns 降到 43ns,快了 27 倍。而 MethodHandle 只有在声明成 static final、让 JIT 能内联时才能接近直接调用,否则比优化过的反射还慢(这点我测出来的时候挺意外)。
为什么 setAccessible 能快这么多
JDK 8 的 Method.invoke 大概长这样(简化):
public Object invoke(Object obj, Object... args) {
if (!override) { // override 就是 setAccessible 设置的标志
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers); // 每次都做!
}
}
MethodAccessor ma = methodAccessor;
if (ma == null) ma = acquireMethodAccessor();
return ma.invoke(obj, args);
}
不调 setAccessible(true) 的话,override 是 false,每次 invoke 都会走一遍访问控制检查,还要 Reflection.getCallerClass() 拿到调用者类。这部分开销占了总耗时的一大半。
另外还有个更隐蔽的成本:JDK 对 Method.invoke 有个膨胀机制。同一个 Method 被反射调用超过 15 次(ReflectionFactory.inflationThreshold),会生成一个字节码版本的 MethodAccessor 来替代 JNI 版本。前 15 次用的是 JNI 调用,慢得离谱。所以反射性能测试必须预热,不预热测出来的数据没有参考价值。
// 相关 JVM 参数
-Dsun.reflect.inflationThreshold=15
-Dsun.reflect.noInflation=true // 直接用字节码版本,跳过膨胀
Fastjson 是怎么做到的
回到那个问题:"这一行里面反射被调用了多少次?"我去翻了 1.2.49 的源码。JSON.toJSONString 走的是 JavaBeanSerializer,它在第一次序列化某个类时做一次性的元数据构建:
// com.alibaba.fastjson.serializer.JavaBeanSerializer 构造过程(简化)
public JavaBeanSerializer(Class<?> beanType) {
// 1. 拿到所有 public 方法
Method[] methods = beanType.getMethods();
// 2. 找出 getter,建立 propertyName -> FieldSerializer 的映射
List<FieldSerializer> getters = new ArrayList<>();
for (Method method : methods) {
if (method.getName().startsWith("get") && method.getParameterTypes().length == 0) {
String propertyName = Character.toLowerCase(method.getName().charAt(3))
+ method.getName().substring(4);
Field field = TypeUtils.getField(beanType, propertyName, declaredFields);
getters.add(createFieldSerializer(propertyName, method, field));
}
}
// 3. 排序,保证输出顺序稳定
Collections.sort(getters);
this.getters = getters.toArray(new FieldSerializer[0]);
}
关键在于这些只做一次,结果缓存在 SerializeConfig 的 IdentityHashMap 里:
// SerializeConfig
private final IdentityHashMap<Type, ObjectSerializer> serializers
= new IdentityHashMap<Type, ObjectSerializer>();
public ObjectSerializer getObjectWriter(Class<?> clazz) {
ObjectSerializer writer = serializers.get(clazz); // 命中缓存直接返回
if (writer == null) {
// ... 构建并放入缓存
}
return writer;
}
后面每次序列化,只做一次 map 查找加 N 次 method.invoke。所以那 4.2 秒里,反射调用的次数是 100 万 × 字段数,但 getMethods() 这类重操作只执行了 1 次。
我还验证了缓存命中的效果。同一个对象序列化 100 万次 vs 100 万个不同 Class 各序列化一次:
同一 Class,预热后序列化 100 万次: 4.2 s
同一 Class,不预热(第一个对象): 首次约 12 ms,后续正常
第一次的 12ms 就是构建 JavaBeanSerializer 的成本。如果这是个只调用几次的冷启动接口,这 12ms 是要算进去的。
我自己写的工具类犯过的错
知道原理之后回头看自己写的代码,问题很明显。我以前写过一个把对象转成 Map 的工具:
// 错误示范
public static Map<String, Object> toMap(Object obj) {
Map<String, Object> map = new HashMap<>();
for (Method m : obj.getClass().getMethods()) { // 每次调用都扫一遍方法
if (m.getName().startsWith("get") && m.getParameterCount() == 0) {
try {
String key = m.getName().substring(3);
map.put(key.toLowerCase(), m.invoke(obj)); // 没有 setAccessible
} catch (Exception e) {
log.error("转换失败", e);
}
}
}
return map;
}
三个问题:getMethods() 每次都调用;没缓存 Method;没 setAccessible。实测这个工具在 10 万次调用下耗时 8.9 秒。
改完之后:
public class BeanMetaCache {
private static final ConcurrentMap<Class<?>, List<GetterMeta>> CACHE
= new ConcurrentHashMap<>();
static class GetterMeta {
final String name;
final Method method;
GetterMeta(String name, Method method) {
this.name = name;
this.method = method;
this.method.setAccessible(true); // 一次性打开权限
}
}
public static List<GetterMeta> get(Class<?> clazz) {
return CACHE.computeIfAbsent(clazz, c -> {
List<GetterMeta> list = new ArrayList<>();
for (Method m : c.getMethods()) {
if (m.getName().startsWith("get")
&& m.getParameterCount() == 0
&& m.getReturnType() != void.class
&& !m.getDeclaringClass().equals(Object.class)) {
String field = Character.toLowerCase(m.getName().charAt(3))
+ m.getName().substring(4);
list.add(new GetterMeta(field, m));
}
}
return list;
});
}
}
同样的 10 万次调用,从 8.9 秒降到 1.3 秒。computeIfAbsent 保证元数据只构建一次,ConcurrentHashMap 保证多线程安全。
关于 setAccessible 的两个注意点
一是它可能被安全管理器拦下来,抛 java.security.AccessControlException。我们项目没启用 SecurityManager,但如果有人写 -Djava.security.manager 就会炸。
二是 JDK 9 开始的模块化会限制跨模块的 setAccessible(会打 warning)。我们还在 JDK 8,暂时没这个问题,但师傅提醒我尽量用 trySetAccessible 那套返回值判断的写法,为将来留余地。2018 年那会儿 JDK 9 刚发布不久,我们部门还在观望。
MethodHandle 值得换吗
我试过把上面的缓存改成 MethodHandle:
private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
MethodHandle mh = LOOKUP.unreflect(method);
Object value = mh.invoke(obj);
结果在上面表里:实例字段持有 MethodHandle 时是 68ns,比反射还慢。原因是 JIT 无法把它内联,每次都走动态调用路径。要让它快,必须声明成 static final:
private static final MethodHandle GET_ORDER_NO; // 这样 JIT 才能当作常量内联
static {
try {
GET_ORDER_NO = MethodHandles.lookup()
.findVirtual(Order.class, "getOrderNo", MethodType.methodType(String.class));
} catch (Throwable e) {
throw new Error(e); // 静态常量不能抛受检异常,只能包一层
}
}
问题是,业务场景里对象的 Class 是运行时才知道的,根本没法写成 static final 常量。所以我的结论是:通用工具类里用缓存 + setAccessible 的反射就够了,MethodHandle 更适合框架级、类型确定的高性能场景。Fastjson 在 JDK 7 上做过 ASM 字节码生成(关掉 ASMDisable 时),那才是真正接近直接调用的方案。
最后那个导出优化
回到 100 万订单导出。4.2 秒其实不是序列化本身的问题,而是我一条一条 toJSONString 再写文件,每条都 flush。改成分批序列化(每 1000 条拼一个 JSONArray)+ BufferedWriter 缓冲写,降到 1.8 秒。另外把 SerializerFeature.WriteMapNullValue 去掉(默认就不输出 null 字段),文件小了 30%。
这件事让我明白,看到"反射慢"这种结论,得先问清楚是哪种用法。缓存好元数据、开好 setAccessible 之后,反射的开销在大部分业务场景里是可以接受的;真正慢的永远是那些没缓存的重复劳动。