搞懂 HashSet & LinkedHashSet 原始碼以及集合常見面試題目

像一隻狗發表於2018-04-17

HashSet & LinkedHashSet 原始碼分析以及集合常見面試題目

經過上兩篇的 HashMapLinkedHashMap 原始碼分析以後,本文將繼續分析 JDK 集合之 Set 原始碼,由於有了之前的 Map 原始碼分析的鋪墊,Set 原始碼就簡單很多了,本文的篇幅也將比之前短很多。檢視 Set 原始碼的構造引數就可以知道,Set 內部其實維護的就是一個 Map,只是單單使用了 Entry 中的 key 。那麼本文將不再贅述內部資料結構,而是通過部分的原始碼,來說明兩個 Set 集合與 Map 之間的關係。本文將從以下幾部分敘述:

  1. Set 集合概述
  2. HashSet 原始碼簡單分析
  3. LinkedHashSet 原始碼簡單分析
  4. 關於面試中的集合問題總結

Set 集合概述

搞懂 HashSet & LinkedHashSet 原始碼以及集合常見面試題目

圖片來自網際網路侵刪

由於本篇文章主要敘述 Set 容器以及和 Map 容器之間關係,我們只需要關注上述集合圖譜中 Set 部分。可以看出 Set 主要的實現類有 HashSetTreeSet 以及沒有畫出的 LinkedHashSet。其中 HashSet 的實現依賴於 HashMapTreeSet 的實現依賴於 TreeMapLinkedHashSet 的實現依賴於 LinkedHashMap

從各個實現類的宣告也可以看出其繼承關係

public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
    
    
public class LinkedHashSet<E>
       extends HashSet<E>
       implements Set<E>, Cloneable, java.io.Serializable 
    
public class TreeSet<E> extends AbstractSet<E>
    implements NavigableSet<E>, Cloneable, java.io.Serializable  
       
複製程式碼

在看 Set 的原始碼之前,我們先概括的說下 Set 集合的特點

  1. HashSet 底層是陣列 + 單連結串列 + 紅黑樹的資料結構
  2. LinkedHashSet 底層是 陣列 + 單連結串列 + 紅黑樹 + 雙向連結串列的資料結構
  3. Set 不允許儲存重複元素,允許儲存 null
  4. HashSet 儲存元素是無序且不等於訪問順序
  5. LinkedHashSet 儲存元素是無序的,但是由於雙向連結串列的存在,迭代時獲取元素的順序等於元素的新增順序,注意這裡不是訪問順序

HashSet 的原始碼分析

HashSet 原始碼只有短短的 300 行,上文也闡述了實現依賴於 HashMap,這一點充分體現在其構造方法和成員變數上。我們來看下 HashSet 的構造方法和成員變數:

 // HashSet 真實的儲存元素結構
 private transient HashMap<E,Object> map;

 // 作為各個儲存在 HashMap 元素的鍵值對中的 Value
 private static final Object PRESENT = new Object();
    
 //空引數構造方法 呼叫 HashMap 的空構造引數  
 //初始化了 HashMap 中的載入因子 loadFactor = 0.75f
 public HashSet() {
        map = new HashMap<>();
 }
 
 //指定期望容量的構造方法
 public HashSet(int initialCapacity) {
    map = new HashMap<>(initialCapacity);
 }
 //指定期望容量和載入因子
 public HashSet(int initialCapacity, float loadFactor) {
    map = new HashMap<>(initialCapacity, loadFactor);
 }
 //使用指定的集合填充Set
 public HashSet(Collection<? extends E> c) {
        //呼叫  new HashMap<>(initialCapacity) 其中初始期望容量為 16 和 c 容量 / 預設 load factor 後 + 1的較大值
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
 }

 // 該方法為 default 訪問許可權,不允許使用者直接呼叫,目的是為了初始化 LinkedHashSet 時使用
 HashSet(int initialCapacity, float loadFactor, boolean dummy) {
        map = new LinkedHashMap<>(initialCapacity, loadFactor);
 }
複製程式碼

