09、java集合類----Map集合

目錄

對比 Hashtable、HashMap、TreeMap 有什麼不同?

(1) 元素特性

(2)順序特性

(3)初始化與增長方式

(4)線程安全性

(4)一段話HashMap

1.Map 整體結構

2.HashMap 源碼分析

3. 容量、負載因子和樹化

我們前面提到了樹化改造,對應邏輯主要在 putVal 和 treeifyBin 中

那麼,爲什麼 HashMap 要樹化呢?

解決哈希衝突的常用方法有:


對比 Hashtable、HashMap、TreeMap 、LinkedHashMap有什麼不同?

(1) 元素特性

HashtableHashMapTreeMap 都是最常見的一些 Map 實現,是以鍵值對的形式存儲和操作數據的容器類型。

Hashtable 是早期 Java 類庫提供的一個哈希表實現,本身是同步的,不支持 null 鍵和值,由於同步導致的性能開銷,所以已經很少被推薦使用。

HashMap 是應用更加廣泛的哈希表實現,行爲上大致上與 HashTable 一致,主要區別在於 HashMap 不是同步的,支持 null 鍵和值等。通常情況下,HashMap 進行 put 或者 get 操作,可以達到常數時間的性能,所以它是絕大部分利用鍵值對存取場景的首選,比如,實現一個用戶 ID 和用戶信息對應的運行時存儲結構。

TreeMap TreeMap實現SortMap接口,是基於紅黑樹的一種提供順序訪問的 Map,能夠把它保存的記錄根據鍵排序,默認是按鍵值的升序排序(自然順序),也可以指定排序的比較器,當用Iterator遍歷TreeMap時,得到的記錄是排過序的。不允許key值爲空,非線程安全的;

TreeMap HashMap 不同,它的 get、put、remove 之類操作都是 O(log(n))的時間複雜度,具體順序可以由指定的 Comparator 來決定,或者根據鍵的自然順序來判斷。TreeMap中當未實現 Comparator 接口時,key 不可以爲null;當實現 Comparator 接口時,若未對null情況進行判斷,則key不可以爲null,反之亦然。

LinkedHashMap保存了記錄的插入順序,在用Iteraor遍歷LinkedHashMap時,先得到的記錄肯定是先插入的,在遍歷的時候會比HashMap慢,有HashMap的全部特性。

 

用法:

1、一般情況下,我們用的最多的是HashMap。HashMap裏面存入的鍵值對在取出的時候是隨機的,它根據鍵的HashCode值存儲數據,根據鍵可以直接獲取它的值,具有很快的訪問速度。在Map 中插入、刪除和定位元素,HashMap 是最好的選擇。

2、TreeMap取出來的是排序後的鍵值對。但如果您要按自然順序或自定義順序遍歷鍵,那麼TreeMap會更好。

3、LinkedHashMap是HashMap的一個子類,如果需要輸出的順序和輸入的相同,那麼用LinkedHashMap可以實現,它還可以按讀取順序來排列,像連接池中可以應用。

 

(2)順序特性

HashTableHashMap具有無序特性。

TreeMap是利用紅黑樹來實現的(樹中的每個節點的值,都會大於或等於它的左子樹種的所有節點的值,並且小於或等於它的右子樹中的所有節點的值),實現了SortMap接口,能夠對保存的記錄根據鍵進行排序。所以一般需要排序的情況下是選擇TreeMap來進行,默認爲升序排序方式(深度優先搜索),可自定義實現Comparator接口實現排序方式。
 

(3)初始化與增長方式

初始化時:

  • HashTable在不指定容量的情況下的默認容量爲11,且不要求底層數組的容量一定要爲2的整數次冪;
  • HashMap默認容量爲16,且要求容量一定爲2的整數次冪。


擴容時:

  • Hashtable將容量變爲原來的2倍加1;
  • HashMap擴容將容量變爲原來的2倍。

 

(4)線程安全性

HashTable其方法函數都是同步的(採用synchronized修飾),由於同步導致的性能開銷,所以已經很少被推薦使用。
HashMap不支持線程的同步,即任一時刻可以有多個線程同時寫HashMap;可能會導致數據的不一致。如果需要同步

(1)可以用 Collections的synchronizedMap方法

(2)使用ConcurrentHashMap類,相較於HashTable鎖住的是對象整體, ConcurrentHashMap基於lock實現鎖分段技術。首先將Map存放的數據分成一段一段的存儲方式,然後給每一段數據分配一把鎖,當一個線程佔用鎖訪問其中一個段的數據時,其他段的數據也能被其他線程訪問。ConcurrentHashMap不僅保證了多線程運行環境下的數據訪問安全性,而且性能上有長足的提升。

 

