【數據結構】HashMap原理及實現學習總結

HashMap是Java中最常用的集合類框架之一,是Java語言中非常典型的數據結構。本篇主要是從HashMap的工作原理,數據結構分析,HashMap存儲和讀取幾個方面對其進行學習總結。關於HashMap的完整源碼分析請查看下一篇。

一. HashMap的工作原理

HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取對象。當我們將鍵值對傳遞給put()方法時,它調用鍵對象的hashCode()方法來計算hashcode,讓後找到bucket位置來儲存值對象。當獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然後返回值對象。HashMap使用LinkedList來解決碰撞問題,當發生碰撞了,對象將會儲存在LinkedList的下一個節點中。 HashMap在每個LinkedList節點中儲存鍵值對對象。
當兩個不同的鍵對象的hashcode相同時會發生什麼? 它們會儲存在同一個bucket位置的LinkedList中。鍵對象的equals()方法用來找到鍵值對。

二.HashMap的定義

HashMap實現了Map接口,繼承AbstractMap。其中Map接口定義了鍵映射到值的規則,而AbstractMap類提供 Map 接口的骨幹實現,以最大限度地減少實現此接口所需的工作!

public class HashMap<K,V>  
    extends AbstractMap<K,V>  
    implements Map<K,V>, Cloneable, Serializable  

三.HashMap的數據結構

HashMap的底層主要是基於數組和鏈表來實現的,它之所以有相當快的查詢速度主要是因爲它是通過計算散列碼來決定存儲的位置。HashMap中主要是通過key的hashCode來計算hash值的,只要hashCode相同,計算出來的hash值就一樣。如果存儲的對象對多了,就有可能不同的對象所算出來的hash值是相同的,這就出現了所謂的hash衝突。學過數據結構的同學都知道,解決hash衝突的方法有很多,HashMap底層是通過鏈表來解決hash衝突的。
這裏寫圖片描述
紫色部分即代表哈希表,也稱爲哈希數組,數組的每個元素都是一個單鏈表的頭節點,鏈表是用來解決衝突的,如果不同的key映射到了數組的同一位置處,就將其放入單鏈表中。

四.HashMap的構造函數

在這裏提到了兩個參數:初始容量,加載因子。這兩個參數是影響HashMap性能的重要參數,其中容量表示哈希表中桶的數量,初始容量是創建哈希表時的容量,加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度,它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用鏈表法的散列表來說,查找一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查找效率的降低;如果負載因子太小,那麼散列表的數據將過於稀疏,對空間造成嚴重浪費。系統默認負載因子爲0.75,一般情況下我們是無需修改的。當哈希表中的條目數超出了加載因子與當前容量的乘積時,通過調用 rehash 方法將容量翻倍。
HashMap一共重載了4個構造方法,分別爲:

HashMap();//構造一個具有默認初始容量 (16) 和默認加載因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity);//構造一個帶指定初始容量和默認加載因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity, float loadFactor);//構造一個帶指定初始容量和加載因子的空 HashMap。
HashMap(Map<? extendsK,? extendsV> m); //構造一個映射關係與指定 Map 相同的 HashMap。

下面是第三個構造方法源碼,其它構造方法最終調用的都是它:

// 構造一個帶指定初始容量和加載因子的空 HashMap。
    public HashMap(int initialCapacity, float loadFactor) {
        // 如果指定初始容量小於0,拋錯
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
        // 如果初始容量大於系統默認最大容量,則初始容量爲最大容量
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        // 如果loadFactor小於0,或loadFactor是NaN,則拋錯
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

        // 尋找一個2的k次冪capacity恰好大於initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        // 設置加載因子
        this.loadFactor = loadFactor;
        // 設置閾值爲capacity * loadFactor,實際上當HashMap當前size到達這個閾值時,HashMap就需要擴大一倍了。
        threshold = (int) (capacity * loadFactor);
        // 創建一個capacity長度的數組用於保存數據
        table = new Entry[capacity];
        // 開始初始化
        init();
    }

從源碼中可以看出,每次新建一個HashMap時,都會初始化一個table數組。table數組的元素爲Entry節點。

