1. 程式人生 > >一篇文章徹底讀懂HashMap之HashMap原始碼解析(上)

一篇文章徹底讀懂HashMap之HashMap原始碼解析(上)

       就身邊同學的經歷來看,HashMap是求職面試中名副其實的“明星”,基本上每一加公司的面試多多少少都有問到HashMap的底層實現原理、原始碼等相關問題。 
      在秋招面試準備過程中,博主閱讀過很多關於HashMap原始碼分析的文章,漫長的拼湊式閱讀之後,博主沒有看到過一篇能夠通俗易懂、透徹講解HashMap原始碼的文章(可能是博主沒有找到)。秋招結束後,國慶假期抽空寫下了這篇文章,用一種通俗易懂的方式向讀者講解HashMap原始碼,並且儘量涵蓋面試中HashMap的所有考察點。希望能夠對後面的求職者有所幫助~

這篇文章將會按以下順序來組織:
HashMap原始碼分析(JDK8,通俗易懂)
HashMap面試“明星”問題彙總,以及明星問題答案

下面是JDK8中HashMap的原始碼分析,在下文原始碼分析中:

    註釋多少與重要性成正比

    註釋多少與重要性成正比

    註釋多少與重要性成正比

(1)HashMap的成員屬性原始碼分析

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

    private static final long serialVersionUID
                   = 362498820763181265L;

    //HashMap的初始容量為16,HashMap的
    //容量指的是儲存元素的陣列大小,
    //即桶的數量
    static final int DEFAULT_INITIAL_CAPACITY 
                     = 1 << 4; 

    //HashMap的最大的容量
    static final int MAXIMUM_CAPACITY
                         = 1 << 30;  
 //下面有詳細解析
static final float DEFAULT_LOAD_FACTOR
                     = 0.75f;
//當某一個桶中連結串列的長度>=8時,連結串列結構會轉換成
//紅黑樹結構,其實還要求桶的中數量>=64,後面會提到
static final int TREEIFY_THRESHOLD = 8;

//當紅黑樹中的節點數量<=6時,紅黑樹結構會轉變為
//連結串列結構
static final int UNTREEIFY_THRESHOLD = 6;

//上面提到的:當Node陣列容量>=64的前提下,如果
//某一個桶中連結串列長度>=8,則會將連結串列結構轉換成
//紅黑樹結構
static final int MIN_TREEIFY_CAPACITY = 64;
} 

DEFAULT_LOAD_FACTOR:HashMap的負載因子,影響HashMap效能的引數之一,是時間和空間之間的權衡,後面會看到HashMap的元素儲存在Node陣列中,這個陣列的大小這裡稱為“桶”的大小。另外還有一個引數size指的是我們往HashMap中put了多少個元素。當size>桶的數量*DEFAULT_LOAD_FACTOR的時候,這時HashMap要進行擴容操作,也就是桶不能裝滿。DEFAULT_LOAD_FACTOR是衡量桶的利用率:

DEFAULT_LOAD_FACTOR較小時(桶的利用率較小),這時浪費的空間較多(因為只能儲存桶的數量DEFAULT_LOAD_FACTOR個元素,超過了就要進行擴容),這種情況下往HashMap中put元素時發生衝突的概率也很小,所謂衝突指的是:多個元素被put到了同一個桶中;衝突小時(可以認為一個桶中只有一個元素)put、get等HashMap的操作代價就很低,可以認為是O(1);
DEFAULT_LOAD_FACTOR很大時,桶的利用率較大的時候(注意可以大於1,因為衝突的元素是使用連結串列或者紅黑樹連線起來的),此時空間利用率較高,這也意味著一個桶中儲存了很多元素,這時HashMap的put、get等操作代價就相對較大,因為每一個put或get操作都變成了對連結串列或者紅黑樹的操作,代價肯定大於O(1),所以說DEFAULT_LOAD_FACTOR是空間和時間的一個平衡點;
DEFAULT_LOAD_FACTOR較小時,需要的空間較大,但是put和get的代價較小;DEFAULT_LOAD_FACTOR較大時,需要的空間較小,但是put和get的代價較大)。擴容操作就是把桶的數量*2,即把Node陣列的大小調整為擴容前的2倍,至於為什麼是兩倍,分析擴容函式時會講解,這其實是一個trick,細節後面會詳細講解。Node陣列中每一個桶中儲存的是Node連結串列,當連結串列長度>=8的時候並且Node陣列的大小>=64,連結串列會變為紅黑樹結構(因為紅黑樹的增刪改查複雜度是logn,連結串列是n,紅黑樹結構比連結串列代價更小)。