(4)一段話HashMap

HashMap基於哈希思想,實現對數據的讀寫。當我們將鍵值對傳遞給put()方法時,它調用鍵對象的hashCode()方法來計算hashcode,然後找到bucket(桶)位置來儲存值對象。當獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然後返回值對象。

HashMap使用鏈表來解決碰撞問題,當發生碰撞了,對象將會儲存在鏈表的下一個節點中。 HashMap在每個鏈表節點中儲存鍵值對對象。當兩個不同的鍵對象的hashcode相同時,它們會儲存在同一個bucket位置的鏈表中,可通過鍵對象的equals()方法用來找到鍵值對。

如果鏈表大小超過閾值(TREEIFY_THRESHOLD, 8),鏈表就會被改造爲樹形結構。

 

1.Map 整體結構

首先,我們先對 Map 相關類型有個整體瞭解,Map 雖然通常被包括在 Java 集合框架裏,但是其本身並不是狹義上的集合類型(Collection),具體你可以參考下面這個簡單類圖。

 

Hashtable 比較特別,作爲類似 Vector、Stack 的早期集合相關類型,它是擴展了 Dictionary 類的,類結構上與 HashMap 之類明顯不同。

HashMap 等其他 Map 實現則是都擴展了 AbstractMap,裏面包含了通用方法抽象。不同 Map 的用途,從類圖結構就能體現出來,設計目的已經體現在不同接口上。

大部分使用 Map 的場景,通常就是放入、訪問或者刪除,而對順序沒有特別要求,HashMap 在這種情況下基本是最好的選擇。HashMap 的性能表現非常依賴於哈希碼的有效性,請務必掌握 hashCode 和 equals 的一些基本約定,比如:

  • equals 相等,hashCode 一定要相等。
  • 重寫了 hashCode 也要重寫 equals。
  • hashCode 需要保持一致性,狀態改變返回的哈希值仍然要一致。
  • equals 的自反、對稱、傳遞、一致等特性。

針對有序 Map 的分析內容比較有限,我再補充一些,雖然 LinkedHashMap 和 TreeMap 都可以保證某種順序,但二者還是非常不同的。

  • LinkedHashMap 通常提供的是遍歷順序符合插入順序,它的實現是通過爲條目(鍵值對)維護一個雙向鏈表。注意,通過特定構造函數,我們可以創建反映訪問順序的實例,所謂的 put、get、compute 等,都算作“訪問”。

這種行爲適用於一些特定應用場景,例如,我們構建一個空間佔用敏感的資源池,希望可以自動將最不常被訪問的對象釋放掉,這就可以利用 LinkedHashMap 提供的機制來實現,參考下面的示例:

import java.util.LinkedHashMap;
import java.util.Map;  
public class LinkedHashMapSample {
    public static void main(String[] args) {
        LinkedHashMap<String, String> accessOrderedMap = new LinkedHashMap<>(16, 0.75F, true){
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, String> eldest) { // 實現自定義刪除策略,否則行爲就和普遍 Map 沒有區別
                return size() > 3;
            }
        };
        accessOrderedMap.put("Project1", "Valhalla");
        accessOrderedMap.put("Project2", "Panama");
        accessOrderedMap.put("Project3", "Loom");
        accessOrderedMap.forEach( (k,v) -> {
            System.out.println(k +":" + v);
        });
        // 模擬訪問
        accessOrderedMap.get("Project2");
        accessOrderedMap.get("Project2");
        accessOrderedMap.get("Project3");
        System.out.println("Iterate over should be not affected:");
        accessOrderedMap.forEach( (k,v) -> {
            System.out.println(k +":" + v);
        });
        // 觸發刪除
        accessOrderedMap.put("Project4", "Mission Control");
        System.out.println("Oldest entry should be removed:");
        accessOrderedMap.forEach( (k,v) -> {// 遍歷順序不變
            System.out.println(k +":" + v);
        });
    }
}
  • 對於 TreeMap,它的整體順序是由鍵的順序關係決定的,通過 Comparator 或 Comparable(自然順序)來決定。

 

我們可以分析 TreeMap 的 put 方法實現:

public V put(K key, V value) {
    Entry<K,V> t = …
    cmp = k.compareTo(t.key);
    if (cmp < 0)
        t = t.left;
    else if (cmp > 0)
        t = t.right;
    else
        return t.setValue(value);
        // ...
   }

