HashMap部分原始碼及疑難問題解析(JDK8)
HashMap是Map(雙列集合)體系中極為重要的一個集合類,執行緒不安全,若需要執行緒安全則使用ConcurrentSkipListMap,較TreeMap擁有更好的查詢、插入效率,具體效率對比請看 Java Map遍歷方式的選——TreeMap、HashMap的key、value遍歷與效率分析
本文只對HashMap原始碼進行解析
目錄
底層資料結構
圖源https://www.cnblogs.com/ITtangtang/p/3948406.html
侵刪
HashMap底層資料結構是陣列+連結串列,即雜湊表,JDK8以後引入了紅黑樹作為補充,大多數情況下還是以雜湊表為主
如圖所示,每當有元素要新增進來時,便會通過hash演算法計算出來的值與陣列長度-1做按位與運算所計算出來的陣列索引(下面會提到)
雜湊表的好處是藉助雜湊碼(雜湊碼)大大優化了插入及查詢效率,試想,若沒有雜湊碼,則每次新增新元素都要呼叫equals方法進行資料的比較,在資料量達到一定量級時所產生的效能開銷是無法想象的
而通過比較雜湊碼則高可以省下許多比較次數,為什麼說是省下許多比較次數而不是一步到位呢?因為hash演算法所依賴的hashcode方法的返回值雖然是按照物件地址計算得出的,但是我們知道,兩個物件相同他們的hashcode返回值一定相同,但是兩個hashcode返回值相同時兩個物件未必是同一個物件
因而每當計算得到相同的hashcode返回值時便會使得這個新新增的元素被放到陣列的某個已有元素的索引處,這便稱之為雜湊碰撞,若比較equals及==後發現這不是同一個物件,便會以連結串列的節點的方式新增到原本已存在的元素的後面,後面再有要新增到這個連結串列的元素,便會迭代這張連結串列以檢查是否與已有元素重複,因此,陣列的每個元素既有可能只是一個普通的節點,也有可能是一個連結串列頭,甚至有可能會是紅黑樹的根節點(當這個連結串列的長度>8的時候,連結串列轉化為紅黑樹 下面會提到)
成員變數
- transient Node<K,V>[] table; // 底層資料結構中的陣列
- final float loadFactor; // 負載因子 用於評估雜湊表的使用程度
- static final float DEFAULT_LOAD_FACTOR = 0.75f; // 不指定負載因子大小時的預設值 一般不需要設定 預設就行
- static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 陣列的初始容量
- int threshold; // 臨界值(閥值) 臨界值 = 負載因子*table陣列長度 大於便擴容
值得一提的是
loadFactor設定的越大時,空間利用率便會更高,但同時也意味著發生雜湊碰撞的機率也會增大,一旦雜湊碰撞便會產生連結串列或紅黑樹,過長時查詢效率便會降低
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;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
構造方法都較為簡單,不設定負載因子便是預設的0.75
方法解析
這裡我們以put方法為起點,對HashMap的一些重要方法及疑難問題結合原始碼進行分析
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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 陣列為空則造一個 注意真正造
// table陣列是在resize方法裡面
if ((p = tab[i = (n - 1) & hash]) == null)
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))))
e = p;
else if (p instanceof TreeNode) // 判斷是否是紅黑樹
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { // 判斷是否是連結串列,若長度>8則轉紅黑樹
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { // 遍歷連結串列 若某個節點的next
//為空則新增到那個節點後面 若發現與已有節點重複則值替換並終止遍歷
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
呼叫put方法新增鍵值時,真正執行新增功能的是putVal方法,方法首先檢查table是否為空,為空則建立一個初始長度為16的Node<K,V>陣列(其實就是以前的Entry 只不過換了個名字),然後便開始計算新增元素應在的陣列位置,即p = tab[i = (n - 1) & hash],詳細計算過程看HashMap方法hash()、tableSizeFor() 這裡只貼出一個例子
圖源上面的連結 即HashMap方法hash()、tableSizeFor() 侵刪
若這個索引處為空則直接新增進去了,若不為空,則通過hashcode和equals方法及==判斷是否為同一物件,是則值替換,否則繼續判斷是紅黑樹還是連結串列(預設是連結串列)然後遍歷連結串列/紅黑樹看是否已有此物件,沒有則新增到連結串列尾部/紅黑樹的對應位置
一般情況下只是形成連結串列,當binCount >= TREEIFY_THRESHOLD - 1 即連結串列長度大於8(TREEIFY_THRESHOLD等於8)時,便呼叫 treeifyBin(tab, hash)將連結串列轉為紅黑樹儲存
最後if (++size >threshold){ resize(); }判斷是否需要擴容,若需要擴容則呼叫resize()方法重新造一個長度為原陣列長度兩倍的table陣列並將原資料重新新增到新陣列,其實擴容是十分消耗效能與記憶體的,畢竟要造一個兩倍原長度的陣列,再把陣列元素計算完新位置後,全部重新添加了一遍
注意看我標的註釋
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;
} // 看下面那一行newCap = oldCap << 1
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0)
newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 預設的陣列長度16就是這樣來的
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];//根據上面計算的newCap造陣列
table = newTab; // table此時更新為擴容後的陣列
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 {
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;
}
這裡特別引申出一個曾經困擾過我一天的知識點Java遍歷HashSet為什麼輸出是有序的? (HashSet底層呼叫的還是HashMap)
有時候當我們新增Integer型別資料進入HashMap中時,遍歷values時會發現資料有時候是有序的,而Map的putVal程式碼顯然並沒有進行排序,那麼這是為什麼呢?
當時看完知乎高贊還是迷迷糊糊的,現在總算明白了,當hashcode計算的是Integer型別時,返回的是整型值本身,比如Integer i = 3拿去hashcode,返回值還是3,而3的hash值就是它自己 當然這還與數值本身大小有關,只有0~65535之間才是
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
因此當我們新增i時,如果i的hash值小於陣列最大下標,會正好新增到這個下標處(n-1&hash),而數字的hash值就是它自己,如果i的hash值大於最大下標,也不會出現陣列越界異常,而是作為節點新增到某個節點後面形成一個連結串列,這就是n-1&hash的巧妙之處,但此時也因此不再排序 看下面一個例子
public class HashSetDemo {
public static void main(String[] args) {
HashSet<Integer> hs = new HashSet<Integer>(4); //HashSet底層呼叫的還是HashMap 這裡
//只是為了方便演示用了HashSet
hs.add(3);
hs.add(1);
// hs.add(9);
for (int i : hs) {
System.out.println(i);
}
}
}
此時執行
輸出結果是: 1 3 有序
那麼我們接著放開註釋 即hs.add(9);
輸出結果是: 1 9 3 無序
根據我們上面的推斷 新新增的9經過hashcode後得到的返回值依然是9,然後我們模擬計算table陣列的方式計算9新增到的位置
public class Test {
public static void main(String[] args) {
int tableLength = 4;
int i = 9;
System.out.println((tableLength-1)&i);
}
}
輸出為1 也就是新增到了1後面,與之前新增的1形成了連結串列,1位連結串列頭,9為下一節點
當table達到一定使用程度 即上面提到的if (++size > threshold) 便進行擴容 並將元素重新新增到新的陣列中,此時又是有序的了
public class HashSetDemo {
public static void main(String[] args) {
HashSet<Integer> hs = new HashSet<Integer>(4);
hs.add(3);
hs.add(1);
hs.add(9);
hs.add(2);
hs.add(10);
hs.add(11);
hs.add(12);
for (int i : hs) {
System.out.println(i);
}
}
}
輸出結果:1 2 3 9 10 11 12
至此,為什麼HashMap有時候是有序的原因我們便明白了 其實就是因為整型值經過hashcode後返回值是整型值本身,而計算下標的演算法(n -1)&hash得到的下標在數值不超過陣列長度的情況下與數值相同,這就間接導致了有時候我們得到的有序的 而有時候又是無序的
值的一提的是,n-1其實還另有玄機,當容量一定是2^n時,hash & (length - 1) == hash % length,在設計原始碼時,通過這一點利用位運算提升了取模的效率,另外,容量為2^n還保證了雜湊表的均勻性,因為當length為偶數時,二進位制尾數肯定是0,即2^n的二進位制肯定尾數是0,那麼(2^n)-1的二進位制尾數肯定是1,而我們知道,按位與運算 & 的規則是兩個資料對應的二進位制都為1則該為為1,否則該位為0,而(2^n)-1這種做法保證了尾數必為1,如果沒有這種保證,一旦table陣列長度為奇數,那麼hash&(table.length-1)得到的尾數必定是0,這便會浪費近半的空間,增加了發生雜湊碰撞的機率,這顯然不是我們希望看到的
你可能會問,你怎麼能保證容量一定是2^n,我們可以檢視原始碼 其實別人老早就設計好了,一環扣一環
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); // 注意這一句
}
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;
}
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)
newCap = oldThr; //看這一句
.....
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 看這一句
table = newTab;
.....
}
即使你指定的是非2^n的capacity,通過tableSizeFor方法也可以得到向上取的最接近的2^n,比如你輸入5得到的就是8,11得到的就是16,(具體演算法解析看上面提到的HashMap方法hash()、tableSizeFor())
當然你可能又會疑惑了。。這裡接收返回值的不是臨界值threshold嗎,實際容量沒變啊,那麼請你重新回看putVal的原始碼 ,其中有這麼一句
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
再結合上面的resize的註釋處 結果顯而易見,原來當我們新增元素時,table才初始化,且table長度一定是2^n
不得不感嘆 設計原始碼的人真牛逼 至此這幾天我碰到的學習難點基本都講完了
總結
- 根據實際情況我們可以調整負載因子的大小來選擇以空間換時間還是以時間換空間 一般情況下不用調
- HashMap新增整型值時的有序無序取決於數值大小及新增的時候是否超過了陣列下標
- 擴容很耗費效能和記憶體,建立新陣列後還要將原陣列中的連結串列或紅黑樹重新計算位置(不重新計算雜湊值)再插入到新的陣列
最後特別提醒,當Map儲存自定義物件時,重寫了equals方法務必還要重寫hashcode方法,這裡引用之前學習時看到的一個例子
public class MyTest {
private static class Person{
int idCard;
String name;
public Person(int idCard, String name) {
this.idCard = idCard;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()){
return false;
}
Person person = (Person) o;
//兩個物件是否等值,通過idCard來確定
return this.idCard == person.idCard;
}
}
public static void main(String []args){
HashMap<Person,String> map = new HashMap<Person, String>();
Person person = new Person(1234,"喬峰");
//put到hashmap中去
map.put(person,"天龍八部");
//get取出,從邏輯上講應該能輸出“天龍八部”
System.out.println("結果:"+map.get(new Person(1234,"蕭峰")));
}
}
實際輸出結果:
結果:null
如果我們已經對HashMap的原理有了一定了解,這個結果就不難理解了。儘管我們在進行get和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由於沒有重寫hashCode方法,所以put操作時,key(hashcode1)-->hash-->indexFor-->最終索引位置 ,而通過key取出value的時候 key(hashcode1)-->hash-->indexFor-->最終索引位置,由於hashcode1不等於hashcode2,導致沒有定位到一個數組位置而返回邏輯上錯誤的值null(也有可能碰巧定位到一個數組位置)
所以,在重寫equals的方法的時候,必須注意重寫hashCode方法,同時還要保證通過equals判斷相等的兩個物件,呼叫hashCode方法要返回同樣的整數值。而如果equals判斷不相等的兩個物件,其hashCode可以相同(只不過會發生雜湊衝突,應儘量避免)。
出處:HashMap實現原理及原始碼分析 侵刪
注意原始碼 雖然查詢的決定性依據是idCard是否相同,然而架不住在查詢之前要先hash鍵物件,hash出來的值不一樣陣列位置定位就不一樣,無從找起
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
那麼結果當然是找不到這個key對應的值了
解決方法也很簡單,在鍵物件的類裡用編譯器自動生成hashcode和equals的重寫方法就好了