2019秋招:460道Java後端面試高頻題答案版【模塊三:Java並發】

  • 2019 年 10 月 6 日
  • 筆記

寫在前面

Java 並發是 Java 後端開發面試中最重要的模塊之一,畢竟這是對 Java 基礎的深度考核。而且現在基本上程序都是需要使用多線程進行處理的,如果說 Java 並發你不會,只要面試官問你了,可以說很難通過面試。所以這一塊一定要好好下功夫。我個人學習這塊知識點的學習方法和 Java集合類是非常像的,重點都在於考察你對源碼的了解,學習Java 並發其實就是學習 JUC 包中的一些關鍵的類:AQS、原子類等等。

1、閱讀源碼:需要閱讀 JUC 包中主要的類的源碼:AQS、鎖:ReentrantLock、ReentrantReadWriteLock、BlockingQueue、CountDownLatch、CyclicBarrier、線程池等等;

2、做筆記:因為看完源碼很快就會忘了,所以需要對關鍵的源碼部分加以注釋做成筆記,這裡推薦寫博客或者寫在 github 倉庫中,方便後面面試時複習;

3、看大佬們的源碼分析文章:因為你看的可是 JDK 的源碼,其中很多設計精妙之處不是「我等菜雞」隨便就可以看出來的,所以多看看大佬們的文章,肯定會有意外的收穫;

4、看面經:這個也是少不了的,了解面試官們問問題的方式和頻率,可以有優先級的準備。

5、特別提醒:對於 Java 並發的面試題來說是一個很好展現自己基礎的模塊。所以如果你對這個模塊掌握的比較好,面試遇到並發的問題千萬不要面試官問什麼,你就只回答什麼,一定要擴展深度和廣度,把你知道的都說出來。曾經有一次面美團,面試官一直問我分佈式的知識,我問他現在對應屆生的分佈式都開始要求了嗎?他回答,面試者太多了,要看到你和「面經」面試者的不一樣。所以一定要在可以突顯自己知識的模塊多擴展,當然這是在你有把握的前提下,不然只會被吊打,適得其反。

1、並行和並發有什麼區別?

1. 並行是指兩個或者多個事件在同一時刻發生;而並發是指兩個或多個事件在同一時間間隔發生;

2. 並行是在不同實體上的多個事件,並發是在同一實體上的多個事件;

3. 在一台處理器上「同時」處理多個任務,在多台處理器上同時處理多個任務。如 Hadoop 分佈式集群。所以並發編程的目標是充分的利用處理器的每一個核,以達到最高的處理性能。

2、線程和進程的區別?

進程:是程序運行和資源分配的基本單位,一個程序至少有一個進程,一個進程至少有一個線程。進程在執行過程中擁有獨立的內存單元,而多個線程共享內存資源,減少切換次數,從而效率更高。

線程:是進程的一個實體,是 cpu 調度和分派的基本單位,是比程序更小的能獨立運行的基本單位。同一進程中的多個線程之間可以並發執行。

3、守護線程是什麼?

守護線程(即 Daemon thread),是個服務線程,準確地來說就是服務其他的線程。

4、創建線程的幾種方式?

1. 繼承 Thread 類創建線程;

2. 實現 Runnable 接口創建線程;

3. 通過 Callable 和 Future 創建線程;

4. 通過線程池創建線程。

5、Runnable 和 Callable 有什麼區別?

1. Runnable 接口中的 run() 方法的返回值是 void,它做的事情只是純粹地去執行 run() 方法中的代碼而已;

2. Callable 接口中的 call() 方法是有返回值的,是一個泛型,和 Future、FutureTask 配合可以用來獲取異步執行的結果。

6、線程狀態及轉換?

Thread 的源碼中定義了6種狀態:new(新建)、runnnable(可運行)、blocked(阻塞)、waiting(等待)、time waiting (定時等待)和 terminated(終止)。

線程狀態轉換如下圖所示:

7、sleep() 和 wait() 的區別?

1. sleep() 方法正在執行的線程主動讓出 cpu(然後 cpu 就可以去執行其他任務),在 sleep 指定時間後 cpu 再回到該線程繼續往下執行(注意:sleep 方法只讓出了 cpu,而並不會釋放同步資源鎖);而 wait() 方法則是指當前線程讓自己暫時退讓出同步資源鎖,以便其他正在等待該資源的線程得到該資源進而運行,只有調用了 notify() 方法,之前調用 wait() 的線程才會解除 wait 狀態,可以去參與競爭同步資源鎖,進而得到執行。(注意:notify 的作用相當於叫醒睡着的人,而並不會給他分配任務,就是說 notify 只是讓之前調用 wait 的線程有權利重新參與線程的調度);

2. sleep() 方法可以在任何地方使用,而 wait() 方法則只能在同步方法或同步塊中使用;

3. sleep() 是線程類(Thread)的方法,調用會暫停此線程指定的時間,但監控依然保持,不會釋放對象鎖,到時間自動恢復;wait() 是 Object 的方法,調用會放棄對象鎖,進入等待隊列,待調用 notify()/notifyAll() 喚醒指定的線程或者所有線程,才會進入鎖池,不再次獲得對象鎖才會進入運行狀態。

8、線程的 run() 和 start() 有什麼區別?

1. 每個線程都是通過某個特定 Thread 對象所對應的方法 run() 來完成其操作的,方法 run() 稱為線程體。通過調用 Thread 類的 start() 方法來啟動一個線程;

2. start() 方法來啟動一個線程,真正實現了多線程運行。這時無需等待 run() 方法體代碼執行完畢,可以直接繼續執行下面的代碼;這時此線程是處於就緒狀態,並沒有運行。然後通過此 Thread 類調用方法 run() 來完成其運行狀態,這裡方法 run() 稱為線程體,它包含了要執行的這個線程的內容,run() 方法運行結束,此線程終止。然後 cpu 再調度其它線程;

3. run() 方法是在本線程里的,只是線程里的一個函數,而不是多線程的。如果直接調用 run(),其實就相當於是調用了一個普通函數而已,直接待用 run() 方法必須等待 run() 方法執行完畢才能執行下面的代碼,所以執行路徑還是只有一條,根本就沒有線程的特徵,所以在多線程執行時要使用 start() 方法而不是 run() 方法。

