1. 程式人生 > 程式設計 >為啥HashMap的預設容量是16?

為啥HashMap的預設容量是16?

集合是Java開發日常開發中經常會使用到的,而作為一種典型的K-V結構的資料結構,HashMap對於Java開發者一定不陌生。

在日常開發中,我們經常會像如下方式以下建立一個HashMap:

Map<String,String> map = new HashMap<String,String>();
複製程式碼

但是,大家有沒有想過,上面的程式碼中,我們並沒有給HashMap指定容量,那麼,這時候一個新建立的HashMap的預設容量是多少呢?為什麼呢?

本文就來分析下這個問題。

什麼是容量

在Java中,儲存資料有兩種比較簡單的資料結構:陣列和連結串列。陣列的特點是:定址容易,插入和刪除困難;而連結串列的特點是:定址困難,插入和刪除容易。HashMap就是將陣列和連結串列組合在一起,發揮了兩者的優勢,我們可以將其理解為連結串列的陣列。

在HashMap中,有兩個比較容易混淆的關鍵欄位:size和capacity ,這其中capacity就是Map的容量,而size我們稱之為Map中的元素個數。

簡單打個比方你就更容易理解了:HashMap就是一個“桶”,那麼容量(capacity)就是這個桶當前最多可以裝多少元素,而元素個數(size)表示這個桶已經裝了多少元素。

-w778

如以下程式碼:

Map<String,String>();
map.put("hollis","hollischuang");

Class<?> mapType = map.getClass();
Method capacity = mapType.getDeclaredMethod("capacity");
capacity.setAccessible(true);
System.out.println("capacity : " + capacity.invoke(map));

Field size = mapType.getDeclaredField("size");
size.setAccessible(true);
System.out.println("size : " + size.get(map));
複製程式碼

輸出結果:

capacity : 16、size : 1
複製程式碼

上面我們定義了一個新的HashMap,並想其中put了一個元素,然後通過反射的方式列印capacity和size,其容量是16,已經存放的元素個數是1。

通過前面的例子,我們發現了,當我們建立一個HashMap的時候,如果沒有指定其容量,那麼會得到一個預設容量為16的Map,那麼,這個容量是怎麼來的呢?又為什麼是這個數字呢?

容量與雜湊

要想講清楚這個預設容量的緣由,我們要首先要知道這個容量有什麼用?

我們知道,容量就是一個HashMap中"桶"的個數,那麼,當我們想要往一個HashMap中put一個元素的時候,需要通過一定的演演算法計算出應該把他放到哪個桶中,這個過程就叫做雜湊(hash),對應的就是HashMap中的hash方法。

-w688

我們知道,hash方法的功能是根據Key來定位這個K-V在連結串列陣列中的位置的。也就是hash方法的輸入應該是個Object型別的Key,輸出應該是個int型別的陣列下標。如果讓你設計這個方法,你會怎麼做?

其實簡單,我們只要呼叫Object物件的hashCode()方法,該方法會返回一個整數,然後用這個數對HashMap的容量進行取模就行了。

如果真的是這麼簡單的話,那HashMap的容量設定就會簡單很多了,但是考慮到效率等問題,HashMap的hash方法實現還是有一定的複雜的。

hash的實現

接下來就介紹下HashMap中hash方法的實現原理。(下面部分內容參考自我的文章:全網把Map中的hash()分析的最透徹的文章,別無二家 。PS:網上的關於HashMap的hash方法的分析的文章,很多都是在我這篇文章的基礎上"衍生"過來的。)

具體實現上,由兩個方法int hash(Object k)和int indexFor(int h,int length)來實現。

hash :該方法主要是將Object轉換成一個整型。

indexFor :該方法主要是將hash生成的整型轉換成連結串列陣列中的下標。

為了聚焦本文的重點,我們只來看一下indexFor方法。我們先來看下Java 7(Java8中雖然沒有這樣一個單獨的方法,但是查詢下標的演演算法也是和Java 7一樣的)中該實現細節:

static int indexFor(int h,int length) {
    return h & (length-1);
}
複製程式碼