// 內置class輸入對象,也就是我們說的桶
    static class Entry<K, V> implements Map.Entry<K, V> {
        final K key;
        V value;
        Entry<K, V> next;
        final int hash;

        // 構造函數
        Entry(int h, K k, V v, Entry<K, V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        // 返回key
        public final K getKey() {
            return key;
        }

        // 返回value
        public final V getValue() {
            return value;
        }

        // 設置value
        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        // 是否相同
        public final boolean equals(Object o) {
            // 如果o不是Map.Entry的實例,那麼肯定不相同了
            if (!(o instanceof Map.Entry))
                return false;
            // 將o轉成Map.Entry
            Map.Entry e = (Map.Entry) o;
            // 得到key和value對比是否相同,相同則爲true
            Object k1 = getKey();
            Object k2 = e.getKey();
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                Object v1 = getValue();
                Object v2 = e.getValue();
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            }
            // 否則爲false
            return false;
        }

        // hashCode
        public final int hashCode() {
            return (key == null ? 0 : key.hashCode()) ^ (value == null ? 0 : value.hashCode());
        }

        // 返回String
        public final String toString() {
            return getKey() + "=" + getValue();
        }

        // 使用該方法證明該key已經在該map中
        void recordAccess(HashMap<K, V> m) {
        }

        // 該方法記錄該key已經被移除了
        void recordRemoval(HashMap<K, V> m) {
        }
    }

其中Entry爲HashMap的內部類,它包含了鍵key、值value、下一個節點next,以及hash值,這是非常重要的,正是由於Entry才構成了table數組的項爲鏈表。

五.HashMap的存儲實現

HashMap中我們最長用的就是put(K, V)和get(K)。我們都知道,HashMap的K值是唯一的,那如何保證唯一性呢?我們首先想到的是用equals比較,沒錯,這樣可以實現,但隨着內部元素的增多,put和get的效率將越來越低,這裏的時間複雜度是O(n),假如有1000個元素,put時需要比較1000次。實際上,HashMap很少會用到equals方法,因爲其內通過一個哈希表管理所有元素,哈希是通過hash單詞音譯過來的,也可以稱爲散列表,哈希算法可以快速的存取元素,當我們調用put存值時,HashMap首先會調用K的hashCode方法,獲取哈希碼,通過哈希碼快速找到某個存放位置,這個位置可以被稱之爲bucketIndex,通過上面所述hashCode的協定可以知道,如果hashCode不同,equals一定爲false,如果hashCode相同,equals不一定爲true。所以理論上,hashCode可能存在衝突的情況,有個專業名詞叫碰撞,當碰撞發生時,計算出的bucketIndex也是相同的,這時會取到bucketIndex位置已存儲的元素,最終通過equals來比較,equals方法就是哈希碼碰撞時纔會執行的方法,所以前面說HashMap很少會用到equals。HashMap通過hashCode和equals最終判斷出K是否已存在,如果已存在,則使用新V值替換舊V值,並返回舊V值,如果不存在 ,則存放新的鍵值對到bucketIndex位置。整個put過程的流程圖如下:
這裏寫圖片描述

相關源碼如下:

// 在此映射中關聯指定值與指定鍵。如果該映射以前包含了一個該鍵的映射關係,則舊值被替換
    public V put(K key, V value) {
        // 當key爲null,調用putForNullKey方法,保存null與table第一個位置中,這是HashMap允許爲null的原因 
        if (key == null)
            return putForNullKey(value);
        // 使用hash函數預處理hashCode,計算key的hash值  
        int hash = hash(key.hashCode());//-------(1)
        // 計算key hash 值在 table 數組中的位置 
        int i = indexFor(hash, table.length);//------(2)
        // 從i出開始迭代 e,找到 key 保存的位置
        for (Entry<K, V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 判斷該條鏈上是否有hash值相同的(key相同) 
            // 若存在相同,則直接覆蓋value,返回舊value 
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                // 舊值 = 新值  
                V oldValue = e.value;
                // 將要存儲的value存進去
                e.value = value;
                e.recordAccess(this);
                // 返回舊的value
                return oldValue;
            }
        }
        // 修改次數增加1 
        modCount++;
        // 將key、value添加至i位置處 
        addEntry(hash, key, value, i);
        return null;
    }

通過源碼我們可以清晰看到HashMap保存數據的過程爲:首先判斷key是否爲null,若爲null,則直接調用putForNullKey方法。若不爲空則先計算key的hash值,然後根據hash值搜索在table數組中的索引位置,如果table數組在該位置處有元素,則通過比較是否存在相同的key,若存在則覆蓋原來key的value,否則將該元素保存在鏈頭(最先保存的元素放在鏈尾)。若table在該處沒有元素,則直接保存。這個過程看似比較簡單,其實深有內幕。有如下幾點:
1、 先看迭代處。此處迭代原因就是爲了防止存在相同的key值,若發現兩個hash值(key)相同時,HashMap的處理方式是用新value替換舊value,這裏並沒有處理key,這就解釋了HashMap中沒有兩個相同的key。
2、 在看(1)、(2)處。這裏是HashMap的精華所在。首先是hash方法,該方法爲一個純粹的數學計算,就是計算h的hash值。