9、在 Java 程序中怎麼保證多線程的運行安全?

線程安全在三個方面體現:

原子性:提供互斥訪問,同一時刻只能有一個線程對數據進行操作,(atomic,synchronized);

可見性:一個線程對主內存的修改可以及時地被其他線程看到,(synchronized、volatile);

有序性:一個線程觀察其他線程中的指令執行順序,由於指令重排序,該觀察結果一般雜亂無序,(happens-before 原則)。

10、Java 線程同步的幾種方法?

1. 使用 Synchronized 關鍵字;

2. wait 和 notify;

3. 使用特殊域變量 volatile 實現線程同步;

4. 使用可重入鎖實現線程同步;

5. 使用阻塞隊列實現線程同步;

6. 使用信號量 Semaphore。

11、Thread.interrupt() 方法的工作原理是什麼?

在 Java 中,線程的中斷 interrupt 只是改變了線程的中斷狀態,至於這個中斷狀態改變後帶來的結果,那是無法確定的,有時它更是讓停止中的線程繼續執行的唯一手段。不但不是讓線程停止運行,反而是繼續執行線程的手段。

在一個線程對象上調用 interrupt() 方法,真正有影響的是 wait、join、sleep 方法,當然這 3 個方法包括它們的重載方法。請注意:上面這三個方法都會拋出 InterruptedException。

1. 對於 wait 中的等待 notify、notifyAll 喚醒的線程,其實這個線程已經「暫停」執行,因為它正在某一對象的休息室中,這時如果它的中斷狀態被改變,那麼它就會拋出異常。這個 InterruptedException 異常不是線程拋出的,而是 wait 方法,也就是對象的 wait 方法內部會不斷檢查在此對象上休息的線程的狀態,如果發現哪個線程的狀態被置為已中斷,則會拋出 InterruptedException,意思就是這個線程不能再等待了,其意義就等同於喚醒它了,然後執行 catch 中的代碼。

2. 對於 sleep 中的線程,如果你調用了 Thread.sleep(一年);現在你後悔了,想讓它早些醒過來,調用 interrupt() 方法就是唯一手段,只有改變它的中斷狀態,讓它從 sleep 中將控制權轉到處理異常的 catch 語句中,然後再由 catch 中的處理轉換到正常的邏輯。同樣,對於 join 中的線程你也可以這樣處理。

12、談談對 ThreadLocal 的理解?

1. Java 的 Web 項目大部分都是基於 Tomcat。每次訪問都是一個新的線程,每一個線程都獨享一個 ThreadLocal,我們可以在接收請求的時候 set 特定內容,在需要的時候 get 這個值。

2. ThreadLocal 提供 get 和 set 方法,為每一個使用這個變量的線程都保存有一份獨立的副本。

public T get() {...}  public void set(T value) {...}  public void remove() {...}  protected T initialValue() {...}

1. get() 方法是用來獲取 ThreadLocal 在當前線程中保存的變量副本;

2. set() 用來設置當前線程中變量的副本;

3. remove() 用來移除當前線程中變量的副本;

4. initialValue() 是一個 protected 方法,一般是用來在使用時進行重寫的,如果在沒有 set 的時候就調用 get,會調用 initialValue 方法初始化內容。

13、在哪些場景下會使用到 ThreadLocal?

在調用 API 接口的時候傳遞了一些公共參數,這些公共參數攜帶了一些設備信息(是安卓還是 ios),服務端接口根據不同的信息組裝不同的格式數據返回給客戶端。假定服務器端需要通過設備類型(device)來下發下載地址,當然接口也有同樣的其他邏輯,我們只要在返回數據的時候判斷好是什麼類型的客戶端就好了。上面這種場景就可以將傳進來的參數 device 設置到 ThreadLocal 中。用的時候取出來就行。避免了參數的層層傳遞。

14、說一說自己對於 synchronized 關鍵字的了解?

synchronized關鍵字解決的是多個線程之間訪問資源的同步性,synchronized 關鍵字可以保證被它修飾的方法或者代碼塊在任意時刻只能有一個線程執行。

另外,在 Java 早期版本中,synchronized 屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層的操作系統的 Mutex Lock 來實現的,Java 的線程是映射到操作系統的原生線程之上的。如果要掛起或者喚醒一個線程,都需要操作系統幫忙完成,而操作系統實現線程之間的切換時需要從用戶態轉換到內核態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的 synchronized 效率低的原因。慶幸的是在 JDK6 之後 Java 官方對從 JVM 層面對synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK6 對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。

15、講一下 synchronized 關鍵字的底層原理?

synchronized 關鍵字底層原理屬於 JVM 層面。

  • synchronized 同步語句塊的情況
public class SynchronizedDemo {      public void method(){          synchronized(this){              System.out.println("manong qiuzhi xiaozhushou");          }      }  }

通過 JDK 自帶的 javap 命令查看 SynchronizedDemo 類的相關位元組碼信息:首先切換到類的對應目錄執行 javac SynchronizedDemo.java 命令生成編譯後的 .class 文件,然後執行 javap -c -s -v -l SynchronizedDemo.class。

從上面我們可以看出:synchronized 同步語句塊的實現使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令則指明同步代碼塊的結束位置。

當執行 monitorenter 指令時,線程試圖獲取鎖也就是獲取 monitor的持有權。monitor 對象存在於每個 Java 對象的對象頭中,synchronized 鎖便是通過這種方式獲取鎖的,也是為什麼 Java 中任意對象可以作為鎖的原因。當計數器為 0 則可以成功獲取,獲取後將鎖計數器設為 1 也就是加 1。相應的在執行 monitorexit 指令後,將鎖計數器設為 0,表明鎖被釋放。如果獲取對象鎖失敗,那當前線程就要阻塞等待,直到鎖被另外一個線程釋放為止。

  • synchronized 修飾方法的的情況
public class SynchronizedDemo2 {      public synchronized void method() {          System.out.println("manong qiuzhi xiaozhushou");      }  }