indexFor方法其實主要是將hashcode換成連結串列陣列中的下標。其中的兩個引數h表示元素的hashcode值,length表示HashMap的容量。那麼return h & (length-1) 是什麼意思呢?

其實,他就是取模。Java之所有使用位運算(&)來代替取模運算(%),最主要的考慮就是效率。

位運算(&)效率要比代替取模運算(%)高很多,主要原因是位運算直接對記憶體資料進行操作,不需要轉成十進位制,因此處理速度非常快。

那麼,為什麼可以使用位運算(&)來實現取模運算(%)呢?這實現的原理如下:

X % 2^n = X & (2^n – 1)
複製程式碼

假設n為3,則2^3 = 8,表示成2進位制就是1000。2^3 -1 = 7 ,即0111。

此時X & (2^3 – 1) 就相當於取X的2進位制的最後三位數。

從2進位制角度來看,X / 8相當於 X >> 3,即把X右移3位,此時得到了X / 8的商,而被移掉的部分(後三位),則是X % 8,也就是餘數。

上面的解釋不知道你有沒有看懂,沒看懂的話其實也沒關係,你只需要記住這個技巧就可以了。或者你可以找幾個例子試一下。

6 % 8 = 6 ,6 & 7 = 6

10 % 8 = 2 ,10 & 7 = 2
複製程式碼

所以,return h & (length-1);只要保證length的長度是2^n 的話,就可以實現取模運算了。

所以,因為位運算直接對記憶體資料進行操作,不需要轉成十進位制,所以位運算要比取模運算的效率更高,所以HashMap在計算元素要存放在陣列中的index的時候,使用位運算代替了取模運算。之所以可以做等價代替,前提是要求HashMap的容量一定要是2^n

那麼,既然是2^n ,為啥一定要是16呢?為什麼不能是4、8或者32呢?

關於這個預設容量的選擇,JDK並沒有給出官方解釋,筆者也沒有在網上找到關於這個任何有價值的資料。(如果哪位有相關的權威資料或者想法,可以留言交流)

根據作者的推斷,這應該就是個經驗值(Experience Value),既然一定要設定一個預設的2^n 作為初始值,那麼就需要在效率和記憶體使用上做一個權衡。這個值既不能太小,也不能太大。

太小了就有可能頻繁發生擴容,影響效率。太大了又浪費空間,不划算。

所以,16就作為一個經驗值被採用了。

在JDK 8中,關於預設容量的定義為:static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 ,其故意把16寫成1<<4,就是提醒開發者,這個地方要是2的冪。值得玩味的是:註釋中的 aka 16 也是1.8中新增的,

那麼,接下來我們再來談談,HashMap是如何保證其容量一定可以是2^n 的呢?如果使用者自己設定了的話又會怎麼樣呢?

關於這部分,HashMap在兩個可能改變其容量的地方都做了相容處理,分別是指定容量初始化時以及擴容時。

指定容量初始化

當我們通過HashMap(int initialCapacity)設定初始容量的時候,HashMap並不一定會直接採用我們傳入的數值,而是經過計算,得到一個新值,目的是提高hash的效率。(1->1、3->4、7->8、9->16)

在JDK 1.7和JDK 1.8中,HashMap初始化這個容量的時機不同。JDK 1.8中,在呼叫HashMap的建構函式定義HashMap的時候,就會進行容量的設定。而在JDK 1.7中,要等到第一次put操作時才進行這一操作。

看一下JDK是如何找到比傳入的指定值大的第一個2的冪的:

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;
複製程式碼

上面的演演算法目的挺簡單,就是:根據使用者傳入的容量值(程式碼中的cap),通過計算,得到第一個比他大的2的冪並返回。

請關註上面的幾個例子中,藍色字型部分的變化情況,或許你會發現些規律。5->8、9->16、19->32、37->64都是主要經過了兩個階段。

Step 1,5->7

Step 2,7->8

Step 1,9->15

Step 2,15->16

Step 1,19->31

Step 2,31->32

對應到以上程式碼中,Step1:

n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
複製程式碼

對應到以上程式碼中,Step2:

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
複製程式碼

Step 2 比較簡單,就是做一下極限值的判斷,然後把Step 1得到的數值+1。