通過 HashSet 的構造引數我們可以看出每個構造方法,都呼叫了對應的 HashMap 的構造方法用來初始化成員變數 map ,因此我們可以知道,HashSet 的初始容量也為 1<<4 即16,載入因子預設也是 0.75f。

我們都知道 Set 不允許儲存重複元素,又由構造引數得出結論底層儲存結構為 HashMap,那麼這個不可重複的屬性必然是有 HashMap 中儲存鍵值對的 Key 來實現了。在分析 HashMap 的時候,提到過 HashMap 通過儲存鍵值對的 Key 的 hash 值(經過擾動函式hash()處理後)來決定鍵值對在雜湊表中的位置,當 Key 的 hash 值相同時,再通過 equals 方法判讀是否是替換原來對應 key 的 Value 還是儲存新的鍵值對。那麼我們在使用 Set 方法的時候也必須保證,儲存元素的 HashCode 方法以及 equals 方法被正確覆寫。

HashSet 中的新增元素的方法也很簡單,我們來看下實現:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}
複製程式碼

可以看出 add 方法呼叫了 HashMap 的 put 方法,構造的鍵值對的 key 為待新增的元素,而 Value 這時有全域性變數 PRESENT 來充當,這個PRESENT只是一個 Object 物件。

除了 add 方法外 HashSet 實現了 Set 介面中的其他方法這些方法有:

public int size() {
        return map.size();
}

public boolean isEmpty() {
   return map.isEmpty();
}

public boolean contains(Object o) {
   return map.containsKey(o);
}

//呼叫 remove(Object key)  方法去移除對應的鍵值對
public boolean remove(Object o) {
   return map.remove(o)==PRESENT;
}

public void clear() {
   map.clear();
}

// 返回一個 map.keySet 的 HashIterator 來作為 Set 的迭代器
public Iterator<E> iterator() {
   return map.keySet().iterator();
}
複製程式碼

關於迭代器我們在講解 HashMap 中的時候沒有詳細列舉,其實 HashMap 提供了多種迭代方法,每個方法對應了一種迭代器,這些迭代器包括下述幾種,而 HashSet 由於只關注 Key 的內容,所以使用 HashMap 的內部類 KeySet 返回了一個 KeyIterator ,這樣在呼叫 next 方法的時候就可以直接獲取下個節點的 key 了。

//HashMap 中的迭代器

