HashMap實現原理及源碼閱讀

可訪問個人網站進行閱讀最新版本,精力有限無法多網站同步更新,更新只會在個人網站進行

面試題

先來看看常問的面試題有哪些

  • 底層數據結構
  • hash衝突解決
  • 1.7和1.8區別
  • 擴容機制(爲什麼是2倍)
  • rehash過程
  • 紅黑樹的左右旋

注意:光理論是不夠的,在此送大家一套2020最新Java架構實戰教程+大廠面試題庫,點擊此處進來獲取 一起交流進步哦!

一、底層數據結構

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; // 初始容量16  
    static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量  
    static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比,佔滿0.75進行resize
    static final int TREEIFY_THRESHOLD = 8; 	// 鏈表長度達到8時將鏈表轉換爲紅黑樹 
    static final int UNTREEIFY_THRESHOLD = 6;   // 樹大小爲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;//被修改的次數fast-fail機制  
    int threshold;//臨界值 當實際大小(容量*填充比)超過臨界值時,會進行擴容   
    final float loadFactor;//填充比

// 1.位桶數組
transient Node<k,v>[] table;//存儲(位桶)的數組</k,v>

// 2.數組元素Node<K,V>實現了Entry接口
//Node是單向鏈表,它實現了Map.Entry接口  
static class Node<k,v> implements Map.Entry<k,v> {  
    final int hash;  
    final K key;  
    V value;  
    Node<k,v> next;  
    //構造函數Hash值 鍵 值 下一個節點  
    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;  
    }  
    //判斷兩個node是否相等,若key和value都相等,返回true。可以與自身比較爲true  
    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;  
    }
}

// 3.紅黑樹
static final class TreeNode<k,v> extends LinkedHashMap.Entry<k,v> {  
    TreeNode<k,v> parent;  // 父節點  
    TreeNode<k,v> left; //左子樹  
    TreeNode<k,v> right;//右子樹  
    TreeNode<k,v> prev;    // needed to unlink next upon deletion  
    boolean red;    //顏色屬性  
    TreeNode(int hash, K key, V val, Node<k,v> next) {  
        super(hash, key, val, next);  
    }  
   
    //返回當前節點的根節點  
    final TreeNode<k,v> root() {  
        for (TreeNode<k,v> r = this, p;;) {  
            if ((p = r.parent) == null)  
                return r;  
            r = p;  
        }  
    }
}

在這裏插入圖片描述

在這裏插入圖片描述

總結:1.7的hashmap是由位桶數組+鏈表組成,1.8之後的hashmap由位桶數組+鏈表+紅黑樹組成。其中數組指bucket數組,數組中的元素是實現了map.Entry<k,v>接口的Node<k,v>,每個Node<k,v>包含key,value,next指針,hash值。當put元素時會調用hashcode計算hash值,相同key而value不同的元素會發生哈希碰撞,採用拉鍊拉解決,將該元素插入到鏈表中。當TREEIFY_THRESHOLD>8時,會轉化成紅黑樹。

1.1 構造函數

//構造函數1  
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  
public HashMap(int initialCapacity) {  
    this(initialCapacity, DEFAULT_LOAD_FACTOR);  
}  
   
//構造函數3  
public HashMap() {  
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted  
}  
   
//構造函數4用m的元素初始化散列映射  
public HashMap(Map<!--? extends K, ? extends V--> m) {  
    this.loadFactor = DEFAULT_LOAD_FACTOR;  
    putMapEntries(m, false);  
}

二、存取機制

在明白它是怎麼取之前需要先明白是怎麼存的

2.1 put(K key, V value)

public V put(K key, V value) {  
    return putVal(hash(key), key, value, false, true);  
}  
/** 
     * Implements Map.put and related methods 
     * 
     * @param hash hash for key 
     * @param key the key 
     * @param value the value to put 
     * @param onlyIfAbsent if true, don't change existing value 元素已經存在,是否改變現值
     * @param evict if false, the table is in creation mode. 區別通過put添加還是創建時初始化數據的
     * @return previous value, or null if none 
     */  
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; // resize()不僅用來調整大小,還用來進行初始化配置 
    	
    	/*如果table的在(n-1)&hash的值是空,就新建一個節點插入在該位置*/ 
    	// (n-1)&hash相當於hash%(n-1)
        if ((p = tab[i = (n - 1) & hash]) == null)  
            tab[i] = newNode(hash, key, value, null);  
    	/*表示有衝突,該位置已存值,開始處理衝突,採用拉鍊法或是紅黑樹*/  
        else {  
            Node<K,V> e;   
        	K k;  
    		/*檢查第一個Node,p是不是要找的值*/  
            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個,看是否需要改變衝突節點的存儲結構,  
            //treeifyBin首先判斷當前hashMap的長度,如果不足64,只進行  
                        //resize,擴容table,如果達到64,那麼將衝突的存儲結構爲紅黑樹  
                        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值,修改元素值*/  
            if (e != null) { // existing mapping for key,就是key的Value存在  
                V oldValue = e.value;  
                if (!onlyIfAbsent || oldValue == null)  
                    e.value = value;  
                afterNodeAccess(e);  
                return oldValue;//返回存在的Value值  
            }  
        }  
        ++modCount;  // 修改次數+1
     	/*如果當前大小大於門限,門限原本是初始容量*0.75*/  
        if (++size > threshold)  
            resize();//擴容兩倍  
        afterNodeInsertion(evict);  
        return null;  
    }

下面簡單說下添加鍵值對put(key,value)的過程:

  1. 判斷位桶數組是否爲空數組,是則通過resize初始化

  2. 通過hash(key)計算hash值判斷該Node<k,v>應該插入的位置(不同的key可能有相同的hashcode)

  3. 如果該位置還沒插入值,則直接插入;如果已存在值

    • 判斷key是否相同,是:則用e記錄該結點;
    • 否:則判斷table[i]是否爲樹結點,
      • 是:則以紅黑樹的方式插入,用e記錄;
      • 否:則遍歷鏈表插入到鏈尾(如果長度>8轉成紅黑樹);遇到已存該元素的情況下,用e記錄,並退出
  4. 在上述步驟中,都有用e記錄了數組中或鏈表或紅黑樹已存在該元素的信息。通過修改e來覆蓋原值

  5. 判斷加入結點後是否超過門限值,是否需要擴容

2.1.1 hash()方法與hashcode()方法

我們通過hash方法計算索引,得到數組中保存的位置

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

以看到HashMap中的hash算法是通過key的hashcode值與其hashcode右移16位後得到的值進行異或運算得到的,那麼爲什麼不直接使用key.hashCode(),而要進行異或操作?我們知道hash的目的是爲了得到進行索引,而hash是有可能衝突的,也就是不同的key得到了同樣的hash值,這樣就很容易產業碰撞,如何減少這種情況的發生呢,就通過上述的hash(Object key)算法將hashcode 與 hashcode的低16位做異或運算,混合了高位和低位得出的最終hash值,衝突的概率就小多了

2.1.2 Fail-Fast 機制

我們知道 java.util.HashMap 不是線程安全的,因此如果在使用迭代器的過程中有其他線程修改了map,那麼將拋出ConcurrentModificationException,這就是所謂fail-fast策略。這一策略在源碼中的實現是通過 modCount 域,modCount 顧名思義就是修改次數,對HashMap 內容的修改都將增加這個值,那麼在迭代器初始化過程中會將這個值賦給迭代器的 expectedModCount。在迭代過程中,判斷 modCount 跟 expectedModCount 是否相等,如果不相等就表示已經有其他線程修改了 Map:注意到 modCount 聲明爲 volatile,保證線程之間修改的可見性。

所以在這裏和大家建議,當大家遍歷那些非線程安全的數據結構時,儘量使用迭代器

2.2 get(key)

通過put過程,我們已經知道Node(k,v)是怎麼保存到map中的,現在來看看怎麼取

public V get(Object key) {  
    Node<K,V> e;  
    return (e = getNode(hash(key), key)) == null ? null : e.value;  
}  
/** 
     * Implements Map.get and related methods 
     * 
     * @param 該key的hash值和key
     * @param key the key 
     * @return the node, or null if none 
     */  
final Node<K,V> getNode(int hash, Object key) {  
    Node<K,V>[] tab;//Entry對象數組  
    Node<K,V> first,e; //在tab數組中經過散列的第一個位置  
    int n;  
    K k;  
    /*找到插入的第一個Node,方法是hash值和n-1相與,tab[(n - 1) & hash]*/  
    //也就是說在一條鏈上的hash值相同的  
    if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {  
        /*檢查第一個Node是不是要找的Node*/  
        if (first.hash == hash && // always check first node  
            ((k = first.key) == key || (key != null && key.equals(k))))//判斷條件是hash值要相同,key值要相同  
            return first;  
        /*檢查first後面的node*/  
        if ((e = first.next) != null) {  
            if (first instanceof TreeNode)  
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);  
            /*遍歷後面的鏈表,找到key值和hash值都相同的Node*/  
            do {  
                if (e.hash == hash &&  
                    ((k = e.key) == key || (key != null && key.equals(k))))  
                    return e;  
            } while ((e = e.next) != null);  
        }  
    }  
    return null;  
}
  1. 通過hash(key)找到bucket數組中該hash值的位置,判斷該位置的元素也就是first的key是否與要找的這個key相同
    • 是:則返回該first元素
    • 否:判斷first是否是樹節點
      • 是:則通過紅黑樹的方式進行查找
      • 否:遍歷鏈表查找到key相同的Node並返回
  2. 如果沒找到,則返回null

2.3 面試題

2.3.1 hashcode()與equals()區別

get()查找元素的過程:計算key的hashcode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。

Object的equals()是基於比較內存地址實現的,hashcode()是比較內存地址的hash值

在map中,hashcode(實際是hash方法,封裝了hashcode和低16位異或運算)用來計算key應該放在數組中的哪個位置,equals是用在有多個hashcode相同的情況下查找需要的key。

2.3.2 爲什麼要重寫equals()方法?

因爲object中的equals()方法比較的是對象的引用地址是否相等,如何你需要判斷對象裏的內容是否相等,則需要重寫equals()方法。

2.3.3 爲什麼改寫了equals(),也需要改寫hashcode()

如果你重載了equals,比如說是基於對象的內容實現的,而保留hashCode的實現(基於內存地址的hash值)不變,那麼在添加進map中時需要比對hashcode,很可能某兩個對象明明是“相等”,而hashCode卻不一樣。

2.3.4 爲什麼改寫了hashcode(),也需要改寫equals()

Hashmap的key可以是任何類型的對象,例如User這種對象,爲了保證兩個具有相同屬性的user的hashcode相同,我們就需要改寫hashcode方法,比方把hashcode值的計算與User對象的id關聯起來,那麼只要user對象擁有相同id,那麼他們的hashcode也能保持一致了,這樣就可以找到在hashmap數組中的位置了。如果這個位置上有多個元素,還需要用key的equals方法在對應位置的鏈表中找到需要的元素,所以只改寫了hashcode方法是不夠的,equals方法也是需要改寫。

在改寫equals方法的時候,需要滿足以下三點:
(1) 自反性:就是說a.equals(a)必須爲true。
(2) 對稱性:就是說a.equals(b)=true的話,b.equals(a)也必須爲true。
(3) 傳遞性:就是說a.equals(b)=true,並且b.equals©=true的話,a.equals©也必須爲true。
通過改寫key對象的equals和hashcode方法,我們可以將任意的業務對象作爲map的key(前提是你確實有這樣的需要)。

三、擴容機制

當hashmap中的元素個數超過數組大小*loadFactor時,就會進行數組擴容,loadFactor的默認值爲0.75,也就是說,默認情況下,數組大小爲16,那麼當hashmap中元素個數超過16*0.75=12的時候,就把數組的大小擴展爲2*16=32,即擴大爲原來2倍,然後重新調用hash方法找到新的bucket位置。

3.1 resize()

jdk1.7的源碼

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    // 創建一個新的 Hash Table
    Entry[] newTable = new Entry[newCapacity];
    // 將 Old Hash Table 上的數據遷移到 New Hash Table 上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
// 遷移數組
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面這段代碼的意思是:
    //  從OldTable裏摘一個元素出來,然後放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

遷移過程:

單線程下的遷移:在擴容之後,重新計算hash定位到新數組中,相同hash值的元素照樣連接成鏈表,只是鏈表相對位置進行了反轉。

多線程下的遷移:

線程1在獲取next結點之後被掛起,Thread 1 的 e 指向了 key(3),而 next 指向了 key(7)。線程2順利完成rehash過程,鏈表反轉。