synchronized 修飾的方法並沒有 monitorenter 指令和 monitorexit 指令,取得代之的確實是 ACC_SYNCHRONIZED 標識,該標識指明了該方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否聲明為同步方法,從而執行相應的同步調用。

16、如何在項目中使用 synchronized 的?

synchronized 關鍵字最主要的三種使用方式:

1. 修飾實例方法:作用於當前對象實例加鎖,進入同步代碼前要獲得當前對象實例的鎖;

2. 修飾靜態方法:作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖 。也就是給當前類加鎖,會作用於類的所有對象實例,因為靜態成員不屬於任何一個實例對象,是類成員(static 表明這是該類的一個靜態資源,不管 new了多少個對象,只有一份,所以對該類的所有對象都加了鎖)。所以如果一個線程 A 調用一個實例對象的非靜態 synchronized 方法,而線程 B 需要調用這個實例對象所屬類的靜態 synchronized 方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的鎖,而訪問非靜態 synchronized 方法佔用的鎖是當前實例對象鎖;

3. 修飾代碼塊:指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。和 synchronized 方法一樣,synchronized(this) 代碼塊也是鎖定當前對象的。synchronized 關鍵字加到 static 靜態方法和 synchronized(class) 代碼塊上都是是給 Class 類上鎖。這裡再提一下:synchronized 關鍵字加到非 static 靜態方法上是給對象實例上鎖。另外需要注意的是:盡量不要使用 synchronized(String a) 因為 JVM 中,字符串常量池具有緩衝功能。

補充:雙重校驗鎖實現單例模式

問到 synchronized 的使用,很有可能讓你用 synchronized 實現個單例模式。這裡補充下使用 synchronized 雙重校驗鎖的方法實現單例模式:

public class Singleton {        private volatile static Singleton uniqueInstance;      private Singleton() {}        public static Singleton getUniqueInstance() {         // 先判斷對象是否已經實例過,沒有實例化過才進入加鎖代碼          if (uniqueInstance == null) {              // 類對象加鎖              synchronized (Singleton.class) {                  if (uniqueInstance == null) {                      uniqueInstance = new Singleton();                  }              }          }          return uniqueInstance;      }  }

另外,需要注意 uniqueInstance 採用 volatile 關鍵字修飾也是很有必要。採用 volatile 關鍵字修飾也是很有必要的, uniqueInstance = new Singleton(); 這段代碼其實是分為三步執行:

1. 為 uniqueInstance 分配內存空間

2. 初始化 uniqueInstance

3. 將 uniqueInstance 指向分配的內存地址

但是由於 JVM 具有指令重排的特性,執行順序有可能變成 1 -> 3 -> 2。指令重排在單線程環境下不會出現問題,但是在多線程環境下會導致一個線程獲得還沒有初始化的實例。例如,線程 T1 執行了 1 和 3,此時 T2 調用 getUniqueInstance() 後發現 uniqueInstance 不為空,因此返回 uniqueInstance,但此時 uniqueInstance 還未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保證在多線程環境下也能正常運行。

17、說說 JDK1.6 之後的 synchronized 關鍵字底層做了哪些優化,可以詳細介紹一下這些優化嗎?

說明:這道題答案有點長,但是回答的詳細面試會很加分。

JDK1.6 對鎖的實現引入了大量的優化,如偏向鎖、輕量級鎖、自旋鎖、適應性自旋鎖、鎖消除、鎖粗化等技術來減少鎖操作的開銷。

鎖主要存在四種狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,它們會隨着競爭的激烈而逐漸升級。注意鎖可以升級不可降級,這種策略是為了提高獲得鎖和釋放鎖的效率。

  • 偏向鎖

引入偏向鎖的目的和引入輕量級鎖的目的很像,它們都是為了沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。但是不同是:輕量級鎖在無競爭的情況下使用 CAS 操作去代替使用互斥量。而偏向鎖在無競爭的情況下會把整個同步都消除掉。

偏向鎖的「偏」就是偏心的偏,它的意思是會偏向於第一個獲得它的線程,如果在接下來的執行中,該鎖沒有被其他線程獲取,那麼持有偏向鎖的線程就不需要進行同步。

但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。

  • 輕量級鎖

倘若偏向鎖失敗,虛擬機並不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段(JDK1.6 之後加入的)。輕量級鎖不是為了代替重量級鎖,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗,因為使用輕量級鎖時,不需要申請互斥量。另外,輕量級鎖的加鎖和解鎖都用到了 CAS 操作。

輕量級鎖能夠提升程序同步性能的依據是「對於絕大部分鎖,在整個同步周期內都是不存在競爭的」,這是一個經驗數據。如果沒有競爭,輕量級鎖使用 CAS 操作避免了使用互斥操作的開銷。但如果存在鎖競爭,除了互斥量開銷外,還會額外發生 CAS 操作,因此在有鎖競爭的情況下,輕量級鎖比傳統的重量級鎖更慢!如果鎖競爭激烈,那麼輕量級將很快膨脹為重量級鎖!

