作 者:BAT的烏託邦
原文連結:https://mp.weixin.qq.com/s/1jqe1-bCSSRchJ4SWykh9Q
本文就先行來認識認識「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:一套以 8 位為一個編碼單位的「可變長」編碼,會將一個碼位(Unicode)編碼為1到4個字節(英文1位元組,大部分漢字3位元組)。
❞
在Java7版本以前,Java是不支持直接書寫除十進位以外的其它進位字面量。但這在Java7以及以後版本就允許了:
@Testpublic 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));}
結果程序,輸出:
51016510000011011100101257100000001
說明:System.out.println()會先自動轉為10進位後再輸出的;toBinaryString()表示轉換為二進位進行「字符串」進行輸出。
JDK自1.0開始便提供了非常便捷的進位轉換的API,這在我們有需要時非常有用。
@Testpublic void test2() { int i = 192; System.out.println(&34;); System.out.println(&34; + Integer.toBinaryString(i)); //11000000 System.out.println(&34; + Integer.toOctalString(i)); //300 System.out.println(&34; + Integer.toHexString(i)); //c0 System.out.println(&34;); // 統一利用的為Integer的valueOf()方法,parseInt方法也是ok的 System.out.println(&34; + Integer.valueOf(&34;, 2).toString()); //192 System.out.println(&34; + Integer.valueOf(&34;, 8).toString()); //192 System.out.println(&34; + Integer.valueOf(&34;, 16).toString()); //192 System.out.println(&34;);}
運行程序,輸出:
---------------------------------十進位轉二進位:11000000十進位轉八進位:300十進位轉十六進位:c0---------------------------------二進位轉十進位:192八進位轉十進位:192十六進位轉十進位:192---------------------------------
我相信每個Javaer都知道Java中的Long類型佔8個字節(64位),那如何證明呢?
❝
小貼士:這算是一道經典面試題,至少我提問過多次~
❞
有個最簡單的方法:拿到Long類型的「最大值」,用2進位表示轉換成字符串看看長度就行了,代碼如下:
@Testpublic void test3() { long l = 100L; //如果不是最大值 前面都是0 輸出的時候就不會有那麼長了(所以下面使用最大/最小值示例) System.out.println(Long.toBinaryString(l)); //1100100 System.out.println(Long.toBinaryString(l).length()); //7 System.out.println(&34;); 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(&34;); l = Long.MIN_VALUE; // -2的63次方 //負數長度為64位(首位為符號位,1代表負數) //1000000000000000000000000000000000000000000000000000000000000000 System.out.println(Long.toBinaryString(l)); System.out.println(Long.toBinaryString(l).length()); //64}
運行程序,輸出:
11001007---------------------------------------11111111111111111111111111111111111111111111111111111111111111163---------------------------------------100000000000000000000000000000000000000000000000000000000000000064
說明:在計算機中,負數以其正值的「補碼」的形式表達。因此,用同樣的方法你可以自行證明Integer類型是32位的(佔4個字節)。
Java語言支持的位運算符還是非常多的,列出如下:
除~以 外,其餘均為「二元」運算符,操作的數據只能是整型(長短均可)或者char字符型。針對這些運算類型,下面分別給出示例,一目了然。
既然是運算,依舊可以分為簡單運算和複合運算兩大類進行歸類和講解。
❝
小貼士:為了便於理解,字面量例子我就都使用二進位表示了,使用十進位(任何進位)不影響運算結果
❞
簡單運算,顧名思義,一次只用一個運算符。
操作規則:「同為1則1,否則為0」。僅當兩個操作數都為1時,輸出結果才為1,否則為0。
❝
說明:1、本示例(下同)中所有的字面值使用的都是十進位表示的,理解的時候請用二進位思維去理解;2、關於負數之間的位運算本文章統一不做講述
❞
@Testpublic void test() { int i = 0B100; // 十進位為4 int j = 0B101; // 十進位為5 // 二進位結果:100 // 十進位結果:4 System.out.println(&34; + Integer.toBinaryString(i & j)); System.out.println(&34; + (i & j));}
操作規則:「同為0則0,否則為1」。僅當兩個操作數都為0時,輸出的結果才為0。
@Testpublic void test() { int i = 0B100; // 十進位為4 int j = 0B101; // 十進位為5 // 二進位結果:101 // 十進位結果:5 System.out.println(&34; + Integer.toBinaryString(i | j)); System.out.println(&34; + (i | j));}
操作規則:「0為1,1為0」。全部的0置為1,1置為0。
❝
小貼士:請務必注意是全部的,別忽略了正數前面的那些0哦~
❞
@Testpublic void test() { int i = 0B100; // 十進位為4 // 二進位結果:11111111111111111111111111111011 // 十進位結果:-5 System.out.println(&34; + Integer.toBinaryString(~i)); System.out.println(&34; + (~i));}
操作規則:「相同為0,不同為1」。操作數不同時(1遇上0,0遇上1)對應的輸出結果才為1,否則為0。
@Testpublic void test() { int i = 0B100; // 十進位為4 int j = 0B101; // 十進位為5 // 二進位結果:1 // 十進位結果:1 System.out.println(&34; + Integer.toBinaryString(i ^ j)); System.out.println(&34; + (i ^ j));}
操作規則:把一個數的「全部位數」都向左移動若干位。
@Testpublic void test() { int i = 0B100; // 十進位為4 // 二進位結果:100000 // 十進位結果:32 = 4 * (2的3次方) System.out.println(&34; + Integer.toBinaryString(i << 2)); System.out.println(&34; + (i << 3));}
左移「用得非常多」,理解起來並不費勁。x左移N位,效果同十進位裡直接乘以2的N次方就行了,但是需要注意值「溢出」的情況,使用時稍加注意。
操作規則:把一個數的「全部位數」都向右移動若干位。
@Testpublic void test() { int i = 0B100; // 十進位為4 // 二進位結果:10 // 十進位結果:2 System.out.println(&34; + Integer.toBinaryString(i >> 1)); System.out.println(&34; + (i >> 1));}
負數右移:
@Testpublic void test() { int i = -0B100; // 十進位為-4 // 二進位結果:11111111111111111111111111111110 // 十進位結果:-2 System.out.println(&34; + Integer.toBinaryString(i >> 1)); System.out.println(&34; + (i >> 1));}
右移用得也比較多,也比較理解:操作其實就是把二進位數「右邊的N位」直接「砍掉」,然後正數右移高位補0,負數右移高位補1。
❝
注意:沒有無符號左移,並沒有<<<這個符號的
❞
它和>>有符號右移的區別是:無論是正數還是負數,「高位通通補0」。所以說對於正數而言,沒有區別;那麼看看對於負數的表現:
@Testpublic void test() { int i = -0B100; // 十進位為-4 // 二進位結果:11111111111111111111111111111110(>>的結果) // 二進位結果:1111111111111111111111111111110(>>>的結果) // 十進位結果:2147483646 System.out.println(&34; + Integer.toBinaryString(i >>> 1)); System.out.println(&34; + (i >>> 1));}
我特意把>>的結果放上面了,方便你對比。因為高位補的是0,所以就沒有顯示啦,但是你心裡應該清楚是怎麼回事。
廣義上的複合運算指的是多個運算「嵌套起來」,通常這些運算都是同種類型的。這裡指的複合運算指的就是和=號一起來使用,類似於+= -=。本來這屬於基礎常識不用做單獨解釋,但誰讓A哥管生管養,管殺管埋呢。
❝
混合運算:指同一個算式裡包含了bai多種運算符,如加減乘除乘方開du方等。
❞
以&與運算為例,其它類同:
@Testpublic void test() { int i = 0B110; // 十進位為6 i &= 0B11; // 效果同:i = i & 3 // 二進位結果:10 // 十進位結果:2 System.out.println(&34; + Integer.toBinaryString(i)); System.out.println(&34; + (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用十進位加上>/<比較符當然可以做,但用位運算符處理來得更加直接(效率最高):
@Testpublic 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);}
運行程序,輸出:
falsetrue
int類型共32bit,右移31位那麼就只剩下1個符號位了(因為是「帶符號右移動」,所以正數剩0負數剩1),再對兩個符號位做^異或操作結果為0就表明二者一致。
❝
複習一下^異或操作規則:「相同為0,不同為1」。
❞
在十進位數中可以通過和2取餘來做,對於位運算有一個更為「高效」的方式:
@Testpublic 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的值。本題如果沒有括號裡那幾個字,是一道大家都會的題目,可以這麼來解:
@Testpublic void test6() { int a = 3, b = 5; System.out.println(a + &34; + b); a = a + b; b = a - b; a = a - b; System.out.println(a + &34; + b);}
運行程序,輸出(成功交換):
3-------55-------3
使用這種方式最大的好處是:容易理解。最大的壞處是:「a+b,可能會超出int型的最大範圍,造成精度丟失導致錯誤,造成非常隱蔽的bug」。所以若你這樣運用在生產環境的話,是有比較大的安全隱患的。
❝
小貼士:如果你們評估數字「絕無可能」超過最大值,這種做法尚可。當然如果你是字符串類型,請當我沒說
❞
因為這種方式既引入了第三方變量,又存在重大安全隱患。所以本文介紹一種安全的替代方式,藉助位運算的「可逆性」來完成操作:
@Testpublic void test7() { // 這裡使用最大值演示,以證明這樣方式是不會溢出的 int a = Integer.MAX_VALUE, b = Integer.MAX_VALUE - 10; System.out.println(a + &34; + b); a = a ^ b; b = a ^ b; a = a ^ b; System.out.println(a + &34; + b);}
運行程序,輸出(成功完成交換):
2147483647-------21474836372147483637-------2147483647
由於全文都沒有對a/b做加法運算,因此不能出現溢出現象,所以是安全的。這種做法的核心原理依據是:「位運算的可逆性」,使用異或來達成目的。
這個使用case是「極具」實際應用意義的,因為在生產上我已用過多次,感覺不是一般的好。
業務系統中資料庫設計的尷尬現象:通常我們的數據表中可能會包含各種狀態屬性, 例如 blog表中,我們需要有欄位表示其是否公開,是否有設置密碼,是否被管理員封鎖,是否被置頂等等。「也會遇到在後期運維中,策劃要求增加新的功能而造成你需要增加新的欄位」,這樣會造成後期的維護困難,欄位過多,索引增大的情況, 這時使用位運算就可以「巧妙的」解決。
舉個例子:我們在網站上進行認證授權的時候,一般支持多種授權方式,比如:
這樣我們就可以使用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」權限,
生成訂單流水號,當然這其實這並不是一個很難的功能,最直接的方式就是日期+主機Id+隨機字符串來拼接一個流水號,甚至看到非常多的地方直接使用UUID,當然這是非常不推薦的。
❝
UUID是字符串,太長,無序,不能承載有效的信息從而不能給定位問題提供有效幫助,因此一般屬於備選方案
❞
今天學了位運算,有個我認為比較優雅方式來實現。什麼叫優雅:可以參考淘寶、京東的訂單號,「看似有規律,實則沒規律」:
此流水號構成:日期+Long類型的值 組成的一個一長串數字,形如2020010419492195304210432。很顯然前面是日期數據,後面的一長串就蘊含了不少的含義:當前秒數、商家ID(也可以是你其餘的業務數據)、機器ID、一串隨機碼等等。
各部分介紹:
這是A哥編寫的一個基於位運算實現的流水號生成工具,已用於生產環境。考慮到源碼較長(一個文件,共200行左右,無任何其它依賴)就不貼了,若有需要,
位運算在「工程的角度」裡缺點還是蠻多的,在實際工作中,如果只是為了數字的計算,是不建議使用位運算符的,只有一些比較特殊的場景,使用位運算去做會給你柳暗花明的感覺,如:
切忌為了炫(zhuang)技(bi)而使用,炫技一時爽,掉坑火葬場;小夥還年輕,還望你謹慎。代碼在大多情況下,「人更容易讀懂比機器能讀懂來得更重要」。