閱讀原始碼,HashMap回顧

希夷小道發表於2021-03-04


本文一是總結前面兩種集合,補充一些遺漏,再對HashMap進行簡單介紹。

回顧

因為前兩篇ArrayList和LinkedList都是針對單獨的集合類分析的,只見樹木未見森林,今天分析HashMap,可以結合起來看一下java中的集合框架。下圖只是一小部分,而且為了方便理解去除了抽象類。

集合體系

Java中的集合(有時也稱為容器)是為了儲存物件,而且多數時候儲存的不止一個物件。

可以簡單的將Java集合分為兩類:

  • 一類是Collection,儲存的是獨立的元素,也就是單個物件。細分之下,常見的有List,Set,Queue。其中List保證按照插入的順序儲存元素。Set不能有重複元素。Queue按照佇列的規則來存取元素,一般情況下是“先進先出”。

  • 一類是Map,儲存的是“鍵值對”,通過鍵來查詢值。比如現實中通過姓名查詢電話號碼,通過身份證號查詢個人詳細資訊等。

    理論上說我們完全可以只用Collection體系,比如將鍵值對封裝成物件存入Collection的實現類,之所以提出Map,最主要的原因是效率。

HashMap簡介

HashMap用來儲存鍵值對,也就是一次儲存兩個元素。在jdk1.8中,其實現是基於陣列+連結串列+紅黑樹,簡單說就是普通情況直接用陣列,發生雜湊衝突時在衝突位置改為連結串列,當連結串列超過一定長度時,改為紅黑樹。

可以簡單理解為:在陣列中存放連結串列或者紅黑樹

  1. 完全沒有雜湊衝突時,陣列每個元素是一個容量為1的連結串列。如索引0和1上的元素。
  2. 發生較小雜湊衝突時,陣列每個元素是一個包含多個元素的連結串列。如索引2上的元素。
  3. 當衝突數量超過8時,陣列每個元素是一棵紅黑樹。如索引6上的元素。

下圖為示意圖,相關結構沒有嚴格遵循規範。

HashMap的儲存結構

類簽名

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

如下圖

HashMap繼承體系

實現Cloneable和Serializable介面,擁有克隆和序列化的能力。

HashMap繼承抽象類AbstractMap的同時又實現Map介面的原因同樣見上一篇LinkedList。

常量

//序列化版本號
private static final long serialVersionUID = 362498820763181265L;

//預設初始化容量為16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//最大容量,2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

//預設負載因子,值為0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;


//以下三個常量應結合看
//連結串列轉為樹的閾值
static final int TREEIFY_THRESHOLD = 8;

//樹轉為連結串列的閾值,小於6時樹轉連結串列
static final int UNTREEIFY_THRESHOLD = 6;

//連結串列轉樹時的集合最小容量。只有總容量大於64,且發生衝突的連結串列大於8才轉換為樹。
static final int MIN_TREEIFY_CAPACITY = 64;

上述變數的關鍵在於連結串列轉樹和樹轉連結串列的時機,綜合看:

  • 當陣列的容量小於64是,此時不管衝突數量多少,都不樹化,而是選擇擴容。
  • 當陣列的容量大於等於64時,
    • 衝突數量大於8,則進行樹化。
    • 當紅黑樹中元素數量小於6時,將樹轉為連結串列。

變數

//儲存節點的陣列,始終為2的冪
transient Node<K,V>[] table;

//批量存入時使用,詳見對應建構函式
transient Set<Map.Entry<K,V>> entrySet;

//實際存放鍵值對的個數
transient int size;

//修改map的次數,便於快速失敗
transient int modCount;

//擴容時的臨界值,本質是capacity * load factor
int threshold;

//負載因子
final float loadFactor;

陣列中儲存的節點型別,可以看出,除了K和Value外,還包含了指向下一個節點的引用,正如一開始說的,節點實際是一個單向連結串列。

static class Node<K,V> implements Map.Entry<K,V> {
    
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    
   //...省略常見方法
}

構造方法

常見的無參構造和一個引數的構造很簡單,直接傳值,此處省略。看一下兩個引數的構造方法。

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;
    //將給定容量轉換為不小於其自身的2的冪
    this.threshold = tableSizeFor(initialCapacity);
}

tableSizeFor方法

上述方法中有一個非常巧妙的方法tableSizeFor,它將給定的數值轉換為不小於自身的最小的2的整數冪。

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

比如cap=10,轉換為16;cap=32,則結果還是32。用了位運算,保證效率。

有一個問題,為啥非要把容量轉換為2的冪?之前講到的ArrayList為啥就不需要呢?其實關鍵在於hash,更準確的說是轉換為2的冪,一定程度上減小了雜湊衝突。

關於這些運算,畫個草圖很好理解,關鍵在於能夠想到這個方法很牛啊。解釋的話配圖太多,這裡篇幅限制,將內容放在另一篇文章。

新增元素

在上面構造方法中,我們沒有看到初始化陣列也就是Node<K,V>[] table的情況,這一步驟放在了新增元素put時進行。

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

可以看出put呼叫的是putVal方法。

putVal方法

在此之前回顧一下HashMap的構成,陣列+連結串列+紅黑樹。陣列對應位置為空,存入陣列,不為空,存入連結串列,連結串列超載,轉換為紅黑樹。

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;
    //根據key計算hash值得出陣列中的位置i,位置i上為空,直接新增。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //陣列對應位置不為空
    else {
        Node<K,V> e; 
        K k;
        //對應節點key上的key存在,直接覆蓋value
        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);
                    //連結串列轉換為紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    ++modCount;
    //下次新增前需不需要擴容,若容量已滿則提前擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

resize()方法比較複雜,最好是配合IDE工具,debug一下,比較容易弄清楚擴容的方式和時機,如果幹講的話反而容易混淆。

獲取元素

根據鍵獲取對應的值,內部呼叫getNode方法

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode方法

final Node<K,V> getNode(int hash, Object key) {
    
    Node<K,V>[] tab; 
    Node<K,V> first, 
    e; int n; 
    K k;
    
    //陣列不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //第一個節點滿足則直接返回對應值
        if (first.hash == hash && 
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            //紅黑樹中查詢
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                //連結串列中查詢
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

總結

HashMap的內容太多,每個內容相關的知識點也很多,篇幅和個人能力限制,很難講清所有內容,比如最基礎的獲取hash值的方法,其實也很講究的。有機會再針對具體的細節慢慢詳細寫吧。

相關文章