面試官: 我必問的容器知識點!

DevYK發表於2020-04-04

前言

相信大家在工作中使用容器的場景是具有多變性的,那麼在效能方面你知道怎麼去選擇一種當前最優的資料結構嗎? 或許對於工作多年有經驗的開發者來說,是沒有問題的。但是對於剛入門 1 ~ 2 年或者只知道怎麼使用而不知道對當前最適用的,那麼這一篇文章將全面為你解惑。

該篇還是延續上一篇 面試官: 說一下你做過哪些效能優化? 的風格,以問答的模式來進行解答,相信對你是有幫助的。

如果你正在找工作, 那麼你需要一份 Android 高階開發面試寶典

List

1、有使用過 ArrayList 嗎 ?說一下它的底層實現 ?

程式設計師:

有使用,它的底層是基於陣列的資料結構, 預設第一次初始化長度為 10 ,由於 add ,put , size 沒有處理執行緒安全,所以它是非執行緒安全的。

要不我手動畫一下它的整體結構吧。如下圖所示。

圖解:

  • Index: ArrayList 的索引下標
  • elementData: ArrayList 的索引下標對應的資料
  • size: ArrayList 的大小

面試官:

嗯,那你詳細說下它的 add 過程,以及自動擴容機制。

程式設計師:

好的,那我先說 add 過程,在說自動擴容機制。

  • add 過程:

    1、當我們例項化 ArrayList 的時候可以指定它的大小,也可以直接空參例項化或者直接傳入一個已有的陣列,如下程式碼所示。

      //1.指定底層初始化的陣列大小
     public ArrayList(int initialCapacity) {
        this.elementData = new Object[initialCapacity];
       ...//省略程式碼
      }

      //2.無引數構造器,預設是空陣列
      public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
      }

      //3.指定初始已有資料
      public ArrayList(Collection<? extends E> c) {
      ...//省略程式碼
       elementData = Arrays.copyOf(elementData, size, Object[].class);
      }
    複製程式碼

    先介紹有幾種初始化的方式,然後在說 add 操作。

    2、當第一次呼叫 add(E) 函式存入資料,先取得最小可以擴容的大小 minCapacity = 10

    3、如果我們希望的最小容量大於目前陣列的長度(預設是空陣列),那麼就擴容

    4、第一次擴容由於 elementData 其實是一個空陣列,也就是 size 為 0 的陣列,所以直接將預設 DEFAULT_CAPACITY=10 賦值給當前 newCapacity 新的擴容大小。

    5、最後通過 System.arraycopy 函式,進行例項化一個預設大小為 10 的空陣列。

    6、如果當我們在 size = 10 的情況下,add[11],那麼就會基於該公式檢查 (size + 1)- size > 0 ,如果成立就會進行第二次擴容。

    7、第二次擴容機制,是有一個計算公式,其實第一次也有隻是可以忽略不計,因為算出來是 0 。大白話的計算公式為 當前陣列大小 + (當前陣列大小 / 2) = 10 + 5 也就是第二次擴容大小為 15 。

    8、最後直接將需要新增的資料以 elementData[size++] = e 形式新增。

    這就是一個詳細的新增和擴容過程。這裡也可以用一張圖來進行說明(詳細程式碼我就不貼了,主要講解以什麼思路來回答面試官),如下所示:

    面試官:

    嗯,新增和擴容機制瞭解挺細緻的。

    下面在說一下,ArrayList 的刪除吧。

  • 刪除機制

    刪除的話,ArrayList 給我們提供了 remove(index)remove(obj) 等方式,平時我在專案中用的最多的就是這 2 個 API , 下面我分別來說下它們底層實現方式吧:

    remove(index):

    //根據陣列下標去刪除
      public E remove(int index) {//比如 index = 5,size = 10
      rangeCheck(index);

      modCount++;
        E oldValue = elementData(index);//[1,2,3,4,5,6,7,8,9,10] //刪除 6

        int numMoved = size - index - 1;//4
      if (numMoved > 0)
          System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);//直接從 [7,8,9,10] 往前移動一位
        elementData[--size] = null// clear to let GC do its work

        return oldValue;
    }
    複製程式碼

    這個 API 它的意思就是根據索引來刪除

    1、檢查索引是否越界

    2、根據索引拿到刪除的資料

    3、將 index + 1 作為移動的起點, size - index -1 作為需要移動陣列的長度

    4、利用 System.arraycopy 陣列,將後面的資料向前移動一位,並將最後一位置空。

    5、最後返回刪除的節點資料

    remove(obj):

      // 根據值去刪除
      public boolean remove(Object obj) {
        // 如果值是空的,找到第一個值是空的刪除
        if (o == null) {
          for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
            fastRemove(index);
              return true;
          }
        } else {
        // 值不為空,找到第一個和入參相等的刪除
          for (int index = 0; index < size; index++)
            // 這裡是根據  equals 來判斷值相等的
            if (o.equals(elementData[index])) {
              fastRemove(index);
              return true;
            }
        }
        return false;
      }
    複製程式碼

    這個 API 是刪除 ArrayList 裡面匹配到的物件

    1、如果需要刪除的 obj = null 那麼就遍歷集合將 elementData[index] == null 的節點全部刪除,刪除原理同 remove(index) 一樣。

    2、如果條件 1 不成立,那麼遍歷,判斷 2 個物件的地址值是否指向同一塊記憶體,如果成立,那麼就刪除,刪除原理同 remove(index) 一樣。

    這裡詢問一下面試官,我描述的是否清晰? 面試官如果沒有懂,那麼我們就可以現場畫一個圖,如下所示:

    面試官:

    嗯,是的, 刪除原理是這樣的!

    程式設計師:

    但是在開發中,使用 ArrayList 還是有幾點注意事項,比如:

    1、不能使用 for 來進行刪除,因為每刪除一個物件 ,底層的索引對應關係就會發生改變, 導致會刪除異常。解決的辦法應該使用迭代器。

    2、多執行緒併發也不能使用 ArrayList ,應該使用 CopyOnWriteArrayList 或者 Collections.synchronizedList(list) 來解決執行緒安全問題。

    3、還有效能問題,新增多,查詢少,應該選擇 LinkedList 資料結構。避免頻繁對陣列的 copy

    其實到這裡,ArrayList 基本原理就介紹的差不多了,面試官也不可能每個 API 都問,一般都是問常用的。

