1. 程式人生 > >關於HashMap你要知道的事情

關於HashMap你要知道的事情

一、HashMap的定義和重要成員變數

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

熟悉原始碼的童鞋會很奇怪,為啥AbstractMap已經實現了Map介面,HashMap還要再實現一遍呢?為啥呢?我™也不知道……

直接看HashMap的重要成員變數:

int DEFAULT_INITIAL_CAPACITY = 16:預設的初始容量為16 ;
int MAXIMUM_CAPACITY = 1 << 30:最大的容量為 2 ^ 30 ;
float DEFAULT_LOAD_FACTOR = 0.75f:預設的載入因子為 0.75f ;
Entry< K,V>[] table:Entry型別的陣列,HashMap用這個來維護內部的資料結構,它的長度由容量決定 ;
int size:HashMap的大小 ;
int threshold:HashMap的極限容量,擴容臨界點(容量和載入因子的乘積);

這些成員變數灰常灰常重要,在HashMap原始碼內部經常看到。這裡重點介紹一下載入因子這玩意兒:載入因子是雜湊表在其容量自動增加之前可以達到多滿的一種尺度。它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用連結串列法的散列表來說,查詢一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼散列表的資料將過於稀疏,對空間造成嚴重浪費。系統預設負載因子為0.75,一般情況下我們是無需修改的。

關於這個,再說得細一點,之所以採用Hash雜湊進行儲存,主要就是為了提高檢索速度。
眾所周知,有序陣列儲存資料,對資料的檢索效率會很高,但是,插入和刪除會有瓶頸產生。而連結串列儲存資料,通常只能採用逐個比較的方法來檢索資料(查詢資料),但是,插入和刪除的效率很高。
於是,將兩者結合,取長補短,優勢互補一下,就產生雜湊雜湊這種儲存方式。
具體是怎麼樣的呢?
我們可以理解成,在連結串列儲存的基礎上,對連結串列結構進行的一項改進。
我們將一個大的連結串列,拆散成幾個或者幾十個小的連結串列。每個連結串列的表頭,存放到一個數組裡面。這樣,在從大連結串列拆分成小連結串列的時候就有講究了。我們按照什麼規則來將一個大連結串列中的資料,分散存放到不同的連結串列中呢?在計算機當中,肯定是要將規則數量化的,也就是說,這個規則,一定要是個數字,這樣才比較好操作。比如,按照存放時間,每5分鐘一個時間段,將相同時間段存放的資料,放到同一個連結串列裡面;或者,將資料排序,每5個數據形成一個連結串列;等等,等等,還有好多可以想象得到的方法。但是,這些方法都會存在一些不足之處。我們就在想了,如果存放的資料,都是整數就好了。這樣,我可以建立一個固定大小的陣列,比如50個大小,然後,讓資料(整數)對50進行取餘運算,然後,這些資料,自然就會被分成50個連結串列了,每個連結串列可以是無序的,反正連結串列要逐個比較進行查詢。如果,我一個有200個數據,分組後,平均每組也就4個數據,那麼,連結串列比較,平均也就比較4次就好了。但是,實際上,我們存放的資料,通常都不是整數。所以,我們需要將資料物件對映成整數的一個演算法。HashCode方法,應運而生了。每個資料物件,都會對應一個HashCode值,通過HashCode我們可以將物件分組存放到不同的佇列裡。這樣,在檢索的時候,就可以減少比較次數。

在實際使用當中,HashCode方法、陣列的大小 以及 資料物件的數量,這三者對檢索的效能有著巨大的影響。
1.如果陣列大小為1,那和連結串列儲存沒有什麼區別了,而且,還多了一步計算HashCode的時間,所以,陣列不能太小,太小查詢費時間。
2.如果我只存放1個數據物件,陣列又非常大,那麼,陣列所佔的記憶體空間,就比資料物件佔的空間還大,這樣,少量資料物件,巨大的陣列,雖然能夠使檢索速度,但是,浪費了很多記憶體空間。
3.如果所有物件的HashCode值都是相同的數,那麼,無論陣列有多大,這些資料都會儲存到同一個連結串列裡面,一個好的HashCode演算法,可以使存放的資料,有較好的分散性,在實際的實現當中,HashSet和HashMap都對資料物件產生的HashCode進行了二次雜湊處理,使得資料物件具有更好的分散性。

二、HashMap的資料結構

先看程式碼,主要是兩塊:

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

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

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

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }
transient Node<K,V>[] table;

沒錯,其實HashMap的底層就是一個數組,陣列的元素是一個單鏈表。用圖看更清晰:
這裡寫圖片描述
Node繼承自Entry是HashMap的一個內部類,它也是維護著一個key-value對映關係,除了key和value,還有next引用(該引用指向當前table位置的連結串列),hash值(用來確定每一個Entry連結串列在table中位置)。
再看下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;
    //載入因子不能<=0 或者 為非數字
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

    //計算出大於初始容量的最小 2的n次方作為雜湊表table的長度,下面會說明為什麼要這樣
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;

    this.loadFactor = loadFactor;
    //設定HashMap的容量極限,當HashMap的容量達到該極限時就會進行擴容操作
    threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    //建立Entry陣列
    table = new Entry[capacity];
    useAltHashing = sun.misc.VM.isBooted() &&
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    init();
}

