前兩篇寫了Collection體系的List下的ArrayList與LinkedList,另外一部分是Set集合下的集合類,但是Set集合的實現類基本是由Map集合的實現類實現的,所以先分析一下重要的HashMap。
數組存儲區間是連續的,佔用內存嚴重,故空間複雜的很大。但數組的二分查找時間複雜度小,爲O(1);數組的特點是:尋址容易,插入和刪除困難;鏈表存儲區間離散,佔用內存比較寬鬆,故空間複雜度很小,但時間複雜度很大,達O(N)。鏈表的特點是:尋址困難,插入和刪除容易。綜合兩者的特性,做出一種尋址容易,插入刪除也容易的數據結構。
由第一篇綜述JDK源碼解析集合篇–綜述 可以看到HashMap實現了Map接口。
類定義爲:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
在上邊的定義,我們可能會很奇怪,爲什麼HashMap繼承了AbstractMap還要去實現Map接口呢,而且在HashSet,LinkedHashSet,LinkedHashMap都出現了這種定義,對於這個問題有很多解釋,但集合類的作者Josh Bloch 承認這是個錯誤,具體可以看StackOverFlower上的解釋Why does LinkedHashSet extend HashSet and implement Set 。
對於Map集合,我們常見的也就:HashMap,LinkedHashMap,Hashtable與TreeMap。由於HashMap中知識點很多,所以在面試中經常會考HashMap的實現原理。
HashMap是一種非常常見、方便和有用的集合,是一種鍵值對(K-V)形式(哈希表)的存儲結構,下面將還是用圖示的方式解讀HashMap的實現原理,
下邊關於HashMap的特點可做個總結:
HashMap是否允許空 ———– Key和Value都允許爲空
HashMap是否允許重複數據——— Key重複會覆蓋、Value允許重複(但hash可能會重複。衝突)
HashMap是否有序 ———無序,特別說明這個無序指的是遍歷HashMap的時候,得到的元素的順序基本不可能是put的順序
HashMap是否線程安全———非線程安全(可能會出現死循環)
HashMap源碼
閱讀其實現,我們首先要看它的底層存儲結構是什麼,從結構實現來講,HashMap是數組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)實現的,如下如所示。
其實也就是利用哈希表(散列表)這種數據結構來實現的。關於哈希表:散列表(Hash table,也叫哈希表),是根據關鍵碼值(Key value)而直接進行訪問的數據結構。也就是說,它通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫做散列函數,存放記錄的數組叫做散列表。這也就是我們的table數組(包括用來解決衝突的鏈表結構)。
下圖給出HashMap的字段:
從HashMap的屬性值,我們可以很好理解上邊的說法。我們可以看一下Node的定義:
//但鏈表結構
static class Node<K,V> implements Map.Entry<K,V> {
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;
}
}
要注意:在JDK1.8後,加入了紅黑樹進行優化,存儲紅黑樹的是TreeNode:
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;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
我們從代碼中可以看到,TreeNode加入相應的紅黑樹的定義屬性,TreeNode是Node的子類,所以table數組就定義的是Node[],這並不會影響後邊轉紅黑樹的操作。
字段分析
HashMap就是使用哈希表來存儲的。哈希表爲解決衝突,可以採用開放地址法和鏈地址法等來解決問題,Java中HashMap採用了鏈地址法。鏈地址法,簡單來說,就是數組加鏈表的結合。在每個數組元素上都一個鏈表結構,當數據被Hash後,得到數組下標,把數據放在對應下標元素的鏈表上。
注意:如果哈希桶數組很大,即使較差的Hash算法也會比較分散,如果哈希桶數組數組很小,即使好的Hash算法也會出現較多碰撞,所以就需要在空間成本和時間成本之間權衡,其實就是在根據實際情況確定哈希桶數組的大小,並在此基礎上設計好的hash算法減少Hash碰撞。那麼通過什麼方式來控制map使得Hash碰撞的概率又小,哈希桶數組(Node[] table)佔用空間又少呢?答案就是好的Hash算法和擴容機制。
//初始化node數組大小爲16
//The default initial capacity - MUST be a power of two. 這跟hash算法有關
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認負載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比
//當add一個元素到某個位桶,其鏈表長度達到8時將鏈表轉換爲紅黑樹
static final int TREEIFY_THRESHOLD = 8;
//由樹轉換成鏈表的閾值UNTREEIFY_THRESHOLD 當進行刪除操作時,轉成鏈表的的閾值
static final int UNTREEIFY_THRESHOLD = 6;
//在轉變成樹之前,還會有一次判斷,只有鍵值對數量(size)大於 64 纔會發生轉換。這是爲了避免在哈希表建立初期,
//多個鍵值對恰好被放入了同一個鏈表中而導致不必要的轉化。這個至少是TREEIFY_THRESHOLD 的4倍
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<k,v>[] table;//存儲元素的數組
transient Set<map.entry<k,v>> entrySet;
//存放元素node的個數
transient int size;
//被修改的次數fast-fail機制 記錄結構變化的次數(主要是刪除添加)
transient int modCount;
//臨界值 當實際大小(容量*填充比)超過臨界值時,會進行擴容 也就是。
//threshold = length * Load factor 當size>threshold 時,進行擴容
int threshold;
final float loadFactor; //負載因子
結合負載因子的定義公式可知,threshold就是在此Load factor和length(數組長度)對應下允許的最大元素數目,超過這個數目就重新resize(擴容),擴容後的HashMap容量是之前容量的兩倍。默認的負載因子0.75是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果內存空間很多而又對時間效率要求很高,可以降低負載因子Load factor的值;相反,如果內存空間緊張而對時間效率要求不高,可以增加負載因子loadFactor的值,這個值可以大於1。
當在創建HashMap時,可以指定數組的初始化大小和負載因子。其構造器源碼爲:
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
看上邊的代碼時,不知道大家有沒有想,到底table數組和threshold都在哪初始化的,我在看的時候也是找了好久,由構造器我們可以看到,這時候還沒有進行數組初始化工作,當我們添加元素時:
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;
}
在resize()函數中,你可以看到起初始化工作,在這裏我不詳細解釋其代碼,在後邊擴容講解時,在詳細介紹。其實就是下邊的代碼:
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
hash原理
爲了減少衝突,設計好的哈希函數是非常必要的。如果完全沒衝突,則HashMap增刪該查的時間複雜度將爲O(1)。這裏還要強調一下:當使用某個位置的鏈表長度大於8時,轉化爲紅黑樹,使查找/刪除/添加的時間複雜度是O(logn),而不會是O(n)。 在衝突的那個bin上插入的時間複雜度是O(n),源碼是插入到鏈表最後,因爲它要先尋址,因爲它先要查找是否有重複的key,再執行插入。
爲了減少衝突:在HashMap中,哈希桶數組table的長度length大小必須爲2的n次方(一定是合數),這是一種非常規的設計,常規的設計是把桶的大小設計爲素數。相對來說素數導致衝突的概率要小於合數,具體證明可以參考http://blog.csdn.net/liuqiyao_01/article/details/14475159,Hashtable初始化桶大小爲11,就是桶大小設計爲素數的應用(Hashtable擴容後不能保證還是素數)。HashMap採用這種非常規設計,主要是爲了在取模和擴容時做優化,同時爲了減少衝突,HashMap定位哈希桶索引位置時,也加入了高位參與運算的過程。
爲什麼要把length設置爲2的n次方,這在後邊的哈希算法中有應用:JDK1.8的哈希函數爲:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這裏的哈希函數就是3步:1 取key的hashcode值 2 將高位與低位進行運算(通過hashCode()的高16位異或低16位實現的,主要是從速度、功效、質量來考慮的,這麼做可以在數組table的length比較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中,同時不會有太大的開銷。) 3 取模定位到數組位置
我們會思考,爲什麼這裏沒有定位取模這一步呢,這是因爲這一步運算又被融合到了put操作中,我們可以單獨取出這一個操作:
tab[i = (n - 1) & hash]
我們這裏的取模運算非常巧妙地運用了數組長度也就是n只能是2的n次冪的特點,利用&操作代替mod操作,提高了效率。具體的解釋可以看下圖:
put方法解析
添加元素的流程圖爲:
下邊結合代碼來解釋一下此過程:
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.
* @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;
//table爲null,則說明table數組還沒有進行初始化,在resize中初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果定位到table[i]沒元素,則直接放入到該位置,並符p賦值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果已經有元素,則進行衝突解決
else {
Node<K,V> e; K k;
//p是table[i]的Node值,如果插入的Node的key與此Node值相等(hash和equals相等)
//則將e賦值爲p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//判斷是否是TreeNode,是的話執行紅黑樹插入,不是的話執行鏈表插入
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,則進行樹化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//若key相等,則e是不爲null的
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//e不爲null,說明有key重複,則直接覆蓋e指向的node的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//size+1,看是否需要擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
上邊的源碼中,邏輯非常清楚,實現也非常巧妙,看完很有感覺。從源碼中,可以看到,新put的node,如果有衝突,會放到鏈表尾部。
上邊的紅黑樹插入,以及樹化操作時紅黑樹的內容,較爲複雜,這裏先不進行解釋。關於紅黑樹可參看:
紅黑樹概念、紅黑樹的插入及旋轉操作詳細解讀 和紅黑樹的移除節點操作 。
下邊我們重點看一下HashMap的擴容過程。
擴容機制
下邊是JDK1.8 hashMap的擴容代碼:在代碼中進行註釋解釋。
在開始之前,因爲涉及rehash過程,在解釋代碼之前,必須要搞明白jdk1.8的一個優化點:在進行rehash時,1.7是重新計算hash然後進行&(n-1)定位的,但是jdk1.8進行了優化,解決了重新計算hash進行定位的計算。經過觀測可以發現,我們使用的是2次冪的擴展(指長度擴爲原來2倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。看下圖可以明白這句話的意思,相當於多了一個高位1。
因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認爲是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。
其實我覺得這樣也並沒有進行多少優化,因爲1.7進行定位時:hash也是知道的,只是進行hash&(n-1)進行定位的,與hash&n == 1{i+n} 都是位運算,也差不多。
但這也是爲什麼長度取2的冪的另一個應用。
/**
* 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
*/
//完成擴容(容量變爲原來的2倍),且完成rehash
final Node<K,V>[] resize() {
//獲得原來的table數組
Node<K,V>[] oldTab = table;
//原table數組的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//原擴容閾值
int oldThr = threshold;
//定義新容量與閾值
int newCap, newThr = 0;
//如果原容量>0
if (oldCap > 0) {
//如果原容量已經達到最大了1<<30,則不進行擴容,只調整閾值爲最大,隨其碰撞了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果沒達到最大,則變爲原來容量的2倍
//其實這句可分解
//newCap = oldCap << 1
//如果擴容後的容量小於最大容量纔會將閾值變爲原來的2倍
//else if (newCap < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
// newThr = oldThr << 1; // double threshold
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果newCap = 0,oldThr > 0 這是適用於不同的構造函數的
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//默認構造器的處理
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果擴容後的容量大於最大容量了1<<30
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;
//完成rehash
if (oldTab != null) {
//對原數組的沒一個位置 這是一個遍歷 所以rehash過程的是很耗費時間的
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//e = oldTab[j])
if ((e = oldTab[j]) != null) {
//將原位置設爲null
oldTab[j] = null;
//如果沒有碰撞,也就是隻有這一個元素,直接定位設置到新數組的位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果當前節點是TreeNode類型,說明已經樹化了,紅黑樹的rehash過程
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//表明當前節點衝突是鏈表存儲的,完成rehash
//注意:這是1.8的優化點,這也是容量聲明爲2的次冪的另一個應用
else { // preserve order
//rehash後還是原位置
Node<K,V> loHead = null, loTail = null;
//rehash後變爲j+oldCap位置
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//如果擴大的那一位hash還是0,則說明它還是在原位置
if ((e.hash & oldCap) == 0) {
//如果它是第一個被加入的
if (loTail == null)
loHead = e;
//進行鏈接
else
loTail.next = e;
loTail = e;
}
//定位到j+oldCap位置
else {
//如果它是第一個被加入的
if (hiTail == null)
hiHead = e;
//進行鏈接
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//在newTab相應位置設置完成rehash的鏈表
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
從我上邊的代碼註釋中,我相信大家已經很容易理解此過程,但是我們沒有涉及紅黑樹的相應操作,單單看其鏈表的操作,我們就可以看到其代碼寫的確實很好。
與JDK1.7的區別
這是基於jdk1.8的,那它與1.7有什麼區別呢?最大的區別當然是關於紅黑樹的那部分操作,另一部分是hash定位的算法,這在上邊已經分析過了。還有一點注意區別,JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置,但是從1.8的源碼可以看出,JDK1.8不會倒置。
JDK1.8中當元素定位的哈希桶是一個鏈表時,則採用尾插入法。首先從頭遍歷鏈表,根據equals和hashCode來比較key是否相同。因此作爲hashMap的key必須同時重載equals方法和hashCode方法。
JDK 1.7的鏈表操作採用了頭插入法,即新的元素插入到鏈表頭部。在JDK1.8中採用了尾插入法。插入以後如果鏈表長度大於8,那麼就會將鏈表轉換爲紅黑樹。因爲如果鏈表長度過長會導致元素的增刪改查效率低下,呈現線性搜索時間。JDK1.8採用採用紅黑樹進行優化,進而提高HashMap性能。
如果哈希桶是一個紅黑樹,則直接使用紅黑樹插入方式直接插入到紅黑樹中。
下邊是jdk1.7的rehash過程。因爲jdk1.7使用的是頭插法,它依次遍歷數組每個bin上的鏈表,完成rehash。
我們從代碼中就不難理解,由於1.7採用的是頭插法,所以JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置。(後在前,前在後)
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了舊的Entry數組
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
Entry<K,V> e = src[j]; //取得舊Entry數組的每個元素
if (e != null) {
src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組不再引用任何對象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
e.next = newTable[i]; //標記[1]
newTable[i] = e; //將元素放在數組上
e = next; //訪問下一個Entry鏈上的元素
} while (e != null);
}
}
}
JDK1.8與JDK1.7對比,當hash衝突較多時,顯然是JDK1.8效率更高。
線程安全性
HashMap線程不安全的體現在哪?主要是resize和迭代器的fail-fast上
在多線程使用場景中,應該儘量避免使用線程不安全的HashMap,而使用線程安全的ConcurrentHashMap。那麼爲什麼說HashMap是線程不安全的,下面舉例子說明在併發的多線程使用場景中使用HashMap可能造成死循環。resize死循環(會形成環形鏈表)。
jdk1.7出現的resize死循環比較好理解,可以參看這篇文章:談談HashMap線程不安全的體現 。但因爲1.8保持了鏈表原來的順序不變,JDK 1.8 是否會出現類似於 JDK 1.7中那樣的死循環呢??這個有點不理解啊啊,求解答。。。。。。。
HashMap與Hashtable的區別
HashMap和Hashtable都實現了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區別有:線程安全性,同步(synchronization),以及速度。
第一、繼承不同
第一個不同主要是歷史原因。Hashtable是基於陳舊的Dictionary類的,HashMap是Java 1.2引進的Map接口的一個實現。但後來Hashtable也實現了map接口。
第二、線程安全不一樣
Hashtable 中的方法是同步的(利用synchronized關鍵字實現),而HashMap中的方法在默認情況下是非同步的。在多線程併發的環境下,可以直接使用Hashtable,但是要使用HashMap的話就要自己增加同步處理了。
第三、允不允許null值
從上面的put()方法源碼可以看到,Hashtable中,key和value都不允許出現null值,否則會拋出NullPointerException異常。
而在HashMap中,null可以作爲鍵,這樣的鍵只有一個;可以有一個或多個鍵所對應的值爲null。當get()方法返回null值時,即可以表示 HashMap中沒有該鍵,也可以表示該鍵所對應的值爲null。因此,在HashMap中不能由get()方法來判斷HashMap中是否存在某個鍵, 而應該用containsKey()方法來判斷。
第四、遍歷方式的內部實現上不同
Hashtable、HashMap都使用了 Iterator。而由於歷史原因,Hashtable還使用了Enumeration的方式 。
第五、哈希值的使用不同
HashTable直接使用對象的hashCode。而HashMap重新計算hash值。
//hashtable
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
第六、內部實現方式的數組的初始大小和擴容的方式不一樣
HashTable中的hash數組初始大小是11,增加的方式是 old*2+1。HashMap中hash數組的默認大小是16,而且一定是2的指數。
HashMap的遍歷方法
HashMap有多種遍歷方式,主要是依賴於迭代器(因爲for-each也是利用迭代器實現的),這裏就不詳細介紹了,可以參看Java Map遍歷方式的選擇 和 HashMap循環遍歷方式及其性能對比 。
HashMap內容太多了,但搞懂還是有必要的,另外要想徹底搞定HashMap,ConcurrentHashMap更是併發學習的經典,在後邊併發包的源碼解析中再進行介紹。