2、有使用過 LinkedList 嗎?說一下它的底層實現 ?

程式設計師:

有用過,它的底層資料結構是雙向連結串列組成, 我還是畫一下它的結構圖吧。如下所示:

圖解:

  • 連結串列每個節點我們叫做 Node,Node 有 prev 屬性,代表前一個節點的位置,next 屬性,代表後一個節點的位置;
  • first 是雙向連結串列的頭節點,它的前一個節點是 null。
  • last 是雙向連結串列的尾節點,它的後一個節點是 null;
  • 當連結串列中沒有資料時,first 和 last 是同一個節點,前後指向都是 null;

面試官:

嗯,基本架構差不多是這樣,那你說說底層的 add , get ,remove 原理吧。

程式設計師:

好的,那我就依次來說一下它們各自實現機制。

add:

    //新增資料
    public boolean add(E e) {
        linkLast(e);
        return true;
    }
    // 從尾部開始追加節點
    void linkLast(E e) {
        // 把尾節點資料暫存
        final Node<E> l = last;
        //新建新的節點,l 是前一個節點,e 是當前節點的值,後一個節點是 null
        final Node<E> newNode = new Node<>(l, e, null);
        //新建的節點放在尾部
        last = newNode;
        //如果連結串列為空,頭部和尾部是同一個節點,都是新建的節點
        if (l == null)
            first = newNode;
            //否則把前尾節點的下一個節點,指向當前尾節點。
        else
            l.next = newNode;
        //大小和版本更改
        size++;
        modCount++;
    }
複製程式碼

新增資料我常用的是 add(E) API ,我就基於它來說吧,它是基於尾結點來新增資料的, 總得來說有如下幾個步驟:

1、拿到 Last 尾結點,賦值給一個臨時變數 l 。

2、生成一個新的 newNode= Node[l,item,null] 的資料型別,l 代表的就是連線上一個尾結點,item 就是存入的資料,null 代表的是 item 的尾結點指向。

3、將生成的 newNode 賦值給 last 尾結點,這一步相當於更新 last 資料。

4、最後是判斷臨時變數的 l 是否為空,如果為空說明連結串列中還沒有資料,然後將 newNode 賦值給 first 頭節點。反之將 newNode 賦值給 l.last 節點。

5、最後更新連結串列 size ,還有修改的版本 modCount

可以由一個動圖來說明以上步驟的操作, 如下所示:

新增的增加過程就是這樣,還是比較簡單,都是操作移動節點資料。

get:

    public E get(int index) {
        checkElementIndex(index);
        return node(index).item;
    }
    // 根據搜尋因為查詢節點
    Node<E> node(int index) {
        // index 處於佇列的前半部分,從頭開始找
        if (index < (size >> 1)) {
            Node<E> x = first;
            // 直到 for 迴圈到 index 的前一個 node
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {// index 處於佇列的後半部分,從尾開始找
            Node<E> x = last;
            // 直到 for 迴圈到 index 的後一個 node
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }
複製程式碼

獲取資料我比較常用的是 get(index) API,我也基於它來說吧,總體來說它是分為兩部分來查詢資料,步驟有如下幾步:

1、檢查 index 是否越界。

2、根據 index > 或 < (size >> 1) 來判斷在連結串列的上半部分還是下半部分。

3、如果是上半部分直接從 first 頭節點開始遍歷 index 次,然後拿到節點 item 資料。反之從下半部份 last 尾部開始向前遍歷 index 次,然後拿到節點 item 資料。

總得來說獲取資料的步驟就這 3 大步,因為它是連結串列結構沒有索引對應關係,取資料只能挨個遍歷。所以,如果對取資料操作頻繁也可以使用 ArrayList 來彌補不足的效能。

remove:

刪除資料,我常用的是 remove() 或者 remove(index) 它們的區別就是一個從頭刪除節點,一個是指定 index index 來刪除節點,我就直接說一下根據索引刪除的原理吧。

1、還是檢查索引是否越界。

2、搜尋節點上的資料原理同 get 的第二小點一樣,都是分段搜尋。

3、拿到當前需要刪除的 node , 然後處理當前節點上的 prev,item , next 節點指向位置,還有將當前 item 的指標全部置空,避免記憶體洩漏。

刪除也是 3 大步驟,相較於 ArrayList 刪除 API 來說,LinkedList 刪除的效能是比 ArrayList 要高的。

所以,如果有 增加和刪除操作比較頻繁的可以選擇 LinkedList 資料結構。

3、你在工作中對 ArrayList 和 LinkedList 是怎麼選型的?

程式設計師:

如果專案中有需要快速的查詢匹配,但是新增刪除不頻繁我一般使用的是 ArrayList 陣列結構,但是如果查詢比較少,新增和刪除比較多我一般用的是 LinkedList 連結串列結構。(ps:結合它們的原理回答為什麼)

4、 ArrayList 在多執行緒使用應該注意什麼?

程式設計師:

在多執行緒使用 List 要注意執行緒安全問題,解決的辦法通常有兩種來解決。第一種也是最簡單的一種直接使用 Collections.synchronizedList(list) ,但是其效能不好,因為它的實現原理相當於委託模式,交於另一個類來處理,而且內部將每個函式都加了 synchronized , 另一種實現是 java.util.concurrent##CopyOnWriteArrayList

面試官:

那你用過 CopyOnWriteArrayList 嗎?它是怎麼實現執行緒安全的 ?

程式設計師:

有用過,它的基本原理和 ArrayList 是一致的,底層也是基於陣列實現。它的基本特性總結有以下幾點:

1、執行緒安全的,多執行緒環境下可以直接使用,無需加鎖;

2、通過鎖 + 陣列拷貝 + volatile 關鍵字保證了執行緒安全;

3、每次陣列操作,都會把陣列拷貝一份出來,在新陣列上進行操作,操作成功之後再賦值回去。

執行緒安全我從原始碼的 addremove 的實現來說一下它們各自怎麼保證執行緒安全的吧?

這裡一定要思路清晰,最好主動找常用的 API 來說明一下內部怎麼實現執行緒安全,不要直接給答案,一定要先分析原始碼,最後給出總結。

面試官:

可以,那你分別說一下吧。

程式設計師:

1、底層陣列是如何保證資料安全的?

在分析底層陣列是如何保證其安全性之前,我先簡單說一下 Java 記憶體模型,因為陣列的執行緒安全會涉及到 volatile 關鍵字。

volatile 的意思是可見的,常用來修飾某個共享變數,意思是當共享變數的值被修改後,會及時通知到其它執行緒上,其它執行緒就能知道當前共享變數的值已經被修改了。

在多核 CPU 下,為了提高效率,執行緒在拿值時,是直接和 CPU 快取打交道的,而不是記憶體。主要是因為 CPU 快取執行速度更快,比如執行緒要拿值 C,會直接從 CPU 快取中拿, CPU 快取中沒有,就會從記憶體中拿,所以執行緒讀的操作永遠都是拿 CPU 快取的值。

這時候會產生一個問題,CPU 快取中的值和記憶體中的值可能並不是時刻都同步,導致執行緒計算的值可能不是最新的,共享變數的值有可能已經被其它執行緒所修改了,但此時修改是機器記憶體的值,CPU 快取的值還是老的,導致計算會出現問題。

這時候有個機制,就是記憶體會主動通知 CPU 快取。當前共享變數的值已經失效了,你需要重新來拉取一份,CPU 快取就會重新從記憶體中拿取一份最新的值。

volatile 關鍵字就會觸發這種機制,加了 volatile 關鍵字的變數,就會被識別成共享變數,記憶體中值被修改後,會通知到各個 CPU 快取,使 CPU 快取中的值也對應被修改,從而保證執行緒從 CPU 快取中拿取出來的值是最新的。

還是畫一個圖來說明一下:

從圖中我們可以看到,執行緒 1 和執行緒 2 一開始都讀取了 C 值,CPU 1 和 CPU 2 快取中也都有了 C 值,然後執行緒 1 把 C 值修改了,這時候記憶體的值和 CPU 2 快取中的 C 值就不等了,記憶體這時發現 C 值被 volatile 關鍵字修飾,發現其是共享變數,就會使 CPU 2 快取中的 C 值狀態置為無效,CPU 2 會從記憶體中重新拉取最新的值,這時候執行緒 2 再來讀取 C 值時,讀取的已經是記憶體中最新的值了。

volatile 原理知道了,那麼通過原始碼我們知道 object[] 就是通過 volatile 關鍵字來修飾的,那麼也就保證了它在記憶體中是可見的,具有安全的。

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccessCloneablejava.io.Serializable 
{
    // volatile 關鍵字修飾,可見的
    // array 只開放出 get set
    private transient volatile Object[] array;
    final Object[] getArray() {
        return array;
    }
    //更新底層陣列記憶體地址
    final void setArray(Object[] a) {
        array = a;
    }
    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }
複製程式碼

2、操作 add(E) API 是怎麼保證資料安全的?

根據之前看原始碼,它是有如下幾個步驟保證了操作 add(E) 的安全性。

    // 新增元素到陣列尾部
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        //加鎖
        lock.lock();
        try {
            // 得到所有的原陣列
            Object[] elements = getArray();
            int len = elements.length;
            //拷貝到新陣列裡面,新陣列的長度是 + 1 的
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            //在新陣列中進行賦值,新元素直接放在陣列的尾部
            newElements[len] = e;
            //替換原來的陣列
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
複製程式碼
  • 1、通過可重入互斥鎖 ReentrantLockadd(E) 加鎖
  • 2、通過 getArray() 方法得到已經存在的陣列
  • 3、例項化一個長度為當前 size + 1 的陣列,然後將 getArray 的陣列放入新陣列中
  • 4、最後將新增的資料存入新陣列的最後索引中
  • 5、基於當前類中的 setArray(newElements); 來替換快取中的陣列資料,因為它在類中中被 volatile 修飾了,所以只要記憶體地址一變,那麼就會立馬通知,其它 CPU 的快取讓它得到更新。
  • 6、釋放可重入互斥鎖

所以 add(E) 方法是根據 ReentrantLock + 陣列copy + update Object[] 記憶體地址 + volatile 來保證其資料安全性的。

面試官:

你剛剛說 add(E) 函式中是通過 ReentrantLock + 陣列copy 等其它手段來實現的執行緒安全,那既然有了互斥鎖保證了執行緒安全,為什麼還要 copy 陣列呢 ?

程式設計師:

的確,對 add(E) 進行加鎖後,能夠保證同一時刻,只有一個執行緒能對陣列進行 add(E),在同單核 CPU 下的多執行緒環境下肯定沒有問題,但我們現在的機器都是多核 CPU,如果我們不通過複製拷貝新建陣列,修改原陣列容器的記憶體地址的話,是無法觸發 volatile 可見性效果的,那麼其他 CPU 下的執行緒就無法感知陣列原來已經被修改了,就會引發多核 CPU 下的執行緒安全問題。

假設我們不復制拷貝,而是在原來陣列上直接修改值,陣列的記憶體地址就不會變,而陣列被 volatile 修飾時,必須當陣列的記憶體地址變更時,才能及時的通知到其他執行緒,記憶體地址不變,僅僅是陣列元素值發生變化時,是無法把陣列元素值發生變動的事實,通知到其它執行緒的。

面試官:

嗯,看來你對這些機制都瞭解的挺清楚的,那你在說說 remove 是怎麼保證的執行緒安全吧?

2、remove 是怎麼保證執行緒安全的?

其實 remove 保證執行緒安全機制跟 add 思路都差不多,都是先加鎖 +不同策略的陣列拷貝最後是釋放鎖。

面試官:

add , remove 方法內部都實現了 copy ,在效能上你有什麼優化建議嗎?

程式設計師:

儘量使用 addAll、removeAll 方法,而不要在迴圈裡面使用 add、remove 方法,主要是因為 for 迴圈裡面使用 add 、remove 的方式,在每次操作時,都會進行一次陣列的拷貝(甚至多次),非常耗效能,而 addAll、removeAll 方法底層做了優化,整個操作只會進行一次陣列拷貝,由此可見,當批量操作的資料越多時,批量方法的高效能體現的越明顯。

Map

1、說一下你對 HashMap 的瞭解

程式設計師:

HashMap 底層是陣列 + 單連結串列 + 紅黑樹 組成的儲存資料結構,簡單來說當連結串列長度大於等於 8 並且陣列長度大於 64 那麼就會由連結串列轉為紅黑樹,當紅黑樹的大小容量 <= 6 時又轉換為 連結串列的一個底層結構。非執行緒安全的。

可以用一張圖來解釋 HashMap 底層結構,如下所示:

圖解:

1、最左邊 tableHashMap 的陣列結構,允許 Nodevalue 值為 NULL

2、陣列的擴容機制第一次預設擴容大小為 16 size, 擴容閥值為 threshold = size * loadFactor -> 12 = 16 * 0.75 ,只要 ++size > threshold 就按照 newCap = oldCap << 1 機制來擴容。

3、陣列的下標有可能是一個連結串列、紅黑樹,也有可能只是一個 Node,只有當陣列長度 > 64,連結串列長度 >= 8 才會將陣列中的 Node 節點轉為 TreeNode 節點。也只有當紅黑樹的大小 <= 6 時,才轉為單連結串列結構。

程式設計師:

HashMap 底層的基本實現實現基本就是這樣。

面試官:

嗯,那你描述一下 put(K key, V value) 這個 API 的儲存過程 。

程式設計師:

好的,我先描述一下基本流程,最後我畫一張流程圖來總結一下

1、根據 key 通過該公式 (h = key.hashCode()) ^ (h >>> 16) 計算 hash 值

2、判斷 HashMap table 陣列是否已經初始化,如果沒有初始化,那麼就按照預設 16 的大小進行初始化,擴容閥值也將按照 size * 0.75 來定義

3、通過該公式 (n - 1) & hash 拿到存入 table 的 index 索引,判斷當前索引下是否有值,如果沒有值就進行直接賦值 tab[index] , 如果有值,那麼就會發生 hash 碰撞 ? ,也就是俗稱 hash衝突 , 在 JDK中的解決是的辦法有 2 個,其一是連結串列,其二是 紅黑樹。

4、當傳送 hash 衝突 首先判斷陣列中已存入的 key 是否與當前存入的 key 相同,並且記憶體地址也一樣,那麼就直接預設直接覆蓋 values

5、如果 key 不相等,那麼先拿到 tab[index] 中的 Node是否是紅黑樹,如果是紅黑樹,那麼就加入紅黑樹的節點;如果 Node 節點不是紅黑樹,那麼就直接放入 node 的 next 下,形成單連結串列結構。

6、如果連結串列結構的長度 >= 8 就轉為紅黑樹的結構。

7、最後檢查擴容機制。

整個 put 流程就是這樣,可以用一個流程圖來進行總結,如下所示:

面試官:

嗯,理解的很透徹,剛剛你說解決 hash 衝突有 2 種辦法,那你描述一下紅黑樹是怎麼實現新增的?

程式設計師:

好的,基本流程有如下幾步:

1、首先判斷新增的節點在紅黑樹上是不是已經存在,判斷手段有如下兩種:

​ 1.1、如果節點沒有實現 Comparable 介面,使用 equals 進行判斷;

​ 1.2、如果節點自己實現了 Comparable 介面,使用 compareTo 進行判斷。

2、新增的節點如果已經在紅黑樹上,直接返回;不在的話,判斷新增節點是在當前節點的左邊還是右邊,左邊值小,右邊值大;

3、自旋遞迴 1 和 2 步,直到當前節點的左邊或者右邊的節點為空時,停止自旋,當前節點即為我們新增節點的父節點;

4、把新增節點放到當前節點的左邊或右邊為空的地方,並於當前節點建立父子節點關係;

5、進行著色和旋轉,結束。

面試官:

你知道連結串列轉紅黑樹定義的長度為什麼是 8 嗎?

程式設計師:

這個答案,我通過 HashMap 類中的註釋有留意過,它大概描述的意思是連結串列查詢的時間複雜度是 O (n),紅黑樹的查詢複雜度是 O (log (n))。在連結串列資料不多的時候,使用連結串列進行遍歷也比較快,只有當連結串列資料比較多的時候,才會轉化成紅黑樹,但紅黑樹需要的佔用空間是連結串列的 2 倍,考慮到轉化時間和空間損耗,所以我們需要定義出轉化的邊界值。

在考慮設計 8 這個值的時候,我們參考了泊松分佈概率函式,由泊松分佈中得出結論,連結串列各個長度的命中概率為:

     * 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 的時候,出現的概率是 0.00000006,不到千萬分之一,所以說正常情況下,連結串列的長度不可能到達 8 ,而一旦到達 8 時,肯定是 hash 演算法出了問題,所以在這種情況下,為了讓 HashMap 仍然有較高的查詢效能,所以讓連結串列轉化成紅黑樹,我們正常寫程式碼,使用 HashMap 時,幾乎不會碰到連結串列轉化成紅黑樹的情況,畢竟概念只有千萬分之一。

面試官:

嗯,還挺細心的。留意到了原始碼中的註釋。

這裡可能會讓面試官感覺是非常注重原始碼中的細節問題,會去思考,為什麼?

開始你介紹到 HashMap 在多執行緒操作下是不安全了,那麼你在工作中是怎麼解決的?

程式設計師:

可以自己在外部加鎖,或者通過 Collections#synchronizedMap 來實現執行緒安全,Collections#synchronizedMap 的實現是在每個方法上加上了 synchronized 鎖;也可以使用 concurrent 包下的 ConcurrentHashMap 類。它的原理我們將在 [6、ConcurrentHashMap 通過哪些手段保證了執行緒安全?](###6、ConcurrentHashMap 通過哪些手段保證了執行緒安全?) 說明。

面試官:

嗯,那你在說說陣列為什麼每次都是以 2的冪次方擴容?

程式設計師:

好的。如下是我的理解。

HashMap 為了存取高效,要儘量減少碰撞,就是要儘量把資料分配均勻。

比如:

2 的 n 次方實際就是 1 後面 n 個 0,2 的 n 次方 -1,實際就是 n 個 1。

那麼長度為 8 時候,3 & (8-1) = 3 ,2 & (8-1) = 2 ,不同位置上,不碰撞。

而長度為 5 的時候,3 & (5-1)= 0 , 2 & (5-1) = 0,都在 0 上,出現碰撞了。

//3 & 4
011
100
000
  
//2 & 4
010
100
000
複製程式碼

所以,保證容積是 2 的 n 次方,是為了保證在做 (size-1) 的時候,每一位都能 & 1 ,也就是和 1111……1111111進行與運算。

面試官:

在考你一道擴容機制的題目,現在後臺的圖片資料有 1000 條, 當我請求下來也處理完了,現在我想要快取到 Map 中,如果我直接呼叫 new HashMap(1000) 構造方法,內部還會擴容嗎?

程式設計師:

你可以這樣回答,其實如果直接給定 1000 的初始化容量,那麼我們需要根據原始碼中的計算來分析,有如下幾個步驟:

1、首先會在建構函式中呼叫 1024 = tableSizeFor(1000); 該 API 來計算擴容閥值。

你可不要認為,這裡就是真正的擴容大小,它在擴容的時候還會有一個計算公式。

2、計算真正的擴容閥值。

那麼根據第一次 put 資料的時候,判斷 table 是否為空,如果為空那麼就需要擴容。

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //第一次進來為 null 那麼就是 0 長度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold; //這裡其實就是 1024
        int newCap, newThr = 0;
        if (oldCap > 0) {
           ...//省略程式碼
        } else if (oldThr > 0
            newCap = oldThr;// newCap = 1024
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;//1024 * 0.75 = 768.0
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE); //768
        }
       //更新擴容的閥值
        threshold = newThr;
       //例項化一個陣列
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        //正在擴容 newTab 的大小
        table = newTab;
       ...//省略程式碼
        }
        return newTab;
    }