這個建構函式主要做的事情就是:
1. 對傳入的 容量 和 載入因子進行判斷處理
2. 設定HashMap的容量極限
3. 計算出大於初始容量的雜湊表table的長度,這個長度一定是2的次方,並且當次方-1時就小於這個初始容量,有點繞,舉個例子來說:初始長度為10,那長度就是16,因為2的3次方為8小於10。 然後用該長度建立Entry陣列(table),這個是最核心的。為什麼一定要是二的次方呢?這裡直接說結論:當length = 2^n時,不同的hash值發生碰撞的概率比較小,這樣就會使得資料在table陣列中分佈較均勻,查詢速度也較快。 原因會在下文中進行測試說明。

三、put

public V put(K key, V value) {
    //如果key為空的情況
    if (key == null)
        return putForNullKey(value);
    //計算key的hash值
    int hash = hash(key);
    //計算該hash值在table中的下標
    int i = indexFor(hash, table.length);
    //對table[i]存放的連結串列進行遍歷
    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;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    //修改次數+1
    modCount++;
    //把當前key,value新增到table[i]的連結串列中
    addEntry(hash, key, value, i);
    return null;
}

看原始碼可以知道HashMap是可以把null當做key的,看下putForNullKey方法:

    private V putForNullKey(V value) {
        //查詢連結串列中是否有null鍵
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //如果鏈中查詢不到,則把該null鍵插入
        addEntry(0, null, value, 0);
        return null;
    }

可以看到HashMap預設把null鍵的Entry放在陣列的0位置,因為null無法獲得hash值。下面看addEntry方法:

    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            //這一步就是對null的處理,如果key為null,hash值為0,也就是會插入到雜湊表的表頭table[0]的位置
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

這裡出現了幾個非常重要的方法,也是hashMap最核心的原理所在。

1.hash方法和indexFor方法:

final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }

        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    static int indexFor(int h, int length) {
        return h & (length-1);
    }

hash方法沒啥可說的,就是對key值進行hash演算法獲得一個hash值,hash演算法請自行百度,這是一個數學方法,沒興趣的同學也可以直接略過,只要記住hash演算法的目的就一個,使hash後的那個int值儘量分散。重點看indexFor,其實很簡單就是拿當前key的hash值與HashMap的長度-1進行與操作,其本質就是個取模的操作,使用與運算效率要比%高得多。好,我們前面說了HashMap的長度一定是2的次方,那2的次方-1滿足什麼條件呢?
2^1 - 1 = 0x1;
2^2 - 1 = 0x11;
2^3 - 1 = 0x111;
2^4 - 1 = 0x1111;
……
我們寫個程式碼簡單測試一下就知道這個方法的意義何在了:

    public static void main(String[] args)
    {   
        int length = 16;

        for(int i = 1; i <= 32; i++)
        {
            System.out.print((i & (length - 1)) + ", ");
        }
    }

當length = 16時,輸出:
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0
當length = 15時,輸出:
0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 0,
明顯後者碰撞的概率要大得多,同時因為15 - 1 = 0x1110,所以無論hash為多少最後一位都會被與成0,導致最後一位為1的空間永遠無法得到利用。

2.resize方法

    void resize(int newCapacity) {
         Entry[] oldTable = table;
         int oldCapacity = oldTable.length;
         if (oldCapacity == MAXIMUM_CAPACITY) {
             threshold = Integer.MAX_VALUE;
             return;
         }

         //建立一個新的 Hash 表
         Entry[] newTable = new Entry[newCapacity];

         transfer(newTable, initHashSeedAsNeeded(newCapacity));
         table = newTable;
         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
     }

     void transfer(Entry[] newTable, boolean rehash) {
         int newCapacity = newTable.length;
         for (Entry<K,V> e : table) {
             while(null != e) {
                 Entry<K,V> next = e.next;
                 if (rehash) {
                     e.hash = null == e.key ? 0 : hash(e.key);
                 }
                 int i = indexFor(e.hash, newCapacity);
                 e.next = newTable[i];
                 newTable[i] = e;
                 e = next;
             }
         }
     }

還記得HashMap中的一個變數嗎,threshold,這是容器的容量極限,還有一個變數size,這是指HashMap中鍵值對的數量,也就是node的數量
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
什麼時候發生擴容?
當不斷新增key-value,size大於了容量極限threshold時,會發生擴resize。
resize這裡是個大坑,因為會導致死鎖,而根本原因來自transfer方法,這個方法乾的事情是把原來數組裡的1-2-3連結串列transfer成了3-2-1,不太看得懂的同學可以看我下面的演示程式碼,本質是一樣的:

package com.amuro.studyhashmap;

