HashMap、HashTable、ConcurrentHashMap總結:
本篇博文將會大篇幅介紹 JDK1.8 HashMap,下面總結:
HashMap:
- JDK1.7底層是 數組 + 鏈表實現的, JDK1.8添加了紅黑樹,節點達到一定條件之後,鏈表和紅黑樹之間存在相互轉化的場景
- key 不可以重複,但可以爲null,value值不做限定。
- HashMap數組初始化size=16,每次擴容爲2倍,size一定爲2的n次冪【初始化時傳入的size,會被轉化爲2的n次冪】
- 默認情況下,當Map中元素總數超過Entry數組的75%,觸發擴容操作,爲了減少鏈表長度,元素分配更均勻
- 哈希衝突:若干Key的哈希值按數組大小取模後【hash & (tab.length – 1)】,如果落在同一個數組下標上,將組成一條Entry鏈【紅黑樹】,對Key的查找需要遍歷Entry鏈上的每個元素執行equals()比較
- 加載因子:爲了降低哈希衝突的概率,默認當HashMap中的鍵值對達到數組大小的75%時,即會觸發擴容
- 空間換時間:如果希望加快Key查找的時間,還可以進一步降低加載因子,加大初始大小,以降低哈希衝突的概率
HashTable:
- 底層數組+鏈表實現,無論key還是value都不能爲null,線程安全,實現線程安全的方式是在修改數據時鎖住整個HashTable,效率低,ConcurrentHashMap做了相關優化
- 初始size爲11,擴容:newsize = olesize*2+1
- 計算index的方法:index = (hash & 0x7FFFFFFF) % tab.length
ConcurrentHashMap:
- ConcurrentHashMap使用了鎖分離的技術實現線程安全,可以完全替代HashTable,在併發編程的場景中使用頻率非常之高。
- JDK1.7 ConcurrentHashMap 是使用Segment段來進行加鎖,個段就相當於一個HashMap的數據結構,每個段使用一個鎖, JDK1.8之後Segment雖保留,但已經簡化屬性,僅僅是爲了兼容舊版本,使用和HashMap一樣的數據結構每個數組位置使用一個鎖。
HashMap源碼解析:
上面我們已經講了 HashMap 是數組、鏈表、紅黑樹組成的,我們把他抽象成一個圖可以看到,一定要記住這個圖片,其中每個方塊是一個節點,每個節點裏面的字母是 key:
打開 HashMap的源碼會發現其中定義了很多變量,其中有幾個比較重要的變量:
- Node<K,V>[] table : node 類型的數組,作爲HashMap的三大結構之一
- int size: 已儲存元素的個數
- int threshold: 擴容的閾值,當 HashMap的size大於threshold時會執行resize操作。 threshold=table .length * loadFactor
- float loadFactor: 負載因子, 用來計算 threshold
// 默認初始容量-須是2的冪 這裏是 2的4次方16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// HashMap 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認負載係數 (HashMap擴容是使用)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 鏈表轉化爲紅黑樹的閾值
static final int TREEIFY_THRESHOLD = 8;
// 紅黑樹中節點個數轉爲鏈表的閾值
static final int UNTREEIFY_THRESHOLD = 6;
// 當哈希表中的容量大於這個值時,表中的桶才能進行樹形化
// 否則桶內元素太多時會擴容,而不是樹形化
// 爲了避免進行擴容、樹形化選擇的衝突,這個值不能小於 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
// HashMap的三大數據結構之一 數組。
transient Node<K,V>[] table;
// Entry 的set集合 遍歷時使用
transient Set<Map.Entry<K,V>> entrySet;
// 已經存儲的數據容量
transient int size;
// 被修改的次數
transient int modCount;
/**
* 擴容的閾值
* 1、當 HashMap的size大於threshold時會執行resize操作。
* 2、threshold=capacity*loadFactor
*/
int threshold;
// 負載因子參數
final float loadFactor;
初始化方法,其中推薦的 第二種,當我們預先知道元素的個數時, 可以有效的避免擴容。
// 無參初始化, 默認容量時16,負載因子 = DEFAULT_LOAD_FACTOR = 0.75f
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* 帶參數的初始化。【推薦,可以避免擴容】
*
* 注意:HashMap推薦initialCapacit最好是2的n次冪,有利於均勻分佈數組的下標。
* 不過放心在tableSizeFor已經幫我們處理好了
* @param initialCapacity 初始化的容量
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 帶參數的初始化
* @param initialCapacity 初始化的容量
* @param 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;
// 設置第一次需要擴容時的閾值,此時由 initialCapacity 決定的,和其他數據無關
this.threshold = tableSizeFor(initialCapacity);
}
// 返回比給定目標 大的 2的n次冪數。
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方法:
我們初始化 HashMap 的時候,並沒有創建數組,僅僅是對一些參數進行賦值:
- 在我們put 一個元素的時候 判斷 table是否已經初始化,並將其初始化,是否達到擴容閾值,進行擴容。
- 計算 key 的hash值 並與數組的長度做:(n - 1) & hash 計算,計算key 在數組上的下標位置。
a. 如果爲空,直接加入
b.如果爲鏈表,放在最後,並判斷是否進行 鏈表轉化爲 紅黑樹
c.如果爲紅黑樹,則放在紅黑樹裏,並對紅黑樹進行修復
d.如果在放入數組、鏈表、紅黑樹時,找到相同的 key 則將其原先的 Value 替代。 - put 方法源碼:
// put 鍵值對到Map中 【重要】
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;
// 判斷儲存數據的table 是否爲空,並將其初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 通過(n - 1) & hash計算其在數組的下標位【目的可以使元素均勻的分佈在數組上】
// 如果當前數據位置沒有元素,就放在這個位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 當前key對應的節點
Node<K,V> e;
K k;
// 我們保存的 key 與數組位置的key重複,最後面會把value值替代
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
/**
* 當前節點是紅黑樹:
* 1、如果紅黑樹中存在相同的 key 返回該節點
* 2、如果紅黑樹中不存在相同的 key,將key-value生成新的節點,放入到紅黑樹中,返回null
* 由於博主對紅黑樹理解不夠透徹,暫時不去解讀putTreeVal方法
*/
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
/**
* 當前節點是鏈表
* 1、遍歷鏈表,鏈表中存在相同的 key 返回該節點
* 2、鏈表中不存在相同的 key,將key-value生成新的節點,放入到鏈表最後,
*/
else {
// 遍歷鏈表
for (int binCount = 0; ; ++binCount) {
// 到了鏈表末端
if ((e = p.next) == null) {
// 生成新的節點,賦值到鏈表最後
p.next = newNode(hash, key, value, null);
// 判斷是否將當前節點 鏈表轉紅黑樹
// 條件1:鏈表的個數 >= TREEIFY_THRESHOLD - 1
// 條件2: 數組的大小 < MIN_TREEIFY_CAPACITY, 滿足條件1,不滿足條件2會進行擴容
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;
p = e;
}
}
// 表示在key存在於 HashMap中,將值進行更新
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;
}
/**
*初始化或加倍表大小。如果爲空,則分配
*與初始容量目標保持一致。
*否則,因爲我們使用的是二次展開的冪,所以
*每個bin中的元素必須保持在同一索引中,或者移動
*在新表中使用兩個偏移量的冪。
*
*@return table
*/
final Node<K,V>[] resize() {
// 保存數據的 數組
Node<K,V>[] oldTab = table;
// 獲取數據長度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 舊的擴容閾值
int oldThr = threshold;
// 擴容後 數組長度
int newCap = 0;
// 擴容後 下一次在擴容的閾值
int newThr = 0;
// 原來數組已經初始化
if (oldCap > 0) {
// 容量已經達到最大,不進行處理了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 將原來的數組容量(oldCap)擴大一倍,賦予新的數組容量(newCap)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 將擴容閾值(oldThr)擴大一倍,賦予新的擴容閾值(newThr)
newThr = oldThr << 1; // double threshold
}
// 初始容量設置爲閾值
else if (oldThr > 0)
newCap = oldThr;
else {
// 初始化數組容量
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;
// 定義一個新的數組長度爲 newCap;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 數據遷移
if (oldTab != null) {
// 先遍歷老的數組
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 拿到原先數組下標的數據
if ((e = oldTab[j]) != null) {
// 位置置空,幫助GC
oldTab[j] = null;
// 當前位置元素 沒有下一個節點
if (e.next == null)
// 根據當前元素的 hash 和新數組的大小,計算元素E在新的數組中的位置
// 爲什麼要 e.hash & (newCap - 1) 計算下標? 因爲這樣會使得元素在數組上的分佈更加均勻
newTab[e.hash & (newCap - 1)] = e;
/**
* 先看下面鏈表的遷移方式,在看紅黑樹的遷移方式 【注意】
*/
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
/**
* 鏈表的遷移方式
* 1、 & 運算規則:兩個數都轉爲二進制,然後從高位開始比較,如果兩個數都爲1則爲1,否則爲0。
* 2、put方法計算時(oldCap - 1) & e.hash < oldCap,保證了結果始終在數組範圍內。
* 3、擴容時:oldCap & e.hash = 0表示當前的key是屬於原來數組範圍內,oldCap & e.hash != 0 表示在擴容的範圍內
* 4、根據上面的計算結果,定義低位和高位兩個鏈表,最後複製到新的數組中newTab
* 5、這也是數組每次擴容兩倍的原因
* 【這裏需要仔細的琢磨琢磨!!!!】
*/
else {
// 低位鏈表的頭和尾節點
Node<K,V> loHead = null, loTail = null;
// 高位鏈表的頭和尾節點
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 根據hash值 將當前節點下的鏈表分爲高位和低位鏈表
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;
}
// 將高位鏈表賦值到(j + oldCap)位置,
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
// 是否將鏈表轉爲紅黑樹
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 數組的大小 < MIN_TREEIFY_CAPACITY, 會進行擴容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 執行樹化
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
put 方法中一些註釋做了一些解釋,最主要的還是在 resize() 擴容方法,在數組、鏈表、以及紅黑樹中查找位置的代碼非常的重要,由於博主對紅黑樹研究不夠深入,暫時沒進行解析。
get方法:
看完 put 方法在看 get 方法就非常簡單
1. 先計算在數組上的位置: (n - 1) & hash
2. 查看數組上節點key值是否相同
3. 然後判斷 是鏈表 還是紅黑樹,進行遍歷,如果沒有找到相同的key 則返回 null
// 獲取Values 值
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
// 根據 key的hash 和 key的值獲取節點
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 根據 (n - 1) & hash 找到對應的下標位,是否有元素
if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {
// 數組下表位元素key 與 傳入的key相同,直接返回該節點
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 查找first後的元素
if ((e = first.next) != null) {
// first 是紅黑樹根節點
if (first instanceof TreeNode)
// 遍歷紅黑樹返回節點
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// first 是鏈表
do {
// 遍歷鏈表返回節點
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
總結:
HashMap 首先需要了解其三種數據結構,再瞭解其擴容的原理,以及鏈表和紅黑樹之間的轉換,基本上就OK 了;
Hashtable
Hashtable 其實是 HashMap 的前身,Hashtable是線程安全的,它的組成結構,只有數組和鏈表並在很多修改數據的方法上加入了 synchronized 來保證線程安全性:
put 和 get 代碼如下:
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// 數組
Entry<?,?> tab[] = table;
int hash = key.hashCode();
// 計算下標位置
int index = (hash & 0x7FFFFFFF) % tab.length;
// 獲取下標位置的元素
Entry<K,V> entry = (Entry<K,V>)tab[index];
// 下標元素和其後面的鏈表是否與key重複,存在則將value替代,並返回
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
// 添加一個節點
addEntry(hash, key, value, index);
return null;
}
// Get
public synchronized V get(Object key) {
// 數組
Entry<?,?> tab[] = table;
int hash = key.hashCode();
// 計算下標
int index = (hash & 0x7FFFFFFF) % tab.length;
// 查詢當前的下標是否有包含key的數據 【當前下標位置的元素可能的值:null,節點,鏈表】
for (Entry<?,?> e = tab[index]; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
public synchronized int size() {
return count;
}
public synchronized boolean isEmpty() {
return count == 0;
}
ConcurrentHashMap
相比 Hashtable,ConcurrentHashMap 採用了分段鎖的概念,它的數據結構和HashMap基本相同,採用了 CAS 原子操作修改一些屬性和元素避免併發問題。並 synchronized 對數組的每個下標位置元素進行加鎖,保證當前節點桶【鏈表、紅黑樹】 併發安全,
我們來簡單的看一下 put 的代碼:
// put
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key、value 不能爲空
if (key == null || value == null) throw new NullPointerException();
// 獲得key的hash值
int hash = spread(key.hashCode());
// 用來計算在這個節點總共有多少個元素,用來控制擴容或者轉移爲樹
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 數組爲空,初始化數組
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 根據 (n - 1) & hash 獲取當前位置的節點
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 通過CAS線程安全,將當前的節點放置到數組指定位置
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// ----------------------------
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 給當前的節點加鎖,保證操作當前的節點線程安全
synchronized (f) {
// 再次取出要存儲的位置的元素,跟前面取出來的比較
if (tabAt(tab, i) == f) {
// 取出來的元素的hash值大於0,當轉換爲樹之後,hash值爲-2
if (fh >= 0) {
binCount = 1;
//遍歷這個鏈表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 要存的元素的hash,key跟要存儲的位置的節點的相同的時候,替換掉該節點的value即可
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
//當使用putIfAbsent的時候,只有在這個key沒有設置值得時候才設置
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
//如果不是同樣的hash,同樣的key的時候,則判斷該節點的下一個節點是否爲空,
if ((e = e.next) == null) {
//爲空的話把這個要加入的節點設置爲當前節點的下一個節點
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//表示已經轉化成紅黑樹類型了
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
//調用putTreeVal方法,將該元素添加到樹中去
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//當在同一個節點的數目達到8個的時候,則擴張數組或將給節點的數據轉爲tree
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//計數
addCount(1L, binCount);
return null;
}
總結:
在學習 Map 的時候,建議先對 數組、鏈表、紅黑樹 有一定的瞭解,然後搞懂其數據結構,先對HashMap進行掌握與熟悉,HashTable 和 ConcurrentHashMap 可以看做是 HashMap 的變形,更好的去學習。源碼還是要去一點點的閱讀,瞭解其設計理念,則在以後的面試中會百戰不殆。