【Java入門提升篇】Day22 Java容器類詳解(五)HashMap源碼分析(上)

  準備了很長時間,終於理清了思路,鼓起勇氣,開始介紹本篇的主角——HashMap。說實話,這傢伙能說的內容太多了,要是像前面ArrayList那樣翻譯一下源碼,稍微說說重點,確定會讓不少人摸不着頭腦,不能把複雜的東西用盡可能簡單的方式說明白,那就說明講的挺失敗的(面壁中)。因此此次決定把內容分四篇進行講解,node

  第一篇主要講解HashMap中的結構,重要參數和重要方法,以及使用中須要注意的地方和應用場景。算法

  第二篇主要講解HashMap中的散列算法,擾動函數以及擴容函數。普通節點的較深刻的解析其中算法的妙處。編程

  第三篇主要講解HashMap中的EntrySet,KeySet和Values。數組

  第四篇主要講解HashMap中的TreeNode結構以及元素增減時的結構調整方式。(以JDK8中的紅黑樹進行講解)。緩存

  由於HashMap中能夠說說的點實在太多了,這裏選取了比較重要的幾點進行說明,四篇的角度和深度各不同,這樣不一樣階段的同窗也能夠選取不一樣的部分進行閱讀,第一篇屬於簡單易懂的初級部分,第2、第三篇和第四篇屬於HashMap的高級部分,若是閱讀有難度,能夠先跳過,之後再來進行閱讀。安全

  好了,話很少說,接下來就進入咱們的主題了。數據結構

  本篇將擯棄以前的講法,直接擺幾百行源碼實在是太乾了,咱們得弄溼一點纔好消化(滑稽),接下來將用圖文並茂的方式進行說明。併發

  經過本篇,你將瞭解如下問題:app

  1.HashMap的結構是什麼?less

  2.HashMap的優勢和缺點是什麼?

  3.何時該使用HashMap?

  4.HashMap中的經常使用方法有哪些?

  5.HashMap的get()方法和put()方法的工做原理是什麼?

  6.HashMap中的碰撞探測(collision detection)以及碰撞的解決方法是什麼?

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

  8.在設置HashMap的鍵是須要注意什麼?可使用自定義的對象做爲鍵嗎? 

  嗯,因此內容仍是挺多的,乾貨也很多。咱們先來看一個HashMap的小栗子:

public class Test {
    public static void main(String[] args){
        Map<String, Integer> map = new HashMap();
        map.put("小明", 66);
        map.put("小李", 77);
        map.put("小紅", 88);
        map.put("小剛", 89);
        map.put("小力", 90);
        map.put("小王", 91);
        map.put("小黃", 92);
        map.put("小青", 93);
        map.put("小綠", 94);
        map.put("小黑", 95);
        map.put("小藍", 96);
        map.put("小紫", 97);
        map.put("小橙", 98);
        map.put("小赤", 99);
        map.put("Frank", 100);
        for(Map.Entry<String, Integer> entry : map.entrySet()){
            System.out.println(entry.getKey() + ":" + entry.getValue());
        }
    }
}

  輸出也很簡單:

小剛:89
小橙:98
小藍:96
小力:90
小青:93
小黑:95
小明:66
小李:77
小王:91
小紫:97
小紅:88
小綠:94
Frank:100
小黃:92
小赤:99

  能夠看到,HashMap中存儲的順序跟咱們放入的順序有些不太同樣,可是每次運行的結果都是同樣的,以一種神奇的順序輸出着,爲何會這樣呢?不要着急,讓咱們先來打個斷點看看。

   

  能夠看到,這個HashMap對象裏,有一個table字段,能夠看出,它是一個數組,咱們put的成績信息,就在這個傢伙裏面了,你看看,這個順序跟上面的輸出順序是否是很像?

  不過,等一下,你有沒有發現,小李,小紫,小赤失蹤了。。這個問題,不要着急,待會咱們就一塊兒去找他們。

  HashMap裏的數據結構是數組+鏈表的形式來存儲節點的,每一個節點以鍵值對(Node<K,V>)的形式存儲,上面看到的table,就是HashMap中存放值的地方,它的數據結構是這樣的:Node<K,V>[] table;那這個Node究竟是什麼東西呢?咱們來看看它的代碼:

    /**
     * 用於大多數鍵值對的普通節點
     */
    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() {
            //返回key和value的哈希值的異或運算結果
            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;
        }
    }

  Node類繼承於Map.Entry接口,若是對這個接口沒有印象了能夠回過頭翻一下Map接口的內容,Node中的內容很簡單,hash,鍵值信息和下一個節點的引用,Node之間正是經過這樣的引用來鏈接起來造成一條鏈。再想一想table的結構,Node<K,V>[],如今是否是理解了什麼是數組+鏈表的存儲方式了?

  什麼?這樣說的還不夠形象?好吧,一圖勝千言,以前說要用圖文並茂的方式來進行講解,因此仍是一塊兒來看幾張圖片:

 

  嗯,咱們存儲的數據在內存裏就是這樣的,咱們再來看一下斷點裏的數據:    

  

  對比兩幅圖應該就能比較清楚的瞭解了,能夠看出裏面數組並非順序往裏存的,中間有不少空的桶(每一個格子稱爲一個bin,這裏蹩腳翻譯成桶),那爲何會是這樣的順序呢?咱們來看看它的put方法:

   /**
     * 將map中指定key和value進行關聯,若是map中已經存在該key的映射,則舊的值將會被替換。
     * 返回該key映射的舊值,若是該key的映射不存在的話則返回null。
     */
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    /**
     *  實現 Map.put 和相關方法
     *
     * @param hash key的哈希值
     * @param key key
     * @param value key將要映射的value
     * @param onlyIfAbsent 若是是true的話,將不會改變已存在的值
     * @param evict 這個參數若是爲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;
        //若是當前table未初始化,則先從新調整大小至初始容量
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //(n-1)& hash 這個地方即根據hash求序號,想了解更多散列相關內容能夠查看下一篇
        if ((p = tab[i = (n - 1) & hash]) == null)
            //不存在,則新建節點
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //先找到對應的node
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                //若是是樹節點,則調用相應的putVal方法
                //todo putTreeVal
                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
                            //若是鏈表長度達到樹化的最大長度,則進行樹化
                            //todo treeifyBin
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //若是已存在該key的映射,則將值進行替換
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //修改次數加一
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

  從這裏能夠看出往HashMap添加元素時的邏輯:

 

  1. 對key的hashCode()作一次散列(hash函數,具體內容下一篇講解),而後根據這個散列值計算index(i = (n - 1) & hash);
  2. 若是沒有發生碰撞(哈希衝突),則直接放到桶裏;
  3. 若是碰撞了,以鏈表的形式掛在桶後;
  4. 若是由於碰撞致使鏈表過長(大於等於TREEIFY_THRESHOLD),就把鏈表轉換成紅黑樹;
  5. 若是節點已經存在就替換old value(保證key的惟一性)
  6. 若是桶滿了(超過負載因子*當前容量),就要resize(從新調整大小並從新散列)。

  也許你想知道這個table是什麼東西,那咱們順便一塊兒看看那幾個重要的成員變量吧:

/* ---------------- 字段 -------------- */

    /**
     * 哈希表,該表在初次使用時初始化,並根據須要調整大小。 分配時,長度老是2的冪
     *
     * todo hashMap的結構
     * todo transient
     */
    transient Node<K,V>[] table;

    /**
     * 保存緩存的entrySet,注意AbstractMap中的字段是用於keySet() 和 values().
     */
    transient Set<Map.Entry<K,V>> entrySet;

    /**
     * map中的鍵值對個數
     */
    transient int size;

    /**
     * hashmap 結構性修改的次數,結構性修改是指改變hashmap中映射數量或者修改內部結構。
     * 該字段用於在HashMap中建立基於集合視圖的可失敗快速的(fail-fast)迭代器。
     */
    transient int modCount;

    /**
     * 下一個調整大小的值(容量*加載因子)。
     */
    int threshold;

    /**
     * hashmap的裝載因子
     */
    final float loadFactor;

  table字段是中保存了咱們的數據,類型是Node數組,Node的結構也很簡單,只是簡單的存放key和value,以及key的hash和指向下一個節點的引用。

    /**
     * 用於大多數鍵值對的普通節點
     */
    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() {
            //返回key和value的哈希值的異或運算結果
            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;
        }
    }

  在成員變量entrySet中緩存了Entry的集合(其實你仔細找找的話,會發現entrySet的存儲元素邏輯並不簡單,這將在第三篇裏講解)。threshold表示進行下一次從新調整的閾值(容量*裝載因子),轉載因子表示table最大裝滿程度,默認是0.75,即當容量被用掉75%後將會觸發擴容,由於當table中的元素足夠多時,發生衝突的機率就會大大增長,衝突的增多會致使每一個桶中的元素個數變多,這樣的話會使得查找元素效率變得低下,當同一個桶中元素個數達到8時,桶中的元素結構將轉換爲紅黑樹。

  那麼,問題來了,爲何是8,而不是6或者7,10呢???這個話題若是要深刻探討的話,又要說上一篇了。。。 這裏我就引用一下JDK8中的HashMap的中註解:

   * Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million
     *

  大體意思是,理想狀況下,HashCode隨機分佈,當負載因子設置成0.75時,那麼在桶中元素個數的機率大體符合0.5的泊松分佈,桶中元素個數達到8的機率小於千萬分之一,由於轉化爲紅黑樹仍是比較耗時耗力的操做,天然不但願常常進行,但若是設置得過大,將失去設置該值的意義。

  那麼,問題又來了。。爲何是0.75,而不是0.5,0.8??? 這是一個經驗值,在空間和時間成本中的折中,跟默認初始容量設置爲16同樣。看看JDK8中HashMap最開頭的註釋便可找到答案:(說實話,JDK中的註解真是太多太詳細了,教科書式的代碼人通常人寫的仍是不同的

 * <p>As a general rule, the default load factor (.75) offers a good
 * tradeoff between time and space costs.  Higher values decrease the
 * space overhead but increase the lookup cost (reflected in most of
 * the operations of the <tt>HashMap</tt> class, including
 * <tt>get</tt> and <tt>put</tt>).  The expected number of entries in
 * the map and its load factor should be taken into account when
 * setting its initial capacity, so as to minimize the number of
 * rehash operations.  If the initial capacity is greater than the
 * maximum number of entries divided by the load factor, no rehash
 * operations will ever occur.

  再蹩腳的翻譯一次:

 * 一般,默認的負載因子(0.75)是在時間和空間成本上比較好的折中選擇。若是設置成更高的值,雖然會
 * 減小空間開銷,可是會增長查找的成本(反應在HashMap的大部分操做中,包括get和put方法)。
 * 在設置初始容量時,應該考慮映射中的條目數量以及負載因子,以儘可能減小從新散列的次數。
 * 若是初始容量大於最大詞條數量除以負載因子,那麼就不會發生從新散列操做。

  並且,裝載因子和容量都是能夠在構造函數中指定的:

    /**
     * 用指定初始容量和裝載因子構造一個空的hashmap,
     */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                    initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                    loadFactor);
        this.loadFactor = loadFactor;
        //從新計算閾值
        this.threshold = tableSizeFor(initialCapacity);
    }

    /**
     * 用指定初始容量和默認的裝載因子(0.75)構造一個空的hashmap
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 用指定默認容量(16)和默認的裝載因子(0.75)構造一個空的hashmap
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    /**
     * 用另外一個map來構造一個新的hashmap,並保留相同的映射。新的HashMap使用默認加載因子(0.75)和適合裝下指定map中全部映射關係的
     * 初始容量。
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

   若是不指定初始大小和加載因子,將使用默認的加載因子和默認的容量,並且HashMap中是使用懶加載的方式進行的,只有真正往裏添加元素時纔會初始化table。上面咱們已經看過了put方法的實現,那咱們再來看看get方法是怎樣實現的:

    /**
     * 返回指定鍵映射的值,當該key不存在的時候返回null
     */
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    /**
     * 實現了Map.get 和相關方法
     */
    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) {
                if (first instanceof TreeNode)
                    //若是是樹節點,則用TreeNode的getTreeNode方法來查找相應的key
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    //不然遍歷鏈表來查找
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

  get的思路很簡單。大體思路以下:

  1. 先匹配bucket裏的第一個節點,直接命中則返回;
  2. 若是有衝突,則經過key.equals(k)去查找對應的entry
    若爲樹節點,則在樹中經過key.equals(k)查找,時間複雜度爲O(logn);
    若爲鏈表節點,則在鏈表中經過key.equals(k)查找,時間複雜度爲O(n)。

  好了,最重要的方法都介紹完了,是時候去救爺爺了,說錯了,是時候來回答最開頭提出的問題了:

  1.HashMap的結構是什麼?

  HashMap是數組+鏈表的存儲形式,默認的初始容量是16,默認的加載因子是0.75,當鏈表長度達到8時將會轉化爲紅黑樹來提升查找效率。

  2.HashMap的優勢和缺點是什麼?

  HashMap的優勢是查找速度很快,咱們能夠在常數時間內迅速定位到某個桶以及要找的對象。缺點嘛,就是它的拿手好戲——散列算法是依賴key的hashcode,因此若是key的hashcode設計的很爛,將會嚴重影響性能。
  極端狀況下,若是每次計算hash值都是同一個值,那麼會形成鏈表中長度過長而後轉化成樹,擴容時再散列的效果也不好的問題。 另外一個極端狀況. 每次計算hash值都是不一樣的值,那麼就是HashMap中的數組會不斷的擴容,形成HashMap的容量不斷增大。 

  另外一方面,HashMap是線程不安全的,若是想在併發編程中使用到HashMap,就須要使用它的同步類,Collections.synchronizedMap()方法將普通的HashMap轉化成線程安全的,或者使用Concurrent包下的ConcurrentHashMap進行替換。

  3.何時該使用HashMap?

  由於HashMap查找速度很快,因此應用在常常須要存取元素的場景,好比要將一個List B中的元素根據另外一個List A的元素來進行排序,那麼須要常常將B中的元素來到A中進行查找,而查找通常都是使用遍歷的方式進行的,若是List很大的狀況下,效率問題仍是須要考慮的,這時候若是將A中的元素存儲在Map中,以B中的元素做爲key,那麼查找效率將大大提升,這是以空間換取時間的策略。

  4.HashMap中的經常使用方法有哪些?

  經常使用方法有put,get,putAll,remove,clear,replace,size。

  5.HashMap的get()方法和put()方法的工做原理是什麼?

  經過對key的hashCode()進行hashing,並計算下標( n-1 & hash),從而得到桶的位置。若是發生哈希衝突,則利用key.equals()方法去鏈表或樹中去查找對應的節點。

  6.HashMap中的碰撞探測(collision detection)以及碰撞的解決方法是什麼?

  當兩個key的hashCode相同時,就會發生碰撞,就像上面的小明和小李,這時候後添加的元素將會以鏈表或樹節點的形式掛在桶後面。

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

  若是HashMap的大小超過了加載因子*容量,那麼將會進行擴容操做,擴容到原來的兩倍。

  8.在設置HashMap的鍵是須要注意什麼?可使用自定義的對象做爲鍵嗎? 

  設置的key儘可能使用不可變對象,例如數值,String,這樣能夠保證key的不可變性,也可使用自定義的類對象,但須要對hashCode方法和equals方法有良好的設計。

  

  此處應有掌聲,本篇終於講解完畢,這段時間由於其餘時間,一直沒多少時間來寫博客,耽擱了兩個星期,對不住各位看官啦,不過寫一篇博客真心挺花時間的,這篇文章我也是想了好幾天纔想好思路,HashMap的東西實在是太多了,細節不太可能面面俱到,並且若是事無鉅細所有介紹的話,顯然對初學者來講不夠友好,因此才決定分紅了四篇,這樣你們能夠根據本身能理解的程度選擇性的閱讀。

  還望各位看官賞個贊,對個人文章感興趣的話,也歡迎動動小手點點關注,仍是持續更新的。也歡迎提出好的建議,若是有重要知識點遺漏或者說錯了的地方,還請各位看官及時指出,多多交流。

  

相關文章
相關標籤/搜索