Jsoniter: JSON 比 thrift/avro 更快
Jsoniter 是一个适用于 Java 和 Go 的新型 JSON 库,具有创新的 API,并且比 thrift/avro 更快。
引言
JSON 被认为速度很慢,比 protobuf/thrift/avro/... 慢几倍。
https://github.com/ngs-doo/dsl-json 是一个用 Java 实现的非常快速的 JSON 库,它证明了 JSON 并没有那么慢。
http://jsoniter.com/ 将 dsl-json 的代码生成器从 .NET 移植到了 Java(尽管 dsl-json
是一个 Java 库,但其代码生成器不是)。我还添加了一个非常强大的“Any”数据类型,可以以弱类型的方式惰性解析任何 JSON(本文不详述,您可以在此处查看)。
序列化速度:https://github.com/json-iterator/jvm-serializers
- protobuf: 684
- thrift: 1317
- avro: 1688
- dsl-json: 471
- jsoniter: 517
反序列化速度
- protobuf: 448
- thrift: 734
- avro: 844
- dsl-json: 776
- jsoniter: 726
dsl-json 之所以能让 JSON 比 thrift/avro 更快,秘密是什么?文本协议难道不慢吗?
优化
没有 JNI 或 SIMD 的暗箱操作。JVM 仍然不是指令级定制的理想平台。Go 可能是尝试 SIMD 的一个好选择,例如:https://github.com/minio/blake2b-simd
优化非常直接
- 分配更少
- 只扫描一次
- 最少的分支
- 快速路径捷径,针对 80% 的情况进行优化
Jsoniter 移植了 dsl-json 的实现,所以下面的文章将基于 jsoniter。
单次扫描
所有解析都直接从字节数组流中一次性完成。单次扫描具有两层含义:
- 宏观上:迭代器 API 是单向的,您只需获取当前位置所需的内容。不能回溯。
- 微观上:
readInt
或readString
是单次完成的。例如,解析整数不是通过截取string
,然后解析string
来完成的。相反,我们直接使用字节流计算int
值。即使是readFloat
或readDouble
,也以这种方式实现,但有例外。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/IterImplNumber.java
最少分配
在所有必要的情况下都避免了复制。例如,解析器有一个内部字节数组缓冲区,用于存储最近的字节。在解析对象的字段名时,我们不分配新的字节来存储字段名。相反,如果可能,缓冲区被用作切片(类似于 Go)。
迭代器实例本身会保留其使用的所有类型缓冲区的副本,并且可以通过重置迭代器并传入新输入来重用它们,而不是创建全新的迭代器。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/IterImpl.java
从流中拉取
输入可以是 InputStream
,我们不会将所有字节读入一个大数组。相反,解析是分块进行的。当我们需要更多时,我们从流中拉取。
认真对待字符串
如果处理不当,String
解析会成为性能瓶颈。我从 jsonparser 和 dsljson 学到的技巧是,对于没有转义字符的 string
,采用快速路径。
对于 Java,string
是基于 utf-16 char
的。将 utf8 字节流解析为 utf16 char
数组直接由解析器完成,而不是使用 UTF8 字符集。构造 string
的成本只是一个 char
数组的复制。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/IterImplString.java
将 string
转换回字节也很慢。如果我们确定 string
只包含 ASCII,那么使用 getBytes
有一种更快的实现方式。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/output/JsonStream.java
基于模式
迭代器 API 是主动的,而不是像 tokenizer
API 那样被动。它不会先解析出 token,然后进行分支。相反,给定模式,我们确切地知道接下来是什么,所以我们直接解析它们。如果输入不匹配,则抛出适当的错误。
如果数据绑定是通过代码生成模式完成的,那么整个解码器/编码器源代码都将被生成。我们确切地知道先写什么,后写什么。接下来期待什么。
跳过应采用不同路径
跳过对象或数组应采用从 jsonparser 学到的不同路径。当我们跳过整个对象时,我们不关心嵌套的字段名等。
这个领域可以通过语言支持 SIMD 进一步改进:https://github.com/kostya/benchmarks/pull/46#issuecomment-147932489
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/IterImplSkip.java
表查找
某些计算,例如字符‘5
’的 int
值,可以提前完成。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/IterImplNumber.java
通过哈希匹配字段
JSON 解析的大部分可以通过提供一个类作为模式来预定义。然而,JSON 中没有字段名的顺序。因此,在匹配字段时必须有大量的分支。dsl-json 中使用的技巧是哈希码。而不是逐个字符串比较字段名,它计算字段的哈希值并进行 switch
-case
查找。
通过前缀树匹配字段
哈希码不是无冲突的。我们可以在哈希码匹配后添加字符串相等性检查,或者我们可以这样做
public Object decode(java.lang.reflect.Type type, com.jsoniter.Jsoniter iter) {
com.jsoniter.SimpleObject obj = new com.jsoniter.SimpleObject();
for (com.jsoniter.Slice field = iter.readObjectAsSlice(); field != null;
field = iter.readObjectAsSlice()) {
switch (field.len) {
case 6:
if (field.at(0)==102) {
if (field.at(1)==105) {
if (field.at(2)==101) {
if (field.at(3)==108) {
if (field.at(4)==100) {
if (field.at(5)==49) {
obj.field1 = iter.readString();
continue;
}
if (field.at(5)==50) {
obj.field2 = iter.readString();
continue;
}
}
}
}
}
}
break;
}
iter.skip();
}
return obj;
}
}
捷径
我们知道大多数字符串是 ASCII,所以我们可以先尝试将其解码为 ASCII。如果不是这种情况,我们会回退到一种较慢的方式。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/IterImplString.java
惰性解析
如果将 JSON 读取到 Object 或 JsonNode,我们会读取所有字段和元素。这会分配大量内存并进行大量计算。如果我们只在必要时进行反序列化,惰性解析可以节省大量不必要的工作。
这是“Any”数据类型背后的核心思想。它本质上使单向 JSON 迭代器可以随机访问。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/any/ObjectLazyAny.java
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/any/ArrayLazyAny.java
一次性写入尽可能多的内容
JSON 序列化中的一个重要优化是不要逐字节写入。write("{\"field1\":
") 比 write('{')
, write("field1")
然后 write(':')
更快。
如果我们知道某些字段不是可空的,我们可以将它们的引号或 []
合并到一次写入调用中。
更快的数字编解码器
SIMD 也可以使这更快,可惜 Java 没有。但比 Integer.toString
和 Integer.valueOf
更快的实现是可能的。
动态类阴影
输入可以是字节数组或输入流。如果只需要处理有限的字节,解码可以大大简化。但是,我们不能假定我们只处理字节数组。通常,我们会使用虚拟方法来解决这个问题。然而,通过 vtable
进行运行时分派速度很慢。
就像 C++ 模板一样,Java 拥有类和类加载器来实现零成本抽象。类加载过程可以注入不同的类。我们实现了两个类,一个 IterImpl.class
,一个 IterImplForStreaming.class
。在运行时,我们通过类阴影决定使用哪个。
相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/DynamicCodegen.java
摘要
别误会,JSON 是一种比二进制协议慢的文本协议。JSON 的实现和二进制协议的实现是不同的。如果没有仔细优化,二进制协议可能会变慢,即使理论上它应该快得多。在当前的 Java 生态系统中,其他选项并没有比 JSON“显著”快。
Java 缺少 SIMD 支持,这让我思考 SIMD 是否能让 JSON 更快?SIMD 能否让 protobuf/thrit/avro 变得更快?Go 可能是尝试这些事的好语言。期待这一点。