複製程式碼

可以看到其實真正的擴容閥門是 768。

3、判斷擴容機制

那麼只要新增到 768 的時候,就會發生擴容,如下程式碼所示:

 if (++size > threshold)//769 > 768 需要擴容
        resize();
複製程式碼

所以當我們給定 1000 為初始化擴容容量的時候,是需要擴容的。因為底層並不會真正以 1024 來進行設定閥門,它還要乘以一個載入因子。這個時候其實我們可以有辦法不讓它擴容,那就是呼叫 new HashMap(1000,1f) 那麼就不會擴容了。

這裡你不僅給出了實際答案,還提供瞭解決辦法。

面試官對你的回答肯定是滿意的。

2、說一下你對 ArrayMap 的瞭解

程式設計師:

ArrayMap 底層通過兩個陣列來建立對映關係,其中 int[] mHashes 按大小順序儲存 Key 物件 hashCode 值,Object[] mArraymHashes 的順序用相鄰位置儲存 Key 物件和 Value 物件。mArray 長度 是 mHashes 長度的 2 倍。

儲存資料是根據 key 的 hashcode() 方法得到 hash 值,計算出在 mArrays 的 index 值,然後利用二分查詢找到對應的位置進行插入,當出現雜湊衝突時,會在 inde 的相鄰位置插入。

