65.9K
CodeProject 正在变化。 阅读更多。
Home

Jsoniter: JSON 比 thrift/avro 更快

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2017 年 1 月 14 日

CPOL

6分钟阅读

viewsIcon

20427

Jsoniter 是一个适用于 Java 和 Go 的新型 JSON 库,具有创新的 API,并且比 thrift/avro 更快。

引言

JSON 被认为速度很慢,比 protobuf/thrift/avro/... 慢几倍。

https://github.com/ngs-doo/dsl-json 是一个用 Java 实现的非常快速的 JSON 库,它证明了 JSON 并没有那么慢。

JSON vs binary

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 是单向的,您只需获取当前位置所需的内容。不能回溯。
  • 微观上:readIntreadString 是单次完成的。例如,解析整数不是通过截取 string,然后解析 string 来完成的。相反,我们直接使用字节流计算 int 值。即使是 readFloatreadDouble,也以这种方式实现,但有例外。

相关源代码: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,我们不会将所有字节读入一个大数组。相反,解析是分块进行的。当我们需要更多时,我们从流中拉取。

相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/IterImplForStreaming.java

认真对待字符串

如果处理不当,String 解析会成为性能瓶颈。我从 jsonparserdsljson 学到的技巧是,对于没有转义字符的 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,然后进行分支。相反,给定模式,我们确切地知道接下来是什么,所以我们直接解析它们。如果输入不匹配,则抛出适当的错误。

如果数据绑定是通过代码生成模式完成的,那么整个解码器/编码器源代码都将被生成。我们确切地知道先写什么,后写什么。接下来期待什么。

示例生成的代码:https://github.com/json-iterator/java/blob/master/demo/src/main/java/encoder/com/jsoniter/demo/User.java

跳过应采用不同路径

跳过对象或数组应采用从 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(':') 更快。

如果我们知道某些字段不是可空的,我们可以将它们的引号或 [] 合并到一次写入调用中。

相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/output/CodegenResult.java

更快的数字编解码器

SIMD 也可以使这更快,可惜 Java 没有。但比 Integer.toStringInteger.valueOf 更快的实现是可能的。

相关源代码:https://github.com/json-iterator/java/blob/master/src/main/java/com/jsoniter/output/StreamImplNumber.java

动态类阴影

输入可以是字节数组或输入流。如果只需要处理有限的字节,解码可以大大简化。但是,我们不能假定我们只处理字节数组。通常,我们会使用虚拟方法来解决这个问题。然而,通过 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 可能是尝试这些事的好语言。期待这一点。

© . All rights reserved.