【集合系列】- 深入淺出的分析 Hashtable
一、摘要
在集合系列的第一章,咱們瞭解到,Map 的實現類有 HashMap、LinkedHashMap、TreeMap、IdentityHashMap、WeakHashMap、Hashtable、Properties 等等。
本文主要從資料結構和演算法層面,探討 Hashtable 的實現,如果有理解不當之處,歡迎指正。
二、簡介
Hashtable 一個元老級的集合類,早在 JDK 1.0 就誕生了,而 HashMap 誕生於 JDK 1.2,在實現上,HashMap 吸收了很多 Hashtable 的思想,雖然二者的底層資料結構都是 陣列 + 連結串列 結構,具有查詢、插入、刪除快的特點,但是二者又有很多的不同。
開啟 Hashtable 的原始碼可以看到,Hashtable 繼承自 Dictionary,而 HashMap 繼承自 AbstractMap。
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {
.....
}
HashMap 繼承自 AbstractMap,HashMap 類的定義如下:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { ..... }
其中 Dictionary 類是一個已經被廢棄的類,翻譯過來的意思是這個類已經過時,新的實現應該實現 Map 介面而不是擴充套件此類,這一點我們可以從它程式碼的註釋中可以看到:
/** * <strong>NOTE: This class is obsolete. New implementations should * implement the Map interface, rather than extending this class.</strong> */ public abstract class Dictionary<K,V> { ...... }
Hashtable 和 HashMap 的底層是以陣列來儲存,同時,在儲存資料通過key
計算陣列下標的時候,是以雜湊演算法為主,因此可能會產生雜湊衝突的可能性。
通俗的說呢,就是不同的key
,在計算的時候,可能會產生相同的陣列下標,這個時候,如何將兩個物件放入一個數組中呢?
而解決雜湊衝突的辦法,有兩種,一種開放地址方式(當發生 hash 衝突時,就繼續以此繼續尋找,直到找到沒有衝突的hash值),另一種是拉鍊方式(將衝突的元素放入連結串列)。
Java Hashtable 採用的就是第二種方式,拉鍊法!
於是,當發生不同的key
通過一系列的雜湊演算法計算獲取到相同的陣列下標的時候,會將物件放入一個數組容器中,然後將物件以單向連結串列
的形式儲存在同一個陣列下標容器中,就像鏈子一樣,掛在某個節點上,如下圖:
與 HashMap 類似,Hashtable 也包括五個成員變數:
/**由Entry物件組成的陣列*/
private transient Entry[] table;
/**Hashtable中Entry物件的個數*/
private transient int count;
/**Hashtable進行擴容的閾值*/
private int threshold;
/**負載因子,預設0.75*/
private float loadFactor;
/**記錄修改的次數*/
private transient int modCount = 0;
具體各個變數含義如下:
- table:表示一個由 Entry 物件組成的連結串列陣列,Entry 是一個單向連結串列,雜湊表的
key-value
鍵值對都是儲存在 Entry 陣列中的; - count:表示 Hashtable 的大小,用於記錄儲存的鍵值對的數量;
- threshold:表示 Hashtable 的閾值,用於判斷是否需要調整 Hashtable 的容量,threshold 等於
容量 * 載入因子
; - loadFactor:表示負載因子,預設為 0.75;
- modCount:表示記錄 Hashtable 修改的次數,用來實現快速失敗拋異常處理;
接著來看看Entry
這個內部類,Entry
用於儲存連結串列資料,實現了Map.Entry
介面,本質是就是一個對映(鍵值對),原始碼如下:
private static class Entry<K,V> implements Map.Entry<K,V> {
/**hash值*/
final int hash;
/**key表示鍵*/
final K key;
/**value表示值*/
V value;
/**節點下一個元素*/
Entry<K,V> next;
......
}
我們再接著來看看 Hashtable 初始化過程,核心原始碼如下:
public Hashtable() {
this(11, 0.75f);
}
this 呼叫了自己的構造方法,核心原始碼如下:
public Hashtable(int initialCapacity, float loadFactor) {
.....
//預設的初始大小為 11
//並且計算擴容的閾值
this.loadFactor = loadFactor;
table = new Entry<?,?>[initialCapacity];
threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
}
可以看到 HashTable 預設的初始大小為 11,如果在初始化給定容量大小,那麼 HashTable 會直接使用你給定的大小;
擴容的閾值threshold
等於initialCapacity * loadFactor
,我們在來看看 HashTable 擴容,方法如下:
protected void rehash() {
int oldCapacity = table.length;
//將舊陣列長度進行位運算,然後 +1
//等同於每次擴容為原來的 2n+1
int newCapacity = (oldCapacity << 1) + 1;
//省略部分程式碼......
Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
}
可以看到,HashTable 每次擴充為原來的 2n+1。
我們再來看看 HashMap,如果是執行預設構造方法,會在擴容那一步,進行初始化大小,核心原始碼如下:
final Node<K,V>[] resize() {
int newCap = 0;
//部分程式碼省略......
newCap = DEFAULT_INITIAL_CAPACITY;//預設容量為 16
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
}
可以看出 HashMap 的預設初始化大小為 16,我們再來看看,HashMap 擴容方法,核心原始碼如下:
final Node<K,V>[] resize() {
//獲取舊陣列的長度
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int newCap = 0;
//部分程式碼省略......
//當進行擴容的時候,容量為 2 的倍數
newCap = oldCap << 1;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
}
可以看出 HashMap 的擴容後的陣列數量為原來的 2 倍;
也就是說 HashTable 會盡量使用素數、奇數來做陣列的容量,而 HashMap 則總是使用 2 的冪作為陣列的容量。
我們知道當雜湊表的大小為素數時,簡單的取模雜湊的結果會更加均勻,所以單從這一點上看,HashTable 的雜湊表大小選擇,似乎更高明些。
Hashtable 的 hash 演算法,核心程式碼如下:
//直接計算key.hashCode()
int hash = key.hashCode();
//通過除法取餘計算陣列存放下標
// 0x7FFFFFFF 是最大的 int 型數的二進位制表示
int index = (hash & 0x7FFFFFFF) % tab.length;
從原始碼部分可以看出,HashTable 的 key 不能為空,否則報空指標錯誤!
但另一方面我們又知道,在取模計算時,如果模數是 2 的冪,那麼我們可以直接使用位運算來得到結果,效率要大大高於做除法。所以在 hash 計算陣列下標的效率上,HashMap 卻更勝一籌,但是這也會引入了雜湊分佈不均勻的問題, HashMap 為解決這問題,又對 hash 演算法做了一些改動,具體我們來看看。
HashMap 的 hash 演算法,核心程式碼如下:
/**獲取hash值方法*/
static final int hash(Object key) {
int h;
// h = key.hashCode() 為第一步 取hashCode值(jdk1.7)
// h ^ (h >>> 16) 為第二步 高位參與運算(jdk1.7)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//jdk1.8
}
/**獲取陣列下標方法*/
static int indexFor(int h, int length) {
//jdk1.7的原始碼,jdk1.8沒有這個方法,但是實現原理一樣的
return h & (length-1); //第三步 取模運算
}
HashMap 由於使用了2的冪次方,所以在取模運算時不需要做除法,只需要位的與運算就可以了。但是由於引入的 hash 衝突加劇問題,HashMap 在呼叫了物件的 hashCode 方法之後,又做了一些高位運算,也就是第二步方法,來打散資料,讓雜湊的結果更加均勻。
與此同時,在 jdk1.8 中 HashMap 還引進來紅黑樹實現,當衝突連結串列長度大於 8 的時候,會將連結串列結構改變成紅黑樹結構,讓查詢變得更快,具體實現可以參見《集合系列》中的 HashMap 分析。
三、常用方法介紹
3.1、put方法
put 方法是將指定的 key, value 對新增到 map 裡。
put 流程圖如下:
開啟 HashTable 的 put 方法,原始碼如下:
public synchronized V put(K key, V value) {
//當 value 值為空的時候,拋異常!
if (value == null) {
throw new NullPointerException();
}
Entry<?,?> tab[] = table;
//通過key 計算儲存下標
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
//迴圈遍歷陣列連結串列
//如果有相同的key並且hash相同,進行覆蓋處理
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
//加入陣列連結串列中
addEntry(hash, key, value, index);
return null;
}
put 方法中的 addEntry 方法,原始碼如下:
private void addEntry(int hash, K key, V value, int index) {
//新增修改次數
modCount++;
Entry<?,?> tab[] = table;
if (count >= threshold) {
//陣列容量大於擴容閥值,進行擴容
rehash();
tab = table;
//重新計算物件儲存下標
hash = key.hashCode();
index = (hash & 0x7FFFFFFF) % tab.length;
}
//將物件儲存在陣列中
Entry<K,V> e = (Entry<K,V>) tab[index];
tab[index] = new Entry<>(hash, key, value, e);
count++;
}
addEntry 方法中的 rehash 方法,原始碼如下:
protected void rehash() {
int oldCapacity = table.length;
Entry<?,?>[] oldMap = table;
//每次擴容為原來的 2n+1
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
//大於最大閥值,不再擴容
return;
newCapacity = MAX_ARRAY_SIZE;
}
Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
modCount++;
//重新計算擴容閥值
threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
table = newMap;
//將舊陣列中的資料複製到新陣列中
for (int i = oldCapacity ; i-- > 0 ;) {
for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
Entry<K,V> e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = (Entry<K,V>)newMap[index];
newMap[index] = e;
}
}
}
總結流程如下:
- 1、通過 key 計算物件儲存在陣列中的下標;
- 2、如果連結串列中有 key,直接進行新舊值覆蓋處理;
- 3、如果連結串列中沒有 key,判斷是否需要擴容,如果需要擴容,先擴容,再插入資料;
有一個值得注意的地方是 put 方法加了synchronized
關鍵字,所以,在同步操作的時候,是執行緒安全的。
3.2、get方法
get 方法根據指定的 key 值返回對應的 value。
get 流程圖如下:
開啟 HashTable 的 get 方法,原始碼如下:
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
//通過key計算節點儲存下標
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
同樣,有一個值得注意的地方是 get 方法加了synchronized
關鍵字,所以,在同步操作的時候,是執行緒安全的。
3.3、remove方法
remove 的作用是通過 key 刪除對應的元素。
remove 流程圖如下:
開啟 HashTable 的 remove 方法,原始碼如下:
public synchronized V remove(Object key) {
Entry<?,?> tab[] = table;
//通過key計算節點儲存下標
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
Entry<K,V> e = (Entry<K,V>)tab[index];
//迴圈遍歷連結串列,通過hash和key判斷鍵是否存在
//如果存在,直接將改節點設定為空,並從連結串列上移除
for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
modCount++;
if (prev != null) {
prev.next = e.next;
} else {
tab[index] = e.next;
}
count--;
V oldValue = e.value;
e.value = null;
return oldValue;
}
}
return null;
}
同樣,有一個值得注意的地方是 remove 方法加了synchronized
關鍵字,所以,在同步操作的時候,是執行緒安全的。
四、總結
總結一下 Hashtable 與 HashMap 的聯絡與區別,內容如下:
- 1、雖然 HashMap 和 Hashtable 都實現了 Map 介面,但 Hashtable 繼承於 Dictionary 類,而 HashMap 是繼承於 AbstractMap;
- 2、HashMap 可以允許存在一個為 null 的 key 和任意個為 null 的 value,但是 HashTable 中的 key 和 value 都不允許為 null;
- 3、Hashtable 的方法是同步的,因為在方法上加了 synchronized 同步鎖,而 HashMap 是非執行緒安全的;
儘管,Hashtable 雖然是執行緒安全的,但是我們一般不推薦使用它,因為有比它更高效、更好的選擇 ConcurrentHashMap,在後面我們也會講到它。
最後,引入來自 HashTable 的註釋描述:
If a thread-safe implementation is not needed, it is recommended to use HashMap in place of Hashtable. If a thread-safe highly-concurrent implementation is desired, then it is recommended to use java.util.concurrent.ConcurrentHashMap in place of Hashtable.
簡單來說就是,如果你不需要執行緒安全,那麼使用 HashMap,如果需要執行緒安全,那麼使用 ConcurrentHashMap。
HashTable 已經被淘汰了,不要在新的程式碼中再使用它。
五、參考
1、JDK1.7&JDK1.8 原始碼
2、部落格園 - 程式設計師趙鑫 - HashMap和HashTable到底哪不同?
作者:炸雞可樂
出處:www.pzblog.cn