Java HashMap工作原理及實現

2021-02-08 程序源


來源: Yikun

連結:http://yikun.github.io/2015/04/01/Java-HashMap%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86%E5%8F%8A%E5%AE%9E%E7%8E%B0/


1. 概述


從本文你可以學習到:


什麼時候會使用HashMap?他有什麼特點?

你知道HashMap的工作原理嗎?

你知道get和put的原理嗎?equals()和hashCode()的都有什麼作用?

你知道hash的實現嗎?為什麼要這樣實現?

如果HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?


當我們執行下面的操作時:


HashMap<String, Integer> map = new HashMap<String, Integer>();

map.put("語文", 1);

map.put("數學", 2);

map.put("英語", 3);

map.put("歷史", 4);

map.put("政治", 5);

map.put("地理", 6);

map.put("生物", 7);

map.put("化學", 8);

for(Entry<String, Integer> entry : lmap.entrySet()) {

    System.out.println(entry.getKey() + ": " + entry.getValue());

}


運行結果是


政治: 5

生物: 7

歷史: 4

數學: 2

化學: 8

語文: 1

英語: 3

地理: 6


發生了什麼呢?下面是一個大致的結構,希望我們對HashMap的結構有一個感性的認識:



在官方文檔中是這樣描述HashMap的:


Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.


幾個關鍵的信息:基於Map接口實現、允許null鍵/值、非同步、不保證有序(比如插入的順序)、也不保證序不隨時間變化。


2. 兩個重要的參數


在HashMap中有兩個很重要的參數,容量(Capacity)和負載因子(Load factor)


Initial capacity The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created.

Load factor The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.


簡單的說,Capacity就是bucket的大小,Load factor就是bucket填滿程度的最大比例。如果對迭代性能要求很高的話不要把capacity設置過大,也不要把load factor設置過小。當bucket中的entries的數目大於capacity*load factor時就需要調整bucket的大小為當前的2倍。


3. put函數的實現


put函數大致的思路為:


對key的hashCode()做hash,然後再計算index;

如果沒碰撞直接放到bucket裡;

如果碰撞了,以鍊表的形式存在buckets後;

如果碰撞導致鍊表過長(大於等於TREEIFY_THRESHOLD),就把鍊表轉換成紅黑樹;

如果節點已經存在就替換old value(保證key的唯一性)

如果bucket滿了(超過load factor*current capacity),就要resize。


具體代碼的實現如下:


public V put(K key, V value) {

    // 對key的hashCode()做hash

    return putVal(hash(key), key, value, false, true);

}

 

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

               boolean evict) {

    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // tab為空則創建

    if ((tab = table) == null || (n = tab.length) == 0)

        n = (tab = resize()).length;

    // 計算index,並對null做處理

    if ((p = tab[i = (n - 1) & hash]) == null)

        tab[i] = newNode(hash, key, value, null);

    else {

        Node<K,V> e; K k;

        // 節點存在

        if (p.hash == hash &&

            ((k = p.key) == key || (key != null && key.equals(k))))

            e = p;

        // 該鏈為樹

        else if (p instanceof TreeNode)

            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // 該鏈為鍊表

        else {

            for (int binCount = 0; ; ++binCount) {

                if ((e = p.next) == null) {

                    p.next = newNode(hash, key, value, null);

                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

                        treeifyBin(tab, hash);

                    break;

                }

                if (e.hash == hash &&

                    ((k = e.key) == key || (key != null && key.equals(k))))

                    break;

                p = e;

            }

        }

        // 寫入

        if (e != null) { // existing mapping for key

            V oldValue = e.value;

            if (!onlyIfAbsent || oldValue == null)

                e.value = value;

            afterNodeAccess(e);

            return oldValue;

        }

    }

    ++modCount;

    // 超過load factor*current capacity,resize

    if (++size > threshold)

        resize();

    afterNodeInsertion(evict);

    return null;

}


4. get函數的實現


在理解了put之後,get就很簡單了。大致思路如下:


bucket裡的第一個節點,直接命中;

如果有衝突,則通過key.equals(k)去查找對應的entry

若為樹,則在樹中通過key.equals(k)查找,O(logn);

若為鍊表,則在鍊表中通過key.equals(k)查找,O(n)。


具體代碼的實現如下:


public V get(Object key) {

    Node<K,V> e;

    return (e = getNode(hash(key), key)) == null ? null : e.value;

}

 

