HashMap知識(源碼)

hashMap普通功能使用並不複雜,如:(Kotlin寫法)

var hMap: HashMap<Int?, String> = hashMapOf()
hMap.put(1, "a")
//或 hMap[1] = "a"

注意:我們在使用 HashMap 時,最好選擇不可變對象作爲 key。如:String、Integer 等不可變類型作爲 key。不要使用可變對象作爲key
參考

HashMap存儲數據是非有序的,且是非線程安全的。如果需要線程安全,去看下ConcurrentHashMap

HashMap的底層是 數組+鏈表+紅黑樹(JDK1.8 增加了紅黑樹部分)。HashMap增刪改查等常規操作,都有不錯的執行效率,是ArrayList和LinkedList等數據結構的一種折中實現。創建一個HashMap,如果沒有指定初始大小,默認底層hash表數組的大小爲16。

其實,HashMap的底層hash表數組的大小,都是2的冪。這是因爲,要進行hash運行,得到hash值時,有位運算(位運算的效率高)

HashMap的核心元素有:
1、size:用於記錄 HashMap 實際存儲元素的個數;
2、loadFactor:負載因子。默認 0.75;
3、threshold:擴容的閾值,達到閾值便會觸發擴容機制 resize;
4、Node<K,V>[] table; 底層數組,充當哈希表的作用,用於存儲對應 hash
位置的元素 Node<K,V>,此數組長度總是 2 的 N 次冪
5、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;
	}
	......
	忽略其他
	......
}

基本理論概念,就先說到這裏了。下面,開始一些問題、源碼的說明。

1、在創建HashMap的時候,有構造函數,可以自由設置容量(及擴展因子)。上面說了,底層數組(充當hash表)的長度,一定是2的N次冪。但是我們隨便設置容量的時候,是沒有報錯的,爲什麼會這樣。我們隨便傳進去的容量值,hashMap做了什麼處理麼?

示例代碼:

var hMap: HashMap<Int?, String> = HashMap(12, 0.5f)
hMap.put(1, "a")

看下源碼:

    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);
    }

傳進去的容量值,最後通過方法tableSizeFor,變成了閾值 threshold