  • 自旋鎖和自適應自旋

輕量級鎖失敗後,虛擬機為了避免線程真實地在操作系統層面掛起,還會進行一項稱為自旋鎖的優化手段。

互斥同步對性能最大的影響就是阻塞的實現,因為掛起線程/恢複線程的操作都需要轉入內核態中完成(用戶態轉換到內核態會耗費時間)。

一般線程持有鎖的時間都不是太長,所以僅僅為了這一點時間去掛起線程/恢複線程是得不償失的。所以,虛擬機的開發團隊就這樣去考慮:「我們能不能讓後面來的請求獲取鎖的線程等待一會而不被掛起呢?看看持有鎖的線程是否很快就會釋放鎖」。為了讓一個線程等待,我們只需要讓線程執行一個忙循環(自旋),這項技術就叫做自旋。

百度百科對自旋鎖的解釋:

何謂自旋鎖?它是為實現保護共享資源而提出一種鎖機制。其實,自旋鎖與互斥鎖比較類似,它們都是為了解決對某項資源的互斥使用。無論是互斥鎖,還是自旋鎖,在任何時刻,最多只能有一個保持者,也就說,在任何時刻最多只能有一個執行單元獲得鎖。但是兩者在調度機制上略有不同。對於互斥鎖,如果資源已經被佔用,資源申請者只能進入睡眠狀態。但是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執行單元保持,調用者就一直循環在那裡看是否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是因此而得名。

自旋鎖在 JDK1.6 之前其實就已經引入了,不過是默認關閉的,需要通過 –XX:+UseSpinning 參數來開啟。JDK1.6 及 1.6 之後,就改為默認開啟的了。需要注意的是:自旋等待不能完全替代阻塞,因為它還是要佔用處理器時間。如果鎖被佔用的時間短,那麼效果當然就很好了。反之,自旋等待的時間必須要有限度。如果自旋超過了限定次數任然沒有獲得鎖,就應該掛起線程。自旋次數的默認值是 10 次,用戶可以修改 –XX:PreBlockSpin 來更改。

另外,在 JDK1.6 中引入了自適應的自旋鎖。自適應的自旋鎖帶來的改進就是:自旋的時間不在固定了,而是和前一次同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定,虛擬機變得越來越「聰明」了。

  • 鎖消除

鎖消除理解起來很簡單,它指的就是虛擬機即使編譯器在運行時,如果檢測到那些共享數據不可能存在競爭,那麼就執行鎖消除。鎖消除可以節省毫無意義的請求鎖的時間。

  • 鎖粗化

原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用範圍限制得盡量小。只在共享數據的實際作用域才進行同步,這樣是為了使得需要同步的操作數量儘可能變小,如果存在鎖競爭,那等待線程也能儘快拿到鎖。

大部分情況下,上面的原則都是沒有問題的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,那麼會帶來很多不必要的性能消耗。

18、談談 synchronized 和 ReenTrantLock 的區別?

1. synchronized 是和 for、while 一樣的關鍵字,ReentrantLock 是類,這是二者的本質區別。既然 ReentrantLock 是類,那麼它就提供了比 synchronized 更多更靈活的特性:等待可中斷、可實現公平鎖、可實現選擇性通知(鎖可以綁定多個條件)、性能已不是選擇標準。

2. synchronized 依賴於 JVM 而 ReenTrantLock 依賴於 API。synchronized 是依賴於 JVM 實現的,JDK1.6 為 synchronized 關鍵字進行了很多優化,但是這些優化都是在虛擬機層面實現的,並沒有直接暴露給我們。ReenTrantLock 是 JDK 層面實現的(也就是 API 層面,需要 lock() 和 unlock 方法配合 try/finally 語句塊來完成),所以我們可以通過查看它的源代碼,來看它是如何實現的。

19、synchronized 和 volatile 的區別是什麼?

1. volatile 本質是在告訴 JVM當前變量在寄存器(工作內存)中的值是不確定的,需要從主存中讀取;synchronized 則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。

2. volatile 僅能使用在變量級別;synchronized 則可以使用在變量、方法、和類級別的。

3. volatile 僅能實現變量的修改可見性,不能保證原子性;而 synchronized 則可以保證變量的修改可見性和原子性。

4. volatile 不會造成線程的阻塞;synchronized 可能會造成線程的阻塞。

5. volatile 標記的變量不會被編譯器優化;synchronized 標記的變量可以被編譯器優化。

20、談一下你對 volatile 關鍵字的理解?

volatile 關鍵字是用來保證有序性和可見性的。這跟 Java 內存模型有關。我們所寫的代碼,不一定是按照我們自己書寫的順序來執行的,編譯器會做重排序,CPU 也會做重排序的,這樣做是為了減少流水線阻塞,提高 CPU 的執行效率。這就需要有一定的順序和規則來保證,不然程序員自己寫的代碼都不知道對不對了,所以有 happens-before 規則,其中有條就是 volatile 變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作、有序性實現的是通過插入內存屏障來保證的。

被 volatile 修飾的共享變量,就具有了以下兩點特性:

1 . 保證了不同線程對該變量操作的內存可見性;

2 . 禁止指令重排序。

備註:這個題如果擴展了答,可以從 Java 的內存模型入手,下一篇 Java 虛擬機高頻面試題中會講到,這裡不做過多贅述。

21、說下對 ReentrantReadWriteLock 的理解?

ReentrantReadWriteLock 允許多個讀線程同時訪問,但是不允許寫線程和讀線程、寫線程和寫線程同時訪問。讀寫鎖內部維護了兩個鎖:一個是用於讀操作的 ReadLock,一個是用於寫操作的 WriteLock。讀寫鎖 ReentrantReadWriteLock 可以保證多個線程可以同時讀,所以在讀操作遠大於寫操作的時候,讀寫鎖就非常有用了。

ReentrantReadWriteLock 基於 AQS 實現,它的自定義同步器(繼承 AQS)需要在同步狀態 state 上維護多個讀線程和一個寫線程,該狀態的設計成為實現讀寫鎖的關鍵。ReentrantReadWriteLock 很好的利用了高低位。來實現一個整型控制兩種狀態的功能,讀寫鎖將變量切分成了兩個部分,高 16 位表示讀,低 16 位表示寫。

  • ReentrantReadWriteLock 的特點:

1. 寫鎖可以降級為讀鎖,但是讀鎖不能升級為寫鎖;

2. 不管是 ReadLock 還是 WriteLock 都支持 Interrupt,語義與 ReentrantLock 一致;

3. WriteLock 支持 Condition 並且與 ReentrantLock 語義一致,而 ReadLock 則不能使用 Condition,否則拋出 UnsupportedOperationException 異常;

4. 默認構造方法為非公平模式 ,開發者也可以通過指定 fair 為 true 設置為公平模式 。

  • 升降級

1. 讀鎖裏面加寫鎖,會導致死鎖;

2. 寫鎖裏面是可以加讀鎖的,這就是鎖的降級。

22、說下對悲觀鎖和樂觀鎖的理解?

