HashMap之tableSizeFor方法圖解

希夷小道發表於2021-03-05


在看HashMap原始碼時,注意到一個問題,容量必須是2的整數冪,為了保證這一點,專門給出了一個巧妙而高效的方法tableSizeFor。不妨想一下,如果是自己解決這個問題,該怎麼解決?

給定一個int型別的整數n,如何求出不小於它的最接近的2的整數冪m,比如給定10得出16,給定25得出32?


普通人的簡單粗暴方式

普通人的想法可能比較簡單,直接對n求以2為底的對數,結果m是double型別,若小數部分為0,則m就是我們要求的指數;小數部分不為0,則對m向上取整,最後直接求2的m次冪。

首先遇到的問題是jdk沒有提供對2求對數的數學公式,只有對自然對數e求對數的公式Math.log(double a)

好在我們可以用對數的換底公式換底公式

示例程式碼

    public static int fun(int n) {
        double m = Math.log(n) / Math.log(2);
        int m2 = (int) Math.ceil(m);
        return (int) Math.pow(2, m2);
    }

不考慮是否有精度損失,上述程式碼很簡潔,只有三步,求對數+取整+求指數。

問題

回顧HashMap中的需求我們知道,這個方法屬於很基礎的方法,將在初始化或者新增時被大量執行,這就要求方法本身一定要高效。

這裡雖然程式碼簡潔,但呼叫的方法細看的話程式碼還是很多的,而且涉及到的運算,比如對數,指數,除運算,取整,強制型別轉換,都是比較高階的,必然依靠大量的底層簡單操作實現。

一個程式執行的時間除了和環境比如時鐘週期的長度和每條指令的平均時鐘週期數有關外,還和指令數有關。感性的認識也能告訴我們,上述程式碼的實際執行的最終指令一定不會少。

我們之所有要用這個方法轉換為2的冪,是為了減少雜湊衝突,提高存取效率,結果這個方法本身嚴重影響了效率,豈不是揀了芝麻丟了西瓜?


大神的實現

我們不妨看看HashMap的作者是如何實現的。

static final int tableSizeFor(int cap) {
    
    int n = cap - 1;
    //移位運算
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

第一行很簡單,為什麼要-1放在最後說,最後一行是兩個三目運算子,其中之一操作是n+1,都很容易理解。關鍵是中間五步移位加上或運算。

移位的思想

說一下我理解的作者的思想:

2的整數冪用二進位制表示都是最高有效位為1,其餘全是0,比如十進位制8和32,下圖只用了一個位元組示意。

2的整數冪形式

對任意十進位制數轉換為2的整數冪,結果是這個數本身的最高有效位的前一位變成1,最高有效位以及其後的位都變為0

核心思想是,先將最高有效位以及其後的位都變為1,最後再+1,就進位到前一位變成1,其後所有的滿2變0。所以關鍵是如何將最高有效位後面都變為1

還是用圖來示意。這裡將十進位制的25轉換為32。

25轉為32

作者的做法是先移位,再或運算。

右移一位,再或運算,就有兩位變為1;

右移兩位,再或運算,就有四位變為1,,,

最後右移16位再或運算,保證32位的int型別整數最高有效位之後的位都能變為1.


全過程示意圖

自己覺得理解了,但是感覺文章寫出來很繞,估計看到這裡的你也有這種感覺。這裡對整個過程畫圖示意。

初始值

選取任意int型別數字,下圖x表示不確定0或者1.
任意數字x

我們目的是將所有的x變為1,如下圖

所有x變為1

最後+1,就能進位得到2的整數冪。

最終結果形式

我們要做的就是不斷通過右移+或運算來達到目的。

右移一位+或運算

右移1位或

可以看出,右移一位再或運算,有兩位變成了1。

右移二位+或運算

右移2位或

右移兩位再或運算,有四位變成了1。

右移四位+或運算

右移4位或

右移四位再或運算,有八位變成了1。

右移八位+或運算

右移8位或

右移八位再或運算,有十六位變成了1。

右移十六位+或運算

右移16位或

右移十六位再或運算,注意這裡不是三十二位全變,而是最高位後面的全變1。

結果+1

結果+1

可以看出,不管x是多少,我們都能將其轉換為1。而且分別經過1,2,4,8,16次轉換,不管這個int型別值多大,我們都會將其轉換,只是值較小時,可能多做幾次無意義操作。


初始容量-1

之所以在開始移位前先將容量-1,是為了避免給定容量已經是8,16這樣2的冪時,不減一直接移位會導致得到的結果比預期大。比如預期16得到應該是16,直接移位的話會得到32。在上圖中就是所有x本身已經是0的情況下,不減1得到的結果變大了。


總結

回到一開始的問題,這個方法之所以高效,是因為移位運算和或運算都屬於比較底層的操作,程式碼的數量不會比最終的指令數多,也就是通過幾個簡單操作實現了我們的目的。

為啥要專門寫一篇文章來解釋這個方法,是因為在看這個方法的時候,意識到了一些原本不太在意的問題。通過這個方法,就理解了為啥學計算機要學一些基礎的知識,比如二進位制的操作,邏輯運算等等,以及為啥一些高階的演算法看起來都在處理簡單的問題。如果單純學習可能覺得枯燥,但實際上它們都是有大用處的。平時可能看不出來,在一些關鍵的細節就看出普通人和大神的區別了。

要學的還有很多!

相關文章