Java二進制和位運算,這一萬字準能餵飽你

基礎不牢,地動山搖。本文已被 https://www.yourbatman.cn 收錄,裏面一併有Spring技術棧、MyBatis、JVM、中間件等小而美的專欄供以免費學習。關注公衆號【BAT的烏托邦】逐個擊破,深入掌握,拒絕淺嘗輒止。

✍前言

你好,我是YourBatman。

本號正在連載Jackson深度解析系列,雖然目前還只講到了其流式API層面,但已接觸到其多個Feature特徵。更爲重要的是我在文章裏贊其設計精妙,處理優雅,因此就有小夥伴私信給我問這樣的話:

題外話:Jackson這個話題本就非常小衆,看着閱讀量我自己都快沒信心寫下去。但自己說過的話就是欠下的債,熬夜也得把承諾的付費內容給公開完了,畢竟還有那麼幾個人在白嫖不是😄。

話外音:以後悶頭做事,少吹牛逼┭┮﹏┭┮

雖然小衆,竟然還有想深入瞭解一波的小夥伴,確實讓我爲之振奮了那麼三秒。既然如此那就幹吧,本文就先行來認識認識Java中的位運算。位運算在Java中很少被使用,那麼爲何Jackson裏愛不釋手呢?一切就爲兩字:性能/高效。用計算機能直接看懂的語言跟它打交道,你說快不快,不用多想嘛。

✍正文

提及位運算,對絕大多數Java程序員來說,是一種既熟悉又陌生的感覺。熟悉是因爲你在學JavaSE時肯定學過,並且在看一些開源框架(特別是JDK源碼)時都能看到它的身影;陌生是因爲大概率我們不會去使用它。當然,不能“流行”起來是有原因的:不好理解,不符合人類的思維,閱讀性差…...

小貼士:一般來說,程序讓人看懂遠比被機器看懂來得更重要些

位運算它在low-level的語言裏使用得比較多,但是對於Java這種高級語言它就很少被提及了。雖然我們使用得很少但Java也是支持的,畢竟很多時候使用位運算纔是最佳實踐

位運算在日常開發中使用得較少,但是巧妙的使用位運算可以大量減少運行開銷,優化算法。一條語句可能對代碼沒什麼影響,但是在高重複,大數據量的情況下將會節省很多開銷。

二進制

在瞭解什麼是位運算之前,十分有必要先科普下二進制的概念。

二進制是計算技術中廣泛採用的一種數制。二進制數據是用0和1兩個數碼來表示的數。它的基數爲2,進位規則是逢二進一,借位規則是借一當二。因爲它只使用0、1兩個數字符號,非常簡單方便,易於用電子方式實現。

小貼士:半導體開代表1,關代表0,這也就是CPU計算的最底層原理😄

先看一個例子:

求 1011(二進制)+ 11(二進制) 的和?
結果爲:1110(二進制)

二進制理解起來非常非常的簡單,比10進制簡單多了。你可能還會思考二進制怎麼和十進制互轉呢?畢竟1110這個也看不到啊。有或者往深了繼續思考:如何轉爲八進制、十六進制、三十二進制......進制轉換並非本文所想講述的內容,請有興趣者自行度娘。

二進制與編碼

這個雖然和本文內容關聯繫並不是很大,但順帶撈一撈,畢竟編碼問題在開發中還是比較常見的。

計算機能識別的只有1和0,也就是二進制,1和0可以表達出全世界的所有文字和語言符號。那如何表達文字和符號呢?這就涉及到字符編碼了。字符編碼強行將每一個字符對應一個十進制數字(請注意字符和數字的區別,比如0字符對應的十進制數字是48),再將十進制數字轉換成計算機理解的二進制,而計算機讀到這些1和0之後就會顯示出對應的文字或符號。

  • 一般對英文字符而言,一個字節表示一個字符,但是對漢字而言,由於低位的編碼已經被使用(早期計算機並不支持中文,因此爲了擴展支持,唯一的辦法就是採用更多的字節數)只好向高位擴展
  • 字符集編碼的範圍utf-8>gbk>iso-8859-1(latin1)>ascll。ascll編碼是美國標準信息交換碼的英文縮寫,包含了常用的字符,如阿拉伯數字,英文字母和一些打印符號共255個(一般說成共128個字符問題也不大)

UTF-8:一套以 8 位爲一個編碼單位的可變長編碼,會將一個碼位(Unicode)編碼爲1到4個字節(英文1字節,大部分漢字3字節)。

Java中的二進制

