Protostuff序列化詳解


簡介

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);
        }

對於自定義類型的序列化

自定義類型的序列化就不再贅述了,其實就是遞歸寫入。

 

參考:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM