分析FastJSON为何那么快与字节码增强技术揭秘

前言

JSON作为一种序列化协议,它有可读性强的特点,其越来越流行,越来越成为一个不同进程交换数据的序列化普遍使用的一个协议,其也不需要配置schama,JSON协议有自己独有的结构,使得反序列化一个JSON串没有语言或是其他什么限制,按照共同知道的JSON规则反序列化就可以了。在分布式环境中,数据的交换是必不可少的,除了二进制的序列化协议,我们常常也大量使用JSON的序列化协议,所以,JSON序列化与反序列化的性能就变得异常重要了。

本篇文章的议题是分析FastJSON的技术内幕,模仿FastJSON自己做一个简易的序列化(不包括反序列化,因为反序列化需要一些语法词法分析,较为繁琐)工具,并且也会使用ASM进行字节码层面的类增强。

学习一个好的作品、性能的锱铢必较和一些优化的思路,我觉得是很好的一件事,就像叶圣陶说的一句话,艺术的事情大都始于模仿,终于独创,而代码与艺术同理,通往一个结果可以有很多条道路,但高级程序员往往追求完美的道路去完成目标,而普通的程序员往往只需要能到目标就行,就像序列化POJO,可以使用反射将属性一一读取出来,但高级程序员会使用字节码增强减少反射开销,而普通程序员直接使用反射,虽然都可以达到目的,但那条达到目的的道路却是不同的,走的路不同,或许这就是高级程序员与一般程序员的区别吧。

1. FastJSON为什么这么快

下面就分几个我认为比较独特的部分,来一一分析FastJSON的技术内幕

1.1 字符数组的处理

我们知道,在序列化与反序列化时,一定少不了字符串的处理,在序列化过程中,往往需要从POJO中读取属性名与属性值,将其一一写入字符串,而字符串究其本身就是字符数组 char[]

我们第一反应其实是使用 StringBuilder 去构造、添加字符串,但FastJSON中不是,在FastJSON定制了相当多的实现,其中就有这么一个定制,其为SerializeWriter 类,为什么要定制这么一个实现呢?有以下几点优化点:

  1. 减少了字符数组内存的开辟(new一个char[])
  2. 减少了剩余容量的检查

1.1.1 减少容量检查

就像ArrayList那样,内部是数组结构,数组有一个通病,就是其创建出来必须指定一个固定的容量,这就有概率造成一些内存浪费,比如Arraylist中开辟了16个容量的int数组,但你只使用了4个容量,这样就有12*4=48字节的内存空间浪费了。并且每次加入元素都要查看数组是否有剩余空间存放,这就需要每次加入元素时都需要一次容量的检查。既然数组这么多缺点,那为什么不使用链表呢?链表有多少元素就用多少容量,唯一浪费空间的点在于存放next或pre指针,而且不需要容量检查,因为数组是一种顺序IO,其内存地址是连续的,可以利用到CPU的缓存,而链表中的元素在内存中地址是乱的,分布在内存的各个地方,这就造成了随机IO,并且对CPU缓存并不友好,两者是最基础的数据结构,各自都有各自的优缺点,需要分不同场景使用最合适的数据结构。(扯的有点远了)

