1. 程式人生 > >HashMap 原始碼學習

HashMap 原始碼學習

簽名(signature)

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

可以看到HashMap繼承了

  • 標記介面Cloneable,用於表明HashMap物件會重寫java.lang.Object#clone()方法,HashMap實現的是淺拷貝(shallow copy)。

  • 標記介面Serializable,用於表明HashMap物件可以被序列化

比較有意思的是,HashMap

同時繼承了抽象類AbstractMap與介面Map,因為抽象類AbstractMap的簽名為

public abstract class AbstractMap<K,Vimplements Map<K,V>

在語法層面繼承介面Map是多餘的,這麼做僅僅是為了讓閱讀程式碼的人明確知道HashMap是屬於Map體系的,起到了文件的作用

AbstractMap相當於個輔助類,Map的一些操作這裡面已經提供了預設實現,後面具體的子類如果沒有特殊行為,可直接使用AbstractMap提供的實現。

It's evil, don't use it.

Cloneable

這個介面設計的非常不好,最致命的一點是它裡面竟然沒有clone方法,也就是說我們自己寫的類完全可以實現這個介面的同時不重寫clone方法。

關於Cloneable的不足,大家可以去看看《Effective Java》一書的作者給出的理由,在所給連結的文章裡,Josh Bloch也會講如何實現深拷貝比較好,我這裡就不在贅述了。

Map介面

在eclipse中的outline面板可以看到Map接口裡麵包含以下成員方法與內部類:

可以看到,這裡的成員方法不外乎是“增刪改查”,這也反映了我們編寫程式時,一定是以“資料”為導向的。

上篇文章講了Map雖然並不是Collection,但是它提供了三種“集合視角”(collection views),與下面三個方法一一對應:

  • Set<K> keySet(),提供key的集合視角

  • Collection<V> values(),提供value的集合視角

  • Set<Map.Entry<K, V>> entrySet(),提供key-value序對的集合視角,這裡用內部類Map.Entry表示序對

AbstractMapMap中的方法提供了一個基本實現,減少了實現Map介面的工作量。
舉例來說:

如果要實現個不可變(unmodifiable)的map,那麼只需繼承AbstractMap,然後實現其entrySet方法,這個方法返回的set不支援add與remove,同時這個set的迭代器(iterator)不支援remove操作即可。

相反,如果要實現個可變(modifiable)的map,首先繼承AbstractMap,然後重寫(override)AbstractMap的put方法,同時實現entrySet所返回set的迭代器的remove方法即可。

設計理念(design concept)

雜湊表(hash table)

HashMap是一種基於雜湊表(hash table)實現的map,雜湊表(也叫關聯陣列)一種通用的資料結構,大多數的現代語言都原生支援,其概念也比較簡單:key經過hash函式作用後得到一個槽(buckets或slots)的索引(index),槽中儲存著我們想要獲取的值,如下圖所示

很容易想到,一些不同的key經過同一hash函式後可能產生相同的索引,也就是產生了衝突,這是在所難免的。所以利用雜湊表這種資料結構實現具體類時,需要:

  • 設計個好的hash函式,使衝突儘可能的減少

  • 其次是需要解決發生衝突後如何處理。

後面會重點介紹HashMap是如何解決這兩個問題的。

HashMap的一些特點

  • 執行緒非安全,並且允許key與value都為null值,HashTable與之相反,為執行緒安全,key與value都不允許null值。

  • 不保證其內部元素的順序,而且隨著時間的推移,同一元素的位置也可能改變(resize的情況)

  • put、get操作的時間複雜度為O(1)。

  • 遍歷其集合視角的時間複雜度與其容量(capacity,槽的個數)和現有元素的大小(entry的個數)成正比,所以如果遍歷的效能要求很高,不要把capactiy設定的過高或把平衡因子(load factor,當entry數大於capacity*loadFactor時,會進行resize,reside會導致key進行rehash)設定的過低。

  • 由於HashMap是執行緒非安全的,這也就是意味著如果多個執行緒同時對一hashmap的集合試圖做迭代時有結構的上改變(新增、刪除entry,只改變entry的value的值不算結構改變),那麼會報ConcurrentModificationException,專業術語叫fail-fast,儘早報錯對於多執行緒程式來說是很有必要的。

  • Map m = Collections.synchronizedMap(new HashMap(...)); 通過這種方式可以得到一個執行緒安全的map。

