瞭解HashMap數據結構,超詳細!

點擊藍色「程序員的時光 」關注我 ,標註「星標」,及時閱讀最新技術文章

寫在前面:

小夥伴兒們,你們好!今天來學習HashMap相關內容,做爲面試必問的知識點,來深刻了解一波!程序員

思惟導圖:
學習框架圖

1,HashMap集合簡介

HashMap基於哈希表的Map接口實現,是以key-value存儲形式存在,即主要用來存放鍵值對。HashMap的實現不是同步的,這意味着它不是線程安全的。它的key、value均可覺得null。此外,HashMap中的映射不是有序的。web

JDK1.8以前的HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要爲了節解決哈希碰撞(兩個對象調用的hashCode方法計算的哈希碼值一致致使計算的數組索引值相同)而存在的(「拉鍊法」解決衝突)。面試

JDK1.8以後在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(或者紅黑樹的邊界值,默認爲8)而且當前數組的長度大於64時,此時此索引位置上的全部數據改成使用紅黑樹存儲。算法

數組裏面都是key-value的實例,在JDK1.8以前叫作Entry,在JDK1.8以後叫作Node。數組

key-value實例

因爲它的key、value都爲null,因此在插入的時候會根據key的hash去計算一個index索引的值。計算索引的方法以下:安全

/**
 * 根據key求index的過程
 * 1,先用key求出hash值
 */

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//2,再用公式index = (n - 1) & hash(n是數組長度)
int hash=hash(key);
index=(n-1)&hash;

這裏的Hash算法本質上就是三步:取key的hashCode值、高位運算、取模運算。微信

這樣的話好比說put("A",王炸),插入了key爲"A"的元素,這時候經過上述公式計算出插入的位置index,若index爲3則結果以下(即hash("A")=3):數據結構

插入索引爲3的結點

那麼,HashMap中的鏈表又是幹什麼用的呢?框架

你們都知道數組的長度是有限的,在有限的長度裏面使用哈希函數計算index的值時,頗有可能插入的k值不一樣,但所產生的hash是相同的(也叫作哈希碰撞),這也就是哈希函數存在必定的機率性。就像上面的K值爲A的元素,若是再次插入一個K值爲a的元素,頗有可能所產生的index值也爲3,也就是即hash("a")=3;那這就造成了鏈表,這種解決哈希碰撞的方法也叫作拉鍊法。編輯器

同一個位置插入不一樣元素

當這個鏈表長度大於閾值8而且數組長度大於64則進行將鏈表變爲紅黑樹。

補充:

將鏈表轉換成紅黑樹前會判斷,若是閾值大於8,可是數組長度小64,此時並不會將鏈表變爲紅黑樹。而是選擇進行數組擴容

這樣作的目的是由於數組比較小,儘可能避開紅黑樹結構,這種狀況下變爲紅黑樹結構,反而會下降效率,由於紅黑樹須要進行左旋,右旋,變色這些操做來保持平衡。同事數組長度小於64時,搜索時間相對快一些。因此綜上所述爲了提升性能和減小搜索時間,底層在閾值大於8而且數組長度大於64時,鏈表才轉換爲紅黑樹。具體能夠參考treeifyBin方法。

固然雖然增了紅黑樹做爲底層數據結構,結構變得複雜了,可是閾值大於8而且數組長度大於64時,鏈表轉換爲紅黑樹時,效率也變得更高效。

特色:

  1. 存取無序的

  2. 鍵和值位置均可以是null,可是鍵位置只能是一個null

  3. 鍵位置是惟一的,底層的數據結構控制鍵的

  4. jdk1.8前數據結構是:鏈表 + 數組  jdk1.8以後是 :鏈表 + 數組  + 紅黑樹

  5. 閾值(邊界值) > 8 而且數組長度大於64,纔將鏈表轉換爲紅黑樹,變爲紅黑樹的目的是爲了高效的查詢。

2,HsahMap底層數據結構

2.1,HashMap存儲數據的過程

每個Node結點都包含鍵值對的key,value還有計算出來的hash值,還保存着下一個 Node 的引用 next(若是沒有下一個 Node,next = null),來看看Node的源碼:

static class Node<K,Vimplements Map.Entry<K,V{
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        ...
   }