其中第二点,在使用StringBuilder时,其内部也是一个char数组,在每一次append字符串时会检查是否有剩余容量可以分配,但如果我们已经知道一次要写入几个值,比如写一个开头 ‘{’ 紧接着就是双引号的属性名 “propertiesName” 然后就是一个冒号 ‘:’ ,这样连续写入3个类型,只需要一次的容量检查。

1.1.2 减少内存分配开销

假如我们使用StringBuilder来序列化,有以下几个问题:

  • 一次分配多大的内存空间比较合适呢?分配太大的char数组又太浪费空间,太小又会造成很多的数组重分配(剩余容量不够分配,new一个新的char并拷贝,操作有点耗时)
  • 序列化一次就new一个char数组,分配一次内存空间,分配一段连续的内存空间有时候也是有点耗时的操作

而FastJSON中定制的字符串处理类SerializeWriter就解决了以上两个问题。

我们先来看看SerializeWriter的构造器:

// 顾名思义,其将char数组缓存起来了
protected char buf[];
// 字符数组缓存的地方,由于有线程安全的问题,我们保证每个线程都有一个缓存的字符数组
// 这样不会多线程同时操作被共享的字符数组
private final static ThreadLocal<char[]> bufLocal = new ThreadLocal<char[]>();

public SerializeWriter(Writer writer, int defaultFeatures, SerializerFeature... features){
  this.writer = writer;

  // 可以看到,每次构造writer类时都会尝试去ThreadLocal查看是否有缓存的char数组
  buf = bufLocal.get();

  if (buf != null) {
    bufLocal.set(null);
  } else {
    // 如果没有缓存,这里就创建一个,默认为2048的容量
    buf = new char[2048];
  }
	// ...
}

可以看到,每次new一个SerializeWriter类时都会尝试从ThreadLocal这个缓存中查看是否有缓存起来的char数组,如果有就直接使用,减少了内存分配的开销(亲测,这部分开销在序列化次数很大的时候还蛮大的)

再来看看如何使用这个Writer,我们直接来看JSON.toJsonString的API中做了什么

public static String toJSONString(Object object, // 
                                  SerializeConfig config, // 
                                  SerializeFilter[] filters, // 
                                  String dateFormat, //
                                  int defaultFeatures, // 
                                  SerializerFeature... features) {
  // new一个writer
  SerializeWriter out = new SerializeWriter(null, defaultFeatures, features);

  try {
    // ...

    serializer.write(object);

    // 调用toString,输出序列化的数据
    return out.toString();
  } finally {
    // 调用close,释放资源
    out.close();
  }
}

这其中就包含了两个writer的用法,一个是toString方法,这和StringBuilder很像,输出自身字符数组所包含的值,这里Writer中也有一个游标,指示字符数组中有效部分(在下面会讲到),这里来看看toString方法

public String toString() {
  return new String(buf, 0, count);
}

指定输出的是字符数组中0到count(游标)的位置的内容。

那么close方法又是做什么的呢?

// 缓存阈值
private static int BUFFER_THRESHOLD = 1024 * 128;

public void close() {
  
  //...
  
  // 没有超过一个阈值,就进行存储重复利用
  if (buf.length <= BUFFER_THRESHOLD) {
    bufLocal.set(buf);
  }

  this.buf = null;
}

这里可以看到,每次序列化完成之后都会放回ThreadLocal中进行重复利用,这样就减少了分配内存的开销了。

其中Writer有一个游标值count,来指示写入位置
在这里插入图片描述
上图假设同一条线程序列化了两次的场景,可以看到,依靠count游标指示内容可以对char数组反复写入,这一点和NIO中的ByteBuf的写游标和读游标有异曲同工之妙

1.2 序列化顺序输出

这一条性能优化是为了反序列化做准备的,在FastJSON中,是按照字符来有序输出的,为什么这么做能提高性能呢?我们来举一个例子:

// pojo
public class User {
  
  private int id;
  private String name;
	private int age;

  // getter/setter
}

此时序列化结果就会变成这样

{"age":18,"id":1,"name":"jack"}

在反序列化的时候我根据POJO的属性进行一个排序,得出age、id、name这样一个顺序,这样我就假设第一次读取的是age,并对字符串进行验证,第一个字符是否是age,若是,做词法语法分析,读取并存放age属性到pojo,下一个继续预测属性为id。若此时id没值,JSON中只有age和name两个属性,那么在第二次预测验证的时候就会失败,进行name的预测,以此类推。

这样做与不排序有什么分别呢?假设不进行排序,无法预测第一个属性是什么,在pojo中第一个属性为id,那么你需要遍历JSON字符串来找id这个字符串,也就是说,找id,你需要遍历字符串中第一个属性age,第二个属性id,这样才找到id,存放id属性,找name属性,需要再从头开始第一个属性第二个属性,到第三次token的遍历才能得到目标值,这样,无序读取就需要6次token遍历的读取,反观有序读取的预测读取,只需要3次token的遍历读取,平均来说可以减少50%的字符串遍历!这在FastJSON中是最重要的性能优化点

1.3 使用ASM避免反射开销

这部分的详细说明将在下面,自定义一个简易FastJSON中会有说明

在序列化时使用反射读取POJO的属性名,以及反射调用属性名对应的get方法获取value,然后将拿到的属性名与value拼接而成JSON字符串,在反序列化的时候又反射调用set方法进行属性的设置。这种思路是最简单的,其也是最低效的,在FastJSON中,使用了ASM框架自己编写字节码,然后使用ClassLoader将自定义的字节码加载成为类,变为特定POJO的序列化器,属性名和属性value将不需要反射获取,减少了这部分的反射开销。

2. 自定义实现简易序列化工具

首先看看整个框架的结构
在这里插入图片描述
其中有5个序列化器,分别序列化专门的类型例如数组、枚举、字典、字符串…

然后就是SerializeWriter,其作用大致在第一节中就有说到,类似一个自定义的StringBuilder

值得关注的是ASM字节码增强,减少反射的部分,这里着重讲述这部分,其他部分感兴趣的话可以自行参考Github上的代码

2.1 ASM字节码增强

直接来看JsonUtil的toJSONString序列化方法

public static String toJSONString(final Object object) throws Exception {

  final SerializeWriter out = new SerializeWriter();

  try {
    if (object == null) {
      out.writeNull();
    } else {
      // 获取POJO的Class类对象
      final Class<?> clazz = object.getClass();
      // 根据类对象,动态生成一个ObjectSerializer的子类
      // serializer为ASM生成的动态对象
      final ObjectSerializer serializer = SerializeConfig.GLOBAL_INSTANCE.getObjectWriter(clazz);

      // 调用动态生成的对象的write方法,完成序列化工作
      serializer.write(out, object);
    }

    return out.toString();
  } finally {
    out.close();
  }
}

我们来debug看一下,动态生成的对象是什么
在这里插入图片描述
其名称是自定义的,由此可以看到,这个类在项目中是没有的,其确实是动态生成的。

接下来先来看看,这个动态生成的类到底长什么样?

Debug进入SerializeConfig这个类的createASMSerializer方法(相同的方法在FastJSON源码中也可以使用,其坐标在com.alibaba.fastjson.serializer.ASMSerializerFactory#createJavaBeanSerializer方法中)
在这里插入图片描述
然后我们打开Evaluate,将字节数组写入文件中
在这里插入图片描述
这样,在/tmp目录下就会有我们动态生成的类文件了
在这里插入图片描述

将此文件放入项目编译后的结果目录,双击即可反编译
在这里插入图片描述
在这里插入图片描述
这样就得到了动态生成的类中的内容,可以看到,我们这里的属性名是直接拿出字符串,属性值是直接调用方法拿到的,为了最大化降低生成字节码的复杂度,我这里只拿到属性名和属性值,就调用SerializeWriter类的writeObject方法进行序列化了。而在FastJSON中,动态生成的逻辑比较复杂,感兴趣的读者可以根据这种方法查看FastJSON中生成的类的逻辑是怎样的。

从以上示例我们可以看出来,我们需要循环生成以下几个操作:

  • 属性名直接子面量赋值字符串
  • 根据属性名,调用pojo的对应的属性名的方法,例如属性名为name,就需要调用getName方法
  • 将以上两个信息作为参数,调用SerializeWriter的writeObject方法,完成序列化

接下来需要有一定的字节码基础

ASM框架使用了Visit访问者的设计模式,在操作类似字节码这种固定的数据结构的场景下可以做到很灵活的更改其中某个信息,所以其可以很容易的实现AOP。但ASM相对其他例如Javassist、Byte Buddy等等字节码操作框架来说上手比较困难,但性能方面会相对比较快。

其主要的类就是ClassWriter

// 首先new一个ClassWriter,准备写字节码
// 参数为0表示操作数栈和本地变量数和Frame都自己计算
// 也可以让ASM框架自动生成,但会有性能开销
ClassWriter cw = new ClassWriter(0);
MethodVisitor mv;

// 52表示Java8版本
// 生成一个类名为fullClassName,继承于javaBeanSerializer
cw.visit(52, ACC_PUBLIC + ACC_SUPER, fullClassName, null, javaBeanSerializer, null);

{
    // 先执行构造器方法,不用细看
    mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
    mv.visitCode();
    mv.visitVarInsn(ALOAD, 0);
    mv.visitMethodInsn(INVOKESPECIAL, javaBeanSerializer, "<init>", "()V", false);
    mv.visitInsn(RETURN);
    mv.visitMaxs(1, 1);
    mv.visitEnd();
}

接下来就是构造我们的主方法,write方法了

mv = cw.visitMethod(ACC_PUBLIC, "write", "(L" + serializeWriter + ";Ljava/lang/Object;)V", null, new String[]{"java/lang/Exception"});
mv.visitCode();

//  public void write(SerializeWriter var1, Object var2) throws Exception {
//      var1.preSymbol = '{';
mv.visitVarInsn(ALOAD, 1);
mv.visitIntInsn(BIPUSH, 123);
mv.visitFieldInsn(PUTFIELD, serializeWriter, "preSymbol", "C");

//  User var3 = (User)var2;
mv.visitVarInsn(ALOAD, 2);
mv.visitTypeInsn(CHECKCAST, jsonObjectClassName);
mv.visitVarInsn(ASTORE, 3);

我把每一段操作,都用对应的java代码注释上去了,这里我用的字节码操作还算简单的,所以有字节码基础的并不难看懂。

接下来就是获取属性名和属性值了

// getters为提前就获取到的元信息数组
for (FieldInfo getter : getters) {

    // String var4 = "name";
    mv.visitLdcInsn(getter.fieldName);
    mv.visitVarInsn(ASTORE, 4);

    // 假设是普通对象
    // Role var18 = var3.getRole();
    // 假设是以下8种基本类型
    // Integer var23 = var3.getAge();
    mv.visitVarInsn(ALOAD, 3);
    switch (getter.primitive) {
        case "int":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()I", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Integer", "valueOf", "(I)Ljava/lang/Integer;", false);
            break;
        case "byte":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()B", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Byte", "valueOf", "(B)Ljava/lang/Byte;", false);
            break;
        case "short":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()S", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Short", "valueOf", "(S)Ljava/lang/Short;", false);
            break;
        case "boolean":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()Z", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Boolean", "valueOf", "(Z)Ljava/lang/Boolean;", false);
            break;
        case "float":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()F", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Float", "valueOf", "(F)Ljava/lang/Float;", false);
            break;
        case "double":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()D", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Double", "valueOf", "(D)Ljava/lang/Double;", false);
            break;
        case "long":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()J", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Long", "valueOf", "(J)Ljava/lang/Long;", false);
            break;
        case "char":
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, "()C", false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Character", "valueOf", "(C)Ljava/lang/Character;", false);
            break;
        default:
            mv.visitMethodInsn(INVOKEVIRTUAL, jsonObjectClassName, getter.methodName, getter.returnType, false);
            break;
    }

    mv.visitVarInsn(ASTORE, index);

    // var1.writeObject(var4, var18);
    mv.visitVarInsn(ALOAD, 1);
    mv.visitVarInsn(ALOAD, 4);
    mv.visitVarInsn(ALOAD, index++);
    mv.visitMethodInsn(INVOKEVIRTUAL, serializeWriter, "writeObject", "(Ljava/lang/String;Ljava/lang/Object;)V", false);

