這 3 個 Set 集合的實現有點簡單,那來做個總結吧

wskwbog發表於2019-07-31

Set 介面是 Java Collections Framework 中的一員,它的特點是:不能包含重複的元素,允許且最多隻有一個 null 元素。Java 中有三個常用的 Set 實現類:

  • HashSet: 將元素儲存在雜湊表中,效能最佳,但不能保證元素的迭代順序
  • LinkedHashSet: 維護一個連結串列貫穿所有元素,按插入順序對元素進行迭代
  • TreeSet: 將元素儲存在一個紅黑樹中,按元素大小排序的序列迭代

JDK 在實現時,這 3 個 Set 集合的核心功能其實分別委託給了: HashMap, LinkedHashMap 和 TreeMap,關於這 3 個 Map 的原始碼分析可檢視本站釋出的其他文章。

接下來對這 3 個 Set 集合的原始碼簡單分析,並解決一些面試可能會遇到的問題。

HashSet

如果去除註釋,HashSet 原始碼也就 200 行左右,除了序列化和克隆的方法,程式碼如下:

public class HashSet<E> extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {
  // 實際儲存元素的物件
  private transient HashMap<E,Object> map;
  
  // 儲存在 HashMap 中所有 key 的共享的 value 值
  private static final Object PRESENT = new Object();
  // 空建構函式
  public HashSet() {
      map = new HashMap<>(); // 0.75f 載入因子
  }
  // 使用已有集合填充並初始化
  public HashSet(Collection<? extends E> c) {
      map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
      addAll(c);
  }
  // 指定關聯 HashMap 的初始容量和載入因子
  public HashSet(int initialCapacity, float loadFactor) {
      map = new HashMap<>(initialCapacity, loadFactor);
  }
  // 只指定初始容量
  public HashSet(int initialCapacity) {
      map = new HashMap<>(initialCapacity);
  }
  // 包訪問許可權的構造方法,僅用於 LinkedHashSet 初始化
  // 使用 LinkedHashMap 作為底層儲存
  HashSet(int initialCapacity, float loadFactor, boolean dummy) {
      map = new LinkedHashMap<>(initialCapacity, loadFactor);
  }
  // HashSet 中的元素就相當於 HashMap 中的 key 
  public Iterator<E> iterator() {
      return map.keySet().iterator();
  }
  
  // 以下這些方法,都是對 Set 介面中定義的方法的實現
  public int size() {
      return map.size();
  }

  public boolean isEmpty() {
      return map.isEmpty();
  }
  
  public boolean contains(Object o) {
      return map.containsKey(o);
  }
  // 所有鍵值對的 value 值都是 PRESENT 這個 Object 物件
  public boolean add(E e) {
      return map.put(e, PRESENT)==null;
  }

  public boolean remove(Object o) {
      return map.remove(o)==PRESENT;
  }

  public void clear() {
      map.clear();
  }
  // JDK 8 提供的一種並行遍歷機制 - 可分割迭代器
  public Spliterator<E> spliterator() {
      return new HashMap.KeySpliterator<E,Object>(map, 0, -1, 0, 0);
  }
}

可以看到,底層使用 HashMap 用於實際存放資料,而 PRESENT 就是所有寫入 map 的 value 值。實現比較簡單,核心功能都委託給了 HashMap

不管是 Set 還是 Map,儲存的都是物件,在 Java 中,判斷兩個物件是否相等,都是通過 equalshashCode 兩個方法:

  • 兩個物件通過 equals 判斷相等,那麼它們肯定返回相同的 hashCode
  • 反之,不要求必須擁有相同的 hashCode

所以,HashSet 儲存的物件,都要正確覆蓋實現 equalshashCode 兩個方法。

其實,HashSet 中的元素其實就是 HashMap 的 key,在插入時:

  1. 首先計算元素的 hashCode 值,找到底層陣列儲存位置
  2. 然後和該位置上的所有元素使用 equals 方法進行比較
  3. 如果都不相等,則插入;否則不插入,本質上這裡做了一次 value 的更新,但 key 不變化。

關於迭代器,就是利用的 HashMap 中的 KeyIterator。

LinkedHashSet

LinkedHashSet 的程式碼就更簡單了,它繼承自 HashSet,程式碼如下:

public class LinkedHashSet<E> extends HashSet<E>
  implements Set<E>, Cloneable, java.io.Serializable {
  // 呼叫父類特定的構造方法,初始一個 LinkedHashMap
  public LinkedHashSet(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor, true);
  }

  public LinkedHashSet(int initialCapacity) {
    super(initialCapacity, .75f, true);
  }

  public LinkedHashSet() {
    super(16, .75f, true);
  }

  public LinkedHashSet(Collection<? extends E> c) {
    super(Math.max(2*c.size(), 11), .75f, true);
    addAll(c);
  }

  @Override
  public Spliterator<E> spliterator() {
    return Spliterators.spliterator(this, Spliterator.DISTINCT | Spliterator.ORDERED);
  }
}

全部程式碼就這些,值得注意的是構造方法中的 super 呼叫的是 HashSet 中的一個預設包訪問許可權的構造方法,核心功能都委託給了 LinkedHashMap。

像 HashSet 那樣,它能在常量時間內完成集合的基本操作 add, contains 和 remove。效能略低於 HashSet,因為要額外維護一個連結串列。但有一個例外,在遍歷時,LinkedHashSet 花費的時間與元素個數成比例,而 HashSet 花費時間較多,因為它與集合容量成比例。

TreeSet

TreeSet 是一個有序的 Set 集合,元素大小比較方式可以是自然順序,也可以指定一個 Comparator 比較器。

它是對 TreeMap 的封裝,提供了在有序集合上的遍歷 API 比如,lower、floor、ceiling 和 higher 分別返回小於、小於等於、大於等於、大於給定元素的元素。能在 log(n) 時間內完成集合的基本操作 add, contains 和 remove。

有一點可以瞭解下,Set 介面定義的是使用 equals 方法比較元素是否相等,而 TreeSet 使用則是 compareTo 或者 compare 方法進行比較,這滿足集合的行為,只不過沒有遵守 Set 介面的規範。

TreeSet 原始碼也比較簡單,畢竟只是對 TreeMap 封裝了一下,這裡不再貼出。

常用集合面試問題總結

之前分析了一部分常用集合的原始碼,這些集合都各有各的特點,它們的區別也經常出現在面試中,本文最後就對常見的面試題進行下總結。

ArrayList 與 LinkedList 有什麼區別?

  • 儲存結構不同,ArrayList 底層使用陣列;LinkedList 使用雙向連結串列
  • 效能上,ArrayList 能夠隨機訪問,但增加和刪除效率較慢,涉及到記憶體拷貝;LinkedList 只能順序或逆序訪問,佔用記憶體稍大,但插入刪除效率高
  • LinkedList 還能當做棧和佇列來使用
  • 兩者均與允許儲存 null 也允許儲存重複元素
  • 兩者都是執行緒不安全的,都可以使用 Collections.synchronizedList(List list) 方法生成一個執行緒安全的 List

ArrayList 與 Vector 有什麼區別?

  • ArrayList 非執行緒安全,Vector 執行緒安全
  • 擴容時,ArrayList 增加 1.5 倍的容量 ; Vector 增加 2倍的容量

JDK 8 對 HashMap 做了哪些優化?

  • 底層結構改為單連結串列 + 陣列 + 紅黑樹的儲存結構,在有大量雜湊衝突時,將查詢時間複雜度從 O(n) 降為 O(log(n))
  • 優化雜湊函式,將 1.7 中的4次位運算 + 5次異或運算,降低到1次位運算 + 1次異或運算
  • 優化擴容機制,1.7 中會重新雜湊計算新的位置,而 1.8 則是根據2的次冪擴充套件機制,不重新計算位置,只根據原雜湊值計算偏移量,要麼位置不變,要麼偏移舊陣列容量的偏移量

HashMap 和 HashTable 的區別

  • HashMap 執行緒不安全 ; HashTable 執行緒安全
  • HashMap 允許 key 和 Vale 為 null ; HashTable 不允許 key、value 為 null
  • HashMap 預設容量為 2^4 且容量一定是 2^n ; HashTable 預設容量是11(素數), 不一定是 2^n
  • HashTable 直接使用模運算計算雜湊桶下標 ; HashMap 使用 & 位運算 進行優化

HashMap 和 LinkedHashMap 的區別

  • LinkedHashMap 繼承自 HashMap 它們有相同的儲存結構和擴容機制
  • LinkedHashMap 內部需要額外維護一個連結串列
  • LinkedHashMap 按插入順序對元素進行迭代 ; 而 HashMap 迭代順序不可預測
  • LinkedHashMap 可按按訪問順序遍歷元素,用於構建 LRU 快取

什麼是 fast-fail,原理是什麼?

fast-fail,即快速失敗,在遍歷集合的過程中,如果發現集合結構發生了變化,會丟擲 ConcurrentModificationException 執行時異常。

注意,在不同步修改的情況下,它不能保證會發生,它只是盡力檢測併發修改的錯誤。

原理是通過一個 modCount 欄位來實現的,這個欄位記錄了列表結構的修改次數,當呼叫 iterator() 返回迭代器時,會快取 modCount 當前的值,如果這個值發生了不期望的變化,那麼就會在 next, remove 操作中丟擲異常。

小結

本文以及之前介紹的集合都是常規的,常用的,非執行緒安全的集合實現,接下來將會介紹 Java 併發包下的執行緒安全的集合,以及一些有特殊用途的集合實現。

相關文章