do {
    Entry<K,V> next = e.next; //  假設線程一執行到這裏就被調度掛起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

線程1繼續執行,仍會把線程二的新表當成原始的hash表,將原來e指向的key(3)節點當成是線程二中的key(3),放在自己所建newTable[3]的頭節點,線程1的next仍然指向key(7),此時key(3)的next已經是null。

 e.next = newTable[i];	// key(3)的 next 指向了線程1的新 Hash 表,因爲新 Hash 表爲空,所以e.next = null
 newTable[i] = e;	// 線程1的新 Hash 表第一個元素指向了線程2新 Hash 表的 key(3)。e 處理完畢
 e = next;			// 將 e 指向 next,所以新的 e 是 key(7)

線程1的e指向了上一次循環的next,也就是key(7),此時key(7)的next已經是key(3)。將key(7)插入到table[0]的頭節點,並且將key(7)的next設置爲key(3), e 和next繼續往下移。此時仍然沒有問題。

繼續下一次循環,e.next = newTable[i] 導致 key(3).next 指向了 key(7),但此時的 key(7).next 已經指向了 key(3), 環形鏈表就這樣出現了。

jdk1.8的源碼

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //以前的容量大於0,也就是hashMap中已經有元素了,或者new對象的時候設置了初始容量
    if (oldCap > 0) {
        //如果以前的容量大於限制的最大容量1<<30,則設置臨界值爲int的最大值2^31-1
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        /**
         * 如果以前容量的2倍小於限制的最大容量,同時大於或等於默認的容量16,則設置臨界值爲以前臨界值的2
         * 倍,因爲threshold = loadFactor*capacity,capacity擴大了2倍,loadFactor不變,
         * threshold自然也擴大2倍。
         */
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    /**
     * 在HashMap構造器Hash(int initialCapacity, float loadFactor)中有一句代碼,this.threshold  	
     * = tableSizeFor(initialCapacity), 表示在調用構造器時,默認是將初始容量暫時賦值給了
     * threshold臨界值,因此此處相當於將上一次的初始容量賦值給了新的容量。什麼情況下會執行到這句?當調用 	 
     * 了HashMap(int initialCapacity)構造器,還沒有添加元素時
     */
    else if (oldThr > 0) 
        newCap = oldThr;
    /**
     * 調用了默認構造器,初始容量沒有設置,因此使用默認容量DEFAULT_INITIAL_CAPACITY(16),臨界值
     * 就是16*0.75
     */
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //對臨界值做判斷,確保其不爲0,因爲在上面第二種情況(oldThr > 0),並沒有計算newThr
    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
    table = newTab;
    if (oldTab != null) {
        //遍歷將原來table中的數據放到擴容後的新表中來
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //沒有鏈表Node節點,直接放到新的table中下標爲[e.hash & (newCap - 1)]位置即可
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果是treeNode節點,則樹上的節點放到newTab中
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //如果e後面還有鏈表節點,則遍歷e所在的鏈表,
                else { // 保證順序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        //記錄下一個節點
                        next = e.next;
                        /**
                         * newTab的容量是以前舊錶容量的兩倍,因爲數組table下標並不是根據循環逐步遞增
                         * 的,而是通過(table.length-1)& hash計算得到,因此擴容後,存放的位置就
                         * 可能發生變化,那麼到底發生怎樣的變化呢,就是由下面的算法得到.
                         *
                         * 通過e.hash & oldCap來判斷節點位置通過再次hash算法後,是否會發生改變,如
                         * 果爲0表示不會發生改變,如果爲1表示會發生改變。到底怎麼理解呢,舉個例子:
                         * e.hash = 13 二進制:0000 1101
                         * oldCap = 32 二進制:0001 0000
                         *  &運算:  0  二進制:0000 0000
                         * 結論:元素位置在擴容後不會發生改變
                         */
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        /**
                         * e.hash = 18 二進制:0001 0010
                         * oldCap = 32 二進制:0001 0000
                         * &運算:  32 二進制:0001 0000
                         * 結論:元素位置在擴容後會發生改變,那麼如何改變呢?
                         * newCap = 64 二進制:0010 0000
                         * 通過(newCap-1)&hash
                         * 即0001 1111 & 0001 0010 得0001 0010,32+2 = 34
                         */
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        /**
                         * 若(e.hash & oldCap) == 0,下標不變,將原表某個下標的元素放到擴容表同樣
                         * 下標的位置上
                         */
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        /**
                         * 若(e.hash & oldCap) != 0,將原表某個下標的元素放到擴容表中
                         * [下標+增加的擴容量]的位置上
                         */
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

總結:

  1. 如果table == null, 則爲HashMap的初始化, 生成空table返回即可;

  2. 如果table不爲空, 需要重新計算table的長度, newLength = oldLength << 1(注, 如果原oldLength已經到了上限, 則newLength = oldLength);

  3. 遍歷oldTable,oldTable[i]爲空,遍歷下一個

    • 否:判斷oldTable[i].next是否爲空
      • 是:存放到newTable中newTab[e.hash & (newCap - 1)]
      • 否:判斷是否紅黑樹
        • 是:走紅黑樹的重定位
        • 否:JAVA7時還需要重新計算hash位, 但是JAVA8做了優化, 通過(e.hash & oldCap)== 0來判斷節點位置通過再次hash算法後,是否會發生改變
          • 是:移動到當前hash槽位 + oldCap的位置
          • 否:移動到新表中原下標的位置

注:newCap/oldCap爲容量

四、面試題

4.1 擴容爲什麼是2倍?

主要與HashMap計算添加元素的位置時,使用的位運算有關,這是特別高效的運算;HashMap的初始容量是2的n次冪,擴容也是2倍的形式進行擴容,可以使得添加的元素均勻分佈在HashMap中的數組上,減少hash碰撞,避免形成鏈表的結構,使得查詢效率降低。

4.2 爲什麼String, Interger這樣的wrapper類適合作爲鍵?

如果兩個不相等的對象返回不同的hashcode的話,那麼碰撞的機率就會小些,這樣就能提高HashMap的性能,也就適合做Hashmap的鍵。因爲獲取對象的時候要用到equals()和hashCode()方法,鍵對象正確的重寫這兩個方法是非常重要的。
因此,String,Interger這樣的wrapper類作爲HashMap的鍵是再適合不過了,而且String最爲常用。因爲String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因爲爲了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那麼就不能從HashMap中找到你想要的對象

4.3 線程不安全的原因

HashMap在併發場景下可能存在以下問題:

死循環:在jdk1.7中,resize過程中,從舊數組重新遷移至新數組的過程中,仍可能會發生hash衝突,形成鏈表,鏈表的相對位置發生了反轉,那麼在併發環境下,容易出現多線程同時resize的情況,那麼就有可能在遷移過程中發生閉環,一旦發生閉環,進行get()操作的時候就會陷入死循環。在jdk1.8中,用 head 和 tail 來保證鏈表的順序和之前一樣,因此不會出現發生閉環的情況。

數據丟失

  1. 如果多個線程同時使用 put 方法添加元素,而且假設正好存在兩個 put 的 key 發生了碰撞(根據 hash 值計算的 bucket 一樣),那麼根據 HashMap 的實現,這兩個 key 會添加到數組的同一個位置,這樣最終就會發生其中一個線程 put 的數據被覆蓋

  2. 如果多個線程同時檢測到元素個數超過數組大小 * loadFactor,這樣就會發生多個線程同時對 Node 數組進行擴容,都在重新計算元素位置以及複製數據,但是最終只有一個線程擴容後的數組會賦給 table,也就是說其他線程的都會丟失,並且各自線程 put 的數據也丟失

4.4 你瞭解重新調整HashMap大小存在什麼問題嗎?

Jdk1.7 當多線程的情況下,可能產生條件競爭(race condition)。

當重新調整HashMap大小的時候,如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試着調整大小。在調 整大小的過程中,存儲在鏈表中的元素的次序會反過來,因爲移動到新的bucket位置的時候,HashMap並不會將元素放在鏈表的尾部,而是放在頭部, 這是爲了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那麼就死循環了。

注意:最後送大家一套2020最新Java架構實戰教程+大廠面試題庫,點擊此處進來獲取 一起交流進步哦!
注:尾部遍歷(避免尾部遍歷是爲了避免在新列表插入數據時,遍歷隊尾的位置。因爲,直接插入的效率更高。)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章