從代碼裏,你可以看出什麼呢? 當我不遵守約定時,兩個不符合唯一性(equals)要求的對象被當作是同一個(因爲,compareTo 返回 0),這會導致歧義的行爲表現。

 

2.HashMap 源碼分析

前面提到,HashMap 設計與實現是個非常高頻的面試題,所以我會在這進行相對詳細的源碼解讀,主要圍繞:

  • HashMap 內部實現基本點分析。
  • 容量(capacity)和負載係數(load factor)。
  • 樹化 。

首先,我們來一起看看 HashMap 內部的結構,它可以看作是數組(Node<K,V>[] table)和鏈表結合組成的複合結構,數組被分爲一個個桶(bucket),通過哈希值決定了鍵值對在這個數組的尋址;哈希值相同的鍵值對,則以鏈表形式存儲,你可以參考下面的示意圖。這裏需要注意的是,如果鏈表大小超過閾值(TREEIFY_THRESHOLD, 8),圖中的鏈表就會被改造爲樹形結構。

從非拷貝構造函數的實現來看,這個表格(數組)似乎並沒有在最初就初始化好,僅僅設置了一些初始值而已。

// 默認的初始容量(容量爲HashMap中槽的數目)是16,且實際容量必須是2的整數次冪。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 最大容量(必須是2的冪且小於2的30次方,傳入容量過大將被這個值替換)
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;

// 指定“容量大小”和“加載因子”的構造函數 
public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        
        // HashMap的最大容量只能是MAXIMUM_CAPACITY
        // 最大容量(必須是2的冪且小於2的30次方,傳入容量過大將被這個值替換)  
        // static final int MAXIMUM_CAPACITY = 1 << 30;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        

        // 加載因此不能小於0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        
        // 設置“加載因子” 
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

所以,我們深刻懷疑,HashMap 也許是按照 lazy-load 原則,在首次使用時被初始化(拷貝構造函數除外,我這裏僅介紹最通用的場景)。既然如此,我們去看看 put 方法實現,似乎只有一個 putVal 的調用:

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

看來主要的密碼似乎藏在 putVal 裏面,到底有什麼祕密呢?爲了節省空間,我這裏只截取了 putVal 比較關鍵的幾部分。

final V putVal(int hash, K key, V value, boolean onlyIfAbent,
               boolean evit) {
    Node<K,V>[] tab; Node<K,V> p; int , i;
    if ((tab = table) == null || (n = tab.length) = 0)
        n = (tab = resize()).legth;
    if ((p = tab[i = (n - 1) & hash]) == ull)
        tab[i] = newNode(hash, key, value, nll);
    else {
        // ...
        
        // 樹化改造
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first 
           treeifyBin(tab, hash);
        //  ... 
     }
     ++modCount;

     // 在放置新的鍵值對的過程中,如果發生下面條件,就會發生擴容
     if (++size > threshold)
         resize();
     afterNodeInsertion(evict);
     return null;
}

從 putVal 方法最初的幾行,我們就可以發現幾個有意思的地方:

  • 如果表格是 null,resize 方法會負責初始化它,這從 tab = resize() 可以看出。
  • resize 方法兼顧兩個職責,創建初始存儲表格或者在容量不滿足需求的時候,進行擴容(resize)
  • 在放置新的鍵值對的過程中,如果發生下面條件,就會發生擴容。
if (++size > threshold)
    resize();
  • 具體鍵值對在哈希表中的位置(數組 index)取決於下面的位運算:
i = (n - 1) & hash

仔細觀察哈希值的源頭,我們會發現,它並不是 key 本身的 hashCode,而是來自於 HashMap 內部的另外一個 hash 方法。注意,爲什麼這裏需要將高位數據移位到低位進行異或運算呢?這是因爲有些數據計算出的哈希值差異主要在高位,而 HashMap 裏的哈希尋址是忽略容量以上的高位的,那麼這種處理就可以有效避免類似情況下的哈希碰撞。

static final int hash(Object kye) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16;
}
  • 我前面提到的鏈表結構(這裏叫 bin),會在達到一定門限值時,發生樹化,我稍後會分析爲什麼 HashMap 需要對 bin 進行處理。

可以看到,putVal 方法本身邏輯非常集中,從初始化擴容樹化,全部都和它有關,推薦你閱讀源碼的時候,可以參考上面的主要邏輯。

我進一步分析一下身兼多職的 resize 方法,很多朋友都反饋經常被面試官追問它的源碼設計。

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);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        
        // 移動到新的數組結構 e 數組結構
        ......
        return newTab;
    }

