HashMap源碼實現分析

HashMap源碼實現分析

1、前言

HashMap 顧名思義,就是用hash表的原理實現的Map接口容器對象,那什麼又是hash表呢。java

咱們對數組都很熟悉,數組是一個佔用連續內存的數據結構,學過C的朋友對這一點影響確定更爲深入。既然是一段連續的內存,數組的特色就顯而易見了,一旦你知道要查第幾個數據,時間複雜度就是O(1),可是對於插入操做就很困難;還有一種數據結構你也必定很熟悉,那就是鏈表,鏈表由一組指向(單向或者雙向)的節點鏈接的數據結構,它的特色是內存不連續,查找困難,可是插入刪除都很容易。git

那有沒有一種查找容易,插入刪除查找都容易的數據結構呢, 沒錯,它就是hash表。程序員

本篇,咱們就來討論:github

  • HashMap的數據結構實現方式
  • HashMap是怎麼作到爲get、put操做提供穩定的時間複雜度的
  • HashMap何時從單節點轉成鏈表又是何時從鏈表轉成紅黑樹
  • HashMap初始化時爲何要給自定義的初始容量。
  • HashMap如何保證容量始終是2的冪
  • HashMap爲什麼要保證容量始終是2的冪
  • HashMap的hash值如何計算
  • HashMap爲何是線程不安全的

要了解HashMap 最好的方式就是看源碼,本篇內容基於Jdk1.8HashMap源碼。面試

2、HashMap的基本要素

磨刀不誤砍柴功,想了解HashMap的原理,必然繞不過HashMap源碼中的如下幾個變量:數組

  • DEFAULT_INITIAL_CAPACITY: 初始容量 1<<4也就是16
  • MAXIMUM_CAPACITY:最大容量 1<<30。
  • DEFAULT_LOAD_FACTOR:負載因子,默認是0.75。什麼意思呢,好比說你定義了一個初始容量爲16的HashMap,當你不斷向裏面添加元素後,最多到初始容量的0.75,HashMap就會觸發擴容操做。
  • threshold:下一個觸發擴容操做的閾值,threshold = CAPACITY * LOAD_FACTOR。
  • TREEIFY_THRESHOLD:鏈表轉紅黑樹閾值,默認爲8,超過8就會執行鏈表轉紅黑樹方法,可是注意轉紅黑樹方法中會判斷當前size是否大於64,只有大於64才轉紅黑樹,不然執行resize()操做
  • UNTREEIFY_THRESHOLD: 紅黑樹轉鏈表閾值,默認爲6,顧名思義,紅黑樹節點小於6就會轉成鏈表。
  • Node<K, V> implements Map.Entry<K, V> HashMap存放數據的基本單位,裏面存有hash值、key、value、next。
  • Node<K, V>[] table:存放Node節點的數組,HashMap最底層數組,數組元素能夠爲單節點Node、多節點鏈表、多節點紅黑樹。

以上內容,有個印象就好,沒必要每一個都記得。但這些概念對理解HashMap相當重要。安全

3、正文

3.1 HashMap 數據結構

HashMap的數據結構很簡單,它是一個Node類型的數組,每一個元素能夠爲單節點、多節點鏈表、多節點紅黑樹。關鍵的問題是,這麼簡單的結構怎麼實現的put、get都很快? 何時從單節點轉成鏈表又是何時從鏈表轉成紅黑樹?數據結構

3.1.1 HashMap如何實現put、get操做時間複雜度爲O(1)~O(n)?

咱們知道,查找一個數組的元素,當咱們不知道index的時候,複雜度是很高的,可是當咱們知道index的時候,這個複雜度就是O(1)級別的。HashMap使用的就是這個原理。 對於get操做,首先根據key計算出hash值,而這個hash值執行操做(n - 1) & hash後就是它所在的index,在最好的狀況下,該index剛好只有一個節點且hash值和key的hash值相同,那麼時間複雜度就是O(1),當該節點爲鏈表或者紅黑樹時,時間複雜度會上升,可是因爲HashMap的優化(鏈表長度、紅黑樹長度相對於HashMap容量不會過長,過長會觸發resize操做),因此最壞的狀況也就是O(n),可能還會小於這個值。多線程

對於put操做,咱們知道,數組插入元素的成本是高昂的,HashMap巧妙的使用鏈表和紅黑樹代替了數組插入元素須要移動後續元素的消耗。這樣在最好的狀況下,插入一個元素,該index位置剛好沒有元素的話,時間複雜度就是O(1),當該位置有元素且爲鏈表或者紅黑樹的狀況下,時間複雜度會上升,可是最壞的狀況下也就是O(n)。架構

3.1.2 HashMap何時從單節點轉成鏈表又是何時從鏈表轉成紅黑樹?

單節點轉鏈表很簡單,當根據新加入的值計算出來的index處有元素時,若元素爲單節點,則從節點轉爲鏈表。 鏈表轉紅黑樹有兩個條件:

  • 鏈表長度大於TREEIFY_THRESHOLD,默認閾值是8

  • HashMap長度大於64

當同時知足這兩個條件,那麼就會觸發鏈表轉紅黑樹的操做。

3.2 HashMap初始化時爲何要給自定義的初始容量?

爲啥前輩們都要求定義一個HashMap的時候必定要使用構造函數HashMap(int initialCapacity)指定初始容量呢?

在阿里的《Java開發手冊》中是這樣說明的:

  1. 【推薦】集合初始化時,指定集合初始值大小。

說明:HashMap 使用 HashMap(int initialCapacity) 初始化,

