二進位制小總結

伯樂鍾情發表於2021-01-11

真值與機器值

  真值很好理解,就是十進位制的數字前面再加上正負號,這是人類可以簡單識別的數字,比如 0、±16、±1084、±10.34、±100.453 等,而正數前面的+符號可以省略。機器值從字面理解就是機器(計算機)識別的值,實際上也確實是這個意思。

  計算機中通過高低電平表示1或者0,這樣就可以表示一個二進位制的數值。一個1或者0表示的數值位稱為一個bit,而計算機中儲存和傳輸資料的最小單位是一個位元組(byte)也就是8個bit,所以說計算機所有計算本質上都是基於二進位制。

  在計算機中,我們可以使用1個或者多個位元組儲存一個數,但無論是多少個位元組,其大小肯定是固定的,同時其所能表示的數值的範圍也是固定的。比如說對使用1個位元組儲存的數進行計算或者傳輸,那麼這個數所能表示的最小值為00000000最大值為11111111,轉換為十進位制為0 ~ 255。那麼無論對這個數做了什麼計算,無論計算之後的結果為多少都不能超出這個範圍,同理使用2個位元組儲存的數範圍為0 ~ 65535。

  由於很多時候一個資料需要使用2個或者2個以上的位元組表示,那麼這種資料無論是儲存還是傳輸的時候都會有一個順序的問題,也就是大小端對齊(位元組序)問題。在儲存時高位位元組在前為大端對齊,反之為小端對齊。在資料傳輸時先傳輸高位位元組為大端位元組序,反之為小端位元組序。目前絕大多數平臺內部都是小端對齊的方式儲存資料,而大多數通訊協議卻都是用大端位元組序傳輸資料,所以這一點值得注意一下。

符號位與數值位

  計算機中使用二進位制儲存傳輸和計算數值,但是不能只有數值,計算的時候還得有正負之分。在計算機中使用最高bit位的數值來表示正負號,這個bit位稱作符號位。

  計算機中符號位的值為0表示這個數為正數,符號位值為1表示這個樹為負數。由於符號位表示符號所以其不表示具體的值,除開符號位剩餘的bit位用來表示數值也就是數值位。比如1個位元組的整數00000001,其中最高bit(最左邊)位的0為符號位,表示這個數為正數,數值位為1,所以其真值為1。同理2個位元組的整數00000000_0000001,其真值也是1。

原碼、反碼和補碼

  計算機只識別機器碼,其實也就是二進位制數,並且使用最高bit位表示符號位。那麼兩個真值為8和-8的8位整數,它們在計算機內部的機器值是否就分別是00001000和10001000?其實並不是,這只是8和-8的原碼,而機器算計中的機器值是使用補碼儲存和計算的。

  計算機中,正數的原碼、反碼和補碼是一樣的,所以上面那個例子中,真值為8的8位整數的機器值確實是00001000,但是-8就不是這麼回事了。負數的首先將原碼數值位按位取反得到反碼,然後再將反碼數值位加1之後則得到補碼。我們來看一下-8這個例子,其原碼為10001000,數值位按位取反之後的反碼為11110111,然後數值位加1之後的補碼為11111000。所以真值為-8的8位整數在計算機中的機器值為11111000,我們來看下面這張表

原碼 反碼 補碼
int8 00000001 00000001 00000001
int8 10000001 11111110 11111111
int16 00000000_00000001 00000000_00000001 00000000_00000001
int16 10000000_00000001 11111111_11111110 11111111_11111111
... ... ... ...

注:int8為8bit位整數佔用1byte,int16為16bit位整數佔用2byte。

  剛說的是原碼轉補碼的步驟,其實補碼轉原碼的步驟是一樣的。首先正數的原碼補碼是一樣的不需要轉換,我們看負數11111000,首先將數值位按位取反得到10000111,然後再將數值位加1得到10001000。我們再來看一個8位的整數10000000,是不是發現這個數原碼和補碼是一樣的,那麼這個看起來像是“-0”的數是怎麼回事呢?其實可以將這個數看成是一個特殊值,它的真實含義就是最小值。8位的這種“-0”的真值為-128,16位的這種“-0”真值為-32768。所以只需要記住100...000這種補碼就是最小值就行,我們看下面的這張表

