Java入門系列之集合Hashtable源碼分析(十一)

前言

上一節咱們實現了散列算法並對衝突解決咱們使用了開放地址法和鏈地址法兩種方式,本節咱們來詳細分析源碼,看看源碼中對於衝突是使用的哪種方式以及對比咱們所實現的,有哪些能夠進行改造的地方。java

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);
        }

還沒完,咱們繼續往下分析以下代碼,將鍵值對添加到數組中去:this

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源碼,感謝您的閱讀,下節見。

相關文章
相關標籤/搜索