那些年,咱們又愛又恨的HashMap(一)

1、HashMap集合簡介

特色:java

  • HashMap是Map接口的一個重要實現類,基於哈希表,以key-value的形式存儲數據,線程不安全;
  • null能夠做爲鍵,這樣的鍵只能有一個,能夠有一個或多個鍵對應的值爲null;
  • 存取元素無序。

底層數據結構:node

  • JDK1.8以前,由數組+鏈表構成,數組是存儲數據的主體,鏈表是爲了解決哈希衝突而存在的;
  • JDK1.8之後,由數組+鏈表+紅黑樹構成,當鏈表長度大於閾值(默認爲8),而且數組長度大於64時,鏈表會轉化爲紅黑樹去解決哈希衝突。

注意: 鏈表轉化爲紅黑樹以前會進行判斷,若果閾值大於8,可是數組長度小於64,這時鏈表不會轉化爲紅黑樹去存儲數據,而是會對數組進行擴容。面試

這樣作的緣由: 若是數組比較小,應儘可能避免紅黑樹結構。由於紅黑樹結構較爲複雜,紅黑樹又稱爲平衡二叉樹,須要進行左旋、右旋、變色這些操做才能保證平衡。在數組容量較小的狀況下,操做數組要比操做紅黑樹更節省時間。綜上所述:爲了提升性能以及減小搜索時間,在閾值大於8而且數組長度大於64的狀況下鏈表纔會轉化爲紅黑樹而存在。具體參考treeifyBin方法。算法

HashMap存儲數據結構圖:數組

2、HashMap底層存儲數據的過程

1.如下面代碼所示進行分析:
package hashmap_demo;
import java.util.HashMap;
public class HashMapTest {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("柳巖", 18);
        map.put("楊冪", 28);
        map.put("劉德華", 40);
        map.put("柳巖", 20);
        System.out.println(map);
    }
}
//輸出結果:{楊冪=28, 柳巖=20, 劉德華=40}
複製代碼
2.HashMap存儲過程圖:

3.存儲過程分析:

1.當執行HashMap<String, Integer> map = new HashMap<>();這行代碼建立HashMap實例對象時;在JDK1.8以前,會在構造方法中建立一個長度爲16 的Entry[] table數組用來存儲鍵值對;JDK1.8以後,建立數組的時機發生了變化,不是在構造方法中建立數組了,而是在第一次調用put()方法時(即第一次向HashMap中添加元素)建立Node[] table數組。安全

注意: 建立HashMap實例對象在JDK1.8先後發生了變化,主要有兩點:建立的時機發生了變化;數組類型發生了變化,由原來的Entry[]類型變爲Node[]類型。bash

2.向哈希表中存儲柳巖-18,會根據柳巖調用String類中重寫後的hashCode()方法計算出柳巖對應的哈希值,而後結合數組長度採用某種算法計算出柳巖在Node[]數組中的索引值。若是該索引位置上無數據,則直接將柳巖-18插入到該索引位置。好比計算出柳巖對應的索引爲3,如上圖所示。數據結構

面試題:哈希表底層採用那種算法計算出索引值?還有哪些算法計算索引值?less

答:採用key的hashCode()方法計算出哈希值,而後結合數組長度進行無符號右移(>>>)、按位異或(^)、按位與(&)計算出索引值;還能夠採用平方取中法、取餘數、僞隨機數法。dom

取餘數:10%8=2 11%8=3;位運算效率最高,其餘方式效率較低。

3.向哈希表中存儲楊冪-28,計算出該索引位置無數據,直接插入。

4.向哈希表中存儲劉德華-40,假設劉德華計算出的索引也是3,那麼此時該索引位置不爲null,這時底層會比較柳巖劉德華的哈希值是否一致,若是不一致,則在此索引位置上劃出一個節點來存儲劉德華-40,這種方式稱爲拉鍊法。

補充:索引計算源碼p = tab[i = (n - 1) & hash],即索引=哈希值&(數組長度-1),按位與運算等價於取餘運算,由於19%4=3,19%8=3,因此會出現同一個數組,索引值相同,但哈希值不一樣的狀況。

