HashMap是平常開發中常常會用到的一種數據結構,在介紹HashMap的時候會涉及到不少術語,好比時間複雜度O、散列(也叫哈希)、散列算法等,這些在大學課程裏都有教過,可是因爲某種不可抗力又還給老師了,在深刻學習HashMap以前先了解HashMap設計的思路以及以及一些重要概念,在後續分析源碼的時候就可以有比較清晰的認識。html
在回答這個問題以前先看個例子:小明打算從A市搬家到B市,拿了兩個箱子把本身的物品打包就出發了。java
到了B市以後,他想拿手機給家裏報個平安,這時候問題來了,東西多了他忘記手機放在哪一個箱子了?小明開始打開1號箱子找手機,沒找到;再打開2號箱子找,找到手機。當只有2個箱子的時候,東西又很少的狀況下,他可能花個2分鐘就找到手機了,假若有20個箱子,每一個箱子的東西又多又雜,那麼花的時間就多了。小明總結了下查找耗時的緣由,發現是由於這些東西放的沒有規律,若是他把每一個箱子分個類別,好比定一個箱子專門放手機、電腦等電子設備,有專門放衣服的箱子等等,那麼他找東西花的時間就能夠大大縮短了。算法
其實HashMap也是用到這種思路,HashMap做爲一種數據結構,像數組和鏈表同樣用於常規的增刪改查,在存數據的時候(put)並非隨便亂放,而是會先作一次相似「分類」的操做再存儲,一旦「分類」存儲以後,下次取(get)的時候就能夠大大縮短查找的時間。咱們知道數組在執行查、改的效率很高,而增、刪(不是尾部)的效率低,鏈表相反,HashMap則是把這二者結合起來,看下HashMap的數據結構數組
從上面的結構能夠看出,一般狀況下HashMap是以數組和鏈表的組合構成(Java8中將鏈表長度超過8的鏈表轉化成紅黑樹)。結合上面找手機的例子,咱們簡單分析下HashMap存取操做的心路歷程。put存一個鍵值對的時候(好比存上圖蓋倫),先根據鍵值"分類","分類"一頓操做後告訴咱們,蓋倫應該屬於14號坑,直接定位到14號坑。接下來有幾種狀況:bash
get取的時候也須要傳鍵值,根據傳的鍵值來肯定要找的是哪一個"類別",好比找火男,"分類"一頓操做夠告訴咱們火男屬於2號坑,因而咱們直接定位到2號坑開始找,亞索不是…找到火男。markdown
HashMap是由數組和鏈表組合構成的數據結構,Java8中鏈表長度超過8時會把長度超過8的鏈表轉化成紅黑樹;存取時都會根據鍵值計算出"類別"(hashCode),再根據"類別"定位到數組中的位置並執行操做。數據結構
仍是舉個栗子:一個工廠有500號人,下圖用兩種方案來存儲廠裏員工的信件。app
左右各有27個信箱,左邊保安大哥存信的時候不作處理,想放哪一個信箱就放哪一個信箱,當員工去找信的時候,只好挨個信箱找,再挨個比對信箱裏信封的名字,萬一哥們臉黑,要找的放在最後一個信箱的最底下,悲劇…因此這種狀況的時間複雜度爲O(N);右邊採用HashCode的方式將27個信箱分類,分類的規則是名字首字母(第一個箱子放不寫名字的哥們),保安大哥將符合對應姓名的信件放在對應的信箱裏,這樣員工就不用挨個找了,只須要比對一個信箱裏的信件便可,大大提升了效率,這種狀況的時間複雜度趨於一個常數O(1)。oop
例子中右圖其實就是hashCode的一個實現,每一個員工都有本身的hashCode,好比李四的hashCode是L,王五的hashCode是W(這取決於你的hash算法怎麼寫),而後咱們根據肯定的hashCode值把信箱分類,hashCode匹配則存在對應信箱。在Java的Object中能夠調用hashCode()方法獲取對象hashCode,返回一個int值。那麼會出現兩個對象的hashCode同樣嗎?答案是會的,就像上上個例子中蓋倫和老王的hashCode就同樣,這種狀況網上有人稱之爲"hash碰撞",出現這種所謂"碰撞"的處理上面已經介紹瞭解決思路,具體源碼後續介紹。源碼分析
hashCode是一個對象的標識,Java中對象的hashCode是一個int類型值。經過hashCode來指定數組的索引能夠快速定位到要找的對象在數組中的位置,以後再遍歷鏈表找到對應值,理想狀況下時間複雜度爲O(1),而且不一樣對象能夠擁有相同的hashCode。
經過上面信箱找信的例子來討論下HashMap的時間複雜度,在使用hashCode以後能夠直接定位到一個箱子,時間的耗費主要是在遍歷鏈表上,理想的狀況下(hash算法寫得很完美),鏈表只有一個節點,就是咱們要的
那麼此時的時間複雜度爲O(1),那不理想的狀況下(hash算法寫得很糟糕),好比上面信箱的例子,假設hash算法計算每一個員工都返回一樣的hashCode
全部的信都放在一個箱子裏,此時要找信就要依次遍歷C信箱裏的信,時間複雜度再也不是O(1),而是O(N),所以HashMap的時間複雜度取決於算法的實現上,固然HashMap內部的機制並不像信箱這麼簡單,在HashMap內部會涉及到擴容、Java8中會將長度超過8的鏈表轉化成紅黑樹,這些都在後續介紹。
HashMap的時間複雜度取決於hash算法,優秀的hash算法可讓時間複雜度趨於常數O(1),糟糕的hash算法可讓時間複雜度趨於O(N)。
咱們知道HashMap中數組長度是16(什麼?你說不知道,看下源碼你就知道),假設咱們用的是最優秀的hash算法,即保證我每次往HashMap裏存鍵值對的時候,都不會重複,當hashmap裏有16個鍵值對的時候,要找到指定的某一個,只須要1次;
以後繼續往裏面存值,必然會發生所謂的"hash碰撞"造成鏈表,當hashmap裏有32個鍵值對時,找到指定的某一個最壞狀況要2次;當hashmap裏有128個鍵值對時,找到指定的某一個最壞狀況要8次
隨着hashmap裏的鍵值對愈來愈多,在數組數量不變的狀況下,查找的效率會愈來愈低。那怎麼解決這個問題呢?只要增長數組的數量就好了,鍵值對超過16,相應的就要把數組的數量增長(HashMap內部是原來的數組長度乘以2),這就是網上所謂的擴容,就算你有128個鍵值對,咱們準備了128個坑,仍是能保證"一個蘿蔔一個坑"。
其實擴容並無那麼風光,就像ArrayList同樣,擴容是件很麻煩的事情,要建立一個新的數組,而後把原來數組裏的鍵值對"放"到新的數組裏,這裏的"放"不像ArrayList那樣用原來的index,而是根據新表的長度從新計算hashCode,來保證在新表的位置,老麻煩了,因此同一個鍵值對在舊數組裏的索引和新數組中的索引一般是不一致的(火男:"我之前是3號,怎麼如今成了127號,給我個完美的解釋!"新表:"大清亡了,如今你得聽個人")。另外,咱們也能夠看出這是典型的以空間換時間的操做。
說了這麼多,那負載因子是個什麼東西?負載因子其實就是規定何時擴容。上面咱們說默認hashmap數組大小爲16,存的鍵值對數量超過16則進行擴容,好像沒什麼毛病。然而HashMap中並非等數組滿了(達到16)才擴容,它會存在一個閥值(threshold),只要hashmap裏的鍵值對大於等於這個閥值,那麼就要進行擴容。閥值的計算公式:
閥值 = 當前數組長度✖負載因子
hashmap中默認負載因子爲0.75,默認狀況下第一次擴容判斷閥值是16 ✖ 0.75 = 12;因此第一次存鍵值對的時候,在存到第13個鍵值對時就須要擴容了;或者另一種理解思路:假設當前存到第12個鍵值對:12 / 16 = 0.75,13 / 16 = 0.8125(大於0.75須要擴容) 。確定會有人有疑問,我要這鐵棒有何用?不,我要這負載因子有何用?直接規定超過數組長度再擴容不就好了,還免得每次擴容以後還要從新計算新的閥值,Google說取0.75是一個比較好的權衡,固然咱們能夠本身修改,HashMap初識化時能夠指定數組大小和負載因子,你徹底能夠改爲1。
public HashMap(int initialCapacity, float loadFactor) 複製代碼
個人理解是這負載因子就像人的飯量,有的人吃要7分飽,有的人要10分飽,穩妥起見默認讓咱們7.5分飽。
在數組大小不變的狀況下,存放鍵值對越多,查找的時間效率會下降,擴容能夠解決該問題,而負載因子決定了何時擴容,負載因子是已存鍵值對的數量和總的數組長度的比值。默認狀況下負載因子爲0.75,咱們可在初始化HashMap的時候本身修改。
hash和rehash的概念其實上面已經分析過了,每次擴容後,轉移舊錶鍵值對到新表以前都要從新rehash,計算鍵值對在新表的索引。以下圖火男這個鍵值對被存進hashmap到後面擴容,會通過hash和rehash的過程
第一次hash能夠理解成'"分類"',方便後續取、改等操做能夠快速定位到具體的"坑"。那麼爲何要進行rehash,按照以前元素在數組中的索引直接賦值,例如火男以前3號坑,如今跑到30號坑。
我的理解是,在未擴容前,能夠看到如13號鏈的長度是3,爲了保證咱們每次查找的時間複雜度O趨於O(1),理想的狀況是"一個蘿蔔一個坑",那麼如今"坑"多了,原來"3個蘿蔔一個坑"的狀況如今就能有效的避免了。
先看下Java7裏的HashMap實現,有了上面的分析,如今在源碼中找具體的實現。
//HashMap裏的數組 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; //Entry對象,存key、value、hash值以及下一個節點 static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; } //默認數組大小,二進制1左移4位爲16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //負載因子默認值 static final float DEFAULT_LOAD_FACTOR = 0.75f; //當前存的鍵值對數量 transient int size; //閥值 = 數組大小 * 負載因子 int threshold; //負載因子變量 final float loadFactor; //默認new HashMap數組大小16,負載因子0.75 public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //能夠指定數組大小和負載因子 public HashMap(int initialCapacity, float loadFactor) { //省略一些邏輯判斷 this.loadFactor = loadFactor; threshold = initialCapacity; //空方法 init(); } 複製代碼
以上就是HashMap的一些先決條件,接着看平時put操做的代碼實現,put的時候會遇到3種狀況上面已分析過,看下Java7代碼:
public V put(K key, V value) { //數組爲空時建立數組 if (table == EMPTY_TABLE) { inflateTable(threshold); } //key爲空單獨對待 if (key == null) return putForNullKey(value); //①根據key計算hash值 int hash = hash(key); //②根據hash值和當前數組的長度計算在數組中的索引 int i = indexFor(hash, table.length); //遍歷整條鏈表 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //③狀況1.hash值和key值都相同的狀況,替換以前的值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); //返回被替換的值 return oldValue; } } modCount++; //③狀況2.坑位沒人,直接存值或發生hash碰撞都走這 addEntry(hash, key, value, i); return null; } 複製代碼
先看上面key爲空的狀況(上面畫圖的時候總要在第一格留個空key的鍵值對),執行 putForNullKey() 方法單獨處理,會把該鍵值對放在index0,因此HashMap中是容許key爲空的狀況。再看下主流程:
步驟①.根據鍵值算出hash值 — > hash(key)
步驟②.根據hash值和當前數組的長度計算在數組中的索引 — > indexFor(hash, table.length)
static int indexFor(int h, int length) { //hash值和數組長度-1按位與操做,聽着費勁?其實至關於h%length;取餘數(取模運算) //如:h = 17,length = 16;那麼算出就是1 //&運算的效率比%要高 return h & (length-1); } 複製代碼
步驟③狀況1.hash值和key值都相同,替換原來的值,並將被替換的值返回。
步驟③狀況2.坑位沒人或發生hash碰撞 — > addEntry(hash, key, value, i)
void addEntry(int hash, K key, V value, int bucketIndex) { //當前hashmap中的鍵值對數量超過閥值 if ((size >= threshold) && (null != table[bucketIndex])) { //擴容爲原來的2倍 resize(2 * table.length); hash = (null != key) ? hash(key) : 0; //計算在新表中的索引 bucketIndex = indexFor(hash, table.length); } //建立節點 createEntry(hash, key, value, bucketIndex); } 複製代碼
若是put的時候超過閥值,會調用 resize() 方法將數組大小擴大爲原來的2倍,而且根據新表的長度計算在新表中的索引(如以前17%16 =1,如今17%32=17),看下resize方法
void resize(int newCapacity) { //傳入新的容量 //獲取舊數組的引用 Entry[] oldTable = table; int oldCapacity = oldTable.length; //極端狀況,當前鍵值對數量已經達到最大 if (oldCapacity == MAXIMUM_CAPACITY) { //修改閥值爲最大直接返回 threshold = Integer.MAX_VALUE; return; } //步驟①根據容量建立新的數組 Entry[] newTable = new Entry[newCapacity]; //步驟②將鍵值對轉移到新的數組中 transfer(newTable, initHashSeedAsNeeded(newCapacity)); //步驟③將新數組的引用賦給table table = newTable; //步驟④修改閥值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); } 複製代碼
上面的重點是步驟②,看下它具體的轉移操做
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; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } //計算在新表中的索引,併到新數組中 int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } } 複製代碼
這段for循環的遍歷會使得轉移先後鍵值對的順序顛倒(Java7和Java8的區別),畫個圖就清楚了,假設石頭的key值爲5,蓋倫的key值爲37,這樣擴容先後二者仍是在5號坑。第一次:
第二次
最後再看下建立節點的方法
void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; } 複製代碼
建立節點時,若是找到的這個坑裏面沒有存值,那麼直接把值存進去就好了,而後size++;若是是碰撞的狀況,
前面說的以單鏈表頭插入的方式就是這樣(蓋倫:」老王已被我一腳踢開!「),總結一下Java7 put流程圖
相比put,get操做就沒這麼多套路,只須要根據key值計算hash值,和數組長度取模,而後就能夠找到在數組中的位置(key爲空一樣單獨操做),接着就是遍歷鏈表,源碼不多就不分析了。
基本思路是同樣的
//定義長度超過8的鏈表轉化成紅黑樹 static final int TREEIFY_THRESHOLD = 8; //換了個馬甲仍是認識你!!! static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; } 複製代碼
看下Java8 put的源碼
public V put(K key, V value) { //根據key計算hash值 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; //步驟1.數組爲空或數組長度爲0,則擴容(咦,看到不同咯) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //步驟2.根據hash值和數組長度計算在數組中的位置 //若是"坑"裏沒人,直接建立Node並存值 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //步驟3."坑"裏有人,且hash值和key值都相等,先獲取引用,後面會用來替換值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //步驟4.該鏈是紅黑樹 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //步驟5.該鏈是鏈表 else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { //步驟5.1注意這個地方跟Java7不同,是插在鏈表尾部!!! p.next = newNode(hash, key, value, null); //鏈表長度超過8,轉化成紅黑樹 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //步驟5.2鏈表中已存在且hash值和key值都相等,先獲取引用,後面用來替換值 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) //統一替換原來的值 e.value = value; afterNodeAccess(e); //返回原來的值 return oldValue; } } ++modCount; //步驟6.鍵值對數量超過閥值,擴容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } 複製代碼
經過上面註釋分析,對比和Java7的區別,Java8一視同仁,管你key爲不爲空的統一處理,多了一步鏈表長度的判斷以及轉紅黑樹的操做,而且比較重要的一點,新增Node是插在尾部而不是頭部!!!。固然上面的主角仍是擴容resize操做
final Node<K,V>[] resize() { //舊數組的引用 Node<K,V>[] oldTab = 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; } //擴容咯,這裏採用左移運算左移1位,也就是舊數組*2 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //一樣新閥值也是舊閥值*2 newThr = oldThr << 1; // double threshold } 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); } 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); //鏈表長度大於1,小於8的狀況,下面高能,單獨拿出來分析 else { // preserve order 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; } } } } } return newTab; } 複製代碼
能夠看到,Java8把初始化數組和擴容全寫在resize方法裏了,可是思路仍是同樣的,擴容後要轉移,轉移要從新計算在新表中的位置,上面代碼最後一塊高能可能不太好理解,剛開始看的我一臉懵逼,看了一張美團博客的分析圖才豁然開朗,在分析前先捋清楚思路
下面咱們講解下JDK1.8作了哪些優化。通過觀測能夠發現,咱們使用的是2次冪的擴展(指長度擴爲原來2倍),因此,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。看下圖能夠明白這句話的意思,n爲table的長度,圖(a)表示擴容前的key1(5)和key2(21)兩種key肯定索引位置的示例,圖(b)表示擴容後key1和key2兩種key肯定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。
圖a中key1(5)和key(21)計算出來的都是5,元素在從新計算hash以後,由於n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),所以新的index就會發生這樣的變化:
圖b中計算後key1(5)的位置仍是5,而key2(21)已經變成了21,所以,咱們在擴充HashMap的時候,不須要像JDK1.7的實現那樣從新計算hash,只須要看看原來的hash值新增的那個bit是1仍是0就行了,是0的話索引沒變,是1的話索引變成「原索引+oldCap」。
有了上面的分析再回來看下源碼
else { // preserve order //定義兩條鏈 //原來的hash值新增的bit爲0的鏈,頭部和尾部 Node<K,V> loHead = null, loTail = null; //原來的hash值新增的bit爲1的鏈,頭部和尾部 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; } } 複製代碼
爲了更清晰明瞭,仍是舉個栗子,下面的表定義了鍵和它們的hash值(數組長度爲16時,它們都在5號坑)
Key | Hash |
---|---|
石頭 | 5 |
蓋倫 | 5 |
蒙多 | 5 |
妖姬 | 21 |
狐狸 | 21 |
日女 | 21 |
假設一個hash算法恰好算出來的的存儲是這樣的,在存第13個元素時要擴容
那麼流程應該是這樣的(只關注5號坑鍵值對的狀況),第一次:
第二次:
省略中間幾回,第六次
兩條鏈找出來後,最後轉移一波,大功告成。
//擴容先後位置不變的鏈 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } //擴容後位置加上原數組長度的鏈 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } 複製代碼
總結下Java8 put流程圖
1.發生hash衝突時,Java7會在鏈表頭部插入,Java8會在鏈表尾部插入
2.擴容後轉移數據,Java7轉移先後鏈表順序會倒置,Java8仍是保持原來的順序
3.關於性能對比能夠參考美團技術博客,引入紅黑樹的Java8大程度得優化了HashMap的性能