HashMap的底層運作和源碼解析---把最珍貴的源碼理解分享出來

HashMap的底層運作和源碼解析

哈希的定義:

  • 任意長度的輸入通過散列算法變換成固定長度的輸出,該輸出就是散列值(又稱哈希值)

哈希的作用:

哈希的作用在數據結構和密碼學中,發揮的作用不盡相同。
今天我們主要去了解數據結構中的應用。

Hash表----HashMap

而JAVA中的HashMap和HashTable就是我們常說的Hash表在計算機的表現形式。

生成HashMap的流程:

:我們先初始化HashMap,此時如果你不加參數時,調用無參方法。

  • PS:加參數自然會去初始化容量和加載因子兩項。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
  • 源碼看出此時只生成一個HashMap對象,沒有初始化容量只設定了它得默認加載因子爲0.75

:初始化後我們存值需要傳key,和value值來傳參,運用put方法,put調用putval方法

public V put(K key, V value) {
     // 生成key得hash值
     return putVal(hash(key), key, value, false, true); 
}
static final int hash(Object key) {
     int h;
    // 使用擾動函數,進行了一次擾動,將高位與低位進行異或操作
    // 以此來減少映射重複的概率
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
public int hashCode() {
        int h = hash;
 		//hash default value : 0 
        if (h == 0 && value.length > 0) {
			 //value : char storage
            char val[] = value;
			// 字符串得hash值生成算法得到固定長度值
            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }
/*
常見的Hash算法:
- 直接定址法
- 平方取中法
- 數字分析法
- 除留餘數法
*/
  • 此時我們會去生成key的hash值(hash算法多種多樣)
  • 是利用key值來進行調用hashcode方法裏面使用了哈希函數來返回int型的hash值(-2147483648到2147483648)
  • 並將其轉爲二進制讓高位向右移16位自身進行異或操作來完成高低位擾動,最後返回一個製作好得hash值。
  • PS:(因爲向右移了16位,本質是讓自身得高位與低位進行異或,這樣當換一個key值時,只要高位或者低位產生一點點變動,都能影響異或結果)。

:此時我們會去進入putval方法進行傳值,因爲此時沒有容量我們會去動態的生成一個初始容量爲16的Node數組來存key和value,到此HashMap存值已經結束

  • 若結點數量超過閾值(負載因子*容量)我們就會擴容

  • 當某一處hash桶的鏈表結點超過8個,我們就會轉爲紅黑樹存儲

  • 可以看出現在1.8版本基本都用Node數組來替代以前的Entry數組

static final int TREEIFY_THRESHOLD = 8;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
    	// 因爲我們沒有初始化容量,我們會去判斷Hashmap是否插入過元素
    	// 以此來通過resize()擴容函數來進行初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
    	// 經典與操作,將hash值映射到我初始化0-15的容量上
    	// 這裏一夥兒細講
        if ((p = tab[i = (n - 1) & hash]) == null)
            // 存進Node數組
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // 這裏使用鏈地址法來解決Hash碰撞問題
            // 當hash值相同時,我們會將其存爲鏈表形式或者紅黑樹形式
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 判斷當前存的結點是否已經變爲樹結點類型
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 存鏈表裏
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 這裏又是經典操作
                        // 鏈表長度超過8就將鏈表轉爲紅黑樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    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;
       	// 經典操作,容量大於閾值(負載因子*容量)時我們將要擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

JDK1.8源碼
// 插入是會把key值進行轉爲hash值
public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

// 獲取也會將hash值傳入
public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

// 返回被擾動過得hash值
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

這裏看完後會產生幾個疑問:
  1. 爲什麼要用擾動函數
  2. 返回hash值之後想存的值怎麼確定在數組得存儲位置
  3. 爲什麼是數組+鏈表,之後還要加紅黑樹
  4. 怎麼擴容?擴容後原先結點是否要rehash
  5. 負載因子爲什麼是0.75
  6. 默認數組容量爲什麼是16
  7. 爲什麼鏈表長度大於8才轉紅黑樹
  8. 如何減少哈希碰撞
  9. 爲何HashMap線程不安全
  • 如果你也有這些疑問,請看我接下來的解答:

一:爲什麼要用擾動函數?

擾動函數的目的是爲了讓Hash值這個巨長的值去映射到固定數組0-15長度時,變得更加不規律,來降低數組裏的Hash值映射後出現碰撞的概率。

(h = key.hashCode()) ^ (h >>> 16) // 具體代碼

這裏的hash二進制向右移動了16位將低位信息抹除了只留下了高位信息

1111 1111 1111 1111 0101 1101 —> 0000 0000 0000 0000 1111 1111

並且讓兩者進行進行異或操作,也就是讓它自己高位與低位進行運算,這樣之後如果出現和它相似的hash值,只要這個相似值有一點點變化,最後異或後的結果都會有所不同。從而降低之後映射完發生碰撞的概率

1.7版擾動了4次,因爲1.8版本加入了紅黑樹,並且本身後3次進行擾動他們的邊際效果不高,統計學上只產生一點的效能提高,加上做異或操作本身就是佔用性能的,所以1.8版本改進之後只擾動了一次,在紅黑樹的加持下,效率幾乎沒有下降。

二:返回hash值之後想存的值怎麼確定在數組得存儲位置

if ((p = tab[i = (n - 1) & hash]) == null)
            // 存進Node數組
            tab[i] = newNode(hash, key, value, null);

直接看源碼,存在數組下標用了操作,HashMap容量-1hash值的與將其直接映射到數組的下標處(這裏也是爲什麼HashMap的容量是2的整數次冪的原因)

原理:

當HashMap爲2的整數次冪時,並將它減一

16二進制:0001 0000 ——>15二進制: 0000 1111

一定會變成全1的二進制,這樣與hash值與操作時,其結果全由hash值得二進制後4位來決定存儲位置(一定爲0-15)。

也就是爲什麼需要擾動函數得原因之一-------讓二進制得後4位得隨機性更大

三:爲什麼是數組+鏈表,之後還要加紅黑樹

爲了解決Hash值類似最後映射到相同數組下標得hash桶裏,我們解決Hash衝突得方法有多種,下面介紹兩種:

  • 開放定址法:1.平方探查,2.線性探查,3.僞隨機序列,4.雙Hash函數

  • 鏈地址法:數組加數組對應下標後延長鏈表

    顯然HashMap用得鏈地址法

    同時這裏的紅黑樹是對鏈表進行優化的方式,當出現hash全部撞到一起時,原本的O(1)查找會退化成O(n),我們是爲了去優化O(n)而引入的紅黑樹結構,將其優化成**O(logn)**查找,具體紅黑樹的介紹另開一篇。

四:怎麼擴容?擴容後原先結點是否要rehash

直接上源碼+加上自己的註解

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;
            }
            // 不是初始化就擴容兩倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                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);
        }
    	// 閾值 = DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
    		// 這裏初始化容量 16
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            // 初始化時直接跳走,因爲oldcap爲0
            // 真正的當達到閾值時,進行擴容的操作
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                // 將當前不爲空的鏈表傳給e
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    // 如果就一個值時對新容量的大小進行rehash
                    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;
                            // 這裏hash值和原先的容量進行取 與
                            // 很騷的是這裏結果不是爲1就是爲0
                            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;
                            // 因爲是擴容2倍
                            // 高位存在擴容後的第二倍的相同位置
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

