從源碼角度簡析 Hashtable、HashMap 和 LinkedHashMap

注意:此文原文均摘自 Sun jdk

Hashtable 與 HashMap

不同點

先看類的定義——

這裏寫圖片描述

這裏寫圖片描述

除了接口的實現是相同的,我們可以看到繼承的類是不同的,我們不妨打開 Dictionary 抽象類看一下

這裏寫圖片描述

我們可以看到紅色箭頭指向的地方,大致翻譯一下就是 —— 注意:這個類已經過時了,新的實現應該去實現 Map 接口,而不是繼承這個類。所以事實上繼承或不繼承這個類並沒有多大影響,Hashtable 實現了 Map 接口 ——

這裏寫圖片描述

這裏寫圖片描述

我們可以看到,Dictionary 類中的方法在 Map 接口中是有相同意義的方法的。接下來就是 HashMap 的父類 AbstractMap 抽象類 ——

這裏寫圖片描述

翻譯過來就是:

此類提供 Map 接口的骨幹實現,以最大限度地減少實現此接口所需的工作。

要實現不可修改的映射,編程人員只需擴展此類並提供 `entrySet` 方法的實現即可,該方法將返回映射的映射關係 set 視圖。通常,返回的 set 將依次在 `AbstractSet` 上實現。此 set 不支持 `add` 或 `remove` 方法,其迭代器也不支持 `remove` 方法。

要實現可修改的映射,編程人員必須另外重寫此類的 `put` 方法(否則將拋出 `UnsupportedOperationException`),`entrySet().iterator()` 返回的迭代器也必須另外實現其 `remove` 方法。

按照 `Map` 接口規範中的建議,編程人員通常應該提供一個 void(無參數)構造方法和 map 構造方法。

此類中每個非抽象方法的文檔詳細描述了其實現。如果要實現的映射允許更有效的實現,則可以重寫所有這些方法。

此類是 Java Collections Framework 的成員。

所以 AbstractMap 就是實現了一些 Map 接口的方法,方便子類複用。

所以說,HashtableHashMap 在類結構上是基本沒有任何差異的,那麼具體的實現呢?
其一:Hashtable 的鍵值都不可爲空,而 HashMap 鍵值對皆可爲空 ——

這裏寫圖片描述

這裏寫圖片描述

其二:Hashtable 相比於 HashMap 線程更安全,因爲它所有的方法都添加了 synchronized 關鍵字(這裏筆者想提到一點就是,線程安全並不意味着在高併發的情況下就能夠得到正確的結果,畢竟它只是能保證任一時刻只有一個線程訪問而不是保證線程訪問的順序)。這裏筆者就不截圖了,大家可以戳一下 Hashtable 源碼查看一下。

其三:初始容量不同,假設我們並未在初始化 HashMapHashtable 指定自定義容量,那麼它們的初始化容量是多少呢?

這裏寫圖片描述

這裏寫圖片描述

Hashtable 初始化容量爲11,而 HashMap 初始化容量爲16(雖然 HashMap 無參構造函數中並未明顯顯示出來,但是註釋中已經透露)。

其四:擴容機制不同——這裏所指的擴容機制不同是指其擴容後的大小與原大小的比例不同,但是它們的觸發條件都是一樣的,噹噹前元素個數超過原大小的0.75倍時,將會擴容當前數組大小,源碼筆者在這裏就不展示了,我們可以通過以下 demo 來看到擴容效果 ——

HashMap 部分:

