一文搞懂Java併發容器相關面試題

我們常用的Java併發容器類是由java.util.concurrent包爲我們提供的
java.util.concurrent包提供的併發容器主要分爲三類:Concurrent*、CopyOnWrite*、Blocking*

其中Concurrent*的特點大部分通過CAS+synchronized實現的,CopyOnWrite*則是通過複製一份原數據來實現的,而Blocking*是通過AQS實現的

面試常見的併發容器如ConcurrentHashMapCopyOnWriteArrayListBlockQueue的實現類等均是來自juc包,我們只是簡單的知道它們是線程安全的是完全不夠的,所以,讓我們一起來從底層認識下Java併發容器吧!

本文會從常見問題,源碼分析,面試題總結三個部分來展開

CopyOnWriteArrayList

常見問題

誕生的歷史和原因

  • 代替Vector和SynchronizedList,就像ConcurrentHashMap代替SynchronizedMap一樣
  • Vector和SynchronizedList的鎖的粒度太大了,併發效率相對較低,並且迭代時無法編輯
  • Copy-On-Right併發容器還包括CopyOnWriteArray用來替代SynchronizedSet

整體架構

從整體架構上來說,CopyOnWriteArrayList 數據結構和 ArrayList 是一致的,底層是個數組,只不過 CopyOnWriteArrayList 在對數組進行操作的時候,基本會分四步走:

  • 加鎖
  • 從原數組中拷貝出新數組
  • 在新數組上進行操作,並把新數組賦值給數組容器
  • 解鎖。

適用場景

  • 讀操作快,寫就算慢一點也太大問題
  • 讀操作多,寫操作少

如:
黑名單,每日一次更新就夠了
監聽器,監聽迭代操作次數遠高於修改操作

讀寫規則

對比讀寫鎖的規則:讀讀共享、讀寫互斥、寫讀互斥、寫寫互斥

CopyOnWriteArrayList的讀寫規則爲:

  • 讀取不需要加鎖(讀讀共享
  • 寫入不會阻塞讀取操作(讀寫共享、寫讀共享
  • 寫入與寫入之間需要同步等待(寫寫互斥

特徵

  1. 線程安全的,多線程環境下可以直接使用,無需加鎖;
  2. 通過鎖 + 數組拷貝 + volatile 關鍵字保證了線程安全;
  3. 每次數組操作,都會把數組拷貝一份出來,在新數組上進行操作,操作成功之後再賦值回去
  4. 修改過程中:讀取的數據是原來的數據,不存在線程安全;迭代的數據是迭代器生成時的數據,之後的修改不可見

缺點

  • 數據不一致問題: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:爲什麼加鎖了不在原數組直接操作呢?

  1. volatile關鍵字修飾的是數組的引用,如果只是修改數組內元素的值是無法觸發可見性的,必須修改數組的地址,也就是對數組進行重新賦值才能使修改內容對其他線程可見
  2. 在新數組上進行拷貝,對老數組沒有影響,保證了修改過程中,其他線程可以訪問原數據

新增到指定下標位置的源碼:

// 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();
    }
}

步驟分爲三步:

  1. 加鎖

  2. 判斷索引位置

    • 如果刪除在數組尾部,直接複製長度爲len-1的數組返回
    • 如果刪除數據在中間,創建長度爲len-1的新數組,分兩段複製到新數組
  3. 解鎖

批量刪除的源碼:

// 批量刪除包含在 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 每次的數據變動,都會產生新的數組,對老數組的值不會產生影響,所以迭代也可以正常進行。
在這裏插入圖片描述

面試題

  1. CopyOnWriteArrayList 與ArrayList相比有哪些異同?
  • 相同點:
    • 底層數據結構相同,都爲數組
    • 提供的API基本相同,方便使用
  • 不同點:
    • CopyOnWriteArrayList 線程安全,多線程環境下使用無需加鎖
  1. CopyOnWriteArrayList 通過哪些手段實現了線程安全?
  • 數組容器被volatile關鍵字修飾,保證了數組內存地址修改後,修改內容其他線程可見
  • 對數組的所有修改操作都進行了加鎖,並且所有的修改操作都是使用的同一把鎖,保證同一時刻只能有一個線程進行修改
  • 修改過程對原數組進行了賦值,修改操作在新數組上,修改過程中,不會對原數組造成任何影響
  1. 在add方法中,對數組進行加鎖後,線程安全了爲什麼還要對老數組進行拷貝
  • volatile修飾的的數組這個對象地址,如果不拷貝修改內存地址,就無法觸發volatile的可見性效果,其他線程就無法感知修改
  1. 對老數組進行拷貝會有性能損耗,使用中有哪些注意點?
  • 批量操作時儘量使用addAll、removeAll方法,而不要循環的使用add、remove方法,使用*All方法時只進行一次拷貝,而循環的調用單體方法時會拷貝調用的次數,當調用次數較多時,對性能影響就非常明顯
  1. 爲什麼 CopyOnWriteArrayList 迭代過程中,數組結構變動,不會拋出ConcurrentModificationException ?
  • CopyOnWriteArrayList 每次修改操作時都會產生新數組,而迭代時,持有的是老數組的引用,所以對數組結構變動不可見,就不會拋出異常了
  1. 在list的中間插入一個數據,ArrayList和CopyOnWriteArrayList 分別會拷貝幾次數組
  • ArrayList只會拷貝一次,然後把插入位置及後面的數據都往後移一位
  • CopyOnWriteArrayList 拷貝兩次將數據分爲兩部分,分別拷入到新數組,然後再空的位置添加新數據

concurrentHashMap

常見問題

爲什麼需要ConcurrentHashMap?

Hashtable線程安全,但各種方法操作時都直接使用了synchronized鎖住了整個結構
HashMap雖然效率高,但是在多線程環境下不安全

需要一箇中和了HashtableHashMap的類在多線程下高效的使用

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的過程

  1. 如果數組爲空,進行初始化
  2. 計算當前槽點有無值,如果沒有值就採用CAS創建,失敗後自旋直到創建成功,
  3. 如果槽點是轉移節點(正在擴容),自旋等待擴容完成後新增
  4. 新增的三種情況
    • 如果槽點有值鎖定槽點,其他線程不能操作
    • 如果是鏈表,新增值到鏈表的尾部
    • 如果是紅黑樹,使用紅黑樹新增方法新增
  5. 新增完成檢查鏈表是否需要轉換爲紅黑樹
  6. 最後檢查是否需要擴容
    具體源碼如下:
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;
}