短短的几行Java代码,需要这么多的字节码操作,这也就是字节码操作框架不普及使用的原因了,门槛稍微有些高,ASM也意识到这一点,其提供了插件,可以将写好的Java代码转换为ASM操作的形式和字节码形式,供用户参考,这样手写字节码的难度稍微小了一些些(难度还是很大)。

插件:
在这里插入图片描述

安装好之后,在想要生成字节码的java代码中右键Show Bytecode outline
在这里插入图片描述
在这里插入图片描述

这样,在右侧就生成好了ASM如何使用,参考右侧来自定义一个自己的实现,基于此做一个序列化get属性的操作难度应该并不是特别大。

其中有些元信息是需要提前获取的,逻辑在SerializeConfig#buildBeanInfo方法中

public SerializeBeanInfo buildBeanInfo(Class<?> clazz) {

    SerializeBeanInfo beanInfo = new SerializeBeanInfo();

    // beanType
    beanInfo.beanType = clazz;

    // FieldInfo[] -> field
    Method[] methods = clazz.getMethods();

    List<FieldInfo> fieldInfos = new ArrayList<>();

    for (Method method : methods) {

        // 拿到需要调用的方法名
        String methodName = method.getName();
        String propertyName;

      	// 如果是get或者is开头的,我们才视为是pojo的属性方法
        if (methodName.startsWith("get")) {

            if (methodName.length() < 4) {
                continue;
            }
            if ("getClass".equals(methodName)) {
                continue;
            }
            // 拿到属性名
            propertyName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
        } else if (methodName.startsWith("is")) {
            if (methodName.length() < 3) {
                continue;
            }
            if (method.getReturnType() != Boolean.TYPE
                    && method.getReturnType() != Boolean.class) {
                continue;
            }
            // 拿到属性名
            propertyName = Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
        } else {
            continue;
        }

        // 属性类对象
        Class type = method.getReturnType();

        String primitive;
        String returnType;

        if (type.isPrimitive()) {

          	// 在调用方法的时候需要统一使用Object参数,原生类型需要装箱成对象
          	// 所以这里需要判断是否是原生类型
            // 这是为了简易性考虑的,在性能上来说,多了一层装箱操作
            primitive = type.getName();
            // 例如Intger就是Ljava/lang/Integer,这里已经知道了,所以没必要获取
            returnType = "";
        } else if (type.isArray()) {
            // "()" + byte[].class.getName().replace('.', '/')
            primitive = "non";
            returnType = "()" + type.getName().replace('.', '/');
        } else {
            // Role.class.getName().replace('.', '/') + ";")
            primitive = "non";
            returnType = "()L" + type.getName().replace('.', '/') + ";";
        }

        // 是否原生类型
        // 返回类型(非原生)
        // 方法名 getAge
        // 字段名 age
        FieldInfo fieldInfo = new FieldInfo(primitive, returnType, methodName, propertyName, method);
        fieldInfos.add(fieldInfo);
    }

    if (!fieldInfos.isEmpty()) {
        FieldInfo[] fields = new FieldInfo[fieldInfos.size()];
        fieldInfos.toArray(fields);
        beanInfo.fields = fields;
    }

    return beanInfo;
}

