1. 程式人生 > 程式設計 >透析HashMap-原始碼分析

透析HashMap-原始碼分析

閒談       

        HashMap是一個用來儲存<Key,Value>資料的類,憑藉其極其優秀的效率,一直是程式設計中最常用的基礎類。一直以來就是面試的熱點,感覺不問問HashMap的原理, 都不好意思是Java面試了。不好好看下HashMap,怎麼能說準備好了面試呢。

概述

       我們都知道HashMap是基於陣列+連結串列的資料結構,可以高效的插入和查詢資料,克服陣列插入資料需要移位,而連結串列查詢資料需要遍歷的缺點,結構如下圖。


通過儲存的Key的雜湊值來計算<Key,Value>儲存的槽位(陣列上的位置),槽位上已經有其他<Key,Value

>,則稱為是雜湊衝突。通過連結串列連線起來。那麼問題來了,極端情況下,所有的key計算結果全都在一個槽位上,那會是什麼情況?實際上就是弱化成一個連結串列,這時候查詢的時間複雜度就為O(n)


為避免出現這種情況,連結串列長度超過一定長度(預設為8),就直接生成紅黑樹(一種近似平衡二叉樹的結構)這樣的話最差的情況下,查詢的時間複雜度是O(logn)


常見面試題

1、HashMap的容量為什麼會是2的冪次方?

通過雜湊值運算儲存的槽位時取餘可以通過位運算來實現,效率高。

2、HashMap中負載因子為什麼是0.75?

關於這個問題,原始碼裡有段註釋“As a general rule,the default load factor (.75) offers a good * tradeoff between time and space costs.”意思是說負載因子0.75能在時間和空間上去得很好平衡。負載因子太高了,容易造成衝突。太小了,空間利用率又不高,還得頻繁擴容。那麼為什麼不是0.6,0.8呢?我在網上看到一個解釋覺得有一定道理,因為容量是2冪次方,容量*負載因子(擴容的臨界值)剛好會是整數。

3、HashMap連結串列轉化為數為什麼是8?

還是從原始碼中的註釋找答案,“Because TreeNodes are about twice the size of regular nodes”,樹節點佔用的空間是常規節點的兩倍,“Ideally,under random hashCodes,the frequency of  nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing  threshold of 0.75。在隨機hashCode下,節點在槽中的分佈符合泊松分佈,在負載因子為0.75下,泊松分佈引數為0.5。槽中節點的個數及對應的概率如下

0: 0.60653066 

1: 0.30326533 

2: 0.07581633 

3: 0.01263606 

4: 0.00157952 

5: 0.00015795 

6: 0.00001316 

7: 0.00000094 

8: 0.00000006

可以看到,但連結串列長度為8個的時候概率是非常非常低的,所以取樹化為8是為了降低樹化的概率,同時當連結串列太長時有可以通過轉換為樹,提高查詢效率(空間換時間)。

4、HashMap的Key或Value是否可以為null

可以,HashMap的Key或Value都可以為null,key為null的<key,value>鍵值對儲存在槽位為0的位置。

原始碼解析

直接開啟程式碼,來看看hashmap是怎麼實現的。⚠️注意:本文的分析基於JDK1.8

一、常量及成員變數

//預設初始化的容量16 二進位制"10000",即陣列的預設長度,或者叫做“槽”的長度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
"泊松分佈",負載因子,儲存元素超過該比例即擴容,預設0.75
    即儲存的元素個數大於槽容量的0.75倍就擴容槽位
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

//連結串列長度大於該值時轉成黑樹
static final int TREEIFY_THRESHOLD = 8;

//紅黑樹原生個數小於該值時轉成連結串列
static final int UNTREEIFY_THRESHOLD = 6;

//儲存元素的陣列,或者叫槽
transient Node<K,V>[] table;

//目前存放元素的個數
transient int size;

//修改的次數,可認為當前的版本
transient int modCount;
/*
擴容的閥值=容量*因子,當儲存的<key,value>數超過該變數,則擴容.
未初始化前,這個值會儲存首次初始化時的容量,建構函式中賦值*/
int threshold;
//因子
final float 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);
    //因子賦值
    this.loadFactor = loadFactor;
    //這個有點意思了,初始化的容量並不一定是我們傳進來的大小。
    this.threshold = tableSizeFor(initialCapacity);
}