依據 resize 源碼,不考慮極端情況(容量理論最大極限由 MAXIMUM_CAPACITY 指定,數值爲 1<<30,也就是 2 的 30 次方),我們可以歸納爲:

  • 門限值等於(負載因子)x(容量),如果構建 HashMap 的時候沒有指定它們,那麼就是依據相應的默認常量值。
  • 門限通常是以倍數進行調整 (newThr = oldThr << 1),我前面提到,根據 putVal 中的邏輯,當元素個數超過門限大小時,則調整 Map 大小。
  • 擴容後,需要將老的數組中的元素重新放置到新的數組,這是擴容的一個主要開銷來源。

 

3. 容量、負載因子和樹化

前面我們快速梳理了一下 HashMap 從創建到放入鍵值對的相關邏輯,現在思考一下,爲什麼我們需要在乎容量和負載因子呢?

這是因爲容量和負載係數決定了可用的桶的數量,空桶太多會浪費空間,如果使用的太滿則會嚴重影響操作的性能。極端情況下,假設只有一個桶,那麼它就退化成了鏈表,完全不能提供所謂常數時間存的性能。

既然容量和負載因子這麼重要,我們在實踐中應該如何選擇呢?

如果能夠知道 HashMap 要存取的鍵值對數量,可以考慮預先設置合適的容量大小。具體數值我們可以根據擴容發生的條件來做簡單預估,根據前面的代碼分析,我們知道它需要符合計算條件:

 負載因子 * 容量 > 元素數量

所以,預先設置的容量需要滿足,大於“預估元素數量 / 負載因子”,同時它是 2 的冪數,結論已經非常清晰了。

而對於負載因子,我建議:

  • 如果沒有特別需求,不要輕易進行更改,因爲 JDK 自身的默認負載因子是非常符合通用場景的需求的。
  • 如果確實需要調整,建議不要設置超過 0.75 的數值,因爲會顯著增加衝突,降低 HashMap 的性能。
  • 如果使用太小的負載因子,按照上面的公式,預設容量值也進行調整,否則可能會導致更加頻繁的擴容,增加無謂的開銷,本身訪問性能也會受影響。

 

我們前面提到了樹化改造,對應邏輯主要在 putVal 和 treeifyBin 中

final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 如果容量小於 MIN_TREEIFY_CAPACITY,只會進行簡單的擴容
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        
        // 如果容量小於 MIN_TREEIFY_CAPACITY,進行樹化改造邏輯
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

上面是 treeifyBin 示意,綜合這兩個方法,樹化改造的邏輯就非常清晰了,可以理解爲,當 bin 的數量大於 TREEIFY_THRESHOLD 時:

  • 如果容量小於 MIN_TREEIFY_CAPACITY,只會進行簡單的擴容。如果容量已經達到了最大值(MAXIMUM_CAPACITY= 1<<30),則不能再擴容,直接返回。
  • 如果容量大於 MIN_TREEIFY_CAPACITY ,則會進行樹化改造。

 

那麼,爲什麼 HashMap 要樹化呢?

本質上這是個安全問題。因爲在元素放置過程中,如果一個對象哈希衝突,都被放置到同一個桶裏,則會形成一個鏈表,我們知道鏈表查詢是線性的,會嚴重影響存取的性能。

而在現實世界,構造哈希衝突的數據並不是非常複雜的事情,惡意代碼就可以利用這些數據大量與服務器端交互,導致服務器端 CPU 大量佔用,這就構成了哈希碰撞拒絕服務攻擊,國內一線互聯網公司就發生過類似攻擊事件。

 

解決哈希衝突的常用方法有:

開放定址法

基本思想是:當關鍵字key的哈希地址p=H(key)出現衝突時,以p爲基礎,產生另一個哈希地址p1,如果p1仍然衝突,再以p爲基礎,產生另一個哈希地址p2,…,直到找出一個不衝突的哈希地址pi ,將相應元素存入其中。
 

再哈希法

這種方法是同時構造多個不同的哈希函數:
Hi=RH1(key)  i=1,2,…,k
當哈希地址Hi=RH1(key)發生衝突時,再計算Hi=RH2(key)……,直到衝突不再產生。這種方法不易產生聚集,但增加了計算時間。
 

鏈地址法

這種方法的基本思想是將所有哈希地址爲i的元素構成一個稱爲同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第i個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。鏈地址法適用於經常進行插入和刪除的情況。
 

建立公共溢出區

這種方法的基本思想是:將哈希表分爲基本表和溢出表兩部分,凡是和基本表發生衝突的元素,一律填入溢出表。

發佈了96 篇原創文章 · 獲贊 19 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章