java hashmap 問題彙總

如何保證hashmap 數組大小一定是2的指數

tableSizeFor 在初始化 hashmap對象時會調用來得到這麼一個值,這個值用來作爲hashmap 數組大小。

 	static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;    //  無符號 右移 1位,|  符號是 或 操作,一直將後面 的地位全部置1
        n |= n >>> 2;    //  無符號 右移 2位
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

這段代碼可以將cap 變成一個2的指數,cap化成二進制後,高位後一位變成1,低位全部變成0,這樣得到一個2的指數代表的容量。

拿數字 5 做例子: 5: 0101

  • n = cap - 1 : n = 4 :0100
  • n |= n >>> 1 : 0100 | 0010 = 0111
  • n |= n >>> 2:0111 | 0000 = 0111
  • 後面幾個操作也是這樣,那麼 n = 0111 + 1 = 1000 = 8
for (int i = 0; i < 7; i++) {
            System.out.println(tableSizeFor(i));
        }

輸出結果:

1
1
2
4
4
8
8

大家可以操作一下。

爲什麼表大小一定是2的倍數?

我們來看下錶長是7,用於取模運算的時候要減去1,也就是7-1 = 6 ,二進制是 0110 ,左高位,右低位,右邊第1位是0,所有數字與它做與運算都是0,不利於均勻散列,而8這個數字,8-1 =7 ,二進制是0111,低位都是1,這樣其他數字跟他 與 運算,0&1=0,1&1=1,比較均衡,那爲啥減1取模?數組座標是從0開始的,所以長度爲8的數組,座標範圍是0到7。擴容都是2倍擴容,總不能3倍擴容,4倍擴容,太浪費空間了,所以綜合初始大小、擴容倍數可以知道表大小一定是2的倍數。同時這麼做有幾點好處:

  • 降低碰裂次數,散列更均衡
  • 避免空間不被浪費利用,其實還是散列問題,散列不好,很多空間都沒有數據,不久浪費了。

爲什麼初始大小是16?

這個問題 首先考慮的就是 爲什麼不是 8 、32 ?

  • 8太小了,很容易導致map擴容影響性能
  • 32太大了,又會浪費資源

這裏的選取主要會考慮:

  • 減少hash碰撞
  • 提高map查詢效率
  • 分配過小會造成頻繁擴容
  • 分配過大浪費資源

擴容閾值

擴容閾值 = 容量 x 加載因子。
擴容閾值(threshold):當哈希表的大小 ≥ 擴容閾值時,就會擴容哈希表(即擴充HashMap的容量), 對哈希表進行resize操作(即重建內部數據結構),從而哈希表將具有大約兩倍的桶數。擴容都是2倍的擴容。

jdk 7 與 jdk 8 不同點

  • 紅黑樹
    當鏈表特別長的時候,查找效率降低了,jdk8引入 紅黑樹,提高查找速度
  • hash碰撞時
    jdk8 先判斷是紅黑樹,是紅黑樹插入樹中,不是紅黑樹插入鏈尾
  • rehash 順序不一樣
    jdk7 逆序擴容,jdk 8 順序擴容,保證不會出現閉環情況,這個單獨講解。

多線程的循環引用情況

hashmap不是線程安全的,在多線程環境下會出現循環引用情況,我們分析一下,有A、B兩個線程,他們都插入數據錢發現要執行擴容,當前情況是有一個數組鏈表是entry 1,下一個entry 是 entry 2,開始表演:

  • 1、線程A進來,拿到了entry 1 ,next 是 entry 2,暫停
  • 2、線程B進來,拿到了entry 1,開始擴容,jdk 7 擴容是逆序,擴容後是entry 2 - - > entry 1。
  • 3、線程A 醒來了,還是指向entry 1 ,進行擴容,hash後還是這個表index,插入表頭,由於entry 2已經在了,所以entry 1指向entry 2
  • 4、然後next 節點晉升當前節點,也就是entry 2,next指向entry 2的下一個節點,由於線程B的緣故,entry 2的下一個節點是entry 1,所以next爲entry 1,
  • 5、entry 2 插入到表頭,指向entry 1,entry 1 在第3步驟中已經指向了entry 2,所以開始了無限循環。