/*******************************************************************
    這個方法的作用就是計算容量,這個方法返回 “大於傳進來引數的最小‘2的冪次方’”,
    為什麼是2的冪次方的,你先不要管,只要知道hashmap的容量是2的冪次方即可...
    是不是有點繞?
    例子 引數14,返回:2^4=16   
        引數 16<cap<=32,則返回2^5=32
********************************************************************/
static final int tableSizeFor(int cap) {
    /**
    這麼一大串位移看起來很頭疼?作用就是讓引數二進位制最高為1的位後面的所有位數變為1
    舉個例子cap為 14則二進位制為
    cap為14	0000 0000 0000 0000 0000 0000 0000 1110   
    n為cap-1	0000 0000 0000 0000 0000 0000 0000 1101
    */
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    //上面位移結果  0000 0000 0000 0000 0000 0000 0000 1111 
    //這個結果+1是  0000 0000 0000 0000 0000 0000 0001 0000 就是2^4=16
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

/*******************************************************************************
位移解析    
       為什麼這裡要-1?這裡-1其實是作用在傳進來的數已經是2^n
       如16  0001 0000 減1 會是0000 1111,可以與傳16以下統一,位移最後的結果都是 0000 1111
*********************************************************************************/
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
還有點懵?再給個例子,自己去意會吧
0010 0000 0000 0000 0000 0000 0000 0001   引數2^29+1
0010 0000 0000 0000 0000 0000 0000 0000   -1後結果
0011 0000 0000 0000 0000 0000 0000 0000   移1位再|原來資料
0011 1100 0000 0000 0000 0000 0000 0000   移2位再|原來資料
0011 1111 1100 0000 0000 0000 0000 0000   移4位再|原來資料
0011 1111 1111 1111 1100 0000 0000 0000   移8位再|原來資料
0011 1111 1111 1111 1111 1111 1111 1111   移16位後再|原來資料
0100 0000 0000 0000 0000 0000 0000 0000   +1後得到的結果2^30
最後的結果是否就是大於引數的最小冪次方?2^30 就是大於2^29+1的最小冪次方
複製程式碼

傳初始容量建構函式:呼叫了上面的建構函式,因子為預設的0.75

public HashMap(int initialCapacity) {
    this(initialCapacity,DEFAULT_LOAD_FACTOR);
}複製程式碼

無引數建構函式,所有引數使用預設值

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}複製程式碼

通過傳進來一個Map來初始化HashMap

public HashMap(Map<? extends K,? extends V> m) {
    //因子使用預設值
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    //將map複製到當前map中
    putMapEntries(m,false);
}

final void putMapEntries(Map<? extends K,? extends V> m,boolean evict) {
    int s = m.size();
    if (s > 0) {
        //1、槽還未初始化,計算要初始化的槽大小
        //2、槽已經初始化且容量不夠,則通過resize()擴容
        if (table == null) {
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize();
        //將源Map中的key、value儲存到當前Map中
        for (Map.Entry<? extends K,? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key),key,value,false,evict);
        }
    }
}
/********************************擴容函式********************************************/
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    //新的容量值,新的閥值
    int newCap,newThr = 0;
    //已經初始化過
    if (oldCap > 0) {
        /*    
              當前容量已經是最大容量,直接返回
              否則直接容量擴充套件為原來的2倍
        */
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //未初始化過,且初始化容量已經計算過,則容量設為threshold
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //其他情況,直接用預設容量
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //未初始化過,閥值直接設定為容量*因子
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    //定義新容量的槽,重新計算<key,value>的位置
    @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;
                /**
                如果槽位沒有連結串列(紅黑樹),則直接計算槽位儲存的<key,value>新位置,
                否則是連結串列則遍歷連結串列,重新計算連結串列上所有<key,value>的位置,
                紅黑樹同樣做遍歷及計算。
                這裡可以看到計算位置只需要(key的雜湊值)按位與(容量-1),
                其實就是hash值對容量取餘,這就是容量是2的冪次方的好處,計算取餘非常方便。
                */
                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;
                        /**
                        這裡的位置計算有一個小技巧,只“按位與”容量,
                        為0則槽位不變,為1則新槽位=(舊槽位位置+舊容量)*/
                        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;
}

複製程式碼

階段總結

HashMap總共有4個建構函式,分別可以傳入容量、因子、Map。1、預設情況下,初始化容量會是16,因子會是0.752、如果傳入容量,則初始化的容量會是大於傳入的容量的 最小“2的冪次方”。