在Java7版本以前,Java是不支持直接書寫除十進制以外的其它進制字面量。但這在Java7以及以後版本就允許了:

  • 二進制:前置0b/0B
  • 八進制:前置0
  • 十進制:默認的,無需前置
  • 十六進制:前置0x/0X
@Test
public void test1() {
    //二進制
    int i = 0B101;
    System.out.println(i); //5
    System.out.println(Integer.toBinaryString(i));
    //八進制
    i = 0101;
    System.out.println(i); //65
    System.out.println(Integer.toBinaryString(i));
    //十進制
    i = 101;
    System.out.println(i); //101
    System.out.println(Integer.toBinaryString(i));
    //十六進制
    i = 0x101;
    System.out.println(i); //257
    System.out.println(Integer.toBinaryString(i));
}

結果程序,輸出:

5
101
65
1000001
101
1100101
257
100000001

說明:System.out.println()會先自動轉爲10進制後再輸出的;toBinaryString()表示轉換爲二進制進行字符串進行輸出。

便捷的進制轉換API

JDK自1.0開始便提供了非常便捷的進制轉換的API,這在我們有需要時非常有用。

@Test
public void test2() {
    int i = 192;
    System.out.println("---------------------------------");
    System.out.println("十進制轉二進制:" + Integer.toBinaryString(i)); //11000000
    System.out.println("十進制轉八進制:" + Integer.toOctalString(i)); //300
    System.out.println("十進制轉十六進制:" + Integer.toHexString(i)); //c0
    System.out.println("---------------------------------");
    // 統一利用的爲Integer的valueOf()方法,parseInt方法也是ok的
    System.out.println("二進制轉十進制:" + Integer.valueOf("11000000", 2).toString()); //192
    System.out.println("八進制轉十進制:" + Integer.valueOf("300", 8).toString()); //192
    System.out.println("十六進制轉十進制:" + Integer.valueOf("c0", 16).toString()); //192
    System.out.println("---------------------------------");
}

運行程序,輸出:

---------------------------------
十進制轉二進制:11000000
十進制轉八進制:300
十進制轉十六進制:c0
---------------------------------
二進制轉十進制:192
八進制轉十進制:192
十六進制轉十進制:192
---------------------------------

如何證明Long是64位的?

我相信每個Javaer都知道Java中的Long類型佔8個字節(64位),那如何證明呢?

小貼士:這算是一道經典面試題,至少我提問過多次~

有個最簡單的方法:拿到Long類型的最大值,用2進製表示轉換成字符串看看長度就行了,代碼如下:

@Test
public void test3() {
    long l = 100L;
    //如果不是最大值 前面都是0  輸出的時候就不會有那麼長了(所以下面使用最大/最小值示例)
    System.out.println(Long.toBinaryString(l)); //1100100
    System.out.println(Long.toBinaryString(l).length()); //7

    System.out.println("---------------------------------------");

    l = Long.MAX_VALUE; // 2的63次方 - 1
    //正數長度爲63爲(首位爲符號位,0代表正數,省略了所以長度是63)
    //111111111111111111111111111111111111111111111111111111111111111
    System.out.println(Long.toBinaryString(l));
    System.out.println(Long.toBinaryString(l).length()); //63

    System.out.println("---------------------------------------");

    l = Long.MIN_VALUE; // -2的63次方
    //負數長度爲64位(首位爲符號位,1代表負數)
    //1000000000000000000000000000000000000000000000000000000000000000
    System.out.println(Long.toBinaryString(l));
    System.out.println(Long.toBinaryString(l).length()); //64
}

運行程序,輸出:

1100100
7
---------------------------------------
111111111111111111111111111111111111111111111111111111111111111
63
---------------------------------------
1000000000000000000000000000000000000000000000000000000000000000
64

說明:在計算機中,負數以其正值的補碼的形式表達。因此,用同樣的方法你可以自行證明Integer類型是32位的(佔4個字節)。

Java中的位運算

Java語言支持的位運算符還是非常多的,列出如下:

  • &:按位與
  • |:按位或
  • ~:按位非
  • ^:按位異或
  • <<:左位移運算符
  • >>:右位移運算符
  • >>>:無符號右移運算符

以 外,其餘均爲二元運算符,操作的數據只能是整型(長短均可)或者char字符型。針對這些運算類型,下面分別給出示例,一目瞭然。

既然是運算,依舊可以分爲簡單運算和複合運算兩大類進行歸類和講解。

小貼士:爲了便於理解,字面量例子我就都使用二進制表示了,使用十進制(任何進制)不影響運算結果

簡單運算

簡單運算,顧名思義,一次只用一個運算符。

&:按位與