二進位制補碼 十進位制
int8 10000000 -128
int16 10000000_00000000 -32768
int32 10000000_00000000_00000000_00000000 -2147483648
int64 10000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000 -9223372036854775808

  有兩對8bit位的整數4、8和4、-8,我們分別看一下他們在計算機中是怎麼做加法計算的。首先看4和8的補碼分別為00000100和00001000,只需要將每個bit位相加就行,結果為00001100,其真值為12。我們再來4和-8的計算,它們補碼分別為00000100和11111000,然後將它們按位相加(注意符號位也要做加法)得到11111100,其原碼為10000100,真值為-4。

  再來看一下減法計算,比如8bit位的整數-8減去4,首先可以將4處理一下可以變為(-8) + (-4),這樣是不是就又變為了加法了?-8和-4的補碼分別為11111000和11111100,將它們按位相加得到補碼11110100(注意這是8位的整數,超出部分發生了溢位),轉換成原碼為10001100,真值為-12。

  再來看一下乘法,比如8bit位的整數-8乘以13,他們的補碼分別為11111000和00001101。其中-8為被乘數,13為乘數,並且乘數有8個bit位,需要將被乘數按位與和位計算8次然後將結果相加,看如下分析:

  • 1、被乘數的第0個bit位值為1,將被乘數乘以1然後左移0位得到:11111000;
  • 2、被乘數的第1個bit位值為0,將被乘數乘以0然後左移1位得到:00000000;
  • 3、被乘數的第2個bit位值為1,將被乘數乘以1然後左移2位得到;11100000;
  • 4、被乘數的第3個bit位值為1,將被乘數乘以1然後左移3位得到;11000000;
  • 5、被乘數的第4個bit位值為0,將被乘數乘以0然後左移4位得到;00000000;
  • 6、被乘數的第5個bit位值為0,將被乘數乘以0然後左移5位得到;00000000;
  • 7、被乘數的第6個bit位值為0,將被乘數乘以0然後左移6位得到;00000000;
  • 8、被乘數的第7個bit位值為0,將被乘數乘以0然後左移7位得到;00000000;

由此可以得計算得到8組補碼(注意上面做位移涉及到的整數溢位,只能是8個bit位),然後將它們做加法得到10011000(也存在整數溢位)轉換為原碼為11101000,真值為-104。

  至於除法則是使用交替加減法的方式,本文只是對計算原理做一下擴充套件,這裡不再繼續深入做介紹,如果有想了解的可以自行上網查詢。

  通過上面的分析可以知道,使用補碼可以將所有計算都轉化為加法計算,這樣可以讓計算機底層對於整數(浮點數再此不做講解,有時間會再單獨寫一篇文章作介紹)計算變得簡單,反碼屬於歷史遺留,因為其存在±0的問題。

Java中的基本資料型別

  在計算機程式語言中都會有資料型別的概念,資料型別是用來修飾變數的。不同資料型別所修飾的變數,其指代的資料在記憶體中佔用空間的大小(基本型別變數使用的空間、指標或引用變數指向的地址空間等,後面簡單稱資料型別佔用的記憶體空間)是固定的。即使在一些弱型別語言中,雖然變數可以不用顯示地宣告資料型別,但當第一次為變數賦值時,還是會隱式地為其附上資料型別屬性。

  對於java來說,由於其具有跨平臺的特性,所以基本資料型別所佔用的記憶體空間大小(位元組數)是固定的。我們來看一下java中的幾個基本資料型別:

byte char short int float long double boolean
位元組數 8bit / 1byte 16bit / 2byte 16bit / 2byte 32bit / 4byte 32bit / 4byte 64bit / 8byte 64bit / 8byte /
取值範圍 -27 ~ 27 - 1 0 ~ 216 - 1 -215 ~ 215 - 1 -231 ~ 231 - 1 - -263 ~ 263 - 1 - false | true
預設值 0 0 0 0 0.0 0 0.0 false
字尾 / / / / f | F l | L d | D /

