Map 源碼閱讀

Map學習

Map是java中存儲key-value數據的容器類,按照慣例我們將從最底層的Map接口開始學習,瞭解其該擁有哪些基本的方法。

一 頂層接口

1. Map接口

先從接口註釋學起,

  • Map是一個存儲key-value映射關係的容器,key不可重複,keyvalue是一一對應的關係。

  • 可返回key集合、value集合、及映射關係集合,返回順序取決於迭代器,或者根據子類特殊指定的順序。

  • Map對象本身不能當做key,但可以作爲value

public interface Map<K,V> {
    // 最大返回值也只能是Integer.MAX_VALUE
	int size();
    boolean isEmpty();
    // key 爲null會報異常,key類型符合要也會拋ClassCastException
    boolean containsKey(Object key);
    boolean containsValue(Object value);
    // 增刪改查的邏輯
    V get(Object key);
    V put(K key, V value);
	V remove(Object key);
    void putAll(Map<? extends K, ? extends V> m);
    void clear();
    // 三個視圖方法
    Set<K> keySet();
    Collection<V> values();
	Set<Map.Entry<K, V>> entrySet();
    
    boolean equals(Object o);
    int hashCode();
}

以上是Map中擁有的基本方法,從中可以看出最初的設計思路,

  • 作爲一個容器類,需要考慮該容器存儲什麼類型的數組,這決定了增刪改查方法的設計。
  • 還需要對提供容器的判空、是否包含指定元素、容器之間的比較等方法。
  • 最後容器類不僅關心容器內元素的進出,更需要考慮容器作爲一個整體如何對外提供容器內數據展示,這就是三個視圖方法的目的。

注意到上面entrySet方法返回的是一個Entry對象,這是一個鍵值對。

interface Entry<K,V> {
	K getKey();   
    V getValue();
    V setValue(V value);
    boolean equals(Object o);
    int hashCode();
}    

當然java8 在Map接口中新增了很多有默認實現的方法,這些方法在不修改子類的情況下擴展了Map的能力,下面將挑部分進行講解。

getOrDefault

這是可以設置默認值的查詢方法,當未找到key對於的value時返回默認值,而不是null

	default V getOrDefault(Object key, V defaultValue) {
        V v;
        return (((v = get(key)) != null) || containsKey(key))
            ? v
            : defaultValue;
    }

forEach

內部遍歷map,並對每個元素執行action的處理,

	default void forEach(BiConsumer<? super K, ? super V> action) {
        Objects.requireNonNull(action);
        for (Map.Entry<K, V> entry : entrySet()) {
            K k;
            V v;
            try {
                k = entry.getKey();
                v = entry.getValue();
            } catch(IllegalStateException ise) {
                // this usually means the entry is no longer in the map.
                throw new ConcurrentModificationException(ise);
            }
            action.accept(k, v);
        }
    }

以上默認方法的實現都依賴於,接口中基礎抽象方法,相當於把在外部經常使用map的一些模式提出來加入到Map中,方便了使用。

以前是磚塊決定了樓層的搭建,現在是樓層搭建方式改變了磚塊。

2.AbstractMap抽象類

AbstractMap中提供了Map接口中大部分的默認實現,但都依賴entrySet()方法獲取kv集合。

二 具體實現

1.HashMap

先從註釋看起

  • 非線程安全,允許key 或 value爲null,不保證返回順序。

  • loadFactor 是加載因子,默認爲0.75 該變量意義就是
    >loadFactor 實際數組大小>數組容量*loadFactor
    就會分配進行擴容。

  • 如果開始就有很多數據需要存儲,則最好初始化HashMap時就指定一個較大的容量。
  • 可以通過Collections中的synchronizedMap方法得到一個線程安全的Map。
  • 和collection集合類一樣,在迭代時發生結構性變化會終止迭代,拋出ConcurrentModificationException

HashMap主要做的工作提供合理的計算哈希值的函數,且儘可能減少哈希衝突概率。這樣就能使元素均勻散列在不同的bucket上,最大化體現複合型數據結構的優勢。

存儲結構

通過查看類中的成員變量我們可以快速瞭解HashMap的存儲結構。

public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {
    // 默認初始化容量爲16
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    // 哈希表最大尺寸
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默認裝填因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 當一個桶上鍊表長度大於8時會轉換成紅黑樹
    static final int TREEIFY_THRESHOLD = 8;
    // 當一個桶上紅黑樹小於等於6時會轉換爲鏈表
    static final int UNTREEIFY_THRESHOLD = 6;
    // 當數組容量大於等於64時,纔會將桶中的符合條件的鏈表轉換爲紅黑樹
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 實際存放數據的數組
    transient Node<K,V>[] table;
    // 鍵值對的副本
    transient Set<Map.Entry<K,V>> entrySet;
    // size實際大小
    transient int size;
    // 修改次數  版本號
    transient int modCount;
    // 數組擴容的閥值
    int threshold;
    // 裝填因子
    final float loadFactor;
	
}

打臉了,看了上面的代碼也不清楚HashMap是怎麼存儲數據的,數據結構中的哈希表使用哈希算法計算得出索引,基本方式是對數組容量取餘,那就看下hash算法源碼。

hash算法

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 得出
index = (n-1) & (h = key.hashCode()) ^ (h >>> 16)

hash算法就是根據hash值生成一個數組索引。

index = node.hashCode % n

這種方式並不高效,採用實際採用的如下方式:

index = (n - 1) & hash(key);

首先我們的hash數組的length一定是2的冪,假設hashCode是51(0011 0011) 數組長度爲16(0001 0000);由於length是2的冪則其二進制表示中肯定只有一個1,如:0000 1000 ,hashCode中高於這個1的位置的數據都能整除length,低於這個1的部分就是餘數。

0011 0011 = 51
0001 0000 = 16
————————————————
0000 0011 = 3

通過位運算可以保留餘數部分

0011 0011 & 0000 1111 = 0000 0011
而
0000 1111 = 0001 0000 - 0000 0001

所以 index = hashCode & (n-1) n爲2的冪,得到了求餘的方式。

上面根據取餘的方式計算索引有個問題,當只有大於length的高位在變化時,求得的餘數都是一樣的。

即高位的變化影響不到餘數,導致的結果就是衝突概率很高。

求解hash的部分,解決辦法就是右移16位再與自身進行與運算

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

求解hash的部分,解決辦法就是右移16位再與自身進行與運算,hashCode 是32位的,右移16位將

高位數據和低位數據拉到了一起,進行與運算產生最終的hashCode。

舉例
hashCode1 = 0101 0011 = 83
hashCode2 = 0001 0011 = 19
n = 0001 0000 = 16
index1 = hashCode1 & n-1 = 0101 0011 & 0000 1111 = 0011 = 3
index2 = hashCode2 & n-1 = 0001 0011 & 0000 1111 = 0011 = 3
# 此處我們實例使用8位,所以用4,。
hashCode1 = hashCode1 ^ (hashCode1 >>> 4) = 0101 0011 ^ 0000 0101 = 0101 0110
hashCode2 = hashCode2 ^ (hashCode2 >>> 4) = 0001 0011 ^ 0000 0001 = 0001 0010

index1 = hashCode1 & n-1 = 0101 0110 & 0000 1111 = 0000 0110 = 6
index2 = hashCode2 & n-1 = 0001 0010 & 0000 1111 = 0000 0010 = 2

經過以上算法處理,產生的hash索引分到了不同的位置。

小結:

(1)原始hash值與無符號右移一半位置的hash值做異或操作,得到結合高低位的新hash值。

(2)新hash值與n-1 做與操作得到最終索引。

插入節點

在插入流程基本可以看到HashMap的存儲結構。

putVal

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    // tab是節點數組,i爲hash對應下標,p爲下標爲i的位置對應節點,
    // i下標可能存在多個節點,p會依次向下變動
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //1.判斷節點數組長度,如果爲空或者length爲0,則進行初始化。
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //2.判斷hash值對應座標是否爲空,如果爲空則創建新節點並賦值。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
    //3.不爲空則說明座標衝突,則先判斷hash值在進行key比較。
        // e是與傳入key相同的節點(暫定),k是相同節點對應key
        Node<K,V> e; K k;
        if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
    	//4.判斷節點是不是樹節點,是則走樹節點插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        //5.循環遍歷鏈表節點,尾部爲null時插入新節點
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 6.如果鏈表節點長度大於等於8則將其鏈表轉換爲樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //7. 如果找到匹配節點則中斷遍歷。
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                //8. 沒有找到匹配節點,p則向下移動
                p = e;
            }
        }
        //9. 找到匹配節點e,則返回舊節點值。
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 查詢到節點後的回調方法。
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //10. 前面兩個分支,當p節點不爲空則前面就返回了。
    // 當p節點爲null時,插入了新節點(插入了數組中),對原有存儲結構進行了變動(尺寸變動)。
    ++modCount;
    //11. 當數組尺寸大於擴容閥值,則觸發數組擴容。
    if (++size > threshold)
        resize();
    // 在數組中插入元素後的回調方法
    afterNodeInsertion(evict);
    return null;
}

小結一下:

  1. 先判hashmap是否初始化過,否則進行首次擴容初始化。
  2. 判斷Key的hash值計算得出的數組下標是否爲null,如果爲null則創建新節點插入數組。
  3. 如果不爲空,則說明和已有節點索引衝突,需要將該節點插入到衝突節點字鏈表中或子樹中。
  4. 如果是樹結構,走單獨插入邏輯。
  5. 如果是鏈表,則向下匹配,未找到則插入到鏈表末尾;判斷鏈表長度,大於等於8則將其轉換爲樹。
  6. 找到則暫存遊標終止遍歷,根據onlyIfAbsent決定是否覆蓋,最後返回找到節點的舊值。
  7. 如果在數組中插入了新節點,需要判斷是否進行擴容。

插入元素涉及三種數據結構,節點數組、節點鏈表、節點紅黑樹。插入元素先在數組中插入,下標有衝突則考慮插入鏈表中,鏈表長度>=8則轉化爲紅黑樹。

學習的小冊中找到一張圖,可以清晰的展示存儲結構。

在這裏插入圖片描述

putTreeVal

普通插入算法中,如果是槽內根節點是紅黑樹,則需要走紅黑樹插入邏輯,putTreeVal是TreeNode中的方法,代碼如下:

/**
* map 是要插入節點的hashmap
* tab 是存儲節點的槽數組
* h k v 是要插入節點的hash值、key 和 value
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {
    Class<?> kc = null;
    // 標記是否已經便利過一次樹
    boolean searched = false;
    // 首先知道根節點
    TreeNode<K,V> root = (parent != null) ? root() : this;
    // 自旋
    for (TreeNode<K,V> p = root;;) {
        int dir, ph; K pk;
        // p是遍歷節點,ph是p節點hash值,dir是方向
        if ((ph = p.hash) > h)
            dir = -1;// 小於0說明要插入的h應該在p的左側
        else if (ph < h)
            dir = 1;// 大於0說明要插入的h應該在p的右側
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p;// 匹配說明找到替換的節點了p,外層方法進行值替換操作。滿足hash值相等和key相等兩個條件
        else if ((kc == null &&
                (kc = comparableClassFor(k)) == null) ||
                (dir = compareComparables(kc, k, pk)) == 0) {
            // 此時兩種情況:(1)k沒有實現comparable接口,(2)k實現了comparable接口並且和p節點pk相同。
            // serarched標記p節點是否被遍歷過,沒有遍歷則進入遞歸遍歷邏輯。
            if (!searched) {
                // q是子樹中找到的節點,ch是p的子節點。
                TreeNode<K,V> q, ch;
                searched = true;
                // 紅黑樹是平衡二叉樹,這邊使用短路邏輯,先從左側遍歷,再從右側遍歷,先找到先終止。
                if (((ch = p.left) != null &&
                     (q = ch.find(h, k, kc)) != null) ||
                   ((ch = p.right) != null &&
                     (q = ch.find(h, k, kc)) != null))
                    return q;
            }
            // 沒找到可替換節點,需要進行插入邏輯,這裏計算從哪邊開始插入。
            dir = tieBreakOrder(k, pk);
        }
		// 這裏首先根據插入方向dir對p進行更新,如果爲空則進行新節點插入
        TreeNode<K,V> xp = p;
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            Node<K,V> xpn = xp.next; //獲取更新前p節點的next
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);// 創建新的樹節點
            if (dir <= 0)
                xp.left = x; 
            else
                xp.right = x;
            xp.next = x;
            x.parent = x.prev = xp;
            if (xpn != null)
                ((TreeNode<K,V>)xpn).prev = x;
            //balanceInsertion 對紅黑樹進行着色或旋轉,以達到更多的查找效率,着色或旋轉的幾種場景如下
            //着色:新節點總是爲紅色;
            //如果新節點的父親是黑色,則不需要重新着色;
            //如果父親是紅色,那麼必須通過重新着色或者旋轉的方法,再次達到紅黑樹的5個約束條件
            //旋轉: 父親是紅色,叔叔是黑色時,進行旋轉
            //如果當前節點是父親的右節點,則進行左旋
            //如果當前節點是父親的左節點,則進行右旋
            moveRootToFront(tab, balanceInsertion(root, x));// 重新平衡
            return null; // 返回null 說明插入了操作產生了一個新的節點。
        }
    }
}

擴容算法

上面流程中涉及到了數組的擴容,這裏也學習一下。擴容需要先了解下需要擴多大,

  1. 初次擴容使用tableSizeFor方法就是用於計算容量,得出的值總是2的冪。

tableSizeFor

計算容量,返回的是結果肯定是2的冪。其中右移後進行或操作。這個方法可以計算出大於等於cap 的最近的那個2的冪。

	static final int tableSizeFor(int cap) {
        int n = cap - 1; // 假設n = 9 = 00001001
        n |= n >>> 1;// n = 00001001 | 00000100 = 00001101 = 13
        n |= n >>> 2;// n = 00001101 | 00000011 = 00001111 = 15
        n |= n >>> 4;// n = 00001111 | 00000000 = 00001111 = 15
        n |= n >>> 8;// ... 
        n |= n >>> 16;// ...
        // 最後在對 n+1 得到 16
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
  1. 使用resize()方法進行擴容,
final Node<K,V>[] resize() {
    // 複製舊有的Node數組
    Node<K,V>[] oldTab = table;
    // 舊容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 舊閥值
    int oldThr = threshold;
    // 新容量及閥值
    int newCap, newThr = 0;
    // 非初次賦值
    if (oldCap > 0) {
        // 如果舊容量已經超過了最大值則,就只修改閥值到最大。
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果新容量和閥值都是舊容量和閥值的兩倍,<<1 
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        // oldCap = 0 但 oldThr > 0 ,這裏應該是減小容量的邏輯。
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 如果oldCap=0則說明未初始化。
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 使用裝填因子*默認容量得到,初次擴容閥值。
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        // 這裏對應了oldCap = 0 但 oldThr > 0的情況,上面的邏輯沒有對newThr賦值。
        float ft = (float)newCap * loadFactor;
        // 設置新的擴容閥值。
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
    }
    // 修改總體閥值
    threshold = newThr;
    // 創建新的容量的數組,並修改總體table。
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 如果原有節點數組不爲空,則需要複製舊數據到新數組。
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 遍歷獲取元素並判空,源碼中有很多相似邏輯
            if ((e = oldTab[j]) != null) {
                // 清空數組對應位置。
                oldTab[j] = null;
                if (e.next == null)
                    // 如果j位置只有一個節點(不是鏈表或樹)
                    // 將舊節點填充到新數組相應位置
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 如果節點是樹節點,將樹節點拆分填充到新數組中
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 如果節點是鏈表
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        // 取到節點的下一個元素
                        next = e.next;
                        // oldCap是2的冪,所以只有一位是1其他位都是0
                        // 這個算法將一個長鏈表拆分再連爲兩部分。
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 頭指針只賦值一次
                                loHead = e;
                            else
                                // 尾指針賦值
                                loTail.next = e;
                            // 尾指針移動
                            loTail = e;
                        }
                        else {
                            // 
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 重新對桶賦值,使用剛拆分出來的低位鏈表。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 對擴充後j + oldCap 的桶賦值,使用剛拆封出來的高位鏈表。
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

2.TreeMap

TreeMap是基於紅黑樹實現的,確保了其containsKey、get、put、remove 等操作的複雜度都是log(n)

public class TreeMap<K,V> extends AbstractMap<K,V> implements NavigableMap<K,V>, Cloneable, java.io.Serializable{
    // 可通過構造函數傳入比較器,決定了map中key的順序,否則使用元素原生的比較器。
    private final Comparator<? super K> comparator;
    // 紅黑樹根節點
    private transient Entry<K,V> root;
    // map內元素個數
    private transient int size = 0;
    // 結構性修改次數
    private transient int modCount = 0;
    // TreeMap的視圖結構
    private transient EntrySet entrySet;
    private transient KeySet<K> navigableKeySet;
    private transient NavigableMap<K,V> descendingMap;
}

小結:

  1. TreeMap中key的順序取決於比較器,優先使用外部傳入比較器,其次使用key自帶的比較方法。
  2. TreeMap實現了NavigableMap接口,擁有一系列導航定位的方法。
  3. TreeMap實現了Cloneable接口和Serializable接口,可以被序列化和克隆。

存儲結構

既然是紅黑樹,這裏看下節點結構。

static final class Entry<K,V> implements Map.Entry<K,V> {
        K key;// 當前節點key
        V value;// 當前節點value
        Entry<K,V> left; 
        Entry<K,V> right;
        Entry<K,V> parent;
        boolean color = BLACK;// 節點默認顏色爲黑色
}

紅黑樹是一種含有紅黑結點並能自平衡的二叉查找樹。它必須滿足下面性質:

  • 性質1:每個節點要麼是黑色,要麼是紅色。
  • 性質2:根節點是黑色。
  • 性質3:每個葉子節點(NIL)是黑色。
  • 性質4:每個紅色結點的兩個子結點一定都是黑色。
  • 性質5:任意一結點到每個葉子結點的路徑都包含數量相同的黑結點。

插入節點

put方法會將新節點插入樹中,如有相同key則進行替換操作並返回舊value,如果是新增節點則返回null。

public V put(K key, V value) {
    // 1.根節點如果爲空則創建新節點
    Entry<K,V> t = root;
    if (t == null) {
        compare(key, key); // 確保key不能爲null
        root = new Entry<>(key, value, null);
        size = 1;
        modCount++;
        return null;
    }
    int cmp;
    Entry<K,V> parent;
    // split comparator and comparable paths
    Comparator<? super K> cpr = comparator;
    // 2.如果TreeMap自帶比較器則使用自帶比較器進行比較。
    if (cpr != null) {
        do { // 自旋遍歷紅黑樹,依次進行比較,parnent 記錄了未匹配到key,終止循環時的父節點
            parent = t;
            cmp = cpr.compare(key, t.key);
            if (cmp < 0)
				// key 小於t.key則說明匹配位置應該在左子樹上
                t = t.left;
            else if (cmp > 0)
                // key 大於t.key則說明匹配位置應該在右子樹上
                t = t.right;
            else
                // key相同則進行替換賦值
                return t.setValue(value);
        } while (t != null);
    }
    else {
        // 使用元素自帶比較器進行遍歷
        if (key == null)
            throw new NullPointerException();
        @SuppressWarnings("unchecked")
        Comparable<? super K> k = (Comparable<? super K>) key;
        do {
            parent = t;
            cmp = k.compareTo(t.key);
            if (cmp < 0)
                t = t.left;
            else if (cmp > 0)
                t = t.right;
            else
                return t.setValue(value);
        } while (t != null);
    }
    // 2.此時說明樹中沒有key相同節點,創建新節點。
    Entry<K,V> e = new Entry<>(key, value, parent);
    // 在最後的父節點中插入新節點
    if (cmp < 0)
        parent.left = e;
    else
        parent.right = e;
    // 插入節點後進行旋轉着色操作,維護平衡性。
    fixAfterInsertion(e);
    size++;
    modCount++;
    return null;
}

小結:

  1. 首先是查找過程,利用紅黑樹特性快速查找與key匹配的節點,找到匹配節點則替換新值返回舊值,未找到則記錄了可供插入的葉子節點的父節點(子節點爲null的節點)。
  2. 插入過程,創建新節點插入到父節點中,着色旋轉維護平衡性。
  3. TreeMap不允許使用null爲key。

插入自平衡

private void fixAfterInsertion(Entry<K,V> x) {
    // 首先將新插入節點置爲紅色
    x.color = RED;
	
    while (x != null && x != root && x.parent.color == RED) {
        if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
            Entry<K,V> y = rightOf(parentOf(parentOf(x)));
            if (colorOf(y) == RED) {
                setColor(parentOf(x), BLACK);
                setColor(y, BLACK);
                setColor(parentOf(parentOf(x)), RED);
                x = parentOf(parentOf(x));
            } else {
                if (x == rightOf(parentOf(x))) {
                    x = parentOf(x);
                    rotateLeft(x);
                }
                setColor(parentOf(x), BLACK);
                setColor(parentOf(parentOf(x)), RED);
                rotateRight(parentOf(parentOf(x)));
            }
        } else {
            Entry<K,V> y = leftOf(parentOf(parentOf(x)));
            if (colorOf(y) == RED) {
                setColor(parentOf(x), BLACK);
                setColor(y, BLACK);
                setColor(parentOf(parentOf(x)), RED);
                x = parentOf(parentOf(x));
            } else {
                if (x == leftOf(parentOf(x))) {
                    x = parentOf(x);
                    rotateRight(x);
                }
                setColor(parentOf(x), BLACK);
                setColor(parentOf(parentOf(x)), RED);
                rotateLeft(parentOf(parentOf(x)));
            }
        }
    }
    root.color = BLACK;
}

刪除節點

刪除節點使用的remove方法,內部實現是deleteEntry方法,這裏可以瞭解一下:

private void deleteEntry(Entry<K,V> p) {
    modCount++;
    size--;
    // 情況一:p有兩個子節點
    
	// 當左右節點都不爲null時,使用successor找到p節點對應的前驅或者後繼節點。
    // 當把紅黑樹上的節點投射到與葉子層平行的水平線上時,節點序列是按從左到右的順序依次遞增的。
    // 前驅節點就是p的前一個相鄰節點,後繼節點就是p的後一個相鄰節點。
    // 刪除p節點相當於把s節點移動到p的位置上,再在原有位置上刪除s節點。
    if (p.left != null && p.right != null) {
        Entry<K,V> s = successor(p);
        p.key = s.key;
        p.value = s.value;
        p = s;
    }

    // 如果最開始p擁有兩個子節點,經過上面過程,p已經被替換爲前驅或後繼節點s。
    // 而前驅節點肯定沒有right節點,後置節點肯定沒有left節點。
    // 所以:此時的p 只有一個子節點,另一個子節點爲null
    
    // 情況二:p只有一個子節點 ,replacement就是要替換p的子節點。
    Entry<K,V> replacement = (p.left != null ? p.left : p.right);
	
    if (replacement != null) {
        // 將子節點replacement連接到parent節點上。
        replacement.parent = p.parent;
        if (p.parent == null)
            root = replacement;
        else if (p == p.parent.left)
            p.parent.left  = replacement;
        else
            p.parent.right = replacement;

        // Null out links so they are OK to use by fixAfterDeletion.
        p.left = p.right = p.parent = null;

        // 如果p.color是RED,其子節點一定是黑色的,刪除一個紅色節點替換爲黑色子節點,
        // 對該子節點而言通往root路徑上的黑色節點數量不變,平衡不變。
        
        // 如果p.color是BLACK,刪除一個黑色節點就需要調整平衡性。
        if (p.color == BLACK)
            fixAfterDeletion(replacement);
    } else if (p.parent == null) { // return if we are the only node.
        root = null;
    } else { //  No children. Use self as phantom replacement and unlink.
        // 情況三:p沒有子節點
        // p爲黑色葉子節點,刪除p需要調整平衡性。
        if (p.color == BLACK)
            fixAfterDeletion(p);
		// p的left和right都是null了,此處要清空p和紅黑樹的關係。
        if (p.parent != null) {
            if (p == p.parent.left)
                p.parent.left = null;
            else if (p == p.parent.right)
                p.parent.right = null;
            p.parent = null;
        }
    }
}

刪除自平衡

以上插入和刪除操作都會涉及到自平衡方法fixAfterDeletion,它對刪除後補位的元素進行調整,這裏看下該方法的實現過程。

private void fixAfterDeletion(Entry<K,V> x) {
    // x是被刪除所在位置的節點,(有可能是補位的節點,也有可能是被刪除的節點)
    // 紅黑樹的平衡是從底部到頂部(根部)的過程
    while (x != root && colorOf(x) == BLACK) {
        // 情況1:被替換的節點是左節點
        if (x == leftOf(parentOf(x))) {
            // x的兄弟節點
            Entry<K,V> sib = rightOf(parentOf(x));
			// 1.1 兄弟節點爲紅色
            if (colorOf(sib) == RED) {
                setColor(sib, BLACK);// 將兄弟節點置爲黑色 
                setColor(parentOf(x), RED);// 將父節點置爲紅色
                rotateLeft(parentOf(x)); // 父節點的右側黑色節點層數變多,以父節點爲中心左旋調整。
                sib = rightOf(parentOf(x));// 左旋後原來父節點right節點會變動,更新兄弟節點sib。
            }
			// 1.2 替換節點的兄弟節點是黑色(原本是黑色或經上一步改爲黑色),
            // 兄弟節點的子節點都是黑色
            if (colorOf(leftOf(sib))  == BLACK &&
                    colorOf(rightOf(sib)) == BLACK) {
                setColor(sib, RED);// 將兄弟節點改爲紅色
                x = parentOf(x);// 將x指向x節點的父節點
            } else {
                if (colorOf(rightOf(sib)) == BLACK) {
                    // 1.3 兄弟節點的右子節點是黑色,左子節點是紅色
                    setColor(leftOf(sib), BLACK);// 將左節點置爲黑色
                    setColor(sib, RED);// 兄弟節點由黑轉紅
                    rotateRight(sib);// 兄弟節點的left黑色節點層數變多,右旋調整
                    sib = rightOf(parentOf(x));// 更新兄弟節點
                }
                // 1.4 兄弟節點左子節點
                setColor(sib, colorOf(parentOf(x)));
                setColor(parentOf(x), BLACK);
                setColor(rightOf(sib), BLACK);
                rotateLeft(parentOf(x));
                x = root;
            }
        } else { // symmetric
            // 被替換的節點是右節點
            Entry<K,V> sib = leftOf(parentOf(x));

            if (colorOf(sib) == RED) {
                setColor(sib, BLACK);
                setColor(parentOf(x), RED);
                rotateRight(parentOf(x));
                sib = leftOf(parentOf(x));
            }

            if (colorOf(rightOf(sib)) == BLACK &&
                    colorOf(leftOf(sib)) == BLACK) {
                setColor(sib, RED);
                x = parentOf(x);
            } else {
                if (colorOf(leftOf(sib)) == BLACK) {
                    setColor(rightOf(sib), BLACK);
                    setColor(sib, RED);
                    rotateLeft(sib);
                    sib = leftOf(parentOf(x));
                }
                setColor(sib, colorOf(parentOf(x)));
                setColor(parentOf(x), BLACK);
                setColor(leftOf(sib), BLACK);
                rotateRight(parentOf(x));
                x = root;
            }
        }
    }

    setColor(x, BLACK);
}

查找元素

查找元素邏輯和插入元素的邏輯類似,代碼如下:

final Entry<K,V> getEntry(Object key) {
    // 同樣優先使用TreeMap中的比較器進行查找
    if (comparator != null)
        return getEntryUsingComparator(key);
    if (key == null)
        throw new NullPointerException();
    @SuppressWarnings("unchecked")
    Comparable<? super K> k = (Comparable<? super K>) key;
    // 從根節點進行遍歷查找
    Entry<K,V> p = root;
    while (p != null) {
        int cmp = k.compareTo(p.key);
        if (cmp < 0)
            p = p.left;
        else if (cmp > 0)
            p = p.right;
        else
            return p;
    }
    return null;
}

3.LinkedHashMap

HashMap是無序的,TreeMap是按照Key的大小進行的排序,而LinkedHashMap是按照插入的順序進行排序的,其最重要的兩個特性是:

  • 可以按照插入順序進行訪問
  • 可以實現LRU

下面的學習主要關注以上兩點的實現。

存儲結構

public class LinkedHashMap<K,V> extends HashMap<K,V>implements Map<K,V>{
    // 雙向鏈表頭結點,維護的是最早插入的節點
	transient LinkedHashMap.Entry<K,V> head;
    // 雙向鏈表尾節點,維護的是最新插入的節點
    transient LinkedHashMap.Entry<K,V> tail;
    // 訪問順序標記符,false爲按插入順序方法
    // true爲按訪問順序,會調整節點順序,將經常訪問節點放到尾部
	final boolean accessOrder;
    // 擴展節點,增加了前驅節點before 和 後繼節點after
   	static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    } 
}

LinkedHashMap繼承HashMap,所以底層數據存儲依舊是數組+鏈表+紅黑樹,不同的地方在於其擴展了節點,

按插入順序維護了一個雙向鏈表,這是實現按插入順序進行訪問的關鍵。

插入節點

上面可知LinkedHashMap中擴展的節點有兩個新增屬性,這兩個屬性是什麼時候賦值的呢?LinkedHashMap自己沒有實現插入方法,使用的是HashMap中的put方法,但覆寫了newNode和newTreeNode方法,在裏面完成了前驅和後繼的連接過程。

調用過程如:put->putVal->newNode/newTreeNode->linkNodeLast

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;
}
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
    TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
    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的前驅節點和鏈表尾節點連接
        p.before = last;
        last.after = p;
    }
}

通過以上過程,每個新增節點都會被按插入順序插入到鏈表中。

刪除節點

刪除節點的方法同樣使用HashMap中的remove方法,但還需要從雙向鏈表中刪除該節點纔算完全刪除。

這裏就用到了在HashMap中刪除元素後的回調方法afterNodeRemoval,linkedHashMap覆寫了這個方法,其中實現了從雙向鏈表中刪除節點的邏輯。

void afterNodeRemoval(Node<K,V> e) { // unlink
    LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    // p是待刪除節點,b是前驅節點,a是後繼節點
    p.before = p.after = null;
    if (b == null)
        head = a;// 前驅節點爲null,則將head指向後繼節點
    else
        b.after = a;// 前驅節點不爲null,則將前驅節點和後繼節點連起來
    if (a == null)
        tail = b;// 後繼節點爲null,則將tail指向前驅節點
    else
        a.before = b;// 後繼節點不爲null,則將後繼節點和前驅節點連起來
}

順序訪問

通過LinkedHashIterator 可以依次訪問LinkedHashMap中所有元素,訪問過程如下:

LinkedHashIterator() {
    next = head;// 頭結點作爲訪問的第一個節點
    expectedModCount = modCount;
    current = null;
}

public final boolean hasNext() {
    return next != null;
}

final LinkedHashMap.Entry<K,V> nextNode() {
    LinkedHashMap.Entry<K,V> e = next;
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    current = e;
    next = e.after;
    return e;
}

因爲在插入節點時,已經按照插入順序將節點插入到雙向鏈表當中,所以在遍歷時只需要按順序依次訪問e.after就得到了節點插入順序。

LRU實現

LRU是指最近最少使用,是一種頁面置換算法,會將不經常使用的頁面刪除,經常用於設計緩存。Android中的LruCache其底層數據存儲就是基於LinkedHashMap。

  • 將訪問過的節點放到隊尾(隊尾是最新插入的節點)
  • 插入元素後如果滿足刪除策略,就刪除最少訪問的節點,新節點插入到隊尾

下面使用一個demo來展示如何使用linkedHashMap來實現LRU

public static void testLru(){
    LinkedHashMap<Integer,Integer> lruMap = 
        // 調用構造函數,指定accessOrder爲true
        new LinkedHashMap<Integer, Integer>(0,0.75f,true){
        	// 覆寫刪除策略,這裏指定元素個數超過3則刪除舊節點
        	@Override
        	protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
            	return size()>3;
        	}
    };

    lruMap.put(1,1);
    lruMap.put(2,2);
    lruMap.put(3,3);
    lruMap.put(4,4);
    lruMap.put(5,5);
    System.out.println(lruMap);
	// {3=3, 4=4, 5=5}  插入了5個元素,根據刪除策略,最早的兩個節點被刪除了

    lruMap.get(5);
    lruMap.get(4);
    lruMap.get(3);
    System.out.println(lruMap);
    // {5=5, 4=4, 3=3} 最近訪問的節點會被移動到隊尾
}

問題1:插入節點時,何時刪除了舊節點?

linkedHashMap 沒有實現put方法,當調用父類HashMap的put方法時會調用afterNodeInsertion方法,通過覆寫該方法實現刪除邏輯。

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    // 在允許刪除並且刪除策略爲true時,刪除頭部節點
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

問題2:查詢節點時,何時將訪問節點移動到了隊尾?

在調用get或getOrDefault 方法時都會回調afterNodeAccess方法,LinkedHashMap覆寫了該方法,並在其中完成

public V get(Object key) {
    Node<K,V> e;
    // 使用HashMap中getNode方法獲取節點
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)// 如果設置了按訪問順序讀取
        afterNodeAccess(e);// 將訪問的節點放置到隊列尾部
    return e.value;
}

public V getOrDefault(Object key, V defaultValue) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return defaultValue;
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        // p是要移動的節點,b是p前置節點,a是p後置節點
        LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e
            , b = p.before, a = p.after;
        p.after = null;// 先斷開p的後置連接
        if (b == null)
            head = a;// 如果前置節點b,不存在則將後置節點a設爲頭節點
        else
            b.after = a;// 否則將前置節點b和後置節點a連起來
        if (a != null) // 後置節點a不爲null則將後置節點a和前置節點b連起來
            a.before = b;
        else
            last = b;// 後置節點a爲null則將前置節點b設爲尾節點
        if (last == null)
            head = p;// 尾節點爲null則將p置爲頭節點
        else {
            p.before = last;// 尾節點不爲null則將p節點插入到尾節點,
            last.after = p;
        }
        tail = p;// 尾節點指向p
        ++modCount;
    }
}

上面調整的過程可分爲兩部分,先把p節點摘下來,再將p節點插入到鏈表尾部。

三 總結

上面介紹了Java集合類中Map相關的代碼,首先從Map接口講起,Map集合用於存儲Key-Value格式的數據,作爲一個數據集合,提供了增刪改查的接口規範以及描述集合整體數據的視圖方法。具體實現部分先講了HashMap,其底層數據存儲是數組+鏈表+紅黑樹 ,圍繞該存儲結構講解了hash算法和擴容算法。TreeMap底層是紅黑樹,節點按照key的大小進行了排序,這裏的重點是插入刪除節點後紅黑樹的自平衡。最後是LinkedHashMap是對Hashmap 的擴展,通過將節點維護成一個雙向鏈表,保留了節點的插入順序,通過覆寫幾個回調方法實現了LRU算法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章