取資料是根據 key 的 hashcode() 方法得到 hash 值,然後通過 hash 值根據二分查詢拿到 mHashes 的 index 索引,最後在根據 index + 1 索引拿到 mArrays 對應的 values 值。

3、你在工作中對 HashMap 和 ArrayMap 還有 SparseArray 是怎麼選型的 ?

程式設計師:

好的,我總結了一套效能對比,每次需求我都是參考如下的總結。

序號 需求 效能選擇
01 有 1K 資料需要裝入容器 key 是 int 選擇 SparseArray 節省 30% 記憶體,反之選擇 ArrayMap 節省 10%
02 有 1W 資料需要裝入容器 HashMap

4、有用過 LinkedHashMap 嗎 ?底層怎麼維護插入順序的,又是怎麼維護刪除最少訪問元素的 ?

ps: 由於內部儲存機制都是散開的,如果按照散開的來連線,那圖上連線線估計很亂,所以為了上圖能夠稍微好點一點,我就按照我自己的思路來繪製的,當然,內部結構還是不會變的。

程式設計師:

有用過,之前看 LruCache 底層也是基於 LinkedHashMap 實現的。那我還是按照我的思路來回答吧。

通過翻閱原始碼得知它是繼承於 HashMap ,那麼間接的它也擁有了 HashMap 的所有特性,而且在此基礎上,還提供了兩大特性,一個是增加了插入順序和實現了最近最少訪問的刪除策略。

