HashMap重點詳解

    Map即映射表通常稱爲散列表。開發中經常使用到這種數據結構,Java中HashMap和ConcurrentHashMap被用到的頻率較高,本文重點說下HashMap的實現原理以及設計思路。html

    HashMap的本質是一個數組,數組的每一個索引被稱爲桶,每一個桶裏放着一個單鏈表,一個節點連着一個節點。很明顯經過下標來檢索數組元素時間複雜度爲O(1),並且遍歷鏈表的時間複雜度是常數級別,因此總體的查詢複雜度仍爲O(1)。咱們先來看下HashMap的成員屬性:java

    

    // 默認的初始容量是16,必須是2的冪(這點很重要,後面講述緣由)
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    // 最大容量(必須是2的冪且小於2的30次方,傳入容量過大將被這個值替換)
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默認加載因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 存儲數據的Entry數組
    transient Entry[] table;

    // HashMap的大小,它是HashMap實際保存的鍵值對的數量
    transient int size;

    // HashMap的閾值(threshold = 容量*加載因子),就是經過它和    
    //size進行比較來判斷是否須要擴容
    int threshold;

    // 加載因子實際大小
    final float loadFactor;

    // HashMap被改變的次數(用於快速失敗,後面詳細講)
    transient volatile int modCount;

    成員屬性的意義如上所述,咱們再來看下它們修飾符的設計含義:table和size以及modCount都被transient所修飾,transient爲短暫的意思,java中只能用來修飾類成員變量,做用是對象序列化時被修飾的字段不會被序列化到目的地。很容易想到:map只要執行put或remove操做後三者的值都會產生變化,對於這種狀態常變(短暫)的屬性咱們不必在對象序列化時將其值帶入。此外,modCount還被volitile修飾,這個關鍵字主要做用是使被修飾的變量在內存中的變化可被多線程所見,由於modCount用於快速失敗機制,因此寫線程執行時帶來的變化需及時被讀線程知道。數組

    咱們再看下Entry類:數據結構

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        // 指向下一個節點
        Entry<K,V> next;
        final int hash;

        // 構造函數。
        // 輸入參數包括"哈希值(h)", "鍵(k)", "值(v)", "下一節點(n)"
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

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

每一個桶的Entry對象其實就是指的單鏈表,Entry做爲hashMap的靜態內部類,實現了Map.Entry<K,V>接口。設計的很硬氣,全部的get&set都是final,不容許再被使用者重寫重定義了。多線程

    研究一種數據結構,知道了它的基本組成,就可進一步瞭解它的存取機制:map的get,put,remove。map不管是增刪查,經歷的第一步就是定位桶的位置,即經過對象的hashCode(其實map中又再次hash了一遍)來取模定位,而後遍歷桶中的鏈表元素進行equals比較。因此,我在這裏重點說下hashCode()和equals(Object o)兩個方法的關聯。併發

    常說hashCode是equals的必要不充分條件,這個說法主要就是根據散列表來的。不重寫的狀況下,hashCode默認返回對象在堆內存中的首地址,equals默認比較兩個對象在堆內存中的首地址。就equals而言,這種比較方式在實際業務中基本無心義,咱們判斷兩個對象是否相等,一般根據他們的某些屬性值是否相等來判斷,就像根據ID和name咱們就能夠判定一個員工的惟一性。eclipse或者idea如今均可默認爲你的model生成equals方法,以下所示:app

class Animal{
    private int id;

    private String name;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || o.getClass()!=this.getClass()) return false;

        Animal animal = (Animal) o;

        if (id != animal.id) return false;
        return name != null ? name.equals(animal.name) : animal.name == null;
    }

}

流程:若是首地址都相等那確定就是一個對象,直接返回true,不等就繼續判斷是否同屬一個類,不是一個類那根本就不用繼續判斷直接false。這裏仍是有爭議的,由於有的寫法是  !(o  instanceof  Animal),二者的區別會在繼承中體現出來,好比我再建立一個子類Dogeclipse

class Dog extends Animal{
    private double weight;

    public double getWeight() {
        return weight;
    }

