HashMap在程式碼編寫和麵試過程中都經常用到,所以有必要總結一下其原始碼
1.1屬性
預設初始容量大小,一定為2的冪次
//The default initial capacity - MUST be a power of two. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
預設最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
預設載入因子大小
//The load factor used when none specified in constructor. static final float DEFAULT_LOAD_FACTOR = 0.75f;
預設紅黑樹的閾值
static final int TREEIFY_THRESHOLD = 8;
預設非樹形閾值
static final int UNTREEIFY_THRESHOLD = 6;
預設轉紅黑樹時當前最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
hashmap中的結點
static class Node<K,V> implements Map.Entry<K,V>{...}
hashmap的鍵值對Node陣列
transient Node<K,V>[] table;
鍵值對的集合!
transient Set<Map.Entry<K,V>> entrySet;
當前hashmap中鍵值對的數量
transient int size;
hashmap被修改的次數!
transient int modCount;
注意這個值當且僅當當前的hashmap結構發生改變的時候會加一,這些結構改變主要包括:新增或刪除元素,rehash(擴容)等,主要用在迭代器使用過程中的 fail_fast機制!
舉個例子,以下程式碼中獲取了hashmap的key集合的迭代器,並在迭代過程中通過hashmap本身刪除了一個key為2的鍵值對,這樣就會導致迭代器在迭代過程中 出現 預期modcount 不等於現在的modcount!最終 throw new ConcurrentModificationException()!注意這種情況不論在單執行緒多執行緒都會發生,即在使用迭代器的時候一定要通過迭代器本身進行修改!該同步同步!public static void main(String[] args){ HashMap<Integer, Integer> hashMap = new HashMap<>(); hashMap.put(1,1); hashMap.put(2,2); hashMap.put(3,3); hashMap.put(4,4); Iterator<Integer> iterator = hashMap.keySet().iterator(); while(iterator.hasNext()){ Integer key= iterator.next(); if(key== 2){ hashMap.remove(key); } } }
下一次擴容的大小
int threshold;// = capacity * load factor
載入因子大小
final float loadFactor;
1.2 核心方法
建構函式
通過觀察以下的建構函式我們可以發現,前面三種都只是在初始化容量和載入因子,並沒有真正去開闢一個node陣列!即使用時建立!- 建構函式一,兩者都指定
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(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
- 建構函式三,空構造器
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
- 建構函式四,使用map作為引數
public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
- 建構函式一,兩者都指定
計算key的hash值——擾動函式!
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
這個方法主要是用來和陣列的長度取餘找到存放的下標的!畢竟如果要存放全部的整數的話記憶體是放不下將近40億的空間的。而尋找這個下標的方法就是 hash & (length -1),這也印證陣列的容量為什麼是2的冪次(使用位運算能加快運算速度!)這樣的結果就是擷取了當前key的hash的低位!!!高位全部為零。
但這也同樣帶了問題,如果插入的某些key在低位上具有某種規律性,這樣會導致當前的hash衝突十分嚴重!所以,下面的這個hash方法採用低16位與高16位進行異或運算,混合原始雜湊碼的高位和低位,以此加大低位隨機性,而混合後的低位摻雜了高位的資訊,即高位的資訊也被變相的保留下來!之後在與長度進行位運算,衝突概率就會降低!下面展示了hash方法的執行過程!
- 將指定容量擴充至2的冪次
通過不斷地或運算將指定容量擴充至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; }
- 核心方法——put方法
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); //onlyIfAbsent預設為false,即覆蓋已存在的key的value }
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) //首先進行node陣列的判斷,如果為空或者長度為0則通過resize方法建立,印證了上面建構函式中使用才建立! n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) //如果要新增的對應下標為空,ok,則說明沒有衝突,可以新增 tab[i] = newNode(hash, key, value, null); else { //此時說明有衝突! Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) //在這判斷兩者的key是否是等價的!!! //注意這裡就是為什麼自己建立的物件需要重寫hashcode和equals方法!!! //如果兩者不重寫,會導致hashmap將等價物件判定為不同物件!!!!或者說插入重複物件! e = p;//兩個物件等價!!! else if (p instanceof TreeNode) //兩者不等價,需要在此節點往後插,先判斷當前節點是否是樹結點!是的話採用樹結點的插入方式 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //不是樹結點,ok ,在這個連結串列上不斷尋找與待插入結點等價的結點 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { //此時說明遍歷完這個連結串列也沒有等價的,那我就再最後一個插入吧!即尾插法! p.next = newNode(hash, key, value, null); //尾部插入中!!!! if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); //如果說連結串列結點達到了8,需要轉紅黑樹! break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) //找到這個連結串列中與待插入結點等價的結點!!直接break break; p = e; } } if (e != null) { // existing mapping for key 即說明連結串列中存在一個與其等價的節點啦! V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value;//進行覆蓋!!!! afterNodeAccess(e); return oldValue; //覆蓋完了直接返回,因為並沒有做數量上的調整! } } ++modCount;//新增之後導致map數量增加,即修改次數加一! if (++size > threshold)//如果新增後的容量等於擴容容量,即需要擴容! resize(); afterNodeInsertion(evict); return null; }
- resize方法
注意jdk1.8之前採用的是頭插法,容易造成連結串列死迴圈!之後改成尾插法,解除隱患!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; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //兩倍擴容!!! oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults 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; //從這開始,才是真正的resize!!!即擴容! 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; //即當前下標只有一個結點,直接rehash過去就好了 else if (e instanceof TreeNode) //如果說是樹結點,執行對應方法 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order //說明此時是長度至少為2的連結串列 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { //這個dowhile在做舊結點往新節點的移動!並且採用尾插法 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; //舊結點hash小於oldcap的不需要移動! } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; //大於hash的直接放到當前下標 + oldcap位置即可! } } } } } return newTab; }
先看下舊版本的擴容方法
void transfer(Entry[] newTable){ //複製一個原陣列src,Entry是一個靜態內部類,有K,V,next三個成員變數
Entry[] src = table; //陣列新容量
int newCapacity = newTable.length;:
// 從OldTable裡摘一個元素出來,然後放到NewTable中
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;//next是當前元素下一個
int i = indexFor(e.hash, newCapacity);//i是元素在新陣列的位置
e.next = newTable[i];//此處體現了頭插法,當前元素的下一個是新陣列的頭元素
newTable[i] = e;//將原陣列元素加入新陣列
e = next;//遍歷到原陣列某一位置下的一串元素的下一個
} while (e != null);
}
}
}
接下來圖示單執行緒情況下,do迴圈內的情況:
初始:當前陣列容量為2,有三個元素3、7、5,此處的hash演算法是簡化處理(對容量取模)。因此,3、7、5都在陣列索引1對應的連結串列上。
擴容新容量為2*2=4。
第一步:當前Entry e對應3,next對應7,新位置i為3,然後將3插入新陣列對應位置。
第二步:當前Entry e對應7,next對應5,新位置i為3,然後將新陣列對應索引處的元素3新增到7的尾巴後(頭插),然後將7插入新陣列對應位置。
第三步:當前Entry e對應5,next對應null,新位置i為1, 然後將5插入新陣列對應位置。
接下來圖示多執行緒情況下死迴圈場景:初始條件相同。如果有兩個執行緒:
執行緒一執行到 Entry<K,V> next = e.next; 便掛起了,即此時Entry e是3,next是7,3是在7前面的。
執行緒二執行完成。
此時如下圖所示,執行緒一的3的next是7,而執行緒二的7的next是3。(此處是Entry裡的next成員變數,在多個執行緒中相同Entry不衝突)。此時可以看出出現了死迴圈問題。
本作品採用《CC 協議》,轉載必須註明作者和本文連結