Java集合系列之---HashMap

Marksman發表於2019-04-16

最近想把集合原始碼好好看看,那就從HashMap開始吧!


HasMap的屬性

先看下HashMap的繼承體系,它繼承自抽象類AbstractMap,實現了Map、Cloneable、Serializable介面,還有較常用的子類LinkedHashMap也實現了Map介面。

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable{...
public abstract class AbstractMap<K,V> implements Map<K,V> {...
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>{...
複製程式碼

再看看HashMap的成員變數和一些預設值:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 預設的初始化陣列大小,16
static final int MAXIMUM_CAPACITY = 1 << 30; // HashMap的最大長度
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 負載因子的預設值
static final Entry<?,?>[] EMPTY_TABLE = {}; // Entry陣列預設為空
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; // Entry陣列
transient int size; // map中key-value 鍵值對的數量
int threshold; // 閾值,即table.length 乘 loadFactor
final float loadFactor; //負載因子,預設值為 DEFAULT_LOAD_FACTOR = 0.75 
transient int modCount; // HashMap結構被修改的次數
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE; // 閾值的預設值
HashMap.Holder.trasient int hashSeed;//翻譯過來叫雜湊種子,是一個隨機數,它能夠減小hashCode碰撞
// 的機率,預設為0,表示不能進行選擇性雜湊(我也不知道是啥意思)
複製程式碼

所以我們用預設構造方法new 出來的HashMap(),長度預設為16,閾值為12,並且size達到threshold,就會resize為原來的2倍。

  再看下HashMap的一些重要的內部類:

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;
    }
}
複製程式碼

Entry實現了Map的內部介面Entry,它有四個屬性,key、value、Entry、hash,是HashMap內陣列每個位置上真正存放元素的資料結構。

private final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
    public Iterator<Map.Entry<K,V>> iterator() {
        return newEntryIterator();
    }
    public boolean contains(Object o) {
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry<K,V> e = (Map.Entry<K,V>) o;
        Entry<K,V> candidate = getEntry(e.getKey());
        return candidate != null && candidate.equals(e);
    }
    public boolean remove(Object o) {
        return removeMapping(o) != null;
    }
    public int size() {
        return size;
    }
    public void clear() {
        HashMap.this.clear();
    }
}
複製程式碼

EntrySet 繼承了AbstractSet,它內部有個迭代器iterator,可以獲取Entry物件,方法contains用來判斷所給的物件是否包含在當前EntrySet中。

put、get、resize方法原始碼分析

我們知道HashMap,在jdk1.8之前底層用陣列+連結串列實現的,jdk1.8改成了陣列+連結串列+紅黑樹實現,以避免長連結串列帶來的遍歷效率低問題。

JDK-1.7下的原始碼

  • put()方法
public V put(K key, V value) {        
&emsp;&emsp;if (table == EMPTY_TABLE) { // 首先判斷陣列若為空,則建立一個新的陣列
  &emsp;&emsp;inflateTable(threshold);
   }
   if (key == null) // 如果key為null,遍歷table陣列,如果找出key=null的位置,將value覆 
   // 蓋,並返回舊的value,否則呼叫addEntry()將它儲存到table[0]位
       return putForNullKey(value);
   int hash = hash(key); // 若key!=null,則計算出hashCode,算出下標 index,遍歷table
   int i = indexFor(hash, table.length);
   for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 若找到hashCode與當前key的hashCode相等,並且key值也相同,
        // 那就覆蓋value的值,並且放回oldValue
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
   }
   modCount++;
   // 若沒滿足(4)中的條件,則呼叫方法addEntry(...)
   addEntry(hash, key, value, i); 
        return null;
}

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

static int indexFor(int h, int length) {
   // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; 
   // 長度必須是2的非零冪
    return h & (length-1); //table陣列的下標計算:hashCode與(table陣列長度減一)做與(&)運算
}

&運算,即同是1才為1,否則為0
例如:h1=3 h2=20 length=16
     h1:        0011
&emsp;   h2:       10100
&emsp;   length-1:  1111
     h1(index): 0011 = 3
&emsp;   h2(index): 0100 = 4
這樣運算得出的index就是捨棄了hashCode一部分高位的hash的值

複製程式碼

若indexFor計算出來的下標在陣列中不為空並且size達到閾值,則擴容,然後在index位置建立一個Entry,將key-value放進去。

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length); 
        hash = (null != key) ? hash(key) : 0; // null的hashCode為0
        bucketIndex = indexFor(hash, table.length); 
    }
    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++;
}
複製程式碼
  • get()方法
