簡介
protostuff是一個java序列化庫,支持向前和向后兼容。
protostuff的序列化編碼算法和Protobuffer基本一致,都是基於varint編碼的變長序列化方式,跟定長序列化相比,在絕大多數情況下,varint編碼能夠使得編碼后的字節數組更小。
下面詳解一下protosutff的序列化編碼方案以及序列化過程中的內存分配模型。
序列化過程中的數據存儲- LinkedBuffer
LinkedBuffer是用於存儲序列化過程中字節數組的數據結構。由於在序列化之前,並不知道需要分配多大的連續內存空間,因此Protostuff設計了LinkedBuffer這種數據結構,通過將不連續內存組成一個鏈表的形式,使得能夠在執行序列化過程中,動態的擴張。
public final class LinkedBuffer
{
final byte[] buffer;
final int start;
int offset;
LinkedBuffer next;
}
簡單說明一下這些成員變量的含義
byte[] buffer:用來存儲序列化過程中生成的byte,默認是512大小
int start:buffer從start開始寫
int offset:buffer已經寫到了offset處
LinkedBuffer next:下一個LinkedBuffer
如上圖,就是把多塊連續內存,通過鏈表的形式鏈接在一起,每個LinkedBuffer的buffer字段保存了部分序列化的內容。當一個LinkedBuffer的buffer寫滿之后,就會創建一個新的LinkedBuffer,並鏈到末尾。Protostuff會在一個序列化過程中(稱之為一個WriteSession),維護一個全局size,表示最終的byte[]的大小。填充完所有LinkedBuffer之后,還需要將這多個buffer寫入到一塊連續內存當中,即合並為一個byte[]。這時會根據size值分配一個byte[size],然后遍歷LinkedBuffer,直到把所有的buffer內容copy到byte[size]中,序列化過程結束。
LinkedBuffer到最終的byte[]代碼如下:
public final byte[] toByteArray()
{
LinkedBuffer node = head;
int offset = 0, len;
final byte[] buf = new byte[size];
do
{
if ((len = node.offset - node.start) > 0)
{
System.arraycopy(node.buffer, node.start, buf, offset, len);
offset += len;
}
} while ((node = node.next) != null);
return buf;
}
由此可以看出,這里需要經過一次內存拷貝。LinkedBuffer中的buffer回收也會導致gc。
編碼方式
Protostuff采用了T-L-V的存儲格式存儲數據,其中的T代表tag,即是key,L是length,代表當前存儲的類型的數據長度,當是數值類型(i32,i63等)的時候L被忽略,V代表value,即存入的值,Protostuff會將每一個key根據不同的類型對應的序列化算法進行序列化,然后按照key-length-value key-length-value的格式存儲,其中key的type類型與對應的壓縮算法關系如下:
write_type |
編碼方式 |
type |
存儲方式 |
---|---|---|---|
0 |
Varint(負數使用Zigzag輔助) |
int32、int64、uint32、uint64、sint32、sint64、bool、enum |
T-V |
1 |
64-bit |
fixed、sfixed64、double |
T-V |
2 |
Length-delimi |
string、bytes、embedded、messages、packed repeated fields |
T-L-V |
3 |
Start group |
Groups |
寫在集合內容之前 |
4 |
End group |
Groups |
寫在集合內容之后 |
5 |
32-bit |
fixed32、sfixed32、float |
T-V |
Protostuff對於key(即tag)的計算,是通過(fieldNumber << 3) | wireType,里面的fieldNumber代表該字段的編號,代碼為:
public static int makeTag(final int fieldNumber, final int wireType){
return (fieldNumber << TAG_TYPE_BITS) | wireType; //TAG_TYPE_BITS = 3;
}
例如
@Tag(1) private long id;
這個tag計算到的結果就是 (1<<3)| 0 = 8
得到tag值之后,會寫入到LinkedBuffer的buffer當中,通過當前LinkedBuffer的offset值確實寫入的位置。
寫入tag的代碼如下
WriteSink.class
public LinkedBuffer writeVarInt32(int value,
final WriteSession session, LinkedBuffer lb) throws IOException
{
while (true)
{
session.size++; //session可以理解為對整個序列化長度的記錄
if (lb.offset == lb.buffer.length)
{
// grow //這里表示當前的linkedBuffer的byte[]已經填充滿了,需要分配下一個linkedBuffer了
lb = new LinkedBuffer(session.nextBufferSize, lb);
}
if ((value & ~0x7F) == 0) //value是正數,並且小於127(127二進制為01111111),則保存value為一個byte
{
lb.buffer[lb.offset++] = (byte) value;
return lb;
}
//保存value的最低7位,並且第8位置為1,然后value右移7位,進行下一次循環
lb.buffer[lb.offset++] = (byte) ((value & 0x7F) | 0x80);
value >>>= 7;
}
}
寫入一個int32類型的值value時,會判斷如果 0 < value < 127,則可以用一個byte表示,低7位是value的值,第8位是0。否則,先保存最低7位,第8位為1,然后右移7位,繼續重復這個過程。對於后者,為什么第8位要置為1呢?原因是Protostuff(Protobuffer)中規定:如果一個byte的最高位為1,則表示下一個byte跟當前byte有關,為0表示無關。從上述分析可以看出Protostuff的優勢,java中本來用4個byte表示int,但是如果這個int的值比較小,在Protostuff序列化中,就不需要4個byte了。
舉兩個例子:
例子1:以上述tag計算后=8為例:二進制為00001000,直接寫入byte即可,值為8。
例子2:value = 532。二進制為1000010100,第一個byte寫入最低7位,第8位是1,即10010100,即-108,然后右移7位,寫入第二個byte,最高位是0,即00000100,即4。
writeVarInt64和writeVarInt32是一樣的實現邏輯。這就使得在java中本來用4個byte表示的int,用8byte表示的long,在Protostuff序列化中,會用到更少的byte。當然,如果這個int或者long的值比較大的情況下,會導致int分配5個byte,long分配9個或10個byte的情況,但是一般情況下,Protostuff還是能節省空間的。
對於負數的處理:
查看Protostuff源碼,發現並沒有對於負數進行特殊處理(Protobuf使用zigzag編碼,把符號數轉化為無符號,再使用varint編碼,但在Protostuff中,並未發現這種處理方式)。由於計算機中對負數都是表示為很大的整數,因此Protostuff存儲負數時就要花更多的存儲空間。這里如果錯誤或遺漏歡迎指出。
對於String類型的序列化
在Protostuff中,string類型會存儲為T-L-V的形式,Tag會采用varient編碼。Length也是用varient編碼,表示后面多少個byte是這個String的Value。Value是采用UTF-8編碼,將string轉化為byte[]。Length和Value的寫入比較復雜。簡單描述一下:
先通過str.length()預估Length的長度,為什么說是預估呢,因為有些char會占用多個byte(比如漢字),然后根據str.length()的大小在LinkedBuffer的byte[]中先預留n個byte,Value寫完之后,再回過來寫Length。
n的取值規則:
if (len < 43) n = 1; //即使所有的都是非ascii編碼,最大也只需要1個byte
if(len < 5462) n = 2; //即使所有的都是非ascii編碼,最大也只需要2個byte
if(len < 699051) n = 3; //即使所有的都是非ascii編碼,最大也只需要3個byte
if(len < 89478486) n = 4; //即使所有的都是非ascii編碼,最大也只需要4個byte
n = 5;
具體代碼為
public static LinkedBuffer writeUTF8VarDelimited(final CharSequence str, final WriteSession session,
LinkedBuffer lb)
{
final int len = str.length();
if (len == 0)
{
if (lb.offset == lb.buffer.length)
{
// buffer full
lb = new LinkedBuffer(session.nextBufferSize, lb);
}
// write zero
lb.buffer[lb.offset++] = 0x00;
// update size
session.size++;
return lb;
}
if (len < ONE_BYTE_EXCLUSIVE)
{
// the varint will be max 1-byte. (even if all chars are non-ascii)
return writeUTF8OneByteDelimited(str, 0, len, session, lb);
}
if (len < TWO_BYTE_EXCLUSIVE)
{
// the varint will be max 2-bytes and could be 1-byte. (even if all non-ascii)
return writeUTF8VarDelimited(str, 0, len, TWO_BYTE_LOWER_LIMIT, 2,
session, lb);
}
if (len < THREE_BYTE_EXCLUSIVE)
{
// the varint will be max 3-bytes and could be 2-bytes. (even if all non-ascii)
return writeUTF8VarDelimited(str, 0, len, THREE_BYTE_LOWER_LIMIT, 3,
session, lb);
}
if (len < FOUR_BYTE_EXCLUSIVE)
{
// the varint will be max 4-bytes and could be 3-bytes. (even if all non-ascii)
return writeUTF8VarDelimited(str, 0, len, FOUR_BYTE_LOWER_LIMIT, 4,
session, lb);
}
// the varint will be max 5-bytes and could be 4-bytes. (even if all non-ascii)
return writeUTF8VarDelimited(str, 0, len, FIVE_BYTE_LOWER_LIMIT, 5, session, lb);
}
所以,寫入String的流程是:
1.寫tag,varient編碼
2.預估Length,預留n個byte
3.將string內容通過UTF-8編碼,寫入LinkedBuffer的byte[]中
4.根據3寫入的實際的長度,得到Length,經過varient編碼,寫入預留的n個byte中。
這里就引出了一個問題,假設str.length = 50,預留的n = 2,string經過UTF-8編碼后得到的byte[].length = 50,因為50 < 128,通過varient編碼后,只需要1個byte。即預留了2個byte,但是實際Length只需要一個byte,因此需要將上述步驟3中的內容整體前移1位,這里就導致了一次System.arraycopy行為,n為其他值時情況類似,可以看到String越長,一旦出現整體前移,會產生一些性能損耗。
對於Map<K,V>的序列化
集合類型的序列化,以Map<K, V>為例。寫tag的時候,會寫兩次,分別是在寫內容之前和寫內容之后。tag值的計算,同樣是通過(fieldNumber << 3) | wireType得到,寫內容之前,wireType為3,表示Start group,寫內容之后,wireType為4,表示End group。我們簡單說一下完整的流程。先通過(fieldNumber << 3) | 3計算得到tag值,寫入。然后開始遍歷Map<K, V>的Entry<K, V>,由於Entry<K, V>也被認為是一種group,因此寫K,V之前又要先寫入tag(wireType為3),然后分別按照K和V的類型寫入K,V,之后寫入tag(wireType為4)。所有的Entry<K, V>遍歷完成后,寫入Map的結束tag(wireType為4),代碼如下:
寫入Map的代碼
public <T> void writeObject(final int fieldNumber, final T value, final Schema<T> schema,
final boolean repeated) throws IOException
{
tail = sink.writeVarInt32(
makeTag(fieldNumber, WIRETYPE_START_GROUP),
this,
tail);
schema.writeTo(this, value);
tail = sink.writeVarInt32(
makeTag(fieldNumber, WIRETYPE_END_GROUP),
this,
tail);
}
上面代碼的schema.writeTo(this, value)的實現
MapSchema
public final void writeTo(Output output, Map<K, V> map) throws IOException
{
for (Entry<K, V> entry : map.entrySet())
{
// allow null keys and values.
output.writeObject(1, entry, entrySchema, true); //遞歸調用writeObject方法
}
}
分別寫K和V
public void writeTo(Output output, Entry<K, V> message) throws IOException
{
if (message.getKey() != null)
writeKeyTo(output, 1, message.getKey(), false);
if (message.getValue() != null)
writeValueTo(output, 2, message.getValue(), false);
}
對於自定義類型的序列化
自定義類型的序列化就不再贅述了,其實就是遞歸寫入。
參考: