一個HashMap,阿里面試官竟然跟我扯了半個小時!

前言

BAT大廠一直都是我們程序員的理想型,很多搞Android的小夥伴都想進這些大公司,但是大家也在擔心:我能順利通過面試嗎?面試遇到的問題會不會很難?

其實,像阿里巴巴、騰訊、字節跳動、百度這些大廠,他們的面試沒有大家想象中的那麼可怕,也不會存在面試官刻意刁難的現象,但是,相對那些小廠,他們會更加重視應聘者的基礎知識。

比如HashMap這個知識點,不管是Java還是Android崗,基本上都是面試中的必問題,尤其是像阿里巴巴,騰訊,字節跳動等這些大廠。

因爲其中的知識點太多,很適合用來考察面試者的Java基礎。

下面的故事改編自某大佬的真實經歷。

這是一個大佬在阿里巴巴的一次面試經過,大家可以參考一下,看看自己能不能答上來,如果換做自己,又會怎樣應對,作爲希望對大家有所幫助。

和麪試官的半小時閒聊

頭髮稀疏的面試官: 你先做個自我介紹吧。

: 我是×××,××年畢業於××大學(畢業院校這個可以看情況來說,有優勢可以提一下,比如什麼名牌大學,或者和老闆是校友什麼的),三年半Android工作經驗,目前在公司做系統開發。(這個很簡單,你也可以事先準備一下,讓自己牛逼一點,但是我覺得沒必要誇大什麼的,坦誠比較好。不然接下來的面試只會更難受)

面試官: 看你簡歷上寫熟悉Java基礎好,HashMap用過的吧?

: 用過。(不需要更多修飾,個人覺得除非你真的在某領域很牛逼,不然就不要多話)

面試官: 那你跟我講講HashMap的內部數據結構?(來了來了,開始挖底層了)

: 目前我用的是JDK1.8版本的,內部使用數組 + 鏈表紅黑樹;可以的話,我讓來畫個數據結構圖吧:

面試官: 那你清楚HashMap的數據插入原理嗎?(來了,就是這個味道)

: 我還是習慣畫個圖,會更加明晰。


開始我的表演:

  1. 判斷數組是否爲空,爲空進行初始化;
  2. 不爲空,計算 k 的 hash 值,通過(n - 1) & hash計算應當存放在數組中的下標 index;
  3. 查看 table[index] 是否存在數據,沒有數據就構造一個Node節點存放在 table[index] 中;
  4. 存在數據,說明發生了hash衝突(存在二個節點key的hash值一樣), 繼續判斷key是否相等,相等,用新的value替換原數據(onlyIfAbsent爲false);
  5. 如果不相等,判斷當前節點類型是不是樹型節點,如果是樹型節點,創造樹型節點插入紅黑樹中;(如果當前節點是樹型節點證明當前已經是紅黑樹了)
  6. 如果不是樹型節點,創建普通Node加入鏈表中;判斷鏈表長度是否大於 8並且數組長度大於64, 大於的話鏈表轉換爲紅黑樹;
  7. 插入完成之後判斷當前節點數是否大於閾值,如果大於開始擴容爲原數組的二倍。

面試官: 停頓了一下,我繼續按照套路問,剛纔你提到HashMap的初始化,那HashMap怎麼設定初始容量大小的嗎?(可能是感覺到我的準備很充分)

:一般如果new HashMap() 不傳值,默認大小是16,負載因子是0.75, 如果自己傳入初始大小k,初始化大小爲 大於k的 2的整數次方,例如如果傳10,大小爲16。(補充說明:實現代碼如下)

static final int tableSizeFor(int cap) {
  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;
}

補充:下圖是詳細過程,算法就是讓初始二進制右移1,2,4,8,16位,分別與自己位或,把高位第一個爲1的數通過不斷右移,把高位爲1的後面全變爲1,最後再進行+1操作,111111 + 1 = 1000000 = 2 6 2^626 (符合大於50並且是2的整數次冪 )