public V get(Object key) {
    if (key == null) // 如果key為null,則判斷HashMap中是否有值,若沒有直接返回null
        return getForNullKey();
    Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); 
    // 最後獲取Entry的value,並返回
}

private V getForNullKey() { // 若有就遍歷table陣列,找到null對應的value並返回
    if (size == 0) {
        return null;
    }
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null)
            return e.value;
    }
    return null;
}

// 若key不為null,則獲取Entry,也就是一個遍歷table陣列命中的過程
final Entry<K,V> getEntry(Object key) { 
    if (size == 0) {
        return null;
    }
    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;
}
複製程式碼
  • resize()方法
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
// 首先將當前物件的一些屬性儲存起來,如果當前HashMap的容量達到最大值,那就無法擴容了,將閾值 
// 設定為Integer的最大值並結束方法
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    
// 否則建立新的Entry陣列,長度為newCapacity,在addEntry()方法中,
// 我們知道 newCapacity = 2  * table.length
    Entry[] newTable = new Entry[newCapacity]; 
    
// 然後呼叫transfer()方法,此方法的作用是將當前陣列中的Entry轉移到新陣列中
    transfer(newTable, initHashSeedAsNeeded(newCapacity)); 
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
複製程式碼

在存入key-value時會呼叫initHashSeedAsNeeded()方法判斷是否需要rehash,該方法的過程見註釋,好吧,我也不知道為什麼這樣處理得出的結果就能 判斷是否需要rehash,後面就是根據rehash重新計算下標,並將key-value存入新的table中。

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    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];
            newTable[i] = e;
            e = next;
        }
    }
}
/**
 * Initialize the hashing mask value. We defer initialization until we really need it.
 */
final boolean initHashSeedAsNeeded(int capacity) { 
    boolean currentAltHashing = hashSeed != 0; // 當前雜湊種子是否為0
    boolean useAltHashing = sun.misc.VM.isBooted() && 
                 // 虛擬機器是否啟動,當前陣列容量是否大於閾值
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    boolean switching = currentAltHashing ^ useAltHashing; // 做異或運算
    if (switching) { 
        // 重置雜湊種子
        hashSeed = useAltHashing ? sun.misc.Hashing.randomHashSeed(this) : 0; 
    }
    return switching; // 返回異或運算的結果,作為是否rehash的標準
}
複製程式碼

JDK-1.8下的原始碼

jdk1.8中將Entry改為Node節點來實現的,屬性都是一樣的。

  • put()方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, 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;
    
    // 如果陣列是null或者陣列為空,就呼叫resize()進行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length; 
        
        // (n-1)&hash 算出下表,這個和1.7是一樣的
    if ((p = tab[i = (n - 1) & hash]) == null)
    
    // 如果當前計算出來的位置為null,就新建一個節點
        tab[i] = newNode(hash, key, value, null);  
    else {
        Node<K,V> e; K k;
        if (p.hash == hash && ((k = p.key) == key || 
        
    // 若計算出來的位置上不為null,它和傳入的key相比,hashCode相等並且key也相等
    (key != null && key.equals(k))))
    // 那麼將p賦給e
      e = p; 
        
        // 如果p是樹型別
        else if (p instanceof TreeNode) 
        
            // 則按照紅黑樹的結構存入進去
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 
        else {
        
            // 遍歷p,p是連結串列
            for (int binCount = 0; ; ++binCount) { 
            
                // 如果p的下一個節點是尾節點(尾節點.next=null)
                if ((e = p.next) == null) {
                
    // 在p的後面建立一個節點,存放key/value(尾插法,多執行緒併發不會形成迴圈連結串列)
                    p.next = newNode(hash, key, value, null); 
                    
// TREEIFY_THRESHOLD =  8,即當binCount達到7時轉換成紅黑樹資料結構,因為binCount是從0開始 
// 的,達到7時p連結串列上就有8個節點了,所以是連結串列上達到8個節點時會轉變成紅黑樹。
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                    
                // 這裡先就不展開了,紅黑樹不會,有時間再研究
                        treeifyBin(tab, hash); 
                    break;
                }
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                    
// 若上面兩個條件都不滿足,此時e=p.next,也就是將p的下一個節點賦給p,進入下一次迴圈
                p = e; 
            }
        }
// existing mapping for key,jdk這段註釋意思是存在key的對映,我的理解是傳入的key 在p位置找 
// 到它自己的坑被別人佔了
        if (e != null) { 
            V oldValue = e.value;
            
            // 下面就是將value存入被佔的位置,並將舊的value返回
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); 
            return oldValue;
        }
    }
    
    // 修改次數加一
    ++modCount; 
    
    // 若已有的鍵值對數大於閾值,就擴容
    if (++size > threshold) 
        resize();
    afterNodeInsertion(evict);
    return null;
}
複製程式碼

