Protobuf 有沒有比 JSON 快 5 倍?

rtcd8909 7年前發布 | 90K 次閱讀 JSON JSON開發包 protobuf

拿 JSON 襯托 Protobuf 的文章真的太多了,經常可以看到文章中寫道:“快來用 Protobuf 吧,JSON 太慢啦”。但是 Protobuf 真的有吹的那么牛么?我覺得從 JSON 切換到 Protobuf 怎么也得快一倍吧,要不然對不起付出的切換成本。然而,DSL-JSON 的家伙們居然說在Java語言里 JSON 和那些二進制的編解碼格式有得一拼( https://blog.dsl-platform.com/improving-java-json-speed/ ),這太讓人驚訝了!雖然你可能會說,咱們能不用蘋果和梨來做比較了么?兩個東西根本用途完全不一樣好么。咱們用 Protobuf 是沖著跨語言無歧義的 IDL 的去的,才不僅僅是因為性能呢。好吧,這個我同意。但是仍然有那么多人盲目相信,Protobuf 一定會快很多,我覺得還是有必要徹底終結一下這個關于速度的傳說。

DSL-JSON 的博客里只給了他們的測試結論,但是沒有給出任何原因,以及優化的細節。這很難讓人信服數據是真實的。你要說 JSON 比二進制格式更快,真的是很反直覺的事情。稍微琢磨一下這個問題,就可以列出好幾個 Protobuf 應該更快的理由:

  • 更容容易綁定值到對象的字段上。JSON 的字段是用字符串指定的,相比之下字符串比對應該比基于數字的字段tag更耗時。
  • JSON 是文本的格式,整數和浮點數應該更占空間而且更費時。
  • Protobuf 在正文前有一個大小或者長度的標記,而 JSON 必須全文掃描無法跳過不需要的字段。

但是僅憑這幾點是不是就可以蓋棺定論了呢?未必,也有相反的觀點:

  • 如果字段大部分是字符串,占到決定性因素的因素可能是字符串拷貝的速度,而不是解析的速度。在這個評測中( https://github.com/fabienrenaud/java-json-benchmark ),我們看到不少庫的性能是非常接近的。這是因為測試數據中大部分是由字符串構成的。
  • 影響解析速度的決定性因素是分支的數量。因為分支的存在,解析仍然是一個本質上串行的過程。雖然Protobuf里沒有[] 或者 {},但是仍然有類似的分支代碼的存在。如果沒有這些分支的存在,解析不過就是一個 memcpy 的操作而已。只有 Parabix 這樣的技術才有革命性的意義,而 Protobuf 相比 JSON 只是改良而非革命。
  • 也許 Protobuf 是一個理論上更快的格式,但是實現它的庫并不一定就更快。這取決于優化做得好不好,如果有不必要的內存分配或者重復讀取,實際的速度未必就快。

有多個 benchmark 都把 DSL-JSON列到前三名里,有時甚至比其他的二進制編碼更快。經過我仔細分析,原因出在了這些 benchmark 對于測試數據的構成選擇上。因為構造測試數據很麻煩,所以一般評測只會對 相同的測試數據 ,去測不同的庫的實現。這樣就使得結果是嚴重傾向于某種類型輸入的。比如 https://github.com/eishay/jvm-serializers/wiki 選擇的測試數據的結構是這樣的

message Image {
  required string uri = 1;      //url to the thumbnail
  optional string title = 2;    //used in the html ALT
  required int32 width = 3;     // of the image
  required int32 height = 4;    // of the image
  enum Size {
    SMALL = 0;
    LARGE = 1;
  }
  required Size size = 5;       // of the image (in relative terms, provided by cnbc for example)
}

message Media {
  required string uri = 1;      //uri to the video, may not be an actual URL
  optional string title = 2;    //used in the html ALT
  required int32 width = 3;     // of the video
  required int32 height = 4;    // of the video
  required string format = 5;   //avi, jpg, 油Tube, cnbc, audio/mpeg formats ...
  required int64 duration = 6;  //time in miliseconds
  required int64 size = 7;      //file size
  optional int32 bitrate = 8;   //video 
  repeated string person = 9;   //name of a person featured in the video
  enum Player {
    JAVA = 0;
    FLASH = 1;
  }
  required Player player = 10;   //in case of a player specific media
  optional string copyright = 11;//media copyright
}

message MediaContent {
  repeated Image image = 1;
  required Media media = 2;
}

無論怎么去構造 small/medium/large 的輸入,benchmark 仍然是存在特定傾向性的。而且這種傾向性是不明確的。比如 medium 的輸入,到底說明了什么?medium 對于不同的人來說,可能意味著完全不同的東西。所以,在這里我想改變一下游戲的規則。不去選擇一個所謂的最現實的配比,而是構造一些 極端的情況 。這樣,我們可以一目了然的知道,JSON的強項和弱點都是什么。通過把這些缺陷放大出來,我們也就可以對最壞的情況有一個清晰的預期。具體在你的場景下性能差距是怎樣的一個區間內,也可以大概預估出來。

好了,廢話不多說了。JMH 擼起來。benchmark 的對象有以下幾個:

  • Jsoniter: http://jsoniter.com/index.cn.html 我抄襲 DSL-JSON 寫的實現。 特別申明:我是 Jsoniter 的作者。這里提到的所有關于Jsoniter 的評測數據都不應該被盲目相信 。大部分的性能優化技巧是從 DSL-JSON 中直接抄來的。
  • Thrift: https://thrift.apache.org 另外一個很流行的 RPC 編解碼格式。這里 benchmark 的是 TCompactProtocol

Decode Integer

先從一個簡單的場景入手。毫無疑問,Protobuf 非常擅長于處理整數

message PbTestObject {
  int32 field1 = 1;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_int

從結果上看,似乎優勢非常明顯。但是因為只有 1 個整數字段,所以可能整數解析的成本沒有占到大頭。所以,我們把測試調整對象調整為 10 個整數字段。再比比看

syntax = "proto3";
option optimize_for = SPEED;
message PbTestObject {
  int32 field1 = 1;
  int32 field2 = 2;
  int32 field3 = 3;
  int32 field4 = 4;
  int32 field5 = 5;
  int32 field6 = 6;
  int32 field7 = 7;
  int32 field8 = 8;
  int32 field9 = 9;
  int32 field10 = 10;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_int_fields

這下優勢就非常明顯了。毫無疑問, Protobuf 解析整數的速度是非常快的,能夠達到 Jackson 的 8 倍

DSL-JSON 比 Jackson 快很多,它的優化代碼在這里 https://github.com/ngs-doo/dsl-json/blob/master/library/src/main/java/com/dslplatform/json/NumberConverter.java

private static int parsePositiveInt(final byte[] buf, final JsonReader reader, final int start, final int end, int i) throws IOException {
        int value = 0;
        for (; i < end; i++) {
                final int ind = buf[i] - 48;
                if (ind < 0 || ind > 9) {
... // abbreviated
                }
                value = (value << 3) + (value << 1) + ind;
                if (value < 0) {
                        throw new IOException("Integer overflow detected at position: " + reader.positionInStream(end - start));
                }
        }
        return value;
}

整數是直接從輸入的字節里計算出來的,公式是 value = (value << 3) + (value << 1) + ind; 相比讀出字符串,然后調用 Integer.valueOf ,這個實現只遍歷了一遍輸入,同時也避免了內存分配。

Jsoniter 在這個基礎上做了循環展開

... // abbreviated
int i = iter.head;
int ind2 = intDigits[iter.buf[i]];
if (ind2 == INVALID_CHAR_FOR_NUMBER) {
    iter.head = i;
    return ind;
}
int ind3 = intDigits[iter.buf[++i]];
if (ind3 == INVALID_CHAR_FOR_NUMBER) {
    iter.head = i;
    return ind * 10 + ind2;
}
int ind4 = intDigits[iter.buf[++i]];
if (ind4 == INVALID_CHAR_FOR_NUMBER) {
    iter.head = i;
    return ind * 100 + ind2 * 10 + ind3;
}
... // abbreviated

Encode Integer

編碼方面情況如何呢?和編碼一樣的測試數據,測試結果如下:

不知道為啥,Thrift 的序列化特別慢。而且別的 benchmark 里 Thrift 的序列化都是算慢的。我猜測應該是實現里有不夠優化的地方吧,格式應該沒問題。 整數編碼方面,Protobuf 是 Jackson 的 3 倍。 但是和 DSL-JSON 比起來,好像沒有快很多。

這是因為 DSL-JSON 使用了自己的優化方式,和 JDK 的官方實現不一樣 https://github.com/ngs-doo/dsl-json/blob/master/library/src/main/java/com/dslplatform/json/NumberConverter.java

private static int serialize(final byte[] buf, int pos, final int value) {
        int i;
        if (value < 0) {
                if (value == Integer.MIN_VALUE) {
                        for (int x = 0; x < MIN_INT.length; x++) {
                                buf[pos + x] = MIN_INT[x];
                        }
                        return pos + MIN_INT.length;
                }
                i = -value;
                buf[pos++] = MINUS;
        } else {
                i = value;
        }
        final int q1 = i / 1000;
        if (q1 == 0) {
                pos += writeFirstBuf(buf, DIGITS[i], pos);
                return pos;
        }
        final int r1 = i - q1 * 1000;
        final int q2 = q1 / 1000;
        if (q2 == 0) {
                final int v1 = DIGITS[r1];
                final int v2 = DIGITS[q1];
                int off = writeFirstBuf(buf, v2, pos);
                writeBuf(buf, v1, pos + off);
                return pos + 3 + off;
        }
        final int r2 = q1 - q2 * 1000;
        final long q3 = q2 / 1000;
        final int v1 = DIGITS[r1];
        final int v2 = DIGITS[r2];
        if (q3 == 0) {
                pos += writeFirstBuf(buf, DIGITS[q2], pos);
        } else {
                final int r3 = (int) (q2 - q3 * 1000);
                buf[pos++] = (byte) (q3 + '0');
                writeBuf(buf, DIGITS[r3], pos);
                pos += 3;
        }
        writeBuf(buf, v2, pos);
        writeBuf(buf, v1, pos + 3);
        return pos + 6;
}

這段代碼的意思是比較令人費解的。不知道哪里就做了數字到字符串的轉換了。過程是這樣的,假設輸入了19823,會被分解為 19 和 823 兩部分。然后有一個 `DIGITS` 的查找表,根據這個表把 19 翻譯為 "19",把 823 翻譯為 "823"。其中 "823" 并不是三個byte分開來存的,而是把bit放到了一個integer里,然后在 writeBuf 的時候通過位移把對應的三個byte解開的

private static void writeBuf(final byte[] buf, final int v, int pos) {
        buf[pos] = (byte) (v >> 16);
        buf[pos + 1] = (byte) (v >> 8);
        buf[pos + 2] = (byte) v;
}

這個實現比 JDK 自帶的 Integer.toString 更快。因為查找表預先計算好了,節省了運行時的計算成本。

Decode Double

解析 JSON 的 Double 就更慢了。

message PbTestObject {
  double field1 = 1;
  double field2 = 2;
  double field3 = 3;
  double field4 = 4;
  double field5 = 5;
  double field6 = 6;
  double field7 = 7;
  double field8 = 8;
  double field9 = 9;
  double field10 = 10;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_double_fields

Protobuf 解析 double 是 Jackson 的 13 倍。 毫無疑問,JSON真的不適合存浮點數。

DSL-Json 中對 Double 也是做了特別優化的 https://github.com/ngs-doo/dsl-json/blob/master/library/src/main/java/com/dslplatform/json/NumberConverter.java

private static double parsePositiveDouble(final byte[] buf, final JsonReader reader, final int start, final int end, int i) throws IOException {
        long value = 0;
        byte ch = ' ';
        for (; i < end; i++) {
                ch = buf[i];
                if (ch == '.') break;
                final int ind = buf[i] - 48;
                value = (value << 3) + (value << 1) + ind;
                if (ind < 0 || ind > 9) {
                        return parseDoubleGeneric(reader.prepareBuffer(start), end - start, reader);
                }
        }
        if (i == end) return value;
        else if (ch == '.') {
                i++;
                long div = 1;
                for (; i < end; i++) {
                        final int ind = buf[i] - 48;
                        div = (div << 3) + (div << 1);
                        value = (value << 3) + (value << 1) + ind;
                        if (ind < 0 || ind > 9) {
                                return parseDoubleGeneric(reader.prepareBuffer(start), end - start, reader);
                        }
                }
                return value / (double) div;
        }
        return value;
}

浮點數被去掉了點,存成了 long 類型,然后再除以對應的10的倍數。如果輸入是3.1415,則會變成 31415/10000。

Encode Double

把 double 編碼為文本格式就更困難了。

解碼 double 的時候,Protobuf 是 Jackson 的13 倍。 如果你愿意犧牲精度的話, Jsoniter 可以選擇只保留6位小數。在這個取舍下,可以好一些,但是 Protobuf 仍然是 Jsoniter 的兩倍。

保留6位小數的代碼是這樣寫的。把 double 的處理變成了長整數的處理。

if (val < 0) {
    val = -val;
    stream.write('-');
}
if (val > 0x4ffffff) {
    stream.writeRaw(Double.toString(val));
    return;
}
int precision = 6;
int exp = 1000000; // 6
long lval = (long)(val * exp + 0.5);
stream.writeVal(lval / exp);
long fval = lval % exp;
if (fval == 0) {
    return;
}
stream.write('.');
if (stream.buf.length - stream.count < 10) {
    stream.flushBuffer();
}
for (int p = precision - 1; p > 0 && fval < POW10[p]; p--) {
    stream.buf[stream.count++] = '0';
}
stream.writeVal(fval);
while(stream.buf[stream.count-1] == '0') {
    stream.count--;
}

到目前來看,我們可以說 JSON 不是為數字設計的。如果你使用的是 Jackson,切換到 Protobuf 的話可以把數字的處理速度提高 10 倍。然而 DSL-Json 做的優化可以把這個性能差距大幅縮小,解碼在 3x ~ 4x 之間,編碼在 1.3x ~ 2x 之間(前提是犧牲 double 的編碼精度)。

因為 JSON 處理 double 非常慢。所以 Jsoniter 提供了一種把 double 的 IEEE 754 的二進制表示(64個bit)用 base64 編碼之后保存的方案。如果希望提高速度,但是又要保持精度,可以使用 Base64FloatSupport.enableEncodersAndDecoders();

long bits = Double.doubleToRawLongBits(number.doubleValue());
Base64.encodeLongBits(bits, stream);

static void encodeLongBits(long bits, JsonStream stream) throws IOException {
    int i = (int) bits;
    byte b1 = BA[(i >>> 18) & 0x3f];
    byte b2 = BA[(i >>> 12) & 0x3f];
    byte b3 = BA[(i >>> 6) & 0x3f];
    byte b4 = BA[i & 0x3f];
    stream.write((byte)'"', b1, b2, b3, b4);
    bits = bits >>> 24;
    i = (int) bits;
    b1 = BA[(i >>> 18) & 0x3f];
    b2 = BA[(i >>> 12) & 0x3f];
    b3 = BA[(i >>> 6) & 0x3f];
    b4 = BA[i & 0x3f];
    stream.write(b1, b2, b3, b4);
    bits = (bits >>> 24) << 2;
    i = (int) bits;
    b1 = BA[i >> 12];
    b2 = BA[(i >>> 6) & 0x3f];
    b3 = BA[i & 0x3f];
    stream.write(b1, b2, b3, (byte)'"');
}

對于 0.123456789 就變成了 "OWNfmt03P78"

Decode Object

我們已經看到了 JSON 在處理數字方面的笨拙丑態了。在處理對象綁定方面,是不是也一樣不堪?前面的 benchmark 結果那么差和按字段做綁定是不是有關系?畢竟我們有 10 個字段要處理那。這就來看看在處理字段方面的效率問題。

為了讓比較起來公平一些,我們使用很短的 ascii 編碼的字符串作為字段的值。這樣字符串拷貝的成本大家都差不到哪里去。所以性能上要有差距,必然是和按字段綁定值有關系。

message PbTestObject {
  string field1 = 1;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_1_string_field

如果只有一個字段,Protobuf 是 Jackson 的 2.5 倍。 但是比 DSL-JSON 要慢。

我們再把同樣的實驗重復幾次,分別對應 5 個字段,10個字段的情況。

message PbTestObject {
  string field1 = 1;
  string field2 = 2;
  string field3 = 3;
  string field4 = 4;
  string field5 = 5;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_5_string_fields

在有 5 個字段的情況下,Protobuf 僅僅是 Jackson 的 1.3x 倍。 如果你認為 JSON 對象綁定很慢,而且會決定 JSON 解析的整體性能。對不起,你錯了。

message PbTestObject {
  string field1 = 1;
  string field2 = 2;
  string field3 = 3;
  string field4 = 4;
  string field5 = 5;
  string field6 = 6;
  string field7 = 7;
  string field8 = 8;
  string field9 = 9;
  string field10 = 10;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_string_fields

把字段數量加到了 10 個之后,Protobuf 僅僅是 Jackson 的 1.22 倍了。 看到這里,你應該懂了吧。

Protobuf 在處理字段綁定的時候,用的是 switch case:

boolean done = false;
while (!done) {
  int tag = input.readTag();
  switch (tag) {
    case 0:
      done = true;
      break;
    default: {
      if (!input.skipField(tag)) {
        done = true;
      }
      break;
    }
    case 10: {
      java.lang.String s = input.readStringRequireUtf8();
      field1_ = s;
      break;
    }
    case 18: {
      java.lang.String s = input.readStringRequireUtf8();
      field2_ = s;
      break;
    }
    case 26: {
      java.lang.String s = input.readStringRequireUtf8();
      field3_ = s;
      break;
    }
    case 34: {
      java.lang.String s = input.readStringRequireUtf8();
      field4_ = s;
      break;
    }
    case 42: {
      java.lang.String s = input.readStringRequireUtf8();
      field5_ = s;
      break;
    }
  }
}

這個實現比 Hashmap 來說,僅僅是稍微略快而已。DSL-JSON 的實現是先 hash,然后也是類似的分發的方式:

switch(nameHash) {
case 1212206434:
        _field1_ = com.dslplatform.json.StringConverter.deserialize(reader);
nextToken = reader.getNextToken();
        break;
case 1178651196:
        _field3_ = com.dslplatform.json.StringConverter.deserialize(reader);
nextToken = reader.getNextToken();
        break;
case 1195428815:
        _field2_ = com.dslplatform.json.StringConverter.deserialize(reader);
nextToken = reader.getNextToken();
        break;
case 1145095958:
        _field5_ = com.dslplatform.json.StringConverter.deserialize(reader);
nextToken = reader.getNextToken();
        break;
case 1161873577:
        _field4_ = com.dslplatform.json.StringConverter.deserialize(reader);
nextToken = reader.getNextToken();
        break;
default:
        nextToken = reader.skip();
        break;
}

使用的 hash 算法是 FNV-1a。

long hash = 0x811c9dc5;
while (ci < buffer.length) {
        final byte b = buffer[ci++];
        if (b == '"') break;
        hash ^= b;
        hash *= 0x1000193;
}

是 hash 就會碰撞,所以用起來需要小心。如果輸入很有可能包含未知的字段,則需要放棄速度選擇匹配之后再查一下字段是不是嚴格相等的。 Jsoniter 有一個解碼模式 DYNAMIC_MODE_AND_MATCH_FIELD_STRICTLY,它可以產生下面這樣的嚴格匹配的代碼:

switch (field.len()) {
case 6:
    if (field.at(0) == 102 &&
            field.at(1) == 105 &&
            field.at(2) == 101 &&
            field.at(3) == 108 &&
            field.at(4) == 100) {
        if (field.at(5) == 49) {
            obj.field1 = (java.lang.String) iter.readString();
            continue;
        }
        if (field.at(5) == 50) {
            obj.field2 = (java.lang.String) iter.readString();
            continue;
        }
        if (field.at(5) == 51) {
            obj.field3 = (java.lang.String) iter.readString();
            continue;
        }
        if (field.at(5) == 52) {
            obj.field4 = (java.lang.String) iter.readString();
            continue;
        }
        if (field.at(5) == 53) {
            obj.field5 = (java.lang.String) iter.readString();
            continue;
        }
    }
    break;
}
iter.skip();

即便是嚴格匹配,速度上也是有保證的。DSL-JSON 也有選項,可以在 hash 匹配之后額外加一次字符串 equals 檢查。

關于對象綁定來說,只要字段名不長,基于數字的 tag 分發并不會比 JSON 具有明顯優勢,即便是相比最慢的 Jackson 來說也是如此。

Encode Object

廢話不多說了,直接比較一下三種字段數量情況下,編碼的速度

只有 1 個字段

有 5 個字段

有 10 個字段

對象編碼方面,Protobuf 是 Jackson 的 1.7 倍。 但是速度其實比 DSL-Json 還要慢。

優化對象編碼的方式是,一次性盡可能多的把控制類的字節寫出去。

public void encode(Object obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException {
  if (obj == null) { stream.writeNull(); return; }
  stream.write((byte)'{');
  encode_((com.jsoniter.benchmark.with_1_string_field.TestObject)obj, stream);
  stream.write((byte)'}');
}

public static void encode_(com.jsoniter.benchmark.with_1_string_field.TestObject obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException {
  boolean notFirst = false;
  if (obj.field1 != null) {
  if (notFirst) { stream.write(','); } else { notFirst = true; }
  stream.writeRaw("\"field1\":", 9);
  stream.writeVal((java.lang.String)obj.field1);
  }
}

可以看到我們把 "field1": 作為一個整體寫出去了。如果我們知道字段是非空的,則可以進一步的把字符串的雙引號也一起合并寫出去。

public void encode(Object obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException {
  if (obj == null) { stream.writeNull(); return; }
  stream.writeRaw("{\"field1\":\"", 11);
  encode_((com.jsoniter.benchmark.with_1_string_field.TestObject)obj, stream);
  stream.write((byte)'\"', (byte)'}');
}

public static void encode_(com.jsoniter.benchmark.with_1_string_field.TestObject obj, com.jsoniter.output.JsonStream stream) throws java.io.IOException {
  com.jsoniter.output.CodegenAccess.writeStringWithoutQuote((java.lang.String)obj.field1, stream);
}

從對象的編解碼的 benchmark 結果可以看出,Protobuf 在這個方面僅僅比 Jackson 略微強一些,而比 DSL-Json 要慢。

Decode Integer List

Protobuf 對于整數列表有特別的支持,可以打包存儲

22        // tag (field number 4, wire type 2)
06        // payload size (6 bytes)
03        // first element (varint 3)
8E 02     // second element (varint 270)
9E A7 05  // third element (varint 86942)
設置 [packed=true]
message PbTestObject {
  repeated int32 field1 = 1 [packed=true];
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_int_list

對于整數列表的解碼,Protobuf 是 Jackson 的 3 倍。 然而比 DSL-Json 的優勢并不明顯。

Jsoniter 里,解碼的循環被展開了:

public static java.lang.Object decode_(com.jsoniter.JsonIterator iter) throws java.io.IOException { 
    java.util.ArrayList col = (java.util.ArrayList)com.jsoniter.CodegenAccess.resetExistingObject(iter);
    if (iter.readNull()) { com.jsoniter.CodegenAccess.resetExistingObject(iter); return null; }
    if (!com.jsoniter.CodegenAccess.readArrayStart(iter)) {
        return col == null ? new java.util.ArrayList(0): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col);
    }
    Object a1 = java.lang.Integer.valueOf(iter.readInt());
    if (com.jsoniter.CodegenAccess.nextToken(iter) != ',') {
        java.util.ArrayList obj = col == null ? new java.util.ArrayList(1): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col);
        obj.add(a1);
        return obj;
    }
    Object a2 = java.lang.Integer.valueOf(iter.readInt());
    if (com.jsoniter.CodegenAccess.nextToken(iter) != ',') {
        java.util.ArrayList obj = col == null ? new java.util.ArrayList(2): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col);
        obj.add(a1);
        obj.add(a2);
        return obj;
    }
    Object a3 = java.lang.Integer.valueOf(iter.readInt());
    if (com.jsoniter.CodegenAccess.nextToken(iter) != ',') {
        java.util.ArrayList obj = col == null ? new java.util.ArrayList(3): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col);
        obj.add(a1);
        obj.add(a2);
        obj.add(a3);
        return obj;
    }
    Object a4 = java.lang.Integer.valueOf(iter.readInt());
    java.util.ArrayList obj = col == null ? new java.util.ArrayList(8): (java.util.ArrayList)com.jsoniter.CodegenAccess.reuseCollection(col);
    obj.add(a1);
    obj.add(a2);
    obj.add(a3);
    obj.add(a4);
    while (com.jsoniter.CodegenAccess.nextToken(iter) == ',') {
        obj.add(java.lang.Integer.valueOf(iter.readInt()));
    }
    return obj;
}

對于成員比較少的情況,這樣搞可以避免數組的擴容帶來的內存拷貝。

Encode Integer List

Protobuf 在編碼數組的時候應該有優勢,不用寫那么多逗號出來嘛。

Protobuf 在編碼整數列表的時候,僅僅是 Jackson 的 1.35 倍。 雖然 Protobuf 在處理對象的整數字段的時候優勢明顯,但是在處理整數的列表時卻不是如此。在這個方面,DSL-Json 沒有特殊的優化,性能的提高純粹只是因為單個數字的編碼速度提高了。

Decode Object List

列表經常用做對象的容器。測試這種兩種容器組合嵌套的場景,也很有代表意義。

message PbTestObject {
  message ElementObject {
    string field1 = 1;
  }
  repeated ElementObject field1 = 1;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_object_list

Protobuf 處理對象列表是 Jackson 的 1.3 倍。 但是不及 DSL-JSON。

Encode Object List

Protobuf 處理對象列表的編碼速度是 Jackson 的 2 倍。 但是 DSL-JSON 仍然比 Protobuf 更快。似乎 Protobuf 在處理列表的編碼解碼方面優勢不明顯。

Decode Double Array

Java 的數組有點特殊,double[] 是比 List<Double> 更高效的。使用 double 數組來代表時間點上的值或者坐標是非常常見的做法。然而,Protobuf 的 Java  庫沒有提供double[] 的支持,repeated 總是使用 List<Double>。我們可以預期 JSON 庫在這里有一定的優勢。

message PbTestObject {
  repeated double field1 = 1 [packed=true];
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_double_array

Protobuf 在處理 double 數組方面,Jackson 與之的差距被縮小為 5 倍。 Protobuf 與 DSL-JSON 相比,優勢已經不明顯了。所以如果你有很多的 double 數值需要處理,這些數值必須是在對象的字段上,才會引起性能的巨大差別,對于數組里的 double,優勢差距被縮小。

Jsoniter 里,處理數組的循環也是被展開的。

public static java.lang.Object decode_(com.jsoniter.JsonIterator iter) throws java.io.IOException {
... // abbreviated
 nextToken = com.jsoniter.CodegenAccess.nextToken(iter);
 if (nextToken == ']') {
     return new double[0];
 }
 com.jsoniter.CodegenAccess.unreadByte(iter);
 double a1 = iter.readDouble();
 if (!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) {
     return new double[]{ a1 };
 }
 double a2 = iter.readDouble();
 if (!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) {
     return new double[]{ a1, a2 };
 }
 double a3 = iter.readDouble();
 if (!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) {
     return new double[]{ a1, a2, a3 };
 }
 double a4 = (double) iter.readDouble();
 if (!com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) {
     return new double[]{ a1, a2, a3, a4 };
 }
 double a5 = (double) iter.readDouble();
 double[] arr = new double[10];
 arr[0] = a1;
 arr[1] = a2;
 arr[2] = a3;
 arr[3] = a4;
 arr[4] = a5;
 int i = 5;
 while (com.jsoniter.CodegenAccess.nextTokenIsComma(iter)) {
     if (i == arr.length) {
         double[] newArr = new double[arr.length * 2];
         System.arraycopy(arr, 0, newArr, 0, arr.length);
         arr = newArr;
     }
     arr[i++] = iter.readDouble();
 }
 double[] result = new double[i];
 System.arraycopy(arr, 0, result, 0, i);
 return result;
}

這避免了數組擴容的開銷。

Encode Double Array

再來看看 double 數組的編碼

Protobuf 可以飛快地對 double 數組進行編碼,是 Jackson 的 15 倍。 在犧牲精度的情況下,Protobuf 只是 Jsoniter 的 2.3 倍。所以,再次證明了,JSON 處理 double 非常慢。如果用 base64 編碼 double,則可以保持精度,速度和犧牲精度時一樣。

Decode String

JSON 字符串包含了轉義字符的支持。Protobuf 解碼字符串僅僅是一個內存拷貝。理應更快才對。被測試的字符串長度是 160 個字節的 ascii。

syntax = "proto3";
option optimize_for = SPEED;
message PbTestObject {
  string field1 = 1;
}

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_long_string

Protobuf 解碼長字符串是 Jackson 的 1.85 倍。 然而,DSL-Json 比 Protobuf 更快。這就有點奇怪了,JSON 的處理負擔更重,為什么會更快呢?

先嘗試捷徑

DSL-JSON 給 ascii 實現了一個捷徑: https://github.com/ngs-doo/dsl-json/blob/master/library/src/main/java/com/dslplatform/json/JsonReader.java

for (int i = 0; i < chars.length; i++) {
        bb = buffer[ci++];
        if (bb == '"') {
                currentIndex = ci;
                return i;
        }
        // If we encounter a backslash, which is a beginning of an escape sequence
        // or a high bit was set - indicating an UTF-8 encoded multibyte character,
        // there is no chance that we can decode the string without instantiating
        // a temporary buffer, so quit this loop
        if ((bb ^ '\\') < 1) break;
        chars[i] = (char) bb;
}

這個捷徑里規避了處理轉義字符和utf8字符串的成本。

JVM 的動態編譯做了特殊優化

在 JDK9 之前,java.lang.String 都是基于 `char[]` 的。而輸入都是 byte[] 并且是 utf-8 編碼的。所以這使得,我們不能直接用 memcpy 的方式來處理字符串的解碼問題。

但是在 JDK9 里,java.lang.String 已經改成了基于`byte[]`的了。從 JDK9 的源代碼里可以看出:

@Deprecated(since="1.1")
public String(byte ascii[], int hibyte, int offset, int count) {
    checkBoundsOffCount(offset, count, ascii.length);
    if (count == 0) {
        this.value = "".value;
        this.coder = "".coder;
        return;
    }
    if (COMPACT_STRINGS && (byte)hibyte == 0) {
        this.value = Arrays.copyOfRange(ascii, offset, offset + count);
        this.coder = LATIN1;
    } else {
        hibyte <<= 8;
        byte[] val = StringUTF16.newBytesFor(count);
        for (int i = 0; i < count; i++) {
            StringUTF16.putChar(val, i, hibyte | (ascii[offset++] & 0xff));
        }
        this.value = val;
        this.coder = UTF16;
    }
}

使用這個雖然被廢棄,但是還沒有被刪除的構造函數,我們可以使用 Arrays.copyOfRange 來直接構造 java.lang.String 了。然而,在測試之后,發現這個實現方式并沒有比 DSL-JSON 的實現更快。

似乎 JVM 的 Hotspot 動態編譯時對這段循環的代碼做了模式匹配,識別出了更高效的實現方式。即便是在 JDK9 使用 +UseCompactStrings 的前提下,理論上來說本應該更慢的 byte[] => char[] => byte[] 并沒有使得這段代碼變慢,DSL-JSON 的實現還是最快的。

如果輸入大部分是字符串,這個優化就變得至關重要了。Java 里的解析藝術,還不如說是字節拷貝的藝術。JVM 的 java.lang.String 設計實在是太愚蠢了。在現代一點的語言中,比如 Go,字符串都是基于 utf-8 byte[] 的。

Encode String

類似的問題,因為需要把 char[] 轉換為 byte[],所以沒法直接內存拷貝。

Protobuf 在編碼長字符串時,比 Jackson 略微快一點點。 一切都歸咎于 char[]。

跳過數據結構

JSON 是一個沒有 header 的格式。因為沒有 header,JSON 需要掃描每個字節才可以定位到所需的字段上。中間可能要掃過很多不需要處理的字段。

message PbTestWriteObject {
  repeated string field1 = 1;
  message Field2 {
    repeated string field1 = 1;
    repeated string field2 = 2;
    repeated string field3 = 3;
  }
  Field2 field2 = 2;
  string field3 = 3;
}
message PbTestReadObject {
  string field3 = 3;
}

消息用 PbTestWriteObject 來編碼,然后用 PbTestReadObject 來解碼。field1 和 field2 的內容應該被跳過。

https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/skip_multi_levels

Protobuf 在跳過數據結構方面,是 Jackson 的 5 倍。 但是如果跳過長的字符串,JSON 的成本是和字符串長度線性相關的,而 Protobuf 則是一個常量操作。

總結

最后,我們把所有的戰果匯總到一起。

編解碼數字的時候,JSON仍然是非常慢的。 Jsoniter 把這個差距從 10 倍縮小到了 3 倍多一些。

JSON 最差的情況是下面幾種:

  • 跳過非常長的字符串:和字符串長度線性相關。
  • 解碼 double 字段:Protobuf 優勢明顯,是 Jsoniter 的 3.27 倍,是 Jackson 的 13.75 倍。
  • 編碼 double 字段:如果不能接受只保留 6 位小數,Protobuf 是 Jackson 的 12.71 倍。如果接受精度損失,Protobuf 是 Jsoniter 的 1.96 倍。
  • 解碼整數:Protobuf 是 Jsoniter 的 2.64 倍,是 Jackson 的 8.51 倍。

如果你的生產環境中的JSON沒有那么多的double字段,都是字符串占大頭,那么基本上來說替換成 Protobuf 也就是僅僅比 Jsoniter 提高一點點,肯定在2倍之內。如果不幸的話,沒準 Protobuf 還要更慢一點。

 

來自:http://www.infoq.com/cn/articles/json-is-5-times-faster-than-protobuf

 

 本文由用戶 rtcd8909 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!