形成循環引用的原因是表頭插入,這樣擴容的時候,從表頭到表尾取數據,rehash到新的數組座標下,做表頭插入,這樣擴容後的順序是擴容前的逆序,jdk 1.8對此做了優化,每次都是表尾插入,這樣順序跟之前還是一樣的。

爲什麼連表大小爲8的時候就轉換爲紅黑樹

鏈表長度達到8就轉成紅黑樹,當長度降到6就轉成普通bin,有的資料說因爲查找效率,因爲:

  • 紅黑樹 時間複雜度是:log(n)log(n) , n=8 時 時間複雜度是3。
  • 鏈表時間複雜度:n2\frac{n}{2},n=8時,時間複雜度是4。

不過這裏有一點,我看了jdk 1.8的鏈表代碼,他是for循環的,for循環的時間複雜度就是O(n)O(n),不知道爲啥在這裏就變成O(n2)O(\frac{n}{2}),估計很多作者在這裏理解時用所謂 的平均複雜度來做計算,但實質是平均複雜度的計算沒有那麼簡單,這還跟概率有關,具體可以參考:複雜度分析(下):淺析最好,最壞,平均,均攤時間復數據結構-最好、最壞、平均時間複雜度的分析(筆記2)

所以這個說法不是最終的原因,書中源碼這麼說:


Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins.  In
usages with well-distributed user hashCodes, tree bins are
rarely used.  Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5)*pow(0.5, k)/factorial(k)). 
The first values are:
0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million


理想情況下使用隨機的哈希碼,容器中節點分佈在hash桶中的頻率遵循泊松分佈(具體可以查看http://en.wikipedia.org/wiki/Poisson_distribution),按照泊松分佈的計算公式計算出了桶中元素個數和概率的對照表,可以看到鏈表中元素個數爲8時的概率已經非常小,再多的就更少了,所以原作者在選擇鏈表元素個數時選擇了8,是根據概率統計而選擇的。

hash算法足夠好,也就是碰撞低,從而hash分佈遵循泊松分佈,那麼這樣,一個鏈表中衝突有8個的概率就是0.00000006,非常低,幾乎是不太可能的,所以 hash算法不好的話,衝突就非常多,多餘8個就爲了提高效率換成紅黑樹。

ConcurrentHashMap

volatile 修飾,保證了可見性。
1.7使用分段鎖,採用了ReentrantLock鎖機制來保證,ReentrantLock,一個可重入的互斥鎖,它具有與使用synchronized方法和語句所訪問的隱式監視器鎖相同的一些基本行爲和語義,但功能更強大。
分段鎖更多的類似二維數組,行表示segment數組,列表示hashEntry數組。segment對象直接繼承ReentrantLock。
1.8使用了 CAS + synchronized 來保證併發安全性。

1、用 HashEntery 對象的不變性來降低讀操作對加鎖的需求

在代碼清單“HashEntry 類的定義”中我們可以看到,HashEntry 中的 key,hash,next 都聲明爲 final 型。這意味着,不能把節點添加到鏈接的中間和尾部,也不能在鏈接的中間和尾部刪除節點。這個特性可以保證:在訪問某個節點時,這個節點之後的鏈接不會被改變。這個特性可以大大降低處理鏈表時的複雜性。

2、用 Volatile 變量協調讀寫線程間的內存可見性

volatile 型變量 count ,特性和前面介紹的 HashEntry 對象的不變性相結合,使得在 ConcurrentHashMap 中,讀線程在讀取散列表時,基本不需要加鎖就能成功獲得需要的值。這兩個特性相配合,不僅減少了請求同一個鎖的頻率(讀操作一般不需要加鎖就能夠成功獲得值),也減少了持有同一個鎖的時間(只有讀到 value 域的值爲 null 時 , 讀線程才需要加鎖後重讀)。

參考博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章