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 中,判斷兩個物件是否相等,都是通過 equals 和 hashCode 兩個方法:
- 兩個物件通過 equals 判斷相等,那麼它們肯定返回相同的 hashCode
- 反之,不要求必須擁有相同的 hashCode
所以,HashSet 儲存的物件,都要正確覆蓋實現 equals 和 hashCode 兩個方法。
其實,HashSet 中的元素其實就是 HashMap 的 key,在插入時:
- 首先計算元素的 hashCode 值,找到底層陣列儲存位置
- 然後和該位置上的所有元素使用 equals 方法進行比較
- 如果都不相等,則插入;否則不插入,本質上這裡做了一次 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 併發包下的執行緒安全的集合,以及一些有特殊用途的集合實現。