一、HashMap的數據結構:
在JDK1.8之前,HashMap採用桶+鏈表實現,本質就是採用數組+單向鏈表組合型的數據結構。它之所以有相當快的查詢速度主要是因爲它是通過計算散列碼來決定存儲的位置。HashMap通過key的hashCode來計算hash值,不同的hash值就存在數組中不同的位置,當多個元素的hash值相同時(所謂hash衝突),就採用鏈表將它們串聯起來(鏈表解決衝突),放置在該hash值所對應的數組位置上。
結構圖如下:
在JDK1.8中,HashMap的存儲結構已經發生變化,它採用數組+鏈表+紅黑樹這種組合型數據結構。當hash值發生衝突時,會採用鏈表或者紅黑樹解決衝突。當同一hash值的結點數小於8時,則採用鏈表,否則,採用紅黑樹。關於紅黑樹的理解,可以參考大牛的博客 :
一步一圖一代碼,一定要讓你真正徹底明白紅黑樹
這個重大改變,主要是提高查詢速度。它的結構圖如下:
二、HashMap源碼分析:
1.繼承於AbstractMap;下面是它的基本屬性:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列號
private static final long serialVersionUID = 362498820763181265L;
// 默認的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 當桶(bucket)上的結點數大於這個值時會轉成紅黑樹
static final int TREEIFY_THRESHOLD = 8;
// 當桶(bucket)上的結點數小於這個值時樹轉鏈表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中結構轉化爲紅黑樹對應的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存儲元素的數組,總是2的冪次倍
transient Node<k,v>[] table;
// 存放具體元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的個數,注意這個不等於數組的長度。
transient int size;
// 每次擴容和更改map結構的計數器
transient int modCount;
// 臨界值 當實際大小(容量*填充因子)超過臨界值時,會進行擴容
int threshold;
// 填充因子
final float loadFactor;
}
2.插入(put)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
我們可以看到,Hashmap存儲底層都是調用putVal函數;下面我們來具體分析一下putVal函數。
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未初始化或者長度爲0,進行擴容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*
* 處理過程:
* 1、(n - 1) & hash 確定元素存放在哪個桶中,桶爲空,新生成結點放入桶中(此時,這個結點是放在數組中).
* 2、若桶中已經存在元素,則比較桶中第一個元素(數組中的結點)的hash值,key值.
* a).hash值相等,key相等,則將第一個元素賦值給e,用e來記錄
* b).hash值不相等,即key不相等;爲鏈表結點,從尾部插入新結點;
* c).若結點數量達到閾值,轉化爲紅黑樹。
* 迭代index索引位置,如果該位置處的鏈表中存在一個一樣的key,則替換其value,返回舊值
*/
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 {
for (int binCount = 0;; ++binCount) {
// 到達鏈表的尾部
if ((e = p.next) == null) {
// 在尾部插入新結點
p.next = newNode(hash, key, value, null);
// 結點數量達到閾值,轉化爲紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 判斷鏈表中結點的key值與插入的元素的key值是否相等.相等則跳出循環
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
// 用於遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表
p = e;
}
}
// 表示在桶中找到key值、hash值與插入元素相等的結點
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;//用新值替換舊值
afterNodeAccess(e);
return oldValue; // 返回舊值
}
}
//擴容和更改map結構的計數器+1
++modCount;
// 閾值默認0.75*16,實際大小大於閾值就擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
注:從源碼中我們可以看到,hashmap的put是有返回值的,返回的是前妻value的值;是個很nice的設計!
3.獲取(get)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
我們可以看到,底層是調用getNodel函數;下面我們來具體分析一下getNode函數:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 判斷合理條件:table已經初始化,長度大於0,根據hash尋找table中的項也不爲空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
/*
* 處理過程:
* 1、桶中第一項(數組元素)hash值相等,key相等;則取第一個的值。
* 2、桶中不止一個結點:
* a).若爲紅黑樹結點,在紅黑樹中查找。
* b).若爲鏈表結點,在鏈表中查找。
*/
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)//紅黑樹
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//鏈表
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
3.紅黑樹的查找
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
我們可以看到,底層是調用getNodel函數;下面我們來具體分析一下getNode函數:
/**
* 從根節點p開始查找指定hash值和關鍵字key的結點
* 當第一次使用比較器比較關鍵字時,參數kc儲存了關鍵字key的比較器類別
* 如果給定哈希值小於當前節點的哈希值,進入左節點
* 如果大於,進入右結點
* 如果哈希值相等,且關鍵字相等,則返回當前節點
* 如果左節點爲空,則進入右結點
* 如果右結點爲空,則進入左節點
* 如果不按哈希值排序,而是按照比較器排序,則通過比較器返回值決定進入左右結點
* 如果在右結點中找到該關鍵字,直接返回
* 進入左節點
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
4.擴容的情況:
/**
*初始化或者是將table大小加倍。
*如果爲空,則按threshold分配空間,
*否則,加倍後,每個容器中的元素在新table中要麼呆在原索引處,要麼有一個2的次冪的位移
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 保存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; //容量翻倍,使用左移,效率更高
}
else if (oldThr > 0) //初始容量爲閾值threshold
newCap = oldThr;
else { //使用缺省值(如使用HashMap()構造函數,之後再插入一個元素會調用resize函數,會進入這一步)
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;
@SuppressWarnings({"rawtypes","unchecked"})
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) {
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);
/**
* 新表索引:hash & (newCap - 1)---》低x位爲Index
* 舊錶索引:hash & (oldCap - 1)---》低x-1位爲Index
* newCap = oldCap << 1
* 舉例說明:resize()之前爲低x-1位爲Index,resize()之後爲低x位爲Index
* 則所有Entry中,hash值第x位爲0的,不需要哈希到新位置,只需要呆在當前索引下的新位置j
* hash值第x位爲1的,需要哈希到新位置,新位置爲j+oldCap
*/
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
擴容處理會遍歷所有的元素,時間複雜度很高;
經過一次擴容處理後,元素會更加均勻的分佈在各個桶中,會提升訪問效率。
所以,說盡量避免進行擴容處理,也就意味着,遍歷元素所帶來的壞處大於元素在桶中均勻分佈所帶來的好處。
結論:進行擴容,會伴隨着一次重新hash分配,並且會遍歷hash表中所有的元素,是非常耗時的。在編寫程序中,要儘量避免resize。
5.HashMap併發的情況;
多線程下,要避免使用HashMap,因爲從以上HashMap的數據結構我們可以知道:
如果多線程同時操作HashMap的同一個hash值下的鏈表時,插入和刪除都有可能會導致操作丟失。
那多線程想要使用HashMap這種數據結構怎麼辦呢?後面找到了這個傢伙,
ConcurrentHashMap是Java 5中支持高併發、高吞吐量的線程安全HashMap實現。
*(jdk1.5以前是使用Hashtable。我們知道,Hashtable則使用了synchronized,
而synchronized是針對整張Hash表的,即每次鎖住整張表讓線程獨佔,安全的背後是巨大的浪費)*
於是,又想看看ConcurrentHashMap的源碼,看它是怎麼做到的:
代碼6千多行,就看一下最主要的吧,其實它就是增加了Segment靜態類,來把併發的數組分成多個segment組成,
每一個segment包含了對自己的hashtable的操作,比如get,put,replace等操作,
這些操作發生的時候,對自己的 hashtable進行鎖定。
由於每一個segment寫操作只鎖定自己的hashtable,所以可能存在多個線程同時寫的情況,
性能無疑好於只有一個 hashtable鎖定的情況。
/**
* Stripped-down version of helper class used in previous version,
* declared for the sake of serialization compatibility
*/
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
參考資料: