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 ?
- HashMap 是非線程安全的,而 HashTable 是線程安全的,HashTable 內的方法大多都經過
synchronized
修飾;因爲線程安全的問題上的處理,導致 HashMap 效率要略高於 HashTable ;並且 HashTable 已經是基本被淘汰的方案了! - HashMap 中允許存在一個爲 null 的 key (value 可以有多個 null),但是如果向 HashTable 中 put 一個爲 null 的 key,會直接拋出 NullPointerException 。
- HashTable 的默認初始容量大小是 11,HashMap 的默認初始容量大小是 16;如果創建時指定初始容量,HashTable 會直接使用指定的容量,HashMap 會將指定容量擴充爲一個 2 的冪大小。(具體原因,在正文會進行分析)
- 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
- 通常
==
是判斷兩個變量或實例指向的內存空間是否相同,即引用是否相同 ;通常equals()
是判斷兩個變量或實例指向的內存空間的值是否相同,即值是否相同 。 - 如果兩個對象相等,hashcode 一定相同;hashcode相同,對象不一定相等。
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) & hash
和hash % 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數組+鏈表+紅黑樹,併發控制使用
synchronize
和CAS
來實現。整個看起來像是優化過且線程安全的 HashMap,雖然 JDK1.8 的源碼中還能看到 Segment 的數據結構,但是已經被簡化了屬性,只是爲了兼容舊版本。
PS:這裏提一下 HashTable 也是用 synchronize
進行併發控制,但是效率缺很低的原因。因爲其所有方法synchronize
時使用的是同一把鎖,所以當一個線程正在訪問同步方法時,其他的線程如果也想去訪問同步方法時,一定會進入阻塞或輪詢狀態形成一種串行化的操作,並且競爭可能會越來越激烈,從而導致效率大打折扣。
這裏看一下 HashTable 和 ConcurrentHashMap 鎖的對比:
圖片來源: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 分段鎖的思想,改爲CAS
和synchronize
來保證併發安全。數據結構和此時的 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