注:jvm規範並沒有指明boolean型別佔用幾個位元組的空間,所以根據jvm產品的不同,實現的方式也可能不同。最廣泛的說法是,jvm內部使用int代替boolean型別,也就是佔用4個位元組。另外這裡沒有列出浮點數的大小範圍,由於本文只介紹整數,後面如果有時間則會單獨出一篇介紹浮點數的博文。

  我們來看下面幾個例子

// 案例1,下面的10進位制真值的寫法,但是當編譯器編譯完成之後,在記憶體中還是會以補碼的形式存在
int value1 = 10;
int value2 = -10;
System.out.printf("value1=%d, value2=%d\n", value1, value2); // 結果為: value1=10, value2=-10

// 案例2,下面是2進位制補碼的寫法,在數字前面加上'0b'或者'0B','_'只是一個分隔符
int value3 = 0b00000000_00000000_00000000_00001010; // 2進位制int型別10的補碼
int value4 = 0B11111111_11111111_11111111_11110110; // 2進位制int型別-10的補碼
System.out.printf("value3=%d, value4=%d\n", value3, value4); // 結果為: value3=10, value4=-10

// 案例3,下面是16進位制補碼的寫法,在數字前面加上'0x'或者'0X',大於9的數值使用a~f或A-F表示
int value5 = 0x0000000a; // 16進位制int型別10的補碼
int value6 = 0Xfffffff6; // 16進位制int型別-10的補碼
System.out.printf("value5=%d, value6=%d\n", value5, value6); // 結果為: value5=10, value6=-10

// 案例4,下面是8進位制補碼的寫法,在數字前面加上'0'
int value7 = 012; // 8進位制int型別10的補碼
int value8 = 037777777766; // 8進位制int型別-10的補碼
System.out.printf("value7=%d, value8=%d\n", value7, value8); // 結果為: value7=10, value8=-10

/*
 * 注: 這裡不論是10進位制還是16進位制等方式寫的整數,在計算機內部都是以二進位制補碼的形式體現,
 *     也就是上面案例2的中2進位制的形式體現。
 */

  額外說明一下,這些基本資料型別只會出現線上程棧中,或者再詳細一點,只會出現線上程棧的執行時棧幀(方法的工作空間)的區域性變數表和運算元棧中。也就是說,只有在區域性方法中才可以宣告基本資料型別的區域性變數。物件的成員變數或者類的靜態變數即使是基本資料型別,最終也會被自動裝箱為包裝型別,然後在堆中開闢空間。 但也有例外,在現在的高效能jvm中一般都會有jit(即時編譯)系統,在逃逸分析時如果物件被判斷為未逃逸(物件不是入參、不是返回值並且也沒有被方法外部變數引用),則會做標量替換(拆分物件為基本資料型別)然後線上程棧或者CPU暫存器中分配空間,方法執行完成之後隨著執行時棧幀出棧而被回收,可減少GC的工作負載。

Java中的整數型別轉換

  整數的基本型別之間可以互相轉換,甚至char型別都可以轉換為byte、short、int、long等型別,反之亦然。但是不同的資料型別,其所佔用的記憶體空間大小是不一樣的,那麼這裡就涉及到補全和溢位的問題了。

  整數型別按所佔記憶體空間從小到大排序分別為byte、short、int、long,由佔用空間小的轉型為佔用空間大的為向上轉型,反之為向下轉型。java中型別轉換運算子為括號,括號中為轉換的目標型別,例如long value = (long) Integer.MAX_VALUE;。向上型別轉換可以隱式地完成,也就是說不需要顯示地編寫出型別轉換運算子例如long value = Integer.MAX_VALUE;。但是向下型別轉換時,可能會發生整數溢位(捨棄高位元組位),所以必須顯式的寫出型別轉換運算子,例如short value = (short) Integer.MAX_VALUE;

  如果是向上轉型時,使用符號位的值填充高位元組位。向下轉型時,直接舍掉高位元組位。我們看下面兩張表,從上往下看為向下轉型,從下往上看則為向上轉型:

