集合類——HashMap詳解

一、Map類

1. Map類族概述

在這裏插入圖片描述

  • 實現類:Hashtable,HashMap,LinkedHashMap,TreeMap。在Hashtable子類中,還有Properties類的實現。

  • Hashtable和HashMap區別
    Hashtable的大部分方法做了同步,而HashMap沒有,因此HashMap不是線程安全的
    ②Hashtable不允許key或者value使用null值,而HashMap允許null鍵/值、非同步、不保證有序(比如插入的順序)、也不保證序不隨時間變化
    ③在內部算法上,他們對key的hash算法和hash值到內存索引的映射算法不同。

2. HashMap

2.1 概述

在獲取HashMap的元素時,基本分兩步:

  1. 首先根據key的hashCode()做hash,然後確定bucket的index;
  2. 如果bucket的節點的key不是我們需要的,則通過keys.equals()在鏈中找。

   在Java 8之前的實現中是用鏈表解決衝突的,在產生碰撞的情況下,進行get時,兩步的時間複雜度是O(1)+O(n)。因此,當碰撞很厲害的時候n很大,O(n)的速度顯然是影響速度的。

   因此在Java 8中,利用紅黑樹替換鏈表,這樣複雜度就變成了O(1)+O(logn)了,這樣在n很大的時候,能夠比較理想的解決這個問題。

 HashMap的性能在一定程度上取決於hashCode( )的實現,一個好的hashCode( )算法,可以儘可能減少衝突,從而提高HashMap的訪問速度。

2.2 兩個重要的參數

  當我們通過HashMap(int initialCapacity)設置初始容量的時候,HashMap並不一定會直接採用我們傳入的數值,而是經過計算,得到一個新值,目的是提高hash的效率。(1->1、3->4、7->8、9->16)。

/**
 *Returns a power of two size for the given target capacity
 */
 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;
 }   

容量(Capacity)和負載因子(Load factor)

  簡單的說,Capacity就是buckets的數目Load factor就是buckets填滿程度的最大比例。如果對迭代性能要求很高的話不要把capacity設置過大,也不要把load factor設置過小。當bucket填充的數目(即hashmap中元素的個數)大於capacity*load factor時就需要調整buckets的數目爲當前的2倍

負載因子 = 元素個數 / 內部數組總大小

loadFactor = threshold / capacity

2.3 put函數的實現

put函數大致的思路爲

  1. 對key的hashCode()做hash,然後再計算index;
  2. 如果沒碰撞直接放到bucket裏;
  3. 如果碰撞了,以鏈表的形式存在buckets後;
  4. 如果碰撞導致鏈表過長(大於等於TREEIFY_THRESHOLD),就把鏈表轉換成紅黑樹
  5. 如果節點已經存在就替換old value(保證key的唯一性)
  6. 如果bucket滿了(超過load factor*current capacity),就要resize。

具體代碼的實現如下:

public V put(K key, V value) {
    // 對key的hashCode()做hash
    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;
    // tab爲空則創建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 計算index,並對null做處理
    if ((p = tab[i = (n - 1) & hash]) == null)
        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;
        // 該鏈爲樹
        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);
                    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))))
                    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;
    // 超過load factor*current capacity,resize
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

2.4 get函數的實現

在理解了put之後,get就很簡單了。大致思路如下:

  1. bucket裏的第一個節點,直接命中;
  2. 如果有衝突,則通過key.equals(k)去查找對應的entry
    • 若爲樹,則在樹中通過key.equals(k)查找,O(logn);
    • 若爲鏈表,則在鏈表中通過key.equals(k)查找,O(n)。