操作規則:同爲1則1,否則爲0。僅當兩個操作數都爲1時,輸出結果才爲1,否則爲0。

說明:1、本示例(下同)中所有的字面值使用的都是十進制表示的,理解的時候請用二進制思維去理解;2、關於負數之間的位運算本文章統一不做講述

@Test
public void test() {
    int i = 0B100; // 十進制爲4
    int j = 0B101; // 十進制爲5

    // 二進制結果:100
    // 十進制結果:4
    System.out.println("二進制結果:" + Integer.toBinaryString(i & j));
    System.out.println("十進制結果:" + (i & j));
}

|:按位或

操作規則:同爲0則0,否則爲1。僅當兩個操作數都爲0時,輸出的結果才爲0。

@Test
public void test() {
    int i = 0B100; // 十進制爲4
    int j = 0B101; // 十進制爲5

    // 二進制結果:101
    // 十進制結果:5
    System.out.println("二進制結果:" + Integer.toBinaryString(i | j));
    System.out.println("十進制結果:" + (i | j));
}

~:按位非

操作規則:0爲1,1爲0。全部的0置爲1,1置爲0。

小貼士:請務必注意是全部的,別忽略了正數前面的那些0哦~

@Test
public void test() {
    int i = 0B100; // 十進制爲4

    // 二進制結果:11111111111111111111111111111011
    // 十進制結果:-5
    System.out.println("二進制結果:" + Integer.toBinaryString(~i));
    System.out.println("十進制結果:" + (~i));
}

^:按位異或

操作規則:相同爲0,不同爲1。操作數不同時(1遇上0,0遇上1)對應的輸出結果才爲1,否則爲0。

@Test
public void test() {
    int i = 0B100; // 十進制爲4
    int j = 0B101; // 十進制爲5

    // 二進制結果:1
    // 十進制結果:1
    System.out.println("二進制結果:" + Integer.toBinaryString(i ^ j));
    System.out.println("十進制結果:" + (i ^ j));
}

<<:按位左移

操作規則:把一個數的全部位數都向左移動若干位。

@Test
public void test() {
    int i = 0B100; // 十進制爲4

    // 二進制結果:100000
    // 十進制結果:32 = 4 * (2的3次方)
    System.out.println("二進制結果:" + Integer.toBinaryString(i << 2));
    System.out.println("十進制結果:" + (i << 3));
}

左移用得非常多,理解起來並不費勁。x左移N位,效果同十進制裏直接乘以2的N次方就行了,但是需要注意值溢出的情況,使用時稍加註意。

>>:按位右移

操作規則:把一個數的全部位數都向右移動若干位。

@Test
public void test() {
    int i = 0B100; // 十進制爲4

    // 二進制結果:10
    // 十進制結果:2
    System.out.println("二進制結果:" + Integer.toBinaryString(i >> 1));
    System.out.println("十進制結果:" + (i >> 1));
}

負數右移:

@Test
public void test() {
    int i = -0B100; // 十進制爲-4

    // 二進制結果:11111111111111111111111111111110
    // 十進制結果:-2
    System.out.println("二進制結果:" + Integer.toBinaryString(i >> 1));
    System.out.println("十進制結果:" + (i >> 1));
}

右移用得也比較多,也比較理解:操作其實就是把二進制數右邊的N位直接砍掉,然後正數右移高位補0,負數右移高位補1

>>>:無符號右移

注意:沒有無符號左移,並沒有<<<這個符號的

它和>>有符號右移的區別是:無論是正數還是負數,高位通通補0。所以說對於正數而言,沒有區別;那麼看看對於負數的表現:

@Test
public void test() {
    int i = -0B100; // 十進制爲-4

    // 二進制結果:11111111111111111111111111111110(>>的結果)
	// 二進制結果:1111111111111111111111111111110(>>>的結果)
    // 十進制結果:2147483646
    System.out.println("二進制結果:" + Integer.toBinaryString(i >>> 1));
    System.out.println("十進制結果:" + (i >>> 1));
}

我特意把>>的結果放上面了,方便你對比。因爲高位補的是0,所以就沒有顯示啦,但是你心裏應該清楚是怎麼回事。

複合運算

廣義上的複合運算指的是多個運算嵌套起來,通常這些運算都是同種類型的。這裏指的複合運算指的就是和=號一起來使用,類似於+= -=。本來這屬於基礎常識不用做單獨解釋,但誰讓A哥管生管養,管殺管埋呢😄。

混合運算:指同一個算式裏包含了bai多種運算符,如加減乘除乘方開du方等。

以&與運算爲例,其它類同:

