前言
相信大家在工作中使用容器的場景是具有多變性的,那麼在效能方面你知道怎麼去選擇一種當前最優的資料結構嗎? 或許對於工作多年有經驗的開發者來說,是沒有問題的。但是對於剛入門 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、每次陣列操作,都會把陣列拷貝一份出來,在新陣列上進行操作,操作成功之後再賦值回去。
執行緒安全我從原始碼的 add
、remove
的實現來說一下它們各自怎麼保證執行緒安全的吧?
這裡一定要思路清晰,最好主動找常用的 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>, RandomAccess, Cloneable, java.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、通過可重入互斥鎖 ReentrantLock
對add(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、最左邊 table
是 HashMap
的陣列結構,允許 Node
的 value
值為 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[] mArray
按 mHashes
的順序用相鄰位置儲存 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, null, false, true);
}
}
複製程式碼
總結來說,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##yield
和 sun.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 迴圈一起使用的,步驟如下:
計算出陣列索引下標,拿出下標對應的原值; CAS 覆蓋當前下標的值,賦值時,如果發現記憶體值和 1 拿出來的原值相等,執行賦值,退出迴圈,否則不賦值,轉到 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、和集合的區別:
部分佇列和部分集合底層的儲存結構很相似的,但兩者為了完成不同的事情,提供的 API 和其底層的操作實現是不同的。 佇列提供了阻塞的功能,能對消費者和生產者進行簡單的管理,佇列空時,會阻塞消費者,有其他執行緒進行 put 操作後,會喚醒阻塞的消費者,讓消費者拿資料進行消費,佇列滿時亦然。 解耦了生產者和消費者,佇列就像是生產者和消費者之間的管道一樣,生產者只管往裡面丟,消費者只管不斷消費,兩者之間互不關心。
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 |
總結
這裡我們總結了容器高頻面試知識點,希望在面試中能幫組到你。
參考
關於我
Email: yang1001yk@gmail.com 個人部落格: www.devyk.top GitHub: github.com/yangkun1992… 掘金部落格: juejin.im/user/578259…
掃碼關注我的公眾號,讓我們離得更進一些!