1. 程式人生 > >Java HashMap工作原理及實現(二)

Java HashMap工作原理及實現(二)

類宣告

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


功能和特點

  1. 實現AbstractMap抽象類。Map的一些操作這裡面已經提供了預設實現,後面具體的子類如果沒有特殊行為,可直接使用AbstractMap提供的實現。
  2. 實現MapCloneSerializable介面。支援拷貝和序列化。支援Map常見的增刪查改。
  3. HashMap是陣列和連結串列的折中,既保證了幾乎的時間複雜度,也保證了插入和刪除的時間複雜度為。

基本概念

HashMap內部,採用了陣列+連結串列的形式來組織鍵值對Entry <Key,Value>

HashMap內部維護了一個Entry[] table 陣列,當我們使用 new HashMap()建立一個HashMap時,Entry[] table的預設長度為16。Entry[] table的長度又被稱為這個HashMap的容量(capacity);

對於Entry[] table的每一個元素而言,或為null,或為由若干個Entry<Key,Value>組成的連結串列。HashMap中Entry<Key,Value>的數目被稱為HashMap的大小(size

);

Entry[] table中的某一個元素及其對應的Entry<Key,Value>又被稱為桶(bucket);

HashMap的容量(即Entry[] table的大小)*載入因子(經驗值0.75)就是threshhold,當hashmap的size大於threshhold時,容量翻倍。

HashMap理解

基本思想

Hash計算

  1. 求key的hash值:
    //將key的hashcode高16位和低16位求異或
    static final int hash(Object key) { 
    int h; 
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 
    } 


  2. 尋找index
    index=(n-1) & hash (n表示hashmap資料結構中table陣列的長度)
    由於hashmap設計中,n總是2的冪次方,(n-1)對應的二進位制就是前面全是0,後面全是1,相與後,只留下hash的後幾位,正好在長度為n的陣列下標範圍內,例如:

為什麼需要將key的hashcode的高16為與第16為異或?
充分利用key的高位和低位(不然在利用hash求index的時候可能永遠也利用不上key的高位,主要是table的長度n的二進位制高位都是0,在求 (n-1)&hash 是利用不上key的hash的高位的),以最小的代價來降低衝突的可能性。
原話:we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage, as well as to incorporate impact of the highest bits that would otherwise never be used in index calculations because of table bounds.

  1. 根據KeyhashCode,可以直接定位到儲存這個Entry<Key,Value>的桶所在的位置,這個時間的複雜度為O(1);
  2. 在桶中查詢對應的Entry<Key,Value>物件節點,需要遍歷這個桶的Entry<Key,Value>連結串列,時間複雜度為O(n);或者遍歷紅黑樹,時間複雜度為O(logn);
    那麼,現在,我們應該儘可能地將第2個問題的時間複雜度O(n)降到最低,我們應該要求桶中的連結串列的長度越短越好!桶中連結串列的長度越短,所消耗的查詢時間就越低,最好就是一個桶中就一個Entry<Key,Value>物件節點就好了!

這樣一來,桶中的Entry<Key,Value>物件節點要求儘可能第少,這就要求,HashMap中的桶的數量要多了。

HashMap的桶數目,即Entry[]table陣列的長度,由於陣列是記憶體中連續的儲存單元,它的空間代價是很大的,但是它的隨機存取的速度是Java集合中最快的。我們增大桶的數量,而減少Entry<Key,Value>連結串列的長度,來提高從HashMap中讀取資料的速度。這是典型的拿空間換時間的策略。

但是我們不能剛開始就給HashMap分配過多的桶(即Entry[] table陣列起始不能太大),這是因為陣列是連續的記憶體空間,它的建立代價很大,況且我們不能確定給HashMap分配這麼大的空間,它實際到底能夠用多少,為了解決這一個問題,HashMap採用了根據實際的情況,動態地分配桶的數量

動態分配桶的數量,HashMap動態分配桶的數量的策略:
如果
HashMap的大小 > HashMap的容量(即Entry[] table的大小)*載入因子(經驗值0.75)
則 HashMap中的Entry[]table的容量擴充為當前的一倍;然後重新將以前桶中的Entry<Key,Value>連結串列重新分配到各個桶中。

容量翻倍,怎麼重新分配解決hash衝突?:容量翻倍後,重新計算每個Entry<Key,Value>的index,將有限的元素對映到更大的陣列中,減少hash衝突的概率。

你瞭解重新調整HashMap大小存在什麼問題嗎?:多執行緒的情況下,可能產生條件競爭(race condition)(雖然一般我們不使用HashMap在多執行緒環境中)。如果在多執行緒環境中使用HashMap,如果兩個執行緒都發現HashMap需要重新調整大小了,它們會同時試著調整大小。在調整大小的過程中,儲存在連結串列中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap並不會將元素放在連結串列的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing),即減少了從頭部開始遍歷到尾部的時間,提高了效能。如果條件競爭發生了,那麼就死迴圈了。

HashMap實現

常量

//預設的初始容量,必須是2的冪。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

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

