JAVA面試HashMap之一問到底

HashMap是工作中經常使用到的集合類,當然面試中也會經常被問到。下面我們就來看下面試中常見的HashMap問題吧。

HashMap如何解決Hash碰撞的?

HashMap底層是有數組+鏈表+紅黑樹(jdk1.8)的數據結構來實現的。
當發生hash碰撞後,hashMap會把當前node(key-val)加到發生碰撞的hash位置的鏈表中。1.8後如果到達轉化爲紅黑樹的閥那麼就會發生重新把鏈表轉化爲紅黑樹。如果添加的node對應hash位置發生碰撞的是TreeNode(紅黑樹節點),那麼直接對添加到該紅黑樹(涉及到左旋右旋等)。

剛剛提到了紅黑樹,那麼爲什麼jdk1.8要使用紅黑樹而不用val樹或者B樹而是要用紅黑樹呢?

此問題同時考驗了你對val,b樹和紅黑樹的理解。
1,首先val樹,這個是一個比較嚴格的平衡二叉樹,而紅黑樹相對沒有那麼嚴格,val在查找方面大多數情況快於紅黑樹但是在插入刪除性能低於紅黑樹,而紅黑樹在查找、插入、刪除幾個方面都比較不錯。因爲HashMap增刪查都使用的比較多,所以選擇性能均衡的紅黑樹。
2,B樹(這個一般數據庫索引使用的比較多),B樹不是二叉樹,是一個平衡多路查找樹,相同數據B樹的整體高度一般會低於紅黑樹,因爲每個節點可以存放多個數據。B樹在數據庫中索引使用的較多,他的層級低可以最大限度的較少磁盤IO。但是在每個節點中又需要遍歷每個節點才能找到對應的數據。這樣效率肯定沒有紅黑樹效率高。

如果我用一個User作爲Key,User的Id相同的Key不能重複該怎麼做?

需要重寫Key對象的hashCode方法和equals方法,因爲HashMap在判斷相同key的時候使用的下面這樣的代碼:

if (p.hash == hash &&
   		((k = p.key) == key || (key != null && key.equals(k))))

判斷hash時候相等(hash就是key的hashCode然後進行位運算後的數字)並且equals爲true,這個時候就會爲相同key。
所以我們在hashCode和equals中判斷id就可以實現了。

HashMap如何遍歷以及原理?

HashMap本身不能被遍歷。
這裏首先說下java幾種遍歷的(不包括stream等)
1、遍歷數組,使用下標獲取,比如ArrayList
2、foreach,這個遍歷我們需要實現Iterable接口
3、直接遍歷相關迭代器。使用while進行hasNext判斷。
HashMap提供了三種迭代器,分別是:KeyIterator(KeySet),,ValueIterator(Values),EntryIterator(EntrySet)。
其實三種遍歷方式都是一樣,只是返回的數據不同而已,第一種只返回key第二種只返回value,第三種返回Node。遍歷代碼主要邏輯代碼:

 for (int i = 0; i < tab.length; ++i) {
      for (Node<K,V> e = tab[i]; e != null; e = e.next)
           action.accept(e.value);
     }

首先遍歷數組然後next查找鏈表。由於hash裏面的數組是一個hash散列,因此這樣的遍歷是沒有先後順序的,但是對於一個節點的鏈表中的數據有時候是有先後順序(僅僅是沒有resize和沒有轉化爲紅黑樹的情況)。

遍歷中使用next,那麼說下紅黑樹節點是如何遍歷的?

其實紅黑樹節點是集成了Node的,雖然是紅黑樹,但是同時也有一個鏈表在維護其中的前後數據關係,關於紅黑樹的前後關係維護,參考如下代碼:

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v) {
            Class<?> kc = null;
            boolean searched = false;
            TreeNode<K,V> root = (parent != null) ? root() : this;
            for (TreeNode<K,V> p = root;;) {
                int dir, ph; K pk;
                if ((ph = p.hash) > h)
                    dir = -1;
                else if (ph < h)
                    dir = 1;
                else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                    return p;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0) {
                    if (!searched) {
                        TreeNode<K,V> q, ch;
                        searched = true;
                        if (((ch = p.left) != null &&
                             (q = ch.find(h, k, kc)) != null) ||
                            ((ch = p.right) != null &&
                             (q = ch.find(h, k, kc)) != null))
                            return q;
                    }
                    dir = tieBreakOrder(k, pk);
                }

                TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    Node<K,V> xpn = xp.next;
                    TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    xp.next = x;
                    x.parent = x.prev = xp;
                    if (xpn != null)
                        ((TreeNode<K,V>)xpn).prev = x;
                    moveRootToFront(tab, balanceInsertion(root, x));
                    return null;
                }
            }
        }

代碼分析可以知道紅黑樹的添加的時候首先判斷父節點hash和當前節點hash的大小,如果小在左邊,大在右邊,這個就是紅黑樹,比較好理解。接下來看next邏輯:
1、如果當前父節點左右都沒有數據,那麼父node的next直接爲當前node。
2、如果右邊有數據,但是右邊的子節點沒數據,那麼把父節點的next指向本節點的next,同時把父節點的next指向當前節點。(左邊有數據右邊沒數據一樣)。
3、如果右邊有數據,但是右邊的子節點有數據,這時和第2種情況一樣,那麼把父節點的next指向本節點的next,同時把父節點的next指向當前節點。
這樣紅黑樹結構的鏈表就形成了。

