1. 程式人生 > >從原始碼深入理解HashMap(附加HashMap面試題)

從原始碼深入理解HashMap(附加HashMap面試題)

HashMap向來是面試中的熱點話題,深入理解了HashMap的底層實現後,才能更好的掌握它,也有利於在專案中更加靈活的使用。

本文基於JDK8進行解析

一、HashMap解析

1. 結構

HashMap結構由陣列加**連結串列(或紅黑樹)**構成。主幹是Entry陣列,Entry是HashMap的基本單位,每一個Entry包含key-value鍵值對。每個Entry可以看成是一個連結串列(或紅黑樹),但是比較特殊的是,當連結串列中的節點個數超過8時,連結串列會轉為紅黑樹進行元素儲存。

####2. 原理
HashMap中的每個數通過雜湊函式計算得出的hash值作為陣列下標,儲存著一對key-value鍵值對。當對一個元素進行查詢時,若該下標處Entry為空,則表示元素不存在,否則通過遍歷連結串列方式進行查詢。

雜湊衝突:雜湊衝突也叫雜湊碰撞,當對兩個不同數進行hash函式計算後可能會得到相同的hash值,即存入陣列下標相同,此時就發生了碰撞,hashMap使用鏈地址法儲存具有相同hash值的元素。

除此之外,解決hash衝突的辦法還有開放地址法(發生hash衝突,尋找下一塊未被佔用的儲存地址)、再雜湊函式法(對求得的hash值再進行一遍hash運算)。

注意:HashMap是執行緒不安全,當多個執行緒進行put操作時,可能會造成put死迴圈。

3. 原始碼分析

HashMap位於java.util.HashMap

部分成員變數
//預設陣列大小16,即2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //陣列最大容量,2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //當連結串列轉換為紅黑樹時,若陣列大小小於64,即為32、16或更小時,需要對陣列進行擴容 static final int MIN_TREEIFY_CAPACITY = 64; //負載因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //用於判斷陣列是否需要進行擴容,即當陣列元素 >= 陣列容量*負載因子時,需要對陣列進行擴容
int threshold;

HashMap預設構造器大小為16,並且要求陣列長度必須為2的次冪。HashMap中有負載因子,預設值為0.75.當HashMap中元素個數超過陣列長度*0.75時,會對陣列進行擴容,擴容後會重新計算每個元素的雜湊值,即涉及到陣列元素的遷移。

Node節點

在JDK7中使用Entry表示陣列中的每個元素,JDK8中使用Node,這兩者並沒有什麼區別,為了更好的理解陣列中的每個元素,下面將以Node進行描述。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
		...
}

每一個節點處都存有key的hash值、key、value和next鏈四個屬性,其中next鏈用來指向相同hash值不同key值的下一個節點。

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

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //table為空則建立
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //通過hash值找到對應的陣列下標,如果該下標處元素為空,則直接插入值
        //通過(n - 1) & hash運算,可以保證計算出的下標在陣列大小範圍內
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //該下標處元素不為空
        else {
            Node<K,V> e; K k;
            //判斷陣列下標處的元素是否就是要插入的元素,判斷key值是否相等
            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;
                    }
                    //存在該key則跳出迴圈
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //e != null表示存在與插入值相同的key,直接進行覆蓋
            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;
    }

put流程:

  1. 判斷陣列是否為空,為空則初始化陣列
  2. 計算該key的hash值作為key存入陣列的下標
  3. 判斷該下標處有無元素,沒有則直接插入
  4. 若有元素存在,則首先判斷該元素是否為待插入的元素,是則直接覆蓋,否則判斷該元素是否為紅黑樹的節點,是則進行紅黑樹的插入,否則遍歷連結串列進行插入,如果遍歷過程中找到key相同的元素則替換,否則在連結串列尾部插入該節點

在程式碼93行可以發現,在put操作完成之後需要對陣列大小進行判斷,若超過陣列容量閾值,則需要對陣列進行擴容。

treeifyBin操作

JDK8之前對於陣列元素以連結串列的形式儲存,對於陣列的索引通過hash值可以直接定位,時間複雜度為O(1),因此對於HashMap查詢的時間複雜度取決於遍歷連結串列所花費的時間。當連結串列長度過長時會對索引帶來不必要的麻煩,因此在JDK8開始,採用連結串列或者紅黑樹的方式進行相同hash值不同key值元素的儲存。

treeifyBin(Node,int)方法則就是當連結串列長度過長時,將連結串列轉為紅黑樹進行儲存。

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //判斷陣列長度是否大於64,小於則擴容
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
				//將Node節點轉換成TreeNode節點
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
get操作
    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>[] 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 && // 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);
                //否則去連結串列中查詢
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

get流程:

  1. 計算key值的hash值
  2. 通過hash值找到陣列下標,若該下標元素為空則返回null,否則判斷該元素是否是待查詢的值,是就直接返回
  3. 若不是判斷該元素節點是否為紅黑樹節點,是則進行紅黑樹的查詢,否則進行連結串列的查詢
resize操作

在構造hash表時如果不指定陣列的大小,則陣列預設初始化大小為16,當陣列元素超過最大容量的75%時就需要對陣列進行擴容,而且擴容是件非常耗時的操作。

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) {
            //如果舊陣列容量大於等於最大容量,則修改threshold的值,並返回舊陣列
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //否則將陣列大小擴大為原來的兩倍(二進位制左移一位),並且將threshold也擴大為原來的兩倍
            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
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            //新陣列長度乘以負載因子作為新陣列的threshold
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        //建立新陣列
        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
                        //建立兩條鏈,lo鏈跟hi鏈,也就是將原先本應該在一條連結串列上的節點如今分成兩條鏈存放
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //將(e.hash & oldCap)計算出值為偶數的放在lo鏈上
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //奇數的放在hi鏈上
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //將lo鏈放在新陣列的原位置
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //將hi鏈放在新陣列的原位置+oldCap的下標處
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

總結

HashMap底層通過陣列加連結串列或紅黑樹(JDK8開始)進行資料儲存,當連結串列中儲存過長時會導致查詢效率降低,因此當連結串列長度超過8個時將採用紅黑樹的方式進行儲存。

當陣列元素超過陣列容量*負載因子大小時,將對陣列進行擴容,擴容也可以使得其中的連結串列長度降低,從而提高查詢速度。

對於key值的使用推薦使用不可變類,比如IntegerString等等。因為對於key值的定位是通過計算它的hash值找到在陣列中的下標,再通過key的equals方法找到在連結串列中的位置。因此要求兩個key值相等的物件,它們的hash值必須相等,而相同的hash值並不要求一定equals。

二、HashMap面試題

1.HashMap的工作原理,其中get()方法的工作原理?
2.我們能否讓HashMap同步?
3.關於HashMap中的雜湊衝突(雜湊碰撞)以及衝突解決辦法?
4.如果HashMap的大小超過負載因子定義的容量會怎麼辦?
5.你瞭解重新調整HashMap大小存在什麼問題嗎?
6.為什麼String, Interger這樣的wrapper類適合作為鍵?
7.我們可以使用自定義的物件作為鍵嗎?
8.我們可以使用CocurrentHashMap來代替Hashtable嗎?
9.HashMap擴容問題?
10.為什麼HashMap是執行緒不安全的?如何體現出不安全的?
11.能否讓HashMap實現執行緒安全,如何做?
12.HashMap中hash函式是怎麼實現的?
13.HashMap什麼時候需要重寫hashcode和equals方法?

長期收錄HashMap面試題,答案也會在下篇文章中給出。
更多瞭解,還請關注我的個人部落格:www.zhyocean.cn