/**
* 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 方法複製出來,跑一些測試數據:

Log.e("tableSizeFor = 1 =", "${MyUtils.tableSizeFor(1)}")
Log.e("tableSizeFor = 3 =", "${MyUtils.tableSizeFor(2)}")
Log.e("tableSizeFor = 5 =", "${MyUtils.tableSizeFor(3)}")
Log.e("tableSizeFor = 12 =", "${MyUtils.tableSizeFor(12)}")
Log.e("tableSizeFor = 13 =", "${MyUtils.tableSizeFor(13)}")
Log.e("tableSizeFor = 16 =", "${MyUtils.tableSizeFor(16)}")
Log.e("tableSizeFor = 17 =", "${MyUtils.tableSizeFor(17)}")

tableSizeFor = 1 =: 1
tableSizeFor = 3 =: 2
tableSizeFor = 5 =: 4
tableSizeFor = 12 =: 16
tableSizeFor = 13 =: 16
tableSizeFor = 16 =: 16
tableSizeFor = 17 =: 32

結論:創建HashMap時,隨便傳進去的容量值,HashMap會進行對應的轉換,即便不是2的N次冪,也會變成2的N次冪對應的值

至此,初始化

var hMap: HashMap<Int?, String> = HashMap(12, 0.5f)

這句話,我們看完了。
接下來,看第二句:

hMap.put(1, "a")

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) // 1
		n = (tab = resize()).length;
	......
	這裏是講解初始化方法,其他代碼先忽略,後面講增刪改查,會詳細說明
	......

}

因爲最開始初始化的時候,沒有創建數組,所以,1 那裏的判斷條件,是true,會走 resize()

resize方法源碼: 現在是初始化的情況。第一次走到這個方法裏

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //初始化,table是null,所以,oldTab=null
        int oldCap = (oldTab == null) ? 0 : oldTab.length; // oldCap=0
        int oldThr = threshold; //傳進來12,經過處理,已經變成了16
        int newCap, newThr = 0;
        if (oldCap > 0) { // 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) // oldThr=16,會走到這裏
            newCap = oldThr; // newCap = 16
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) { //上面沒對newThr處理,所有,它是0
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr; //經過上面的計算,newThr = 容量值*擴展因子
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
       ......
     	忽略
       ......
        return newTab;
    }

上面的代碼中,我加了註釋。

結論就是:
1、如果用 var hMap: HashMap<Int?, String> = hashMapOf() 創建,容量是默認的16,擴展因子是默認的 0.75,擴展閾值是 16 * 0.75 = 12;
2、如果用 var hMap: HashMap<Int?, String> = HashMap(12, 0.5f) 創建,容量是16(傳進來的容量,會被轉化爲2的N次冪),擴展因子是 0.5f,擴展閾值,是 16 * 0.5 = 8

===============================
這裏詳細說下增(put)、查方法(get)。刪除(remove)、替換(replace)方法都並不複雜,其中,remove方法中,注意下鏈表的節點變換即可。至於替換,我個人用的不多,因爲put方法中有覆蓋值的功能,我更多的,是用put(沒有就存,有就覆蓋)

增:put
源碼:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

--------------------

    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)  // ==1
            n = (tab = resize()).length;
            
        if ((p = tab[i = (n - 1) & hash]) == null)  // ==2
            tab[i] = newNode(hash, key, value, null);
        else {
        	 // ==3
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))  // ==4
                e = p;
            else if (p instanceof TreeNode)  // ==5
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
            	 // ==6
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {  // ==7
                        p.next = newNode(hash, key, value, null);
                        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))))  // ==8
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key   // ==9
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)  // ==10
            resize();
        afterNodeInsertion(evict);
        return null;
    }

注意源碼中添加是註釋 1-10。下面,會對應做說明
源碼說明:
1、判斷底層數組(hash表)是否爲空,如果爲空,就走 resize方法,裏面會創建;
2、根據插入的鍵值 key 的 hash 值,通過 (n-1) & hash (hash表長度-1 & 當前元素的hash值),計算出存儲位置 table[i](一個節點)。如果這個存儲位置沒有元素存放,則將新增節點存儲在此位置 table[i]
3、走到這裏,說明key算出來的hash位置上,已經有值了。
4、要存儲的位置有值,且這個值的key及key對應的hash值,和當前傳進來的操作元素一致,就保存下來,做保存操作;
5、當前存儲位置即有元素,有不和當前操作元素一致,則證明此位置 table[i] 已經發生了hash衝突。判斷頭節點是否是 treeNode,如果是,則證明此位置的結構是紅黑樹,以紅黑樹的方式新增節點。
6、當前存儲位置即有元素,有不和當前操作元素一致,則證明此位置 table[i] 已經發生了hash衝突。且,當前位置的結構,不是紅黑樹,是一個普通的單鏈表。
7、鏈表中不存在操作元素,將新元素結點放置此鏈表的最後一位, 然後判斷鏈接個數,滿足一定條件,就去進行“樹”相關操作
8、如果鏈表中已經存在對應的 key,則覆蓋 value
9、 已存在對應 key,如果允許修改,則修改 value 爲新值
10、當前HashMap中,個數是否大於等於閾值,如果滿足條件,就擴容。

注意:
put的源碼中 ,有這麼一句

if (binCount >= TREEIFY_THRESHOLD - 1) 
	treeifyBin(tab, hash);

翻譯過來就是:鏈表中的節點個數大於等於8,就走“樹”的方法。
再去 treeifyBin 看下

MIN_TREEIFY_CAPACITY = 64;

final void treeifyBin(Node<K,V>[] tab, int hash) {
	int n, index; Node<K,V> e;
	if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
		resize();
	else if (...)

	......
	......

總結下:這個“樹”相關的方法中,先進行判斷,如果滿足一定條件(數組長度小於一定範圍),優先進行擴容。

也就是說,擴容有2個情況會觸發:
1、HashMap中,元素個數大於閾值;
2、鏈表中元素個數大於等於8,且底層數組長度小於64

這就奇怪了,鏈表的時間複雜度是O(n),紅黑樹的時間複雜度O(logn),很顯然,紅黑樹的複雜度是優於鏈表的。爲什麼,在調用“樹”相關方法的時候,還是要優先去擴容呢?爲什麼不直接用樹去替代鏈表呢?

繼續翻看源碼。在一段註釋說明中找到了如下信息:

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

翻譯:
因爲樹形節點大約是常規節點的兩倍大,所以我們
只有當箱子包含足夠的節點時才使用它們
(見TREEIFY_THRESHOLD)。當它們變得太小(由於
移除或調整大小)它們被轉換回普通的箱子。在
使用分佈良好的用戶哈希碼,樹箱是
很少使用。理想情況下,在隨機哈希碼下,的頻率
bin中的節點遵循泊松分佈
(http://en.wikipedia.org/wiki/Poisson_distribution)
參數大約0.5的平均默認大小
閾值爲0.75,儘管由於
調整粒度。忽略方差,期望
列表大小k的出現次數爲(exp(-0.5) * pow(0.5, k) /
factorial (k))。第一個值是:

**忽略部分數字
8:    0.00000006 (千萬分之6)
更多:不到千萬分之一

源碼中說的很清楚了,樹,需要的節點更多,佔的空間較大,需要有足夠的空間下,纔去使用。典型的空間換性能。在性能差異不大的情況下,當然是使用空間越少越好了。

鏈表中元素到達8個的情況非常少,如果真到了那個時候,說明表已經“不堪重負”了,性能上會有影響,在那種特殊情況下,不得已,用空間換取性能,即:把鏈表換成紅黑樹


查:get

hMap.get(1)

對應源碼

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

--------------------

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

查的代碼很好理解了:
1、先調用 hash(key)方法計算出 key 的 hash 值
2、判斷hash數組是否是空,或者存儲位置是否有值,如果爲空或者沒有值,就返回null
3、不爲空,且有值的時候,就去判斷存儲位置鏈表的頭結點,如果頭結點是符合條件的,就返回頭結點。
4、如果頭結點不是符合要求的,就進行後續的判斷:
4.1:是否是紅黑樹,如果是,就走紅黑樹邏輯;
4.2:不是紅黑樹,就是單鏈表形式,注意,有個 do…while,就是去“遍歷”。如果找到,就返回,如果沒有找到,就返回 null

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