5.最後向哈希表中存儲柳巖-20柳巖對應的索引值爲3。由於該索引位置已有數據,因此此時會比較柳巖與該索引位置上的其餘數據的哈希值是否相等,若是相等,則發生哈希碰撞。此時底層會調用柳巖所屬String字符串類中的equals()方法比較兩個對象的內容是否相同:

相同:則後添加數據的value值會覆蓋以前的value值,即柳巖-20覆蓋掉柳巖-18

不相同:繼續和該索引位置的其餘對象進行比較,若是都不相同,則向下劃出一個節點存儲(拉鍊法)。

注意點:若是一個索引位置向下拉鍊,即鏈表長度大於閾值8且數組長度大於64,則會將此鏈表轉化爲紅黑樹。由於鏈表的時間複雜度爲O(N),紅黑樹的時間複雜度爲O(logN),鏈表長度多大時O(N)>O(logN)。

3、HashMap的擴容機制

1.HashMap何時進行擴容?

首先看添加元素的put()方法流程:

說明:

  • 上圖中的size表示HashMapK-V的實時數量,不等於數組的長度;
  • threshold(臨界值)=capacity(數組容量)*loadFactory(加載因子),臨界值表示當前已佔用數組的最大值。size若是超過這個臨界值進調用resize()方法進行擴容,擴容後的容量是原來的兩倍;
  • 默認狀況下,16*0.75=12,即HashMap中存儲的元素超過12就會進行擴容。
2.HashMap擴容後的大小是多少?
是原來容量的2倍,即HashMap是以2n進行擴容的。
複製代碼
3.HashMap的默認初始容量是多少?

HashMap的無參構造,默認初始值爲16,源碼以下:

/** * 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
}
複製代碼

默認初始值源碼:

/** * The default initial capacity - MUST be a power of two. * 默認初始容量必須是2的冪 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
複製代碼

由源碼能夠看到,HashMap的默認初始容量爲1左移4位,即1*2的4次方爲16。若是使用HashMap的無參構造進行初始化,第一次put元素時,會觸發resize()方法(擴容方法),擴容後的容量爲16。這一點和ArrayList初始化過程很類似(使用ArrayList的無參構造初始化時,建立的是一個空數組,當第一次向空數組添加元素時會觸發grow()擴容方法,擴容後的容量爲10)。

4.指定初始容量爲何必須是2的冪?

HashMap的有參構造,便可以指定初始化容量大小,源碼以下:

/** * Constructs an empty <tt>HashMap</tt> with the specified initial * capacity and the default load factor (0.75). * * @param initialCapacity the initial capacity. * @throws IllegalArgumentException if the initial capacity is negative. */
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製代碼

即構造一個指定容量和默認加載因子(0.75)的空HashMap

由上面的內容咱們知道,當向HashMap中添加元素時,首先會根據key的哈希值結合數組長度計算出索引位置。HashMap爲了存取高效須要減小哈希碰撞,使數據分配均勻,採用按位與**hash&(length-1)**計算索引值。

HashMap採用取餘的算法計算索引,即hash%length,可是取餘運算不如位運算效率高,因此底層採用按位與**hash&(length-1)**進行運算。兩種算法等價的前提就是length是2的n次冪。

5.爲何這樣就能均勻分佈?

咱們須要知道兩個結論:

  • 2的n次方就是1後面n個0;如2的4次方爲16,二進制表示爲10000;
  • 2的n次方-1就是n個1;好比2的4次方-1位15,二進制表示爲1111。

舉例說明爲何數組長度是2的n次冪能夠均勻分佈:

按位與運算:相同二進制位上都是1,結果爲1,不然爲0。
假設數組長度爲23次冪8,哈希值爲3,即3&(8-1)=3,索引爲3;
假設數組長度爲23次冪8,哈希值爲2,即2&(8-1)=2,索引爲2;
運算過程以下:
3&(8-10000 0011 -->3
0000 0111 -->7
----------------
0000 0011 -->3

2&(8-10000 0010 -->2
0000 0111 -->7
----------------
0000 0010 -->2

結論:索引值不一樣,不一樣索引位置都有數據分佈,分佈均勻。
複製代碼

假設數組長度不是2的n次冪,好比長度爲9,運算過程以下:

假設數組長度爲9,哈希值爲3,即3&(9-1)=3,索引爲0;
假設數組長度爲9,哈希值爲2,即2&(9-1)=2,索引爲2;
運算過程以下:
3&(9-10000 0011 -->3
0000 1000 -->8
----------------
0000 0000 -->0

2&(9-10000 0010 -->2
0000 1000 -->8
----------------
0000 0000 -->0

結論:索引值都爲0,致使同一索引位置上有不少數據,而其餘索引位置沒有數據,導致鏈表或紅黑樹過長,效率下降。
複製代碼

注意: hash%length等價於hash&(length-1)的前提條件是數組長度爲2的n次冪。因爲底層採用按位與運算計算索引值,因此須要保證數組長度必須爲2的n次冪。

6.若是指定的初始容量不是2的n次冪會怎樣?
這時HashMap會經過位運算和或運算獲得一個2的冪次方數,而且這個數是離指定容量最小的2的冪次數。好比初始容量爲10,通過運算最後會獲得16。
複製代碼

該過程涉及到的源碼以下:

//建立HashMap集合對象,並指定容量爲10,不是2的冪
HashMap<String, Integer> map = new HashMap<>(10);
//調用有參構造
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//this關鍵字繼續調用
public HashMap(int initialCapacity, float loadFactor) {//initialCapacity=10
        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;
        this.threshold = tableSizeFor(initialCapacity);//initialCapacity=10
}
//調用tableSizeFor()方法
/** * Returns a power of two size for the given target capacity. * 返回指定目標容量的2的冪。 */
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;
}
複製代碼

下面分析tableSizeFor()方法:

  • int n = cap - 1;爲何要減1操做呢?
這是爲了防止`cpa`已是2的冪了。若是`cpa`已是2的冪,又沒有執行減1的操做,則執行完下面的無符號右移後,返回的將爲`cap`的2倍。
複製代碼
  • n等與0時,返回1,這裏不討論你等於0的狀況。
  • |表示按位或運算:運算規則爲相同二進制位上都是0,結果爲0,不然爲1。

第1次運算:

int n = cap - 1;//cap=10,n=9
n |= n >>> 1;//無符號右移1位,而後再與n進行或運算
00000000 00000000 00000000 00001001  //n=9
00000000 00000000 00000000 00000100  //9無符號右移1位變爲4
-----------------------------------------------
00000000 00000000 00000000 00001101  //按位或運算結果爲13,即此時n=13
複製代碼

第2次運算:

int n = 13
n |= n >>> 2;
00000000 00000000 00000000 00001101  //n=13
00000000 00000000 00000000 00000011  //13無符號右移2位變爲3
------------------------------------------------
00000000 00000000 00000000 00001111  //按位或運算結果爲15,即此時n=15
複製代碼

第3次運算:

int n = 15
n |= n >>> 4;
00000000 00000000 00000000 00001111  //n=15
00000000 00000000 00000000 00000000  //15無符號右移4位變爲0
------------------------------------------------
00000000 00000000 00000000 00001111  //按位或運算結果爲15,即此時n=15
複製代碼

接下來的運算結果都是n=15,因爲最後有一個n + 1操做,最後結果爲16。

總結: 由以上運算過程能夠看出,若是指定的初始容量不是2的n次冪,通過運算後會獲得離初始容量最小的2冪。

4、HashMap源碼分析

1.成員變量
private static final long serialVersionUID = 362498820763181265L; //序列化版本號
複製代碼
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //初始化容量,必須是2的n次冪
複製代碼
static final int MAXIMUM_CAPACITY = 1 << 30; //集合最大容量:2的30次冪
複製代碼
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默認的加載因子
/**1.加載因子是用來衡量HashMap的疏密程度,計算HashMap的實時加載因子的方法爲:size/capacity; *2.加載因子太大致使查找元素效率低,過小致使數組的利用率低,默認值爲0.75f是官方給出的一個較好的臨界值; *3.當HashMap裏面容納的元素已經達到HashMap數組長度的75%時,表示HashMap太擠了,須要擴容,而擴容這個過程涉及到rehash、複製數據等操做,很是消耗性能,因此開發中儘可能減小擴容的次數,能夠經過建立HashMap集合對象時指定初始容量來儘可能避免擴容; *4.同時在HashMap的構造方法中能夠指定加載因子大小。 */
HashMap(int initialCapacity, float loadFactor) //構造一個帶指定初始容量和加載因子的空HashMap
複製代碼
static final int TREEIFY_THRESHOLD = 8; //鏈表轉紅黑樹的第一個條件,鏈表長度大於閾值8
複製代碼
static final int UNTREEIFY_THRESHOLD = 6; //刪除紅黑樹節點時,當紅黑樹節點小於6,轉化爲鏈表
複製代碼
static final int MIN_TREEIFY_CAPACITY = 64; //鏈表轉紅黑樹的第二個條件,數組長度大於64
複製代碼

5、常見面試題

1.發生哈希碰撞的條件是什麼?
兩個對象的索引相同,而且hashCode(即哈希值)相等時,會發生哈希碰撞。
複製代碼
2.如何解決哈希衝突?
JDK1.8以前,採用鏈表解決;JDK1.8以後,採用鏈表+紅黑樹解決。
複製代碼
3.若是兩個key的hashCode相同,如何存儲?
使用equals比較內容是否相同:

相同:後添加的value值會覆蓋以前的value值;

不相同:劃出一個節點存儲(拉鍊法)。
複製代碼
4.HashMap的底層數據結構?

JDK1.8:數組+鏈表+紅黑樹。其中數組是主體,鏈表和紅黑樹是爲解決哈希衝突而存在的,具體以下圖所示:

5.JDK1.8爲何引入了紅黑樹?紅黑樹結構不是更復雜嗎?

JDK1.8之前HashMap的底層數據是數組+鏈表,咱們知道,即便哈希函數作得再好,哈希表中的元素也很難達到百分之百均勻分佈。當HashMap中有大量的元素都存在同一個桶(同一個索引位置),這個桶下就會產生一個很長的鏈表,這時HashMap就至關因而一個單鏈表的結構了,假如單鏈表上有n個元素,則遍歷的時間複雜度就是O(n),遍歷效率很低。針對這種狀況,JDK1.8引入了紅黑樹,遍歷紅黑樹的時間複雜度爲O(logn),因爲O(n)>O(logn);因此這一問題獲得了優化。

6.爲何鏈表長度大於8才轉化爲紅黑樹?

咱們知道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 * more: less than 1 in ten million */
複製代碼

翻譯過來的的值意思就是說:

紅黑樹節點所佔空間是普通鏈表節點的兩倍,而且鏈表中存儲數據的頻率符合泊松分佈,咱們能夠看到,在鏈表爲8的節點上存儲數據的機率是0.00000006,這也就代表超過8之後的節點存儲數據的機率就很是小了。

由上述分析能夠得出:

  • 若是小於閾值8就是用紅黑樹,會使得結構一開始就很複雜;
  • 若是大於閾值8還使用鏈表,會致使鏈表節點不能被充分利用;
  • 因此,閾值8是科學合理的一個值,是空間和時間的權衡值。
7.爲何加載因子設置爲0.75?邊界值是12?
  • 若是加載因子是0.4,那麼16*0.4=6,導致數組中滿6個空間就擴容,形成數組利用率過低了;

  • 若是加載因子是0.9,那麼16*0.9=14,這樣就會使數組太滿,很大概率形成某一個索引節點下的鏈表過長,進而致使查找元素效率低;

  • 因此兼顧數組利用率又考慮鏈表不要太長,通過大量測試0.75是最佳值。

相關文章
相關標籤/搜索