1.HashMap
簡單來說,HashMap由陣列+連結串列組成的,陣列是HashMap的主體,連結串列則是主要為了解決雜湊衝突而存在的,如果定位到的陣列位置不含連結串列(當前entry的next指向null),那麼對於查詢,新增等操作很快,僅需一次定址即可;如果定位到的陣列包含連結串列,對於新增操作,其時間複雜度依然為O(1),因為最新的Entry會插入連結串列頭部,僅需簡單改變引用鏈即可,而對於查詢操作來講,此時就需要遍歷連結串列,然後通過key物件的equals方法逐一比對查詢。所以,效能考慮,HashMap中的連結串列出現越少,效能才會越好。
hash函式(對key的hashcode進一步進行計算以及二進位制位的調整等來保證最終獲取的儲存位置儘量分佈均勻)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
查詢函式
在JDK1.8 對hashmap做了改造,如下圖
JDK 1.8 以前 HashMap 的實現是 陣列+連結串列,即使雜湊函式取得再好,也很難達到元素百分百均勻分佈。
當 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的連結串列,這個時候 HashMap 就相當於一個單連結串列,假如單連結串列有 n 個元素,遍歷的時間複雜度就是 O(n),完全失去了它的優勢。
針對這種情況,JDK 1.8 中引入了 紅黑樹(查詢時間複雜度為 O(logn))來優化這個問題
2.HashTable
Hashtable它包括幾個重要的成員變數:table, count, threshold, loadFactor, modCount。
- table是一個 Entry[] 陣列型別,而 Entry(在 HashMap 中有講解過)實際上就是一個單向連結串列。雜湊表的”key-value鍵值對”都是儲存在Entry陣列中的。
- count 是 Hashtable 的大小,它是 Hashtable 儲存的鍵值對的數量。
- threshold 是 Hashtable 的閾值,用於判斷是否需要調整 Hashtable 的容量。threshold 的值=”容量*載入因子”。
- loadFactor 就是載入因子。
- modCount 是用來實現 fail-fast 機制的。
put 方法
put 方法的整個流程為:
- 判斷 value 是否為空,為空則丟擲異常;
- 計算 key 的 hash 值,並根據 hash 值獲得 key 在 table 陣列中的位置 index,如果 table[index] 元素不為空,則進行迭代,如果遇到相同的 key,則直接替換,並返回舊 value;
- 否則,我們可以將其插入到 table[index] 位置。
public synchronized V put(K key, V value) {
// Make sure the value is not null確保value不為null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
//確保key不在hashtable中
//首先,通過hash方法計算key的雜湊值,並計算得出index值,確定其在table[]中的位置
//其次,迭代index索引位置的連結串列,如果該位置處的連結串列存在相同的key,則替換value,返回舊的value
Entry tab[] = table;
int hash = hash(key);
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
V old = e.value;
e.value = value;
return old;
}
}
modCount++;
if (count >= threshold) {
// Rehash the table if the threshold is exceeded
//如果超過閥值,就進行rehash操作
rehash();
tab = table;
hash = hash(key);
index = (hash & 0x7FFFFFFF) % tab.length;
}
// Creates the new entry.
//將值插入,返回的為null
Entry<K,V> e = tab[index];
// 建立新的Entry節點,並將新的Entry插入Hashtable的index位置,並設定e為新的Entry的下一個元素
tab[index] = new Entry<>(hash, key, value, e);
count++;
return null;
}複製程式碼
get 方法
相比較於 put 方法,get 方法則簡單很多。其過程就是首先通過 hash()方法求得 key 的雜湊值,然後根據 hash 值得到 index 索引(上述兩步所用的演算法與 put 方法都相同)。然後迭代連結串列,返回匹配的 key 的對應的 value;找不到則返回 null。
public synchronized V get(Object key) {
Entry tab[] = table;
int hash = hash(key);
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return e.value;
}
}
return null;
}複製程式碼
3.ConcurrentHashMap
在JDK1.7版本中,ConcurrentHashMap的資料結構是由一個Segment陣列和多個HashEntry組成
Segment陣列的意義就是將一個大的table分割成多個小的table來進行加鎖,也就是上面的提到的鎖分離技術,而每一個Segment元素儲存的是HashEntry陣列+連結串列,這個和HashMap的資料儲存結構一樣
put操作
對於ConcurrentHashMap的資料插入,這裡要進行兩次Hash去定位資料的儲存位置
static
class
Segment<K,V>
extends
ReentrantLock
implements
Serializable {
從上Segment的繼承體系可以看出,Segment實現了ReentrantLock,也就帶有鎖的功能,當執行put操作時,會進行第一次key的hash來定位Segment的位置,如果該Segment還沒有初始化,即通過CAS操作進行賦值,然後進行第二次hash操作,找到相應的HashEntry的位置,這裡會利用繼承過來的鎖的特性,在將資料插入指定的HashEntry位置時(連結串列的尾端),會通過繼承ReentrantLock的tryLock()方法嘗試去獲取鎖,如果獲取成功就直接插入相應的位置,如果已經有執行緒獲取該Segment的鎖,那當前執行緒會以自旋的方式去繼續的呼叫tryLock()方法去獲取鎖,超過指定次數就掛起,等待喚醒。
get操作
ConcurrentHashMap的get操作跟HashMap類似,只是ConcurrentHashMap第一次需要經過一次hash定位到Segment的位置,然後再hash定位到指定的HashEntry,遍歷該HashEntry下的連結串列進行對比,成功就返回,不成功就返回null。
計算ConcurrentHashMap的元素大小是一個有趣的問題,因為他是併發操作的,就是在你計算size的時候,他還在併發的插入資料,可能會導致你計算出來的size和你實際的size有相差(在你return size的時候,插入了多個資料),要解決這個問題,JDK1.7版本用兩種方案。
- 第一種方案他會使用不加鎖的模式去嘗試多次計算ConcurrentHashMap的size,最多三次,比較前後兩次計算的結果,結果一致就認為當前沒有元素加入,計算的結果是準確的;
- 第二種方案是如果第一種方案不符合,他就會給每個Segment加上鎖,然後計算ConcurrentHashMap的size返回。
JDK1.8的實現
JDK1.8的實現已經摒棄了Segment的概念,而是直接用Node陣列+連結串列+紅黑樹的資料結構來實現,併發控制使用Synchronized和CAS來操作,整個看起來就像是優化過且執行緒安全的HashMap,雖然在JDK1.8中還能看到Segment的資料結構,但是已經簡化了屬性,只是為了相容舊版本。
put操作
在上面的例子中我們新增個人資訊會呼叫put方法,我們來看下。
- 如果沒有初始化就先呼叫initTable()方法來進行初始化過程
- 如果沒有hash衝突就直接CAS插入
- 如果還在進行擴容操作就先進行擴容
- 如果存在hash衝突,就加鎖來保證執行緒安全,這裡有兩種情況,一種是連結串列形式就直接遍歷到尾端插入,一種是紅黑樹就按照紅黑樹結構插入,
- 最後一個如果該連結串列的數量大於閾值8,就要先轉換成黑紅樹的結構,break再一次進入迴圈
- 如果新增成功就呼叫addCount()方法統計size,並且檢查是否需要擴容
get操作
我們現在要回到開始的例子中,我們對個人資訊進行了新增之後,我們要獲取所新增的資訊,使用String name = map.get(“name”)獲取新增的name資訊,現在我們依舊用debug的方式來分析下ConcurrentHashMap的獲取方法get()
public
V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p;
int
n, eh; K ek;
int
h = spread(key.hashCode());
//計算兩次hash
if
((tab = table) !=
null
&& (n = tab.length) >
0
&&
(e = tabAt(tab, (n -
1
) & h)) !=
null
) {
//讀取首節點的Node元素
if
((eh = e.hash) == h) {
//如果該節點就是首節點就返回
if
((ek = e.key) == key || (ek !=
null
&& key.equals(ek)))
return
e.val;
}
//hash值為負值表示正在擴容,這個時候查的是ForwardingNode的find方法來定位到nextTable來
//查詢,查詢到就返回
else
if
(eh <
0
)
return
(p = e.find(h, key)) !=
null
? p.val :
null
;
while
((e = e.next) !=
null
) {
//既不是首節點也不是ForwardingNode,那就往下遍歷
if
(e.hash == h &&
((ek = e.key) == key || (ek !=
null
&& key.equals(ek))))
return
e.val;
}
}
return
null
;
}
- 計算hash值,定位到該table索引位置,如果是首節點符合就返回
- 如果遇到擴容的時候,會呼叫標誌正在擴容節點ForwardingNode的find方法,查詢該節點,匹配就返回
- 以上都不符合的話,就往下遍歷節點,匹配就返回,否則最後就返回null
其實可以看出JDK1.8版本的ConcurrentHashMap的資料結構已經接近HashMap,相對而言,ConcurrentHashMap只是增加了同步的操作來控制併發,從JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+紅黑樹,相對而言,總結如下思考:
- JDK1.8的實現降低鎖的粒度,JDK1.7版本鎖的粒度是基於Segment的,包含多個HashEntry,而JDK1.8鎖的粒度就是HashEntry(首節點)
- JDK1.8版本的資料結構變得更加簡單,使得操作也更加清晰流暢,因為已經使用synchronized來進行同步,所以不需要分段鎖的概念,也就不需要Segment這種資料結構了,由於粒度的降低,實現的複雜度也增加了
- JDK1.8使用紅黑樹來優化連結串列,基於長度很長的連結串列的遍歷是一個很漫長的過程,而紅黑樹的遍歷效率是很快的,代替一定閾值的連結串列,這樣形成一個最佳拍檔
- JDK1.8為什麼使用內建鎖synchronized來代替重入鎖ReentrantLock,我覺得有以下幾點:
- 因為粒度降低了,在相對而言的低粒度加鎖方式,synchronized並不比ReentrantLock差,在粗粒度加鎖中ReentrantLock可能通過Condition來控制各個低粒度的邊界,更加的靈活,而在低粒度中,Condition的優勢就沒有了
- JVM的開發團隊從來都沒有放棄synchronized,而且基於JVM的synchronized優化空間更大,使用內嵌的關鍵字比使用API更加自然
- 在大量的資料操作下,對於JVM的記憶體壓力,基於API的ReentrantLock會開銷更多的記憶體,雖然不是瓶頸,但是也是一個選擇依據
4.總結
Hashtable和HashMap有幾個主要的不同:執行緒安全以及速度。僅在你需要完全的執行緒安全的時候使用Hashtable,而如果你使用Java 5或以上的話,請使用ConcurrentHashMap吧。