五:負載因子爲什麼是0.75

這裏其實源碼有解釋,大致意思就是

  • 負載因子小時,我們數組容量還很大,就會被迫提前進行擴容這個費時又費空間的操作。
  • 負載因子大時,我們空閒的數組容量不夠了,就會發生很多次的hash碰撞,造成查找上的時間浪費。
  • 0.75是我們綜合時間複雜度和空間複雜度的權衡,最終經過多次測試選定的值。

六:默認數組容量爲什麼是16

其實這個問題主要問的是爲什麼是2的整數次冪,其次問的爲什麼是16,

  • 2^n是因爲之後我們需要使用數組容量插入元素擴容時都需要與key的hash值進行操作,只有當2的n次冪長度時,它的長度再減一的二進制形式全爲1,
  • 16二進制:0001 0000 ——>15二進制: 0000 1111
  • 當與全爲1的二進制進行時,對於存儲數組在哪個下標的位置的控制權才能全權交給hash值得二進制來控制,並且剛好將hash值映射數組下標範圍,沒有超出,很騷的操作。
  • 其次第二問題,爲什麼是16,不是8,32,原因也很簡單:太小了就有可能頻繁發生擴容,影響效率。太大了又浪費空間,不划算

七:爲什麼鏈表長度大於8才轉紅黑樹

JDK1.7版本里僅僅只是數組加鏈表沒有紅黑樹,1.8才加,所以源碼因此也膨脹了一倍(裏面自己實現了一個treemap),當hashmap產生了鏈表形態。說明產生了hash碰撞,這個本身就是一件不好的現象,那爲什麼不提前轉紅黑呢?雖然紅黑樹查找效率相比鏈表提升到了O(logn),但是建造紅黑和插入元素後維持紅黑的形態本身就太麻煩了,TreeNodes佔用空間普通Nodes的兩倍,所以只有當bin包含足夠多的節點時纔會轉成TreeNodes

所以得出結論紅黑樹本身就是雙刃劍,雖然查找效率高,但是建造和維護浪費的性能也很大。

同時源碼提到,hashcode受隨機分佈的影響,所以存在數組的下標也是收概率分佈影響,(泊松分佈),如果一個好的hash算法,是會將隨機性,降到很低,所以形成一個長鏈表本身也是一個概率極低的事件。

既然概率極低,一旦發生了說明此事件的嚴重性,甚至說這是人爲攻擊,後續碰撞的概率會很大,那就必須要運用紅黑樹來進行優化了,不然可能後續會造成更嚴重的後果。

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時概率不及百萬分之一,對此既然產生了這種情況,機器就會去判斷這次事件比較嚴重,需要紅黑樹優化

 static final int UNTREEIFY_THRESHOLD = 6;

在這裏插入圖片描述

當resize時鏈表結點後續小於6個時,又會回成鏈表

在這裏插入圖片描述

但是remove時紅黑樹結點必須要將近刪完,纔會將其轉化爲鏈表。

這也是爲了防止轉化紅黑樹時,資源過度浪費。

所以本身用到紅黑樹的情況幾乎很少,大概率是受到了黑客攻擊。

八:爲何HashMap線程不安全

1.7版本的不安全不想說了,說白了就是擴容的時候轉移鏈表造成了鏈表指針的循環死鎖,數據順序改變。

我們現在用的是1.8版本,其高低位指針本身就優化了這個,但任然還是不安全的,是因爲put操作中的代碼:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            // 當這裏假設有兩個線程A,B,他們各有一個hash值不相同,
            // 但是卻進行與操作之後到達了同一個數組下標,
            // 此時線程A阻塞,讓線程B執行,線程B將值傳入後,
            // 線程B又阻塞,線程A也在這個數組下標存值,
            // 最後造成數據覆蓋,不安全
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章