@Test
public void test() {
    int i = 0B110; // 十進制爲6
    i &= 0B11; // 效果同:i = i & 3

	// 二進制結果:10
	// 十進制結果:2
    System.out.println("二進制結果:" + Integer.toBinaryString(i));
    System.out.println("十進制結果:" + (i));
}

複習一下&的運算規則是:同爲1則1,否則爲0

位運算使用場景示例

位運算除了高效的特點,還有一個特點在應用場景下不容忽視:計算的可逆性。通過這個特點我們可以用來達到隱蔽數據的效果,並且還保證了效率。

在JDK的原碼中。有很多初始值都是通過位運算計算的。最典型的如HashMap:

HashMap:
	
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
	static final int MAXIMUM_CAPACITY = 1 << 30;

位運算有很多優良特性,能夠在線性增長的數據中起到作用。且對於一些運算,位運算是最直接、最簡便的方法。下面我安排一些具體示例(一般都是面試題),感受一把。

判斷兩個數字符號是否相同

同爲正數or同爲負數都表示相同,否則爲不同。像這種小小case用十進制加上>/<比較符當然可以做,但用位運算符處理來得更加直接(效率最高):

@Test
public void test4() {
    int i = 100;
    int j = -2;

    System.out.println(((i >> 31) ^ (j >> 31)) == 0);

    j = 10;
    System.out.println(((i >> 31) ^ (j >> 31)) == 0);
}

運行程序,輸出:

false
true

int類型共32bit,右移31位那麼就只剩下1個符號位了(因爲是帶符號右移動,所以正數剩0負數剩1),再對兩個符號位做^異或操作結果爲0就表明二者一致。

複習一下^異或操作規則:相同爲0,不同爲1

判斷一個數的奇偶性

在十進制數中可以通過和2取餘來做,對於位運算有一個更爲高效的方式:

@Test
public void test5() {
    System.out.println(isEvenNum(1)); //false
    System.out.println(isEvenNum(2)); //true
    System.out.println(isEvenNum(3)); //false
    System.out.println(isEvenNum(4)); //true
    System.out.println(isEvenNum(5)); //false
}

/**
 * 是否爲偶數
 */
private static boolean isEvenNum(int n) {
    return (n & 1) == 0;
}

爲何&1能判斷基偶性?因爲在二進制下偶數的末位肯定是0,奇數的最低位肯定是1
而二進制的1它的前31位均爲0,所以在和其它數字的前31位與運算後肯定所有位數都是0(無論是1&0還是0&0結果都是0),那麼唯一區別就是看最低位和1進行與運算的結果嘍:結果爲1表示奇數,反則結果爲0就表示偶數。

交換兩個數的值(不借助第三方變量)

這是一個很古老的面試題了,交換A和B的值。本題如果沒有括號裏那幾個字,是一道大家都會的題目,可以這麼來解:

@Test
public void test6() {
    int a = 3, b = 5;
    System.out.println(a + "-------" + b);
    a = a + b;
    b = a - b;
    a = a - b;
    System.out.println(a + "-------" + b);
}

運行程序,輸出(成功交換):

3-------5
5-------3

使用這種方式最大的好處是:容易理解。最大的壞處是:a+b,可能會超出int型的最大範圍,造成精度丟失導致錯誤,造成非常隱蔽的bug。所以若你這樣運用在生產環境的話,是有比較大的安全隱患的。

小貼士:如果你們評估數字絕無可能超過最大值,這種做法尚可。當然如果你是字符串類型,請當我沒說

因爲這種方式既引入了第三方變量,又存在重大安全隱患。所以本文介紹一種安全的替代方式,藉助位運算的可逆性來完成操作:

@Test
public void test7() {
    // 這裏使用最大值演示,以證明這樣方式是不會溢出的
    int a = Integer.MAX_VALUE, b = Integer.MAX_VALUE - 10;
    System.out.println(a + "-------" + b);
    a = a ^ b;
    b = a ^ b;
    a = a ^ b;
    System.out.println(a + "-------" + b);
}

運行程序,輸出(成功完成交換):

2147483647-------2147483637
2147483637-------2147483647

由於全文都沒有對a/b做加法運算,因此不能出現溢出現象,所以是安全的。這種做法的核心原理依據是:位運算的可逆性,使用異或來達成目的。

位運算用在數據庫字段上(重要)

這個使用case是極具實際應用意義的,因爲在生產上我以用過多次,感覺不是一般的好。

