前言
上一節我們實現了散列算法並對沖突解決我們使用了開放地址法和鏈地址法兩種方式,本節我們來詳細分析源碼,看看源碼中對於沖突是使用的哪一種方式以及對比我們所實現的,有哪些可以進行改造的地方。
Hashtable源碼分析
我們通過在控制台中實例化Hashtable並添加鍵值對實例代碼來分析背后究竟做了哪些操作,如下:
public static void main(String[] args) { Hashtable hashtable = new Hashtable(); hashtable.put(-100, "first"); }
接下來我們來看看在我們初始化Hashtable時,背后做了哪些准備工作呢?
public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable { //存儲鍵值對數據 private transient Entry<?,?>[] table; //存儲數據大小 private transient int count; //閾值:(int)(capacity * loadFactor).) private int threshold; //負載因子: 從時間和空間成本折衷考慮默認為0.75。因為較高的值雖然會減少空間開銷,但是增加查找元素的時間成本 private float loadFactor; //指定容量和負載因子構造函數 public Hashtable(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal Load: "+loadFactor); if (initialCapacity==0) initialCapacity = 1; this.loadFactor = loadFactor; table = new Entry<?,?>[initialCapacity]; //默認閾值為8 threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1); } //指定容量構造函數 public Hashtable(int initialCapacity) { this(initialCapacity, 0.75f); } //默認無參構造函數(初始化容量為11,負載因子為0.75f) public Hashtable() { this(11, 0.75f); } private static class Entry<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Entry<K,V> next; protected Entry(int hash, K key, V value, Entry<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } } }
Hashtable內部通過Entry數組存儲數據,通過Entry結構可看出采用鏈地址法解決哈希沖突,當初始化Hashtable未指定容量和負載因子時,默認初始化容量為11,負載因子為0.75,閾值為8,若容量小於0則拋出異常,若容量等於0則容量為1且閾值為0,否則閾值以指定容量*0.75計算或者以指定容量*指定負載因子計算為准。
通過如上源代碼和變量定義我們很快能夠得出如上結論,這點就不必我們再進行過多討論,接下來我們再來看看當我們如上添加如上鍵值對數據時,內部是如何做的呢?
public synchronized V put(K key, V value) { if (value == null) { throw new NullPointerException(); } Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; 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; }
我們一步步來分析,首先若添加的值為空則拋出異常,緊接着獲取添加鍵的哈希值,重點來了,如下代碼片段的作用是什么呢?
int index = (hash & 0x7FFFFFFF) % tab.length;
因為數組索引不可能為負值,所以這里通過邏輯與操作將鍵的哈希值轉換為正值,也就是本質上是為了保證索引為正值,那么 int index = (hash & 0x7FFFFFFF) % tab.length; 是如何計算的呢?0x7FFFFFFF的二進制就是1111111111111111111111111111111,由於是正數所以符號為0即01111111111111111111111111111111,而對於我們添加的值為-100,則二進制為11111111111111111111111110011100,將二者轉換為二進制進行邏輯加操作,最終結果為01111111111111111111111110011100,轉換為十進制結果為2147483548,這是我們講解的原理計算方式,實際上我們通過十進制相減即可,上述0x7FFFFFFF的十進制為2147483647,此時我們直接在此基礎上減去(100-1)即99,最終得到的也是2147483548。最后取初始容量11的模結果則索引為為1。如果是鍵的哈希值為正值那就不存在這個問題,也就是說通過邏輯與操作得到的哈希值就是原值。接下來獲取對應索引在數組中的位置,然后進行循環,問題來了為何要循環數組呢?也就是如下代碼片段:
for(; entry != null ; entry = entry.next) { if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; entry.value = value; return old; } }
上述是為了解決相同鍵值將對應的值進行覆蓋,還是不能理解?我們在控制台再加上一行如下代碼:
public static void main(String[] args) { Hashtable hashtable = new Hashtable(); hashtable.put(-100, "first"); hashtable.put(-100, "second"); }
如上我們添加的鍵都為-100,通過我們對上述循環源碼的分析,此時將如上第一行的值first替換為second,換言之當我們添加相同鍵時,此時會發生后者的值覆蓋前者值的情況,同時我們也可以通過返回值得知,若返回值為空說明沒有出現覆蓋的情況,否則有返回值,說明存在相同的鍵且返回被覆蓋的值。我們通過如下打印出來Hashtable中數據可得出,這點和C#操作Hashtable不同,若存在相同的鍵則直接拋出異常。
Enumeration keys = hashtable.keys(); while (keys.hasMoreElements()) { Object key = keys.nextElement(); String values = (String) hashtable.get(key); System.out.println(key + "------>" + values); }
還沒完,我們繼續往下分析如下代碼,將鍵值對添加到數組中去:
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++; }
在添加數據到存儲的數組中去時必然要判斷是否已經超過閾值,說到底就是為了擴容哈希表,接下來我們看看具體實現是怎樣的呢?
protected void rehash() { //獲取存儲數組當前容量 int oldCapacity = table.length; Entry<?,?>[] oldMap = table; // 新容量 = 當前容量*2 + 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; } } }
如上解釋已經非常清晰明了,接下來我們再在控制台添加如下代碼:
public static void main(String[] args) { Hashtable hashtable = new Hashtable(); hashtable.put(-100, "first"); hashtable.put(-100, "second"); hashtable.put("Aa", "third"); hashtable.put("BB", "fourth"); Enumeration keys = hashtable.keys(); while (keys.hasMoreElements()) { Object key = keys.nextElement(); String values = (String) hashtable.get(key); System.out.println(key + "------>" + values); } }
當我們添加如上兩行代碼,此時我們想想打印出的結果數據將是怎樣的呢?如下:
咦,好像發現一點問題,上述我們明明首先添加的鍵為Aa,難道首先打印出來的不應該是Aa嗎?怎么是鍵BB呢?不僅讓我們心生疑竇,主要是因為鍵Aa和鍵BB計算出來的哈希值一樣導致,不信,我們可打印出二者對應的哈希值均為2112,如下:
System.out.println("Aa".hashCode());
System.out.println("BB".hashCode());
接下來我們再來看看最終存放到數組里面去時,具體是怎么操作的呢?我們摘抄上述代碼片段,如下:
Entry<K,V> e = (Entry<K,V>) tab[index]; tab[index] = new Entry<>(hash, key, value, e);
問題就出在這個地方,在上一節我們講解散列算法為解決沖突使用鏈地址法時,我們是將鍵計算出來的相同哈希值添加到單鏈表的尾部,在這里剛好相反,這里采取的是將后續添加的放到單鏈表頭部,而已添加的則放到下一個引用。因為上述首先將已添加的鍵Aa對應的索引取出來,然后重新實例化存儲鍵BB的數據時,它的下一個即(next)指向的是Aa,所以才有了上述打印結果,這里需要我們注意下。那么為何要這么做呢?對比上一節我們的實現,主要是數據結構定義不同,上一節我們采用循環遍歷方式,但是在源碼中采用構造函數中賦值下一引用的方式,當然源碼的方式是性能最佳,因為免去了循環遍歷。好了,接下來我們再來看看刪除方法,我們在控制台繼續添加如下代碼:
hashtable.remove("Aa");
我們同時也對應看看源碼中刪除是如何操作的,源碼如下:
public synchronized V remove(Object key) { //定義存儲數組變量 Entry<?,?> tab[] = table; //計算鍵哈希值 int hash = key.hashCode(); //獲取鍵索引 int index = (hash & 0x7FFFFFFF) % tab.length; //獲取鍵索引存儲數據 Entry<K,V> e = (Entry<K,V>)tab[index]; 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; } //數組大小減1 count--; //返回刪除值 V oldValue = e.value; //要刪除值置為空 e.value = null; return oldValue; } } return null; }
通過上述對刪除操作的分析,此時我們刪除鍵Aa,此時單鏈表頭部鍵為BB,所以會進行下一循環,最后進入上述第二個if語句,若是刪除鍵BB,因為此時就存在單鏈表頭部,所以prev為空,進入else語句進行元素刪除操作。關於Hashtable源碼的分析到此結束,至於其他比如獲取鍵對應值或者鍵是否包含在存儲數組中比較簡單,這里就不再闡述。
總結
本節我們詳細分析了Hashtable源碼,Hashtable采用鏈地址法解決哈希沖突,同時當發生沖突時,將沖突數據存儲在單鏈表頭部,而已有數據作為頭部下一引用,Hashtable不允許插入任何空的鍵和值,方法通過關鍵字synchronized修飾得知Hashtable是線程安全的,同時默認初始化容量為11,負載因子為0.75f,負載因子定為0.75f的原因在於:若沖突或碰撞產生非常頻繁會減緩使用元素的操作,因為此時僅僅只知道索引是不夠的的,此時需要遍歷鏈表才能找到存儲的元素,因此,減少碰撞次數非常重要, 數組越大,碰撞的機會就越小,負載因子決定了陣列大小和性能之間平衡,這意味着當75%的存儲桶變為空時,數組大小會擴容,此操作由rehash()方法來執行。下一節我們進一步學習hashCode、equals以及hashCode計算原理,然后分析HashMap源碼,感謝您的閱讀,下節見。