    public void setWeight(double weight) {
        this.weight = weight;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;

        Dog dog = (Dog) o;

        return Double.compare(dog.weight, weight) == 0;
    }

}

Dog中添加了一個weight屬性,並在基類Animal的基礎上再次重寫了equals方法。看下面一段代碼:ide

        Animal animal=new Animal();
        animal.setId(1);
        animal.setName("dog");
        Dog dog = new Dog();
        dog.setId(1);
        dog.setName("dog");
     dog.setWeight(1); System.out.print(animal.equals(dog));

若是按照  getClass() != o.getClass()  這個邏輯,二者equals就直接false了,而按照!(o  instanceof  Animal)這個邏輯最終會返回true。理論講應該返回false的,不然weight這個字段的意義呢?被dog吃了?因此當該類下有子類時,equals中最好採用getClass()這種判斷方式。再看hashCode():函數

    @Override
    public int hashCode() {
        int result = id;
        result = 31 * result + (name != null ? name.hashCode() : 0);
        return result;
    }

這時候就要思考爲何hashCode值取決於ID和Name字段,咱們知道在map裏尋找元素經過equals比較只是第二步驟,首要步驟是先定位到桶的位置(hash&length-1),若是兩個本equals的對象連hashCode都不相等,那就很容易形成下述3種狀況:

1:get(key)的時候取不出來

2:put(k,v)的時候存了重複值

3:remove(key)的時候刪不掉

接下來,再瞭解下HashMap的構造方法:

    // 指定「容量大小」和「加載因子」的構造函數
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // HashMap的最大容量只能是MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // 找出「大於initialCapacity」的最小的2的冪
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        // 設置「加載因子」
        this.loadFactor = loadFactor;
        // 設置「HashMap閾值」,當HashMap中存儲數據的數量達到threshold時,就須要將HashMap的容量加倍。
        threshold = (int)(capacity * loadFactor);
        // 建立Entry數組,用來保存數據
        table = new Entry[capacity];
        init();
    }


    // 指定「容量大小」的構造函數
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

你不指定容量和加載因子時hashMap就按默認的給你,指定的話就按你的來,有意思的是hashmap怕你不夠懂它特地又對你賦的容量值進行了一次計算,轉化爲小於該值的最大偶數。容量值爲二次冪的設計魅力後面會講。

    最後再簡單看下兩個方法咱們奔主題了:

    static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    // 返回索引值
    // h & (length-1)保證返回值的小於length
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

 

 hashmap會對全部的key再重hash一次,至於爲何這麼寫不須要理解,只須要知道一切都是最好的安排。indexFor則是用來定位key對應哪一個桶。

    準備完畢,開始看下get(key)的實現:

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        // 獲取key的hash值
        int hash = hash(key.hashCode());
        // 在「該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.equals(k)))
                return e.value;
        }
        return null;
    }

hashMap與hashTable其中不一樣的一點是前者容許key爲null,這點設計的很取巧,把key爲null的對象存在數組首位(table[0]),代碼以下:

    private V getForNullKey() {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

接下來的步驟就是:重hash->定位桶->遍歷桶中的鏈表一一比較。在判斷過程當中會先判斷e.hash==hash,更印證了以前說的hashCode相等是equals成立的必要不充分條件。

再來看put方法的實現:

public V put(K key, V value) {
        // 若「key爲null」,則將該鍵值對添加到table[0]中。
        if (key == null)
            return putForNullKey(value);
        // 若「key不爲null」,則計算該key的哈希值,而後將其添加到該哈希值對應的鏈表中。
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 若「該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;
            }
        }

        // 若「該key」對應的鍵值對不存在,則將「key-value」添加到table中
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

首先仍是會先判斷key值是否爲null,若是爲null,則將該元素放置在數組0位置,以下圖所示:

    private V putForNullKey(V value) {
        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++;
        addEntry(0, null, value, 0);
        return null;
    }