往HashMap中增刪查資料

一)、新增

新增一個<key,value>
public V put(K key,V value) {
    return putVal(hash(key),true);
}
/**
    這裡有個點需要說明以下,
    1、hashmap不是直接使用物件的雜湊值,
    而是會讓高16為按位異或低16位,這是為了儘量讓32位的hash值都能在計算槽位時起作用,減少衝突。
    2、如果key是null,則hash值會取0,也就是key位null,則會儲存在槽位0的位置。
*/
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;
    //通過hash計算儲存位置,如果位置上沒有節點,則直接將節點放入該位置,槽位個數n為2的冪次方
    //的好處就在這裡,這裡雜湊值對槽大小計算餘數,即要儲存的槽位置,通過按為與即可實現。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash,null);
    else {
        Node<K,V> e; K k;
      //如果計算出的位置有節點,且節點的key等於要新增節點的key,先不做什麼操作,只把已有節點儲存到e
        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,value);
        /**
        連結串列結構,則遍歷連結串列,直到連結串列尾部,或者連結串列中已經有相同的key。
        如果遍歷到連結串列尾部,則新增新節點,如果key已經在連結串列中,則停止遍歷*/
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash,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;
            }
        }
        //key已經存在於Map中,直接替換其value值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //移動節點到連結串列尾部
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //Map版本號+1
    ++modCount;
    //超過閥值,直接擴容
    if (++size > threshold)
        resize();
    //這個方法,HashMap沒做操作,主要是給擴充套件子類來使用
    afterNodeInsertion(evict);
    return null;
}複製程式碼

二)、HashMap中通過key獲取value

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

final Node<K,V> getNode(int hash,Object key) {
    Node<K,V> first,e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //槽位上節點first,如果key等於first的key,直接返回first
        if (first.hash == hash && // always check first node
            ((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);
            //連結串列結構,連結串列結構,直接往後遍歷連結串列,直到找到節點key等於傳進來的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中的節點

final Node<K,V> removeNode(int hash,Object key,Object value,boolean matchValue,boolean movable) {
    Node<K,index;
    //通過hashcode找到儲存的槽位
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null,e; K k; V v;
        //如果槽位上的節點key等於要刪除的key,則將節點記錄到node
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        //否則,槽上面的節點還有下一個節點
        else if ((e = p.next) != null) {
            //如果是樹結構
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash,key);
            //如果是連結串列、遍歷連結串列,直到找到跌點或者找到連結串列尾部
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //判斷是否找到節點
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            //如果是樹,刪除數中的節點
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this,movable);
            /**
            node為要刪除的節點,p為要刪除節點的刪一個節點,
            如果要刪的是槽位置,則p跟node都指向槽位上的節點*/
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            //版本更改
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}複製程式碼

階段總結

至此,增刪查都已經解析完畢,程式碼相對也比較簡單,就是通過hash計算出槽位,再順槽位上的連結串列或者樹查詢元素。注意點:1、HashMap中key跟vlue是可以位null的,key為null會儲存在槽位為0的位置。2、可以看到,增刪都沒有做任何資料同步或者鎖,所以HashMap是非執行緒安全的。

版本號用武之地

通過HashMap物件,可以獲取到key的集合還有Value的集合,或者節點的集合,通過集合Iterator迭代器可以遍歷HashMap中所有的key、value或者節點。

//獲取key的集合、value的集合,還有節點的集合方法如下
hashMap.keySet();
hashMap.values();
hashMap.entrySet();
//再來看看這些集合共用的迭代器
abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot

    HashIterator() {
        expectedModCount = modCount;
        //.....省略不重要程式碼
    }

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

    final Node<K,V> nextNode() {
        Node<K,V>[] t;
        Node<K,V> e = next;
        /*************關鍵程式碼*****************
        迭代器建立的時候會儲存HashMap的版本,一旦發現當前的HashMap版本
        與建立迭代器時候的HashMap版本不一致,則說明遍歷期間,HashMap被其他執行緒修改了,直接丟擲一個ConcurrentModificationException異常
        **/
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        if ((next = (current = e).next) == null && (t = table) != null) {
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }
    //......省略不重要程式碼
}複製程式碼

階段總結:HashMap中有個變數,用來記錄HashMap的版本號。HashMap迭代器對於多執行緒修改的反應是“快速失敗”的方法。迭代過程,一旦發現HashMap被修改了,迭代元素就直接丟擲異常。