1. 程式人生 > 實用技巧 >Java基礎之集合框架Map介面及其實現類

Java基礎之集合框架Map介面及其實現類

集合框架之Map介面及其實現類

1、概述

Map與Collection介面類似,是java集合框架中的兩大主要根介面之一,其也派生了大量的集合介面以及這些介面的實現類和操作他們的方法,代表著儲存key-value對的集合(對應的List代表著有序可重複的集合,Set代表無序不可重複集合)。

2、Map介面實現類---HashMap

2-1、HahsMap之概述

Map是一種特殊的資料儲存介面,每一組資料存放著鍵和值,例如:username="abc",這裡鍵為username,值為"abc"。其實從名字中我們就可以聯想到一些Map的特點----Map即對映(將一個值對映到另一個值中),這裡以函式作為類比,因為函式也是一個對映關係(將某一個x通過運算對映到某一個y中)

,在函式中存在這樣一個特點:一個x不能對映到多個y上面。比如下面這個函式y=cos(x),其中每一個x只能對映到一個y上面,但是一個y可能存在多個x的取值。在Map中也存著這個的特點:{username="abc",password="abc"},任意兩個鍵不能相同,但值可以

HashMap是Map的一個具體的實現類,在java中Map只是一個介面,只聲明瞭所有的Map應該有什麼方法,但是並沒有描述其底層結構要如何儲存,而HashMap作為Map的實現類,實現了Map所宣告的所有方法,同時也聲明瞭其在底層是如何進行儲存的。

2-2、HashMap之底層結構

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    private static final long serialVersionUID = 362498820763181265L;
    
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    static final int TREEIFY_THRESHOLD = 8;
    
    static final int UNTREEIFY_THRESHOLD = 6;
    
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    transient Node<K,V>[] table;
    
    transient Set<Map.Entry<K,V>> entrySet;
    
    transient int size;
    
    transient int modCount;
    
    int threshold;
    
    final float loadFactor;
    
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }
}

HashMap的底層是使用靜態內部類Node的陣列物件來儲存的(transient Node<K,V>[] table;),每一個Node類物件儲存著四個屬性:key、value、hash值、next(下一節點地址),其中hash值和value使用final關鍵字修飾---一經賦值不可修改

其中還有很多個常用的屬性

  • capacity---底層陣列長度
  • size---HashMap中鍵值對的數量
  • loadFactor---負載因子,HashMap底層是一個數組,在StringBuffer或者ArrayList的底層也是一個數組,涉及到了陣列的擴容,而在它們底層都是當底層陣列空間不足時,進行擴容,而HashMap並不是這樣,而是在儲存了一定數量的資料就進行擴容
    (這涉及到HashMap的一個特性),這就是負載因子,預設為0.75。
  • threshold---閾值,標記陣列應該在存放多少資料後進行擴容(當size超出threshold時將會進行擴容,呼叫resize()函式進行擴容後的threshold=capacity*loadFactor,建構函式建立的threshold>capacity)

HashMap底層是使用陣列+連結串列+紅黑樹來進行儲存的,當你需要儲存資料時會根據物件的hash值(根據物件的hashCode()方法計算出來)來存放資料,hash值是存在碰撞的(hash碰撞,鍵與值不同,但計算出來的hash值卻相同),如果發生hash碰撞,會遍歷儲存點的連結串列,呼叫物件的equals()方法來判斷鍵是否相同,如果不同,則會將資料插入到連結串列(JDK版本不同插入的地方也不同,頭插、尾插,一個儲存節點資料比較多時會將連結串列轉換成紅黑樹)。

2-3、HahsMap之初始化

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

這裡列舉了三個常用的建構函式

  1. 無參建構函式:初始化負載因子loadFactor為DEFAULT_LOAD_FACTOR(0.75f),底層陣列未初始化,長度為0。
  2. 傳入底層陣列長度(initialCapacity)以及負載因子(loadFactor)建構函式:設定負載因子,呼叫tableSizeFor(int cap)方法初始化閾值,tableSizeFor(int cap)方法其實就是返回一個最小的、不小於cap的2n的數(不小於0以及不超過230)。
  3. 傳入底層陣列長度(initialCapacity)建構函式:呼叫上述的建構函式,其負載因子使用預設負載因子(0.75)。

