讀Java 11 源碼(3)HashMap

一圖概覽

在這裏插入圖片描述

0 基本屬性

最基本的屬性

// 存儲數據的基本結構
transient Node<K,V>[] table;

transient Set<Map.Entry<K,V>> entrySet;

transient int size;

transient int modCount;

int threshold;

final float loadFactor;

table:這個屬性是就是所謂的HashMaps是“數組+鏈表”中的“數組”的由來。爲什麼要用transient來修飾,主要是因爲本質上他它還是個動態擴容的數組,大部分的時間都不會滿的,所以,如果每次直接序列化,浪費空間。
entrySet:這個參數,其實就是我們實現map遍歷操作的時候的直接調用的就這個參數,entrySet()這個方法返回的就是這個參數(有代碼有真相):

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

threshold :邊界值,這個主要是和負載因子聯繫在一起的,一般來說就是當前的數組的大小*負載因子 = 邊界值,主要是用來減少哈希碰撞。
loadFactor:負載因子,這個是可以用戶自己手動賦值的,但是一般來說不建議這麼做,除非你有非常好的哈希算法,不然一般不建議去修改這個負載因子。

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

size:不用說,但凡有是屬於集合的,都有這個,表示現在集合中存儲多少對象。

關鍵屬性

/**
 * 默認的初始化大小16 - 必須是2的冪次方
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * MUST be a power of two <= 1<<30.
 * 可以存儲最大的元素的個數 必須也是2的冪次方
 */
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;

DEFAULT_INITIAL_CAPACITY:數組的默認默認的大小,HashMap底層也是一個可以動態擴容的數組,如果你沒有指定初始化的大小的參數,那麼這個在你第一次put操作的是就會進行初始化,table的第一次初始化大小就是DEFAULT_INITIAL_CAPACITY(16)。
MAXIMUM_CAPACITY :最大能放多少元素
DEFAULT_LOAD_FACTOR :默認的負載因子,負載因子(load_factor)這個主要是爲了減少哈希碰撞而產生的。沒有意外不建議修改。
TREEIFY_THRESHOLD :轉化爲紅黑樹的閥值,默認爲8。
UNTREEIFY_THRESHOLD:當某個位置上的元素個數小於,等於6的時候,就會轉化爲鏈表。
MIN_TREEIFY_CAPACITY :容量最小64時纔會轉會成紅黑樹,就是如果整個map裏的元素小於64,就算有某個位置上的超過8,也不能轉換爲紅黑樹

1 爲什麼是數組+鏈表

數組是怎麼來的?
先來看個成員變量。

/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;

以上就是數組的由來,這就是hashmap的最基本的數據結構,它是由Node<K,V>組成的數組。
我們可以翻譯他的註釋:

該表在首次使用時初始化,如果必要的時候會調整大小。分配大小的時候,它的長度始終是2的冪次方。

鏈表是怎麼來的 ?
來看看Node<K,V>,這個類定義的代碼,其實熟悉鏈表的朋友肯定能看出來,這個就是鏈表的節點的基礎數據結構。