//預設裝載因子,這個後面會做解釋
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//JDK1.8特有
//當hash值相同的記錄超過TREEIFY_THRESHOLD,會動態的使用一個專門的treemap實現來代替連結串列結構,使得查詢時間複雜度從O(n)變為O(logn)
static final int TREEIFY_THRESHOLD = 8;

//JDK1.8特有
//也是閾值同上一個相反,當桶(bucket)上的連結串列數小於UNTREEIFY_THRESHOLD 時樹轉連結串列
static final int UNTREEIFY_THRESHOLD = 6;
//JDK1.8特有
//樹的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然後為了避免(resizing 和 treeification thresholds) 設定成64
static final int MIN_TREEIFY_CAPACITY = 64;

//儲存資料的Entry陣列,長度是2的冪。看到陣列的內容了,接著看陣列中存的內容就明白為什麼博文開頭先複習資料結構了
transient Node<K,V>[] table;

transient Set<Map.Entry<K,V>> entrySet;

//map中儲存的鍵值對的數量
transient int size;

//Map結構被改變的次數
transient int modCount;

//需要調整大小的極限值(容量*裝載因子)。儲存的是下次entrySet大小的極限值。
int threshold;

//裝載因子,當Map結構中的bucket數等於capacity*loadFactor時,bucket數量翻倍。
final float loadFactor;

構造方法

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

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

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}


有四個構造器,除HashMap(int initialCapacity, float loadFactor)都是使用預設的載入因子構造。
HashMap(int initialCapacity, float loadFactor)中,載入因子是使用者設定的,並且根據使用者設定的載入因子和容量確定threshold。
確定threshold的方法是tableSizeFor,保證threshhold是2的冪次方(大於或等於initialCapacity的最小的2的冪次方)。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}


先將cap-1保證最後的結果是大雨或等於cap的最小的2的冪次方,例如輸入的本來就是一個2的冪次方的數,比如4,如果不先-1,則會輸出8,-1就會輸出4。
為什麼每次移動位數的分別是1,2,4,8,16位?先移動一位,並做或運算,將最高位上的二進位制1移動到次高位;再右移兩位,將最高位和次高位上的二進位制11移動到與次高位相鄰的兩位上,以此類推,最後保證最改為和比最高位的所有二進位制位全部是1,在返回時,+1,就保證這個書是2的冪次方。
為什麼沒有移動32位?正整數的最大2的冪次方是$2^16$次方。

tableSizeFor是一個求大於或等於給定數的最小2的冪次方的最快方法。實用的演算法!

節點資料結構

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


繼承自Map.Entry,主要功能:節點的初始化,set方法,重寫hashCode和equals方法。是所有操作的基礎

核心方法

put

public V put(K key, V value) {
    //傳入key的hash值
    return putVal(hash(key), key, value, false, true);
}