具體代碼的實現如下:

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) {
            // 在樹中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 在鏈表中get
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
   

2.5 hash函數的實現

在get和put的過程中,計算下標時,先對hashCode進行hash操作,然後再通過hash值進一步計算下標,如下圖所示:
在這裏插入圖片描述

在對hashCode()計算hash時具體實現是這樣的:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

可以看到這個函數大概的作用就是:高16bit不變,低16bit和高16bit做了一個異或。其中代碼註釋是這樣寫的:

Computes key.hashCode() and spreads (XORs) higher bits of hash to lower. Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide. (Among known examples are sets of Float keys holding consecutive whole numbers in small tables.) So we apply a transform that spreads the impact of higher bits downward. There is a tradeoff between speed, utility, and quality of bit-spreading. Because many common sets of hashes are already reasonably distributed (so don’t benefit from spreading), and because we use trees to handle large sets of collisions in bins, we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage, as well as to incorporate impact of the highest bits that would otherwise never be used in index calculations because of table bounds.

在設計hash函數時,因爲目前的table長度n爲2的冪,而計算下標的時候,是這樣實現的(使用&位操作,而非%求餘):

	
(n - 1) & hash

設計者認爲這方法很容易發生碰撞。爲什麼這麼說呢?不妨思考一下,在n - 1爲15(0x1111)時,其實散列真正生效的只是低4bit的有效位,當然容易碰撞了。

因此,設計者想了一個顧全大局的方法(綜合考慮了速度、作用、質量),就是把高16bit和低16bit異或了一下。設計者還解釋到因爲現在大多數的hashCode的分佈已經很不錯了,就算是發生了碰撞也用O(logn)的tree去做了。僅僅異或一下,既減少了系統的開銷,也不會造成的因爲高位沒有參與下標的計算(table長度比較小時),從而引起的碰撞。

如果還是產生了頻繁的碰撞,會發生什麼問題呢?作者註釋說,他們使用樹來處理頻繁的碰撞(we use trees to handle large sets of collisions in bins),在JEP-180中,描述了這個問題:

    Improve the performance of java.util.HashMap under high hash-collision conditions by using balanced trees rather than linked lists to store map entries. Implement the same improvement in the LinkedHashMap class.

2.6 RESIZE的實現

   當put時,如果發現目前的bucket佔用程度已經超過了Load Factor所希望的比例,那麼就會發生resize。在resize的過程,簡單的說就是把bucket擴充爲2倍,之後重新計算index,把節點再放到新的bucket中。resize的註釋是這樣描述的:

   Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.

即:當超過限制的時候會resize,然而又因爲我們使用的是2次冪的擴展(指長度擴爲原來2倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置

例如我們從16擴展爲32時,具體的變化如下所示:
在這裏插入圖片描述

因此元素在重新計算hash之後,因爲n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
resize
因此,我們在擴充HashMap的時候,不需要重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。可以看看下圖爲16擴充爲32的resize示意圖:
在這裏插入圖片描述
這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認爲是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了

下面是代碼的具體實現:

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;
        }
        // 沒超過最大值,就擴充爲原來的2倍
        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);
    }
    // 計算新的resize上限
    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) {
        // 把每個bucket都移動到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                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;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket裏
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket裏
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

2.7 總結

2.7.1 HashMap特點

  是基於Map接口的實現,存儲鍵值對時,它可以接收null的鍵值,是非同步的,HashMap存儲着Entry(hash, key, value, next)對象。

2.7.2 HashMap的工作原理

  通過hash的方法,通過put和get存儲和獲取對象。存儲對象時,我們將K/V傳給put方法時,它調用hashCode計算hash從而得到bucket位置,進一步存儲,HashMap會根據當前bucket的佔用情況自動調整容量(超過Load Facotr則resize爲原來的2倍)。獲取對象時,我們將K傳給get,它調用hashCode計算hash從而得到bucket位置,並進一步調用equals()方法確定鍵值對。如果發生碰撞的時候,Hashmap通過鏈表將產生碰撞衝突的元素組織起來,在Java 8中,如果一個bucket中碰撞衝突的元素超過某個限制(默認是8),則使用紅黑樹來替換鏈表,從而提高速度

2.7.3 get和put的原理,equals()和hashCode()的作用

  通過對key的hashCode()進行hashing,並計算下標( n-1 & hash),從而獲得buckets的位置。如果產生碰撞,則利用key.equals()方法去鏈表或樹中去查找對應的節點

2.7.4 hash的實現,爲什麼要這樣實現?

  在Java 1.8的實現中,是通過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,這麼做可以在bucket的n比較小的時候,也能保證考慮到高低bit都參與到hash的計算中,同時不會有太大的開銷。

2.7.5 HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?

  如果超過了負載因子(默認0.75),則會重新resize一個原來長度兩倍的HashMap,並且重新調用hash方法。

2.7.6 HashMap的默認長度爲什麼是16?

  如果兩個元素不相同,但是hash函數的值相同,這兩個元素就是一個碰撞。

  因爲把任意長度的字符串變成固定長度的字符串,所以存在一個hash對應多個字符串的情況,所以碰撞必然存在。

  爲了減少hash值的碰撞,需要實現一個儘量均勻分佈的hash函數,在HashMap中通過利用key的hashcode值,來進行位運算。

公式:index = e.hash & (newCap - 1)

舉個例子:

1)當HashMap的默認長度是16