型別 二進位制正數補碼 十進位制正數
long 00000000_00000000_00000000_00000000_00000000_00000000_00000000_01111111 127
int 00000000_00000000_00000000_01111111 127
short 00000000_01111111 127
byte 01111111 127
  
型別 二進位制負數補碼 十進位制負數
long 11111111_11111111_11111111_11111111_11111111_11111111_11111111_10000000 -128
int 11111111_11111111_11111111_10000000 -128
short 11111111_10000000 -128
byte 10000000 -128

  可以看到,不論轉換成什麼型別,最終的值還是不變的。我們看下面的案例:

// 案例1
byte value = (byte) Short.MAX_VALUE; // Short.MAX_VALUE為short型別的最大值: 32767
// 案例2
short value = Byte.MIN_VALUE; // Byte.MIN_VALUE為byte型別的最小值: -128

  案例1為一個向下型別轉換,由於32767這個值超過了byte能表示的最大值,所以其必然會發生整數溢位。short型別的32767的二進位制補碼為01111111_11111111,向下轉型為byte舍掉高位位元組的二進位制補碼為11111111,其值變為了-1。

  案例2為一個向上型別轉換,byte型別的-128的二進位制補碼為10000000,將其轉換為short型別之後的二進位制補碼為11111111_10000000。我們知道雖然其表示的值沒有變還是-128,但是如果我們在向上型別轉換之後,還想讓原來的符號位表示數值,也就是得到byte型別-128這個值的無符號數,則可以做按位與計算int value = Byte.MIN_VALUE & 0xff;

Java中的字面量

  Java中可以可以定義兩種型別的整數字面量,分別為int和long。例如int value = 10;或者long value = 10L;,可以看到其中long型別的字面量需要加上'L'型別的字尾,當然也可以是'l'。

  Java中無法直接定義byte和short型別的字面量,但是如果這麼寫byte value = 127;編譯也沒錯,那麼這裡的127是不是就是byte型別的字面量呢?其實不是,這個127還是int型別的,只不過做了向下型別轉換而已。但是前面說向下型別轉換必須顯式的寫出型別轉換運算子,這裡沒有那麼是不是前面說錯了呢?其實也不是,int型別127的二進位制補碼為00000000_00000000_00000000_01111111,向下轉型為byte型別之後的二進位制補碼為01111111,那些被捨棄的0可以看做是填充位所以並沒有發生整數溢位。由於字面量是靜態不可變的值,編譯器在編譯的時候就知道其並不會發生整數溢位,所以就直接做了隱式型別轉換。但是我們來看字面量為128的int型別整數,其二進位制補碼為00000000_00000000_00000000_10000000,轉換為byte型別之後的補碼為10000000,原本屬於數值位的1變為了符號位,這裡發生了整數溢位,所以需要顯式的加上型別轉換運算子byte value = (byte) 128;

  我們來看下面這些關於字面量的隱式型別轉換的案例

// 案例1
byte value1 = 127;

// 案例2,-128在byte能夠表示的數值範圍內,編譯器直接做了隱式型別轉換。
byte value2 = -128; // 賦值給value1的二進位制補碼為: 10000000

// 案例3,Short.MAX_VALUE是一個常量,編譯器編譯的時候可以明確的知道其值為32767,所以編譯器會先計算出32767 - 32640的值然後賦值給value3變數
byte value3 = Short.MAX_VALUE - 32640; // 賦值給value3的二進位制補碼為: 01111111

// 案例4,下面由於變數value4被final修飾,其值在編譯期間是不可變的,編譯器也能明確知道其值不會發生整數溢位
final byte int value4 = 127;
byte value5 = value4;

// 案例5
static final int value6 = 32767;
void func() {
	short value7 = value6;
}

  我們看這一行程式碼long value = 2147483647,這是一個向上轉型的例子,將int型別的字面量2147483647轉換為long型別。但是如果字面量為2147483648就必須這麼寫long value = 2147483648L;,因為2147483648這個值已經超過了int型別的最大值,必須使用long型別的字面量表示。