static class Node<K,V> implements Map.Entry<K,V> {
   	final int hash;
   	final K key;
    V value;
    Node<K,V> next; // ①

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

看到 的時候,不知道你有沒有感覺到這就是爲了鏈表而生的感覺?
是的,這個就是單向鏈表的基本節點的結構。
所以目前看來就是這樣的,數組的每個位置上放結點,就像這樣:
在這裏插入圖片描述
前面那一排就是數組了,然後每個節點後面可以接着和他們發生 “哈西碰撞” 的節點。
這裏要好好說說,因爲哈希表本質上是個數組,那麼我們怎麼知道每次put的那些數據要放在數組的哪個位置上?我們是通過哈希函數根據對象值來算出一個值,然後利用值來和數組的長度取餘數,是的,其實算出神奇的位置的步奏的最後一步就是取餘數。
但是很遺憾,沒有完美的 哈希函數可以完美的讓每個值都分配到不同的位置的值,如果有兩個不同的對象,但是卻被算出相同的位置,那麼我們就叫這種情況叫做 “哈希碰撞(哈希衝突)”
被分配到相同的位置要咋辦,兩個數組都要存儲啊,市面上最佳實踐就是 “鏈地址法”
分配到相同的位置的node,但是兩個nodekey互相不equals,就這樣一個接着一個,成了鏈表。

2 關鍵操作

插入、更新操作 :put

這個put的操作其實用的還是putVal 這個方法,我把代碼和我寫的註釋都寫下來了:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 判斷當天的table數組是否爲空,或者長度是否爲0,
    // 就是還沒初始化的意思,如果沒有初始化,就初始化,而且返回初始化後的數組的數量。
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 根據hashcode來判斷是否存在數組老的數組是否存在職,如果不存在就直接賦值即可。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 說明tab[i = (n - 1) & hash])已經存在值了
    else {
        Node<K,V> e; K k;
        // 如果在tab[i = (n - 1) & hash]) 的p 已經在存在的p
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
            e = p;
        }
        // 判斷p是否已經是紅黑樹的節點了,執行紅黑樹的插入值的操作
        else if (p instanceof TreeNode) {
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        }
        // 說明說出現hash碰撞
        else {
            // 這個過程就是在找位置的過程,以下幾種情況:
            // 1 如果說一直到鏈表的最後都沒找既和hashCode相等,又equals的那個值,
            // 那麼直接插入到鏈表的最後即可
            // 2 如果存在hashCode而且equals的值,那麼就賦值給e,然後退出
            for (int binCount = 0; ; ++binCount) {
                // p的next,也就是鏈表的下一個是null,說明到了鏈表最後都沒找到。
                // 那麼直接把吧新的值接到鏈表的最後就行了
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 接了鏈表之後,再判斷是否需要轉換爲紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 這就是那個hashCode相等,還equals的e的位置
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 找到了e,如果這個不是null,那麼就開始執行替換舊的value的操作
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent == false,或者原來的值爲null, 就執行改變值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // modCount 加上1 
    ++modCount;
    // 如果是新的值,說明需要擴容了
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

3 關鍵字參數詳解

爲什麼要在鏈表長度8的時候變成紅黑樹?

這個解釋在解釋中找到。設計8的這值的時候,作者們結合泊松分佈的函數。HashMap中的每個位置上的各種長度的命中概率。

* 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

可以看到達到鏈表達到8這個長度的,出現的概率不到千萬只一,如果說達到了,那麼很遺憾,就是你的對象的哈希算法(就是那個hashCode()方法)出了問題。
爲了讓這種極端情況,這個依然有效,就把它給轉換爲了紅黑樹,保證查詢效率爲O(LogN)O(LogN),就轉爲紅黑樹了

爲什麼複雜因子是0.75?

load factor(影響因子) 默認值是 0.75, 是均衡了時間和空間損耗算出來的值,較高的值會減少空間開銷(擴容減少,數組大小增長速度變慢),但增加了查找成本(hash 衝突增加,鏈表長度變長)。但是具體是怎麼計算的,這個,我就跟你掰扯掰扯吧。。。
原來的文檔的解釋這樣的。
https://docs.oracle.com/javase/6/docs/api/java/util/HashMap.html

An instance of HashMap has two parameters that affect its performance: initial capacity and load factor. The capacity is the number of buckets in the hash table, and the initial capacity is simply the capacity at the time the hash table is created. The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets.

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

翻譯過來就是:

HashMap的實例具有兩個影響其性能的參數:初始容量和負載因子。容量是哈希表中存儲桶的數量,初始容量只是創建哈希表時的容量。負載因子是在自動增加其哈希表容量之前允許哈希表獲得的滿度的度量。當哈希表中的條目數超過負載因子和當前容量的乘積時,哈希表將被重新哈希(即,內部數據結構將被重建),因此哈希表的存儲桶數大約爲兩倍。
通常,默認負載因子(.75)在時間和空間成本之間提供了一個很好的折衷方案。較高的值會減少空間開銷,但會增加查找成本(在HashMap類的大多數操作中都得到體現,包括get和put)。設置映射表的初始容量時,應考慮映射中的預期條目數及其負載因子,以最大程度地減少重新哈希操作的數量。如果初始容量大於最大條目數除以負載因子,則將不會進行任何哈希操作。

桶就表示hashmap數組每個位置上的鏈表的稱呼。的那是以上的這不足以解決爲什麼是0.75的問題。我們只知道結果有啥用,但是不知道結果是怎麼來的。
直到找到https://stackoverflow.com/questions/10901752/what-is-the-significance-of-load-factor-in-hashmap
在這裏插入圖片描述
大概就是非空桶的概率是大概是0.693,負載因子在0.693之上,和.075之間都有不錯的效果。這個0.75是個意想不到的,天選之子的答案吧。。

爲什麼得到索引的算法是(size-1)&hash?

我們知道,哈希表的最後就是把元素存在數組上,那麼就要知道存放的位置。所以應該是個取餘的操作。但是%對計算機來說計算量比較大!所以,我們需要一個更好的算法來取代取餘。結合下面的的爲什麼都要是2的冪次方?因爲只有2的冪次方,才能夠實現用&運算來代替取餘數。

System.out.println(10 % 8);
System.out.println(7 & 10);
System.out.println("--------------------");
System.out.println(11 % 8);
System.out.println(11 & 7);
System.out.println("--------------------");
System.out.println(13 % 8);
System.out.println(13 & 7);
System.out.println("--------------------");
System.out.println(15 % 8);
System.out.println(15 & 7);

System.out.println("+++++++++++++++++++++++");
System.out.println(10 % 32);
System.out.println(10 & 31);
System.out.println("--------------------");
System.out.println(11 % 32);
System.out.println(11 & 31);
System.out.println("--------------------");
System.out.println(13 % 32);
System.out.println(13 & 31);
System.out.println("--------------------");
System.out.println(15 % 32);
System.out.println(15 & 31);

會有以下的結果:

2
2
--------------------
3
3
--------------------
5
5
--------------------
7
7
+++++++++++++++++++++++
10
10
--------------------
11
11
--------------------
13
13
--------------------
15
15

所以只有在2的冪次方,然後用這種方法,就能用與運算得到和取餘數相同的結果了。

爲什麼map的大小都要是2的冪次方?

以上就說明,爲什麼都要是2的冪次方了,只有這樣的情況,才能夠完成快速運算出索引位置。

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