二進制基礎回顧
以下操作相對正整數的二進制而言,對非整數不太適用。
二進制轉十進制
在二進制中,位權是2的冪,所以每一位所代表的權值從右到左分別為2^(1-1) 、2^(2-1) 、... 、 2^(n-1) ,第n位的權值為2的(n-1)次冪。
所以: 100101 = 2^5 + 2^2 + 2^0 = 37。
二進制位移操作
當一個二進制數左移一位,右補"0"的時候,這個數每一位的權值就變成了原來的兩倍,那么整個數值也擴大了2倍;當這個數左移n位的時候,這個數就擴大到原來的2^n 倍。同樣的,往右移動n位,左補"0",相當於除以2^n ,如果考慮到右邊數位被舍棄的問題,這就相當於除以2^n 然后取整數。這和十進制是一樣的,十進制數字左移n位,就是擴大到原來的10^n 倍……
“按位與”與取模
在HashMap和ThreadLocal源碼中可以看到類似這樣的操作:
在這里,“按位與”操作的作用是取模,其中"n"和"len"都是數組的長度,此處代碼是要把元素的hash值映射成數組的索引(下標),以此來決定該元素的存儲位置。
由於hash值相對於數組的長度來說很大,所以不能把hash值直接一一映射為數組下標,而是對其取模,通過余數來映射。
比如一個元素的hash值為123456,而數組的長度為7,取模123456%7=4,那么可以把元素存到數組中下標為4的位置,獲取該元素時,同樣用hash值和數組長度取模,在數組中對應位置獲取。這是“散列表”的相關知識。關於詳細的取模的意義,詳見百度-散列表。
原理
當n等於2的次冪時,"hash%n"和"hash&(n-1)"等價,求證如下:
設n=16,hash=2740216402
- 當n取2的冪時,n的二進制表示有個特點——除去左邊補全的0外,數字以"1"開頭,后面全是"0";n-1的二進制表示也有一個特點——n-1的二進制位數比n少一位,數位左邊全是"0",右邊全是"1"。
- n-1與hash值進行“按位與”操作時,就相當於把hash前面部分舍去,只保留后面部分(這與掩碼
類似,在源碼注釋部分,也把這操作稱為"mask")。這實際上就是取模操作,后面的保留部分棕紅色的0010就是“余數”。為什么這部分是余數?接着往下求證:
把hash值2740216402的二進制表示拆成兩部分,可變為:
解析: r為保留部分,hash=p+r
p與n是存在倍數關系的,如下所示:
總結上述數量關系,可得:"hash = p + r = q * n + r",即hash = q * n + r,逆運算就是 hash ÷ n = q...r,當然,想要讓逆運算的算式成立,前提條件是r要小於n,又因為r比n少一位,r自然比n小,所以該算式成立。因此當n等於2的冪,hash&(n-1)=hash%n。
簡單的說,就是當n等於2的次冪時,n-1與hash進行“按位與”運算,n-1像掩碼一樣,恰好把hash中n的倍數舍去,只保留不足一倍n的余數部分。hash&(n-1)=hash%n
HashMap中的異或操作
在HashMap源碼中有這樣一段代碼:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
先簡單了解下java的位移操作:
- <<:左移運算符,num << 1,相當於num乘以2
- >>:右移運算符,num >> 1,相當於num除以2
- >>>:無符號右移,忽略符號位,空位都以0補齊
關於異或的知識:
- A ^ 0 = A,即當0與一個數(0/1)進行異或操作時,結果等於這個數本身,如0 ^ 0 = 0、 0 ^ 1 = 1;
- A ^ 1 = ! A,即當1與一個數(0/1)進行異或操作,結果等於非-此數,或者說取反,如1 ^ 0 = 1, 1 ^ 1 = 0;
再來畫下圖看這里代碼干了些什么:
好了,圖已經畫出來了,接下來對該操作進行解讀——代碼先是這樣……然后那樣……最后又……
解讀個鬼啊,其實這樣很難看出這段操作的意義,所以還是看代碼上面的注釋吧,注釋寫得很清楚了,這段代碼主要作用是利用hashcode的結果來生成更加“散列”的哈希值hash,什么意思?接着往下看:
接着上一小節的“按位與”講起,思考下,如果用原始的"hashcode"執行上小節的“按位與操作”會有怎樣的問題。
引用上面的舊圖分析,如果直接用原始的hashcode來取模,然后映射為數組的下標,這樣會產生一個很大的問題。通常數組的長度不會太大,即上圖紅棕色的部分不會很長,那么原始的hashcode的“高位”對最后的余數的影響會很小,意思就是,只要hashcode后面的四位數為"0010",不管前面藍紫色部分是什么,“hashcode&(n-1)”的結果始終為"0010",映射為數組的下標就是“2”,這樣會非常容易造成“哈希沖突”(又名“哈希碰撞”)。
所以需要采取一種策略,使得hashcode的每一位,都盡量參與運算,盡量對取模結果產生影響,充分利用hashcode的每一位,使得取模的結果更加“零散”。因此,HashMap的源碼給出了以上的方法。
hashcode長度為32位,右移16位,就是給原始的hashcode“折成兩半”,把高位的一半與低位的一半對齊,然后通過異或操作把高位和低位“結合”起來。
生成的新的hash值,其高位部分(左邊16位藍紫色部分)保留了原hashcode的高位,低位部分(紅色部分)保留了原來的高位和低位的“特征”——如果原來高位部分某一位發生改變,則影響到結果的對應位;如果原來低位某一位發生改變,也同樣影響到結果相應的位。
這里有一個問題,為什么要用異或操作?因為只能用異或操作,因為“與”和“或”不能很好的保留操作數的特征:
- 使用“與”操作時,當一個數為“0”,則結果必然為“0”,不必考慮另一個操作數;
- 使用“或”操作時,當一個操作數為“1”,則結果必然為“1”;
- 使用“異或”操作時,需要知道兩個操作數才能決定結果。
當用上述方法生成新的hash值后,原來的hashcode的每一位都對最終的取模結果產生了影響,這時在一定程度上可以使得生成的余數更加均勻,更加“散列”,使得發生“碰撞”的幾率降低。