  • 悲觀鎖

總是假設最壞的情況,每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖(共享資源每次只給一個線程使用,其它線程阻塞,用完後再把資源轉讓給其它線程)。傳統的關係型數據庫裡邊就用到了很多這種鎖機制,比如:行鎖、表鎖、讀鎖、寫鎖等,都是在做操作之前先上鎖。Java 中 synchronized 和 ReentrantLock 等獨佔鎖就是悲觀鎖思想的實現。

  • 樂觀鎖

總是假設最好的情況,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號機制和 CAS 算法實現。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,像數據庫提供的類似於 write_condition 機制,其實都是提供的樂觀鎖。在 Java 中 java.util.concurrent.atomic 包下面的原子變量類就是使用了樂觀鎖的一種實現方式 CAS 實現的。

  • 兩種鎖的使用場景

從上面對兩種鎖的介紹,我們知道兩種鎖各有優缺點,不可認為一種好於另一種,像樂觀鎖適用於寫比較少的情況下(多讀場景),即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果是多寫的情況,一般會經常產生衝突,這就會導致上層應用會不斷的進行 retry,這樣反倒是降低了性能,所以一般多寫的場景下用悲觀鎖就比較合適。

23、樂觀鎖常見的兩種實現方式是什麼?

樂觀鎖一般會使用版本號機制或者 CAS 算法實現。

  • 版本號機制

一般是在數據表中加上一個數據版本號 version 字段,表示數據被修改的次數,當數據被修改時,version 值會加 1。當線程 A 要更新數據值時,在讀取數據的同時也會讀取 version 值,在提交更新時,若剛才讀取到的 version 值為當前數據庫中的 version 值相等時才更新,否則重試更新操作,直到更新成功。

  • CAS 算法

即 compare and swap(比較與交換),是一種有名的無鎖算法。無鎖編程,即不使用鎖的情況下實現多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實現變量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三個操作數:

1、需要讀寫的內存值 V

2、進行比較的值 A

3、擬寫入的新值 B

當且僅當 V 的值等於 A 時,CAS 通過原子方式用新值 B 來更新 V 的值,否則不會執行任何操作(比較和替換是一個原子操作)。一般情況下是一個自旋操作,即不斷的重試。

24、樂觀鎖的缺點有哪些?

  • 1. ABA 問題

如果一個變量 V 初次讀取的時候是 A 值,並且在準備賦值的時候檢查到它仍然是 A 值,那我們就能說明它的值沒有被其他線程修改過了嗎?很明顯是不能的,因為在這段時間它的值可能被改為其他值,然後又改回 A,那 CAS 操作就會誤認為它從來沒有被修改過。這個問題被稱為 CAS 操作的 "ABA" 問題。

JDK 1.5 以後的AtomicStampedReference 類就提供了此種能力,其中的 compareAndSet 方法就是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置為給定的更新值。

  • 2. 循環時間長開銷大

自旋 CAS(也就是不成功就一直循環執行直到成功)如果長時間不成功,會給 CPU 帶來非常大的執行開銷。如果 JVM 能支持處理器提供的 pause 指令那麼效率會有一定的提升,pause 指令有兩個作用,第一:它可以延遲流水線執行指令(de-pipeline),使 CPU 不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二:它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起 CPU 流水線被清空(CPU pipeline flush),從而提高 CPU 的執行效率。

  • 3. 只能保證一個共享變量的原子操作

CAS 只對單個共享變量有效,當操作涉及跨多個共享變量時 CAS 無效。 但是從 JDK 1.5 開始,提供了 AtomicReference 類來保證引用對象之間的原子性,你可以把多個變量放在一個對象里來進行 CAS 操作。所以我們可以使用鎖或者利用 AtomicReference 類把多個共享變量合併成一個共享變量來操作。

25、CAS 和 synchronized 的使用場景?

簡單的來說 CAS 適用於寫比較少的情況下(多讀場景,衝突一般較少),synchronized 適用於寫比較多的情況下(多寫場景,衝突一般較多)。

1. 對於資源競爭較少(線程衝突較輕)的情況,使用 synchronized 同步鎖進行線程阻塞和喚醒切換以及用戶態內核態間的切換操作額外浪費消耗 cpu 資源;而 CAS 基於硬件實現,不需要進入內核,不需要切換線程,操作自旋幾率較少,因此可以獲得更高的性能。

2. 對於資源競爭嚴重(線程衝突嚴重)的情況,CAS 自旋的概率會比較大,從而浪費更多的 CPU 資源,效率低於 synchronized。

26、簡單說下對 Java 中的原子類的理解?

這裡 Atomic 是指一個操作是不可中斷的。即使是在多個線程一起執行的時候,一個操作一旦開始,就不會被其他線程干擾。所以,所謂原子類說簡單點就是具有原子操作特徵的類。

並發包 java.util.concurrent 的原子類都存放在 java.util.concurrent.atomic 下。根據操作的數據類型,可以將 JUC 包中的原子類分為 4 類:

  • 1. 基本類型

使用原子的方式更新基本類型:

AtomicInteger:整型原子類

AtomicLong:長整型原子類

AtomicBoolean :布爾型原子類

  • 2. 數組類型

使用原子的方式更新數組裡的某個元素:

AtomicIntegerArray:整型數組原子類

AtomicLongArray:長整型數組原子類

AtomicReferenceArray :引用類型數組原子類

  • 3. 引用類型

AtomicReference:引用類型原子類

AtomicStampedReference:原子更新引用類型里的字段原子類

AtomicMarkableReference :原子更新帶有標記位的引用類型