面試官: 你提到hash函數,你知道HashMap的哈希函數怎麼設計的嗎?

: hash函數是先拿到 key 的hashcode,是一個32位的int值,然後讓hashcode的高16位和低16位進行異或操作。
(問的真的挺細,不過,一般來說,都是根據前面的談話繼續的,如果自己答不上來,可以直接跟面試官說,但是可以表示自己對着方面也很感興趣,或者請教對方)

面試官: 那你知道爲什麼這麼設計嗎?

: 這個也叫擾動函數,這麼設計有二點原因:

  1. 一定要儘可能降低hash碰撞,越分散越好;
  2. 算法一定要儘可能高效,因爲這是高頻操作, 因此採用位運算;

面試官: 爲什麼採用hashcode的高16位和低16位異或能降低hash碰撞?hash函數能不能直接用key的hashcode?

(這問題就問的有點刁鑽了我差點答不上來)

: 因爲key.hashCode()函數調用的是key鍵值類型自帶的哈希函數,返回int型散列值。int值範圍爲-2147483648~2147483647,前後加起來大概40億的映射空間。只要哈希函數映射得比較均勻鬆散,一般應用是很難出現碰撞的。但問題是一個40億長度的數組,內存是放不下的。你想,如果HashMap數組的初始大小才16,用之前需要對數組的長度取模運算,得到的餘數才能用來訪問數組下標。(來自知乎-胖君)

大家平時也可以多看一下大佬的技術文講解,這樣如果遇到了相關問題也能侃侃而談。

源碼中模運算就是把散列值和數組長度-1做一個"與"操作,位運算比取餘%運算要快。

bucketIndex = indexFor(hash, table.length);

static int indexFor(int h, int length) {
     return h & (length-1);
}

順便說一下,這也正好解釋了爲什麼HashMap的數組長度要取2的整數冪。因爲這樣(數組長度-1)正好相當於一個“低位掩碼”。“與”操作的結果就是散列值的高位全部歸零,只保留低位值,用來做數組下標訪問。以初始長度16爲例,16-1=15。2進製表示是00000000 00000000 00001111。和某散列值做“與”操作如下,結果就是截取了最低的四位值。

  10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
  00000000 00000000 00000101    //高位全部歸零,只保留末四位

但這時候問題就來了,這樣就算我的散列值分佈再鬆散,要是隻取最後幾位的話,碰撞也會很嚴重。更要命的是如果散列本身做得不好,分佈上成等差數列的漏洞,如果正好讓最後幾個低位呈現規律性重複,就無比蛋疼。

時候“擾動函數”的價值就體現出來了,說到這裏大家應該猜出來了。看下面這個圖,

右移16位,正好是32bit的一半,自己的高半區和低半區做異或,就是爲了混合原始哈希碼的高位和低位,以此來加大低位的隨機性。而且混合後的低位摻雜了高位的部分特徵,這樣高位的信息也被變相保留下來。

最後我們來看一下Peter Lawley的一篇專欄文章《An introduction to optimising a hashing strategy》裏的的一個實驗:他隨機選取了352個字符串,在他們散列值完全沒有衝突的前提下,對它們做低位掩碼,取數組下標。

結果顯示,當HashMap數組長度爲512的時候(2 9 2^929),也就是用掩碼取低9位的時候,在沒有擾動函數的情況下,發生了103次碰撞,接近30%。而在使用了擾動函數之後只有92次碰撞。碰撞減少了將近10%。看來擾動函數確實還是有功效的。

另外Java1.8相比1.7做了調整,1.7做了四次移位和四次異或,但明顯Java 8覺得擾動做一次就夠了,做4次的話,多了可能邊際效用也不大,所謂爲了效率考慮就改成一次了。

下面是1.7的hash代碼:

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

面試官: 你剛剛說到1.8對hash函數做了優化,1.8還有別的優化嗎?(感覺到我的準備還是很充足的)

