本文由陶文分享,InfoQ編輯發佈,有修訂和改動。
1、前言
本系列的前幾篇主要是從各個角度講解Protobuf的基本概念、技術原理這些內容,但回過頭來看,對比JSON這種事實上的數據協議工業標準,Protobuf到底性能到底高多少?本篇將以Protobuf為基準,對比市面上的一些主流的JSON解析庫,通過全方位測試來證明給你看看Protobuf到底比JSON快幾倍。
學習交流:
- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
-
開源IM框架源碼:https://github.com/JackJiang2...(備用地址點此)
(本文已同步發佈於:http://www.52im.net/thread-40...)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... 選擇的測試數據的結構是這樣的:
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-itera...
從結果上看,似乎優勢非常明顯。但是因為只有 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-itera...
這下優勢就非常明顯了。毫無疑問,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 在這個基礎上做了循環展開:... // abbreviatedint 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;}... // abbreviated6、整數編碼性能測試(Encode Integer)
編碼方面情況如何呢?和編碼一樣的測試數據,測試結果如下:
不知道為啥,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-itera...
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 編碼為文本格式就更困難了。
解碼 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; // 6long 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-itera...
如果只有一個字段,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-itera...
在有 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-itera...
把字段數量加到了 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 來説也是如此。10、對象編碼性能測試(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 要慢。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-itera...
對於整數列表的解碼,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 在編碼數組的時候應該有優勢,不用寫那麼多逗號出來嘛。
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-itera...
Protobuf 處理對象列表是 Jackson 的 1.3 倍。但是不及 DSL-JSON。14、對象列表編碼性能測試(Encode Object List)
Protobuf 處理對象列表的編碼速度是 Jackson 的 2 倍。但是 DSL-JSON 仍然比 Protobuf 更快。似乎 Protobuf 在處理列表的編碼解碼方面優勢不明顯。
15、雙精度浮點數數組解碼性能測試(Decode Double Array)
Java 的數組有點特殊,double[] 是比 List<Double> 更高效的。使用 double 數組來代表時間點上的值或者座標是非常常見的做法。然而,Protobuf 的 Java 庫沒有提供double[] 的支持,repeated 總是使用 List<Double>。我們可以預期 JSON 庫在這裏有一定的優勢。message PbTestObject { repeated doublefield1 = 1[packed=true];}https://github.com/json-itera...
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-itera...
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[],所以沒法直接內存拷貝。
Protobuf 在編碼長字符串時,比 Jackson 略微快一點點,一切都歸咎於 char[]。
19、本文總結
最後,我們把所有的戰果彙總到一起。
編解碼數字的時候,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 倍。
如果你的生產環境中的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-40...)