可訪問個人網站進行閱讀最新版本,精力有限無法多網站同步更新,更新只會在個人網站進行
面試題
先來看看常問的面試題有哪些
- 底層數據結構
- hash衝突解決
- 1.7和1.8區別
- 擴容機制(爲什麼是2倍)
- rehash過程
- 紅黑樹的左右旋
注意:光理論是不夠的,在此送大家一套2020最新Java架構實戰教程+大廠面試題庫,點擊此處進來獲取 一起交流進步哦!
一、底層數據結構
public class HashMap<k,v> extends AbstractMap<k,v> implements Map<k,v>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始容量16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比,佔滿0.75進行resize
static final int TREEIFY_THRESHOLD = 8; // 鏈表長度達到8時將鏈表轉換爲紅黑樹
static final int UNTREEIFY_THRESHOLD = 6; // 樹大小爲6,就轉回鏈表
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<k,v>[] table;//存儲元素的數組
transient Set<map.entry<k,v>> entrySet;
transient int size;//存放元素的個數
transient int modCount;//被修改的次數fast-fail機制
int threshold;//臨界值 當實際大小(容量*填充比)超過臨界值時,會進行擴容
final float loadFactor;//填充比
// 1.位桶數組
transient Node<k,v>[] table;//存儲(位桶)的數組</k,v>
// 2.數組元素Node<K,V>實現了Entry接口
//Node是單向鏈表,它實現了Map.Entry接口
static class Node<k,v> implements Map.Entry<k,v> {
final int hash;
final K key;
V value;
Node<k,v> next;
//構造函數Hash值 鍵 值 下一個節點
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;
}
//判斷兩個node是否相等,若key和value都相等,返回true。可以與自身比較爲true
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;
}
}
// 3.紅黑樹
static final class TreeNode<k,v> extends LinkedHashMap.Entry<k,v> {
TreeNode<k,v> parent; // 父節點
TreeNode<k,v> left; //左子樹
TreeNode<k,v> right;//右子樹
TreeNode<k,v> prev; // needed to unlink next upon deletion
boolean red; //顏色屬性
TreeNode(int hash, K key, V val, Node<k,v> next) {
super(hash, key, val, next);
}
//返回當前節點的根節點
final TreeNode<k,v> root() {
for (TreeNode<k,v> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
}
總結:1.7的hashmap是由位桶數組+鏈表組成,1.8之後的hashmap由位桶數組+鏈表+紅黑樹組成。其中數組指bucket數組,數組中的元素是實現了map.Entry<k,v>接口的Node<k,v>,每個Node<k,v>包含key,value,next指針,hash值。當put元素時會調用hashcode計算hash值,相同key而value不同的元素會發生哈希碰撞,採用拉鍊拉解決,將該元素插入到鏈表中。當TREEIFY_THRESHOLD
>8時,會轉化成紅黑樹。
1.1 構造函數
//構造函數1
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);//新的擴容臨界值
}
//構造函數2
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//構造函數3
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//構造函數4用m的元素初始化散列映射
public HashMap(Map<!--? extends K, ? extends V--> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
二、存取機制
在明白它是怎麼取之前需要先明白是怎麼存的
2.1 put(K key, V value)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value 元素已經存在,是否改變現值
* @param evict if false, the table is in creation mode. 區別通過put添加還是創建時初始化數據的
* @return previous value, or null if none
*/
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; // resize()不僅用來調整大小,還用來進行初始化配置
/*如果table的在(n-1)&hash的值是空,就新建一個節點插入在該位置*/
// (n-1)&hash相當於hash%(n-1)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
/*表示有衝突,該位置已存值,開始處理衝突,採用拉鍊法或是紅黑樹*/
else {
Node<K,V> e;
K k;
/*檢查第一個Node,p是不是要找的值*/
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 { // 遍歷鏈表插入鏈尾
for (int binCount = 0; ; ++binCount) {
/*指針爲空就掛在後面*/
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果衝突的節點數已經達到8個,看是否需要改變衝突節點的存儲結構,
//treeifyBin首先判斷當前hashMap的長度,如果不足64,只進行
//resize,擴容table,如果達到64,那麼將衝突的存儲結構爲紅黑樹
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;
}
}
/*就是鏈表上有相同的key值,修改元素值*/
if (e != null) { // existing mapping for key,就是key的Value存在
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;//返回存在的Value值
}
}
++modCount; // 修改次數+1
/*如果當前大小大於門限,門限原本是初始容量*0.75*/
if (++size > threshold)
resize();//擴容兩倍
afterNodeInsertion(evict);
return null;
}
下面簡單說下添加鍵值對put(key,value)的過程:
-
判斷位桶數組是否爲空數組,是則通過resize初始化
-
通過hash(key)計算hash值判斷該Node<k,v>應該插入的位置(不同的key可能有相同的hashcode)
-
如果該位置還沒插入值,則直接插入;如果已存在值
- 判斷key是否相同,是:則用e記錄該結點;
- 否:則判斷table[i]是否爲樹結點,
- 是:則以紅黑樹的方式插入,用e記錄;
- 否:則遍歷鏈表插入到鏈尾(如果長度>8轉成紅黑樹);遇到已存該元素的情況下,用e記錄,並退出
-
在上述步驟中,都有用e記錄了數組中或鏈表或紅黑樹已存在該元素的信息。通過修改e來覆蓋原值
-
判斷加入結點後是否超過門限值,是否需要擴容
2.1.1 hash()方法與hashcode()方法
我們通過hash方法計算索引,得到數組中保存的位置
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
以看到HashMap中的hash算法是通過key的hashcode值與其hashcode右移16位後得到的值進行異或運算得到的,那麼爲什麼不直接使用key.hashCode(),而要進行異或操作?我們知道hash的目的是爲了得到進行索引,而hash是有可能衝突的,也就是不同的key得到了同樣的hash值,這樣就很容易產業碰撞,如何減少這種情況的發生呢,就通過上述的hash(Object key)算法將hashcode 與 hashcode的低16位做異或運算,混合了高位和低位得出的最終hash值,衝突的概率就小多了
2.1.2 Fail-Fast 機制
我們知道 java.util.HashMap 不是線程安全的,因此如果在使用迭代器的過程中有其他線程修改了map,那麼將拋出ConcurrentModificationException,這就是所謂fail-fast策略。這一策略在源碼中的實現是通過 modCount 域,modCount 顧名思義就是修改次數,對HashMap 內容的修改都將增加這個值,那麼在迭代器初始化過程中會將這個值賦給迭代器的 expectedModCount。在迭代過程中,判斷 modCount 跟 expectedModCount 是否相等,如果不相等就表示已經有其他線程修改了 Map:注意到 modCount 聲明爲 volatile,保證線程之間修改的可見性。
所以在這裏和大家建議,當大家遍歷那些非線程安全的數據結構時,儘量使用迭代器
2.2 get(key)
通過put過程,我們已經知道Node(k,v)是怎麼保存到map中的,現在來看看怎麼取
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*
* @param 該key的hash值和key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab;//Entry對象數組
Node<K,V> first,e; //在tab數組中經過散列的第一個位置
int n;
K k;
/*找到插入的第一個Node,方法是hash值和n-1相與,tab[(n - 1) & hash]*/
//也就是說在一條鏈上的hash值相同的
if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {
/*檢查第一個Node是不是要找的Node*/
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))//判斷條件是hash值要相同,key值要相同
return first;
/*檢查first後面的node*/
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
/*遍歷後面的鏈表,找到key值和hash值都相同的Node*/
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
- 通過hash(key)找到bucket數組中該hash值的位置,判斷該位置的元素也就是first的key是否與要找的這個key相同
- 是:則返回該first元素
- 否:判斷first是否是樹節點
- 是:則通過紅黑樹的方式進行查找
- 否:遍歷鏈表查找到key相同的Node並返回
- 如果沒找到,則返回null
2.3 面試題
2.3.1 hashcode()與equals()區別
get()查找元素的過程:計算key的hashcode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。
Object的equals()是基於比較內存地址實現的,hashcode()是比較內存地址的hash值
在map中,hashcode(實際是hash方法,封裝了hashcode和低16位異或運算)用來計算key應該放在數組中的哪個位置,equals是用在有多個hashcode相同的情況下查找需要的key。
2.3.2 爲什麼要重寫equals()方法?
因爲object中的equals()方法比較的是對象的引用地址是否相等,如何你需要判斷對象裏的內容是否相等,則需要重寫equals()方法。
2.3.3 爲什麼改寫了equals(),也需要改寫hashcode()
如果你重載了equals,比如說是基於對象的內容實現的,而保留hashCode的實現(基於內存地址的hash值)不變,那麼在添加進map中時需要比對hashcode,很可能某兩個對象明明是“相等”,而hashCode卻不一樣。
2.3.4 爲什麼改寫了hashcode(),也需要改寫equals()
Hashmap的key可以是任何類型的對象,例如User這種對象,爲了保證兩個具有相同屬性的user的hashcode相同,我們就需要改寫hashcode方法,比方把hashcode值的計算與User對象的id關聯起來,那麼只要user對象擁有相同id,那麼他們的hashcode也能保持一致了,這樣就可以找到在hashmap數組中的位置了。如果這個位置上有多個元素,還需要用key的equals方法在對應位置的鏈表中找到需要的元素,所以只改寫了hashcode方法是不夠的,equals方法也是需要改寫。
在改寫equals方法的時候,需要滿足以下三點:
(1) 自反性:就是說a.equals(a)必須爲true。
(2) 對稱性:就是說a.equals(b)=true的話,b.equals(a)也必須爲true。
(3) 傳遞性:就是說a.equals(b)=true,並且b.equals©=true的話,a.equals©也必須爲true。
通過改寫key對象的equals和hashcode方法,我們可以將任意的業務對象作爲map的key(前提是你確實有這樣的需要)。
三、擴容機制
當hashmap中的元素個數超過數組大小*loadFactor時,就會進行數組擴容,loadFactor的默認值爲0.75,也就是說,默認情況下,數組大小爲16,那麼當hashmap中元素個數超過16*0.75=12的時候,就把數組的大小擴展爲2*16=32,即擴大爲原來2倍,然後重新調用hash方法找到新的bucket位置。
3.1 resize()
jdk1.7的源碼:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
// 創建一個新的 Hash Table
Entry[] newTable = new Entry[newCapacity];
// 將 Old Hash Table 上的數據遷移到 New Hash Table 上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
// 遷移數組
void transfer(Entry[] newTable) {
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;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
遷移過程:
單線程下的遷移:在擴容之後,重新計算hash定位到新數組中,相同hash值的元素照樣連接成鏈表,只是鏈表相對位置進行了反轉。
多線程下的遷移:
線程1在獲取next結點之後被掛起,Thread 1 的 e 指向了 key(3),而 next 指向了 key(7)。線程2順利完成rehash過程,鏈表反轉。
do {
Entry<K,V> next = e.next; // 假設線程一執行到這裏就被調度掛起了
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
線程1繼續執行,仍會把線程二的新表當成原始的hash表,將原來e指向的key(3)節點當成是線程二中的key(3),放在自己所建newTable[3]的頭節點,線程1的next仍然指向key(7),此時key(3)的next已經是null。
e.next = newTable[i]; // key(3)的 next 指向了線程1的新 Hash 表,因爲新 Hash 表爲空,所以e.next = null
newTable[i] = e; // 線程1的新 Hash 表第一個元素指向了線程2新 Hash 表的 key(3)。e 處理完畢
e = next; // 將 e 指向 next,所以新的 e 是 key(7)
線程1的e指向了上一次循環的next,也就是key(7),此時key(7)的next已經是key(3)。將key(7)插入到table[0]的頭節點,並且將key(7)的next設置爲key(3), e 和next繼續往下移。此時仍然沒有問題。
繼續下一次循環,e.next = newTable[i] 導致 key(3).next 指向了 key(7),但此時的 key(7).next 已經指向了 key(3), 環形鏈表就這樣出現了。
jdk1.8的源碼:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the 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;
//以前的容量大於0,也就是hashMap中已經有元素了,或者new對象的時候設置了初始容量
if (oldCap > 0) {
//如果以前的容量大於限制的最大容量1<<30,則設置臨界值爲int的最大值2^31-1
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
/**
* 如果以前容量的2倍小於限制的最大容量,同時大於或等於默認的容量16,則設置臨界值爲以前臨界值的2
* 倍,因爲threshold = loadFactor*capacity,capacity擴大了2倍,loadFactor不變,
* threshold自然也擴大2倍。
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
/**
* 在HashMap構造器Hash(int initialCapacity, float loadFactor)中有一句代碼,this.threshold
* = tableSizeFor(initialCapacity), 表示在調用構造器時,默認是將初始容量暫時賦值給了
* threshold臨界值,因此此處相當於將上一次的初始容量賦值給了新的容量。什麼情況下會執行到這句?當調用
* 了HashMap(int initialCapacity)構造器,還沒有添加元素時
*/
else if (oldThr > 0)
newCap = oldThr;
/**
* 調用了默認構造器,初始容量沒有設置,因此使用默認容量DEFAULT_INITIAL_CAPACITY(16),臨界值
* 就是16*0.75
*/
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//對臨界值做判斷,確保其不爲0,因爲在上面第二種情況(oldThr > 0),並沒有計算newThr
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) {
//遍歷將原來table中的數據放到擴容後的新表中來
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//沒有鏈表Node節點,直接放到新的table中下標爲[e.hash & (newCap - 1)]位置即可
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果是treeNode節點,則樹上的節點放到newTab中
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//如果e後面還有鏈表節點,則遍歷e所在的鏈表,
else { // 保證順序
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//記錄下一個節點
next = e.next;
/**
* newTab的容量是以前舊錶容量的兩倍,因爲數組table下標並不是根據循環逐步遞增
* 的,而是通過(table.length-1)& hash計算得到,因此擴容後,存放的位置就
* 可能發生變化,那麼到底發生怎樣的變化呢,就是由下面的算法得到.
*
* 通過e.hash & oldCap來判斷節點位置通過再次hash算法後,是否會發生改變,如
* 果爲0表示不會發生改變,如果爲1表示會發生改變。到底怎麼理解呢,舉個例子:
* e.hash = 13 二進制:0000 1101
* oldCap = 32 二進制:0001 0000
* &運算: 0 二進制:0000 0000
* 結論:元素位置在擴容後不會發生改變
*/
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
/**
* e.hash = 18 二進制:0001 0010
* oldCap = 32 二進制:0001 0000
* &運算: 32 二進制:0001 0000
* 結論:元素位置在擴容後會發生改變,那麼如何改變呢?
* newCap = 64 二進制:0010 0000
* 通過(newCap-1)&hash
* 即0001 1111 & 0001 0010 得0001 0010,32+2 = 34
*/
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
/**
* 若(e.hash & oldCap) == 0,下標不變,將原表某個下標的元素放到擴容表同樣
* 下標的位置上
*/
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
/**
* 若(e.hash & oldCap) != 0,將原表某個下標的元素放到擴容表中
* [下標+增加的擴容量]的位置上
*/
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
總結:
-
如果table == null, 則爲HashMap的初始化, 生成空table返回即可;
-
如果table不爲空, 需要重新計算table的長度, newLength = oldLength << 1(注, 如果原oldLength已經到了上限, 則newLength = oldLength);
-
遍歷oldTable,oldTable[i]爲空,遍歷下一個
- 否:判斷oldTable[i].next是否爲空
- 是:存放到newTable中newTab[
e.hash & (newCap - 1
)] - 否:判斷是否紅黑樹
- 是:走紅黑樹的重定位
- 否:JAVA7時還需要重新計算hash位, 但是JAVA8做了優化, 通過
(e.hash & oldCap)
== 0來判斷節點位置通過再次hash算法後,是否會發生改變- 是:移動到當前hash槽位 + oldCap的位置
- 否:移動到新表中原下標的位置
- 是:存放到newTable中newTab[
- 否:判斷oldTable[i].next是否爲空
注:newCap/oldCap爲容量
四、面試題
4.1 擴容爲什麼是2倍?
主要與HashMap計算添加元素的位置時,使用的位運算有關,這是特別高效的運算;HashMap的初始容量是2的n次冪,擴容也是2倍的形式進行擴容,可以使得添加的元素均勻分佈在HashMap中的數組上,減少hash碰撞,避免形成鏈表的結構,使得查詢效率降低。
4.2 爲什麼String, Interger這樣的wrapper類適合作爲鍵?
如果兩個不相等的對象返回不同的hashcode的話,那麼碰撞的機率就會小些,這樣就能提高HashMap的性能,也就適合做Hashmap的鍵。因爲獲取對象的時候要用到equals()和hashCode()方法,鍵對象正確的重寫這兩個方法是非常重要的。
因此,String,Interger這樣的wrapper類作爲HashMap的鍵是再適合不過了,而且String最爲常用。因爲String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因爲爲了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那麼就不能從HashMap中找到你想要的對象
4.3 線程不安全的原因
HashMap在併發場景下可能存在以下問題:
死循環:在jdk1.7中,resize過程中,從舊數組重新遷移至新數組的過程中,仍可能會發生hash衝突,形成鏈表,鏈表的相對位置發生了反轉,那麼在併發環境下,容易出現多線程同時resize的情況,那麼就有可能在遷移過程中發生閉環,一旦發生閉環,進行get()操作的時候就會陷入死循環。在jdk1.8中,用 head 和 tail 來保證鏈表的順序和之前一樣,因此不會出現發生閉環的情況。
數據丟失:
-
如果多個線程同時使用 put 方法添加元素,而且假設正好存在兩個 put 的 key 發生了碰撞(根據 hash 值計算的 bucket 一樣),那麼根據 HashMap 的實現,這兩個 key 會添加到數組的同一個位置,這樣最終就會發生其中一個線程 put 的數據被覆蓋
-
如果多個線程同時檢測到元素個數超過數組大小 * loadFactor,這樣就會發生多個線程同時對 Node 數組進行擴容,都在重新計算元素位置以及複製數據,但是最終只有一個線程擴容後的數組會賦給 table,也就是說其他線程的都會丟失,並且各自線程 put 的數據也丟失
4.4 你瞭解重新調整HashMap大小存在什麼問題嗎?
Jdk1.7 當多線程的情況下,可能產生條件競爭(race condition)。
當重新調整HashMap大小的時候,如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試着調整大小。在調 整大小的過程中,存儲在鏈表中的元素的次序會反過來,因爲移動到新的bucket位置的時候,HashMap並不會將元素放在鏈表的尾部,而是放在頭部, 這是爲了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那麼就死循環了。
注意:最後送大家一套2020最新Java架構實戰教程+大廠面試題庫,點擊此處進來獲取 一起交流進步哦!
注:尾部遍歷(避免尾部遍歷是爲了避免在新列表插入數據時,遍歷隊尾的位置。因爲,直接插入的效率更高。)