天天看點

HashMap詳解

摘要

散清單(Hash table,也叫哈希表),是根據關鍵碼值(Key value)而直接進行通路的資料結構。也就是說,它通過把關鍵碼值映射到表中一個位置來通路記錄,以加快查找的速度。這個映射函數叫做散列函數,存放記錄的數組叫做散清單。

HashMap是Java程式員使用最頻繁的的用于鍵值對(key value)資料處理的容器,在JDK1.7(Java Developmet Kit)時HashMap采取的是數組+連結清單的形式存儲資料,JDK1.8對HashMap進行了存儲結構上的優化,引入了紅黑樹資料結構,極大的增強了HashMap的存取性能!為什麼會引入紅黑樹呢?因為HashMap存在一個問題,即使負載因子和Hash算法設計的再合理,也無法避免出現在連結清單上拉鍊過長的問題,如果極端情況下出現嚴重的Hash沖突,會嚴重影響HashMap的存取性能,于是HashMap在jdk1.8時,引入了紅黑樹,利用紅黑樹快速增删改查的特點來優化了HashMap的性能!

簡介

Java為資料結構中的映射(鍵值對)定義了一個接口java.util.Map,這個接口有四個我們在開發中使用較為頻繁的實作類,分别是HashMap、Hashtable、LinkedHashMap、TreeMap

HashMap詳解
序号 實作類 特點
1 HashMap<K, V>

1、根據鍵的hashcode存儲資料

2、允許一條記錄的key為null,允許多條記錄的value為null

3、非線程安全

2 Hashtable<K, V>

1、線程安全

2、性能較低,需要使用的場景可以用ConcurrentHashMap替換

3 LinkedHashMap<K,V>

1、通路具有順序性,儲存了插入順序

2、可以通過構造函數入參來使其按照通路次序排序

4 TreeMap<K,V>

1、有序Map,可以通過排序比較器來自定義存儲資料的排序規則,預設按照key的生序排列

2、使用時key需要實作Comparable接口或者通過構造函數傳入自定義Comparator

存儲結構

jdk1.8以後HashMap采用數組+連結清單/紅黑樹的方式來存儲資料

HashMap詳解

源碼分析

主幹

// HashMap的主幹,也就是上面的綠色部分,是一個Node數組,每個Node包含一個K-V鍵值對

transient Node[] table;

節點元素

// Node是HashMap的靜态内部類,實作了Map接口中的内部Entry接口

static class Node implements Map.Entry {

    // 記錄目前Node的key的hash值,可以避免重複計算,空間換時間

    final int hash;

    // 鍵

    final K key;

    // 值

    V value;

    // 存儲指向下一個Entry的引用,是單向連結清單結構

    Node next;

    // ...

 }

其他重要字段

// 預設的初始容量 16

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 最大容量,1左移30位

static final int MAXIMUM_CAPACITY = 1 << 30;

// 預設擴容因子 0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 連結清單轉紅黑樹的連結清單長度

static final int TREEIFY_THRESHOLD = 8;

// 紅黑樹轉連結清單的門檻值

static final int UNTREEIFY_THRESHOLD = 6;

// 連結清單轉紅黑樹的數組長度

static final int MIN_TREEIFY_CAPACITY = 64;

// 實際存儲K-V鍵值對的個數

transient int size;

// 記錄HashMap被改動的次數,由于HashMap非線程安全,modCount可用于FailFast機制

transient int modCount;

// 擴容門檻值,預設16*0.75=12,當填充到13個元素時,擴容後将會變為32,

int threshold;

// 負載因子 loadFactor=capacity*threshold,HashMap擴容需要參考loadFactor的值

final float loadFactor;

構造函數

// 看一個參數比較全的構造函數,構造函數中并未給table配置設定記憶體空間,此構造函數HashMap(Map m)會給table配置設定記憶體空間