(2)HashMap內部類——Node原始碼分析

//Node是HashMap的內部類
static class Node<K,V> 
     implements Map.Entry<K,V> {
        final int hash; 
        final K key;//儲存map中的key
        V value;//儲存map中的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;
        }

HashMap的內部類Node:HashMap的所有資料都儲存在Node陣列中那麼這個Node到底是個什麼東西呢?
Node的hash屬性:儲存key的hashcode的值:key的hashcode ^ (key的hashcode>>>16)。這樣做主要是為了減少hash衝突當我們往map中put(k,v)時,這個k,v鍵值對會被封裝為Node,那麼這個Node放在Node陣列的哪個位置呢:index=hash&(n-1),n為Node陣列的長度。那為什麼這樣計算hash可以減少衝突呢?如果直接使用hashCode&(n-1)來計算index,此時hashCode的高位隨機特性完全沒有用到,因為n相對於hashcode的值很小,計算index的時候只能用到低16位。基於這一點,把hashcode高16位的值通過異或混合到hashCode的低16位,由此來增強hashCode低16位的隨機性。

(3)HashMap hash函式分析

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

HashMap允許key為null,null的hash為0(也意味著HashMap允許key為null的鍵值對),非null的key的hash高16位和低16位分別由由:key的hashCode高16位和hashCode的高16位異或hashCode的低16位組成。主要是為了增強hash的隨機性減少hash&(n-1)的隨機性,即減小hash衝突,提高HashMap的效能。所以作為HashMap的key的hashCode函式的實現對HashMap的效能影響較大,極端情況下:所有key的hashCode都相同,這是HashMap的效能很糟糕!

(4)HashMap tableSizeFor函式原始碼分析

static final int tableSizeFor(int cap) {
    //舉例而言:n的第三位是1(從高位開始數), 
    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;
}

在new HashMap的時候,如果我們傳入了大小引數,這是HashMap會對我們傳入的HashMap容量進行傳到tableSizeFor函式處理:這個函式主要功能是:返回一個數:這個數是大於等於cap並且是2的整數次冪的所有數中最小的那個,即返回一個最接近cap(>=cap),並且是2的整數次冪的數。
具體邏輯如下:一個數是2的整數次冪,那麼這個數減1的二進位制就是一串掩碼,即二進位制從某位開始是一 串連續的1。所以只要對的對應的掩碼,掩碼+1一定是2的整數次冪,這也是為什麼n=cap-1的原因。
舉例而言,假設:
n=00010000_00000000_00000000

n |= n >>> 1;//執行完後
//n=00011000_00000000_00000000

n |= n >>> 2;//執行完後
//n= 00011110_00000000_00000000

n |= n >>> 4;//執行完後
//n= 00011111_11100000_00000000

n |= n >>> 8;//執行完後
//n= 00011111_11111111_11100000

n |= n >>> 16;//執行完後
//n=00011111_11111111_11111111

返回n+1,(n+1)>=cap、為2的整數次冪,並且是與cap差值最小的那個數。最後的n+1一定是2的整數次冪,並且一定是>=cap。
整體的思路就是:如果n的二進位制的第k為1,那麼經過上面四個‘|’運算後[0,k]位都變成了1,即:一連串連續的二進位制‘1’(掩碼),最後n+1一定是2的整數次冪(如果不溢位)。
(5)HashMap成員屬性原始碼分析

/我們往map中put的(k,v)都被封裝在Node中,
//所有的Node都存放在table陣列中
transient Node<K,V>[] table;

//用於返回keySet和values
transient Set<Map.Entry<K,V>> entrySet;

//儲存map當前有多少個元素
    transient int size;

//failFast機制,在講解ArrayList
//和LinkedList一文中已經講過了
transient int modCount;

(6)threshold屬性分析

int threshold;//下面有詳細講解

