深入理解系列之JAVA數據結構(3)——HashMap

1、 HashMap基於哈希表的 Map 接口的實現。此實現提供所有可選的映射操作,並允許使用 null 值和 null鍵。(除了不同步和允許使用 null 之外,HashMap 類與 Hashtable大致相同)
2、此類不保證映射的順序,特別是它不保證該順序恆久不變。
3、 值得注意的是HashMap不是線程安全的,如果想要線程安全的HashMap,可以通過Collections類的靜態方法synchronizedMap獲得線程安全的HashMap。
Map map = Collections.synchronizedMap(new HashMap());

同樣,還是列出HashMap的屬性參數!

   //樹化鏈表節點的閾值,當某個鏈表的長度大於或者等於這個長度,則擴大數組容量,或者數化鏈表  
   static final int TREEIFY_THRESHOLD = 8;  
   //初始容量,必須是2的倍數,默認是16  
   static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16  
   //最大所能容納的key-value 個數  
   static final int MAXIMUM_CAPACITY = 1 << 30;  
   //默認的加載因子  
   static final float DEFAULT_LOAD_FACTOR = 0.75f;  
   //存儲數據的Node數組,長度是2的冪。  
   transient Node<K,V>[] table;  
   //keyset 方法要返回的結果  
   transient Set<Map.Entry<K,V>> entrySet;  
   //map中保存的鍵值對的數量  
   transient int size;  
   //hashmap 對象被修改的次數  
   transient int modCount;  
   // 容量乘以裝在因子所得結果,如果key-value的 數量等於該值,則調用resize方  法,擴大容量,同時修改threshold的值。  
   int threshold;
   //裝載因子  
   final float loadFactor;  

問題一、HashMap的底層結構是怎樣的?