HashMap存儲數據須要用到put()方法,關於這些方法的詳解,咱們下節再說,這裏簡要說一下;

public static void main(String[] args) {
        HashMap<String,Integer> hmap=new HashMap<>();
        hmap.put("斑",55);
        hmap.put("鏡",63);
        hmap.put("帶土",25);
        hmap.put("鼬",9);
        hmap.put("佐助",43);
        hmap.put("斑",88);
        System.out.println(hmap);
    }

當建立HashMap集合對象的時候,在jdk1.8以前,構造方法中會建立不少長度是16的Entry[] table用來存儲鍵值對數據的。在jdk1.8以後不是在HashMap的構造方法底層建立數組了,是在第一次調用put方法時建立的數組,Node[] table用來存儲鍵值對數據的。

比方說咱們向哈希表中存儲"斑"-55的數據,根據K值("斑")調用String類中重寫以後的hashCode()方法計算出值(數量級很大),而後結合數組長度採用取餘((n-1)&hash)操做或者其餘操做方法來計算出向Node數組中存儲數據的空間的索引值。若是計算出來的索引空間沒有數據,則直接將"斑"-55數據存儲到數組中。跟上面的"A-王炸"數據差很少。

咱們回到上方的數組圖,若是此時再插入"A-蘑菇"元素,那麼首先根據Key值("A")調用hashCode()方法結合數組長度計算出索引確定也是3,此時比較後存儲的"A-蘑菇"和已經存在的數據"A-王炸"的hash值是否相等,若是hash相等,此時發生hash碰撞。

那麼底層會調用"A"所屬類String中的equals方法比較兩個key內容是否相等,若相等,則後添加的數據直接覆蓋已經存在的Value,也就是"蘑菇"直接覆蓋"王炸";若不相等,繼續向下和其餘數據的key進行比較,若是都不相等,則規劃出一個節點存儲數據。

兩個結點key值比較,是否覆蓋

2.2,哈希碰撞相關的問題

哈希表底層採用何種算法計算hash值?還有哪些算法能夠計算出hash值?

底層是採用key的hashCode方法的值結合數組長度進行無符號右移(>>>)、按位異或(^)、按位與(&)計算出索引的

還能夠採用:平方取中法,取餘數,僞隨機數法。這三種效率都比較低。而無符號右移16位異或運算效率是最高的。

當兩個對象的hashCode相等時會怎麼樣?

會產生哈希碰撞,若key值內容相同則替換舊的value.不然鏈接到鏈表後面,鏈表長度超過閾值8就轉換爲紅黑樹存儲。

什麼時候發生哈希碰撞和什麼是哈希碰撞,如何解決哈希碰撞?

只要兩個元素的key計算的哈希值相同就會發生哈希碰撞。jdk8前使用鏈表解決哈希碰撞。jdk8以後使用鏈表+紅黑樹解決哈希碰撞。

若是兩個鍵的hashcode相同,如何存儲鍵值對?

hashcode相同,經過equals比較內容是否相同。相同:則新的value覆蓋以前的value 不相同:則將新的鍵值對添加到哈希表中

2.3,紅黑樹結構

當位於一個鏈表中的元素較多,即hash值相等可是內容不相等的元素較多時,經過key值依次查找的效率較低。而jdk1.8中,哈希表存儲採用數組+鏈表+紅黑樹實現,當鏈表長度(閥值)超過 8 時且當前數組的長度 > 64時,將鏈表轉換爲紅黑樹,這樣大大減小了查找時間。jdk8在哈希表中引入紅黑樹的緣由只是爲了查找效率更高。

紅黑樹結構

JDK 1.8 之前 HashMap 的實現是 數組+鏈表,即便哈希函數取得再好,也很難達到元素百分百均勻分佈。當 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候 HashMap 就至關於一個單鏈表,假如單鏈表有 n 個元素,遍歷的時間複雜度就是 O(n),徹底失去了它的優點。針對這種狀況,JDK 1.8 中引入了 紅黑樹(查找時間複雜度爲 O(logn))來優化這個問題。當鏈表長度很小的時候,即便遍歷,速度也很是快,可是當鏈表長度不斷變長,確定會對查詢性能有必定的影響,因此才須要轉成樹。