數組初始化的線程安全保證

  1. 通過自旋保證初始化一定能成功
  2. 通過CAS設置sizeCtl遍歷值保證同時只有一個線程進行初始化
  3. 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;
}

新增槽點值時的線程安全保障

  1. 通過自旋保證一定能新增成功
  2. 當前槽點爲空時,通過CAS新增
  3. 當前槽點有值時,鎖住當前槽點(發生hash衝突了)
    在這裏插入圖片描述
  4. 紅黑樹旋轉時,鎖住紅黑樹根節點,保證同一時刻當前紅黑樹只能被一個線程旋轉
    在這裏插入圖片描述

通過自旋 + CAS + synchronized保證了新增槽點值的線程安全

擴容時的線程安全保證

ConcurrentHashMap 的擴容時機和HashMap一致,都是在put方法的最後一步檢查是否需要擴容,但是擴容的過程完全不同。
ConcurrentHashMap 的擴容方法叫做transfer,實現思路如下

  1. 將老數組的值拷貝到擴容後的新數組上,從數組的隊尾開始拷貝
  2. 拷貝數組的槽點時,先把原數組的槽點鎖住,保證原數組槽點不能被操作,成功拷貝後將原數組這個槽點設置爲轉移節點
  3. 此時如果有新數據需要put到此槽點時,發現槽點爲轉移節點,就會自旋等待。所以擴容完成前數據不會發生改變
  4. 直到所有數組數據都拷貝到新數組時,把新數組賦值給數組容器,拷貝完成

關鍵源碼如下:

// 擴容主要分 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的過程

  1. 計算hash值獲取數組下標
  2. 找到對應的位置,根據情況取值
    • 直接取值
    • 紅黑樹取值
    • 遍歷鏈表取值
  3. 返回找到的結果

具體源碼如下:

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;
}