public class HashMapStudy
{
    public static void main(String[] args)
    {
        Node n1 = new Node();
        n1.data = 1;

        Node n2 = new Node();
        n2.data = 2;

        Node n3 = new Node();
        n3.data = 3;

        n1.next = n2;
        n2.next = n3;

        printLinkedNode(n1);
        System.out.println("------");

        Node newHead = mockHashMapTransfer(n1);
        printLinkedNode(newHead);
    }

    static class Node
    {
        int data;
        Node next;
    }

    static void printLinkedNode(Node head)
    {

        while(head != null)
        {
            System.out.println(head.data);
            head = head.next;
        }
    }

    static Node mockHashMapTransfer(Node e)
    {
        Node newHead = null;

        while(e != null)
        {

            Node next = e.next;
            e.next = newHead;
            newHead = e;
            e = next;
            System.out.print("");
        }

        return newHead;
    }

}

結果輸出:
1
2
3
_ _
3
2
1
transfer方法的本質就是這個,那為什麼會導致死鎖呢?簡單分析一下:
我們假設有兩個執行緒T1、T2,HashMap容量為2,T1執行緒放入key A、B、C、D、E。在T1執行緒中A、B、C Hash值相同,於是形成一個連結,假設為A->C->B,而D、E Hash值不同,於是容量不足,需要新建一個更大尺寸的hash表,然後把資料從老的Hash表中遷移到新的Hash表中(refresh)。這時T2程序闖進來了,T1暫時掛起,T2程序也準備放入新的key,這時也發現容量不足,也refresh一把。refresh之後原來的連結串列結構假設為C->A,之後T1程序繼續執行,連結結構為A->C,這時就形成A.next=B,B.next=A的環形連結串列。一旦取值進入這個環形連結串列就會陷入死迴圈。
所以多執行緒場景下,建議使用ConcurrentHashMap,用到了分段鎖的技術,後面有機會再講。

最後整理一下put的步驟:
1. 傳入key和value,判斷key是否為null,如果為null,則呼叫putForNullKey,以null作為key儲存到雜湊表中;
2. 然後計算key的hash值,根據hash值搜尋在雜湊表table中的索引位置,若當前索引位置不為null,則對該位置的Entry連結串列進行遍歷,如果鏈中存在該key,則用傳入的value覆蓋掉舊的value,同時把舊的value返回,結束;
3. 否則呼叫addEntry,用key-value建立一個新的節點,並把該節點插入到該索引對應的連結串列的頭部。

四、get

    public V get(Object key) {
        //如果key為null,求null鍵
        if (key == null)
            return getForNullKey();
        // 用該key求得entry
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }
    final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

put能看懂的同學看get應該毫無壓力,呼叫hash(key)求得key的hash值,然後呼叫indexFor(hash)求得hash值對應的table的索引位置,然後遍歷索引位置的連結串列,如果存在key,則把key對應的Entry返回,否則返回null。

從HashMap的結構和put原理我們也能理解為什麼HashMap在遍歷資料時,不能保證插入時的順序。這時需要使用LinkedHashMap。

最後把java裡map的四個實現類做個總結。
1.Hashmap 是一個最常用的Map,它根據鍵的HashCode值儲存資料,根據鍵可以直接獲取它的值,具有很快的訪問速度,遍歷時,取得資料的順序是完全隨機的。 HashMap最多隻允許一條記錄的鍵為Null;允許多條記錄的值為 Null;HashMap不支援執行緒的同步,即任一時刻可以有多個執行緒同時寫HashMap;可能會導致資料的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。

2.Hashtable與 HashMap類似,它繼承自Dictionary類,不同的是:它不允許記錄的鍵或者值為空;它支援執行緒的同步,即任一時刻只有一個執行緒能寫Hashtable,因此也導致了 Hashtable在寫入時會比較慢。

3.LinkedHashMap 是HashMap的一個子類,儲存了記錄的插入順序,在用Iterator遍歷LinkedHashMap時,先得到的記錄肯定是先插入的.也可以在構造時用帶引數,按照應用次數排序。在遍歷的時候會比HashMap慢,不過有種情況例外,當HashMap容量很大,實際資料較少時,遍歷起來可能會比 LinkedHashMap慢,因為LinkedHashMap的遍歷速度只和實際資料有關,和容量無關,而HashMap的遍歷速度和他的容量有關。

4.TreeMap實現SortMap介面,能夠把它儲存的記錄根據鍵排序,預設是按鍵值的升序排序,也可以指定排序的比較器,當用Iterator 遍歷TreeMap時,得到的記錄是排過序的。

最後的最後再加個tip:
ConcurrentHashMap提供的執行緒安全是指他的put和get等操作是原子操作,是執行緒安全的。但沒有提供多個操作(判斷-更新)的事務保護,也就是說:

//T1:                                        
concurrent_map.insert(key1,val1);
//T2:
concurrent_map.contains(key1);

是執行緒安全的。

//T1,T2同時
if (! concurrent_map.contains(key1) {
    concurrent_map.insert(key1,val1)
}

執行緒不安全。