Step 1 怎麼理解呢?其實是對一個二進位制數依次向右移位,然後與原值取或。其目的對於一個數字的二進位制,從第一個不為0的位開始,把後面的所有位都設定成1。

隨便拿一個二進位制數,套一遍上面的公式就發現其目的了:

1100 1100 1100 >>>1 = 0110 0110 0110
1100 1100 1100 | 0110 0110 0110 = 1110 1110 1110
1110 1110 1110 >>>2 = 0011 1011 1011
1110 1110 1110 | 0011 1011 1011 = 1111 1111 1111
1111 1111 1111 >>>4 = 1111 1111 1111
1111 1111 1111 | 1111 1111 1111 = 1111 1111 1111
複製程式碼

通過幾次無符號右移和按位或運算,我們把1100 1100 1100轉換成了1111 1111 1111 ,再把1111 1111 1111加1,就得到了1 0000 0000 0000,這就是大於1100 1100 1100的第一個2的冪。

好了,我們現在解釋清楚了Step 1和Step 2的程式碼。就是可以把一個數轉化成第一個比他自身大的2的冪。

但是還有一種特殊情況套用以上公式不行,這些數字就是2的冪自身。如果數字4套用公式的話。得到的會是 8,不過其實這個問題也被解決了,具體驗證辦法及JDK的解決方案見全網把Map中的hash()分析的最透徹的文章,別無二家,這裡就不再展開了。

總之,HashMap根據使用者傳入的初始化容量,利用無符號右移和按位或運算等方式計算出第一個大於該數的2的冪。

擴容

除了初始化的時候回指定HashMap的容量,在進行擴容的時候,其容量也可能會改變。

HashMap有擴容機制,就是當達到擴容條件時會進行擴容。HashMap的擴容條件就是當HashMap中的元素個數(size)超過臨界值(threshold)時就會自動擴容。

在HashMap中,threshold = loadFactor * capacity。

loadFactor是裝載因子,表示HashMap滿的程度,預設值為0.75f,設定成0.75有一個好處,那就是0.75正好是3/4,而capacity又是2的冪。所以,兩個數的乘積都是整數。

對於一個預設的HashMap來說,預設情況下,當其size大於12(16*0.75)時就會觸發擴容。

下面是HashMap中的擴容方法(resize)中的一段:

if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
    newThr = oldThr << 1; // double threshold
}
複製程式碼

從上面程式碼可以看出,擴容後的table大小變為原來的兩倍,這一步執行之後,就會進行擴容後table的調整,這部分非本文重點,省略。

可見,當HashMap中的元素個數(size)超過臨界值(threshold)時就會自動擴容,擴容成原容量的2倍,即從16擴容到32、64、128 ...

所以,通過保證初始化容量均為2的冪,並且擴容時也是擴容到之前容量的2倍,所以,保證了HashMap的容量永遠都是2的冪。

總結

HashMap作為一種資料結構,元素在put的過程中需要進行hash運算,目的是計算出該元素存放在hashMap中的具體位置。

hash運算的過程其實就是對目標元素的Key進行hashcode,再對Map的容量進行取模,而JDK 的工程師為了提升取模的效率,使用位運算代替了取模運算,這就要求Map的容量一定得是2的冪。

而作為預設容量,太大和太小都不合適,所以16就作為一個比較合適的經驗值被採用了。

為了保證任何情況下Map的容量都是2的冪,HashMap在兩個地方都做了限制。

首先是,如果使用者制定了初始容量,那麼HashMap會計算出比該數大的第一個2的冪作為初始容量。

另外,在擴容的時候,也是進行成倍的擴容,即4變成8,8變成16。

本文,通過分析為什麼HashMap的預設容量是16,我們深入HashMap的原理,分析了下背後的原理,從程式碼中我們可以發現,JDK 的工程師把各種位運算運用到了極致,想盡各種辦法優化效率。值得我們學習!

關於作者:Hollis,一個對Coding有著獨特追求的人,現任阿里巴巴技術專家,個人技術博主,技術文章全網閱讀量數千萬,《程式設計師的三門課》聯合作者。