public HashMap(int initialCapacity, float loadFactor) {

    // 判斷初始化容量是否合法,如果<0則抛出異常

    if (initialCapacity < 0)

        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);

    // 判斷initialCapacity是否大于 1<<30,如果大于則取 1<<30 = 2^30

    if (initialCapacity > MAXIMUM_CAPACITY)

        initialCapacity = MAXIMUM_CAPACITY;

    // 判斷負載因子是否合法,如果小于等于0或者isNaN,loadFactor!=loadFactor,則抛出異常

    if (loadFactor <= 0 || Float.isNaN(loadFactor))

        throw new IllegalArgumentException("Illegal load factor: " +loadFactor);

    // 指派loadFactor

    this.loadFactor = loadFactor;

    // 通過位運算将threshold設值為最接近initialCapacity的一個2的幂次方(這裡非常重要)

    this.threshold = tableSizeFor(initialCapacity);

}

hash算法實作

HashMap中的hash算法實作分為三步。其中第二步使用hash值高16位參與位運算,是為了保證在數組table的length比較小的時候,可以保證高低bit都參與到hash運算中,保證配置設定均勻的同時采用位運算,也不會有太多的性能消耗;其中第三步,當n是2的整數的幂次方是,hash&(n-1),相當于對hash值取模,而位運算比取模運算效率更高;具體流程可以通過圖示檢視。

第一步:通過key.hashCode()擷取key的hashcode;

第二步:通過(h = key.hashCode()) ^ (h >>> 16)進行高16位的位運算;

第三步:通過(n - 1) & hash對計算的hash值取模運算,得到節點插入的數組所在位置。

HashMap詳解

HashMap之put方法

第一步:判斷鍵值對數組table[i]是否為空/null,是則執行resize()擴容。

第二步:根據鍵key計算hash值得到插入數組的索引i,如果tab[i]== null則直接插入,執行第六步;如果tab[i] != null,執行第三步。

第三步:判斷tab[i]的第一個元素與插入元素key的hashcode&equals是否相等,相等則覆寫,否則執行第四步。

第四步:判斷tab[i]是否是紅黑樹節點TreeNode,是則在紅黑樹中插入節點,否則執行第五步。

第五步:周遊tab[i]判斷連結清單是否大于8,大于8則可能轉成紅黑樹(數組需要大于64),滿足則在紅黑樹中插入節點;否則在連結清單中插入;在周遊連結清單的過程中如果存在key的hashcode&equals相等則替換即可。

第六步:插入成功,判斷hashmap的size是否超過threshold的值,超過則擴容。

HashMap詳解

put(K key, V value)

public V put(K key, V value) {

    // hash(key) 根據key計算一個hash值,具體實作如下函數

    return putVal(hash(key), key, value, false, true);

計算key的hash值

static final int hash(Object key) {

    int h;

    // 如果key等于null,傳回0

    // 否則取key的hashcode值h, 将h無符号右移16位也就是擷取高16位,将兩個值做異或位運算後傳回

    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

putVal(int hash, K key, V value, boolean onlyIfAbsent,  boolean evict)

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {

    Node[] tab; Node p; int n, i;

    // 判斷table是否為空,為空則resize()建立

    if ((tab = table) == null || (n = tab.length) == 0)

        n = (tab = resize()).length;

    // (n - 1) & hash計算插入節點在數組中的索引index,為空則直接插入

    if ((p = tab[i = (n - 1) & hash]) == null)

        tab[i] = newNode(hash, key, value, null);

    else {

        Node e; K k;

        // 如果節點key存在,則覆寫目前元素

        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))

            e = p;

        // 節點不存在且目前的數組節點上的鍊為RBT紅黑樹,則按照紅黑樹的方式插入節點

        else if (p instanceof TreeNode)

            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);

        // 鍊為連結清單

        else {

            for (int binCount = 0; ; ++binCount) {

                // 節點的下一個節點為null,說明周遊到了連結清單的最後一個節點,将目前周遊到的最後一個節點的next指向新插入節點

                if ((e = p.next) == null) {

                    p.next = newNode(hash, key, value, null);

                    // 連結清單長度大于8可能需要做紅黑樹轉換,具體要看treeifyBin()中判斷數組的長度是否大于64

                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

                        treeifyBin(tab, hash);

                    break;

                }

                // 節點存在則傳回

                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))

                // 不存在且寫一個節點不為null,則将下一個節點指派給目前節點,繼續下一次循環

                p = e;

            }

        }

        // 節點存在替換後直接傳回,不會記錄HashmMap結構變化

        if (e != null) { // existing mapping for key

            V oldValue = e.value;

            if (!onlyIfAbsent || oldValue == null)

                e.value = value;

            afterNodeAccess(e);

            return oldValue;

    }

    // 記錄HashMap的結構變化

    ++modCount;

    // 判斷節點插入後是否需要對hashMap的數組進行擴容

    if (++size > threshold)

        resize();

    afterNodeInsertion(evict);

    return null;