正例:initialCapacity = (須要存儲的元素個數 / 負載因子) + 1。注意負載因子(即 loader

factor)默認爲 0.75,若是暫時沒法肯定初始值大小,請設置爲 16(即默認值)。

反例:HashMap 須要放置 1024 個元素,因爲沒有設置容量初始大小,隨着元素不斷增長,容

量 7 次被迫擴大,resize 須要重建 hash 表,嚴重影響性能。

這個問題在HashMap源碼中是顯而易見的,每次put函數中都會檢查當前size是否大於threshold,若是大於就會進行擴容,新容量是原來容量的二倍。那麼問題就來了,當你要存大量數據到HashMap中而又不指定初始容量的話,擴容會被一次接一次的觸發,很是消耗性能。

而初始容量和負載因子給多少好呢,平常開發中如無必要不建議動負載因子,而是根據要使用的HashMap大小肯定初始容量,這也不是說爲了不擴容初始容量給的越大越好, 越大申請的內存就越大,若是你沒有這麼多數據去存,又會形成hash值過於離散。

3.3 HashMap如何保證容量始終是2的冪

HashMap使用方法tableSizeFor()來保證不管你給值是什麼,返回的必定是2的冪:

static final int tableSizeFor(int cap)
    {
        int n = cap - 1; // 做用:保證當cap爲2的冪時,返回原值而不是二倍,如8 返回8 而不是16
        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;
    }複製代碼

首先咱們來看操做:

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

假設 n=01000000, n |= n >>> 1後 n=01100000,n |= n >>> 2後n=01111000,n |= n >>> 4;後n=01111111,咱們能夠發現,上述5步操做能夠將一個32位數第一位爲1的後面全部位全變爲1。這樣再執行n + 1操做後,該數就必爲2的冪次方了。如01111111+1 = 10000000。 那又爲何要保證必定是2的冪次方呢?不是不行嗎?

3.3.1 HashMap爲什麼要保證容量始終是2的冪

說到這個問題不得不說執行put()方法時,是如何根據hash值在table中定位。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
    {
        ......
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        ......複製代碼

能夠看到,它使用了一個 (n - 1) & hash的操做,n爲當前hashmap的容量,而容量必定爲2的冪次方,n-1的二進制低位都爲1,舉例:16=0000000000010000,15=0000000000001111,這樣的處理好處在於,當執行(n - 1) & hash的操做時,元素的位置僅取決於低位而與高位無關(這種無關性隨着HashMap容量的增大而減少),這個邏輯優勢是,不管你的hash值有多大,我都鎖定了你的取值範圍小於當前容量,這樣作避免了hash值過於離散的狀況,而當HashMap擴容時又能夠同時增大hash值的取值範圍,缺點是增長了hash碰撞的可能性,爲了解決這個問題HashMap修改了hash值的計算方法來增長低位的hash複雜度。

3.3.2 HashMap計算hash值

不廢話,直接上源碼:

static final int hash(Object key)
    {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }複製代碼

hash方法用 key的hash值異或上key的hash值的高16位,爲何要這樣作呢? 首先,h>>>16 的值高16位都爲0,這樣h^(h>>>16)時,高16位的值不會有任何變化,可是低16位的值混雜了key的高16位的值,從而增長了hash值的複雜度,進一步減小了hash值同樣的機率。

3.4 HashMap爲何是線程不安全的

在Jdk1.7中,形成HashMap線程不安全的緣由之一是transfer函數,該函數使用頭查法在多線程的狀況下很容易出現閉環鏈表從而致使死循環,同時還有數據丟失的問題,Jdk1.8中沒有transfer函數而是在resize函數中完成了HashMap擴容或者初始化操做,resize採用尾插法很好的解決了閉環鏈表的問題,可是依舊避免不了數據覆蓋的問題。 在HashMap的put操做中:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
    {
        Node<K, V>[] tab;
        Node<K, V> p;
        int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        ......複製代碼

在執行完 if ((tab = table) == null || (n = tab.length) == 0)判斷且爲true的狀況下,會直接進行賦值,可是在多線程的環境下,當兩個線程同時完成判斷,線程1剛賦值完,線程2再進行賦值,就形成了數據覆蓋的問題。 這只是最簡單的現象,咱們要想線程安全,首先要有多線程安全的處理邏輯,很明顯HashMap是沒有這樣的邏輯的,那麼不少爲單線程設計的邏輯就很大可能出問題,因此HashMap爲何是線程不安全的?它自己設計就不支持多線程下的操做,因此不應有此問。 若是想要線程安全的使用基於hash表的map,可使用ConcurrentHashMap,該實現get操做是無鎖的,put操做也是分段鎖,性能很好。 因此說術業有專攻,每一個容器的實現都有它對應的優缺點。咱們須要學會的是分析面對的每一種狀況,合理的使用不一樣的容器去解決問題。

HashMap基本的原理和對應實現就說到這裏了,更深刻的話題如:紅黑樹插入節點、平衡紅黑樹、遍歷紅黑樹,能夠直接看紅黑樹對應的原理和實現。

須要源碼註釋的請戳這裏源碼解析


最後,最近不少小夥伴找我要Linux學習路線圖,因而我根據本身的經驗,利用業餘時間熬夜肝了一個月,整理了一份電子書。不管你是面試仍是自我提高,相信都會對你有幫助!目錄以下:

免費送給你們,只求你們金指給我點個贊!

電子書 | Linux開發學習路線圖

也但願有小夥伴能加入我,把這份電子書作得更完美!

有收穫?但願老鐵們來個三連擊,給更多的人看到這篇文章

推薦閱讀:

相關文章
相關標籤/搜索