-
二進制補碼
先說說二進制補碼。對於有符號型整數的二進制表示而言:
1. 最高位是符號位,0代表正數,1代表負數;
2. 正數和0的反碼和補碼都是本身;
3. 對於負數,反碼就是符號位不變,其他各位依次取反;補碼就是在反碼的基礎上加1;
以int32舉例:
數值 | 源碼 | 反碼 | 補碼 |
15 | 0...0,0000,1111 共32bit | 0...0,0000,1111 共32bit | 0...0,0000,1111 共32bit |
-11 | 10...0,0000 ,1011 共32bit | 11...1,1111,0100 共32bit | 11...1,1111,0101 共32bit |
在計算機中,正數存放的本身的二進制表示,而負數存放的是它的補碼。爲什麼要存放補碼?源碼不是更好嗎?我們來分析一下。
在計算機中,加法是最常見的運算形式。一個簡單的減法運算可以轉換爲加法運算,比如15-11 = 15 + (-11)。
先看減法15-11的二進制運算: 0...0,0000,1111 - 0...0,0000 ,1011 = 0100 =十進制 4。
再看加法15 + (-11)的二進制運算: 0...0,0000,1111 + 11...1,1111,0101 = 1,0000,...,0000,0100,共33位。因爲數據類型爲int32,故高位發生了截斷,丟棄了最高位的1, 故結果爲0100 = 十進制 4。
這麼看來,負數用補碼來表示沒什麼問題。這裏理解的關鍵是符號位參與了運算,且如果超出存儲長度,高位截斷。試想一下,如果不高位截斷,則結果爲 1,0000,...,0000,0100,共33bit,高位截斷本質就是取模運算,也就是取餘。這裏的模是2^32。
舉例:
模爲12,4+8 =12,所以4和8互爲補數。
9 + 4 = (9 + 8)%12 = 5,這裏的取模就相當於高位截斷。
故有公式: a+負數b = (a + 負數b的補數),如超出長度,則高位截斷 = (a + 負數b的補數)%模。這就是補碼來代表負數的理論基礎。實際中,對於有符號數而言,符號位爲0,則該值爲正數二進制本身;符號位爲1,則這裏的值爲負數二進制的補碼。
-
移位操作
移位操作分爲有符號數的移位和無符號數的移位。
有符號的左移位<<:即算術左移位,是一種帶符號的左移位運算。是將運算數的二進制碼整體左移指定位數,左移之後的右側空位用0補充;
有符號的右移位>>:即算術右移位,是一種帶符號的右移位運算。是將運算數的二進制碼整體右移指定位數,右移之後的左側空位用運算數的符號位補充,如果是正數用0補充,負數用1補充。
無符號的左移位<<:即邏輯左移位,是將運算數的二進制碼整體左移指定位數,左移之後的右側空位用0補充;
無符號的右移位>>:即邏輯右移位,是將運算數的二進制碼整體右移指定位數,右移之後的左側空位用0補充;
注:
(1). 如果移動的位數超過了該類型的最大位數,那麼編譯器會對移動的位數取模。如對int型移動33位,實際上只移動了33%32=1位。
(2). 當移位的運算數是byte 和short類型時,將自動把這些類型擴大爲 int 型。
(3). 在java中,無符號右移運算符爲 >>>
它的格式:value >>> num,num 指定要移位值value 移動的位數。
無符號右移的規則只記住一點:忽略了符號位擴展(即右移之後的左側空位用運算數的符號位補充,如果是正數用0補充,負數用1補充。),0補最高位。
無符號右移運算符>>> 只是對32位和64位的值有意義
(4). 算術移位和邏輯移位雖然方式是一樣的,但他們表示的移位後數的範圍是不一樣的。以8位二進制舉例,有符號數移位(算術移位)後的範圍是-128——127;而無符號數移位(邏輯移位)後的範圍是0——255。不管是哪種移位,均要考慮移位後的範圍。
以8位二進制數舉例:
-12 >>2 | 11110100(-12的補碼) -> 11111101 (-3的補碼) |
-3 << 2 | 11111101 (-3的補碼) -> 11110100(-12的補碼) |
-
Varint算法分析
言歸正傳,下面進入今天的主題:varint算法分析。
對於存儲IO和網絡IO而言,數據越小,越能減少IO開銷和節省網絡帶寬。對於常規的數據存儲,固定類型的數據所佔的內存大小是確定的。比如:byte佔8bit,int32佔32bit,float佔32bit等等。也就是說,不論數值大小如何,內存都要開闢固定長度的內存區用來存放數據。絕大多數情況下這是浪費的。比如無符號型0~255,有符號型-128~127,本來8bit就可以搞定,而int32都分配了32bit的長度,這意味着前面的3字節長度並沒有利用,是浪費的。Varint存儲算法就是針對這種情況的改良。
對於Varint,每8bit的數據中,首位叫作most signficant bit,簡稱爲msb,它不是數據位,代表特殊的含義:
0:代表該字節就是數據的結尾;1:代表該字節的下一字節依然是數據的一部分。
後面的7bit用來表示數據域。
舉例說明。
1的二進制爲:0000 0001,用varint編碼爲:0000 0001,即0x01,顯然一個字節就表示出了大小。節省了3字節的空間。
666的二進制爲:1010011010,用varint編碼爲:10011010, 0000 0101。佔兩字節大小。這裏解釋一下,varint算法採用的是逆序存儲。即二進制數據從後向前開始,每7位爲一個單元,開頭加上msb,按從前向後排列。從後往前的第一個7bit爲0011010,一個字節顯然存不下666,所以msb爲1,所以第一個字節爲10011010;剩下的3bit爲101,不足7bit補0,加上msb爲1,故第二字節爲0000 0101。
需要說明的是,這裏的數據域存放的都是原數據的補碼。文章的開頭提過,0和正數的補碼爲本身;負數的補碼爲符號位不變,其他位分別取反,最後的結果加1。
再來看下解碼過程。
對於1001 1010, 0000 0101。首先讀取首字節10011010,再讀取msb,這裏爲1,可知接下來的1字節依然需要讀取;剩餘的數據域爲0011010;再讀取接下來的1字節:0000 0101, msb爲0,故該字節即爲最後一字節。數據域爲000 0101;逆序還原最終數據,得到:0000 10 1001 1010,轉換爲十進制得到666。
總結一下,Varints 編碼規則主要有三點:
- 在每個字節開頭的 1bit 設置了 msb(most significant bit ),標識是否需要繼續讀取下一個字節;剩餘的7bit爲數據域;
- 數據域存儲數字對應的二進制補碼;
- 數據域按逆序排列,低位排在前面;
到目前爲止,我們看到的都是Varint編碼的優點:用盡可能少的字節表示數據。顯然對於一般的小數值而言這是巨大的優勢。下面我們分析一下這種編碼的缺點。
(1) 不適合存儲大數值
一方面,對於int32而言,有32個bit表示數據域。Varint每字節都有一個msb,即意味着32bit中只有28bit來表示數據。對於小於2^28的數據,Varint最多需要4字節來編碼。但對於2^28~ (2^32-1)範圍內的數據,Varint編碼4字節顯然存不下,還需要額外的1字節,共5字節。所以,對於2^28~ (2^32-1)範圍的數據,Varint編碼性能是降低的,開銷還大。但絕大多數情況下,我們用到的值大概率的都是小於2^28的,所以Varint編碼還是很有優勢的。
(2) 不適合存儲負數
另一方面,對於負數。我們知道存儲的是補碼。-1的補碼爲1111...,1111,共32bit。Varint要存放-1需要多少字節呢?乍一看一共32個1,所以需要5字節。
而在protocol buffer中,Varint編碼需要10字節來存放負數。這是因爲爲了兼容,將 int32 擴展成 int64 的八個字節。所以-1的補碼爲1111,..., 1111,共64bit。加上10個msb,一共10字節。(64bit的數據域爲:9個7bit + 1bit,故共10個msb。 )
顯然Varint編碼對於存放int32、int64的負數是低效的,開銷更浪費。爲解決這個問題, ZigZag 編碼被推出以解決負數編碼效率低的問題。ZigZag 的原理和概念簡單易懂,用話概括介紹 ZigZag 編碼:有符號整數映射到無符號整數,然後再使用 Varints 編碼。既然Varint編碼負數效率低下,那麼將負數"轉換、映射成"正數,針對正數進行Varint編碼並傳輸,對方收到數據後,再進行"轉換、映射"解碼成對應的負數即可。
如上圖所示,0的ZigZag編碼爲0;-1的ZigZag編碼爲1;1的ZigZag編碼爲2;-2的ZigZag編碼爲3 ...。比如int32 a = -1,經過ZigZag編碼得到1,對1進行Varint編碼得到0000 0001,並存儲傳輸。接收方收到數據0000 0001後進行Varint解碼並得到1,將通過ZigZag解碼得到-1。
需要注意的是:這裏的“編碼、映射”並非簡單存儲映射表,是以移位操作實現的。另外可知,在不溢出的前提下,ZigZag編碼正數得到的是原數的兩倍。
下面我們看下ZigZag的代碼實現:
//int to uint的轉換:
private static uint Zig(int value){
return (uint)((value << 1) ^ (value >> 31));
}
//uint to int的轉換:
private static int Zag(uint ziggedValue){
int value = (int)ziggedValue;
return (-(value & 0x01)) ^ ((value >> 1) & ~( 1<< 31));
}
前面提到Varint編碼int32的正數最多需要5字節。下面是對一個uint進行Varint編碼:
private static byte[] WriteUint32ToVarint(uint value){
byte[] data = new byte[5];
int count = 0;
do{
data[count] = (byte)((value & 0x7F) | 0x80);
count++;
} while ((value >>= 7) != 0);
data[count - 1] &= 0x7F;
return data;
}
分析下代碼:
data[count] = (byte)((value & 0x7F) | 0x80); // value & 0x7F得到頭7位的數值; | 0x80是表明後面的byte也是數字的一部分。
while ((value >>= 7) != 0) 右移7位如果不爲零,則繼續如上操作。
data[count - 1] &= 0x7F 因爲最後一個字節就是編碼的結束,故把最高位設置成0。
接下來就是一個uint的解碼過程 :
private static uint ReadUInt32FromVarint(byte[] data){
uint value = data[0];
if ((value & 0x80) == 0) return value;
value &= 0x7F;
uint chunk = data[1];
value |= (chunk & 0x7F) << 7;
if ((chunk & 0x80) == 0) return value;
chunk = data[2];
value |= (chunk & 0x7F) << 14;
if ((chunk & 0x80) == 0) return value;
chunk = data[3];
value |= (chunk & 0x7F) << 21;
if ((chunk & 0x80) == 0) return value;
chunk = data[4];
value |= chunk << 28;
if ((chunk & 0xF0) == 0) return value;
throw new OverflowException("ReadUInt32Variant Error!");
}
(value & 0x80) == 0 表示最高位msb爲0,說明後面數據已經不是該數據部分了,故停止解碼並返回。
(chunk & 0xF0) == 0 因data[4]只有低4位,故chunk只有低4位,如果不是則表明這個byte不是數值存儲的一部分。
測試一下看下編碼效果
byte[] data = WriteUint32ToVarint(Zig(0));
System.out.println(data.length);
data = WriteUint32ToVarint(Zig(666));
System.out.println(data.length);
data = WriteUint32ToVarint(Zig(10000));
System.out.println(data.length);
data = WriteUint32ToVarint(Zig(-100000));
System.out.println(data.length);
分別是1,2,3,3。分析一下:
0的ZigZag編碼還是0,對應的二進制爲0000 0001,故對應Varint編碼還是0000 0001,1字節;
666的ZigZag編碼是1332,對應的二進制爲10100110100,故對應Varint編碼還是1#0110100,0#000 1010,2字節;
10000的ZigZag編碼是20000,對應的二進制爲10011100 0100000,故對應Varint編碼還是1#0100000,1#0011100 0#000 0001,3字節;
-100000的ZigZag編碼對應的二進制爲1100 0011010 0111111,故對應Varint編碼還是1#0111111,1#0011010 0#000 1100,3字節;