2-4、HashMap之新增資料

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // 節點指標、鍵的temp
        Node<K,V> e; K k;
        // 如果節點的hash值與要存入的節點的hash值相同,並且鍵的地址、值相等,將e指向p,跳到後面的if(e != null)
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果節點是個紅黑樹,則遍歷紅黑樹
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 反之,節點是個連結串列
        else {
            // 遍歷連結串列
            for (int binCount = 0; ; ++binCount) {
                // 迴圈終止條件
                if ((e = p.next) == null) {
                    // 節點連結串列不存在鍵相同的節點,在連結串列末尾插入新節點
                    p.next = newNode(hash, key, value, null);
                    // 如果這一個連結串列的節點數大於等於8,將連結串列轉換成紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果連結串列中存在某一個節點的hash值與要存入的節點的hash值相同,並且其地址或者值相同,結束迴圈(e!=null)
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 通過上一步獲得節點指標,將其值進行替換
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            // 結束函式
            return oldValue;
        }
    }
    ++modCount;
    // 判斷是否需要進行擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
  1. 呼叫HashMap的hash()函式進行hash值的計算,而這個hash()函式實際上就是呼叫了鍵的hashCode()函式進行hash計算。

  2. 呼叫putVal()函式進行放值

    1. 如果HashMap底層陣列為空或者長度為0,則呼叫resize()進行底層陣列擴容(初始化),並將其長度賦值給變數n

    2. 通過將y=(n - 1) & hash作為hash函式,確定節點要存放的位置,比如下面的n=64,hash=1886138741,則計算的i=53,即這個節點要存放在陣列索引為53的地方。

      1. 如果這個索引(i=53)中沒有資料,則在這個索引下建立一個hash值為第一步計算出的hash值,key、value為放入資料的鍵與值
      2. 反之這個索引(i=53)中有資料,遍歷這條連結串列或者紅黑樹,找到相同鍵的地址或者插入新節點,替換其value,並直接返回函式,不進行後面的操作
    3. 判斷鍵值對的個數(size)是否超過threshold,若超過則進行擴容。

注意,我們在向連結串列或者紅黑樹中插入/修改資料時不會進行擴容(2.2),只有在向陣列中插入元素時才會進行擴容(2.1)

2-5、HashMap之底層陣列擴容

final Node<K,V>[] resize() {
    int oldThr = threshold;
    // 宣告新陣列長度,閾值=0
    int newCap, newThr = 0;
    // ==================確定擴容後的陣列長度、閾值=============
    // 如果擴容前的底層陣列長度>0(已經初始化過)
    if (oldCap > 0) {
        // 如果擴容前的陣列長度大於等於1<<30(2^30)
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 設定新閾值長度為0x7fffffff(Integer的最大值)
            threshold = Integer.MAX_VALUE;
            // 返回底層陣列(不進行擴容,只修改閾值長度)
            return oldTab;
        }
        // 讓擴容前的底層陣列長度*2並賦值給新陣列長度,若新陣列長度小於(1<<30)以及擴容前的陣列長度大於等於1<<4
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 讓擴容前的閾值*2並賦值給新閾值
            newThr = oldThr << 1; // double threshold
    }
    // 如果擴容前的閾值>0(擴容前的底層陣列長度<=0)(只調用過除無參建構函式的其他建構函式)
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 讓新陣列長度 = 擴容前的閾值
        newCap = oldThr;
    // 反之(閾值<=0、擴容前的底層陣列長度<=0)(只調用過無參建構函式)
    else {               // zero initial threshold signifies using defaults
        // 新陣列長度 = 1<<4
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 新閾值 = 預設的負載因子(0.75)*預設的初始化陣列長度(1<<4)
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果新閾值=0(只調用過除無參建構函式的其他建構函式),初始化閾值=newCap * loadFactor
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
	// 初始化擴容後的新底層陣列
    @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)
                    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;
                        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;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
  1. 獲取擴容前的各種引數(底層陣列長度,閾值),以及宣告變數(擴容後的各種引數)

  2. 確定擴容後的底層陣列長度以及閾值

    1. 如果底層陣列已經初始化過(底層陣列長度>0)
      1. 如果擴容前的底層陣列長度>=1<<30(2^30)---->設定新閾值長度為0x7fffffff(Integer的最大值),同時直接返回擴容前的陣列(不進行擴容,只調整閾值)
      2. 讓擴容前的底層陣列長度x2並賦值給新陣列長度,若新陣列長度<(1<<30)且擴容前的陣列長度>=1<<4---->擴容前的閾值x2並賦值給新閾值
    2. 如果只調用過除無參建構函式的其他建構函式(擴容前的底層陣列長度<=0,擴容前的閾值>0)(無參建構函式不初始化閾值,所有的建構函式都不初始化底層陣列)---->新陣列長度 = 擴容前的閾值
    3. 反之只調用過無參建構函式(閾值<=0、擴容前的底層陣列長度<=0)---->賦預設值,新底層陣列長度 = 1<<4、閾值 = 預設的負載因子(0.75)*預設的初始化陣列長度(1<<4)
    4. 簡而言之就是如果沒初始化過,則以預設大小進行初始化,如果初始化過,則擴容到原來的兩倍,如果已經擴容到極限,則不進行擴容,值調整閾值
  3. 如果新閾值=0(只調用過除無參建構函式的其他建構函式),初始化新閾值=newCap * loadFactor,接2.2,在此處改變閾值。

  4. 初始化擴容後的新陣列

  5. 將資料拷貝到新陣列中(連結串列和紅黑樹將會重新計算應該放置的地方)

    1. 如果某個位置只有一個節點,直接重新計算應該放置的位置即可(hash函式為e.hash & (newCap - 1))
    2. 如果某個位置存放的是一棵紅黑樹,使用split()方法進行拷貝
    3. 如果某個位置存放的是一個連結串列,根據e.hash & oldCap是否為0將一條連結串列拆分成兩類
      1. 若e.hash & oldCap == 0,則連結串列的需要放置的位置不變
        1. 比如e.hash=0101,oldCap-1=0111,其中oldCap=2^n,e.hash & (oldCap- 1)=0101。
        2. e.hash=0101,newCap -1=01111(擴容到原來的兩倍),e.hash & (newCap - 1)=00101。
        3. 總結:只要原節點的hash值在oldCap的最高位為0,則連結串列的索引不變。
      2. 反之,連結串列的需要放置的位置需要改變
        1. 比如e.hash=1101,oldCap-1=0111,其中oldCap=2^n,e.hash & (oldCap- 1)=0101。
        2. e.hash=1101,newCap -1=01111(擴容到原來的兩倍),e.hash & (newCap - 1)=01101。
        3. 總結:反之原節點的hash值在oldCap的最高位為1,則hash函式計算出來的索引在原來的基礎上偏移了oldCap的長度。
    4. 將位置不變的第一條連結串列直接放置到原來的索引
    5. 將位置需要改變的第二條連結串列放置到原來索引+原陣列長度
  6. 返回新陣列

2-6、總結

  1. HashMap的底層使用陣列(加連結串列加紅黑樹)來儲存的。
  2. HashMap儲存資料的hash函式為e.hash & (table.length - 1)---節點物件的hash值邏輯與底層陣列長度-1,通過hash函式來確定節點存放在哪裡。
  3. 當發生hash碰撞(hash函式計算出來的值相同)時,使用連結串列或者紅黑樹來處理hash碰撞,資料較少時使用連結串列,會將新節點儲存在連結串列的尾部,當某條連結串列長度>=8時,將會將這一條轉換成紅黑樹來處理hash碰撞。
  4. 其底層陣列的長度一定為2n**(由建構函式以及擴容機制決定,預設大小為24),一般情況下每次擴容為原來的兩倍**。
  5. threshold決定了何時進行擴容,threshold=capacity(底層陣列長度)*loadFactor(負載因子,預設為0.75)
  6. 只有在往新的位置插入資料時才可能會發生擴容(向連結串列或者紅黑樹中插入資料不會發生擴容!),同時當鍵值對個數>threshold就會發生擴容(注意並不是陣列的使用情況),預設為0.75。
  7. HashMap在進行擴容時比較快捷(擴容後的節點只存放在原索引或者原索引+原陣列長度的位置),這是由底層陣列長度、擴容方式、hash函式一起決定的。

3、Map介面實現類---LinkedHashMap

3-1、LinkedHashMap之概述

我們先看一種情況,這裡我們插入四個資料,但是輸出訪問時的順序並不是我們想要的(通過hash函式其實相當於隨機地將資料儲存在底層陣列中,因此按照預設的方式輸出時(從頭到尾遍歷底層陣列),其輸出的順序也是隨機的)。因此有了LinkedHashMap。

LinkedHashMap繼承自HashMap,其底層儲存結構與HashMap完全一致,但其額外維護了一組連結串列,用來標記輸出的順序。

3-2、LinkedHashMap之底層結構

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,將會記錄節點操作(get)
    final boolean accessOrder;
    
    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;
        }
    }
    
    void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;
        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;
        }
    }
    ......
    
}
  1. 在LinkedHashMap中重寫了newNode(),即在建立新節點時,會將節點放入接入到維護的連結串列末尾

  2. 在LinkedHashMap中重寫了afterNodeAccess(),這裡主要記錄節點的操作(如果accessOrder = true,每次節點進行操作(put或者get)時都會將“操作”記錄,並放於維護的連結串列(用於記錄輸出的順序)末尾)

  3. 刪除節點以及節點修改這裡不做說明了,做法類似------在HashMap中的節點操作,而這些節點操作時會預留出一個什麼都沒幹的函式,這些函式會在LinkedHashMap中重寫,當這個物件為LinkedHashMap的物件時,這些“空函式”會有具體的操作。


4、Map介面實現類---HashTable

4-1、HashTable之概述

與HashMap類似,不同的是其大部分方法使用關鍵字synchronized修飾(執行緒安全),而HashMap沒有使用關鍵字synchronized修飾(和StringBuilder、StringBuffer的關係類似)

5、Map介面實現類---TreeMap

5-1、TreeMap之概述

TreeMap作為Map的另一種實現類,其在底層結構與HashMap不同,其底層使用樹來進行資料的儲存