業務系統中數據庫設計的尷尬現象:通常我們的數據表中可能會包含各種狀態屬性, 例如 blog表中,我們需要有字段表示其是否公開,是否有設置密碼,是否被管理員封鎖,是否被置頂等等。 也會遇到在後期運維中,策劃要求增加新的功能而造成你需要增加新的字段,這樣會造成後期的維護困難,字段過多,索引增大的情況, 這時使用位運算就可以巧妙的解決。

舉個例子:我們在網站上進行認證授權的時候,一般支持多種授權方式,比如:

  • 個人認證 0001 -> 1
  • 郵箱認證 0010 -> 2
  • 微信認證 0100 -> 4
  • 超管認證 1000 -> 8

這樣我們就可以使用1111這四位來表達各自位置的認證與否。要查詢通過微信認證的條件語句如下:

select * from xxx where status = status & 4;

要查詢既通過了個人認證,又通過了微信認證的:

select * from xxx where status = status & 5;

當然你也可能有排序需求,形如這樣:

select * from xxx order by status & 1 desc

這種case和每個人都熟悉的Linux權限控制一樣,它就是使用位運算來控制的:權限分爲 r 讀, w 寫, x 執行,其中它們的權值分別爲4,2,1,你可以隨意組合授權。比如 chomd 7,即7=4+2+1表明這個用戶具有rwx權限,

注意事項

  1. 需要你的DB存儲支持位運算,比如MySql是支持的
  2. 請確保你的字段類型不是char字符類型,而應該是數字類型
  3. 這種方式它會導致索引失效,但是一般情況下狀態值是不需要索引的
  4. 具體業務具體分析,別一味地爲了show而用,若用錯了容易遭對有噴的

流水號生成器(訂單號生成器)

生成訂單流水號,當然這其實這並不是一個很難的功能,最直接的方式就是日期+主機Id+隨機字符串來拼接一個流水號,甚至看到非常多的地方直接使用UUID,當然這是非常不推薦的。

UUID是字符串,太長,無序,不能承載有效的信息從而不能給定位問題提供有效幫助,因此一般屬於備選方案

今天學了位運算,有個我認爲比較優雅方式來實現。什麼叫優雅:可以參考淘寶、京東的訂單號,看似有規律,實則沒規律

  • 不想把相關信息直接暴露出去。
  • 通過流水號可以快速得到相關業務信息,快速定位問題(這點非常重要,這是UUID不建議使用的最重要原因)。
  • 使用AtomicInteger可提高併發量,降低了衝突(這是不使用UUID另一重要原因,因爲數字的效率比字符串高)

實現原理簡介

此流水號構成:日期+Long類型的值 組成的一個一長串數字,形如2020010419492195304210432。很顯然前面是日期數據,後面的一長串就蘊含了不少的含義:當前秒數、商家ID(也可以是你其餘的業務數據)、機器ID、一串隨機碼等等。

各部分介紹:

  1. 第一部分爲當前時間的毫秒值。最大999,所以佔10位
  2. 第二部分爲:serviceType表示業務類型。比如訂單號、操作流水號、消費流水號等等。最大值定爲30,足夠用了吧。佔5位
  3. 第三部分爲:shortParam,表示用戶自定義的短參數。可以放置比如訂單類型、操作類型等等類別參數。最大值定爲30,肯定也是足夠用了的。佔5位
  4. 第四部分爲:longParam,同上。用戶一般可放置id參數,如用戶id、商家id等等,最大支持9.9999億。絕大多數足夠用了,佔30位
  5. 第五部分:剩餘的位數交給隨機數,隨機生成一個數,佔滿剩餘位數。一般至少有15位剩餘(此部分位數是浮動的),所以能支持2的15次方的併發,也是足夠用了的
  6. 最後,在上面的long值前面加上日期時間(年月日時分秒)

這是A哥編寫的一個基於位運算實現的流水號生成工具,已用於生產環境。考慮到源碼較長(一個文件,共200行左右,無任何其它依賴)就不貼了,若有需要,請到公衆號後臺回覆流水號生成器免費獲取

✍總結

位運算在工程的角度裏缺點還是蠻多的,在實際工作中,如果只是爲了數字的計算,是不建議使用位運算符的,只有一些比較特殊的場景,使用位運算去做會給你柳暗花明的感覺,如:

  • N多狀態的控制,需要兼具擴展性。比如數據庫是否狀態的字段設計
  • 對效率有極致要求。比如JDK
  • 場景非常適合。比如Jackson的Feature特針值

切忌爲了炫(zhuang)技(bi)而使用,炫技一時爽,掉坑火葬場;小夥還年輕,還望你謹慎。代碼在大多情況下,人能容易讀懂比機器能讀懂來得更重要

✔推薦閱讀:
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章