  • 4. 對象的屬性修改類型

AtomicIntegerFieldUpdater:原子更新整型字段的更新器

AtomicLongFieldUpdater:原子更新長整型字段的更新器

AtomicStampedReference :原子更新帶有版本號的引用類型。該類將整數值與引用關聯起來,可用於解決原子的更新數據和數據的版本號,可以解決使用 CAS 進行原子更新時可能出現的 ABA 問題。

27、atomic 的原理是什麼?

Atomic 包中的類基本的特性就是在多線程環境下,當有多個線程同時對單個(包括基本類型及引用類型)變量進行操作時,具有排他性,即當多個線程同時對該變量的值進行更新時,僅有一個線程能成功,而未成功的線程可以向自旋鎖一樣,繼續嘗試,一直等到執行成功。

Atomic 系列的類中的核心方法都會調用 unsafe 類中的幾個本地方法。我們需要先知道一個東西就是 Unsafe 類,全名為:sun.misc.Unsafe,這個類包含了大量的對 C 代碼的操作,包括很多直接內存分配以及原子操作的調用,而它之所以標記為非安全的,是告訴你這個裏面大量的方法調用都會存在安全隱患,需要小心使用,否則會導致嚴重的後果,例如在通過 unsafe 分配內存的時候,如果自己指定某些區域可能會導致一些類似 C++ 一樣的指針越界到其他進程的問題。

28、說下對同步器 AQS 的理解?

AQS 的全稱為:AbstractQueuedSynchronizer,這個類在 java.util.concurrent.locks 包下面。AQS 是一個用來構建鎖和同步器的框架,使用 AQS 能簡單且高效地構造出應用廣泛的大量的同步器,比如:我們提到的 ReentrantLock,Semaphore,其他的諸如ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等皆是基於 AQS 的。當然,我們自己也能利用 AQS 非常輕鬆容易地構造出符合我們自己需求的同步器。

29、AQS 的原理是什麼?

AQS 核心思想是:如果被請求的共享資源空閑,則將當前請求資源的線程設置為有效的工作線程,並且將共享資源設置為鎖定狀態。如果被請求的共享資源被佔用,那麼就需要一套線程阻塞等待以及被喚醒時鎖分配的機制,這個機制 AQS 是用 CLH 隊列鎖實現的,即將暫時獲取不到鎖的線程加入到隊列中。

CLH隊列:

CLH(Craig, Landin, and Hagersten)隊列是一個虛擬的雙向隊列(虛擬的雙向隊列即不存在隊列實例,僅存在結點之間的關聯關係)。AQS 是將每條請求共享資源的線程封裝成一個 CLH 鎖隊列的一個結點(Node)來實現鎖的分配。

AQS 使用一個 int 成員變量 (state) 來表示同步狀態,通過內置的 FIFO 隊列來完成獲取資源線程的排隊工作。AQS 使用 CAS 對該同步狀態進行原子操作實現對其值的修改。

// 共享變量,使用 volatile 修飾保證線程可見性  private volatile int state;

30、AQS 對資源的共享模式有哪些?

1. Exclusive(獨佔):只有一個線程能執行,如:ReentrantLock,又可分為公平鎖和非公平鎖:

2. Share(共享):多個線程可同時執行,如:CountDownLatch、Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock。

31、AQS 底層使用了模板方法模式,你能說出幾個需要重寫的方法嗎?

使用者繼承 AbstractQueuedSynchronizer 並重寫指定的方法。將 AQS 組合在自定義同步組件的實現中,並調用其模板方法,而這些模板方法會調用使用者重寫的方法。

1. isHeldExclusively() :該線程是否正在獨佔資源。只有用到 condition 才需要去實現它。

2. tryAcquire(int) :獨佔方式。嘗試獲取資源,成功則返回 true,失敗則返回 false。

3. tryRelease(int) :獨佔方式。嘗試釋放資源,成功則返回 true,失敗則返回 false。

4. tryAcquireShared(int) :共享方式。嘗試獲取資源。負數表示失敗;0 表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。

5. tryReleaseShared(int) :共享方式。嘗試釋放資源,成功則返回 true,失敗則返回 false。

32、說下對信號量 Semaphore 的理解?

synchronized 和 ReentrantLock 都是一次只允許一個線程訪問某個資源,Semaphore (信號量)可以指定多個線程同時訪問某個資源。

執行 acquire 方法阻塞,直到有一個許可證可以獲得然後拿走一個許可證;每個 release 方法增加一個許可證,這可能會釋放一個阻塞的 acquire 方法。然而,其實並沒有實際的許可證這個對象,Semaphore 只是維持了一個可獲得許可證的數量。Semaphore 經常用於限制獲取某種資源的線程數量。當然一次也可以一次拿取和釋放多個許可證,不過一般沒有必要這樣做。除了 acquire方法(阻塞)之外,另一個比較常用的與之對應的方法是 tryAcquire 方法,該方法如果獲取不到許可就立即返回 false。

33、CountDownLatch 和 CyclicBarrier 有什麼區別?

CountDownLatch 是計數器,只能使用一次,而 CyclicBarrier 的計數器提供 reset 功能,可以多次使用。

對於 CountDownLatch 來說,重點是「一個線程(多個線程)等待」,而其他的 N 個線程在完成「某件事情」之後,可以終止,也可以等待。而對於 CyclicBarrier,重點是多個線程,在任意一個線程沒有完成,所有的線程都必須等待。

CountDownLatch 是計數器,線程完成一個記錄一個,只不過計數不是遞增而是遞減,而 CyclicBarrier 更像是一個閥門,需要所有線程都到達,閥門才能打開,然後繼續執行。

應用場景:

CountDownLatch 應用場景:

1.某一線程在開始運行前等待 n 個線程執行完畢:啟動一個服務時,主線程需要等待多個組件加載完畢,之後再繼續執行。

2.實現多個線程開始執行任務的最大並行性。注意是並行性,不是並發,強調的是多個線程在某一時刻同時開始執行。類似於賽跑,將多個線程放到起點,等待發令槍響,然後同時開跑。

3. 死鎖檢測:一個非常方便的使用場景是,你可以使用 n 個線程訪問共享資源,在每次測試階段的線程數目是不同的,並嘗試產生死鎖。

CyclicBarrier 應用場景:

CyclicBarrier 可以用於多線程計算數據,最後合併計算結果的應用場景。比如:我們用一個 Excel 保存了用戶所有銀行流水,每個 Sheet 保存一個帳戶近一年的每筆銀行流水,現在需要統計用戶的日均銀行流水,先用多線程處理每個 sheet 里的銀行流水,都執行完之後,得到每個 sheet 的日均銀行流水,最後,再用 barrierAction 用這些線程的計算結果,計算出整個 Excel 的日均銀行流水。

34、說下對線程池的理解?為什麼要使用線程池?

線程池提供了一種限制和管理資源(包括執行一個任務)的方式。每個線程池還維護一些基本統計信息,例如:已完成任務的數量。