2.4,存儲流程圖

HashMap存放數據是用的put方法,put 方法內部調用的是 putVal() 方法,因此對 put 方法的分析也是對 putVal 方法的分析,整個過程比較複雜,流程圖以下:

來看看put()源碼:

public V put(K key, V value) {
    //對key的hashCode()作hash,調用的是putVal方法
        return putVal(hash(key), key, value, falsetrue);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict)
 
{
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        /*
           1,tab爲空則開始建立,
           2,(tab = table) == null 表示將空的table賦值給tab,而後判斷tab是否等於null,第一次確定是null
           3,(n = tab.length) == 0 表示沒有爲table分配內存
           4,tab爲空,執行代碼 n = (tab = resize()).length; 進行擴容。並將初始化好的數組長度賦值給n.
           5,執行完n = (tab = resize()).length,數組tab每一個空間都是null
        */

       
        if ((tab = table) == null || (n = tab.length) == 0)
            //調用resize()方法進行擴容
            n = (tab = resize()).length;
         /*
        1,i = (n - 1) & hash 表示計算數組的索引賦值給i,即肯定元素存放在哪一個桶中
        2,p = tab[i = (n - 1) & hash]表示獲取計算出的位置的數據賦值給節點p
        3,(p = tab[i = (n - 1) & hash]) == null 判斷節點位置是否等於null,
         若是爲null,則執行代碼:tab[i] = newNode(hash, key, value, null);根據鍵值對建立新的節點放入該位置的桶中
        小結:若是當前桶沒有哈希碰撞衝突,則直接把鍵值對插入空間位置
    */
 
        if ((p = tab[i = (n - 1) & hash]) == null)
            //節點位置爲null,則直接進行插入操做
            tab[i] = newNode(hash, key, value, null);
        //節點位置不爲null,表示這個位置已經有值了,因而須要進行比較hash值是否相等
        else {
            Node<K,V> e; K k;
             /*
          比較桶中第一個元素(數組中的結點)的hash值和key是否相等
               1,p.hash == hash 中的p.hash表示原來存在數據的hash值  hash表示後添加數據的hash值 比較兩個hash值是否相等
               2,(k = p.key) == key :p.key獲取原來數據的key賦值給k key表示後添加數據的key 比較兩個key的地址值是否相等
               3,key != null && key.equals(k):可以執行到這裏說明兩個key的地址值不相等,那麼先判斷後添加的key是否等於null,若是不等於null再調用equals方法判斷兩個key的內容是否相等
        */

            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                 /*
                 說明:兩個元素哈希值相等(哈希碰撞),而且key的值也相等
                 將舊的元素總體對象賦值給e,用e來記錄
                */
 
                e = p;
            // hash值不相等或者key不相等;判斷p是否爲紅黑樹結點
            else if (p instanceof TreeNode)
                // 是紅黑樹,調用樹的插入方法
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 說明是鏈表節點,這時進行插入操做
            else {
                /*
                1,若是是鏈表的話須要遍歷到最後節點而後插入
                2,採用循環遍歷的方式,判斷鏈表中是否有重複的key
                */

                for (int binCount = 0; ; ++binCount) {
                    /*
                 1)e = p.next 獲取p的下一個元素賦值給e
                 2)(e = p.next) == null 判斷p.next是否等於null,等於null,說明p沒有下一個元     素,那麼此時到達了鏈表的尾部,尚未找到重複的key,則說明HashMap沒有包含該鍵
                 將該鍵值對插入鏈表中
                */

                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //插入後發現鏈表長度大於8,轉換成紅黑樹結構
                        if (binCount >= TREEIFY_THRESHOLD - 1
                            //轉換爲紅黑樹
                            treeifyBin(tab, hash);
                        break;
                    }
                    //key值以及存在直接覆蓋value
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //若結點爲null,則不進行插入操做
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //修改記錄次數
        ++modCount;
        // 判斷實際大小是否大於threshold閾值,若是超過則擴容
        if (++size > threshold)
            resize();
        // 插入後回調
        afterNodeInsertion(evict);
        return null;
    }
小結:
  1. 根據哈希表中元素個數肯定是 擴容仍是樹形化
  2. 若是是樹形化遍歷桶中的元素,建立相同個數的樹形節點,複製內容,創建起聯繫
  3. 而後讓桶中的第一個元素指向新建立的樹根節點,替換桶的鏈表內容爲樹形化內容

3,HashMap的擴容機制

咱們知道,數組的容量是有限的,屢次插入數據的話,到達必定數量就會進行擴容;先來看兩個問題

何時須要擴容?

當HashMap中的元素個數超過數組長度loadFactor(負載因子)時,就會進行數組擴容,loadFactor的默認值是0.75,這是一個折中的取值。也就是說,默認狀況下,數組大小爲16,那麼當HashMap中的元素個數超過16×0.75=12(這個值就是閾值)的時候,就把數組的大小擴展爲2×16=32,即擴大一倍,而後從新計算每一個元素在數組中的位置,而這是一個很是耗性能的操做,因此若是咱們已經預知HashMap中元素的個數,那麼預知元素的個數可以有效的提升HashMap的性能。

怎麼進行擴容的?

HashMap在進行擴容時使用 resize() 方法,計算 table 數組的新容量和 Node 在新數組中的新位置,將舊數組中的值複製到新數組中,從而實現自動擴容。由於每次擴容都是翻倍,與原來計算的 (n-1)&hash的結果相比,只是多了一個bit位,因此節點要麼就在原來的位置,要麼就被分配到"原位置+舊容量"這個位置。

所以,咱們在擴充HashMap的時候,不須要從新計算hash,只須要看看原來hash值新增的那個bit是1仍是0就能夠了,是0的話索引沒變,是1的話索引變成「原索引+oldCap(原位置+舊容量)」。這裏再也不詳細贅述,能夠看看下圖爲16擴充爲32的resize示意圖:

hashmap擴容

4,HashMap數組長度爲何是2的次冪

咱們先看看它的成員變量:

序列化版本號

private static final long serialVersionUID = 362498820763181265L;

集合的初始化容量initCapacity

//默認的初始容量是16 -- 1<<4至關於1*2的4次方---1*16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   

初始化容量默認是16,容量過大,遍歷時會減慢速度,效率低;容量太小,那麼擴容的次數變多,很是耗費性能。

負載因子

/**
     * The load factor used when none specified in constructor.
     */

    static final float DEFAULT_LOAD_FACTOR = 0.75f;

初始默認值爲0.75,若過大,會致使哈希衝突的可能性更大;若太小,擴容的次數也會提升。

爲何必須是2的n次冪?

當向HashMap中添加一個元素的時候,須要根據key的hash值,去肯定其在數組中的具體位置。HashMap爲了提升存取效率,要儘可能較少碰撞,就是要儘可能把數據分配均勻,每一個鏈表長度大體相同,這個實現就在把數據存到哪一個鏈表中的算法。

這個算法實際就是取模,hash%length,計算機中直接求餘效率不如位移運算。因此源碼中作了優化,使用 hash&(length-1),而實際上hash%length等於hash&(length-1)的前提是length是2的n次冪。

若是輸入值不是2的冪會怎麼樣?

若是數組長度不是2的n次冪,計算出的索引特別容易相同,及其容易發生hash碰撞,致使其他數組空間很大程度上並無存儲數據,鏈表或者紅黑樹過長,效率下降。

小結:

1,當根據key的hash肯定其在數組的位置時,若是n爲2的冪次方能夠保證數據的均勻插入,若是n不是2的冪次方,可能數組的一些位置永遠不會插入數據,浪費數組的空間,加大hash衝突。

2,通常可能會想經過 % 求餘來肯定位置,這樣也能夠,只不過性能不如 & 運算。並且當n是2的冪次方時:hash & (length - 1) == hash % length

3,所以,HashMap 容量爲2次冪的緣由,就是爲了數據的的均勻分佈,減小hash衝突,畢竟hash衝突越大,表明數組中一個鏈的長度越大,這樣的話會下降hashmap的性能


好了,今天就先分享到這裏了,下期繼續給你們帶來HashMap面試內容!更多幹貨、優質文章,歡迎關注個人原創技術公衆號~


文章好看點這裏


本文分享自微信公衆號 - 程序員的時光(gh_9211ec727426)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索