目錄結構
一、面試常見問題
二、基本常量屬性
三、構造方法
四、節點結構
4.1 Node類
4.2.TreeNode
五、put方法
5.1 key的hash方法
5.2 resize() 擴容方法
六、get方法
一、面試常見問題
Q1: HashMap底層資料結構是什麼?
jdk1.7是陣列+連結串列; jdk1.8 採用陣列+ 連結串列+紅黑樹
Q2:為什麼要引入紅黑樹,有什麼優勢,解決什麼問題?
紅黑樹的引入是為了提高查詢效率,雜湊衝突可以減少(元素key均勻雜湊程度和通過擴容),不可避免,
如果衝突元素較多,會導致連結串列過長,而連結串列查詢效率是O(n), 當連結串列長度超過8後,且陣列長度(length)超過64才轉化為紅黑樹, 這點易忽略(後原始碼證明),
不是連結串列長度一超高8個就將其樹化為紅黑樹,此時擴容解決衝突效果更好。(注:紅黑樹的特性需要了解掌握)
Q3: 在一條連結串列上新增節點,什麼方式插入?
1.8 是尾插法,1.7是採用頭插法,為什麼1.7需要頭插法?頭插法效率高?
Q4:放入HashMap中元素需要什麼特性?
需要重寫hashCode和 equals()方法 ,不重寫會有什麼問題?
Q5: HashMap是執行緒安全的嗎?你列舉其他執行緒安全的,特性?
不是, 因為它的方法沒有同步鎖,HashTable 是執行緒安全的,每個方法有加synhronized關鍵字,執行緒安全,但也會導致效率變低;
一般多執行緒環境使用 ConcurrentHashMap,其原理是採用了分段鎖機制,每一段(Segment)中是一個HashMap,每個Segment中操作是加鎖的,
即保證執行緒操作某一個Segment是排他的,但不同執行緒在不同Segment是可以同時操作的,即保證了執行緒安全,又提高了併發效率。
(此處不詳細展開ConcurrentHashMap,面試問題是環環相套的,好的面試官會步步引導,目的是為了全面考察面試者的技術水平)。
Q6: 1.7中 HashMap存在什麼問題?
在多執行緒環境下,1.7中Map可能會導致cpu使用率過高,是因為存在環形連結串列了,HashMap中 連結串列是單連結串列結構,怎麼會有環?
是連結串列中元素指向下一個元素的指標next,指向了前面的元素,導致了環,所以在遍歷連結串列時,
程式一直死迴圈無法結束。在多個執行緒放置元素時,resize()方法中導致(後原始碼證明)。
Q7: 1.8是先插入新值再判斷是否擴容,還是先擴容在插入新值?
1.8是先插入元素,在判斷容量是否超過閾值,擴容,1.7是先擴容再插入新值
Q8: HashMap是延遲初始化?
是的,建立的Map物件,如果有指定容量大小,會記錄下來;沒有指定會使用預設16,在第一次put元素時才初始化陣列,1.7,1.8都是如此,可以節省記憶體空間。
Q9: HashMap允許放置空鍵(null),空值(null)嗎?
允許放置,null 鍵是放置在陣列第一個位置的,因此在判斷某個key是否存在時,不能通過該get() 方法獲取value為null判斷,
這時鍵值對可能是 null:null,此時是存在Node物件的,可以通過containsKey(key)判斷 ,key為null,Node物件不為null,如下圖1所示
對比HashTable ,HashTable 不允許 null鍵,null值.
Q10 :簡要講述一下HashMap放置元素過程,即put()方法。
put方法流程如下圖2所示
二、基本常量屬性
//預設初始容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
*最大容量,如果在建構函式中指定大於該值,會使用該值作為容量大小,2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 預設載入因子75%,好比地鐵滿載率,受疫情影響地鐵滿載率不超過30%
* 1,載入因子設定較大,隨著陣列中元素裝的越多,發生的衝突的概率越大,即對應的連結串列
* 越長,影響查詢效率。
* 2,載入因子設定較小,元素很容易達到設定的閾值,發生擴容操作,陣列空間還有很大部分
* 沒有利用上,造成空間浪費。
* 因此在時間與空間上的權衡考慮
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 樹化閾值:在連結串列元素超過8個了,將連結串列轉化為紅黑數結果,提高查詢效率
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 取消樹化閾值:在紅黑樹中節點數量小於6個,將其轉化為連結串列結構
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小樹化容量,容易忽視
* 連結串列樹化紅黑樹條件:
* 1,連結串列中元素數量超過(>)8個;
* 2, 滿足map中元素個數(size)大於等於64否則會先擴容,擴容對解決hash衝突更有效。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
三、構造方法
//傳有初始容量和載入因子
1.HashMap(int initialCapacity, float loadFactor)
//有初始容量,載入因子預設
2.HashMap(int initialCapacity)
//初始容量, 載入因子預設
3.HashMap()
//傳遞的一個Map子集
4.HashMap(Map<? extends K, ? extends V> m)
四、節點結構
-
1.陣列和連結串列節點物件為HashMap內部類 Node.
-
2.紅黑樹節點 TreeNode,繼承LinkedHashMap.Entry , 而 Entry繼承Node,因此 TreeNode 實際是 Node孫子.
-
3.Node類,重寫了hashCode和 equals方法,記錄了當前key, value, key的hash值,以及指向後一個元素指標.
4.1 Node類
//實現Map集合的Entry類
static class Node<K,V> implements Map.Entry<K,V> {
//記錄當前節點key的hash
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; }
//重寫了hashcode
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//重寫了equals
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;
}
}
4.2.TreeNode
什麼是紅黑樹?特點?
性質1. 節點是紅色或黑色.
性質2. 根節點是黑色.
性質3.所有葉子都是黑色.(葉子是NUIL節點)
性質4. 每個紅色節點的兩個子節點都是黑色.(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)
性質5. 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點.
// 繼承LinkedHashMap.Entry 繼承>> HashMap.Node
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
//顏色是否為紅色
boolean red;
五、put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
5.1 key的hash方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash(key)方法:
1.hashMap支援 key 為null,放在陣列索引為0的位置
2.為了保證key的hash值分佈均分、雜湊,減少衝突
(h = key.hashCode()) ^ (h >>> 16)
等於 key的hash值 異或於 其hash值的低 16位
例:"水果"的 hashCode()
h = 11011000000111101000
h>>>16=00000000000000001101
兩者異或,不同為1,相同為0
1101 10000001 11101000
^
0000 00000000 00001101
----------------------
1101 10000001 11100101
put核心方法
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)
//1,陣列為空,初始化操作,此處resize() 待解析1
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
/**2,通過key的計算出的hash值與陣列長度-1與運算,算出在陣列下標位置
* 若該位置為空則建立新節點封裝元素值,放在該位置
*/
tab[i] = newNode(hash, key, value, null);
else {
// 若陣列下標位置已經有值
Node<K,V> e; K k;
//3,首先判斷陣列位置兩個key是否相同,e用來指向存在相同key的節點。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//4,key不相同的情況下,判斷陣列該下標位置連線的是連結串列還是樹節點
//若是樹結構,就將該值放入樹中(放入樹中操作,也會遍歷樹判斷是否存在相同的key的節點,若無相同會以新節點插入樹中)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//陣列該下標位置只有1個節點或者連結串列:這裡統一為連結串列形式
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//5,沒有找到相同key的元素,在連結串列後插入新節點
p.next = newNode(hash, key, value, null);
/**此處為什麼要>=7,因為bincount從0開始遞增,當bincount=7時,
*此時for迴圈執行了7次,加上新增加的節點,以及陣列下標位置節點
*共7+1+1= 9了,即該陣列下標位置連結串列長度大於8了,需要轉化為紅黑樹
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//遍歷連結串列比較判斷是否存在兩個key相同元素
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//有上面 e= p.next,這裡 p有重新被賦值為e,即繼續遍歷連結串列下一個元素
p = e;
}
}
// 存在相同的key的元素,可能在陣列、連結串列、樹上
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//6,覆蓋舊值,返回舊值,也可設定僅僅當舊value存在,不為null情況才覆蓋原值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//修改次數+1
++modCount;
//7,判斷整個map中元素個數是否大於閾值,大於則擴容,由此可見是先插入元素再判斷容量大小是否擴容,區別1.7先擴容再插入新值
if (++size > threshold)
resize();
//該方法為空,不用理會
afterNodeInsertion(evict);
return null;
}
5.2 resize() 擴容方法
該方法有個比較有意思的是將舊陣列的元素移到擴大為原來容量2倍的新陣列中時,原來陣列中連結串列需要進行拆鏈,非常巧妙,下見。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//1,原來陣列容量>0情況
if (oldCap > 0) {
//如果陣列容量已經為最大值了 2^30,那就僅僅是將擴容閾值修改
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//1.1 如果原來陣列容量>=16,且其2倍小於最大容量(oldCap<<1 等價於 oldCap*2^1)
// 閾值也擴容為原來2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//1.2 什麼情況陣列容量=0,閾值>0呢?
//建立HashMap,指定了陣列初始容量cap,會將其轉換為大於等於cap的最小的2的冪次方的數賦值給threshold,這裡即將陣列容量賦值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//1.3原來陣列容量和閾值都為0,即常用的無參構造方式,使用預設容量大小
//閾值根據容量和負載因子算出
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//2,newThr = 0情況出現在1.1中,沒滿足條件擴大為原來2倍
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
//小於最大容量,則用容量和載入因子乘積,否則為 Integer最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//3,建立新陣列物件
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//4,原陣列物件不為空,需要將原陣列元素移到新陣列中
if (oldTab != null) {
//遍歷舊陣列
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//陣列下標位置有元素
if ((e = oldTab[j]) != null) {
//直接把該下標位置連結串列(或紅黑樹)取出來,1個元素也當做連結串列看待,原陣列該位置置空,只要我們拿著連結串列頭節點/樹根節點(e)就行
oldTab[j] = null;
if (e.next == null)
//該位置只有1個元素,直接與新陣列長度hash,確定位置放入新陣列
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//該位置是一顆紅黑樹,遷移到新陣列
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//5,重點解析:這裡將原陣列連結串列拆分為2條連結串列放入新陣列,那它是怎麼拆的呢?
// loHead,loTail(lo 理解為low縮寫)分別記錄放在新陣列下標小的那一條連結串列的頭節點和尾結點,
// 同理hiHead,hiTail(hi理解為 hight縮寫)放在新陣列下標大的位置
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 是什麼目的呢?
// e.hash & (oldCap-1)求得是陣列下標,e.hash & oldCap
// 是為了獲取比oldCap-1更高的那位一是0還是1,是0的就留在原位,是1的話需要增加oldCap。這裡不易理解,下面詳解
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;
}
擴容拆鏈過程分析:
#1,擴容前位置
23&15= 7
0001 0111
&
0000 1111
---------
0000 0111
#2,擴容後位置
23&31= 23
0001 0111
&
0001 1111
---------
0001 0111
/**因為每次擴容都是old陣列長度的2倍,那麼在要計算在擴容後新陣列位置,
那麼只需要關心key的hash值左邊新增計算的那位是0還是1,如上未擴容前23與15與運算,只需關心低4位值,高位無論其是否有1,與運算後都會為0; 而擴容後,15變為31,那麼key的hash值影響計算結果位由4位變為5位,
因此 e.hash & oldCap的結果就是獲取新增的位置,000X 0000,即X的值,0或者1;
0運算結果不變,放在原位,1與運算結果會增加一個原陣列長度。
*/
如下圖3所示:
7 & 7=7
0000 0111
0000 0111
----------
23 & 7=7
0001 0111
0000 0111
----------
31 & 7=7
0001 1111
0000 0111
----------
15 & 7=7
0000 1111
0000 0111
#可見第4位(從右向左)為1的有15,31,擴容後位置:原有下標+原陣列長度
拆鏈位運算實現的巧妙:
1,擴容遷移原有陣列中的元素,不用再重複計算原有元素key的hash值,提高效率.
2,擴容後拆鏈,會將連結串列長度縮短,減少hash衝突,提高查詢效率.
六、get方法
根據鍵值對中的鍵(key)獲取值
public V get(Object key) {
Node<K,V> e;
//獲取節點賦值給e,e!=null, 返回該鍵值對的value值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
下見方法:getNode(int hash, Object key)
final Node<K,V> getNode(int hash, Object key) {
//傳入的hash值與put方法一樣的方法,相同規則計算key的hash值
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) {
//1,獲取陣列下標位置的第一個節點,first
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null &&
key.equals(k))))
//2,檢查是否為相同的key,是直接返回該節點物件;不是則遍歷下一個節點
return first;
if ((e = first.next) != null) {
//3,下一個節點存在,需判斷是紅黑樹,還是連結串列
if (first instanceof TreeNode)
//3.1紅黑樹則遍歷樹獲取是否存在與該key相同的節點
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//3.2 連結串列則依次遍歷,查詢相同的key,找到則返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//陣列為空,或者沒有找到返回空
return null;
}