我們常用的Java併發容器類是由java.util.concurrent包爲我們提供的
java.util.concurrent包提供的併發容器主要分爲三類:Concurrent*、CopyOnWrite*、Blocking*
其中Concurrent*
的特點大部分通過CAS+synchronized
實現的,CopyOnWrite*
則是通過複製一份原數據來實現的,而Blocking*
是通過AQS
實現的
面試常見的併發容器如ConcurrentHashMap
、CopyOnWriteArrayList
、BlockQueue的實現類
等均是來自juc包,我們只是簡單的知道它們是線程安全的是完全不夠的,所以,讓我們一起來從底層認識下Java併發容器吧!
本文會從常見問題,源碼分析,面試題總結三個部分來展開
CopyOnWriteArrayList
常見問題
誕生的歷史和原因
- 代替Vector和SynchronizedList,就像ConcurrentHashMap代替SynchronizedMap一樣
- Vector和SynchronizedList的鎖的粒度太大了,併發效率相對較低,並且迭代時無法編輯
- Copy-On-Right併發容器還包括CopyOnWriteArray用來替代SynchronizedSet
整體架構
從整體架構上來說,CopyOnWriteArrayList 數據結構和 ArrayList 是一致的,底層是個數組,只不過 CopyOnWriteArrayList 在對數組進行操作的時候,基本會分四步走:
- 加鎖
- 從原數組中拷貝出新數組
- 在新數組上進行操作,並把新數組賦值給數組容器
- 解鎖。
適用場景
- 讀操作快,寫就算慢一點也太大問題
- 讀操作多,寫操作少
如:
黑名單,每日一次更新就夠了
監聽器,監聽迭代操作次數遠高於修改操作
讀寫規則
對比讀寫鎖的規則:讀讀共享、讀寫互斥、寫讀互斥、寫寫互斥
CopyOnWriteArrayList的讀寫規則爲:
- 讀取不需要加鎖(讀讀共享)
- 寫入不會阻塞讀取操作(讀寫共享、寫讀共享)
- 寫入與寫入之間需要同步等待(寫寫互斥)
特徵
- 線程安全的,多線程環境下可以直接使用,無需加鎖;
- 通過鎖 + 數組拷貝 + volatile 關鍵字保證了線程安全;
- 每次數組操作,都會把數組拷貝一份出來,在新數組上進行操作,操作成功之後再賦值回去
- 修改過程中:讀取的數據是原來的數據,不存在線程安全;迭代的數據是迭代器生成時的數據,之後的修改不可見
缺點
- 數據不一致問題:CopyOnWrite容器只能保證數據最終一致性,不能保證數據實時一致性。所以希望寫入數據馬上看到就不要用CopyOnWrite容器
- 內存佔用問題:因爲CopyOnWrite的寫是複製機制,所以進行寫操作時,內存會同時有兩個對象,如果對象較大,會佔用較大內存
案例演示
案例一:
演示下使用ArrayList和CopyOnWriteArrayList迭代時進行修改操作
首先使用ArrayList
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
list.add("4");
list.add("5");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()){
System.out.println("list is: "+list);
String next = iterator.next();
System.out.println("cur is: "+next);
if (next.equals("2")){
list.remove("5");
}
if (next.equals("3")){
list.add("find 3");
}
}
}
運行拋出異常
list is: [1, 2, 3, 4, 5]
cur is: 1
list is: [1, 2, 3, 4, 5]
cur is: 2
list is: [1, 2, 3, 4]
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
at java.util.ArrayList$Itr.next(ArrayList.java:859)
at collection.CopyOnWriteArrayListDemo.main(CopyOnWriteArrayListDemo.java:27)
將ArrayList修改爲CopyOnWriteArrayList
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
運行結果如圖
list is: [1, 2, 3, 4, 5]
cur is: 1
list is: [1, 2, 3, 4, 5]
cur is: 2
list is: [1, 2, 3, 4]
cur is: 3
list is: [1, 2, 3, 4, find 3]
cur is: 4
list is: [1, 2, 3, 4, find 3]
cur is: 5
可以很驚奇的發現,最後一個是cur is:5
而不是預想的find 3
CopyOnWriteArrayList在迭代的時候如果有修改是不可見的,會保持開始迭代時的內容
案例二:演示迭代時迭代數據的確定時間
創建一個迭代器之後對容器進行修改,然後再創建一個迭代器,打印兩個迭代器的數據
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> list =
new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3});
Iterator<Integer> iterator1 = list.iterator();
list.add(4);
Iterator<Integer> iterator2 = list.iterator();
iterator1.forEachRemaining(System.out::print);
System.out.println();
iterator2.forEachRemaining(System.out::print);
}
打印結果如下:
123
1234
從結果可以得知,迭代器的數據在迭代器生成時就已經確定了,對生成迭代器之後的數據修改時不可見的
源碼分析
1. 新增
新增包括新增到數組尾部,新增到數組某一個索引位置,批量新增等等,操作的思路都是那四步:加鎖、拷貝、操作後賦值、解鎖
新增到數組尾部的源碼:
// 添加元素到數組尾部
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 裏面釋放鎖,保證即使 try 發生了異常,仍然能夠釋放鎖
} finally {
lock.unlock();
}
}
從源碼中可以看出,整個add過程都在持有鎖的狀態下進行的,通過鎖保證了只能有一個線程同時對一個數組進行add操作
add過程中會創建一個老數組長度+1的新數組,然後把老數組的值拷貝到新數組內,再添加值到尾部
question:爲什麼加鎖了不在原數組直接操作呢?
- volatile關鍵字修飾的是數組的引用,如果只是修改數組內元素的值是無法觸發可見性的,必須修改數組的地址,也就是對數組進行重新賦值才能使修改內容對其他線程可見
- 在新數組上進行拷貝,對老數組沒有影響,保證了修改過程中,其他線程可以訪問原數據
新增到指定下標位置的源碼:
// len:數組的長度、index:插入的位置
int numMoved = len - index;
// 如果要插入的位置正好等於數組的末尾,直接拷貝數組即可
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
// 如果要插入的位置在數組的中間,就需要拷貝 2 次
// 第一次從 0 拷貝到 index。
// 第二次從 index+1 拷貝到末尾。
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
// index 索引位置的值是空的,直接賦值即可。
newElements[index] = element;
// 把新數組的值賦值給數組的容器中
setArray(newElements);
從源碼可以看出,如果插入的位置是數組末尾,只需要拷貝一次。當插入的位置是中間,就會把原數組分成兩部分進行復制,然後添加新值到新數組
2. 刪除
指定數組索引位置刪除的源碼:
// 刪除某個索引位置的數據
public E remove(int index) {
final ReentrantLock lock = this.lock;
// 加鎖
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 先得到老值
E oldValue = get(elements, index);
int numMoved = len - index - 1;
// 如果要刪除的數據正好是數組的尾部,直接刪除
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
// 如果刪除的數據在數組的中間,分三步走
// 1. 設置新數組的長度減一,因爲是減少一個元素
// 2. 從 0 拷貝到數組新位置
// 3. 從新位置拷貝到數組尾部
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
步驟分爲三步:
-
加鎖
-
判斷索引位置
- 如果刪除在數組尾部,直接複製長度爲
len-1
的數組返回 - 如果刪除數據在中間,創建長度爲
len-1
的新數組,分兩段複製到新數組
- 如果刪除在數組尾部,直接複製長度爲
-
解鎖
批量刪除的源碼:
// 批量刪除包含在 c 中的元素
public boolean removeAll(Collection<?> c) {
if (c == null) throw new NullPointerException();
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 說明數組有值,數組無值直接返回 false
if (len != 0) {
// newlen 表示新數組的索引位置,新數組中存在不包含在 c 中的元素
int newlen = 0;
Object[] temp = new Object[len];
// 循環,把不包含在 c 裏面的元素,放到新數組中
for (int i = 0; i < len; ++i) {
Object element = elements[i];
// 不包含在 c 中的元素,從 0 開始放到新數組中
if (!c.contains(element))
temp[newlen++] = element;
}
// 拷貝新數組,變相的刪除了不包含在 c 中的元素
if (newlen != len) {
setArray(Arrays.copyOf(temp, newlen));
return true;
}
}
return false;
} finally {
lock.unlock();
}
}
批量刪除並不會對數組中的數據挨個刪除,而是對老數組的值進行遍歷,如果值在傳入集合c
中存在,就放入新數組,最後返回的新數組就是不包含待刪除數組的數組了
3.indexOf
indexOf正向搜索源碼:
// o:我們需要搜索的元素
// elements:我們搜索的目標數組
// index:搜索的開始位置
// fence:搜索的結束位置
private static int indexOf(Object o, Object[] elements,
int index, int fence) {
// 支持對 null 的搜索
if (o == null) {
for (int i = index; i < fence; i++)
// 找到第一個 null 值,返回下標索引的位置
if (elements[i] == null)
return i;
} else {
// 通過 equals 方法來判斷元素是否相等
// 如果相等,返回元素的下標位置
for (int i = index; i < fence; i++)
if (o.equals(elements[i]))
return i;
}
return -1;
}
indexOf方法主要用於查找元素在數組中第一次出現的下標位置,如果元素不存在就返回-1,並且支持對null值的搜索
步驟:
- 判斷是否爲空值,如果爲空值遍歷數組判斷是否爲空,找到第一個null返回下標
- 如果不爲空值,遍歷數組,比較值是否相同,找到第一個值相同的返回下標
- 如果找不到就返回-1
4. 迭代
CopyOnWriteArrayList 在迭代過程中,即使原數組的值發生了改變也不會拋出ConcurrentModificationException 異常,因爲每次改動都會生成新數組,不會影響老數組
CopyOnWriteArrayList 迭代持有的是老數組的引用,而 CopyOnWriteArrayList 每次的數據變動,都會產生新的數組,對老數組的值不會產生影響,所以迭代也可以正常進行。
面試題
- CopyOnWriteArrayList 與ArrayList相比有哪些異同?
- 相同點:
- 底層數據結構相同,都爲數組
- 提供的API基本相同,方便使用
- 不同點:
- CopyOnWriteArrayList 線程安全,多線程環境下使用無需加鎖
- CopyOnWriteArrayList 通過哪些手段實現了線程安全?
- 數組容器被volatile關鍵字修飾,保證了數組內存地址修改後,修改內容其他線程可見
- 對數組的所有修改操作都進行了加鎖,並且所有的修改操作都是使用的同一把鎖,保證同一時刻只能有一個線程進行修改
- 修改過程對原數組進行了賦值,修改操作在新數組上,修改過程中,不會對原數組造成任何影響
- 在add方法中,對數組進行加鎖後,線程安全了爲什麼還要對老數組進行拷貝
- volatile修飾的的數組這個對象地址,如果不拷貝修改內存地址,就無法觸發volatile的可見性效果,其他線程就無法感知修改
- 對老數組進行拷貝會有性能損耗,使用中有哪些注意點?
- 批量操作時儘量使用addAll、removeAll方法,而不要循環的使用add、remove方法,使用*All方法時只進行一次拷貝,而循環的調用單體方法時會拷貝調用的次數,當調用次數較多時,對性能影響就非常明顯
- 爲什麼 CopyOnWriteArrayList 迭代過程中,數組結構變動,不會拋出ConcurrentModificationException ?
- CopyOnWriteArrayList 每次修改操作時都會產生新數組,而迭代時,持有的是老數組的引用,所以對數組結構變動不可見,就不會拋出異常了
- 在list的中間插入一個數據,ArrayList和CopyOnWriteArrayList 分別會拷貝幾次數組
- ArrayList只會拷貝一次,然後把插入位置及後面的數據都往後移一位
- CopyOnWriteArrayList 拷貝兩次將數據分爲兩部分,分別拷入到新數組,然後再空的位置添加新數據
concurrentHashMap
常見問題
爲什麼需要ConcurrentHashMap?
Hashtable
線程安全,但各種方法操作時都直接使用了synchronized
鎖住了整個結構
HashMap
雖然效率高,但是在多線程環境下不安全
需要一箇中和了Hashtable
和HashMap
的類在多線程下高效的使用
ConcurrentHashMap的構造方法有哪些
- 無參數的
- 傳入初始化容量的
- 傳入map的
- 傳入初始化容量和閾值的
- 傳入初始化容量、閾值和併發級別的
//無參構造函數
public ConcurrentHashMap() {
}
//可傳初始容器大小的構造函數
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
//可傳入map的構造函數
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
//可設置閾值和初始容量
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
//可設置初始容量和閾值和併發級別
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
ConcurrentHashMap使用什麼技術來保證線程安全?
- JDK1.7中採用
segment + ReentrantLock
實現 - JDK1.8中採用
node + CAS + synchronized
實現
JDK1.7中
- concurrentHashMap最外層是多個
segment
,每個segment
的底層實現和HashMap
類似,任然是數組加鏈表組成的拉鍊法 - 每個
segment
單獨上一個ReentrantLock
鎖,每個segment
之間互不影響,提高了併發效率 - concurrentHashMap默認有16個
Segment
,所以最多可以同時支持16個線程併發寫,其默認值可以在初始化時設置,一旦初始化完成不可以擴容
JDK1.8中
錯誤的使用concurrentHashMap依然會造成線程安全問題
案例:
構建兩個線程,對concurrentHashMap進行讀取,修改,重新寫入的操作
/**
* 〈組合操作不能保證concurrentHashMap線程安全〉
*
* @author Chkl
* @create 2020/3/28
* @since 1.0.0
*/
public class OptionNotSafe implements Runnable {
private static ConcurrentHashMap<String, Integer> scores
= new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
scores.put("張三", 0);
Thread thread1 = new Thread(new OptionNotSafe());
Thread thread2 = new Thread(new OptionNotSafe());
thread1.start();;
thread2.start();
thread1.join();
thread2.join();
System.out.println(scores.get("張三"));
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
Integer score = scores.get("張三");
int newScore = score + 1;
scores.put("張三", newScore);
}
}
}
如果是線程安全的,預期結果應該是2000,而實際運行結果不等於2000
雖然concurrentHashMap可以保證併發下的單個操作是安全的,但是不能保證組合操作的安全,這樣使用是錯誤的用法
正確的用法
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
while (true) {
Integer score = scores.get("張三");
int newScore = score + 1;
boolean b = scores.replace("張三", score, newScore);
if (b) break;
}
}
}
concurrentHashMap針對這種情況有相應的解決措施,調用replace方法,參數列表爲key,oldVal,newVal
,進行修改時會判斷值是否爲oldVal,如果不是則修改失敗返回false,所以需要不斷的進行重試,如果修改成功再退出。這裏應用的就是CAS的思想
concurrentHashMap提供 的組合操作方法:
- replace(key,oldVal,newVal)
- putIfAbsent(key,value)
等價代碼爲:if (!map.containsKey(key)) return map.put(key,value); else { return map.get(key); }
源碼分析
put value的過程
- 如果數組爲空,進行初始化
- 計算當前槽點有無值,如果沒有值就採用
CAS
創建,失敗後自旋直到創建成功, - 如果槽點是轉移節點(正在擴容),自旋等待擴容完成後新增
- 新增的三種情況
- 如果槽點有值鎖定槽點,其他線程不能操作
- 如果是鏈表,新增值到鏈表的尾部
- 如果是紅黑樹,使用紅黑樹新增方法新增
- 新增完成檢查鏈表是否需要轉換爲紅黑樹
- 最後檢查是否需要擴容
具體源碼如下:
public V put(K key, V value) {
return this.putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 或 value 不允許爲 null
if (key == null || value == null) throw new NullPointerException();
// 計算 key 的哈希碼
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
// 1. 如果 table 數組爲空,則進行初始化
if (tab == null || (n = tab.length) == 0) {
// 基於 CAS 策略初始化 table,初始化大小爲 16
tab = this.initTable();
}
// 2. 否則,計算 hash 值對應的下標,獲取 table 上對應下標的頭結點
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
/*
* table 對應下標的頭結點爲 null
* 基於 CAS 設置結點,如果成功則本次 put 操作完成,
* 如果失敗則說明期間有併發操作,需要進入一輪新的循環
*/
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
// 設置結點成功,put 操作完成
break;
}
}
// 3. 否則,如果 Map 正在執行擴容操作(MOVED 哈希值表示正在擴容),則幫助擴容
else if ((fh = f.hash) == MOVED) {
tab = this.helpTransfer(tab, f);
}
// 4. 否則,獲取到 hash 值對應下標的頭結點,且結點不爲 null
else {
V oldVal = null;
synchronized (f) { // 加鎖
if (tabAt(tab, i) == f) { // 再次校驗頭結點爲 f
// 頭結點的哈希值大於等於 0,說明是鏈表,如果是樹的話應該是 -2
if (fh >= 0) {
binCount = 1;
for (Node<K, V> e = f; ; ++binCount) {
K ek;
// 如果是已經存在的 key,則在允許覆蓋的前提下直接覆蓋已有的值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent) {
e.val = value;
}
break;
}
// 如果是不存在的 key,則直接在鏈表尾部插入一個新的結點
Node<K, V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<>(hash, key, value, null);
break;
}
}
}
// 紅黑樹
else if (f instanceof TreeBin) {
Node<K, V> p;
binCount = 2;
// 調用紅黑樹的方法獲取到修改的結點,並插入或更新結點(如果允許)
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent) {
p.val = value;
}
}
}
}
} // end synchronized
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) {
/*
* 結點數目大於等於 8,對鏈表執行轉換操作
* - 如果 table 長度小於 64,則執行擴容
* - 如果 table 長度大於等於 64,則轉換成紅黑樹
*/
this.treeifyBin(tab, i);
}
if (oldVal != null) {
return oldVal;
}
break;
}
}
}
// size 加 1
this.addCount(1L, binCount);
return null;
}
數組初始化的線程安全保證
- 通過自旋保證初始化一定能成功
- 通過CAS設置sizeCtl遍歷值保證同時只有一個線程進行初始化
- CAS成功後再次判斷數組是否完成初始化,如果未完成再次初始化
通過自旋+CAS+雙中檢查保證了數組初始化的線程安全
具體源碼如下:
//初始化 table,通過對 sizeCtl 的變量賦值來保證數組只能被初始化一次
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//通過自旋保證初始化成功
while ((tab = table) == null || tab.length == 0) {
// 小於 0 代表有線程正在初始化,釋放當前 CPU 的調度權,重新發起鎖的競爭
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 賦值保證當前只有一個線程在初始化,-1 代表當前只有一個線程能初始化
// 保證了數組的初始化的安全性
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 很有可能執行到這裏的時候,table 已經不爲空了,這裏是雙重 check
if ((tab = table) == null || tab.length == 0) {
// 進行初始化
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
新增槽點值時的線程安全保障
- 通過自旋保證一定能新增成功
- 當前槽點爲空時,通過CAS新增
- 當前槽點有值時,鎖住當前槽點(發生hash衝突了)
- 紅黑樹旋轉時,鎖住紅黑樹根節點,保證同一時刻當前紅黑樹只能被一個線程旋轉
通過自旋 + CAS + synchronized保證了新增槽點值的線程安全
擴容時的線程安全保證
ConcurrentHashMap 的擴容時機和HashMap一致,都是在put方法的最後一步檢查是否需要擴容,但是擴容的過程完全不同。
ConcurrentHashMap 的擴容方法叫做transfer
,實現思路如下
- 將老數組的值拷貝到擴容後的新數組上,從數組的隊尾開始拷貝
- 拷貝數組的槽點時,先把原數組的槽點鎖住,保證原數組槽點不能被操作,成功拷貝後將原數組這個槽點設置爲轉移節點
- 此時如果有新數據需要put到此槽點時,發現槽點爲轉移節點,就會自旋等待。所以擴容完成前數據不會發生改變
- 直到所有數組數據都拷貝到新數組時,把新數組賦值給數組容器,拷貝完成
關鍵源碼如下:
// 擴容主要分 2 步,第一新建新的空數組,第二移動拷貝每個元素到新數組中去
// tab:原數組,nextTab:新數組
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
// 老數組的長度
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果新數組爲空,初始化,大小爲原數組的兩倍,n << 1
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
// 新數組的長度
int nextn = nextTab.length;
// 代表轉移節點,如果原數組上是轉移節點,說明該節點正在被擴容
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
// 無限自旋,i 的值會從原數組的最大值開始,慢慢遞減到 0
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
// 結束循環的標誌
if (--i >= bound || finishing)
advance = false;
// 已經拷貝完成
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 每次減少 i 的值
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// if 任意條件滿足說明拷貝結束了
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 拷貝結束,直接賦值,因爲每次拷貝完一個節點,都在原數組上放轉移節點,所以拷貝完成的節點的數據一定不會再發生變化。
// 原數組發現是轉移節點,是不會操作的,會一直等待轉移節點消失之後在進行操作。
// 也就是說數組節點一旦被標記爲轉移節點,是不會再發生任何變動的,所以不會有任何線程安全的問題
// 所以此處直接賦值,沒有任何問題。
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) {
// 進行節點的拷貝
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
// 如果節點只有單個數據,直接拷貝,如果是鏈表,循環多次組成鏈表拷貝
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 在新數組位置上放置拷貝的值
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
// 在老數組位置上放上 ForwardingNode 節點
// put 時,發現是 ForwardingNode 節點,就不會再動這個節點的數據了
setTabAt(tab, i, fwd);
advance = true;
}
// 紅黑樹的拷貝
else if (f instanceof TreeBin) {
// 紅黑樹的拷貝工作,同 HashMap 的內容,代碼忽略
…………
// 在老數組位置上放上 ForwardingNode 節點
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
get value的過程
- 計算hash值獲取數組下標
- 找到對應的位置,根據情況取值
- 直接取值
- 紅黑樹取值
- 遍歷鏈表取值
- 返回找到的結果
具體源碼如下:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//計算hashcode
int h = spread(key.hashCode());
//不是空的數組 && 並且當前索引的槽點數據不是空的
//否則該key對應的值不存在,返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//槽點第一個值和key相等,直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果是紅黑樹或者轉移節點,使用對應的find方法
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//如果是鏈表,遍歷查找
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
面試題
- ConcurrentHashMap 和 HashMap 的相同點和不同點
-
相同之處:
- 都是數組 +鏈表+紅黑樹的數據結構(JDK8之後),所以基本操作的思想一致
- 都實現了Map接口,繼承了AbstractMap 操作類,所以方法大都相似,可以相互切換
-
不同之處:
- ConcurrentHashMap 是線程安全的,多線程環境下,無需加鎖直接使用
- ConcurrentHashMap 多了轉移節點,主要用戶保證擴容時的線程安全
- ConcurrentHashMap 通過哪些手段保證線程安全
- 儲存Map數據的數組時被volatile關鍵字修飾,一旦被修改,其他線程就可見修改。因爲是數組存儲,所以只有改變數組內存值是纔會觸發volatile的可見性
- 如果put操作時hash計算出的槽點內沒有值,採用自旋+CAS保證put一定成功,且不會覆蓋其他線程put的值
- 如果put操作時節點正在擴容,即發現槽點爲轉移節點,會等待擴容完成後再進行put操作,保證擴容時老數組不會變化
- 對槽點進行操作時會鎖住槽點,保證只有當前線程能對槽點上的鏈表或紅黑樹進行操作
- 紅黑樹旋轉時會鎖住根節點,保證旋轉時線程安全
- 描述一下 CAS 算法在 ConcurrentHashMap 中的應用
- CAS是一種樂觀鎖,在執行操作時會判斷內存中的值是否和準備修改前獲取的值相同,如果相同,把新值賦值給對象,否則賦值失敗,整個過程都是原子性操作,無線程安全問題
- ConcurrentHashMap 的put操作是結合自旋用到了CAS,如果hash計算出的位置的槽點值爲空,就採用CAS+自旋進行賦值,如果賦值是檢查值爲空,就賦值,如果不爲空說明有其他線程先賦值了,放棄本次操作,進入下一輪循環
- ConcurrentHashMap 是如何發現當前槽點正在擴容的?
- ConcurrentHashMap 新增了一個節點類型,叫做轉移節點,當我們發現當前槽點是轉移節點時(轉移節點的 hash 值是 -1),即表示 Map 正在進行擴容
- 發現槽點正在擴容時,put 操作會怎麼辦?
- 無限 for 循環,或者走到擴容方法中去,幫助擴容,一直等待擴容完成之後,再執行 put 操作
- ConcurrentHashMap 和HashMap的擴容有什麼不同?
- HashMap的擴容是創建一個新數組,將值直接放入新數組中,JDK7採用頭鏈接法,會出現死循環,JDK8採用尾鏈接法,不會造成死循環
- ConcurrentHashMap 擴容是從數組隊尾開始拷貝,拷貝槽點時會鎖住槽點,拷貝完成後將槽點設置爲轉移節點。所以槽點拷貝完成後將新數組賦值給容器
- ConcurrentHashMap 在 Java 7 和 8 中關於線程安全的做法有啥不同?
- 兩者實現差距很大
- Java7中採用分段鎖,默認爲16個segment,操作時最多能滿足16個的併發
- Java8中採用自旋鎖+CAS+synchronized,鎖住的是某個槽點,併發效率高
- JDK1.7和JDK1.8中的concurrentHashMap的區別
- 數據結構不同了,JDK1.8幾乎全部重寫了concurrentHashMap的結構,不再使用segment,而是讓hash的每一個節點都是一個node,都是獨立的,提高了併發度
- Hash碰撞的處理不同了,變更同HashMap,除了拉鍊法之外還增加紅黑樹
- 保證併發安全的方式不同了,1.7中通過分段鎖實現,而1.8中採用CAS+synchronized
- 查詢複雜度不同,1.7中鏈表時間可能很長查詢時間
0(n)
,1.8中超過閾值轉爲紅黑樹查詢時間變爲n(logn)
- 爲什麼超過沖突超過8纔將鏈表轉爲紅黑樹而不直接用紅黑樹
- 默認使用鏈表, 鏈表佔用的內存更小
- 正常情況下,想要達到衝突爲8的機率非常小(泊松分佈計算爲千萬分之幾的概率),如果真的發生了轉爲紅黑樹可以保證極端情況下的效率
阻塞隊列
常見問題
爲什麼要使用隊列?
- 使用了隊列可以在線程間傳遞數據:生產者消費者模式,銀行轉賬
- 隊列可以將線程安全問題交給隊列解決
阻塞隊列
- 什麼是阻塞隊列
- 具有阻塞功能的隊列
- 通常,一端給生產者放數據,另一個端給消費者來拿數據。
- 阻塞隊列是線程安全的隊列
- 阻塞隊列是線程池的重要組成部分
- 主要方法
- take,put:
- put放數據,如果隊列滿了put阻塞住,直到有空閒空間。
- take拿數據 , 如果隊列空了take阻塞住 ,直到有數據
- add、remove、element
- add放數據,類似於put,如果隊列滿了拋出異常
- remove拿數據,類似於take,如果隊列空了拋出異常
- element返回隊列頭元素,如果隊列爲空拋出異常
- offer、poll、peek
- offer放數據,類似於put,如果隊列滿了返回false
- poll拿數據,類似於take,如果隊列空了返回null
- peek返回隊列頭元素,如果隊列爲空返回null
- ArrayBlockingQueue
- 有界
- 能指定容量
- 可以指定公平或者非公平
- LinkedBlockingQueue
- 無界
- 容量Integer.MAX_VALUE
- 內部結構:
- Node
- 兩把鎖 :takeLock、putLock
- PriorityBlockingQueue
- 支持優先級
- 自然排序(不是先進先出)
- 無界隊列(不夠了可以擴容)
- PriorityQueue的線程安全版本(傳入內容必須是可比較的,可重寫比較規則)
- SynchronousQueue
- 容量爲0,不需要存儲,直接傳遞,效率很高
- 無peek等函數,因爲容量爲0不存在頭結點概念
- 極好的直接傳遞的併發數據結構
- SynchronousQueue是newCachedThreadPool的阻塞隊列
- DelayQueue
- 延遲隊列,根據延遲時間排序
- 元素需要實現Delayed接口,規定排序順序
- 無界隊列
非阻塞隊列
- ConcurrentLinkedQueue
- JUC中只有這一種非阻塞隊列
- 鏈表作爲數據結構
- 採用CAS實現線程安全
- 適合對性能要求高的併發場景
如何選擇適合自己的隊列
- 是否需要邊界
- 是否需要容量
- 吞吐量
源碼分析
1. LinkedBlockingQueue
類圖:
從類圖可以看出,直接繼承了AbstractQueue類 並實現了BlockingQueue 接口
那麼LinkedBlockingQueue應該具有集合的相關方法和Queue接口相關的方法
其主要方法可以總結爲:
內部構成源碼:
// 鏈表結構 begin
//鏈表的元素
static class Node<E> {
E item;
//當前元素的下一個,爲空表示當前節點是最後一個
Node<E> next;
Node(E x) { item = x; }
}
//鏈表的容量,默認 Integer.MAX_VALUE
private final int capacity;
//鏈表已有元素大小,使用 AtomicInteger,所以是線程安全的
private final AtomicInteger count = new AtomicInteger();
//鏈表頭
transient Node<E> head;
//鏈表尾
private transient Node<E> last;
// 鏈表結構 end
// 鎖 begin
//take 時的鎖
private final ReentrantLock takeLock = new ReentrantLock();
// take 的條件隊列,condition 可以簡單理解爲基於 ASQ 同步機制建立的條件隊列
private final Condition notEmpty = takeLock.newCondition();
// put 時的鎖,設計兩把鎖的目的,主要爲了 take 和 put 可以同時進行
private final ReentrantLock putLock = new ReentrantLock();
// put 的條件隊列
private final Condition notFull = putLock.newCondition();
// 鎖 end
// 迭代器
// 實現了自己的迭代器
private class Itr implements Iterator<E> {
………………
}
內部構成主要分爲三個部分:鏈表 + 兩個鎖 + 迭代器
其中兩把鎖爲take鎖和put鎖,爲了保證線程安全設計了兩把鎖,保證了take和put可以同時進行,互不影響
構造方法源碼:
// 不指定容量,默認 Integer 的最大值
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
// 指定鏈表容量大小,鏈表頭尾相等,節點值(item)都是 null
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node<E>(null);
}
// 已有集合數據進行初始化
public LinkedBlockingQueue(Collection<? extends E> c) {
this(Integer.MAX_VALUE);
final ReentrantLock putLock = this.putLock;
putLock.lock(); // Never contended, but necessary for visibility
try {
int n = 0;
for (E e : c) {
// 集合內的元素不能爲空
if (e == null)
throw new NullPointerException();
// capacity 代表鏈表的大小,在這裏是 Integer 的最大值
// 如果集合類的大小大於 Integer 的最大值,就會報錯
// 其實這個判斷完全可以放在 for 循環外面,這樣可以減少 Integer 的最大值次循環(最壞情況)
if (n == capacity)
throw new IllegalStateException("Queue full");
enqueue(new Node<E>(e));
++n;
}
count.set(n);
} finally {
putLock.unlock();
}
}
構造方法有三種:
- 指定鏈表容量大小
- 不指定鏈表容量大小(默認Integer.MAX_VALUE)
- 對已有集合數據進行初始化
新增(入隊)源碼:
入隊有put、offer、add三種方法,都差不多,以put爲例
// 把e新增到隊列的尾部。
// 如果有可以新增的空間的話,直接新增成功,否則當前線程陷入等待
public void put(E e) throws InterruptedException {
// e 爲空,拋出異常
if (e == null) throw new NullPointerException();
// 預先設置 c 爲 -1,約定負數爲新增失敗
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
// 設置可中斷鎖
putLock.lockInterruptibly();
try {
// 隊列滿了
// 當前線程阻塞,等待其他線程的喚醒(其他線程 take 成功後就會喚醒此處被阻塞的線程)
while (count.get() == capacity) {
// await 無限等待
notFull.await();
}
// 隊列沒有滿,直接新增到隊列的尾部
enqueue(node);
// 新增計數賦值,注意這裏 getAndIncrement 返回的是舊值
// 這裏的 c 是比真實的 count 小 1 的
c = count.getAndIncrement();
// 如果鏈表現在的大小 小於鏈表的容量,說明隊列未滿
// 可以嘗試喚醒一個 put 的等待線程
if (c + 1 < capacity)
notFull.signal();
} finally {
// 釋放鎖
putLock.unlock();
}
// c==0,代表隊列裏面有一個元素
// 會嘗試喚醒一個take的等待線程
if (c == 0)
signalNotEmpty();
}
// 入隊,把新元素放到隊尾
private void enqueue(Node<E> node) {
last = last.next = node;
}
步驟:
- 上一個可中斷put鎖
- 如果隊列不爲滿追加到鏈表尾部
- 如果隊列滿了,線程阻塞
- 新增數據完成後
- 如果隊列不爲滿,喚醒一個put等待線程
- 如果對列有一個元素時,喚醒一個take等待線程
offer與put只有一點點不同,會自旋嘗試,超時了會中斷返回false
刪除(出隊)源碼:
以take爲例說明刪除的原理
// 阻塞拿數據
public E take() throws InterruptedException {
E x;
// 默認負數,代表失敗
int c = -1;
// count 代表當前鏈表數據的真實大小
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
// 空隊列時,阻塞,等待其他線程喚醒
while (count.get() == 0) {
notEmpty.await();
}
// 非空隊列,從隊列的頭部拿一個出來
x = dequeue();
// 減一計算,注意 getAndDecrement 返回的值是舊值
// c 比真實的 count 大1
c = count.getAndDecrement();
// 如果隊列裏面有值,從 take 的等待線程裏面喚醒一個。
// 意思是隊列裏面有值啦,喚醒之前被阻塞的線程
if (c > 1)
notEmpty.signal();
} finally {
// 釋放鎖
takeLock.unlock();
}
// 如果隊列空閒還剩下一個,嘗試從 put 的等待線程中喚醒一個
if (c == capacity)
signalNotFull();
return x;
}
// 隊頭中取數據
private E dequeue() {
Node<E> h = head;
Node<E> first = h.next;
h.next = h; // help GC
head = first;
E x = first.item;
first.item = null;// 頭節點指向 null,刪除
return x;
}
步驟:
- 上一個可中斷take鎖
- 如果隊列不爲空從隊列頭部取出節點
- 如果隊列爲空,線程阻塞
- 刪除數據完成後
- 如果隊列不爲空,喚醒一個take等待線程
- 如果對列只剩下一個空閒了,喚醒一個put等待線程
查看隊首元素源碼:
以peek爲例
// 查看並不刪除元素,如果隊列爲空,返回 null
public E peek() {
// count 代表隊列實際大小,隊列爲空,直接返回 null
if (count.get() == 0)
return null;
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
// 拿到隊列頭
Node<E> first = head.next;
// 判斷隊列頭是否爲空,並返回
if (first == null)
return null;
else
return first.item;
} finally {
takeLock.unlock();
}
}
步驟:
- 加可中斷take鎖
- 獲取隊首值
- 關閉鎖
**2. ArrayBlockingQueue **
數據結構:
// 隊列存放在 object 的數組裏面
// 數組大小必須在初始化的時候手動設置,沒有默認大小
final Object[] items;
// 下次拿數據的時候的索引位置
int takeIndex;
// 下次放數據的索引位置
int putIndex;
// 當前已有元素的大小
int count;
// 可重入的鎖
final ReentrantLock lock;
// take的隊列
private final Condition notEmpty;
// put的隊列
private final Condition notFull;
其中有兩個很重要的變量,takeIndex和putIndex,分別表示下次拿數據和放數據的索引位置,只要維護好這兩個指針,每次操作就不需要進行計算
初始化:
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
// 隊列不爲空 Condition,在 put 成功時使用
notEmpty = lock.newCondition();
// 隊列不滿 Condition,在 take 成功時使用
notFull = lock.newCondition();
}
初始化時有兩個參數:數組的大小和是否公平
如果是公平鎖,鎖競爭時就會按先來後到順序
如果是不公平鎖,鎖競爭是隨機的
新增
// 新增,如果隊列滿,無限阻塞
public void put(E e) throws InterruptedException {
// 元素不能爲空
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 隊列如果是滿的,就無限等待
// 一直等待隊列中有數據被拿走時,自己被喚醒
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
private void enqueue(E x) {
// assert lock.getHoldCount() == 1; 同一時刻只能一個線程進行操作此方法
// assert items[putIndex] == null;
final Object[] items = this.items;
// putIndex 爲本次插入的位置
items[putIndex] = x;
// ++ putIndex 計算下次插入的位置
// 如果下次插入的位置,正好等於隊尾,下次插入就從 0 開始
if (++putIndex == items.length)
putIndex = 0;
count++;
// 喚醒因爲隊列空導致的等待線程
notEmpty.signal();
}
新增是存在兩種情況
- 本次新增數據在中間,可以直接新增,然後更新下一次新增的putindex
- 本次新增數據在數組尾部,更新下次新增的putindex爲數組頭
拿數據:
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 如果隊列爲空,無限等待
// 直到隊列中有數據被 put 後,自己被喚醒
while (count == 0)
notEmpty.await();
// 從隊列中拿數據
return dequeue();
} finally {
lock.unlock();
}
}
private E dequeue() {
final Object[] items = this.items;
// takeIndex 代表本次拿數據的位置,是上一次拿數據時計算好的
E x = (E) items[takeIndex];
// 幫助 gc
items[takeIndex] = null;
// ++ takeIndex 計算下次拿數據的位置
// 如果正好等於隊尾的話,下次就從 0 開始拿數據
if (++takeIndex == items.length)
takeIndex = 0;
// 隊列實際大小減 1
count--;
if (itrs != null)
itrs.elementDequeued();
// 喚醒被隊列滿所阻塞的線程
notFull.signal();
return x;
}
從源碼可以看出,每次拿數據的位置是takeIndex的位置,拿到數據後更新takeIndex的位置,如果拿的數據在中間部分,takeIndex+1,如果位於數組尾部,將takeIndex指針指向數組頭部
面試題
- 說一說你對隊列的理解,隊列和集合的區別
- 對隊列的理解
- 隊列本身也是一個容器,底層可以採用不同的數據結構,如LinkedBlockingQueue 底層是鏈表,ArrayBlockingQueue 底層是數組
- 有的隊列具有存儲功能,有的隊列不具有存儲功能,如SynchronousQueue不具有存儲空間
- 隊列可以使入隊出隊兩端解耦合
- 隊列能提供阻塞功能,能實現線程安全
- 隊列和集合的區別
- 雖然底層數據結構相似,但是實現的接口不用,提供的API不同
- 隊列提供了阻塞功能,能實現生產者消費者關係
- 隊列能實現數據解耦合
- 哪些隊列具有阻塞的功能,大概是如何阻塞的?
- LinkedBlockingQueue 和ArrayBlockingQueue 是阻塞隊列,前者容量爲Integer.MAX_VALUE,後者的可以指定。進行put和take操作時會鎖住隊列
- SynchronousQueue 是同步隊列,本身不能存儲數據,如果生成了數據沒有被消費就會阻塞住,如果消費了沒有生成的也會阻塞住
- LinkedBlockingQueue 和 ArrayBlockingQueue 的區別?
- 底層實現不同,LinkedBlockingQueue 爲鏈表,ArrayBlockingQueue 爲數組
- 數據容量不同,LinkedBlockingQueue 默認Integer.MAX_VALUE,ArrayBlockingQueue 必須手動指定
- LinkedBlockingQueue 使用了take鎖和put鎖,ArrayBlockingQueue 都使用的同一個鎖
- 往隊列裏 put 數據和take數據是線程安全的麼?爲什麼?
- 是線程安全的
- put操作之前,隊列會加鎖,put操作完之後才釋放
- take操作之前,隊列會加鎖,take操作完之後才釋放
- take和put方法是不是同一時間只能運行其中一個。
- 這要視底層實現決定
- LinkedBlockingQueue 的take 和put有單獨的鎖,可以同時進行
- ArrayBlockingQueue take和put是同一個鎖,同一時刻只能運行一個方法
- 工作中經常使用隊列的 put、take 方法有什麼危害,如何避免?
- put和take 如果發生阻塞之後會永久等待,直到滿足條件爲止
- 大流量時採用offer和poll來代替,只要設置好超時時間會自動返回false,避免被阻塞
- DelayQueue 對元素有什麼要求麼,我把 String 放到隊列中去可以麼?
- DelayQueue 要求元素必須實現Delayed接口,String類沒有實現不能放入
- DelayQueue 如何讓快過期的元素先執行的?
- DelayQueue 實現了Comparable 接口並重寫了排序方法,過期時間與當前時間差小的在前面被執行
- 如何查看 SynchronousQueue 隊列的大小?
- 此題是個陷進題,題目首先設定了 SynchronousQueue 是可以查看大小的,實際上 SynchronousQueue 本身是沒有容量的,所以也無法查看其容量的大小,其內部的 size 方法都是寫死的返回 0。
- SynchronousQueue 底層有幾種數據結構,兩者有何不同?
- 有兩種,分別是隊列和堆棧
- 隊列維護了先入先出的順序,是公平的,而堆棧則是先入後出的,是不公平的
- 假設 SynchronousQueue 底層使用的是堆棧,線程 1 執行 take 操作阻塞住了,然後有線程 2 執行 put 操作,問此時線程 2 是如何把 put 的數據傳遞給 take 的?
- 線程1倍阻塞住了,堆棧頭是線程1,而線程2執行put操作,會把put的數據賦值給堆棧頭的match屬性,並喚醒線程1,線程1醒了之後獲取堆棧頭中的數據
- 如果想使用固定大小的隊列,有幾種隊列可以選擇,有何不同?
- 可以使用 LinkedBlockingQueue 和 ArrayBlockingQueue 兩種隊列。
- 前者是鏈表,後者是數組,鏈表新增時,只要建立起新增數據和鏈尾數據之間的關聯即可,數組新增時,需要考慮到索引的位置(takeIndex 和 putIndex 分別記錄着下次拿數據、放數據的索引位置),如果增加到了數組最後一個位置,下次就要重頭開始新增
- ArrayBlockingQueue 可以動態擴容麼?用到數組最後一個位置時怎麼辦?
- ArrayBlockingQueue 雖然底層是數組實現,但是不支持動態擴容
- 如果使用到了數組尾部,下一次操作會指向數組頭
- ArrayBlockingQueue 的take 和 put 都是怎麼找到索引位置的?是利用 hash 算法計算得到的麼?
- ArrayBlockingQueue 維護了兩個指針:takeIndex和putIndex,分別標識下一次相關操作的位置,不需要通過hash算法獲取
參考:
慕課網《面試官系統精講Java源碼及大廠真題》
極客時間《Java核心技術面試精講》
慕課網《玩轉Java併發工具,精通JUC,成爲併發多面手》
更多Java面試複習筆記和總結可訪問我的面試複習專欄《Java面試複習筆記》,或者訪問我另一篇博客《Java面試核心知識點彙總》查看目錄和直達鏈接