final Node<K,V> getNode(int hash, Object key) {

    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

    if ((tab = table) != null && (n = tab.length) > 0 &&

        (first = tab[(n - 1) & hash]) != null) {

        // 直接命中

        if (first.hash == hash && // always check first node

            ((k = first.key) == key || (key != null && key.equals(k))))

            return first;

        // 未命中

        if ((e = first.next) != null) {

            // 在樹中get

            if (first instanceof TreeNode)

                return ((TreeNode<K,V>)first).getTreeNode(hash, key);

            // 在鍊表中get

            do {

                if (e.hash == hash &&

                    ((k = e.key) == key || (key != null && key.equals(k))))

                    return e;

            } while ((e = e.next) != null);

        }

    }

    return null;

}


5. hash函數的實現


在get和put的過程中,計算下標時,先對hashCode進行hash操作,然後再通過hash值進一步計算下標,如下圖所示:



在對hashCode()計算hash時具體實現是這樣的:


static final int hash(Object key) {

    int h;

    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}


可以看到這個函數大概的作用就是:高16bit不變,低16bit和高16bit做了一個異或。其中代碼注釋是這樣寫的:


Computes key.hashCode() and spreads (XORs) higher bits of hash to lower. Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide. (Among known examples are sets of Float keys holding consecutive whole numbers in small tables.) So we apply a transform that spreads the impact of higher bits downward. There is a tradeoff between speed, utility, and quality of bit-spreading. Because many common sets of hashes are already reasonably distributed (so don’t benefit from spreading), and because we use trees to handle large sets of collisions in bins, we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage, as well as to incorporate impact of the highest bits that would otherwise never be used in index calculations because of table bounds.


在設計hash函數時,因為目前的table長度n為2的冪,而計算下標的時候,是這樣實現的(使用&位操作,而非%求餘):


(n - 1) & hash


設計者認為這方法很容易發生碰撞。為什麼這麼說呢?不妨思考一下,在n – 1為15(0×1111)時,其實散列真正生效的只是低4bit的有效位,當然容易碰撞了。


因此,設計者想了一個顧全大局的方法(綜合考慮了速度、作用、質量),就是把高16bit和低16bit異或了一下。設計者還解釋到因為現在大多數的hashCode的分布已經很不錯了,就算是發生了碰撞也用O(logn)的tree去做了。僅僅異或一下,既減少了系統的開銷,也不會造成的因為高位沒有參與下標的計算(table長度比較小時),從而引起的碰撞。


如果還是產生了頻繁的碰撞,會發生什麼問題呢?作者注釋說,他們使用樹來處理頻繁的碰撞(we use trees to handle large sets of collisions in bins),在JEP-180中,描述了這個問題:


Improve the performance of java.util.HashMap under high hash-collision conditions byusing balanced trees rather than linked lists to store map entries. Implement the same improvement in the LinkedHashMap class.


之前已經提過,在獲取HashMap的元素時,基本分兩步:


首先根據hashCode()做hash,然後確定bucket的index;

如果bucket的節點的key不是我們需要的,則通過keys.equals()在鏈中找。


在Java 8之前的實現中是用鍊表解決衝突的,在產生碰撞的情況下,進行get時,兩步的時間複雜度是O(1)+O(n)。因此,當碰撞很厲害的時候n很大,O(n)的速度顯然是影響速度的。


因此在Java 8中,利用紅黑樹替換鍊表,這樣複雜度就變成了O(1)+O(logn)了,這樣在n很大的時候,能夠比較理想的解決這個問題,在Java 8:HashMap的性能提升一文中有性能測試的結果。


6. resize的實現


當put時,如果發現目前的bucket佔用程度已經超過了Load Factor所希望的比例,那麼就會發生resize。在resize的過程,簡單的說就是把bucket擴充為2倍,之後重新計算index,把節點再放到新的bucket中。resize的注釋是這樣描述的:


Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.


