1. 程式人生 > >HashMap(Java 7)的實現原理

HashMap(Java 7)的實現原理

一、HashMap的定義和建構函式

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

  HashMap繼承自AbstractMap,AbstractMap是Map介面的骨幹實現,AbstractMap中實現了Map中最重要最常用和方法,這樣HashMap繼承AbstractMap就不需要實現Map的所有方法,讓HashMap減少了大量的工作。 
而在這裡仍然實現Map結構,沒有什麼作用,應該是為了讓map的層次結構更加清晰

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內部經常看到

HashMap的四個建構函式

  public HashMap():構造一個具有預設初始容量 (16) 和預設載入因子 (0.75) 的空 HashMap 
  public HashMap(int initialCapacity):構造一個帶指定初始容量和預設載入因子 (0.75) 的空 HashMap 
  public HashMap(int initialCapacity, float loadFactor):構造一個帶指定初始容量和載入因子的空 HashMap 
  public HashMap(Map< ? extends K, ? extends V> m):構造一個對映關係與指定 Map 相同的新 HashMap

  這裡有兩個很重要的引數:initialCapacity(初始容量)、loadFactor(載入因子),看看JDK中的解釋: 
  HashMap 的例項有兩個引數影響其效能:初始容量 和載入因子。 
  容量 :是雜湊表中桶的數量,初始容量只是雜湊表在建立時的容量,實際上就是Entry< K,V>[] table的容量 
  載入因子 :是雜湊表在其容量自動增加之前可以達到多滿的一種尺度。它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用連結串列法的散列表來說,查詢一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼散列表的資料將過於稀疏,對空間造成嚴重浪費。系統預設負載因子為0.75,一般情況下我們是無需修改的。 
  當雜湊表中的條目數超出了載入因子與當前容量的乘積時,則要對該雜湊表進行 rehash 操作(即重建內部資料結構),從而雜湊表將具有大約兩倍的桶數

二、HashMap的資料結構

  我們知道在Java中最常用的兩種結構是陣列和模擬指標(引用),幾乎所有的資料結構都可以利用這兩種來組合實現,HashMap也是如此。實際上HashMap是一個“連結串列雜湊”,如下是它資料結構: 
這裡寫圖片描述

  從上圖我們可以看出HashMap底層實現還是陣列,只是陣列的每一項都是一條鏈。其中引數initialCapacity就代表了該陣列的長度。下面為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. 計算出大於初始容量的最小 2的n次方作為雜湊表table的長度,然後用該長度建立Entry陣列(table),這個是最核心的

  可以發現,一個HashMap對應一個Entry陣列,來看看Entry這個元素的內部結構: 
  

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

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

  Entry是HashMap的一個內部類,它也是維護著一個key-value對映關係,除了key和value,還有next引用(該引用指向當前table位置的連結串列),hash值(用來確定每一個Entry連結串列在table中位置)

三、HashMap的儲存實現put(K,V)

    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;
    }

  從上面的過程中,我們起碼可以發現兩點: 
  1. 如果為null,則呼叫putForNullKey:這就是為什麼HashMap可以用null作為鍵的原因,來看看HashMap是如何處理null鍵的: 
  

    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;
    }
    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);
    }

  2. 如果鏈中存在該key,則用傳入的value覆蓋掉舊的value,同時把舊的value返回:這就是為什麼HashMap不能有兩個相同的key的原因

  對於hash操作,最重要也是最困難的就是如何通過確定hash的位置,我們來看看HashMap的做法: 
  首先求得key的hash值:hash(key)

    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);
    }

  這是一個數學計算,可以不用深入,關鍵是下面這裡: 
  計算該hash值在table中的下標

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