//負載因子,見上面對DEFAULT_LOAD_FACTOR
//引數的講解,預設值是0.75
final float loadFactor;

threshold也是比較重要的一個屬性:
建立HashMap時,該變數的值是:初始容量(2的整數次冪),之後threshold的值是HashMap擴容的門限值:即當前Nodetable陣列的長度* loadfactor。舉個例子而言,如果我們傳給HashMap構造器的容量大小為9,那麼threshold初始值為16,在向HashMap中put第一個元素後,內部會建立長度為16的Node陣列,並且threshold的值更新為160.75=12。具體而言,當我們一直往HashMap put元素的時候,如果put某個元素後,Node陣列中元素個數為13,此時會觸發擴容(因為陣列中元素個數>threshold了,即13>threshold=12),具體擴容操作之後會詳細分析,簡單理解就是,擴容操作將Node陣列長度2;並且將原來的所有元素遷移到新的Node陣列中。

(7)HashMap構造器原始碼分析

//構造器:指定map的大小,和loadfactor
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);
       //儲存loadfactor
       this.loadFactor = loadFactor;

    /*注意,前面有講tableSizeFor函式,
    該函式返回值:>=initialCapacity、
    返回值是2的整數次冪,並且得是滿足
    上面兩個條件的所有數值中最小的那個數。
     */
    this.threshold = 
         tableSizeFor(initialCapacity);
}
/*
只指定HashMap容量的構造器,
loadfactor使用的是
預設的值:0.75
   */
public HashMap(int initialCapacity) {
    this(initialCapacity
          , DEFAULT_LOAD_FACTOR);
}

//無參構造器,預設loadfactor:0.75,
//預設的容量是16
public HashMap() {
    this.loadFactor 
          = DEFAULT_LOAD_FACTOR; 
}
//其他不常用的構造器就不分析了

從構造器中我們可以看到:HashMap是“懶載入”,在構造器中值保留了相關保留的值,並沒有初始化table陣列,當我們向map中put第一個元素的時候,map才會進行初始化!
(8)HashMap的get函式原始碼分析

//入口,返回對應的value
public V get(Object key) {
    Node<K,V> e;
        
    //hash函式上面分析過了
    return (e = getNode(hash(key), key))
            == null
            ? null : e.value;
    }

get函式實質就是進行連結串列或者紅黑樹遍歷搜尋指定key的節點的過程;另外需要注意到HashMap的get函式的返回值不能判斷一個key是否包含在map中,get返回null有可能是不包含該key;也有可能該key對應的value為null。HashMap中允許key為null,允許value為null。
(9)getNode函式原始碼分析

//下面分析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;//沒找到,返回null
}

注意getNode返回的型別是Node:當返回值為null時表示map中沒有對應的key,注意區分value為null:如果key對應的value為null的話,體現在getNode的返回值e.value為null,此時返回值也是null,也就是HashMap的get函式不能判斷map中是否有對應的key:get返回值為null時,可能不包含該key,也可能該key的value為null!那麼如何判斷map中是否包含某個key呢?見下面contains函式分析。getNode函式細節分析:
(n-1)&hash:當前key可能在的桶索引,put操作時也是將Node存放在index=(n-1)&hash位置。
getNode的主要邏輯:如果table[index]處節點的key就是要找的key則直接返回該節點; 否則:如果在table[index]位置進行搜尋,搜尋是否存在目標key的Node:這裡的搜尋又分兩種:連結串列搜尋和紅黑樹搜尋,具體紅黑樹的查詢就不展開了,紅黑樹是一種弱平衡(相對於AVL)BST,紅黑樹查詢、刪除、插入等操作都能夠保證在O(lon(n))時間複雜度內完成,紅黑樹原理不在本文範圍內,但是大家要知道紅黑樹的各種操作是可以實現的,簡單點可以把紅黑樹理解為BST,BST的查詢、插入、刪除等操作的實現在之前的文章中有
BST java實現講解,紅黑樹實際上就是一種平衡的BST。
(10)contains函式原始碼分析:

public boolean containsKey(Object key) {
    //注意與get函式區分,我們往map中put的
    //所有的<key,value>都被封裝在Node中,
    //如果Node都不存在顯然一定不包含對應的key
    return getNode(hash(key), key) != null;
}