先來看下是怎麼實現順序插入:

LinkedHashMap 外部結構是一個雙向連結串列結構,內部是一個 HashMap 結構,它就是相當於 HashMap + LinkedHashMap 的結合體。

其實 LinkedHashMap 的原始碼實現很簡單,它就是重寫了 HashMap##put 方法執行中呼叫的 newNode/newTreeNode 方法。然後在該函式內部中實現了連結串列的雙向連線。如下圖所示:

總結來說,LinkedHashMap 把新增的節點都使用雙向連結串列連線起來,從而實現了插入順序。然後核心的資料結構還是交於 HashMap 來處理維護的。

在來看下是怎麼實現的訪問最少刪除功能:

其實訪問最少刪除功能的這種策略也叫做 LRU 演算法,底層原理就是把經常使用的元素會被追加到當前連結串列的尾結點,而不經常使用的就自然都靠在連結串列的頭節點,然後我們就可以設定刪除策略,比如給當前 Map 設定一個策略大小,那麼當存入的資料大於設定的大小時,就會從頭節點開始刪除。

在原始碼當中,將經常使用的 節點資料追加到連結串列的操作是在 get API 中,如下所示:

    public V get(Object key) {
        Node<K,V> e;
        // 呼叫 HashMap  getNode 方法
        if ((e = getNode(hash(key), key)) == null)
            return null;
        // 如果設定了 LRU 策略
        if (accessOrder)
        // 這個方式把當前 key 移動到尾節點
            afterNodeAccess(e);
        return e.value;
    }
複製程式碼

當存入資料的時候 LinkedHashMap 重寫的 HashMap#putVal 方法中的 afterNodeInsertion API 。

//HashMap
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
  ....//刪除其餘程式碼
    
  // 刪除不經常使用的元素
 afterNodeInsertion(evict);
}

//LinkedHashMap
// 刪除不經常使用的元素
void afterNodeInsertion(boolean evict) // possibly remove eldest
  // 得到元素頭節點
  Entry<K,V> first;
  // removeEldestEntry 來控制刪除策略,removeEldestEntry 外部控制是否刪除
  if (evict && (first = head) != null && removeEldestEntry(first)) {
    //拿到頭節點的 key,刪除頭,因為最近使用的在 get 的時候都會移動到尾結點
    K key = first.key;
    // removeNode 刪除節點
    removeNode(hash(key), key, nullfalsetrue);
  }
}
複製程式碼

總結來說,LinkedHashMap 的操作都是基於 HashMap 暴露的 API , 實現了順序儲存和最近最少刪除策略。

說了這些原理之後,一般來說面試官不會再問其它的。因為核心功能我們都已經回答完了。

5、你知道 TreeMap 的內部是怎麼排序的嗎 ?

程式設計師:

嗯,知道。這個 API 我使用的比較少,之前只是看過它的原始碼,知道它的底層還是紅黑樹結構,跟 HashMap 的紅黑樹是一樣的。然後 TreeMap 是利用了紅黑樹左大右小的性質,根據 key 來進行排序的。

面試官:

嗯,那你具體來說一下底層是怎麼根據 key 排序的?

程式設計師:

在程式中,如果我們想給一個 List 排序的話,其一是實現 Comparable##compareTo 介面抽象方法,其二是利用外部排序器 Comparator 進行排序, 而 TreeMap 利用的也是此原理,從而實現了對 key 的排序。

我就直接說一下 put(K key, V value) API 怎麼實現的排序吧。

1、判斷紅黑樹的節點是否為空,為空的話,新增的節點直接作為根節點,程式碼如下:

Entry<K,V> t = root;
//紅黑樹根節點為空,直接新建
if (t == null) {
    // compare 方法限制了 key 不能為 null
    compare(key, key); // type (and possibly null) check
    // 成為根節點
    root = new Entry<>(key, value, null);
    size = 1;
    modCount++;
    return null;
}
複製程式碼

2、根據紅黑樹左小右大的特性,進行判斷,找到應該新增節點的父節點,程式碼如下:

Comparator<? super K> cpr = comparator;
if (cpr != null) {
    //自旋找到 key 應該新增的位置,就是應該掛載那個節點的頭上
    do {
        //一次迴圈結束時,parent 就是上次比過的物件
        parent = t;
        // 通過 compare 來比較 key 的大小
        cmp = cpr.compare(key, t.key);
        //key 小於 t,把 t 左邊的值賦予 t,因為紅黑樹左邊的值比較小,迴圈再比
        if (cmp < 0)
            t = t.left;
        //key 大於 t,把 t 右邊的值賦予 t,因為紅黑樹右邊的值比較大,迴圈再比
        else if (cmp > 0)
            t = t.right;
        //如果相等的話,直接覆蓋原值
        else
            return t.setValue(value);
        // t 為空,說明已經到葉子節點了
    } while (t != null);
}
複製程式碼

3、在父節點的左邊或右邊插入新增節點,程式碼如下:

//cmp 代表最後一次對比的大小,小於 0 ,代表 e 在上一節點的左邊
if (cmp < 0)
    parent.left = e;
//cmp 代表最後一次對比的大小,大於 0 ,代表 e 在上一節點的右邊,相等的情況第二步已經處理了。
else
    parent.right = e;
複製程式碼

4、著色旋轉,達到平衡,結束。

可以看到 TreeMap 排序是根據如果外部有傳進來 Comparator 比較器,那麼就用 Comparator 來進行對 key 比較,如果外部沒有就用 Key 自己實現 Comparable 的 compareTo 方法。

6、ConcurrentHashMap 通過哪些手段保證了執行緒安全?

程式設計師:

它的主要結構跟 HashMap 一樣底層都是基於陣列 + 單連結串列 + 紅黑樹構成。

保證執行緒安全主要有一下幾點:

1、儲存 Map 資料的陣列被 volatile 關鍵字修飾,一旦被修改,立馬就能通知其他執行緒,因為是陣列,所以需要改變其記憶體值,才能真正的發揮出 volatile 的可見特性;

    //第一次插入時才會初始化,java7是在構造器時就初始化了
    //容量大小都是2的冪次方,通過iterators進行迭代
    transient volatile Node<K,V>[] table;

    //擴容後的陣列
    private transient volatile Node<K,V>[] nextTable;
複製程式碼

2、put 時,如果陣列還未初始化,那麼使用 Thread##yieldsun.misc.Unsafe##compareAndSwapInt 保證了只有一個執行緒初始化陣列。

3、put 時,如果計算出來的陣列下標索引沒有值的話,採用無限 for 迴圈 + CAS 演算法,來保證一定可以新增成功,又不會覆蓋其他執行緒 put 進去的值;

//如果當前索引位置沒有值,直接建立
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//cas 在 i 位置建立新的元素,當i位置是空時,建立成功結束for自循,否則繼續自旋
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))break;
   }


/**
 *  CAS
  * @param tab       要修改的物件
  * @param i       物件中的偏移量
  * @param c        期望值
  * @param v         更新值
  * @return          true | false
  */

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v)
 