: 1.8還有三點主要的優化:

  1. 數組+鏈表改成了數組+鏈表或紅黑樹;
  2. 鏈表的插入方式從頭插法改成了尾插法,簡單說就是插入時,如果數組位置上已經有元素,1.7將新元素放到數組中,原始節點作爲新節點的後繼節點,1.8遍歷鏈表,將元素放置到鏈表的最後;
  3. 擴容的時候1.7需要對原數組中的元素進行重新hash定位在新數組的位置,1.8採用更簡單的判斷邏輯,位置不變或索引+舊容量大小;
  4. 在插入時,1.7先判斷是否需要擴容,再插入,1.8先進行插入,插入完成再判斷是否需要擴容;

面試官: 你分別跟我講講爲什麼要做這幾點優化;

: 【咳咳,果然是連環炮】

  1. 防止發生hash衝突,鏈表長度過長,將時間複雜度由O(n)降爲O(logn);

  2. 因爲1.7頭插法擴容時,頭插法會使鏈表發生反轉,多線程環境下會產生環;

    A線程在插入節點B,B線程也在插入,遇到容量不夠開始擴容,重新hash,放置元素,採用頭插法,後遍歷到的B節點放入了頭部,這樣形成了環,如下圖所示:

    1.7的擴容調用transfer代碼,如下所示:

    void transfer(Entry[] newTable, boolean rehash) {
      int newCapacity = newTable.length;
      for (Entry<K,V> e : table) {
        while(null != e) {
          Entry<K,V> next = e.next;
          if (rehash) {
            e.hash = null == e.key ? 0 : hash(e.key);
          }
          int i = indexFor(e.hash, newCapacity);
          e.next = newTable[i]; //A線程如果執行到這一行掛起,B線程開始進行擴容
          newTable[i] = e;
          e = next;
        }
      }
    }
    
    
  3. 擴容的時候爲什麼1.8 不用重新hash就可以直接定位原節點在新數據的位置呢?

    這是由於擴容是擴大爲原數組大小的2倍,用於計算數組位置的掩碼僅僅只是高位多了一個1,怎麼理解呢?

    擴容前長度爲16,用於計算(n-1) & hash 的二進制n-1爲0000 1111,擴容爲32後的二進制就高位多了1,爲0001 1111。

    因爲是& 運算,1和任何數 & 都是它本身,那就分二種情況,如下圖:原數據hashcode高位第4位爲0和高位爲1的情況;

    第四位高位爲0,重新hash數值不變,第四位爲1,重新hash數值比原來大16(舊數組的容量)

面試官: 那HashMap是線程安全的嗎?

: 不是,在多線程環境下,1.7 會產生死循環、數據丟失、數據覆蓋的問題,1.8 中會有數據覆蓋的問題,以1.8爲例,當A線程判斷index位置爲空後正好掛起,B線程開始往index位置的寫入節點數據,這時A線程恢復現場,執行賦值操作,就把A線程的數據給覆蓋了;還有++size這個地方也會造成多線程同時擴容等問題。

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;
  if ((p = tab[i = (n - 1) & hash]) == null)  //多線程執行到這裏
    tab[i] = newNode(hash, key, value, null);
  else {
    Node<K,V> e; K k;
    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);
          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;
      }
    }
    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()
    resize();
  afterNodeInsertion(evict);
  return null;
}

面試官: 那你平常怎麼解決這個線程不安全的問題?

: Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以實現線程安全的Map。

HashTable是直接在操作方法上加synchronized關鍵字,鎖住整個數組,粒度比較大,Collections.synchronizedMap是使用Collections集合工具的內部類,通過傳入Map封裝出一個SynchronizedMap對象,內部定義了一個對象鎖,方法內通過對象鎖實現;ConcurrentHashMap使用分段鎖,降低了鎖粒度,讓併發度大大提高。

面試官: 那你知道ConcurrentHashMap的分段鎖的實現原理嗎?