面試題

  1. ConcurrentHashMap 和 HashMap 的相同點和不同點
  • 相同之處:

    • 都是數組 +鏈表+紅黑樹的數據結構(JDK8之後),所以基本操作的思想一致
    • 都實現了Map接口,繼承了AbstractMap 操作類,所以方法大都相似,可以相互切換
  • 不同之處:

    • ConcurrentHashMap 是線程安全的,多線程環境下,無需加鎖直接使用
    • ConcurrentHashMap 多了轉移節點,主要用戶保證擴容時的線程安全
  1. ConcurrentHashMap 通過哪些手段保證線程安全
  • 儲存Map數據的數組時被volatile關鍵字修飾,一旦被修改,其他線程就可見修改。因爲是數組存儲,所以只有改變數組內存值是纔會觸發volatile的可見性
  • 如果put操作時hash計算出的槽點內沒有值,採用自旋+CAS保證put一定成功,且不會覆蓋其他線程put的值
  • 如果put操作時節點正在擴容,即發現槽點爲轉移節點,會等待擴容完成後再進行put操作,保證擴容時老數組不會變化
  • 對槽點進行操作時會鎖住槽點,保證只有當前線程能對槽點上的鏈表或紅黑樹進行操作
  • 紅黑樹旋轉時會鎖住根節點,保證旋轉時線程安全
  1. 描述一下 CAS 算法在 ConcurrentHashMap 中的應用
  • CAS是一種樂觀鎖,在執行操作時會判斷內存中的值是否和準備修改前獲取的值相同,如果相同,把新值賦值給對象,否則賦值失敗,整個過程都是原子性操作,無線程安全問題
  • ConcurrentHashMap 的put操作是結合自旋用到了CAS,如果hash計算出的位置的槽點值爲空,就採用CAS+自旋進行賦值,如果賦值是檢查值爲空,就賦值,如果不爲空說明有其他線程先賦值了,放棄本次操作,進入下一輪循環
  1. ConcurrentHashMap 是如何發現當前槽點正在擴容的?
  • ConcurrentHashMap 新增了一個節點類型,叫做轉移節點,當我們發現當前槽點是轉移節點時(轉移節點的 hash 值是 -1),即表示 Map 正在進行擴容
  1. 發現槽點正在擴容時,put 操作會怎麼辦?
  • 無限 for 循環,或者走到擴容方法中去,幫助擴容,一直等待擴容完成之後,再執行 put 操作
  1. ConcurrentHashMap 和HashMap的擴容有什麼不同?
  • HashMap的擴容是創建一個新數組,將值直接放入新數組中,JDK7採用頭鏈接法,會出現死循環,JDK8採用尾鏈接法,不會造成死循環
  • ConcurrentHashMap 擴容是從數組隊尾開始拷貝,拷貝槽點時會鎖住槽點,拷貝完成後將槽點設置爲轉移節點。所以槽點拷貝完成後將新數組賦值給容器
  1. ConcurrentHashMap 在 Java 7 和 8 中關於線程安全的做法有啥不同?
  • 兩者實現差距很大
    • Java7中採用分段鎖,默認爲16個segment,操作時最多能滿足16個的併發
    • Java8中採用自旋鎖+CAS+synchronized,鎖住的是某個槽點,併發效率高
  1. 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)
  1. 爲什麼超過沖突超過8纔將鏈表轉爲紅黑樹而不直接用紅黑樹
  • 默認使用鏈表, 鏈表佔用的內存更小
  • 正常情況下,想要達到衝突爲8的機率非常小(泊松分佈計算爲千萬分之幾的概率),如果真的發生了轉爲紅黑樹可以保證極端情況下的效率

阻塞隊列

常見問題

爲什麼要使用隊列?

  • 使用了隊列可以在線程間傳遞數據:生產者消費者模式,銀行轉賬
  • 隊列可以將線程安全問題交給隊列解決

阻塞隊列

  1. 什麼是阻塞隊列
  • 具有阻塞功能的隊列
  • 通常,一端給生產者放數據,另一個端給消費者來拿數據。
  • 阻塞隊列是線程安全的隊列
  • 阻塞隊列是線程池的重要組成部分
  1. 主要方法
  • 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
  1. ArrayBlockingQueue
  • 有界
  • 能指定容量
  • 可以指定公平或者非公平
  1. LinkedBlockingQueue
  • 無界
  • 容量Integer.MAX_VALUE
  • 內部結構:
    • Node
    • 兩把鎖 :takeLock、putLock
  1. PriorityBlockingQueue
  • 支持優先級
  • 自然排序(不是先進先出)
  • 無界隊列(不夠了可以擴容)
  • PriorityQueue的線程安全版本(傳入內容必須是可比較的,可重寫比較規則)
  1. SynchronousQueue
  • 容量爲0,不需要存儲,直接傳遞,效率很高
  • 無peek等函數,因爲容量爲0不存在頭結點概念
  • 極好的直接傳遞的併發數據結構
  • SynchronousQueue是newCachedThreadPool的阻塞隊列
  1. DelayQueue
  • 延遲隊列,根據延遲時間排序
  • 元素需要實現Delayed接口,規定排序順序
  • 無界隊列

非阻塞隊列

  1. 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指針指向數組頭部

