首頁 > 運動

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

由 即時通訊技術分享 發表于 運動2023-01-15

簡介} If we encounter a backslash, which is a beginning of an escape sequence or a high bit was set - indicating an UTF-

jdk的主要內容是什麼

本文由陶文分享,InfoQ編輯釋出,有修訂和改動。

1、前言

本系列的前幾篇主要是從各個角度講解Protobuf的基本概念、技術原理這些內容,但回過頭來看,對比JSON這種事實上的資料協議工業標準,Protobuf到底效能到底高多少?

本篇將以Protobuf為基準,對比市面上的一些主流的JSON解析庫,透過全方位測試來證明給你看看Protobuf到底比JSON快幾倍。

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

學習交流:

- 移動端IM開發入門文章:《

新手入門一篇就夠:從零開發移動端IM

- 開源IM框架原始碼:

https://github。com/JackJiang2011/MobileIMSDK

備用地址點此

本文已同步釋出於:

http://www。52im。net/thread-4095-1-1。html

2、系列文章

本文是系列文章中的第 5 篇,本系列總目錄如下:

IM通訊協議專題學習(一):Protobuf從入門到精通,一篇就夠!

IM通訊協議專題學習(二):快速理解Protobuf的背景、原理、使用、優缺點

IM通訊協議專題學習(三):由淺入深,從根上理解Protobuf的編解碼原理

IM通訊協議專題學習(四):從Base64到Protobuf,詳解Protobuf的資料編碼原理

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測!

》(

* 本文

《IM通訊協議專題學習(六):手把手教你如何在Android上從零使用Protobuf》(稍後釋出。。)

《IM通訊協議專題學習(七):手把手教你如何在NodeJS中從零使用Protobuf》(稍後釋出。。)

《IM通訊協議專題學習(八):金蝶隨手記團隊的Protobuf應用實踐(原理篇) 》(稍後釋出。。)

《IM通訊協議專題學習(九):金蝶隨手記團隊的Protobuf應用實踐(實戰篇) 》(稍後釋出。。)

3、寫在前面

拿 JSON 襯托 Protobuf 的文章真的太多了,經常可以看到文章中寫道:“快來用 Protobuf 吧,JSON 太慢啦”。

但是 Protobuf 真的有吹的那麼牛麼?

我覺得從 JSON 切換到 Protobuf 怎麼也得快一倍吧,要不然對不起付出的切換成本。然而,

DSL-JSON

的傢伙們居然說在

Java語言裡 JSON 和那些二進位制的編解碼格式有得一拼

,這太讓人驚訝了!

雖然你可能會說,咱們能不用蘋果和梨來做比較了麼?兩個東西根本用途完全不一樣好麼。咱們用 Protobuf 是衝著跨語言無歧義的 IDL 的去的,才不僅僅是因為效能呢。好吧,這個我同意。但是仍然有那麼多人盲目相信,Protobuf 一定會快很多,我覺得還是有必要徹底終結一下這個關於速度的傳說。

DSL-JSON

的部落格裡只給了他們的測試結論,但是沒有給出任何原因,以及最佳化的細節,這很難讓人信服資料是真實的。你要說 JSON 比二進位制格式更快,真的是很反直覺的事情。

稍微琢磨一下這個問題,就可以列出好幾個 Protobuf 應該更快的理由。

比如:

1)

更容容易繫結值到物件的欄位上。JSON 的欄位是用字串指定的,相比之下字串比對應該比基於數字的欄位tag更耗時;

2)

JSON 是文字的格式,整數和浮點數應該更佔空間而且更費時;

3)

Protobuf 在正文前有一個大小或者長度的標記,而 JSON 必須全文掃描無法跳過不需要的欄位。

但是僅憑這幾點是不是就可以蓋棺定論了呢?未必。

也有相反的觀點:

1)

如果欄位大部分是字串,佔到決定性因素的因素可能是字串複製的速度,而不是解析的速度。在這個

評測

中,我們看到不少庫的效能是非常接近的。這是因為測試資料中大部分是由字串構成的;

2)

影響解析速度的決定性因素是分支的數量。因為分支的存在,解析仍然是一個本質上序列的過程。雖然Protobuf裡沒有[] 或者 {},但是仍然有類似的分支程式碼的存在。如果沒有這些分支的存在,解析不過就是一個 memcpy 的操作而已。只有 Parabix 這樣的技術才有革命性的意義,而 Protobuf 相比 JSON 只是改良而非革命;

3)

也許 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, youtube, 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的強項和弱點都是什麼。透過把這些缺陷放大出來,我們也就可以對最壞的情況有一個清晰的預期。具體在你的場景下效能差距是怎樣的一個區間內,也可以大概預估出來。

4、本次評測物件

好了,廢話不多說了,JMH 擼起來。

benchmark 的物件有以下幾個:

1)

Jackson

:Java 程式裡用的最多的 JSON 解析器。benchmark 中開啟了 AfterBurner 的加速特性;

2)

DSL-JSON

:世界上最快的 Java JSON 實現;

3)

Jsoniter

:抄襲 DSL-JSON 寫的實現;

4)

Fastjson

:在中國很流行的 JSON 解析器;

5)

Protobuf

:在 RPC (遠端方法呼叫)裡非常流行的二進位制編解碼格式;

6)

Thrift

:另外一個很流行的 RPC 編解碼格式。這裡 benchmark 的是 TCompactProtocol。

5、整數解碼效能測試(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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

從結果上看,似乎優勢非常明顯。但是因為只有 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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

DSL-JSON 比 Jackson 快很多,它的最佳化程式碼在這裡:

private static int parsePositiveInt(final byte[] buf, final JsonReader reader, final int start, final int end, int i) throwsIOException {

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

6、整數編碼效能測試(Encode Integer)

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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

這是因為 DSL-JSON 使用了自己的最佳化方式,和 JDK 的官方實現不一樣(程式碼點此檢視):

private static int serialize(final byte[] buf, int pos, final int value) {

int i;

if(value < 0) {

if(value == Integer。MIN_VALUE) {

for(intx = 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 更快。因為查詢表預先計算好了,節省了執行時的計算成本。

7、雙精度浮點數解碼效能測試(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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

DSL-Json 中對 Double 也是做了特別最佳化的(詳見原始碼):

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。

8、雙精度浮點數編碼效能測試(Encode Double)

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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

解碼 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”。

9、物件解碼效能測試(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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

在有 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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

把欄位數量加到了 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 檢查。

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

關於物件繫結來說,只要欄位名不長,基於數字的 tag 分發並不會比 JSON 具有明顯優勢,即便是相比最慢的 Jackson 來說也是如此。

10、物件編碼效能測試(Encode Object)

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

只有 1 個欄位:

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

有 5 個欄位:

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

有 10 個欄位:

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

物件編碼方面,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 要慢。

11、整形列表解碼效能測試(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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

對於整數列表的解碼,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); returnnull; }

if(!com。jsoniter。CodegenAccess。readArrayStart(iter)) {

returncol == null? newjava。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? newjava。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? newjava。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? newjava。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? newjava。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;

}

對於成員比較少的情況,這樣搞可以避免陣列的擴容帶來的記憶體複製。

12、整形列表編碼效能測試(Encode Integer List)

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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

Protobuf 在編碼整數列表的時候,僅僅是 Jackson 的 1。35 倍。

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

13、物件列表解碼效能測試(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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

Protobuf 處理物件列表是 Jackson 的 1。3 倍。但是不及 DSL-JSON。

14、物件列表編碼效能測試(Encode Object List)

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

15、雙精度浮點數陣列解碼效能測試(Decode Double Array)

Java 的陣列有點特殊,double[] 是比 List 更高效的。使用 double 陣列來代表時間點上的值或者座標是非常常見的做法。

然而,Protobuf 的 Java 庫沒有提供double[] 的支援,repeated 總是使用 List。我們可以預期 JSON 庫在這裡有一定的優勢。

message PbTestObject {

repeated doublefield1 = 1[packed=true];

}

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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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;

inti = 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 = newdouble[i ];

System。arraycopy(arr, 0, result, 0, i);

return result;

}

這避免了陣列擴容的開銷。

16、雙精度浮點數陣列編碼效能測試(Encode Double Array)

再來看看 double 陣列的編碼:

Protobuf 可以飛快地對 double 陣列進行編碼,是 Jackson 的 15 倍。在犧牲精度的情況下,Protobuf 只是Jsoniter 的 2。3 倍。

所以,再次證明了,JSON 處理 double 非常慢。如果用 base64 編碼 double,則可以保持精度,速度和犧牲精度時一樣。

17、字串解碼效能測試(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

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

先嚐試捷徑:

DSL-JSON 給 ascii 實現了一個捷徑(原始碼點此):

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(inti = 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[] 的。

18、字串編碼效能測試(Encode String)

類似的問題,因為需要把 char[] 轉換為 byte[],所以沒法直接記憶體複製。

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

19、本文總結

最後,我們把所有的戰果彙總到一起。

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

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

1)

跳過非常長的字串:和字串長度線性相關;

2)

解碼 double 欄位:Protobuf 優勢明顯,是 Jsoniter的 3。27 倍,是 Jackson 的 13。75 倍;

3)

編碼 double 欄位:如果不能接受只保留 6 位小數,Protobuf 是 Jackson 的 12。71 倍(如果接受精度損失,Protobuf 是 Jsoniter 的 1。96 倍);

4)

解碼整數:Protobuf 是 Jsoniter 的 2。64 倍,是 Jackson 的 8。51 倍。

IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測

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

20、參考資料

[1]

Protobuf官方編碼資料

[2]

Protobuf官方手冊

[3]

Why do we use Base64?

[4]

The Base16, Base32, and Base64 Data Encodings

[5]

Protobuf從入門到精通,一篇就夠!

[5]

如何選擇即時通訊應用的資料傳輸格式

[7]

強列建議將Protobuf作為你的即時通訊應用資料傳輸格式

[8]

APP與後臺通訊資料格式的演進:從文字協議到二進位制協議

[9]

面試必考,史上最通俗大小端位元組序詳解

[10]

移動端IM開發需要面對的技術問題(含通訊協議選擇)

[11]

簡述移動端IM開發的那些坑:架構設計、通訊協議和客戶端

[12]

理論聯絡實際:一套典型的IM通訊協議設計詳解

[13]

58到家實時訊息系統的協議設計等技術實踐分享

本文已同步釋出於:

http://www。52im。net/thread-4095-1-1。html

Tags:ProtobufjavacomDoubleJSON