/**
 * hash key的hash值
 * key 鍵
 * value 值
 * onlyIfAbsent true時,不改變已經存在的值
 * evict false時,table在建立模式中
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // tab為空則建立table
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 計算index,當index所在bucket沒有資料null,則直接將index位置設定為傳入的key-value。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 節點存在,並且key值相等,直接覆蓋
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //節點中的資料為TreeNode的例項,則是使用紅黑樹優化的結構
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
       //節點中的資料不是TreeNode的例項,是普通的單鏈表結構
        else {
            for (int binCount = 0; ; ++binCount) {
                //不斷遍歷,沒有找到相同的key,則直接加到連結串列或的後一個節點
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) //-1 for 1st 超過TREEIFY_THRESHOLD,則將連結串列變為樹結構,提高衝突鏈效率
                        treeifyBin(tab, hash);
                    break;
                }
                //如果找到key,後面直接覆蓋
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // 找到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;
}


put函式大致的思路為:
1. 對key的hashCode()做hash,然後再計算index;
2. 如果沒碰撞直接放到bucket裡;
3. 如果碰撞了,以連結串列的形式存在buckets後;
4. 如果碰撞導致連結串列過長(大於等於TREEIFY_THRESHOLD),就把連結串列轉換成紅黑樹;
5. 如果節點已經存在就替換old value(保證key的唯一性)
6. 如果bucket滿了(超過load factor*current capacity),就要resize

resize

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;

        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //探測:容量翻倍後還是小於MAXIMUM_CAPACITY,並且原來的容量大於等於預設容量。則threshold翻倍,容量翻倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // 初始化的容量被加入到threshold中,則新的容量等於就得threshold
            newCap = oldThr;
        else {               // threshold=0,即threshold未被使用過。
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {//表明
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

(e.hash & oldCap) == 0是擴容的關鍵點,因為容量擴充套件為原來的兩倍,相當於oldCap<<1,所以計算hash時,需要考慮的二進位制位數向高位多增加了一位(相當於求hash的掩碼由以前的前x位為0,後32-x位1變為前x-1位0,32-x+1位1),為了避免重複計算hash(key)和(n-1)&hash,直接判斷key的hash在增加位上的值是否為1(通過e.hash & oldCap,得到增加位上,key的hash值。),如果為1,索引的二進位制位的增加位也為1,如果為0,則索引的增加位也是0。既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket。 

例如:


其中增加位為紅色。
經過擴容重新分配 ,原來在一個bucket的index 5,分配到不同的index=21的bucket,避免與index=5的key衝突,提高了查詢的效率。

resize的策略:
1. 容量超過最大容量,容量不變,threshold變為最大整數
2. 容量翻倍後還是小於最大容量,並且原來的容量大於等於預設容量。則threshold翻倍,容量翻倍(大多數情況)。
3. 初始化了容量和threshold,新的容量=原來的threshold
4. 容量和threshold均為使用過(常見情況),則直接分配預設的容量和threshold。
5. 將原來的資料重新調整分配到新的table中。
6. 根據原來每個hash值是否有衝突,和衝突節點是否是樹結構儲存,分為不同的方式。

putMapEntries

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        if (table == null) { // pre-size
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize();
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

putMapEntries是一個預設訪問許可權的final型別函式,表示該函式只能在它所在的包內訪問,並且該方法不能被過載。

java訪問許可權複習:
java的訪問許可權有:public,protected,private,預設。

  • public是公開訪問,所有的包中的類均可訪問;
  • protected是繼承訪問,對於同一個包的類,這個類的方法或變數是可以被訪問的;對於不同包的類,只有繼承於該類的類才可以訪問到該類的方法或者變數;
  • private只能在該類本身中被訪問,在類外以及其他類中都不能顯示地進行訪問;
  • 預設訪問許可權是包訪問許可權,只有本包內的類可以訪問。

get

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

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


getNode的核心流程:

  • bucket裡的第一個節點=key,直接命中;
  • 如果有衝突,則通過key.equals(k)去查詢對應的entry
    • 若為樹,則在樹中通過key.equals(k)查詢,O(logn);
    • 若為連結串列,則在連結串列中通過key.equals(k)查詢,O(n)。

final關鍵字:

  • 修飾變數:變數的引用不能變,但是可以改變引用值;成員變數必須在構造器中初始化;
  • 修飾函式:把方法鎖定,以防任何繼承類修改它的含義;提高效率效率。在早期的Java實現版本中,會將final方法轉為內嵌呼叫。但是如果方法過於龐大,可能看不到內嵌呼叫帶來的任何效能提升。在最近的Java版本中,不需要使用final方法進行這些優化了。
  • 修飾類:類不能被繼承。

常見問題

  1. 為什麼String, Interger這樣的wrapper類適合作為鍵?
    String, Interger這樣的wrapper類作為HashMap的鍵是再適合不過了,而且String最為常用。因為String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因為為了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那麼就不能從HashMap中找到你想要的物件。不可變性還有其他的優點如執行緒安全。如果你可以僅僅通過將某個field宣告成final就能保證hashCode是不變的,那麼請這麼做吧。因為獲取物件的時候要用到equals()和hashCode()方法,那麼鍵物件正確的重寫這兩個方法是非常重要的。如果兩個不相等的物件返回不同的hashcode的話,那麼碰撞的機率就會小些,這樣就能提高HashMap的效能。
  2. 我們可以使用自定義的物件作為鍵嗎?
    這是前一個問題的延伸。當然你可能使用任何物件作為鍵,只要它遵守了equals()和hashCode()方法的定義規則,並且當物件插入到Map中之後將不會再改變了。如果這個自定義物件時不可變的,那麼它已經滿足了作為鍵的條件,因為當它建立之後就已經不能改變了。
  3. 我們可以使用CocurrentHashMap來代替Hashtable嗎?Hashtable是synchronized的,但是ConcurrentHashMap同步效能更好,因為它僅僅根據同步級別對map的一部分進行上鎖。ConcurrentHashMap當然可以代替HashTable,但是HashTable提供更強的執行緒安全性。

總結

  1. HashMap是執行緒不安全的,如果想使用執行緒安全的,可以使用Hashtable;它提供的功能和Hashmap基本一致。HashMap實際上是一個Hashtable的輕量級實現;
  2. 允許以Key為null的形式儲存<null,Value>鍵值對;
  3. HashMap的查詢效率非常高,因為它使用Hash表對進行查詢,可直接定位到Key值所在的桶中;
  4. 使用HashMap時,要注意HashMap容量和載入因子的關係,這將直接影響到HashMap的效能問題。載入因子過小,會提高HashMap的查詢效率,但同時也消耗了大量的記憶體空間,載入因子過大,節省了空間,但是會導致HashMap的查詢效率降低。
  5. 通過對key的hashCode()進行hashing,並計算下標( n-1 )& hash,從而獲得buckets的位置。如果產生碰撞,則利用key.equals()方法去連結串列或樹中去查詢對應的節點。
  6. 在JDK8裡,新增預設為8的TREEIFY_THRESHOLD閥值,當一個桶裡的Entry超過閥值,就不以單向連結串列而以紅黑樹來存放以加快Key的查詢速度。