大致意思就是說,當超過限制的時候會resize,然而又因為我們使用的是2次冪的擴展(指長度擴為原來2倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。


怎麼理解呢?例如我們從16擴展為32時,具體的變化如下所示:



因此元素在重新計算hash之後,因為n變為2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:



因此,我們在擴充HashMap的時候,不需要重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成「原索引+oldCap」。可以看看下圖為16擴充為32的resize示意圖:



這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。


下面是代碼的具體實現:


final Node<K,V>[] resize() {

    Node<K,V>[] oldTab = table;

    int oldCap = (oldTab == null) ? 0 : oldTab.length;

    int oldThr = threshold;

    int newCap, newThr = 0;

    if (oldCap > 0) {

        // 超過最大值就不再擴充了,就只好隨你碰撞去吧

        if (oldCap >= MAXIMUM_CAPACITY) {

            threshold = Integer.MAX_VALUE;

            return oldTab;

        }

        // 沒超過最大值,就擴充為原來的2倍

        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&

                 oldCap >= DEFAULT_INITIAL_CAPACITY)

            newThr = oldThr << 1; // double threshold

    }

    else if (oldThr > 0) // initial capacity was placed in threshold

        newCap = oldThr;

    else {               // zero initial threshold signifies using defaults

        newCap = DEFAULT_INITIAL_CAPACITY;

        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

    }

    // 計算新的resize上限

    if (newThr == 0) {

 

        float ft = (float)newCap * loadFactor;

        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?

                  (int)ft : Integer.MAX_VALUE);

    }

    threshold = newThr;

    @SuppressWarnings({"rawtypes","unchecked"})

        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

    table = newTab;

    if (oldTab != null) {

        // 把每個bucket都移動到新的buckets中

        for (int j = 0; j < oldCap; ++j) {

            Node<K,V> e;

            if ((e = oldTab[j]) != null) {

                oldTab[j] = null;

                if (e.next == null)

                    newTab[e.hash & (newCap - 1)] = e;

                else if (e instanceof TreeNode)

                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                else { // preserve order

                    Node<K,V> loHead = null, loTail = null;

                    Node<K,V> hiHead = null, hiTail = null;

                    Node<K,V> next;

                    do {

                        next = e.next;

                        // 原索引

                        if ((e.hash & oldCap) == 0) {

                            if (loTail == null)

                                loHead = e;

                            else

                                loTail.next = e;

                            loTail = e;

                        }

                        // 原索引+oldCap

                        else {

                            if (hiTail == null)

                                hiHead = e;

                            else

                                hiTail.next = e;

                            hiTail = e;

                        }

                    } while ((e = next) != null);

                    // 原索引放到bucket裡

                    if (loTail != null) {

                        loTail.next = null;

                        newTab[j] = loHead;

                    }

                    // 原索引+oldCap放到bucket裡

                    if (hiTail != null) {

                        hiTail.next = null;

                        newTab[j + oldCap] = hiHead;

                    }

                }

            }

        }

    }

    return newTab;

}


7. 總結


我們現在可以回答開始的幾個問題,加深對HashMap的理解:


1. 什麼時候會使用HashMap?他有什麼特點?


是基於Map接口的實現,存儲鍵值對時,它可以接收null的鍵值,是非同步的,HashMap存儲著Entry(hash, key, value, next)對象。


2. 你知道HashMap的工作原理嗎?


通過hash的方法,通過put和get存儲和獲取對象。存儲對象時,我們將K/V傳給put方法時,它調用hashCode計算hash從而得到bucket位置,進一步存儲,HashMap會根據當前bucket的佔用情況自動調整容量(超過Load Facotr則resize為原來的2倍)。獲取對象時,我們將K傳給get,它調用hashCode計算hash從而得到bucket位置,並進一步調用equals()方法確定鍵值對。如果發生碰撞的時候,Hashmap通過鍊表將產生碰撞衝突的元素組織起來,在Java 8中,如果一個bucket中碰撞衝突的元素超過某個限制(默認是8),則使用紅黑樹來替換鍊表,從而提高速度。


3. 你知道get和put的原理嗎?equals()和hashCode()的都有什麼作用?


通過對key的hashCode()進行hashing,並計算下標( n-1 & hash),從而獲得buckets的位置。如果產生碰撞,則利用key.equals()方法去鍊表或樹中去查找對應的節點


4. 你知道hash的實現嗎?為什麼要這樣實現?


在Java 1.8的實現中,是通過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,這麼做可以在bucket的n比較小的時候,也能保證考慮到高低bit都參與到hash的計算中,同時不會有太大的開銷。


5. 如果HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?


如果超過了負載因子(默認0.75),則會重新resize一個原來長度兩倍的HashMap,並且重新調用hash方法。


關於Java集合的小抄中是這樣描述的:


以Entry[]數組實現的哈希桶數組,用Key的哈希值取模桶數組的大小可得到數組下標。


插入元素時,如果兩條Key落在同一個桶(比如哈希值1和17取模16後都屬於第一個哈希桶),Entry用一個next屬性實現多個Entry以單向鍊表存放,後入桶的Entry將next指向桶當前的Entry。


查找哈希值為17的key時,先定位到第一個哈希桶,然後以鍊表遍歷桶裡所有元素,逐個比較其key值。


當Entry數量達到桶數量的75%時(很多文章說使用的桶數量達到了75%,但看代碼不是),會成倍擴容桶數組,並重新分配所有原來的Entry,所以這裡也最好有個預估值。