{
   return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
複製程式碼

4、如果 put 的節點正好在擴容,會等待擴容完成之後,再進行 put ,保證了在擴容時,老陣列的值不會發生變化;

//如果當前的hash是轉發節點的hash,表示該槽點正在擴容,就會一直等待擴容完成
if ((fh = f.hash) == MOVED)
  tab = helpTransfer(tab, f);
複製程式碼

5、對陣列的槽點進行操作時,會先鎖住槽點,保證只有當前執行緒才能對槽點上的連結串列或紅黑樹進行操作;

6、紅黑樹旋轉時,會鎖住根節點,保證旋轉時的執行緒安全。

面試官:

剛剛看你說了到了 CAS 演算法,那你描述一下 CAS 演算法在 ConcurrentHashMap 中的應用?

程式設計師:

CAS 其實是一種樂觀鎖,一般有三個值,分別為:賦值物件,原值,新值,在執行的時候,會先判斷記憶體中的值是否和原值相等,相等的話把新值賦值給物件,否則賦值失敗,整個過程都是原子性操作,沒有執行緒安全問題。

ConcurrentHashMap 的 put 方法中,有使用到 CAS ,是結合無限 for 迴圈一起使用的,步驟如下:

  1. 計算出陣列索引下標,拿出下標對應的原值;
  2. CAS 覆蓋當前下標的值,賦值時,如果發現記憶體值和 1 拿出來的原值相等,執行賦值,退出迴圈,否則不賦值,轉到 3;
  3. 進行下一次 for 迴圈,重複執行 1,2,直到成功為止。

可以看到這樣做的好處,第一是不會盲目的覆蓋原值,第二是一定可以賦值成功。

面試官:

嗯,還不錯,那你再說下與 HashMap 的相同點和不同點

程式設計師:

相同點:

1、都實現了 Map 介面,繼承了 AbstractMap 抽象類,所以兩者的方法大多都是相似的,可以互相切換。

2、底層都是基於 陣列 + 單連結串列 + 紅黑樹實現。

不同點:

1、ConcurrentHashMap 是執行緒安全的,在多執行緒環境下,無需加鎖,可直接使用;

2、資料結構上,ConcurrentHashMap 多了轉移節點,主要用於保證擴容時的執行緒安全。

Queue

1、說一說你對佇列的理解,佇列和集合的區別 ?

程式設計師:

好的,那我先說一下對佇列的理解,然後在說下區別;

對佇列的理解:

1、首先佇列本身也是個容器,底層也會有不同的資料結構,比如 LinkedBlockingQueue 是底層是連結串列結構,所以可以維持先入先出的順序,比如 DelayQueue 底層可以是佇列或堆疊,所以可以保證先入先出,或者先入後出的順序等等,底層的資料結構不同,也造成了操作實現不同;

2、部分佇列(比如 LinkedBlockingQueue )提供了暫時儲存的功能,我們可以往佇列裡面放資料,同時也可以從佇列裡面拿資料,兩者可以同時進行;

3、佇列把生產資料的一方和消費資料的一方進行解耦,生產者只管生產,消費者只管消費,兩者之間沒有必然聯絡,佇列就像生產者和消費者之間的資料通道一樣,如 LinkedBlockingQueue;

4、佇列還可以對消費者和生產者進行管理,比如佇列滿了,有生產者還在不停投遞資料時,佇列可以使生產者阻塞住,讓其不再能投遞,比如佇列空時,有消費者過來拿資料時,佇列可以讓消費者 hodler 住,等有資料時,喚醒消費者,讓消費者拿資料返回,如 ArrayBlockingQueue;

5、佇列還提供阻塞的功能,比如我們從佇列拿資料,但佇列中沒有資料時,執行緒會一直阻塞到佇列有資料可拿時才返回。

區別:

1、和集合的相同點,佇列(部分例外)和集合都提供了資料儲存的功能,底層的儲存資料結構是有些相似的,比如說 LinkedBlockingQueue 和 LinkedHashMap 底層都使用的是連結串列,ArrayBlockingQueue 和 ArrayList 底層使用的都是陣列。

2、和集合的區別:

  1. 部分佇列和部分集合底層的儲存結構很相似的,但兩者為了完成不同的事情,提供的 API 和其底層的操作實現是不同的。
  2. 佇列提供了阻塞的功能,能對消費者和生產者進行簡單的管理,佇列空時,會阻塞消費者,有其他執行緒進行 put 操作後,會喚醒阻塞的消費者,讓消費者拿資料進行消費,佇列滿時亦然。
  3. 解耦了生產者和消費者,佇列就像是生產者和消費者之間的管道一樣,生產者只管往裡面丟,消費者只管不斷消費,兩者之間互不關心。

2、有用過 LinkedBlockingQueue 佇列嗎? 說一下 LinkedBlockingQueue 底層怎麼實現資料存取。

程式設計師:

有用過。LinkedBlockingQueue 整體是一個具有阻塞的連結串列結構,具有生產者和消費者的作用。阻塞底層的實現是基於 AQS 實現的。

阻塞存資料:

佇列存資料有多種函式實現,比如 add , put , offer,這些函式的功能都是存資料,但是內部實現確都不一樣。這裡我就介紹 put 吧,因為它是我比較常用的。

大概意思就是當存入的資料已經達到內部最大容量,那麼就會陷入執行緒阻塞,只有等 take 函式喚醒的時機才會執行後面的程式碼。如果佇列未滿,就直接入佇列,把當前新增的元素放入尾端。最後在通知消費者也就是 take 函式可以取資料了。

阻塞取資料:

阻塞取資料其實同阻塞存資料同理。取資料的原理是先判斷當前佇列大小是否為空,如果為空就陷入無限等待,喚醒的時機是存完資料會通知。如果佇列中有資料那麼從佇列的頭部獲取(遵循先入先出)。

內部實現還是比較簡單,難點在於存取 API 的嫻熟使用。應用場景也比較多,比如執行緒池就是基於阻塞佇列的原理。

3、有用過 ArrayBlockingQueue 佇列嗎? 說一下 ArrayBlockingQueue 底層怎麼實現資料存取? 支援自動擴容嗎 ?

程式設計師:

有用過,底層是基於陣列實現的儲存結構。例項化 ArrayBlockingQueue 必須設定一個固定的佇列大小,內部不支援擴容。

面試官:

嗯,那你說說底層實現新增資料的原理吧?

程式設計師:

存入資料的時候先利用 ReentrantLock 給該函式上鎖,然後判斷當前佇列的容量是否滿了,如果滿了就無限等待,直到有資料在喚醒;如果還未滿,那麼就入佇列。入佇列這裡有如下幾步我就直接畫一個流程圖吧,這樣好說明一些:

根據流程圖,我們知道,先根據 putIndex 來存入資料,然後計算下一次 putIndex 的值跟佇列大小比較,如果相等說明下一次就只能從佇列頭部存入了。

面試官:

那你在說在如何從佇列中獲取資料吧?

程式設計師:

其實 take 跟 put 是有相關聯的,內部機制也都差不多。基本上都是先上鎖,然後判斷佇列中是否有資料,如果沒有就無限等待;如果有資料就存入佇列,存入佇列我這裡還是畫一個流程圖吧。

根據流程圖,我們知道,先根據 takeIndex 來拿到資料,然後計算下一次 takeIndex 的值跟佇列大小比較,如果相等說明下一次就只能從佇列頭部拿資料了。

4、說一下有哪些佇列具有阻塞功能? 大概是如何阻塞的 ?

程式設計師:

1、LinkedBlockingQueue 連結串列阻塞佇列和 ArrayBlockingQueue 陣列阻塞佇列是一類,前者容量是 Integer 的最大值,後者陣列大小固定,兩個阻塞佇列都可以指定容量大小,當佇列滿時,如果有執行緒 put 資料,執行緒會阻塞住,直到有其他執行緒進行消費資料後,才會喚醒阻塞執行緒繼續 put,當佇列空時,如果有執行緒 take 資料,執行緒會阻塞到佇列不空時,繼續 take。

2、SynchronousQueue 同步佇列,當執行緒 put 時,必須有對應執行緒把資料消費掉,put 執行緒才能返回,當執行緒 take 時,需要有對應執行緒進行 put 資料時,take 才能返回,反之則阻塞,舉個例子,執行緒 A put 資料 A1 到佇列中了,此時並沒有任何的消費者,執行緒 A 就無法返回,會阻塞住,直到有執行緒消費掉資料 A1 時,執行緒 A 才能返回。

面試官:

具體說一下底層阻塞原理?

程式設計師:

其實這些具有阻塞的功能,最終都會在 Java 層呼叫 LockSupport##park/unpark 函式,這裡我就以我常用的 LinkedBlockingQueue 佇列來說下具體實現阻塞原理:

最後實現是 native 函式,如下所示:

public native void unpark(Object var1);

public native void park(boolean var1, long var2);
複製程式碼

接著我們直接看 c++ 實現:

C++ 原始碼實現比較多,感興趣的可以看這篇文章 LockSupport原理分析

5、說一下 LinkedBlockingQueue 和 ArrayBlockingQueue 有什麼區別?

程式設計師:

相同點:

1、兩者的阻塞機制大體相同,比如在佇列滿、空時,執行緒都會阻塞住。

不同點:

1、LinkedBlockingQueue 底層是連結串列結構,容量預設是 Interge 的最大值,ArrayBlockingQueue 底層是陣列,容量必須在初始化時指定。

2、兩者的底層結構不同,所以 take、put、remove 的底層實現也就不同。

6、佇列的存取 API 都有什麼區別?比如 put take 和 offer poll

程式設計師:

這裡我就用一個下面的表格來總結一下:

功能 無限阻塞 拋異常 有時間限制阻塞 特殊值
新增 - 佇列滿 put add offer 過超時時間 return false offer return false
檢視並刪除 - 佇列空 take remove poll 過超時時間 return null offer return null
只檢視不刪除 - 佇列空 element Peek return null

總結

這裡我們總結了容器高頻面試知識點,希望在面試中能幫組到你。

參考

關於我

掃碼關注我的公眾號,讓我們離得更進一些!

相關文章