hashMap說到底還是hash表,默認的初始化大小爲16,且保證爲2的n次方!hash表本質上還是採用的數組來構造的!但是在存數據的時候會出現hash衝突,所以爲了解決衝突主要出現了兩種方式:
1、開放地址法:即當出現衝突後會,尋找下一個空的散列地址,只要數組足夠大總能找到空閒的位置,用來存放數據!
2、鏈地址法:即出現衝突後,把當前位置作爲鏈表擴展爲鏈表,採用頭插法插入新的數據!
而HashMap正是採用第二種方式!但是如果衝突很多會出現鏈表越來越長,所以在JDK8的時候,爲了提高效率當一個位置的鏈表節點大於8,將把鏈表重新組成紅黑樹!所以,實際JDK8的數據結構形式是這樣的:
當單個鏈表的節點數<=8的時候:(圖片來自於https://blog.csdn.net/fighterandknight/article/details/61624150
這裏寫圖片描述

當單個鏈表的節點>8的時候:(圖片來自於http://www.th7.cn/Program/java/201611/1005180.shtml

這裏寫圖片描述

問題二、HashMap如何保證的2的n次方容量?

上文講到,hashMap的默認容量大小是16,且不管如何初始化,均能保證是2的n次方,但是源碼中是怎麼實現這一點的呢?這個要看HashMap的構造函數了!

public HashMap(int initialCapacity, float loadFactor) {
        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,可是最終作用的結果是:

this.threshold = tableSizeFor(initialCapacity);

所以我們進入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的n次方!而且,源碼的做法真的很巧妙——他通過不斷的“無符號右移+或”操作運算,把原數據一步步變成全1的二進制,而我們知道任意數的二進制每位都置1的話就是大於這個數據的最小2的n次方減1!例如:
13=1101——>1111=15
這樣我們最好只需要+1操作就可以獲得我們需要的數了! 這裏解釋四個問題:
1、爲什麼 int n = cap - 1操作?
這是因爲有的時候可能傳遞進去的cap恰好是2的n次方,加入是16,則如果直接使用此方法操作就會出現等於17,不符合我們所需的結果!
2、這些位移操作是如何運算的?
這裏引入網上的一個截圖,進行講解!
這裏寫圖片描述
所以,可以看到其實就是不斷右移把0和1錯開,然後在或操作,則不論是0還是1最終的結果都是1,直到所有位置都爲1,則右移+或操作將保持結果不變!之所以最終以爲16位,是因爲最大的是32位,1->2->4->8->16正好可以滿足32位最大值(全1)
3、 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
這句話的解釋是這樣的——如果n<0,則直接返回1;否則看n是否大於最大值,大於等於最大值則只能取最大值,否則直接取當前值+1即可
4、爲什麼返回的結果賦值給this.threshold = tableSizeFor(initialCapacity);
這個問題需要到下面HashMap存取數據來看!簡單來說就是,存數據的時候回再把這個threshold賦值給cap!

問題三、hashMap存取數據的原理是什麼?

首先要明白,不論是數組節點和鏈表節點、紅黑樹節點每個節點存放的是一個Node <K,V>對象(注意是對象,不是value基本的數據類型值),而Node<K,V>又繼承自Map.Entry<K,V>;實際上HashMap底層維護的是一個Node[] table數組,我們稱之爲Hash桶數組;

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;     //每個儲存元素key的哈希值
        final K key;        //key
        V value;            //value
        Node<K,V> next;     //鏈表下一個node

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            ......
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
        public final int hashCode() { ...... }
        public final V setValue(V newValue) { ...... }
        public final boolean equals(Object o) { ....... }
    }

當存放數據的時候,首先會根據存放數據key計算hashcode,然後再通過Hash算法的後兩步運算(高位運算和取模運算,下文有介紹)來定位該鍵值對的存儲位置,有時兩個key會定位到相同的位置,表示發生了Hash碰撞。當出現碰撞的時候則通過鏈地址發生成鏈表或者紅黑樹!當然Hash算法計算結果越分散均勻,Hash碰撞的概率就越小,map的存取效率就會越高。首先來看存儲位置的計算方法:
(參見//- - - - - - - - - - - -//註釋標記)

public V put(K key, V value) {
    //-----------------hash(key)位置計算------------------------//
        return putVal(hash(key), key, value, false, true);
    }
static final int hash(Object key) {
        int h;
    //----------------1、HashCode計算---------------------//
    //----------------2、高位運算-------------------------//
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

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:判斷是否爲null或者長度是否爲0,如果是則擴容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
//步驟2:當前數組索引位置沒有數據,則直接賦值
    //-------------------3、取模運算------------------------//
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
//步驟3:如果數組索引位節點已經存在(即key值相等),則直接覆蓋
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
//步驟4:否則判斷該節點是否是紅黑樹,是的話則直接插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//步驟5:否則生成鏈表,對鏈表進行插入(尾插法)
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
//步驟6:若鏈表的長度大於8了,則轉換爲紅黑樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
//步驟7:在遍歷的時候若節點(非數組位首節點)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;
//步驟8:插入後發現大於閾值了(注意是大於閾值,不是大於實際長度)則進行擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

存儲位置就是源碼中的tab[i]中的i,他的確立包含三步:
這裏寫圖片描述
1、計算HashCode:h = key.hashCode()
hashcode的計算方法分爲以下幾類:
①Object類的hashCode
  返回對象的經過處理後的內存地址,由於每個對象的內存地址都不一樣,所以哈希碼也不一樣。這個是native方法,取決於JVM的內部設計,一般是某種C地址的偏移。
②String類的hashCode
  根據String類包含的字符串的內容,根據一種特殊算法返回哈希碼,只要字符串的內容相同,返回的哈希碼也相同。
③Integer等包裝類
  返回的哈希碼就是Integer對象裏所包含的那個整數的數值,例如Integer i1=new Integer(100),i1.hashCode的值就是100 。由此可見,2個一樣大小的Integer對象,返回的哈希碼也一樣。
④int,char這樣的基礎類
  它們不需要hashCode,如果需要存儲時,將進行自動裝箱操作,計算方法包裝類。
2、高位運算:(h = key.hashCode()) ^ (h >>> 16)
高位運算的目的就是使得計算出來的hashCode的高16位和低16都能參與到運算當中——因爲最終確立位置的是數組的第三步,第三步中n是數組的長度,如果數組長度很小,那麼實際和hash參與運算的只是hash值的很低的幾位,那麼對於32位的hash值來說前面的位數都是無效的,那麼舉個例子:
Hash01 =1111 1111 1111 1111 1111 1111 1111 0000
Hash02 =1000 1111 1111 1111 1111 1111 1111 0000
假設hash的長度是16位,即n-1 = 1111,你會發現只要最後四位相同,即使前面28位不同,也會發生碰撞,即此時發生碰撞的概率是28/32 = 7/8=87.5%,可見碰撞的概率有多大!如果採用高位運算的方式,將避免此種因素導致的碰撞!
3、取模運算:i = (n - 1) & hash
其實上一步已經講解了這個步驟的作用,但是這裏討論一個問題:

HashMap的數組長度默認是16且擴容後要爲是2的n次方是爲什麼?

正常情況下,爲了防止碰撞發生,常規的設計是把桶的大小設計爲素數。相對來說素數導致衝突的概率要小於合數,具體證明可以參考http://blog.csdn.net/liuqiyao_01/article/details/14475159,Hashtable初始化桶大小爲11,就是桶大小設計爲素數的應用。而之所以設計成2的n次方,一方面已經採取第二步的“高位運算”優化了碰撞的概率發生機率,另一方面就是考慮了這一步:取模運算!這個設計的很巧妙——本來爲了定位一個處於n之內的數字作爲實際的數據的索引位的方法是拿hash%(n-1),但是實際代碼中我們很忌諱使用乘除法,但是此時我們發現,如果n是2的n次方,那麼-1就是n位的全1二級制,則位運算就是取餘運算,所以大大提高了效率!
當確定數據的位置後,就開始直接插入數據!(請對照源碼進行閱讀)

這裏寫圖片描述

步驟1: 判斷是否爲null或者長度是否爲0,如果是則擴容!
步驟2:若當前數組索引位置沒有數據,則直接賦值;
步驟4:否則判斷該節點是否是紅黑樹,是的話則直接插入
步驟5:否則生成鏈表,對鏈表進行插入
步驟6:若鏈表的長度大於8了,則轉換爲紅黑樹
步驟7:在遍歷的時候若節點(非數組位首節點)key值相等,同樣採取覆蓋操作
步驟8:插入後發現大於閾值了(注意是大於閾值,不是大於實際長度)則進行擴容

問題四、HashMap的擴容機制是什麼?

HashMap底層的hash桶數組Node[] table默認的初始化大小length是16,同時有一個默認負載因子Loadfactor=0.75(可以才構造函數中更改),閾值threshold = length * loadfactor,擴容倍數爲原來數組大小長度length的兩倍!需要注意的是(這一點存疑,還請讀者確認後理解):

擴容的時候是實際所有的鍵值對(包括鏈表節點和紅黑樹節點)大於threshold就會觸發,而不是hash桶數組被佔用的大小大於threshold,也就是說即使只使用了兩個hash桶數組位置,只要所有的節點數量size>threshold就會觸發擴容!

看源碼:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
//如果原map容量>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,即初始化階段!則判斷閾值的大小,閾值大於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);
        }
//無論上述條件如何,都要判斷新閾值的大小,如果爲0則更新新閾值!
        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;
//如果當前數據不爲null,則緩存至e,同時置空原索引位,如果該位是數組索引位,則重新計算e的新索引位,並放置!
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
//否則是紅黑樹,則作紅黑樹的操作——太麻煩,現在不講
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//否則是鏈表,則進行鏈表重排——這個下面會詳細講述!
                    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;
    }

問題五:擴容後的鏈表重排機制是什麼?

在JDK7中,採用頭插法來進行擴容後的節點重排,但是在JDK8中是尾插法,這個和起初發生衝突後的做法是一致的!正常情況下,由於擴容後的length不一樣了,那麼每個節點的hash位置也是不一樣的,所以理論上應該是全部重新計算一下然後進行重排,但是這種消耗是巨大的!我們來看看JDK8中是怎麼實現的——現在拉出來上面的for循環代碼來剖析:

{ // 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;
                        }
                    }
                }