取模用位運算(hash & (arrayLength-1))會比較快,所以數組的大小永遠是2的N次方, 你隨便給一個初始值比如17會轉為32。默認第一次放入元素時的初始值是16。


iterator()時順著哈希桶數組來遍歷,看起來是個亂序。


在JDK8裡,新增默認為8的閥值,當一個桶裡的Entry超過閥值,就不以單向鍊表而以紅黑樹來存放以加快Key的查找速度。


參考資料

HashMap的工作原理

Java 8:HashMap的性能提升

JEP 180: Handle Frequent HashMap Collisions with Balanced Trees

ConurrentHashMap和Hashtable的區別

HashMap和Hashtable的區別


丨往期精選丨



請添加小編微信:2518988391(備註崗位)

相關焦點

  • Java面試高頻考點:HashMap的底層原理
    作為一個Java開發工程師,在面試的過程中,最高頻被問到的一個問題就是:「請簡述一下HashMap的實現原理」,在日常開發中,大多數程式設計師只會使用,對於其實現細節,卻不了解,殊不知這是較基礎卻也最重要的知識點。這篇文章將向大家詳細解釋hashmap的底層到底做了哪些事情。
  • 來複習一波,HashMap底層實現原理解析
    那麼我們能不能把以上兩種結合一起使用,從而實現查詢效率高和插入刪除效率也高的數據結構呢?答案是可以滴,那就是哈希表可以滿足,接下來我們一起複習HashMap中的put()和get()方法實現原理。HashMap的put()和get()的實現1、map.put(k,v)實現原理第一步
  • java中HashMap原理?面試?你是誰,你在哪?
    是一個散列桶(數組和鍊表),它存儲的內容是鍵值對(key-value)映射HashMap採用了數組和鍊表的數據結構,能在查詢和修改方便繼承了數組的線性查找和鍊表的尋址修改HashMap是非synchronized,所以HashMap很快HashMap可以接受null鍵和值,而Hashtable則不能(原因就是equlas()方法需要對象,因為HashMap是後出的API經過處理才可以)2、HashMap的工作原理是什麼
  • 高並發編程系列:ConcurrentHashMap的實現原理(JDK1.7和JDK1.8)
    HashMap、CurrentHashMap 的實現原理基本都是BAT面試必考內容,阿里P8架構師談:深入探討HashMap的底層結構、原理、擴容機制深入談過hashmap的實現原理以及在JDK 1.8的實現區別,今天主要談CurrentHashMap的實現原理,以及在JDK1.7和1.8的區別。
  • Java面試必問之Hashmap底層實現原理(JDK1.7)
    Hashmap底層實現原理(get\put\resize)Hashmap怎麼解決hash衝突?Hashmap是線程安全的嗎?綜上所述,可以得出:Hashmap底層是基於數組和鍊表實現的3.hash運算值是一個int整形值,在java中int佔4個字節,32位,下邊通過圖示來說明位運算。
  • Java:從 Map 到 HashMap 的一步步實現!
    鍵和值之間通過 Hash函數 來實現映射關係。當進行遍歷的 key 是無序的TreeMap:使用紅黑樹構建的數據結構,因為紅黑樹的原理,可以很自然的對 key 進行排序,所以 TreeMap 的 key 遍歷時是默認按照自然順序(升序)排列的。LinkedHashMap: 保存了插入的順序。遍歷得到的記錄是按照插入順序的。
  • Java類隔離加載實現原理是什麼?
    Java類隔離加載實現原理是什麼? JVM 提供一個全局類加載器的設置接口,直接替換全局類加載器,但無法解決多個自定義類加載器同時存在的問題。然而JVM會選擇當前類的類加載器來加載所有該類的引用的類。類隔離技術是什麼?
  • 阿里java面試被pass後,奮戰1個月,最終拿下美團offer!
    阿里mq 消息可靠性,冪等如何保證分布式鎖的實現方案比較,為什麼選擇 zookeeper, zookeeper 一致性協議原理線程池參數,阻塞隊列實現一致性 Hash解決什麼問題, 如何實現? 虛擬節點的作用?Java 鎖的實現方式, 比較? AQS實現原理?公平非公平實現原理?
  • 教你用 Python 實現 HashMap 數據結構
    今天這篇文章給大家講講hashmap,這個號稱是所有Java工程師都會的數據結構。為什麼說是所有Java工程師都會呢,因為很簡單,他們不會這個找不到工作。幾乎所有面試都會問,基本上已經成了標配了。在今天的這篇文章當中我們會揭開很多謎團。
  • HashMap是如何工作的
    作者:阿進的寫字檯主頁:www.cnblogs.com/homejim一、HashMap在JAVA中的怎麼工作的?基於Hash的原理二、什麼是哈希?最簡單形式的 hash,是一種在對任何變量/對象的屬性應用任何公式/算法後, 為其分配唯一代碼的方法。
  • 阿里螞蟻金服Java程式設計師面試的11個問題,你會幾個呢?
    比如阿里巴巴java面經、小米java面經、網易java面經等,吸引了大多數的程式設計師們的圍觀。可謂是一次總結就能實現多次利用的效果。比如面向對象基本知識,這幾乎是面試必考的,比如什麼是類,繼承,多態等等。面向對象的特徵:抽象、繼承、封裝、多態常見算法的應用,包括算法基礎和Java編程實現。
  • 一文搞懂WeakHashMap工作原理(java後端面試高薪必備知識點)
    這個問題是一個高頻面試題,本篇文章將從概念、原理、實際使用的角度來分析。希望對你有幫助:一、什麼是WeakHashMap?下面我們就來看看,WeakHashMap是如何實現這些功能。二、WeakHashMap工作原理1、WeakHashMap為什麼具有弱引用的特點:隨時被回收對象這個問題就比較簡單了,我們的目的主要是驗證。
  • Java ArrayList 工作原理及實現
    關於Java集合小抄中的描述:http://calvin1978.blogcn.com/articles/collection.html以數組實現具體實現如下:public boolean add(E e) {    ensureCapacityInternal(size + 1);      elementData[size++] = e;    return true;}我們可以看到他的實現其實最核心的內容就是
  • 搞定HashMap面試,深入講解HashMap的工作原理
    摘要:HashMap是近幾年java面試新秀,出場率高達80%以上,如此高頻的出場不得不讓碼農們慎重其事。但依舊拜倒在它的石榴裙下,讓面試場面一度尷尬。它也是開發中最常用到的key-value數據類型。
  • HashMap的工作原理
    幾乎每個Java程式設計師都知道HashMap,都知道哪裡要用HashMap,知道HashTable和HashMap之間的區別,那麼為何這道面試題如此特殊呢?是因為這道題考察的深度很深。這題經常出現在高級或中高級面試中。投資銀行更喜歡問這個問題,甚至會要求你實現HashMap來考察你的編程能力。ConcurrentHashMap和其它同步集合的引入讓這道題變得更加複雜。讓我們開始探索的旅程吧!
  • Java 8 Lambda 的實現原理及源碼剖析
    在沒有深入分析前,讓我們先想一想,Java 8中每一個Lambda表達式必須有一個函數式接口與之對應,函數式接口與普通接口的區別,可以參考前面的內容,那麼你或許在想Lambda表達式是不是轉化成與之對應的函數式接口的一個實現類呢,然後通過多態的方式調用子類的實現呢,如下面代碼是一個Lambda表達式的樣例:@FunctionalInterfaceinterface
  • Watchdog實現原理
    本文旨在介紹Watchdog的工作原理,讓大家有一個理性認識。Watchdog工作原理先看一下Watchdog的總體框圖: 我們以AMS為例,看看Watchdog是如何監測它是否死鎖的,先看ActivityManagerService.java類做了哪些特殊處理:[frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java]public final class ActivityManagerService
  • Java程式設計師需要什麼學歷才能找到工作?學歷低怎麼辦?
    高學歷的人學習能力強,學習IT網際網路行業裡的東西不是什麼難事,而且學歷高相對低學歷的人來說是很好找工作的。高中及以下學歷,現在非常非常難找到工作,可能有人會說見過小學學歷的人在阿里等大廠工作,但是你要考慮幾點,這個小學學歷的人:是什麼時候開始學習編程的當時企業對技術的要求是怎樣的工作經驗有多久
  • Open Clustering HashMap實現原理:ThreadLocalMap中HashMap實現原理的深度剖析
    一、前言看到標題大家都應該覺得奇怪,我們去面試被問到HashMap的實現,大家不都是說的基於數組+鍊表的方式麼。
  • Java之FileFilter過濾器的使用與及原理的簡單介紹
    2.File[] listFiles(FilenameFilter filter)java.io.FilenameFilter接口:實現此接口的類實例可用於過濾文件名作用:用於過濾文件名稱抽象方法參數:File dir:構造方法中傳遞的被遍歷的目錄String name:使用ListFiles方法遍歷目錄,獲取的每一個文件夾/文件夾的名稱注意:兩個過濾接口是沒有實現類的,需要我們自己寫實現類,重寫過濾的方法accept,在方法中自己定義過濾的規則