對於HashMap的table而言,資料分佈需要均勻(最好每項都只有一個元素,這樣就可以直接找到),不能太緊也不能太鬆,太緊會導致查詢速度慢,太鬆則浪費空間。計算hash值後,怎麼才能保證table元素分佈均與呢?我們會想到取模,但是由於取模的消耗較大,而HashMap是通過&運算子(按位與操作)來實現的:h & (length-1)

  在建構函式中存在:capacity <<= 1,這樣做總是能夠保證HashMap的底層陣列長度為2的n次方。當length為2的n次方時,h&(length - 1)就相當於對length取模,而且速度比直接取模快得多,這是HashMap在速度上的一個優化。至於為什麼是2的n次方下面解釋。 
  我們回到indexFor方法,該方法僅有一條語句:h&(length - 1),這句話除了上面的取模運算外還有一個非常重要的責任:均勻分佈table資料和充分利用空間。 
  這裡我們假設length為16(2^n)和15,h為5、6、7。 
  這裡寫圖片描述 
   
  當length-1 = 14時,6和7的結果一樣,這樣表示他們在table儲存的位置是相同的,也就是產生了碰撞,6、7就會在一個位置形成連結串列,這樣就會導致查詢速度降低詳細地看看當length-1 = 14 時的情況: 
  這裡寫圖片描述 
  可以看到,這樣發生發生的碰撞是非常多的,1,3,5,7,9,11,13都沒有存放資料,空間減少,進一步增加碰撞機率,這樣就會導致查詢速度慢, 
  分析一下:當length-1 = 14時,二進位制的最後一位是0,在&操作時,一個為0,無論另一個為1還是0,最終&操作結果都是0,這就造成了結果的二進位制的最後一位都是0,這就導致了所有資料都儲存在2的倍數位上,所以說,所以說當length = 2^n時,不同的hash值發生碰撞的概率比較小,這樣就會使得資料在table陣列中分佈較均勻,查詢速度也較快。 
   
  然後我們來看看計算了hash值,並用該hash值來求得雜湊表中的索引值之後,如何把該key-value插入到該索引的連結串列中: 
  呼叫 addEntry(hash, key, value, i) 方法: 
  

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //如果size大於極限容量,將要進行重建內部資料結構操作,之後的容量是原來的兩倍,並且重新設定hash值和hash值在table中的索引值
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        //真正建立Entry節點的操作
        createEntry(hash, key, value, bucketIndex);
    }
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

  首先取得bucketIndex位置的Entry頭結點,並建立新節點,把該新節點插入到連結串列中的頭部,該新節點的next指標指向原來的頭結點 
   
  這裡有兩點需要注意: 
  一、鏈的產生 
  這是一個非常優雅的設計。系統總是將新的Entry物件新增到bucketIndex處。如果bucketIndex處已經有了物件,那麼新新增的Entry物件將指向原有的Entry物件,形成一條Entry鏈,但是若bucketIndex處沒有Entry物件,也就是e==null,那麼新新增的Entry物件指向null,也就不會產生Entry鏈了。 
  二、擴容問題 
  還記得HashMap中的一個變數嗎,threshold,這是容器的容量極限,還有一個變數size,這是指HashMap中鍵值對的數量,也就是node的數量

threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

  什麼時候發生擴容? 
  當不斷新增key-value,size大於了容量極限threshold時,會發生擴容 
  如何擴容? 
  擴容發生在resize方法中,也就是擴大陣列(桶)的數量,如何擴容參考:http://blog.csdn.net/jeffleo/article/details/63684953 
  

我們重新來理一下儲存的步驟:

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

四、HashMap的讀取實現get(key,value)

    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;
    }

讀取的步驟比較簡單,呼叫hash(key)求得key的hash值,然後呼叫indexFor(hash)求得hash值對應的table的索引位置,然後遍歷索引位置的連結串列,如果存在key,則把key對應的Entry返回,否則返回null

五、HashMap鍵的遍歷,keySet()

HashMap遍歷的核心程式碼如下:

    private abstract class HashIterator<E> implements Iterator<E> {
        Entry<K,V> next;        // next entry to return
        int expectedModCount;   // For fast-fail
        int index;              // current slot
        Entry<K,V> current;     // current entry

        //當呼叫keySet().iterator()時,呼叫此程式碼
        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                //從雜湊表陣列從上到下,查詢第一個不為null的節點,並把next引用指向該節點
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }

        public final boolean hasNext() {
            return next != null;
        }

        //當呼叫next時,會呼叫此程式碼
        final Entry<K,V> nextEntry() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();

            //如果當前節點的下一個節點為null,從節點處罰往下查詢雜湊表,找到第一個不為null的節點
            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
            current = e;
            return e;
        }

        public void remove() {
            if (current == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Object k = current.key;
            current = null;
            HashMap.this.removeEntryForKey(k);
            expectedModCount = modCount;
        }
    }

從這裡可以看出,HashMap遍歷時,按雜湊表的每一個索引的連結串列從上往下遍歷,由於HashMap的儲存規則,最晚新增的節點都有可能在第一個索引的連結串列中,這就造成了HashMap的遍歷時無序的。

原文連結:http://blog.csdn.net/jeffleo/article/details/54946424