基礎不牢,地動山搖。本文已被 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許可權,
注意事項
- 需要你的DB儲存支援位運算,比如MySql是支援的
- 請確保你的欄位型別不是char字元型別,而應該是數字型別
- 這種方式它會導致索引失效,但是一般情況下狀態值是不需要索引的
- 具體業務具體分析,別一味地為了show而用,若用錯了容易遭對有噴的
流水號生成器(訂單號生成器)
生成訂單流水號,當然這其實這並不是一個很難的功能,最直接的方式就是日期+主機Id+隨機字串來拼接一個流水號,甚至看到非常多的地方直接使用UUID,當然這是非常不推薦的。
UUID是字串,太長,無序,不能承載有效的資訊從而不能給定位問題提供有效幫助,因此一般屬於備選方案
今天學了位運算,有個我認為比較優雅方式來實現。什麼叫優雅:可以參考淘寶、京東的訂單號,看似有規律,實則沒規律:
- 不想把相關資訊直接暴露出去。
- 通過流水號可以快速得到相關業務資訊,快速定位問題(這點非常重要,這是UUID不建議使用的最重要原因)。
- 使用AtomicInteger可提高併發量,降低了衝突(這是不使用UUID另一重要原因,因為數字的效率比字串高)
實現原理簡介
此流水號構成:日期+Long型別的值 組成的一個一長串數字,形如2020010419492195304210432
。很顯然前面是日期資料,後面的一長串就蘊含了不少的含義:當前秒數、商家ID(也可以是你其餘的業務資料)、機器ID、一串隨機碼等等。
各部分介紹:
- 第一部分為當前時間的毫秒值。最大999,所以佔10位
- 第二部分為:serviceType表示業務型別。比如訂單號、操作流水號、消費流水號等等。最大值定為30,足夠用了吧。佔5位
- 第三部分為:shortParam,表示使用者自定義的短引數。可以放置比如訂單型別、操作型別等等類別引數。最大值定為30,肯定也是足夠用了的。佔5位
- 第四部分為:longParam,同上。使用者一般可放置id引數,如使用者id、商家id等等,最大支援9.9999億。絕大多數足夠用了,佔30位
- 第五部分:剩餘的位數交給隨機數,隨機生成一個數,佔滿剩餘位數。一般至少有15位剩餘(此部分位數是浮動的),所以能支援2的15次方的併發,也是足夠用了的
- 最後,在上面的long值前面加上日期時間(年月日時分秒)
這是A哥編寫的一個基於位運算實現的流水號生成工具,已用於生產環境。考慮到原始碼較長(一個檔案,共200行左右,無任何其它依賴)就不貼了,若有需要,請到公眾號後臺回覆流水號生成器
免費獲取。
✍總結
位運算在工程的角度裡缺點還是蠻多的,在實際工作中,如果只是為了數字的計算,是不建議使用位運算子的,只有一些比較特殊的場景,使用位運算去做會給你柳暗花明的感覺,如:
- N多狀態的控制,需要兼具擴充套件性。比如資料庫是否狀態的欄位設計
- 對效率有極致要求。比如JDK
- 場景非常適合。比如Jackson的Feature特針值
切忌為了炫(zhuang)技(bi)而使用,炫技一時爽,掉坑火葬場;小夥還年輕,還望你謹慎。程式碼在大多情況下,人能容易讀懂比機器能讀懂來得更重要。