Administrator
发布于 2018-10-10 / 1967 阅读
13

反射到底有多慢?从 Fastjson 的序列化说起

一个订单对象序列化 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 ns1x
反射,每次 getMethod()1183 ns455x
反射,Method 缓存,不 setAccessible347 ns133x
反射,Method 缓存 + setAccessible(true)43 ns16.5x
MethodHandle(实例字段,非 static final)68 ns26x
MethodHandle(static final 常量)3.1 ns1.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]);
}

关键在于这些只做一次,结果缓存在 SerializeConfigIdentityHashMap 里:

// 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 之后,反射的开销在大部分业务场景里是可以接受的;真正慢的永远是那些没缓存的重复劳动。

参考