原始碼剖析

首先從建構函式開始講,HashMap遵循集合框架的約束,提供了一個引數為空的建構函式與有一個引數且引數型別為Map的建構函式。除此之外,還提供了兩個建構函式,用於設定HashMap的容量(capacity)與平衡因子(loadFactor)。

    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;
        threshold = initialCapacity;
        init();
    }
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

從程式碼上可以看到,容量與平衡因子都有個預設值,並且容量有個最大值

    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

可以看到,預設的平衡因子為0.75,這是權衡了時間複雜度與空間複雜度之後的最好取值(JDK說是最好的),過高的因子會降低儲存空間但是查詢(lookup,包括HashMap中的put與get方法)的時間就會增加。

這裡比較奇怪的是問題:容量必須為2的指數倍(預設為16),這是為什麼呢?解答這個問題,需要了解HashMap中雜湊函式的設計原理。

雜湊函式的設計原理

   /**
     * Retrieve object hash code and applies a supplemental hash function to the
     * result hash, which defends against poor quality hash functions.  This is
     * critical because HashMap uses power-of-two length hash tables, that
     * otherwise encounter collisions for hashCodes that do not differ
     * in lower bits. Note: Null keys always map to hash 0, thus index 0.
     */
    final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

看到這麼多位操作,是不是覺得暈頭轉向了呢,還是搞清楚原理就行了,畢竟位操作速度是很快的,不能因為不好理解就不用了。網上說這個問題的也比較多,我這裡根據自己的理解,儘量做到通俗易懂。

在雜湊表容量(也就是buckets或slots大小)為length的情況下,為了使每個key都能在衝突最小的情況下對映到[0,length)(注意是左閉右開區間)的索引(index)內,一般有兩種做法:

  1. 讓length為素數,然後用hashCode(key) mod length的方法得到索引

  2. 讓length為2的指數倍,然後用hashCode(key) & (length-1)的方法得到索引

HashTable用的是方法1,HashMap用的是方法2。

因為本篇主題講的是HashMap,所以關於方法1為什麼要用素數,我這裡不想過多介紹,大家可以看這裡

重點說說方法2的情況,方法2其實也比較好理解:

因為length為2的指數倍,所以length-1所對應的二進位制位都為1,然後在與hashCode(key)做與運算,即可得到[0,length)內的索引

但是這裡有個問題,如果hashCode(key)的大於length的值,而且hashCode(key)的二進位制位的低位變化不大,那麼衝突就會很多,舉個例子:

Java中物件的雜湊值都32位整數,而HashMap預設大小為16,那麼有兩個物件那麼的雜湊值分別為:0xABAB00000xBABA0000,它們的後幾位都為0,那麼與16與後得到的都是0,也就是產生了衝突。

造成衝突的原因關鍵在於16限制了只能用低位來計算,高位直接捨棄了,所以我們需要額外的雜湊函式而不只是簡單的物件的hashCode方法了。
具體來說,就是HashMap中hash函式乾的事了

首先有個隨機的hashSeed,來降低衝突發生的機率

然後如果是字串,用了sun.misc.Hashing.stringHash32((String) k);來獲取索引值

最後,通過一系列無符號右移操作,來把高位與低位進行或操作,來降低衝突發生的機率

右移的偏移量20,12,7,4是怎麼來的呢?因為Java中物件的雜湊值都是32位的,所以這幾個數應該就是把高位與低位做與運算,至於這幾個數是如何選取的,就不清楚了,網上搜了半天也沒統一且讓人信服的說法,大家可以參考下面幾個連結:

HashMap.Entry

HashMap中存放的是HashMap.Entry物件,它繼承自Map.Entry,其比較重要的是建構函式

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

        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
        // setter, getter, equals, toString 方法省略
        public final int hashCode() {
            //用key的hash值與上value的hash值作為Entry的hash值
            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
        }
        /**
         * This method is invoked whenever the value in an entry is
         * overwritten by an invocation of put(k,v) for a key k that's already
         * in the HashMap.
         */
        void recordAccess(HashMap<K,V> m) {
        }

        /**
         * This method is invoked whenever the entry is
         * removed from the table.
         */
        void recordRemoval(HashMap<K,V> m) {
        }
    }

可以看到,Entry實現了單向連結串列的功能,用next成員變數來級連起來。

介紹完Entry物件,下面要說一個比較重要的成員變數

    /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    //HashMap內部維護了一個為陣列型別的Entry變數table,用來儲存新增進來的Entry物件
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

你也許會疑問,Entry不是單向連結串列嘛,怎麼這裡又需要個數組型別的table呢?
我翻了下之前的演算法書,其實這是解決衝突的一個方式:開雜湊法(鏈地址法),效果如下:

就是相同索引值的Entry,會以單向連結串列的形式存在

鏈地址法的視覺化

網上找到個很好的網站,用來視覺化各種常見的演算法,很棒。瞬間覺得國外大學比國內的強不知多少倍。下面的連結可以模仿雜湊表採用鏈地址法解決衝突,大家可以自己去玩玩

get操作

get操作相比put操作簡單,所以先介紹get操作

    public V get(Object key) {
        //單獨處理key為null的情況
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);
    
        return null == entry ? null : entry.getValue();
    }
    private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        //key為null的Entry用於放在table[0]中,但是在table[0]衝突鏈中的Entry的key不一定為null
        //所以需要遍歷衝突鏈,查詢key是否存在
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        //首先定位到索引在table中的位置
        //然後遍歷衝突鏈,查詢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操作

因為put操作有可能需要對HashMap進行resize,所以實現略複雜些

    private void inflateTable(int toSize) {
        //輔助函式,用於填充HashMap到指定的capacity
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
        //threshold為resize的閾值,超過後HashMap會進行resize,內容的entry會進行rehash
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     */
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        //這裡的迴圈是關鍵
        //當新增的key所對應的索引i,對應table[i]中已經有值時,進入迴圈體
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //判斷是否存在本次插入的key,如果存在用本次的value替換之前oldValue
            //並返回之前的oldValue
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //如果本次新增key之前不存在於HashMap中,modCount加1,說明結構改變了
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }
    void addEntry(int hash, K key, V value, int bucketIndex) {
        //如果增加一個元素會後,HashMap的大小超過閾值,需要resize
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //增加的幅度是之前的1倍
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }
    void createEntry(int hash, K key, V value, int bucketIndex) {
        //首先得到該索引處的衝突鏈Entries,有可能為null,不為null
        Entry<K,V> e = table[bucketIndex];
        //然後把新的Entry新增到衝突鏈的開頭,也就是說,後插入的反而在前面(第一次還真沒看明白)
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }
    //下面看看HashMap是如何進行resize,廬山真面目就要揭曉了
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //如果已經達到最大容量,那麼就直接返回
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        //initHashSeedAsNeeded(newCapacity)的返回值決定了是否需要重新計算Entry的hash值
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        //遍歷當前的table,將裡面的元素新增到新的newTable中
        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];
                //最後這兩句用了與put放過相同的技巧
                //將後插入的反而在前面
                newTable[i] = e;
                e = next;
            }
        }
    }
    /**
     * Initialize the hashing mask value. We defer initialization until we
     * really need it.
     */
    final boolean initHashSeedAsNeeded(int capacity) {
        boolean