Java——LinkedHashMap原始碼解析

Zhaoxi_Zhang發表於2018-12-12

以下針對JDK 1.8版本中的LinkedHashMap進行分析。 對於HashMap的原始碼解析,可閱讀Java——HashMap原始碼解析

概述

  雜湊表和連結串列基於Map介面的實現,其具有可預測的迭代順序。此實現與HashMap的不同之處在於它維護了一個包括所有條目(Entry)的雙向連結串列。相比於無序的HashMapLinkedHashMap迭代順序支援按插入條目順序或者按訪問條目順序,預設迭代順序為按插入順序。對於相同 key 的重複插入,其不會改變插入順序。

  此實現可以讓客戶端免受由HashMap(和Hashtable)提供的未指定的,通常是混亂的排序,而對於與TreeMap提供的預設根據鍵排序的功能相比,其效能成本會更小。使用它可以生成一個與原來順序相同的對映副本,而與原對映的實現無關:

void foo(Map m) {
    Map copy = new LinkedHashMap(m);
    ...
}
複製程式碼

如果模組通過輸入得到一個對映,複製這個對映,然後返回由此副本確定其順序的結果,這種情況下這項技術特別有用(客戶端通常期望返回的內容與其出現的順序相同)。

  LinkedHashMap提供一種特殊的構造方法來建立雜湊表,其迭代順序根據條目的訪問順序排序,從近期訪問最少到近期訪問最多的順序(訪問順序)。這種對映的迭代順序很適合構建 LRU Cache。呼叫putputIfPresentgetgetOrDefaultcomputecomputeIfAbsentcomputerIfPresent或者merge方法都算是對相應條目的訪問(假定呼叫完成後它還存在)。replace()方法只有在值被替換的情況下,才算是對條目的訪問。putAll方法以指定對映的條目集迭代器提供的鍵-值對映關係的順序,為指定對映的每個對映關係生成一個條目訪問。任何其他方法均不生成條目訪問。特別是,collection 檢視上的操作不 影響底層對映的迭代順序。

  可以重寫removeEldestEntry(Map.Entry)方法來實施策略,以便在將新的條目新增到雜湊表時,如果超過指定容量,自動移除舊的條目,這在實現 LRU Cahce的時候將非常有用。

  這個類提供了所有可選的Map的操作,並且允許null元素。和HashMap一樣,假定雜湊函式將元素均勻分佈到各個桶中,對於基本操作如addcontainsremove,其提供了常數時間的效能。由於增加了維護連結串列的開支,其效能很可能比HashMap稍遜一籌,不過有一點是例外的:LinkedHashMap的 collection 檢視迭代所需時間與對映的大小(size)成比例,而與容量(capacity)無關;HashMap迭代時間很可能開支較大,因為它所需要的時間與其容量(capacity)成比例。

  LinkedHashMap有兩個因子影響著其效能:初始容量負載因子。它們的定義與HashMap完全相同。要注意,為初始容量選擇非常高的值對此類的影響比對HashMap要小,因為此類的迭代時間不受容量的影響。

  **值得注意的是,這個類對於Map介面都不是同步的。**如果多個執行緒併發的訪問一個雜湊表,並且至少有一個執行緒對這個雜湊表進行結構性更改,那麼必須增添額外的同步操作。這一般通過對自然封裝該對映的物件進行同步操作來完成。如果不存在這樣的物件,則應該使用Collections.synchronizedMap方法來“包裝”該雜湊表。最好在建立時完成這一操作,以防止對雜湊表的意外的非同步訪問:Map m = Collections.synchronizedMap(new LinkedHashMap(...));

  對於結構性更改指任何新增或者刪除一個或者多個條目,或者在按訪問順序的雜湊表中影響迭代順序的任何操作。在按插入順序的雜湊表中,僅更改已存在的 key 對應的 value 值不是結構性修改。在按訪問順序的雜湊表中,僅利用get查詢不是結構性修改。)

  Collection(由此類的所有 collection 檢視方法所返回)的 iterator 方法返回的迭代器都是快速失敗的:在迭代器建立之後,如果從結構上對對映進行修改,除非通過迭代器自身的remove方法,其他任何時間任何方式的修改,迭代器都將丟擲ConcurrentModificationException。因此,面對併發的修改,迭代器很快就會完全失敗,而不冒將來不確定的時間發生任意不確定行為的風險。

  注意,迭代器的快速失敗行為無法得到保證,因為一般來說,不可能對是否出現不同步併發修改做出任何硬性保證。快速失敗迭代器會盡最大努力丟擲 ConcurrentModificationException。因此,為提高這類迭代器的正確性而編寫一個依賴於此異常的程式是錯誤的做法:迭代器的快速失敗行為應該僅用於檢測 bug。

原始碼分析

建構函式

/**
 * 根據指定的初始容量和負載因子,初始化一個空的按照插入順序排序的 LinkedHashMap 的例項
 */
public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor);
    accessOrder = false;
}

/**
 * 根據指定的容量和預設的負載因子(0.75),初始化一個空的按照插入順序排序的 LinkedHashMap 的例項
 */
public LinkedHashMap(int initialCapacity) {
    super(initialCapacity);
    accessOrder = false;
}

/**
 * 根據預設的容量(16)和負載因子(0.75),初始化一個空的按照插入順序排序的 LinkedHashMap 例項
 */
public LinkedHashMap() {
    super();
    accessOrder = false;
}

/**
 * 初始化一個根據傳入的對映關係並且按照插入順序排序的 LinkedHashMap 的例項
 * 這個 LinkedHashMap 例項的負載因子為0.75,容量不小於指定的對映關係的數量的最小2次冪
 */
public LinkedHashMap(Map<? extends K, ? extends V> m) {
    super();
    accessOrder = false;
    putMapEntries(m, false);
}

/**
 * 根據指定的容量、負載因子、排序模式來初始化一個空的 LinkedHashMap 的例項
 */
public LinkedHashMap(int initialCapacity,
                     float loadFactor,
                     boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}
複製程式碼

  從上面的建構函式可以看出來:accessOrder = false,如果沒有特別指定排序模式,那麼其將按照插入順序來作為迭代順序。

三個重要的回撥函式

HashMap原始碼中,預留了三個回撥函式,來讓LinkedHashMap進行後期操作:

// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
複製程式碼

LinkedHashMap中,這三個函式實現如下:

//移除節點的時候會觸發回撥,將節點從雙向連結串列中刪除,在呼叫 removeNode 函式時候會執行
void afterNodeRemoval(Node<K, V> e) { // unlink
    LinkedHashMap.Entry<K, V> p =
        (LinkedHashMap.Entry<K, V>)e, b = p.before, a = p.after;
    p.before = p.after = null;
    if (b == null)
        head = a;
    else
        b.after = a;
    if (a == null)
        tail = b;
    else
        a.before = b;
}

//新節點插入時會觸發回撥,根據條件判斷是否移除最老的條目,在呼叫 compute computeIfAbsent merge putVal 函式時候會實行
//實現 LruCache 的時候會用到這個函式
void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K, V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

//將節點放置連結串列尾,在呼叫 putVal 函式時會執行,保證最近訪問節點在連結串列尾部
void afterNodeAccess(Node<K, V> e) { // move node to last
    LinkedHashMap.Entry<K, V> last;
    //accessOrder為 true表示按照訪問順序排序,並且此時的鍵值對不在連結串列尾部
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K, V> p =
            (LinkedHashMap.Entry<K, V>)e, b = p.before, a = p.after;
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            last = b;
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}
複製程式碼

從上面三個回撥函式可以看出,其主要是在對條目進行操作的時候觸發來維護雙向連結串列。另外值得一提的是afterNodeInsertionremoveEldestEntry函式,在構建 LruCache 時將非常有用。對於removeEldestEntry,其預設返回false,因此預設情況下不會刪除最舊的元素:

/**
 * @param    eldest 雜湊表中最近插入的條目,或者如果迭代順序是按照訪問順序排序,則是最近最少訪問的條目。
 *                  如果這個方法返回 true,則這是將被刪除的條目。如果在 put 或 putAll 呼叫之前雜湊表為空時,觸發此呼叫,
 *                  則這將是剛插入的條目;換句話說,如果雜湊表包含單個條目,則最老的條目也是最新的。
 * @return   返回 true 表明將刪除最老的條目
 */
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return false;
}
複製程式碼

如果需要刪除最舊條目,則返回true。在將新條目插入後,putputAll將呼叫此方法。它為實現者提供了在每次新增新條目時刪除最舊條目的機會。如果用來實現快取,則此選項非常有用:它允許雜湊表通過刪除過時條目來減少記憶體消耗。 示例使用:重寫這個函式實現,以下例子將允許在增長到100個條目時,然後在每次新增新條目時刪除最舊的條目,保持100個條目的穩定狀態。

private static final int MAX_ENTRIES = 100;
protected boolean removeEldestEntry(Map.Entry eldest) {
   return size() > MAX_ENTRIES;
}
複製程式碼

此方法通常不通過重寫來修改雜湊表,而是通過返回值來判斷是否對雜湊表進行修改。當然,此方法允許直接修改雜湊表,但如果它這樣做,則必須返回false(表示雜湊表不應嘗試任何進一步的修改)。如果在此方法中修改雜湊表後返回 true,那麼對於結果是未指定。