1.計算"book"的hashcode
十進制 : 3029737
二進制 : 101110001110101110 1001

2.HashMap長度是默認的16,length - 1的結果
十進制 : 15
二進制 : 1111

3.把以上兩個結果做與運算
101110001110101110 1001 & 1111 = 1001
1001的十進制 : 9,所以 index=9

結論:hash算法最終得到的index結果,取決於hashcode值的最後幾位

2)當HashMap的默認長度是10

重複剛纔的運算步驟:
hashcode : 101110001110101110 1001
length - 1 : 1001
index : 1001

再換一個hashcode 101110001110101110 1111 試試:
hashcode : 101110001110101110 1111
length - 1 : 1001
index : 1001

結論: 雖然hashcode變化了,但是運算的結果都是1001,也就是說,當HashMap長度爲10的時候,有些index結果的出現機率會更大,而有些index結果永遠不會出現(比如0111),這樣就不符合hash均勻分佈的原則。

綜上:當長度16或者其他2的冪,length - 1的值是所有二進制位全爲1`,這種情況下,index的結果等同於hashcode後幾位的值,只要輸入的hashcode本身分佈均勻,hash算法的結果就是均勻的。

所以,HashMap的默認長度爲16,是爲了降低hash碰撞的機率。

2.7.7 爲何HashTable不允許鍵或值爲null,而HashMap是允許爲null?

  ConcurrentHashmap和Hashtable都是支持併發的,這樣會有一個問題,當你通過get(k)獲取對應的value時,如果獲取到的是null時,你無法判斷,它是put(k,v)的時候value爲null,還是這個key從來沒有做過映射。HashMap是非併發的,可以通過contains(key)來做這個判斷。而支持併發的Map在調用m.contains(key)和m.get(key),m可能已經不同了。

二、Collection類:包括 List 和 Set

Collection
├List
│├LinkedList
│├ArrayList
│└Vector
│ └Stack
└Set

img

三、List、Map、Set默認初始容量和擴容增量及加載因子

Class 初始大小 加載因子 擴容倍數 底層實現 Code 是否線程安全 同步方式
ArrayList 10 1 1.5倍+1 Object數組 int newCapacity = oldCapacity + (oldCapacity >> 1)+1;
">>"右移符號,所以是除以2,所以新的容量是就的1.5倍+1
Arrays.copyOf 調用 System.arraycopy 擴充數組
線程不安全 -
Vector 10 1 2倍 Object數組 int newCapacity = oldCapacity + ((capacityIncrement > 0) ?capacityIncrement : oldCapacity);
capacityIncrement默認爲0,所以是加上本身,也就是2*oldCapacity,2倍大小
Arrays.copyOf 調用 System.arraycopy 擴充數組
線程安全 synchronized
HashSet 16 0.75f 2倍 HashMap<E,Object> add方法實際調用HashMap的方法put 線程不安全 -
HashMap 16 0.75f 2倍 Map.Entry void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}

createEntry(hash, key, value, bucketIndex);
}
線程不安全 -
Hashtable 11 0.75f 2倍+1 Hashtable.Entry數組 int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
return;
newCapacity = MAX_ARRAY_SIZE;
}
線程安全 synchronized
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章