記憶體對齊和有效位移

  不管是四則運算還是位運算,參與計算的整數有可能是不同的資料型別,也就意味著它們在記憶體中的佔用的位元組數不同,所以在計算的時候需要將轉換為相同的型別,也就是記憶體對齊。

  如果是byte、short、int等型別的資料做計算,預設會將byte和short了型別的資料先轉換為int型別的資料然後在做計算。如果參與計算的資料中有long型別的資料,則會將非long型別的資料先轉換為long型別,然後在做計算。

  位移計算的時候資料型別要麼是int型別要麼是long型別,也就是說計算的說要麼有32的bit位要麼有64個bit為,那麼我們做位移的時候是不是可以位移無限的bit位呢?當然不是,有效位移的位數為0到bit為數量(字寬)減去1,也就是說int的有效位移大小為0 ~ 31,long的有效位移數量為0 ~ 63。如果超出這個範圍的話則會與31或者63做按位與計算,比如int資料位移36位則實際位移為36 & 31 = 4

Varints 128位可變長整型

  通過前面的說明,大家應該對計算機中的整數有了清晰的理解。比如有一個int型別的整數,我們需要在網路傳輸傳輸它,那麼傳送方只需要傳輸4個位元組的資料就行,接收方也只需要接收4個位元組。比如我們傳輸一個值為10的int型別資料,大家可以知道其實只需要傳輸一個位元組就行,但是誰也不能保證下一個傳輸的資料使用一個位元組會不會溢位,所以這時候我們就需要設計一個可以變換位元組數量的編解碼方式,並且資料接收方也能夠知道自己需要接受幾個位元組,Varints就是為了解決這個問題的。

  Varints是按小端位元組序排列,也就是低位位元組在前高位位元組在後。每個位元組的第7個bit位不表示數值,只是用來標識是否為最後一個位元組,如果第7個bit位值為1則代表不是最後一個位元組,如果值為0則代表為最後一個位元組,如下表:

真值 補碼 varints
100 01100100 [01100100]
1000 00000011_11101000 [11101000, 00000111]
100000 00000000_00000001_10000110_10100000 [10100000, 10001101, 00000010]
... ... ...

  我們看上表中,紅色部分為標識是否最後一個位元組的bit位,那麼這樣是不是就可以表示一個無窮大的整數了,不過一般也沒必要太大,就拿java來說,最大也就long型別的8個位元組64個bit位大小。其實正真生產過程中應用所處理處理資料時,遇到小數值的概率要比遇到大數值的概率要大得多,所以varints可以在做網路傳輸或者資料儲存時可以省不少流量和空間,而且可以便於擴充套件。不說別的,直接上程式碼了:

public final class ByteUtil {

    private ByteUtil() {}
    
    public static byte[] enVarInt(int value) {
        int size = 0;
        byte[] temps = new byte[5];
        for (; (value & 0xffffff80) != 0; value >>>= 7, ++size) {
            temps[size] = (byte) (value & 0x7f | 0x80);
        }
        temps[size] = (byte) value;
        byte[] result = new byte[size + 1];
        System.arraycopy(temps, 0, result, 0, result.length);
        return result;
    }
    
    public static byte[] enVarInt(long value) {
        int size = 0;
        byte[] temps = new byte[10];
        for (; (value & 0xffffffffffffff80L) != 0; value >>>= 7, ++size) {
            temps[size] = (byte) (value & 0x7fL | 0x80L);
        }
        temps[size] = (byte) value;
        byte[] result = new byte[size + 1];
        System.arraycopy(temps, 0, result, 0, result.length);
        return result;
    }
    