咱們知道在hashMap中存儲一個已有的key,新key對應的value值會替換掉old值。因此put操做會先判斷一下是否已經存在該key,存在的話就替換成新值返回老值。不存在執行addEntry返回null。這裏須要注意的是若是key以前存在過,替換舊值不會修改modCount,不存在該key則modCount+1。咱們能夠這麼認爲,只有map中的元素數量增多或減小的狀況下才認爲map的結構的發生了變化。

接下來說一下重點方法:addEntry(xxx);擴容操做就是在這裏進行的

    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 保存「bucketIndex」位置的值到「e」中
        Entry<K,V> e = table[bucketIndex];
        // 設置「bucketIndex」位置的元素爲「新Entry」,
        // 設置「e」爲「新Entry的下一個節點」
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
        // 若HashMap的實際大小 不小於 「閾值」,則調整HashMap的大小
        if (size++ >= threshold)
            resize(2 * table.length);
    }

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

        // 新建一個HashMap,將「舊HashMap」的所有元素添加到「新HashMap」中,
        // 而後,將「新HashMap」賦值給「舊HashMap」。
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

    // 將HashMap中的所有元素都添加到newTable中
    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

流程以下:

1:將新元素做爲桶中鏈表的頭節點,若是達到閾值則第二步

2:擴容爲原來2倍,若是以前容量已是最大值了,則直接將閾值設爲Int型的最大值返回(有點棄療的意思)

3:從新散列-->外循環遍歷每一個桶,內循環遍歷每一個桶中鏈表的每一個節點,將每一個節點定位到新的位置。

 

在多線程中,通過resize過程後,再涉及到迭代或者擴容操做時,會有必定概率形成死循環或者數據丟失。

先看圖一:首先向length=2的map中插入三個元素(爲方便畫圖這裏直接採用hash&length),最終桶1中造成鏈表3-7-5。

 這時候A線程再添加一個元素,而後進行擴容操做,並將元素3房屋新的桶,此時元素3的next是7:

 

 

此時線程B添加了元素後也進行了擴容操做,且直接擴容完成,以下圖:

此時7的next指向了3而再也不指向5

而後A線程繼續向下走的時候就出現了死循環問題,由於在線程A中3的next是指向7的,因此當再把7進行重定位時就出現了以下圖所示:

因此以後的遍歷或者擴容過程只要到了桶3,便會一直在7和3之間死循環。數據缺失的發生場景也是如此,能夠本身分析。

下面來說下:爲何map內部數組的長度要爲2次冪。

咱們知道數組的長度主要被用來作了這麼一件事,就是經過indexFor方法去定位key位於哪一個桶,即 h & (length-1);

分析一下:&運算是同一位都爲1時才爲1,假如一個key的hash爲43,即二進制爲101011,map的長度爲16

則indexFor:

  101011

 &  001111

——————

   001011

爲11,

當進行一次resize操做時,length=16<<1=32,再次進行indexFor操做:

        101011

 &  011111

——————

   001011

依然爲11。

咱們能夠很容易發現,若是length是2的次冪,length-1的二進制每位均是1,而擴容後-1二進制依然每位均是1,因此&的結果取決於hash的二進制,即有一半概率該節點依然位於原來的桶(但節點依然是會移動的),一半概率被分到了其餘的桶,從而保證了擴容後節點分配的均衡性。這是其一。

其二:咱們假如桶的長度不是2次冪,拿length=15舉例,length-1=14=1110。那麼這時候任何key與其&操做,最後一位都是0,這就意味着桶的第1個位置永遠都不會被放入元素!同理假如length-1=12=1100,那麼第1,2,3的位置也永遠不可能被放入元素。這會形成空間的浪費以及數據的分配不均。

以上,就是map的數組長度要爲2次冪的奧祕所在。

順便在提一下除map外的其餘容器的初始長度設定:拿StringBuilder來說,字符串相加時咱們考慮到內存回收通常採用StringBuilder或StringBuffer的append來代替,那麼假如能夠提早估算出一個字符串的大概長度,那麼請以這個大概長度直接在集合類的構造器中賦值進去,由於StringBuilder每次進行數組擴容的時候都會伴隨着元素的copy,頻繁的copy會必定程度上影響效率。ArrayList也是同理。