鏈表和紅黑樹相互轉化的時機?

HashMap內部有一個參數(TREEIFY_THRESHOLD=8),當hash桶位碰撞的數據的超過這個參數時會進行重構爲紅黑樹(調用treeifyBin進行),
當紅黑樹中數據少於(UNTREEIFY_THRESHOLD = 6)這個參數時就會轉化爲鏈表。

描述下put的整個流程

  1. 首先對key進行hash算法算出hash。
static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  1. 判斷hash桶(table)是否爲空,爲空或者長度爲0,那麼進行resize初始化hash桶的數組。hash散列算法代碼:
(n - 1) & hash
  1. 取key對應的hash數組位置時候不存在,不存在那麼創建一個node把當前key-val放進去。
  2. 如果存在,那麼取出來,判斷hash是否和equals返回爲ture,如果是就直接覆蓋當前value。
  3. 如果是紅黑樹節點,那麼直接添加到紅黑樹。
  4. 如果是鏈表形式的那麼添加到鏈表
  5. 最後如果hash桶數據 達到閥值(負載因子*數組容量),那麼進行resize。

HashMap擴容?爲什麼容量總是2的n次冪?爲什麼初始容量是16?

HashMap有默認的一個負載因子(0.75)。當size到達了capacity * load factor那麼就會發生擴擴容,擴容的具體resize方法:

final Node<K,V>[] resize() {
	...省略
	//擴容機制,直接爲原來的兩倍。這個也是容量爲什麼是2的n次冪原因之一
	else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold 	
    ...省略
    //擴容後會對老的hash桶進行遍歷把數據重新放入到新的hash桶。
	Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
    ...省略        
            
}

爲什麼是2的n次冪有兩個原因:1,初始容量爲2的n次冪。2,每次擴容爲上次的兩倍。
第二個原因我們已經知道了,現在主要分析爲什麼初始容量是2的n次冪。
先看下jdk的註釋:

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

初始容量必須是2的倍數。至於原因jdk其實也有。因爲在計算hash數組的下標時候會進行這樣的會運算(n-1)&hash。這個算法對於爲什麼容量是2^n非常重要。
下面我編寫一個demo演示你就一目瞭然了。
在這裏插入圖片描述
看見了嗎?
如果容量爲奇數那麼(n-1)&hash公示計算出來的下標永遠都是偶數,這個樣就會有很多的hash桶位置不能得到利用。

問題又來了!那爲什麼不是其他偶數呢?比如12,14等。
這個就要分析計算機位運算的二進制了。2^n二進制表現形式位100000…00000這種形式,如果n-1那麼就會位0111111…11111這種形式,這種形式的n-1與添加元素的hash值進行位運算時,能夠充分的散列,使得添加的元素均勻分佈在HashMap的每個位置上,減少hash碰撞。

至於爲什麼初始容量是16,我理解這是一個統計學的問題,可能大多數情況16就夠了,但是2,4,8又太少了。

加載因子爲什麼是0.75

先說說我的理解,當然不一定正確。
首先理解需要在不能是如果是0.5以及一下,那麼就會浪費很多內存空間,比如16的時候容量到達8個就會擴容到32。這個過於浪費。
如果1的時候再擴容,那麼勢必會產生更多的hash碰撞。因此折中一個0.75.

下面是jdk對加載因子的解釋和分析。

* Because TreeNodes are about twice the size of regular nodes, we
 * use them only when bins contain enough nodes to warrant use
 * (see TREEIFY_THRESHOLD). And when they become too small (due to
 * removal or resizing) they are converted back to plain bins.  In
 * usages with well-distributed user hashCodes, tree bins are
 * rarely used.  Ideally, under random hashCodes, the frequency of
 * nodes in bins follows a Poisson distribution
 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 * parameter of about 0.5 on average for the default resizing
 * threshold of 0.75, although with a large variance because of
 * resizing granularity. Ignoring variance, the expected
 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
 * factorial(k)). The first values are:
 *
 * 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
 * more: less than 1 in ten million

HashMap是有序的嗎?爲什麼?說一說LinkedHashMap如何實現有序的?

HashMap不是有序的,因爲HashMap遍歷的時候是先對數組遍歷,然後遍歷鏈表的。hash本來就是無需的。遍歷如下:

 do {} while (index < t.length && (next = t[index++]) == null);

LinkedHashMap維護了一個鏈表來標示每個node的前後關係,不僅僅是hash衝入的時候纔會有鏈表。

HashMap是線程安全的嗎?ConcurrentHashMap底層實現原理?

HashMap不是線程安全的。

ConcurrentHashMap是線程安全的,他比HashTable更加高效,因爲他的鎖範圍更加精細,鎖粒度更加小。

那麼ConcurrentHashMap是如何保證線程安全的呢?(1.8)
1,在設置值和判斷空的時候使用CAS。比如在判斷是否初始化數組的時候使用U.compareAndSwapObject。在獲取hash散列的中的某個元素的時候使用U.getObjectVolatile,配合volatile。
2,在hash碰撞後的處理中使用synchronized鎖住第一個節點。

1.8以前使用的是分段鎖Segment分段鎖(繼承的ReentrantLock)。

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