    public static IntPair deVarInt(int offset, byte... buffer) {
        isLegalArg(offset >= 0 && offset < buffer.length, String.format("offset=%d, bufferLen=%d, Must meet: offset >= 0 && offset < %d", offset, buffer.length, buffer.length));
        int bitSize = 0, result = 0, length = 0;
        for (; offset < buffer.length && bitSize < Integer.SIZE; ++offset) {
            int value = buffer[offset] & 0xff;
            result |= (value & 0x7f) << bitSize;
            bitSize += 7;
            ++length;
            if (0 == (value & 0x80)) {
                break;
            }
        }
        return IntPair.of(length, result);
    }
    
    public static IntLPair deVarLong(int offset, byte... buffer) {
        isLegalArg(offset >= 0 && offset < buffer.length, String.format("offset=%d, bufferLen=%d, Must meet: offset >= 0 && offset < %d", offset, buffer.length, buffer.length));
        int bitSize = 0, length = 0;
        long result = 0;
        for (; offset < buffer.length && bitSize < Long.SIZE; ++offset) {
            int value = buffer[offset] & 0xff;
            result |= (value & 0x7fL) << bitSize;
            bitSize += 7;
            ++length;
            if (0 == (value & 0x80)) {
                break;
            }
        }
        return IntLPair.of(length, result);
    }

    private static void isLegalArg(boolean isLegalArg, String message) {
        if (!isLegalArg) {
            throw new IllegalArgumentException(message);
        }
    }

}

ZigZag

  前面我們講解了varints可以省流量和空間,因為一般遇到小數值的概率要比遇到大數值的概率要大得多,同時我們也說了計算機內部是隻是別補碼的,如果處理一個數值很小但是卻是負數的時候,單憑varints可就不能達到省流量和省空間的效果了。前面我們也講到符號位為整數的最高bit位,那麼即使一個負數的數值再小,整個整數看起來也不小,所以這時候就需要使用到ZigZag來吧符號位處理一下。

  ZigZag編碼時,正數不做任何處理。對於負數則將整體數值位按位取反再左移一位,然後將符號位放到第0個bit位上,這樣處理之後就會得到一個與原來不一樣的整數,然後再按varints進行編碼。直接上ZigZag的程式碼了:

public final class ByteUtil {

    private ByteUtil() {}

    public static int enZigZag(int value) {
        return (value << 1) ^ (value >> 31);
    }
    
    public static long enZigZag(long value) {
        return (value << 1) ^ (value >> 63);
    }
    
    public static int deZigZag(int value) {
        return (value >>> 1) ^ -(value & 1);
    }
    
    public static long deZigZag(long value) {
        return (value >>> 1) ^ -(value & 1);
    }

}

附加程式碼

import java.util.Objects;

public class IntLPair {
    
    private int left;
    
    private long right;
    
    public IntLPair() {
    }
    
    private IntLPair(int left, long right) {
        this.left = left;
        this.right = right;
    }
    
    public static IntLPair of(int left, long right) {
        return new IntLPair(left, right);
    }
    
    public int getInt() {
        return left;
    }
    
    public void setInt(int left) {
        this.left = left;
    }
    
    public long getLong() {
        return right;
    }
    
    public void setLong(long right) {
        this.right = right;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        IntLPair intLPair = (IntLPair) o;
        return left == intLPair.left &&
                right == intLPair.right;
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(left, right);
    }
    
    @Override
    public String toString() {
        return "IntLPair{" +
                "left=" + left +
                ", right=" + right +
                '}';
    }
    
}
import java.util.Objects;

public class IntLPair {
    
    private int left;
    
    private long right;
    
    public IntLPair() {
    }
    
    private IntLPair(int left, long right) {
        this.left = left;
        this.right = right;
    }
    
    public static IntLPair of(int left, long right) {
        return new IntLPair(left, right);
    }
    
    public int getInt() {
        return left;
    }
    
    public void setInt(int left) {
        this.left = left;
    }
    
    public long getLong() {
        return right;
    }
    
    public void setLong(long right) {
        this.right = right;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        IntLPair intLPair = (IntLPair) o;
        return left == intLPair.left &&
                right == intLPair.right;
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(left, right);
    }
    
    @Override
    public String toString() {
        return "IntLPair{" +
                "left=" + left +
                ", right=" + right +
                '}';
    }
    
}

相關文章