面試被問到 HashMap 有這一文就夠了!

HashMap 是難點也是重點,更是面試中的常客,充分了解 HashMap 絕對有助於提升編程的內功心法。本文重點是對 JDK1.7 和 JDK1.8 中其實現方式的變化進行分析學習。

上一篇文章:面試一文搞定之ArrayList和LinkedList

不同集合底層數據結構?

集合實現 Object數組
List ArrayList Object數組
Vector Object數組
LinkedList 雙向鏈表(JDK1.6及之前爲循環鏈表)
Set HashSet(無序、唯一) 基於 HashMap 實現
LinkedHashSet 繼承自 HashSet ,內部通過 LinkedHashMap 實現。
TreeSet(有序、唯一) 紅黑樹(自平衡BST)
Map HashMap JDK1.8 之前數組+鏈表,JDK1.8開始數組+鏈表+紅黑樹
LinkedHashMap 繼承自 HashMap 並在其基礎上增加了一條雙向鏈表,使得其可以保持鍵值對插入的順序,實現了訪問順序相關邏輯。
HashTable 數組+鏈表
TreeMap 紅黑樹,基於 Key 進行排序

說HashTable、HashMap ?

  1. HashMap 是非線程安全的,而 HashTable 是線程安全的,HashTable 內的方法大多都經過synchronized修飾;因爲線程安全的問題上的處理,導致 HashMap 效率要略高於 HashTable ;並且 HashTable 已經是基本被淘汰的方案了!
  2. HashMap 中允許存在一個爲 null 的 key (value 可以有多個 null),但是如果向 HashTable 中 put 一個爲 null 的 key,會直接拋出 NullPointerException 。
  3. HashTable 的默認初始容量大小是 11,HashMap 的默認初始容量大小是 16;如果創建時指定初始容量,HashTable 會直接使用指定的容量,HashMap 會將指定容量擴充爲一個 2 的冪大小。(具體原因,在正文會進行分析)
  4. JDK1.8 之後 HashMap 當鏈表長度大於閾值(默認 8 )時,先檢查當前數組數組的長度,數組長度小於 64 則先進行數組擴容,否則轉換爲紅黑樹,以減少 Hash 衝突從而減少搜索時間。HashTable 沒有這個機制。

HashSet用過麼?

HashSet 的底層就是一個 HashMap ,HashSet 源碼很少,大多直接調用調用了 HashMap 的實現。

和 HashMap 的區別:HashMap 實現了 Map 接口,存儲鍵值對,其 hashcode 使用 Key進行計算;HashSet 實現了Set 接口,直接存儲數據對象,使用成員對象計算 hashcode值。

HashSet 如何保證插入對象唯一:當元素插入時,先調用hash()方法計算插入對象的 hashcode值去得到對象加入的位置,同時會和集合中其他的對象的 hashcode 值進行比較,如果沒有相同的 hashcode 值則說明對象沒重複;如果 hashcode 值重複,這是會調用對象的equals()方法來檢查 hashcode 相同的對象是否真的相同,最終相同則插入失敗,否則成功。

PS

  1. 通常==是判斷兩個變量或實例指向的內存空間是否相同,即引用是否相同 ;通常equals()是判斷兩個變量或實例指向的內存空間的值是否相同,即值是否相同 。
  2. 如果兩個對象相等,hashcode 一定相同;hashcode相同,對象不一定相等。
  3. hashCode()的默認行爲是對堆上的對象產生獨特值。如果沒有重寫hashCode(),則該 class 的兩個對象無論如何都不會相等(即使這兩個對象指向相同的數據)。

正文

歷史課 (* ̄︶ ̄) JDK1.8 前後的變化

JDK1.8 之前 HashMap 底層是採用數組+鏈表的實現。HashMap 通過 Key 的 hashcode 經過hash()處理後得到 hash 值:

static int hash(int h) {
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

在通過(n-1)&hash判斷當前元素存放的位置(n數組長度),最後如果計算得到的位置上存在元素,就判斷這個元素和待插入元素的 hash 和 Key 是否相同,如果相同則進行覆蓋,否則鏈表散列解決衝突。

JDK1.8 之後 HashMap 底層採用數組+鏈表+紅黑樹的實現。hash 的計算方法相較於之前更簡潔,但是原理不變:

    static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^ :按位異或
      // >>>:無符號右移,忽略符號位,空位都以0補齊
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

依然是採用之前那套方法找到待插入元素的位置,區別在於當發生 hash 衝突之後,如果這個位置上的鏈表大於閾值(默認是 8 ),檢查當前數組的長度,如果長度小於 64 則進行數組擴容,否則將鏈表轉化爲紅黑樹,從而增加之後的搜索效率。

HashMap數組長度爲什麼是2的冪次方?

插個題外話 HashMap 是如何保證總是使用2的冪作爲數組大小的?看指定初始容量的構造方法:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默認大小16
static final int MAXIMUM_CAPACITY = 1 << 30; //默認最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默認裝載因子

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

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(initialCapacity)方法,這個方法保證了總是使用2的冪作爲數組大小:

/**
 * 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;
}

迴歸正題,爲什麼採用2的冪大小?hash 取值範圍是[-2^8,2^8-1],當然不能真的創建一個 42億大小的數組,所以 hash 值用之前要對數組長度取模,得到的餘數纔是真正的存放位置,這點和前面"歷史課"散列計算的方法(n - 1) & hash是匹配的!

(n - 1) & hash採用位運算的方式取餘,相較於使用 % 操作的運算效率要高。如果數組的長度是 2的冪 則可以使(n - 1) & hashhash % n這兩個運算的結果相同!

線程安全問題!

主要原因是因爲併發場景下的 rehash 會造成元素之間形成一個循環鏈表。

所謂 rehash : 當增大 Hash表 的容量,整個 Hash表 裏所有無素的插入位置都需要被重算一遍。這叫rehash,這個成本相當的大。 關於rehash—— https://coolshell.cn/articles/9606.html

不過 JDK1.8 後解決了這個問題,但是在多線程的情況下使用 HashMap 還是會存在數據丟失等問題,併發場景下推薦使用 ConcurrentHashMap。

ConcurrentHashMap

說到 HashMap 的線程安全問題,就不得不提到 J.U.C 包下的 ConcurrentHashMap ,這也是面試的常客。

先介紹一下 ConcurrentHashMap :

  • 數據結構:JDK1.7的 ConcurrentHashMap 底層採用分段數組+鏈表實現,JDK1.8 開始其數據結構採用和 HashMap 一樣的數組+鏈表+紅黑樹,數組是本體,鏈表和紅黑樹主要是爲了解決哈希衝突存在的。
  • 如何實現線程安全:在 JDK1.7 的時候,ConcurrentHashMap 採用分段鎖對整個桶數組進行了分割分段(Segment),每一把鎖只鎖住容器中一部分的數據,多線程訪問容器裏不同數據段的數據,就不會引發鎖的競爭,提高了併發量。從 JDK1.8 開始不再使用之前的Segment的概念,改爲直接使用Node數組+鏈表+紅黑樹,併發控制使用synchronizeCAS來實現。整個看起來像是優化過且線程安全的 HashMap,雖然 JDK1.8 的源碼中還能看到 Segment 的數據結構,但是已經被簡化了屬性,只是爲了兼容舊版本。

PS:這裏提一下 HashTable 也是用 synchronize進行併發控制,但是效率缺很低的原因。因爲其所有方法synchronize時使用的是同一把鎖,所以當一個線程正在訪問同步方法時,其他的線程如果也想去訪問同步方法時,一定會進入阻塞或輪詢狀態形成一種串行化的操作,並且競爭可能會越來越激烈,從而導致效率大打折扣。

這裏看一下 HashTable 和 ConcurrentHashMap 鎖的對比:

JvqspT.png

Jvqy1U.png

圖片來源:http://www.cnblogs.com/chengxiao/p/6842045.html

再說 ConcurrentHashMap 線程安全

JDK1.7 首先將數據分成一段一段的進行存儲,每一段數據有一把鎖,當一個線程訪問某段數據時,就去獲得這段數據對應的鎖,並不對其他段的數據造成影響,即其他線程可以對其他段數據進行訪問而不被阻塞。

此時的 ConcurrentHashMap 是由 Segment 數據結構和 HashEntry 數組結構組成。

Segment 繼承自 ReentrantLock,所以是一種可重入鎖,其作用就是扮演鎖的角色。HashEntry 纔是用於存儲鍵值對數據的。

static class Segment<K,V> extends ReentrantLock implements Serializable {
}

一個 ConcurrentHashMap 裏包含一個 Segment 數組,Segment 的結構和 此時的HashMap 類似,是一種數組+鏈表的結構,一個 Segment 對應包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元素,每個 Segment 可以鎖住其對應的一個 HashEntry 數組中的元素,即當對一個 HashEntry 數組中的元素進行修改時,必須先獲得其對應的 Segment 鎖。

JDK1.8 的 ConcurrentHashMap 取消了 Segment 分段鎖的思想,改爲CASsynchronize來保證併發安全。數據結構和此時的 HashMap 的結構類似數組+鏈表+紅黑樹。前文說過的 Java 8 在鏈表長度上超過閾值(8)並且數組長度超過 64 時將鏈表轉換爲紅黑樹。

synchronize只會鎖住當前鏈表或紅黑樹的首節點,只要 hash 不衝突,就不會發生併發,效率大大提升。

補充

Comparable 和 Comparator

  • Comparable 接口出自 java.lang 包下,接口下只有一個 compareTo(T o)抽象方法,如果希望 TreeMap/TreeSet 插入元素時希望採用自定義排序,可以讓插入對象實現這個接口重寫方法:

    public int compareTo(T o);
    
  • Comparator 是個函數式接口出自 java.util 包下,這個接口下方法有二十多個方法,其中有一個抽象方法 compare(T o1, T o2),其常用方式是調用帶參數的Collections.sort()時傳一個 Comparator 的匿名類,支持採用 Lambda 的方式實現:

    int compare(T o1, T o2);
    

當我們需要對一個集合進行自定義排序時,可以重寫 compare(T o1, T o2)或者 compareTo(T o)

或者當我們需要對某一個集合實現兩種自定義排序的時候,比如對 Student 對象元素中的姓名採用一種自定義排序方式、學校名採用另一種自定義排序方式的需求,可以重寫 compareTo(T o)方法實現一種、再實現 Comparable 接口實現另一種方法,也可以用兩個 Comparator 分別重寫compareTo(T o)方法進行實現,第二個方案可以使用兩個帶參數的 Collections.sort()實現。

自定義排序方式、學校名採用另一種自定義排序方式的需求,可以重寫 compareTo(T o)方法實現一種、再實現 Comparable 接口實現另一種方法,也可以用兩個 Comparator 分別重寫compareTo(T o)方法進行實現,第二個方案可以使用兩個帶參數的 Collections.sort()實現。


本人菜鳥,有錯誤請告知,感激不盡!

更多題解和源碼:github

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