: (來了來了熟悉的套路,熟悉的配方,【深挖】絕技)ConcurrentHashMap成員變量使用volatile 修飾,免除了指令重排序,同時保證內存可見性,另外使用CAS操作和synchronized結合實現賦值操作,多線程操作只會鎖住當前操作索引的節點。

如下圖,線程A鎖住A節點所在鏈表,線程B鎖住B節點所在鏈表,操作互不干涉。

面試官: 你前面提到鏈表轉紅黑樹是鏈表長度達到閾值,這個閾值是多少?

: 閾值是8,紅黑樹轉鏈表閾值爲6

面試官: 爲什麼是8,不是16,32甚至是7 ?又爲什麼紅黑樹轉鏈表的閾值是6,不是8了呢?

: 【你去問作者啊!天啦擼,biubiubiu 真想213連招】因爲作者就這麼設計的,哦,不對,因爲經過計算,在hash函數設計合理的情況下,發生hash碰撞8次的機率爲百萬分之6,概率說話。。因爲8夠用了,至於爲什麼轉回來是6,因爲如果hash碰撞次數在8附近徘徊,會一直髮生鏈表和紅黑樹的互相轉化,爲了預防這種情況的發生。

面試官: HashMap內部節點是有序的嗎?

: 是無序的,根據hash值隨機插入

面試官: 那有沒有有序的Map?

: LinkedHashMap 和 TreeMap

面試官: 跟我講講LinkedHashMap怎麼實現有序的?

: LinkedHashMap內部維護了一個單鏈表,有頭尾節點,同時LinkedHashMap節點Entry內部除了繼承HashMap的Node屬性,還有before 和 after用於標識前置節點和後置節點。可以實現按插入的順序或訪問順序排序。

/**
 * The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;

/**
  * The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;
//鏈接新加入的p節點到鏈表後端
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
  LinkedHashMap.Entry<K,V> last = tail;
  tail = p;
  if (last == null)
    head = p;
  else {
    p.before = last;
    last.after = p;
  }
}
//LinkedHashMap的節點類
static class Entry<K,V> extends HashMap.Node<K,V> {
  Entry<K,V> before, after;
  Entry(int hash, K key, V value, Node<K,V> next) {
    super(hash, key, value, next);
  }
}

示例代碼:

public static void main(String[] args) {
  Map<String, String> map = new LinkedHashMap<String, String>();
  map.put("1", "我");
  map.put("2", "的");
  map.put("3", "博客");

  for(Map.Entry<String,String> item: map.entrySet()){
    System.out.println(item.getKey() + ":" + item.getValue());
  }
}
//console輸出
1:我
2:的
3:博客

面試官: 跟我講講TreeMap怎麼實現有序的?

:TreeMap是按照Key的自然順序或者Comprator的順序進行排序,內部是通過紅黑樹來實現。所以要麼key所屬的類實現Comparable接口,或者自定義一個實現了Comparator接口的比較器,傳給TreeMap用於key的比較。

面試官: 前面提到通過CAS 和 synchronized結合實現鎖粒度的降低,你能給我講講CAS 的實現以及synchronized的實現原理嗎?

: 下一期咋們再約時間,OK?

面試官: 好吧,回去等通知吧!

總結

面試到這裏就差不多結束了,後面就沒有技術面了,只有和HR的傾心交談。

面試過程中我們相談甚歡(誤),但是沒想到,一個HashMap,阿里面試官竟然跟我扯了半個小時!

事實證明,Android學習這條漫長的道路,我們要學習的東西不僅僅只有表面的 技術,還要深入底層,弄明白下面的 原理,只有這樣,我們才能夠提高自己的競爭力,在當今這個競爭激烈的世界裏立足。

千里之行始於足下,願你我共勉。

這裏整合了很多底層原理的知識,還有我認爲比較重要的學習方向和知識點,放在了我的GitHub:https://github.com/xieyuliang/Android,歡迎大家一起學習進步。

此外,這些資料會不定期更新,最近還更新了很多面試相關的資料,希望對放大家有幫助。

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