以上元信息在动态生成类的时候将会使用到,反复序列化同一个POJO也只需要反射获取一次元信息,而动态生成的类作为此次的POJO专用序列化器,其被存放在一个Map中

public ObjectSerializer getObjectWriter(Class<?> clazz) {

  ObjectSerializer writer;

  //...
  
  if (writer == null) {
    writer = createJavaBeanSerializer(buildBeanInfo(clazz));
    // 放入Map缓存起来,之后就不需要生成类,直接get使用即可
    put(clazz, writer);
  }
}

  return writer;
}

private void put(Class type, ObjectSerializer value) {
  if (!mixInSerializers.containsKey(type)) {
    // 放入Map缓存起来,之后就不需要生成类,直接get使用即可
    mixInSerializers.putIfAbsent(type, value);
  }
}

最后,存放字节码的字节数组byte[] 又如何被加载成为类的呢?这里需要自定义一个类加载器

public static class AsmClassLoader extends ClassLoader {

  private static final AsmClassLoader INSTANCE = AccessController.doPrivileged(
    (PrivilegedAction<AsmClassLoader>) AsmClassLoader::new);

  public AsmClassLoader() {
    // 设置父加载器
    super(getParentClassLoader());
  }

  private static ClassLoader getParentClassLoader() {
		// 由于双亲委派机制,需要设置一个应用类加载器作为父类加载器
    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

    if (classLoader == null) {
      classLoader = JsonUtil.class.getClassLoader();
    }

    return classLoader;
  }

