HashMap概述java
基於哈希表的 Map 接口的實現。此實現提供全部可選的映射操做,並容許使用 null 值和 null 鍵。(除了非同步和容許使用 null 以外,HashMap 類與 Hashtable 大體相同。)此類不保證映射的順序,特別是它不保證該順序恆久不變。<!--more-->此實現假定哈希函數將元素適當地分佈在各桶之間,可爲基本操做(get 和 put)提供穩定的性能。迭代 collection 視圖所需的時間與 HashMap 實例的「容量」(桶的數量)及其大小(鍵-值映射關係數)成比例。因此,若是迭代性能很重要,則不要將初始容量設置得過高(或將加載因子設置得過低)。——百度百科node
HashMap是Java程序員使用頻率最高的用於映射(鍵值對)處理的數據類型(鍵值對集合)。隨着JDK版本的更新,JDK1.8對HashMap底層的實現進行了優化,例如引入紅黑樹的數據結構和擴容的優化等。文章先基於1.7描述,最後再提1.8與之更改的地方。程序員
HashMap<String,String> hashMap = new HashMap();
hashMap.put("張三","男");
hashMap.get("張三");
那麼它裏面存的元素就key和value麼?(它其實裏面封裝成一個entry能夠看到除了key與value以外還有next屬性,因此HaspMap裏的元素不只僅含有鍵值對還有指向下一節點的元素的信息)編程
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;//Key-value結構的key
V value;//存儲值
Entry<K,V> next;//指向下一個鏈表節點
final int hash;//哈希值
}
HashMap的數據結構
在Java編程語言中,最基本的結構就是兩種,一個是數組,另一個是模擬指針(引用),全部的數據結構均可以用這兩個基本結構來構造的,HashMap也不例外。HashMap底層就是一個數組結構,數組中的每一項又是一個鏈表。在1.8以後當鏈表長度大於等於8時轉爲紅黑樹(自平衡的二叉樹這裏不詳細展開,之後有機會再講)。數組
那麼它是如何去存儲?(每一個元素存進來如何找到內置數組的位置)微信
hashMap.put("戰三","work");
hashMap.put("裏的","energer");
hashMap.put("約翰","cookie");
....
假如和ArrayList同樣初始化一個index變量值爲0做爲下標,當arrayList.add("a")時將元素存在內置數組array[index]再index自增。即每次日後添加元素。至到數組滿了再依次從第一個日後添加鏈。cookie
在這種狀況下咱們去hashMap.get("約翰"),怎麼去找到。很顯然沒辦法定位。只能從數組第一位開始找比較entry的key值如不一樣再下一個比較直到找到相同的key。那麼數量不少並且對應的entry位置比較偏後查找是及其費時的。雖然添加沒毛病速度也快但查找是遍歷比較因此是不合理的。
數據結構
在HashMap當中實際上存儲時它會去給key進行相似hash獲得的hashCode與數組的容量取模的獲得的就是數組的位置在這樣的位置存下。多線程
在使用key查找時也是對查找的key進行相同的hash直接定位到當初存的位置。這樣的一個位置在數組是隨機的,不一樣的code取模有可能會出現相同的位置即造成鏈表。而後在鏈表當中找到對應key的entry返回。下面僞代碼描述了它的get方法的一個大概意思。直接找到數組所在的entry若是不是就是該entry的next。若是還不是就是next的next直到找到對應key的entry。
併發
get(key){
int hashcode = key.hashCode();
int index = hashcode%table.length;
Entry entry = table[index];
...
return entry;
}
那麼在這裏所謂的鏈表就是在邏輯上理解爲它們在數組同一個位置,實際上就是先找到數組的這個位置獲得了一個entry1而後這個entry1裏的next屬性引用的就是一個entry2,經過這個entry2的next就能找到entry3。這樣的一個鏈表,基於找到第一個(數組中的entry)而後經過next字段一個一個的引用。下圖與上圖相對應
上圖數組存的entry3變量引用堆中的0x003的一個entry對象,這個對象裏的next屬性引用了地址0x004的entry對象。put時它是去怎樣去插入的。當發生碰撞是找到這個鏈表的最下的entry把它的next=null換成當前插入的entry地址,仍是把當前插入的entry的next改成第一個就是數組中的table[index]。也就是頭插仍是尾插。
那麼怎樣去存下面使用僞代碼說明大概流程(實際源碼還有判斷空判斷key是否重複先忽略)
假設是尾插
put(key,value){
int code = key.hashCode();
int index = code%table.length;
Entry e = table[index];
/*
1.數組當前位置空
table[index] = new Entry(key,value,null);
2.當前位置有entry就將它的next設爲當前建立的entry
table[index].next = new Entry(key,value,null);
3.當前有entry而且next也有
table[index].next.next = new Entry(key,value,null);
遍歷鏈表找到最後一個entry也就是next爲空的entry,將它的next設成當前建立的entry
*/
while(e!=null){
e = e.next;
}
e.next = new Entry(key,value,null);
}
假設是頭插
put(key,value){
int code = key.hashCode();
int index = code%table.length;
//1.new當前的entry,並將next設成如今第一個也就是數組上的table[index]
//2.將當前的entry設成第一個就是放在數組上。
//它的next就是之前的第一個entry對象,如今把當前對象放數組
table[index] = new entry(key,value,table[index]);
}
兩種方式首先頭插法明顯要快。直接插而後下移只要一句代碼而尾插須要遍歷。因此咱們在jdk1.7當中是的確使用的頭插法,但在1.8以後修改爲尾插法下面會提。
容量問題
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
// 初始默認容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量值
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認加載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
......
}
默認容量16,加載因子0.75。經過構造方法能夠本身傳入容量與加載因子。但值得一提的是容量必定是2的指數冪
/**
* Inflates the table.
*/
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY+1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
爲何它的容量必定要求爲2的指數冪呢。桶數組用來去定位存儲,hashCode與容量取模定位。確實不論是不是2的指數冪都能進行取模運算,所以這個要求大概是與效率有關。能夠看到源碼得index的方式
//並非index = h % length
indexFor(h,length){
index = h & (length - 1);
}
那麼就多是在容量位2的指數冪的狀況下經過這樣的運算就能代替取模運算,而且位運算與減運算效率高於除運算。下面能夠試一下:
//假如入容量16,一個隨機hashCode
hashCode 1011 0010 0111
length 0000 0000 1111
index 0000 0000 0111
//假如入容量10,一個隨機hashCode
hashCode 1011 0010 0111
length 0000 0000 1001
index 0000 0000 0001
經過與運算結果確定是小於的。因此結果不會越界,但若是容量不爲2的指數冪有某些下標是永遠取不到的,在例子二中比容量1001(10)小的是0-9也就是所有的下標。但111(7),101(5),11(3)永遠取不到。因此只有知足2的指數冪減一與運算的結果纔是具備所有下標的可能性和取模實際同樣。擴容以後全部元素也要遍歷從新定位這個時候也要進行大量的這個運算因此採用這個h&(length-1)效率要更高。
加載因子
第二就是加載因子爲什麼是0.75,這個你們可能都知道等桶數組滿了再擴容哈希碰撞必定挺多的很容易長鏈1.8會生成紅黑樹,但裝到一半就擴容就空位太多很浪費空間,0.75也就是這樣的一個折中的選擇。總而言之就是這樣規定。也是經過統計出來的比較好的結果。下面這段註釋也說明了取0.75發生8次碰撞的機率已是一億分之六紅黑樹並不會那麼容易生成那樣就夠了,其實關於爲何是0.75就把他當作一個折中的選擇。這裏包括下面註釋內容其實上並無說明,這個東西泊松分佈和紅黑樹爲何在設定在鏈表到8的時候生成有關係下面會講
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
擴容問題
擴容源碼(1.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);
}
//put時當達到容量的0.75,進行擴容resize方法建立新數組後就是調用transfer
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;//新數組的長度
for (Entry<K,V> e : table) {//遍歷舊數組
while(null != e) {//遍歷舊數組中的每一個鏈表結點
Entry<K,V> next = e.next;//next指向當前遍歷結點e的下一個結點
if (rehash) {//再hash
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//從新計算當前元素在新數組中的位置
/*********關鍵的 3行代碼(頭插法移動元素)*********/
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
put時當達到閾值後進行擴容也就是執行resize方法建立新數組,再執行transfer方法裏面雙循環遍歷數組與鏈表,從新定位放入新桶數組,在1.7的擴容會有死環問題,這裏先根據源碼的擴容步驟做出了以下的擴容步驟圖
擴容流程
假設上圖數組正在擴容,首先循環到null != e的時候才能執行while的內容,e是遍歷table數組的元素因此e = first,next = e.next這時代碼中的next = sec。
而後求e.hash從新計算使用indexFor獲得新數組的下標i(假設爲3,第4個位置),讓e.next改成newTable[i]的引用,再將newTable[i]改成當前e也就是fist,也就是頭插法
代碼第三步e = next 將變量e的引用原先是fist改成next的引用也就是e = sec。所以while裏面就是遍歷鏈表的元素賦值給e,在判斷e是否爲空當前e是sec,再進行一樣的操做(定位,頭插,移位)
如今新數組就變成圖中右下的樣子了,能夠看到就是sec的next變成first,first的next爲空比起原先數組的關係就是反過來的。由於頭插嘛第一個先插過去後面的插過去就更靠前第一個就是最下面。接下來e就是sec的next也就是遍歷到第三個了而後沒有下一個while結束,for循環下一個數組元素。就這樣循環往復遍歷全部的元素轉移到新數組。
死環問題就是在併發的狀況下作這些擴容步驟出現的問題,經過上述演示單線程的一個擴容流程。假設如今兩個線程同時對一個hashmap擴容。那麼同時訪問resize方法兩個線程裏面都建立了一個本身線程裏的新數組,而後再執行transfer方法共同執行到while裏的第一句next = e.next時的狀況以下圖。兩個線程裏都有本身的e與next還有新數組。
這時線程1停在這裏。線程2繼續所有執行完(while循環兩次直到e爲空),這時線程2的新數組已經移完原來的兩個元素
第一次循環
第二次循環
這時候線程1又日後開始執行了。其實問題就在於此時堆中的fisrt對象和sec已經被改了。first對象裏的next屬性如今實際上是空,而後sec裏的next值爲first。按照步驟
第一步完成後e(first)到新數組,以後e改成next(sec)。
到這一步問題就來了,處理完first以後如今e引用sec,再次while循環 得next = e.next。如今e.next = first而不是null,就出現循環了。處理完sec到時候e再引用first再循環一遍以後e才爲空
將sec移過去而後next爲first(其實它的next原本就是first了由於開始那個線程已經改了所以前面next就不等於null,而是first)。致使接下來e 不是null,是first。而後再進while循環e =first,next=null。這時first就要插在sec上就是first的next改成sec。就造成了死循環
總結這個問題產生就是由於頭插法致使從舊數組到新數組的時候鏈表方向會反過來,再由於併發的問題開始讀取的是first而且next是sec。但中途卻被別的線程改了由於擴容next反過來。它還拿着以前的循環一次再取就是有問題了原本應該鏈表結束了爲0,結果能夠連到fisrt。
JDK8的優化
resize 擴容優化
解決了resize時多線程死循環問題
引入了紅黑樹,目的是避免單條鏈表過長而影響查詢效率
關於resize優化,在1.7上面擴容源碼裏拿到元素從新相似取模運算h & (length-1)獲得位置,實際上在擴容是原來的兩倍,這個時候取模結果就是原來的位置或原來的位置加原數組長度就這兩種狀況。
//擴容前長度16
length-1 0000 0000 1111
hashCode1 1011 0010 0111 (111)3
hashCode2 0001 0101 0111 (111)3
//擴容後長度32
length-1 0000 0001 1111
hashCode1 1011 0010 0111 (111)3
hashCode2 1101 0101 0111 (10111)19
因此hashcode以前沒用到的高一位要麼是0要麼是1。新數組容量是之前兩倍高位多了個1,hashcode若是高一位是0和之前就沒有區別,若是是1就多了1 一個原數組大小。從之前的111要麼沒有要麼仍是111要麼是10111就是看那個高位因此咱們只須要判斷h & length的結果。就看那一個位就能夠判斷它的新下標是不變仍是加上老數組容量
//擴容前長度16
length 0000 0001 0000
hashCode1 1011 0010 0111 (0000 0000)0
hashCode2 0001 0101 0111 (0001 0000)16
//這段代碼放在遍歷原數組裏面
if((h&length)=0){
當前遍歷的下標i就是新數組下標
}else{
當前遍歷的i+oldTable.length就是新下標
}
1.8源碼當中也就是這樣去肯定新的下標位置
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
在上述源碼中,除了新座標的計算外。內部定義的兩組共四個指針(低頭、低尾、高頭、高尾)低高區分新下標是保持原樣的仍是增長的和上面提的同樣不須要rehash,不管是低指針仍是高指針都有兩個頭尾指針head、tail,標記頭尾也是解決1.7當中因爲體位倒置會出現的死循環問題
本文分享自微信公衆號 - IT那個小筆記(qq1839646816)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。