public static void main(String[] args) {
    Map<String, String> hashMap = new HashMap<>();
    Class<? extends Map> hashMapClass = hashMap.getClass();
    try {
        Field count = hashMapClass.getDeclaredField("table");
        count.setAccessible(true);
        hashMap.put("1", "1");
        hashMap.put("2", "1");
        hashMap.put("3", "1");
        hashMap.put("4", "1");
        hashMap.put("5", "1");
        hashMap.put("6", "1");
        hashMap.put("7", "1");
        hashMap.put("8", "1");
        hashMap.put("9", "1");
        hashMap.put("10", "1");
        hashMap.put("11", "1");
        hashMap.put("12", "1");
        System.out.println(((Object[]) count.get(hashMap)).length);
        hashMap.put("13", "1");
        System.out.println(((Object[]) count.get(hashMap)).length);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
}

輸出:
>> 16
>> 32

Hashtable 部分:

public static void main(String[] args) {
    Map<String, String> hashTable = new Hashtable<>();
    Class<? extends Map> hashTableClass = hashTable.getClass();
    try {
        Field count = hashTableClass.getDeclaredField("table");
        count.setAccessible(true);
        hashTable.put("1", "1");
        hashTable.put("2", "1");
        hashTable.put("3", "1");
        hashTable.put("4", "1");
        hashTable.put("5", "1");
        hashTable.put("6", "1");
        hashTable.put("7", "1");
        hashTable.put("8", "1");
        System.out.println(((Object[]) count.get(hashTable)).length);
        hashTable.put("9", "1");
        System.out.println(((Object[]) count.get(hashTable)).length);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
}

輸出:
>> 11
>> 23

其五:索引算法不同。我們知道,對於基於 Hash 算法的數據結構,索引算法是一道關鍵點,採用好的索引算法不僅能夠快速計算出索引位置,而且能夠避免 Hash 衝突——

HashMap 部分首先是將 key 的 hash 值進行再 hash ——

int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

接着,其索引算法基於上述 hash 值 index = (n - 1) & hash

Hashtabl 部分是 (key.hashCode & 0x7FFFFFFF) % tab.length

相同點

數據結構

解決完不同點,我們來看看相同點,共同實現的幾個接口就沒有什麼好介紹的了,我們來看看它們的數據結構,HashMapHashtable 都是基於哈希表實現的,那麼什麼是哈希表?

這裏寫圖片描述
這裏寫圖片描述

這裏寫圖片描述
這裏寫圖片描述

打開 HashMapHashtable 類我們都能看到一個數組,這兩個數組的實質是一樣的,畫一張圖會更清晰 ——

這裏寫圖片描述

HashMapHashtable 就是基於這樣的數組實現的。數組中的每個值實際上都是一個單鏈表,這個鏈表中的每個元素的 hash 值是相同的!hash 值也就對應數組中的索引!新進入的結點會放在表頭的位置!

這樣說可能還有點抽象,我們來舉個例子,假如我們 HashMapHashtable 的 hash 值計算方法就是元素的 key 對數組長度求餘(事實上肯定不是這樣的),即 hash == key % arrays.length(),數組初始長度爲5,現在我們需要插入5個鍵值對,代碼如下:

Map<Integer, String> hashtable = new Hashtable<Integer, String>();
hashtable.put(1, "張");
hashtable.put(2, "李");
hashtable.put(3, "王");
hashtable.put(4, "劉");
hashtable.put(5, "趙");

那麼 hashtable 會怎麼做呢?我們不妨查看一下 Hashtableput() 方法 ——

這裏寫圖片描述
這裏寫圖片描述
這裏寫圖片描述

put() 方法有兩個英文註釋已經解釋得很清楚了,第一步是確保 value 不爲空,否則拋出空指針異常;第二步是假設這個 key 已經存在 hashtable 中了,那麼就替換原來的 value;第三步就是 value 不爲空,並且是一個新的 key,那麼就添加到 hashtable 中我們可以看到 // Creates the new entry 下面的那四行代碼,可以看出來,先將數組索引位置的 entry 賦給了一個新的值,然後又創建一個指向該值的新的 entry,故新值就是新的表頭了。

對於鍵值對 (1, "張)",我們求出該鍵值對的 hash 值等於 1 % 5 等於4,同理 (2, "李")(3, "王")(4, "劉")(5, "趙)" 的 hash 值分別是3,2,1,0,它們經過的都是上述的第三步,最後得到的 hash 表如下 ——

這裏寫圖片描述

那現在加入我們再插入一個 (6, "周") 的鍵值對會怎樣呢?老規矩——先計算 hash 值,發現 hash 值爲1,於是對數組索引爲1的那個單鏈表進行遍歷,但是發現沒有 key 相等的鍵值對,於是也是進入第三步,插在了包含鍵值對爲 (4, "劉") 的那條單鏈表的頭部,如下圖 ——

這裏寫圖片描述

HashMapHashtableput() 方法同理,此處就不再擴展了。

索引算法

此處不做擴展,可見hashmap和hash算法研究

LinkedHashMap

首先查看類的定義 ——

這裏寫圖片描述

LinkedHashMap 是繼承自 HashMap 的。我們再看看官方文檔的描述 ——

這裏寫圖片描述
這裏寫圖片描述

注意:以上截圖只是節選部分重要文檔。

大致翻譯如下:

`Map` 接口的哈希表和鏈接列表實現,具有可預知的迭代順序。此實現與 `HashMap` 的不同之處在於,後者維護着一個運行於所有條目的雙重鏈接列表。此鏈接列表定義了迭代順序,該迭代順序通常就是將鍵插入到映射中的順序(插入順序)。注意,如果在映射中重新插入 鍵,則插入順序不受影響。(如果在調用 `m.put(k, v)` 前 `m.containsKey(k)` 返回了 true,則調用時會將鍵 k 重新插入到映射 m 中。)
提供特殊的構造方法來創建鏈接哈希映射,該哈希映射的迭代順序就是最後訪問其條目的順序,從近期訪問最少到近期訪問最多的順序(訪問順序)。這種映射很適合構建 LRU 緩存。調用 `put` 或 `get` 方法將會訪問相應的條目(假定調用完成後它還存在)。`putAll` 方法以指定映射的條目集迭代器提供的鍵-值映射關係的順序,爲指定映射的每個映射關係生成一個條目訪問。任何其他方法均不生成條目訪問。特別是,collection 視圖上的操作不影響底層映射的迭代順序。
可以重寫 `removeEldestEntry(Map.Entry)` 方法來實施策略,以便在將新映射關係添加到映射時自動移除舊的映射關係。

從以上文字我們節選出以下幾條信息:

  • LinkedHashMap 內部維護的是雙鏈表,且此鏈表定義了插入的順序
  • LinkedHashMap 通過特殊的構造方法,可以將輸出的值從按照插入的順序改成符合 LRU 的順序

LinkedHashMap 內部維護的是雙鏈表,且此鏈表定義了插入的順序

打開 LinkedHashMap 類,我們查看它的結點類型,如圖——

這裏寫圖片描述

我們可以看到,LinkedHashMap 的結點是繼承自 HashMap 結點的,我們再看下 HashMap 的結點 ——

這裏寫圖片描述

所以對於 LinkedHashMap 的結點來說,它是有三個指針的,而 LinkedHashMap 又沒有複寫父類的 put() 方法,所以說,相比於 HashMap 來說,LinkedHashMap 就是元素多了兩個指針,分別指向插入時前一個元素,和插入時後一個元素。當然,口說無憑,我們繼續查看 LinkedHashMap 類,類中有一個方法的名字看起來就很特別 ——

這裏寫圖片描述

源代碼很簡單我此處就不做擴展了。那麼何時會調用該方法呢?其實解決這個問題很簡單,自己對對象進行 debug 就可以了 ——

這裏寫圖片描述

此處爲了方便我是對第二個進行 put 的元素進行 debug 查看流程,接下來:

這裏寫圖片描述

進入父類的 put() 方法,再進入 putVal() 方法,再第二個 if 分支中進入 newNode() 方法中 ——

這裏寫圖片描述

關鍵時刻到了,此時由於 LinkedHashMap 重寫了父類 newNode() 方法,所以調用的就是 LinkedHashMapnewNode() 方法,也就是 java 中的多態。看到這裏也就不向下擴展了,我們可以看到第四行就是我們想要的答案 —— 調用了 linkNodeLast() 方法!有一句話叫做“一圖勝千言”,抽象理解完了 LinkedHashMap 的數據結構,我再上一張圖來加深印象,上圖之前也要配合代碼,代碼如下 ——

Map<Integer, String> map = new LinkedHashMap<Integer, String>();
map.put(1, "張");
map.put(2, "李");
map.put(3, "王");
map.put(4, "劉");
map.put(5, "趙");

這裏依然爲了簡單起見,我們假設 hashCode() 方法所求的 hash 值就是 key 對數組的長度求餘,也就是 keyhash == key % arrays.length()。數據結構圖如下 ——

這裏寫圖片描述

如果在此之後再插入一個鍵值對(6, 周) 又會怎樣呢?

這裏寫圖片描述

比起插入前,有以下幾點更改 ——

  • 第二個雙向鏈表的表頭變成了 (6, 周) 這個鍵值對
  • tail 尾結點指針指向了 (6, 周)
  • 原尾結點 (5, 趙) 的 after 指針從指向 null 變成了指向 (6, 周)

HashMap 通過特殊的構造方法,可以將輸出的值從按照插入的順序改成符合 LRU 的順序

這裏寫圖片描述

LinkedHashMap 共有五個構造函數,那麼上述中特殊的構造函數到底是指哪一個呢?答案就是最後一個,看構造函數上方的註釋,對於參數 accessOrder 的解釋如下:

the ordering mode   - <tt>true</tt> for access-order, <tt>false</tt> for insertion-order

翻譯成中文就是:

排序的模式,如果是 true 的話就是訪問順序,如果是 false 的話就是插入順序。

註釋已經說得很清楚了,那麼我們就不妨上代碼試驗一下,如果設置成 true 會是什麼樣的呢?代碼如下:

public class Test extends Inner {
    public static void main(String[] args) {
        Map<String, String> map = new LinkedHashMap<String, String>(16, .75F, true);
        map.put("1", "1");
        map.put("2", "2");
        map.put("3", "3");
        map.put("4", "4");
        map.forEach((k, v) -> System.out.println(k + "    " + v));
        System.out.println("--------------------");

        map.get("4");
        map.get("2");
        map.get("3");
        map.forEach((k, v) -> System.out.println(k + "    " + v));
    }
}

打印結果如下:

這裏寫圖片描述

我們可以發現,再調用了 get() 方法後,map 的輸出順序發生了改變,越是後調用的值越是越後輸出,我們不妨看一下 LinkedHashMapget() 方法源碼 ——

這裏寫圖片描述

在第五行,如果 accessOrder 的值爲 true 的話,我們就會進入 afterNodeAccess() 方法,繼續跟蹤進入該方法,源碼如下 ——

這裏寫圖片描述

我們可以很清楚的看到,原有尾結點會被設成 e 結點的 before 結點,而 e 結點又會被複制給 tail 結點變成新的尾結點,同時 e 結點的 after 結點賦空,所以最新使用的結點會在雙鏈表的最末端,而最久未使用的那個結點會存在雙鏈表的始端,所以如果在空間不足的情況下,就可以刪除前面的結點了,這就是 LRU 算法的思想。

發佈了41 篇原創文章 · 獲贊 81 · 訪問量 15萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章