put方法流程圖如下:

HashMap put()方法執行流程.png-81.7kB

  • 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;
}
複製程式碼

get()方法也沒什麼,就是根據key的hashCode算出下標,找到對應位置上key與引數key是否相等,hash是否相等,如果是樹就獲取樹的節點,如果是連結串列就遍歷直到找到為止,找不到就返回null。

  • resize()方法
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table; 
    
    // oldCap就是原陣列的長度
    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;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                  oldCap >= DEFAULT_INITIAL_CAPACITY)
                  
            // double threshold 擴容成兩倍
            newThr = oldThr << 1; 
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
// zero initial threshold signifies using defaults, 這裡表示初始化resize的另一個作用
    else {       
        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變數
    table = newTab; 
    if (oldTab != null) {
    
        // 遍歷原陣列
        for (int j = 0; j < oldCap; ++j) { 
            Node<K,V> e;
            
            // 將不為null的j位置的元素指向e節點
            if ((e = oldTab[j]) != null) { 
                oldTab[j] = null;
                if (e.next == null)
                
    // 若e是尾節點,或者說e後面沒有節點了,就將e指向新陣列的e.hash&(newCap-1)位置
                    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;
                        
                        // e.hash&oldCap 的值要麼是0要麼是oldCap ###
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                            // 第一次進來,先確定頭節點,以後都走else,loHead指向e
                                loHead = e; 
                            else
                            
// 第二次進來時loTail的next指向e(e=e.next),注意此時loHead的地址和loTail還是一樣的,
// 所以loHead也指向e,也就是說e被掛在了loHead的後面(尾插法,不會形成迴圈連結串列),
// 以此類推,後面遍歷的e都會被掛在loHead的後面。
                               loTail.next = e;
                               
// loTail指向e,第一次進來時頭和尾在記憶體中的指向是一樣的都是e,第二次進來時,
// loTail指向了e(e=e.next),這時和loHead.next指向的物件是一樣的,所以下一次
// 進來的時候loHead可以找到loTail.next,並將e掛在後面。
// 這段不明白的可以參考:https://blog.csdn.net/u013494765/article/details/77837338。
                            loTail = e;  
                        }
                        else { // 和if裡面的原理是一樣的
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) { 
                        loTail.next = null;
                        newTab[j] = loHead; // 將loHead節點存到新陣列中原下標位置
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 將hiHead節點存到新陣列中 [原下標+原陣列長度] 的位置
                        newTab[j + oldCap] = hiHead; 
                    }
                }
            }
        }
    }
    return newTab;
}
複製程式碼

這裡爭對 ### 標註的程式碼詳細講下:

為什麼(e.hash&oldCap) == 0為true或false就能判斷存放的位置是newTab[原下標],還是newTab[原下標+原陣列長度],而不用像jdk1.7那樣每次都要rehash?

jdk1.8_HashMap_resize_過程.png-43.7kB
    

JDK-1.7多執行緒併發形成迴圈連結串列問題

jdk1.7_HashMap_resize_形成迴圈連結串列過程.png-32.4kB

併發訪問HashMap會出現哪些問題,如何解決呢?

經過上面分析,我們知道jdk1.8已經不會在多執行緒下出現迴圈連結串列問題了,那還會出現哪些問題呢?   如:資料丟失、結果不一致...... 解決方案:

  • HashTable

用synchronized鎖住整個table,效率太低,不好。

  • Collections.SynchronizedMap()

它是對put等方法用synchronized加鎖的,效率一般是不如ConcurrentHashMap的,用的不多。

  • ConcurrentHashMap

jdk1.8以前底層是陣列+連結串列,並採用鎖分段,segment,每次對要操作的那部分資料加鎖,並且get()是不用加鎖的,這效率就高多了;

jdk1.8開始底層採用陣列+連結串列+紅黑樹,並放棄鎖分段,而採用CAS + synchronized技術實現,效率更高 。具體實現原理,且聽下回分解。

最後:文中若有寫的不對或者不好的地方,請各位看官指出,謝謝。

參考:

  1. HashMap? ConcurrentHashMap? 相信看完這篇沒人能難住你!
  2. Java8系列之重新認識HashMap
  3. Java 1.8中HashMap的resize()方法擴容部分的理解

相關文章