  • 使用線程池的好處

1. 降低資源消耗:通過重複利用已創建的線程降低線程創建和銷毀造成的消耗;

2. 提高響應速度:當任務到達時,任務可以不需要的等到線程創建就能立即執行;

3. 提高線程的可管理性:線程是稀缺資源,如果無限制的創建,不僅會消耗系統資源,還會降低系統的穩定性,使用線程池可以進行統一的分配,調優和監控。

35、創建線程池的參數有哪些?

1. corePoolSize(線程池的基本大小):當提交一個任務到線程池時,如果當前 poolSize < corePoolSize 時,線程池會創建一個線程來執行任務,即使其他空閑的基本線程能夠執行新任務也會創建線程,等到需要執行的任務數大於線程池基本大小時就不再創建。如果調用了線程池的prestartAllCoreThreads() 方法,線程池會提前創建並啟動所有基本線程。

2. maximumPoolSize(線程池最大數量):線程池允許創建的最大線程數。如果隊列滿了,並且已創建的線程數小於最大線程數,則線程池會再創建新的線程執行任務。值得注意的是,如果使用了無界的任務隊列這個參數就沒什麼效果。

3. keepAliveTime(線程活動保持時間):線程池的工作線程空閑後,保持存活的時間。所以,如果任務很多,並且每個任務執行的時間比較短,可以調大時間,提高線程的利用率。

4. TimeUnit(線程活動保持時間的單位):可選的單位有天(DAYS)、小時(HOURS)、分鐘(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和納秒(NANOSECONDS,千分之一微秒)。

5. workQueue(任務隊列):用於保存等待執行的任務的阻塞隊列。

可以選擇以下幾個阻塞隊列:

1. ArrayBlockingQueue:是一個基於數組結構的有界阻塞隊列,此隊列按 FIFO(先進先出)原則對元素進行排序。

2. LinkedBlockingQueue:一個基於鏈表結構的阻塞隊列,此隊列按 FIFO 排序元素,吞吐量通常要高於 ArrayBlockingQueue。靜態工廠方法 Executors.newFixedThreadPool() 使用了這個隊列。

3. SynchronousQueue:一個不存儲元素的阻塞隊列。每個插入操作必須等到另一個線程調用移除操作,否則插入操作一直處於阻塞狀態,吞吐量通常要高於 LinkedBlockingQueue,靜態工廠方法 Executors.newCachedThreadPool 使用了這個隊列。

4. PriorityBlockingQueue:一個具有優先級的無限阻塞隊列。

6. threadFactory:用於設置創建線程的工廠,可以通過線程工廠給每個創建出來的線程設置更有意義的名字。

7. RejectExecutionHandler(飽和策略):隊列和線程池都滿了,說明線程池處於飽和狀態,那麼必須採取一種策略處理提交的新任務。這個策略默認情況下是 AbortPolicy,表示無法處理新任務時拋出異常。

飽和策略:

在 JDK1.5 中 Java 線程池框架提供了以下 4 種策略:

1. AbortPolicy:直接拋出異常。

2. CallerRunsPolicy:只用調用者所在線程來運行任務。

3. DiscardOldestPolicy:丟棄隊列里最近的一個任務,並執行當前任務。

4. DiscardPolicy:不處理,丟棄掉。

當然,也可以根據應用場景需要來實現RejectedExecutionHandler 接口自定義策略。如記錄日誌或持久化存儲不能處理的任務。

36、如何創建線程池?

方式一:通過 ThreadPoolExecutor 的構造方法實現:

方式二:通過 Executor 框架的工具類 Executors 來實現:

我們可以創建三種類型的 ThreadPoolExecutor:

1. FixedThreadPool:該方法返回一個固定線程數量的線程池。該線程池中的線程數量始終不變。當有一個新的任務提交時,線程池中若有空閑線程,則立即執行。若沒有,則新的任務會被暫存在一個任務隊列中,待有線程空閑時,便處理在任務隊列中的任務。

2. SingleThreadExecutor:方法返回一個只有一個線程的線程池。若多餘一個任務被提交到該線程池,任務會被保存在一個任務隊列中,待線程空閑,按先進先出的順序執行隊列中的任務。

3. CachedThreadPool:該方法返回一個可根據實際情況調整線程數量的線程池。線程池的線程數量不確定,但若有空閑線程可以復用,則會優先使用可復用的線程。若所有線程均在工作,又有新的任務提交,則會創建新的線程處理任務。所有線程在當前任務執行完畢後,將返回線程池進行復用。

注意:

阿里巴巴Java開發手冊》中強制線程池不允許使用 Executors 去創建,而是通過 ThreadPoolExecutor 的方式,這樣的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險。

Executors 創建線程池對象的弊端如下:

FixedThreadPool 和 SingleThreadExecutor :允許請求的隊列長度為 Integer.MAX_VALUE,可能堆積大量的請求,從而導致 OOM。CachedThreadPool 和 ScheduledThreadPool : 允許創建的線程數量為 Integer.MAX_VALUE ,可能會創建大量線程,從而導致 OOM。

37、線程池中的的線程數一般怎麼設置?需要考慮哪些問題?

主要考慮下面幾個方面:

  • 1. 線程池中線程執行任務的性質:

計算密集型的任務比較占 cpu,所以一般線程數設置的大小 等於或者略微大於 cpu 的核數;但 IO 型任務主要時間消耗在 IO 等待上,cpu 壓力並不大,所以線程數一般設置較大。

  • 2. cpu 使用率:

當線程數設置較大時,會有如下幾個問題:第一,線程的初始化,切換,銷毀等操作會消耗不小的 cpu 資源,使得 cpu 利用率一直維持在較高水平。第二,線程數較大時,任務會短時間迅速執行,任務的集中執行也會給 cpu 造成較大的壓力。第三, 任務的集中支持,會讓 cpu 的使用率呈現鋸齒狀,即短時間內 cpu 飆高,然後迅速下降至閑置狀態,cpu 使用的不合理,應該減小線程數,讓任務在隊列等待,使得 cpu 的使用率應該持續穩定在一個合理,平均的數值範圍。所以 cpu 在夠用時,不宜過大,不是越大越好。可以通過上線後,觀察機器的 cpu 使用率和 cpu 負載兩個參數來判斷線程數是否合理。

  • 3. 內存使用率:

線程數過多和隊列的大小都會影響此項數據,隊列的大小應該通過前期計算線程池任務的條數,來合理的設置隊列的大小,不宜過小,讓其不會溢出,因為溢出會走拒絕策略,多少會影響性能,也會增加複雜度。

  • 4. 下游系統抗並發能力:

多線程給下游系統造成的並發等於你設置的線程數,例如:如果是多線程訪問數據庫,你就考慮數據庫的連接池大小設置,數據庫並發太多影響其 QPS,會把數據庫打掛等問題。如果訪問的是下游系統的接口,你就得考慮下游系統是否能抗的住這麼多並發量,不能把下游系統打掛了。

38、執行 execute() 方法和 submit() 方法的區別是什麼呢?

1. execute() 方法用於提交不需要返回值的任務,所以無法判斷任務是否被線程池執行成功與否;

2. submit() 方法用於提交需要返回值的任務。線程池會返回一個 Future 類型的對象,通過這個 Future 對象可以判斷任務是否執行成功,並且可以通過 Future 的 get() 方法來獲取返回值,get() 方法會阻塞當前線程直到任務完成,而使用 get(long timeout,TimeUnit unit) 方法則會阻塞當前線程一段時間後立即返回,這時候有可能任務沒有執行完。

39、說下對 Fork/Join 並行計算框架的理解?

Fork/Join 並行計算框架主要解決的是分治任務。分治的核心思想是「分而治之」:將一個大的任務拆分成小的子任務的結果聚合起來從而得到最終結果。

Fork/Join 並行計算框架的核心組件是 ForkJoinPool。ForkJoinPool 支持任務竊取機制,能夠讓所有的線程的工作量基本均衡,不會出現有的線程很忙,而有的線程很閑的情況,所以性能很好。

ForkJoinPool 中的任務隊列採用的是雙端隊列,工作線程正常獲取任務和「竊取任務」分別是從任務隊列不同的端消費,這樣能避免很多不必要的數據競爭。

40、JDK 中提供了哪些並發容器?

JDK 提供的這些容器大部分在 java.util.concurrent 包中。

1. ConcurrentHashMap:線程安全的 HashMap;

2. CopyOnWriteArrayList:線程安全的 List,在讀多寫少的場合性能非常好,遠遠好於 Vector;

3. ConcurrentLinkedQueue:高效的並發隊列,使用鏈表實現。可以看做一個線程安全的 LinkedList,這是一個非阻塞隊列;

4. BlockingQueue:這是一個接口,JDK 內部通過鏈表、數組等方式實現了這個接口。表示阻塞隊列,非常適合用於作為數據共享的通道;

5. ConcurrentSkipListMap:跳錶的實現。這是一個 Map,使用跳錶的數據結構進行快速查找。

41、談談對 CopyOnWriteArrayList 的理解?

在很多應用場景中,讀操作可能會遠遠大於寫操作。由於讀操作根本不會修改原有的數據,因此對於每次讀取都進行加鎖其實是一種資源浪費。我們應該允許多個線程同時訪問 List 的內部數據,畢竟讀取操作是安全的。

CopyOnWriteArrayList 類的所有可變操作(add,set 等等)都是通過創建底層數組的新副本來實現的。當 List 需要被修改的時候,我們並不需要修改原有內容,而是對原有數據進行一次複製,將修改的內容寫入副本。寫完之後,再將修改完的副本替換原來的數據,這樣就可以保證寫操作不會影響讀操作了。

從 CopyOnWriteArrayList 的名字就能看出 CopyOnWriteArrayList 是滿足 CopyOnWrite 的 ArrayList,所謂 CopyOnWrite 也就是說:在計算機,如果你想要對一塊內存進行修改時,我們不在原有內存塊中進行寫操作,而是將內存拷貝一份,在新的內存中進行寫操作,寫完之後,就將指向原來內存指針指向新的內存,原來的內存就可以被回收掉了。

CopyOnWriteArrayList 讀取操作沒有任何同步控制和鎖操作,理由就是內部數組 array 不會發生修改,只會被另外一個 array 替換,因此可以保證數據安全。

CopyOnWriteArrayList 寫入操作 add() 方法在添加集合的時候加了鎖,保證了同步,避免了多線程寫的時候會 copy 出多個副本出來。

42、談談對 BlockingQueue 的理解?分別有哪些實現類?

阻塞隊列(BlockingQueue)被廣泛使用在「生產者-消費者」問題中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。當隊列容器已滿,生產者線程會被阻塞,直到隊列未滿;當隊列容器為空時,消費者線程會被阻塞,直至隊列非空時為止。

BlockingQueue 是一個接口,繼承自 Queue,所以其實現類也可以作為 Queue 的實現來使用,而 Queue 又繼承自 Collection 接口。下面是 BlockingQueue 的相關實現類:

43、談談對 ConcurrentSkipListMap 的理解?

對於一個單鏈表,即使鏈表是有序的,如果我們想要在其中查找某個數據,也只能從頭到尾遍歷鏈表,這樣效率自然就會很低,跳錶就不一樣了。跳錶是一種可以用來快速查找的數據結構,有點類似於平衡樹。它們都可以對元素進行快速的查找。

但一個重要的區別是:對平衡樹的插入和刪除往往很可能導致平衡樹進行一次全局的調整。而對跳錶的插入和刪除只需要對整個數據結構的局部進行操作即可。這樣帶來的好處是:在高並發的情況下,你會需要一個全局鎖來保證整個平衡樹的線程安全。而對於跳錶,你只需要部分鎖即可。這樣,在高並發環境下,你就可以擁有更好的性能。而就查詢的性能而言,跳錶的時間複雜度也是 O(logn) 。跳錶的本質是同時維護了多個鏈表,並且鏈表是分層的。