研究數據結構,咱們還有一個重要的關注點就是元素的遍歷。java的集合類通常都會在內部實現一個迭代器即Iterator,它的意義是什麼呢?從客戶端角度來說,我可能並不關心目前操做的數據結構的內部實現,像ArrayList內部是個數組,LinkedList內部是個鏈表,HashMap內部又是個數組鏈表,I dont care。我只想拿它作個遍歷,而不是針對數組時使用索引遍歷,針對鏈表時使用xxx.next,map時又二者並用,Iterator就解決了這個問題,它做爲接口定義了hasNext(),next(),remove()三個核心方法。任何一個集合類,均可以經過本身的一個內部類去實現該接口而後對外提供遍歷方法和移除元素方法。如今經過hashmap的源碼來看下原理:

hashmap實現了三種Iterator,分別針對key,value,還有entry。源碼以下:

     final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
        public final int size()                 { return size; }
        public final void clear()               { HashMap.this.clear(); }
        public final Iterator<Map.Entry<K,V>> iterator() {
            return new EntryIterator();
        }
     }

     abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
      int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { // advance to first entry do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount; } } final class KeyIterator extends HashIterator implements Iterator<K> { public final K next() { return nextNode().key; } } final class ValueIterator extends HashIterator implements Iterator<V> { public final V next() { return nextNode().value; } } final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> { public final Map.Entry<K,V> next() { return nextNode(); } }

 

內部類的一大特徵就是能夠訪問主類的成員變量和成員方法,EntrySet和EntryIterator做爲HashMap的內部類三者相輔相成,能夠看到不管是next仍是remove,實際上都是操做的主類hashMap的table。可是這種操做對外部是透明的,能夠看到封裝的魅力。在HashIterator構造器中,modCount會被賦值到expectedModcount,顧名思義expectedModcount是指望的變化值,若是當前是多線程環境,進行next遍歷時,當前節點可能已被其餘線程remove了,或者其餘線程的put操做已經改變了當前節點的位置。這種狀況下expectedModcount再也不等於modCount,HashMap會認爲該遍歷獲得的數據是無效的,便執行快速失敗機制。這就是modCount被validate修飾的緣由。固然這種快速失敗機制只是爲了防止必定程度上的髒讀,而不是完全解決併發問題。

說完Iterator咱們再來談HashMap的遍歷方式,無需多說,數據量大的時候第一種遠高於第二種

    /*
     * 經過entry set遍歷HashMap
     * 效率高!
     */
    private static void iteratorHashMapByEntryset(HashMap map) {
        if (map == null)
            return ;

        System.out.println("\niterator HashMap By entryset");
        String key = null;
        Integer integ = null;
        Iterator iter = map.entrySet().iterator();
        while(iter.hasNext()) {
            Map.Entry entry = (Map.Entry)iter.next();
            
            key = (String)entry.getKey();
            integ = (Integer)entry.getValue();
            System.out.println(key+" -- "+integ.intValue());
        }
    }

    /*
     * 經過keyset來遍歷HashMap
     * 效率低!
     */
    private static void iteratorHashMapByKeyset(HashMap map) {
        if (map == null)
            return ;

        System.out.println("\niterator HashMap By keyset");
        String key = null;
        Integer integ = null;
        Iterator iter = map.keySet().iterator();
        while (iter.hasNext()) {
            key = (String)iter.next();
            integ = (Integer)map.get(key);
            System.out.println(key+" -- "+integ.intValue());
        }
    }

第二種方法每當取得了key值後又進行了一次get(key)操做,不但無心義且影響效率。

以上是我的對HashMap的理解和分析,沒有什麼佈局且做爲初版吧,本文粘貼的代碼小部分直接來源於jdk,大部分採用了下面的博客(https://www.cnblogs.com/skywang12345/p/3310835.html#a3),由於這邊博客已經將每行代碼作了什麼用中文講的很清楚了,我又在一些關鍵點上加了一些我的理解。不足之處還望你們指正。

相關文章
相關標籤/搜索