面試題

  1. 說一說你對隊列的理解,隊列和集合的區別
  • 對隊列的理解
    • 隊列本身也是一個容器,底層可以採用不同的數據結構,如LinkedBlockingQueue 底層是鏈表,ArrayBlockingQueue 底層是數組
    • 有的隊列具有存儲功能,有的隊列不具有存儲功能,如SynchronousQueue不具有存儲空間
    • 隊列可以使入隊出隊兩端解耦合
    • 隊列能提供阻塞功能,能實現線程安全
  • 隊列和集合的區別
    • 雖然底層數據結構相似,但是實現的接口不用,提供的API不同
    • 隊列提供了阻塞功能,能實現生產者消費者關係
    • 隊列能實現數據解耦合
  1. 哪些隊列具有阻塞的功能,大概是如何阻塞的?
  • LinkedBlockingQueue 和ArrayBlockingQueue 是阻塞隊列,前者容量爲Integer.MAX_VALUE,後者的可以指定。進行put和take操作時會鎖住隊列
  • SynchronousQueue 是同步隊列,本身不能存儲數據,如果生成了數據沒有被消費就會阻塞住,如果消費了沒有生成的也會阻塞住
  1. LinkedBlockingQueue 和 ArrayBlockingQueue 的區別?
  • 底層實現不同,LinkedBlockingQueue 爲鏈表,ArrayBlockingQueue 爲數組
  • 數據容量不同,LinkedBlockingQueue 默認Integer.MAX_VALUE,ArrayBlockingQueue 必須手動指定
  • LinkedBlockingQueue 使用了take鎖和put鎖,ArrayBlockingQueue 都使用的同一個鎖
  1. 往隊列裏 put 數據和take數據是線程安全的麼?爲什麼?
  • 是線程安全的
  • put操作之前,隊列會加鎖,put操作完之後才釋放
  • take操作之前,隊列會加鎖,take操作完之後才釋放
  1. take和put方法是不是同一時間只能運行其中一個。
  • 這要視底層實現決定
  • LinkedBlockingQueue 的take 和put有單獨的鎖,可以同時進行
  • ArrayBlockingQueue take和put是同一個鎖,同一時刻只能運行一個方法
  1. 工作中經常使用隊列的 put、take 方法有什麼危害,如何避免?
  • put和take 如果發生阻塞之後會永久等待,直到滿足條件爲止
  • 大流量時採用offer和poll來代替,只要設置好超時時間會自動返回false,避免被阻塞
  1. DelayQueue 對元素有什麼要求麼,我把 String 放到隊列中去可以麼?
  • DelayQueue 要求元素必須實現Delayed接口,String類沒有實現不能放入
  1. DelayQueue 如何讓快過期的元素先執行的?
  • DelayQueue 實現了Comparable 接口並重寫了排序方法,過期時間與當前時間差小的在前面被執行
  1. 如何查看 SynchronousQueue 隊列的大小?
  • 此題是個陷進題,題目首先設定了 SynchronousQueue 是可以查看大小的,實際上 SynchronousQueue 本身是沒有容量的,所以也無法查看其容量的大小,其內部的 size 方法都是寫死的返回 0。
  1. SynchronousQueue 底層有幾種數據結構,兩者有何不同?
  • 有兩種,分別是隊列和堆棧
  • 隊列維護了先入先出的順序,是公平的,而堆棧則是先入後出的,是不公平的
  1. 假設 SynchronousQueue 底層使用的是堆棧,線程 1 執行 take 操作阻塞住了,然後有線程 2 執行 put 操作,問此時線程 2 是如何把 put 的數據傳遞給 take 的?
  • 線程1倍阻塞住了,堆棧頭是線程1,而線程2執行put操作,會把put的數據賦值給堆棧頭的match屬性,並喚醒線程1,線程1醒了之後獲取堆棧頭中的數據
  1. 如果想使用固定大小的隊列,有幾種隊列可以選擇,有何不同?
  • 可以使用 LinkedBlockingQueue 和 ArrayBlockingQueue 兩種隊列。
  • 前者是鏈表,後者是數組,鏈表新增時,只要建立起新增數據和鏈尾數據之間的關聯即可,數組新增時,需要考慮到索引的位置(takeIndex 和 putIndex 分別記錄着下次拿數據、放數據的索引位置),如果增加到了數組最後一個位置,下次就要重頭開始新增
  1. ArrayBlockingQueue 可以動態擴容麼?用到數組最後一個位置時怎麼辦?
  • ArrayBlockingQueue 雖然底層是數組實現,但是不支持動態擴容
  • 如果使用到了數組尾部,下一次操作會指向數組頭
  1. ArrayBlockingQueue 的take 和 put 都是怎麼找到索引位置的?是利用 hash 算法計算得到的麼?
  • ArrayBlockingQueue 維護了兩個指針:takeIndex和putIndex,分別標識下一次相關操作的位置,不需要通過hash算法獲取

參考:

慕課網《面試官系統精講Java源碼及大廠真題》
極客時間《Java核心技術面試精講》
慕課網《玩轉Java併發工具,精通JUC,成爲併發多面手》


更多Java面試複習筆記和總結可訪問我的面試複習專欄《Java面試複習筆記》,或者訪問我另一篇博客《Java面試核心知識點彙總》查看目錄和直達鏈接

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