final class KeyIterator extends HashIterator
   implements Iterator<K> {
   public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
   implements Iterator<V> {
   public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
   implements Iterator<Map.Entry<K,V>> {
   public final Map.Entry<K,V> next() { return nextNode(); }
}

複製程式碼

關於 HashSet 中的原始碼分析就這些,其實除了一些序列化和克隆的方法以外,我們已經列舉了所有的 HashSet 的原始碼,有沒有感覺巨簡單,其實下面的 LinkedHashSet 由於繼承自 HashSet 使得其程式碼更加簡單隻有短短100多行不信繼續往下看。

搞懂 HashSet & LinkedHashSet 原始碼以及集合常見面試題目

LinkedHashSet 原始碼分析

在上述分析 HashSet 構造方法的時候,有一個 default 許可權的構造方法沒有講,只說了其跟 LinkedHashSet 構造有關係,該構造方法內部呼叫的是 LinkedHashMap 的構造方法。

LinkedHashMap 較之 HashMap 內部多維護了一個雙向連結串列用來維護元素的新增順序:

// dummy 引數沒有作用這裡可以忽略
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
   map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

//呼叫 LinkedHashMap 的構造方法,該方法初始化了初始起始容量,以及載入因子,
//accessOrder = false 即迭代順序不等於訪問順序
public LinkedHashMap(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor);
        accessOrder = false;
}

複製程式碼

LinkedHashSet的構造方法一共有四個,統一呼叫了父類的 HashSet(int initialCapacity, float loadFactor, boolean dummy)構造方法。

//初始化 LinkedHashMap 的初始容量為誒 16 載入因子為 0.75f
public LinkedHashSet() {
   super(16, .75f, true);
}

//初始化 LinkedHashMap 的初始容量為 Math.max(2*c.size(), 11) 載入因子為 0.75f 
public LinkedHashSet(Collection<? extends E> c) {
   super(Math.max(2*c.size(), 11), .75f, true);
   addAll(c);
}

//初始化 LinkedHashMap 的初始容量為引數指定值 載入因子為 0.75f 
public LinkedHashSet(int initialCapacity) {
   super(initialCapacity, .75f, true);
}
 
 //初始化 LinkedHashMap 的初始容量,載入因子為引數指定值 
 public LinkedHashSet(int initialCapacity, float loadFactor) {
   super(initialCapacity, loadFactor, true);
}
複製程式碼

完了..沒錯,LinkedHashSet 原始碼就這幾行,所以可以看出其實現依賴於 LinkedHashMap 內部的資料儲存結構。

關於面試中的集合問題總結

之前分析了多篇關於 JDK 集合原始碼的文章,而這些集合原始碼中的知識點都是面試的時候常客,因此在本篇結尾作為 "充數" 的一節,我們來以面試題的形式總結一下之前所分過的原始碼中的知識點,這些知識點在之前的文章中都有詳細的分析,如果有疑問可以回顧一下之前的原始碼分析文章。

  • ArrayList 與 LinkedList 區別 ?
  1. 儲存結構上 ArrayList 底層使用陣列進行元素的儲存,LinkedList 使用雙向連結串列作為儲存結構。

  2. 兩者均與允許儲存 null 也允許儲存重複元素。

  3. 在效能上 ArrayList 在儲存大量元素時候的增刪效率 平均低於 LinkedList,因為 ArrayList 在增刪的是需要拷貝元素到新的陣列,而 LinkedList 只需要將節點前後指標指向改變。

  4. 在根據角標獲取元素的時間效率上ArrayList優於 LinkedList,因為陣列本身有儲存連續,有 index 角標,而 LinkedList 儲存元素離散,需要遍歷連結串列。

  5. 不要使用 for 迴圈去遍歷 LinkedList 因為效率很低。

  6. 兩者都是執行緒不安全的,都可以使用 Collections.synchronizedList(List<E> list) 方法生成一個執行緒安全的 List。

  • ArrayList 與 Vector 區別(為什麼要用Arraylist取代Vector呢?)
  1. ArrayList 的擴容機制由於 Vector , ArrayList 每次 resize 增加 1.5 倍的容量,Vector 每次增加 2倍的容量,在儲存大量元素後擴容的時候就能有很大的空間節省。
  2. Vector 新增刪除方法以及迭代器遍歷的方法都是 synchronized 修飾的方法,線上程安全的情況下使用效率低於 ArrayList
  3. ArrayList 和 LinkedList 通過Collections.synchronizedList(List<E> list) 的執行緒同步的集合,迭代器並不同步,需要使用者去加鎖。
  • 簡述 HashMap 的工作原理 JDK 1.8後做了哪些優化

    1. JDK 1.7 HashMap 底層採用單連結串列 + 陣列的儲存結構儲存元素(鍵值對)。JDK1.8之後 HashMap 在同一雜湊桶中節點數量(單連結串列長度)超過 8之後會使用 紅黑樹替換單連結串列來提高效率
    2. HashMap 通過鍵值對的 key 的 hashCode 值經過擾動函式處理後確定儲存的陣列角標位置,1.7 中擾動函式使用了 4次位運算 + 5次異或運算,1.8 中降低到 1次位運算 + 1次異或運運算
    3. HashMap 擴容的時候會增加原來陣列長度兩倍,並對所儲存的元素節點hash 值的重新計算,1.7中 HashMap 會重新呼叫 hash 函式計算新的位置,而 1.8中對此進行了優化通過 (e.hash & oldCap) == 0 來確定節點新位置是位於擴容前的角標還是之前的 2倍角標位置。
    4. HashMap 在多執行緒使用前提下,擴容的時候可能會導致迴圈連結串列的情況,當然我們不應線上程不安全的情況下使用 HashMap
  • HashMap 和 HashTable 的區別

    1. HashMap 是執行緒不安全的,HashTable是執行緒安全的。

    2. HashMap 允許 key 和 Vale 是 null,但是隻允許一個 key 為 null,且這個元素存放在雜湊表 0 角標位置。 HashTable 不允許key、value 是 null

    3. HashMap 內部使用hash(Object key)擾動函式對 key 的 hashCode 進行擾動後作為 hash 值。HashTable 是直接使用 key 的 hashCode() 返回值作為 hash 值。

    4. HashMap預設容量為 2^4 且容量一定是 2^n ; HashTable 預設容量是11,不一定是 2^n

    5. HashTable 取雜湊桶下標是直接用模運算,擴容時新容量是原來的2倍+1。HashMap 在擴容的時候是原來的兩倍,且雜湊桶的下標使用 &運算代替了取模。

  • HashMap 和 LinkedHashMap 的區別

  1. LinkedHashMap 擁有與 HashMap 相同的底層雜湊表結構,即陣列 + 單連結串列 + 紅黑樹,也擁有相同的擴容機制。

  2. LinkedHashMap 相比 HashMap 的拉鍊式儲存結構,內部額外通過 Entry 維護了一個雙向連結串列。

  3. HashMap 元素的遍歷順序不一定與元素的插入順序相同,而 LinkedHashMap 則通過遍歷雙向連結串列來獲取元素,所以遍歷順序在一定條件下等於插入順序。

  4. LinkedHashMap 可以通過構造引數 accessOrder 來指定雙向連結串列是否在元素被訪問後改變其在雙向連結串列中的位置。

  • HashSet 如何檢查重複,與 HashMap 的關係?
  1. HashSet 內部使用 HashMap 儲存元素,對應的鍵值對的鍵為 Set 的儲存元素,值為一個預設的 Object 物件。
  2. HashSet 通過儲存元素的 hashCode 方法和 equals 方法來確定元素是否重複。
  • 是否瞭解 fast-fail 規則 簡單說明一下
  1. 快速失敗(fail—fast)在用迭代器遍歷一個集合物件時,如果遍歷過程中集合物件中的內容發生了修改(增加、刪除、修改),則會丟擲ConcurrentModificationException

  2. 迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個 modCount 變數。集合在被遍歷期間如果內容發生變化,就會改變 modCount 的值。每當迭代器使用hasNext()/next() 遍歷下一個元素之前,都會檢測 modCount 變數是否為expectedmodCount 值,是的話就返回遍歷值;否則丟擲異常,終止遍歷。

  3. 場景:java.util包下的集合類都是快速失敗的,不能在多執行緒下發生併發修改(迭代過程中被修改)。

  • 集合在遍歷過程中是否可以刪除元素,為什麼迭代器就可以安全刪除元素
  1. 集合在使用 for 迴圈或者高階 for 迴圈迭代的過程中不允許使用,集合本身的 remove 方法刪除元素,如果進行錯誤操作將會導致 ConcurrentModificationException異常的發生

  2. Iterator 可以刪除訪問的當前元素(current),一旦刪除的元素是Iterator 物件中 next 所正在引用的,在 Iterator 刪除元素通過 修改 modCount 與 expectedModCount 的值,可以使下次在呼叫 remove 的方法時候兩者仍然相同因此不會有異常產生。

總結

本文分析了 JDK 中 HashSetLinkedHashSet 的原始碼實現,闡述了Set 與 Map 的關係,也通過最後一節的面試題總結複習了一下之前幾篇原始碼分析文章的知識點。之後可能會繼續分析一下 Android 中特有的 ArrayMapSparseArray 原始碼分析。

集合原始碼分析文章目錄,歡迎大家檢視。

  1. 搞懂 Java HashMap 原始碼
  2. 搞懂 Java LinkedHashMap 原始碼
  3. 搞懂 Java ArrayList 原始碼
  4. 搞懂 Java LinkedList 原始碼
  5. 搞懂 Java equals 和 hashCode 方法
  6. Java List 容器原始碼分析的補充

相關文章