static int hash(int h) {  
        h ^= (h >>> 20) ^ (h >>> 12);  
        return h ^ (h >>> 7) ^ (h >>> 4);  
    }  
 我們知道對於HashMap的table而言,數據分佈需要均勻(最好每項都只有一個元素,這樣就可以直接找到),不能太緊也不能太鬆,太緊會導致查詢速度慢,太鬆則浪費空間。計算hash值後,怎麼才能保證table元素分佈均與呢?我們會想到取模,但是由於取模的消耗較大,HashMap是這樣處理的:調用indexFor方法。
static int indexFor(int h, int length) {  
        return h & (length-1);  
    } 

HashMap的底層數組長度總是2的n次方,在構造函數中存在:capacity <<= 1;這樣做總是能夠保證HashMap的底層數組長度爲2的n次方。當length爲2的n次方時,h&(length - 1)就相當於對length取模,而且速度比直接取模快得多,這是HashMap在速度上的一個優化。
這裏再來複習put的流程:當我們想一個HashMap中添加一對key-value時,系統首先會計算key的hash值,然後根據hash值確認在table中存儲的位置。若該位置沒有元素,則直接插入。否則迭代該處元素鏈表並依此比較其key的hash值。如果兩個hash值相等且key值相等(e.hash == hash && ((k = e.key) == key || key.equals(k))),則用新的Entry的value覆蓋原來節點的value。如果兩個hash值相等但key值不等 ,則將該節點插入該鏈表的鏈頭。具體的實現過程見addEntry方法,如下:

// 添加一個新的桶來保存該key和value
    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 獲取bucketIndex處的Entry 
        Entry<K, V> e = table[bucketIndex];
        // 將新創建的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry  
        table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
        // 若HashMap中元素的個數超過極限了,則容量擴大兩倍 
        if (size++ >= threshold)
            // 調整容量
            resize(2 * table.length);
    }

這個方法中有兩點需要注意:
一是鏈的產生:系統總是將新的Entry對象添加到bucketIndex處。如果bucketIndex處已經有了對象,那麼新添加的Entry對象將指向原有的Entry對象,形成一條Entry鏈,但是若bucketIndex處沒有Entry對象,也就是e==null,那麼新添加的Entry對象指向null,也就不會產生Entry鏈了。
二是擴容問題:隨着HashMap中元素的數量越來越多,發生碰撞的概率就越來越大,所產生的鏈表長度就會越來越長,這樣勢必會影響HashMap的速度,爲了保證HashMap的效率,系統必須要在某個臨界點進行擴容處理。該臨界點在當HashMap中元素的數量等於table數組長度*加載因子。但是擴容是一個非常耗時的過程,因爲它需要重新計算這些數據在新table數組中的位置並進行復制處理。所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的性能。

六.HashMap的讀取實現

通過key的hash值找到在table數組中的索引處的Entry,然後返回該key對應的value即可。

// 返回指定鍵所映射的值;如果對於該鍵來說,此映射不包含任何映射關係,則返回 null
    public V get(Object key) {
        // 若爲null,調用getForNullKey方法返回相對應的value 
        if (key == null)
            return getForNullKey();
        // 根據該 key 的 hashCode 值計算它的 hash 碼  
        int hash = hash(key.hashCode());
        // 取出 table 數組中指定索引處的值
        for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
            Object k;
            // 如果hash值相等,並且key相等則證明這個桶裏的東西是我們想要的
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        // 所有桶都找遍了,沒找到想要的,所以返回null
        return null;
    }

在這裏能夠根據key快速的取到value除了和HashMap的數據結構密不可分外,還和Entry有莫大的關係,在前面就提到過,HashMap在存儲過程中並沒有將key,value分開來存儲,而是當做一個整體key-value來處理的,這個整體就是Entry對象。同時value也只相當於key的附屬而已。在存儲的過程中,系統根據key的hashcode來決定Entry在table數組中的存儲位置,在取的過程中同樣根據key的hashcode取出相對應的Entry對象。

七.HashMap和多線程相關的問題

HashMap是線程不安全的實現,而HashTable是線程安全的實現。所謂線程不安全,就是在多線程情況下直接使用HashMap會出現一些莫名其妙不可預知的問題,多線程和單線程的區別:單線程只有一條執行路徑,而多線程是併發執行(非並行),會有多條執行路徑。如果HashMap是隻讀的(加載一次,以後只有讀取,不會發生結構上的修改),那使用沒有問題。那如果HashMap是可寫的(會發生結構上的修改),則會引發諸多問題,如上面的fail-fast,也可以看下這裏,這裏就不去研究了。
那在多線程下使用HashMap我們需要怎麼做,幾種方案:
1.在外部包裝HashMap,實現同步機制
2.使用Map m = Collections.synchronizedMap(new HashMap(…));,這裏就是對HashMap做了一次包裝
3.使用java.util.HashTable,效率最低
4.使用java.util.concurrent.ConcurrentHashMap,相對安全,效率較高

[關於HashMap的完整源碼分析請查看下一篇。]

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