文章目录
前言
JSON作为一种序列化协议,它有可读性强的特点,其越来越流行,越来越成为一个不同进程交换数据的序列化普遍使用的一个协议,其也不需要配置schama,JSON协议有自己独有的结构,使得反序列化一个JSON串没有语言或是其他什么限制,按照共同知道的JSON规则反序列化就可以了。在分布式环境中,数据的交换是必不可少的,除了二进制的序列化协议,我们常常也大量使用JSON的序列化协议,所以,JSON序列化与反序列化的性能就变得异常重要了。
本篇文章的议题是分析FastJSON的技术内幕,模仿FastJSON自己做一个简易的序列化(不包括反序列化,因为反序列化需要一些语法词法分析,较为繁琐)工具,并且也会使用ASM进行字节码层面的类增强。
学习一个好的作品、性能的锱铢必较和一些优化的思路,我觉得是很好的一件事,就像叶圣陶说的一句话,艺术的事情大都始于模仿,终于独创,而代码与艺术同理,通往一个结果可以有很多条道路,但高级程序员往往追求完美的道路去完成目标,而普通的程序员往往只需要能到目标就行,就像序列化POJO,可以使用反射将属性一一读取出来,但高级程序员会使用字节码增强减少反射开销,而普通程序员直接使用反射,虽然都可以达到目的,但那条达到目的的道路却是不同的,走的路不同,或许这就是高级程序员与一般程序员的区别吧。
1. FastJSON为什么这么快
下面就分几个我认为比较独特的部分,来一一分析FastJSON的技术内幕
1.1 字符数组的处理
我们知道,在序列化与反序列化时,一定少不了字符串的处理,在序列化过程中,往往需要从POJO中读取属性名与属性值,将其一一写入字符串,而字符串究其本身就是字符数组 char[]
。
我们第一反应其实是使用 StringBuilder
去构造、添加字符串,但FastJSON中不是,在FastJSON定制了相当多的实现,其中就有这么一个定制,其为SerializeWriter
类,为什么要定制这么一个实现呢?有以下几点优化点:
- 减少了字符数组内存的开辟(new一个char[])
- 减少了剩余容量的检查
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的