儲存

  LinkedHashMap直接使用了HashMapput函式,但重寫了newNodeafterNodeAccessafterNodeInsertion方法。

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    //將節點放置連結串列尾部
    linkNodeLast(p);
    return p;
}

// 將新增節點放置連結串列尾部
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}
複製程式碼

刪除

  同樣的,LinkedHashMap仍然直接使用了HashMapremove函式,只是對afterNodeRemoval回撥函式進行了重寫。對於afterNodeRemoval函式上面已經分析過了。

查詢

/**
 * 返回指定 key 所對應的 value 值,當不存在指定的 key 時,返回 null。
 *
 * 當返回 null 的時候並不表明雜湊表中不存在這種關係的對映,有可能對於指定的 key,其對應的值就是 null。
 * 因此可以通過 containsKey 來區分這兩種情況。
 */
public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}
複製程式碼

  與HashMap相比,其多了一步對 accessOrder 的判斷來維護連結串列,當指定迭代順序按照訪問順序排序時,get操作表明對指定的條目進行了一次訪問,那麼此條目應該移到連結串列尾部。對於afterNodeAccess在上面已經分析過了,值得注意的是,在呼叫afterNodeAccess時,會修改 modeCount,所以當你正在accessOrder = true的模式下迭代LinkedHashMap時,如果同時查詢訪問資料,會導致 fail-fast,因為迭代的順序已經變了。

其他

  對於LinkedHashMap其與HashMap還有一些不同,由於LinkedHashMap維護一個雙向連結串列,因此在判斷雜湊表中是否儲存著某個鍵值對的時候,不需要在整個陣列桶中查詢,而只需要對連結串列遍歷即可,這也是LinkedHashMap的其中一處優化。

public boolean containsValue(Object value) {
    for (LinkedHashMap.Entry<K, V> e = head; e != null; e = e.after) {
        V v = e.value;
        if (v == value || (value != null && value.equals(v)))
            return true;
    }
    return false;
}
複製程式碼

實現 LruCache

在 LeetCode 有一道題——Lru Cache:設計和實現一個 LRU (最近最少使用) 快取機制,那麼就可以利用LinkedHashMap可選的迭代順序——按訪問順序的模式來進行實現:

class LRUCache {
    private int capacity;
    private Map<Integer, Integer> cache;
    
    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.cache = new java.util.LinkedHashMap<Integer, Integer> (capacity, 0.75f, true) {
            protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
                return size() > capacity;
            }
        };
    }
    
    public int get(int key) {
        if (cache.containsKey(key)) {
            return cache.get(key);
        } else
            return -1;
    }
    
    public void put(int key, int value) {
        cache.put(key, value);
    }
}

/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache obj = new LRUCache(capacity);
 * int param_1 = obj.get(key);
 * obj.put(key,value);
 */
複製程式碼

當然,如果覺得直接使用LinkedHashMap的方式太過取巧,我們仍可以借鑑LinkedHashMap的思想來進行實現——使用 HashMap 和 雙向連結串列 的組合來實現:

class LRUCache {
    class Node{
        Integer key;        
        Integer value;
        Node prev;
        Node next;

        public Node(Integer key, Integer value){
            this.key = key;
            this.value = value;
        }
    }

    private Map<Integer, Node>map;
    Node head;
    Node tail;
    int size;

    public LRUCache(int capacity) {
        size = capacity;
        map = new HashMap<>(capacity);
        head = new Node(null, null);
        tail = new Node(null, null);

        head.next = tail;
        tail.prev = head;
    }
    
    public int get(int key) {
        Node node = map.get(key);
        if (null != node){
            map.remove(node.key);

            node.prev.next = node.next;
            node.next.prev = node.prev;

            appendTail(node);
            map.put(key, node);
        }

        int value = null == node ? -1 : node.value;
        return value;
    }
    
    public void put(int key, int value) {
        Node node = map.get(key);
        if (null != node){
            map.remove(node.key);

            node.prev.next = node.next;
            node.next.prev = node.prev;

            node.value = value;
        }else if (map.size() == size){
            Node tmp = head.next;
            map.remove(tmp.key);

            head.next = tmp.next;
            tmp.next.prev = head;

            tmp = null;
        }

        if (null == node)   node = new Node(key, value);
        appendTail(node);
        map.put(key, node);
    }

    public void appendTail(Node node){
        tail.prev.next = node;
        node.prev = tail.prev;
        node.next = tail;
        tail.prev = node;
    }
}

/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache obj = new LRUCache(capacity);
 * int param_1 = obj.get(key);
 * obj.put(key,value);
 */
複製程式碼

相關文章