  // 这里定义一个方法,调用了ClassLoader的protected final的方法
  public Class<?> defineClassPublic(String name, byte[] b, int off, int len) throws ClassFormatError {
    return defineClass(name, b, off, len);
  }
}

由于ClassLoader中接收一个字节数组并加载为类的方法(defineClass)的修饰符是protected且final的,无法复写,只能被子类调用,所以我们需要自己定义一个方法然后隔一层调用它。

接下来就是利用反射将加载好的类对象变为实例了

AsmClassLoader classLoader = AsmClassLoader.INSTANCE;
Class<?> serializerClass = classLoader.defineClassPublic(defineClassName, code, 0, code.length);

Constructor<?> constructor = serializerClass.getConstructor();

JavaBeanSerializer serializer = (JavaBeanSerializer) constructor.newInstance();

这样,就完成了字节码增强的全过程,在后续的序列化过程中就能避免很多反射操作。当然,在反序列化的时候也是同理,我们还能把反序列化过程批量set(Batch Set),也能对性能进一步提升。

总结

当然,FastJSON性能快的技术内幕应该还有一些,我们这里只提及了几个比较特别的几点,只要做到以上几点,序列化速度已经不慢了(亲测100万数据下,做到以上几点的自定义序列化工具比GSON还要快几倍,稍微比Jackson慢一些,比FastJSON慢30-40%),但还是可以看出,与FastJSON本体的性能还是有一些差距的,首先FastJSON定制化了很多东西(例如ASM框架、存放序列化器的线程安全Map等等),还做了一些优化算法,这些都能对性能进行更进一步的提升。可以看出来,一个中间件框架工具,其很多东西都是锱铢必较的,就像Netty,ThreadLocal都是自己定制的,数据结构用的都是自己包装的数组结构等等。

发布了84 篇原创文章 · 获赞 85 · 访问量 10万+

猜你喜欢

转载自blog.csdn.net/qq_41737716/article/details/102621091