首先,聲明瞭:

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;

我們來說明一下重排的機制,就明白這幾個變量的用途:在JDK8中,我們認爲擴容後只出現兩種情況:
1、一種是還保持在原來的鏈表節點位置上;
2、另一種就是分離到了另一個索引位置(記住是另一個,不是另外好幾個)
所以也就相當把當前鏈表拆分到了兩個hash桶索引位,而“巧合的”是,其中一部分保持原位置,另一部分正好是原位置+原Map長度!所以,這裏定義這四個變量,就是尋找被分離後的第一節點,找到第一個節點後,則後續節點按照規則直接順序連接即可,而連接完成後直接把頭結點放到相應的位置就完成了所有節點的重排!
這裏寫圖片描述

然後同樣再來講一講,JDK8中爲什麼可以用當前位置j當前位置+原大小j + oldCap作爲新的分離位置索引!看下圖可以明白這句話的意思,n爲table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。
這裏寫圖片描述
元素在重新計算hash之後,因爲n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
這裏寫圖片描述
因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”,這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認爲是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。有一點注意區別,JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置,但是從上圖可以看出,JDK1.8不會倒置!
但是細心的讀者發現源碼中明明是if ((e.hash & oldCap) == 0)作爲區分的,並不是什麼n-1&hash啊?其實情況是這樣的,這種做法可以通過等於還是不等於0,來判斷當前索引位的hash位置是否變化,舉例來說:
16就是10000,和key1(注意:第5位爲0)相與結果爲0,而和key2(第5位上面爲1)就成了16了(!=0),這個也就是說,如果相與結果爲0,即節點的hash小於< oldLength,否則就是大於>oldLength,這個時候就需要把位置增加oldLength!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章