擴容

final Node[] resize() {

    // 将擴容前的數組指派給oldTab

    Node[] 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;

        // 将原先數組大小左移一位,也就是擴大兩倍,擴容前需要判斷大小是否超過允許的最大值

        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&

                 oldCap >= DEFAULT_INITIAL_CAPACITY)

            newThr = oldThr << 1; // double threshold

    // 如果數組大小小于等于0且threshold大于0(比如HashMap中的元素被全部删除了),則将oldThr的值指派給newCap

    else if (oldThr > 0) 

        newCap = oldThr;

    //首次初始化,給與預設的值

    else {               

        newCap = DEFAULT_INITIAL_CAPACITY;

        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

    // 如果新的擴容臨界值仍為0,需要給newThr計算一個值

    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數組

    Node[] newTab = (Node[])new Node[newCap];

    table = newTab;

    if (oldTab != null) {

        // 周遊舊數組指派到新數組中

        for (int j = 0; j < oldCap; ++j) {

            Node e;

            // 如果節點不為null,則将節點指派給e

            if ((e = oldTab[j]) != null) {

                // 将節點引用置空,等待GC回收

                oldTab[j] = null;

                // 如果節點的next指向為空,則根據節點記錄的hash值&擴容的大小-1,重新計算節點在數組中的索引位置

                if (e.next == null)

                    newTab[e.hash & (newCap - 1)] = e;

                // 如果節點是紅黑樹節點,按照紅黑樹節點插入方式插入

                else if (e instanceof TreeNode)

                    ((TreeNode)e).split(this, newTab, j, oldCap);

                // 此處表示目前節點上的鍊為連結清單,周遊連結清單,将連結清單轉移到新的數組上

                else {

                    // 建立兩條鍊lo鍊在原先數組節點位置

                    Node loHead = null, loTail = null;

                    // hi鍊插入原先數組索引+oldCap位置

                    Node hiHead = null, hiTail = null;

                    Node next;

                    do {

                        next = e.next;

                        // 重點

                        // 擷取目前節點的hash值,與oldCap做按位與運算,如果運算結果為0,則加入lo鍊

                        if ((e.hash & oldCap) == 0) {

                            if (loTail == null)

                                loHead = e;

                            else

                                loTail.next = e;

                            loTail = e;

                        }

                        // 否則加入hi鍊

                        else {

                            if (hiTail == null)

                                hiHead = e;

                                hiTail.next = e;

                            hiTail = e;

                    } while ((e = next) != null);

                    // 新數組的原位置指向lo鍊的頭節點

                    if (loTail != null) {

                        loTail.next = null;

                        newTab[j] = loHead;

                    }

                    // 新數組的j+oldCap指向hi鍊的頭節點

                    if (hiTail != null) {

                        hiTail.next = null;

                        newTab[j + oldCap] = hiHead;

    return newTab;

圖文聊聊jdk1.8中擴容後resize()那些事

在jdk1.8中,當發生HashMap擴容時我們通過源碼可以發現,HashMap并未重新通過新的數組長度來确定節點的位置,而是通過(e.hash & oldCap) == 0,原hash值與原數組長度做與位運算來确定節點在新數組中的位置,當(e.hash & oldCap) == 0時,節點在新數組中的位置和老數組位置一緻;當(e.hash & oldCap) != 0時,節點在新數組中的位置為j + oldCap,也就是原先的位置加上老數組的長度。

這裡抛出兩個問題,帶着問題來分析,

問題一:為什麼能這麼實作呢?

問題二:以前hash值相同的節點在數組的同一個索引位置上,現在竟然會被随機配置設定到兩個新的索引位置上,這會不會導緻get(key)時無法擷取到元素呢?

三個前提條件

1、HashMap在做初始化的時候數組的預設大小是16,且如果我們設值的初始值不是2的整數次幂,那麼它會自動的去計算一個與初始化值最靠近的2的整數次幂的初始值,具體實作可以檢視源碼中的tableSizeFor()方法。

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;

2、HashMap擴容的方式是oldCap << 1,一定是擴容為原來的2倍,保證擴容後仍然是2的整數次幂

3、HashMap計算數組索引的方式為(n - 1) & hash

數組的初始大小為16,擴容因子為0.75,依次插入三個節點,節點的key分别為key1、key2、key3,假設在插入完成第三個節點後數組中節點的個數到達12個,此時需要進行擴容

HashMap詳解

此時HashMap中元素分布假設下圖所示,size=12,進行擴容

HashMap詳解

擴容的算法為 e.hash & oldCap,計算後發現key2的值為0,維持在老數組中下标位置,key1和key3需要轉移到j + oldCap的位置,也就是1+16=17下标位置上

HashMap詳解

擴容後節點分布圖

HashMap詳解

此時我們再來看擴容後根據key擷取元素的代碼

public V get(Object key) {

    Node e;

    // 根據目前key計算hash值,這個計算方式上面已經詳細介紹過

    return (e = getNode(hash(key), key)) == null ? null : e.value;

final Node getNode(int hash, Object key) {

    Node[] tab; Node first, e; int n; K k;

    // 當數組不為空,并且key對應的數組下标的元素存在

    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {

        // 檢查擷取的節點是否是第一個節點,是則傳回

        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))

            return first;

        // 不是頭結點則判斷下一個節點是否存在

        if ((e = first.next) != null) {

            // 如果節點是紅黑樹,則在紅黑樹中擷取元素

            if (first instanceof TreeNode)

                return ((TreeNode)first).getTreeNode(hash, key);

            // 如果是連結清單則周遊連結清單中的節點,比對key則傳回,當e不存在下一個節點則結束循環傳回null

            do {

                if (e.hash == hash &&

                    ((k = e.key) == key || (key != null && key.equals(k))))

                    return e;

            } while ((e = e.next) != null);

    // 為比對上則傳回null

從上面的源代碼中我們可以非常明顯的看見,擷取節點所在數組中的下标的方式也是(n - 1) & hash,與插入節點時計算數組下标的方式是一抹一樣的,那此時為何能準确的找到在key1、key2、key3呢?原因就是n的值變為了原來的兩倍,而節點的hash值是不會發生改變的,此時計算出來的節點在數組中的位置,與我們在擴容時,進行的節點移動操作(也可以了解為重新計算數組中的索引)是一緻的;我們可以通過get時,計算key1、key2、key3的索引來展示效果,如下

HashMap詳解

小結

  • HashMap在1.8中做了很大的優化,無論是在計算hash值的算法上,還是底層資料結構從數組+連結清單變為數組+連結清單/紅黑樹,都是對性能有很大的提升
  • HashMap是線程不安全的,在實際生産開發過程中我們如果需要使用的線程安全的key-value存儲,可以選擇ConcurrentHashMap或者Collections的synchronizedMap是的HashMap具有線程安全的能力,當然推薦的是ConcurrentHashMap
  • 為了保證HashMap的性能,減少擴容帶來的性能損耗,我們在使用的過程中,要提前預估存儲值的大小,設定合理的初始大小和擴容因子也是有必要的
  • 在使用HashMap時,有必要的情況下我們一定要重寫key的hashcode方法和equals方法