征服面試官的Java併發高頻面試題

前面整理了Java基礎、Mysql、Spring的高頻面試題,今天爲大家帶來Java併發方面的高頻面試題,因爲併發知識不管在學習、面試還是工作過程中都非常非常重要,看完本文,相信絕對能助你一臂之力。

1、線程和進程有什麼區別?

線程是進程的子集,一個進程可以有很多線程。每個進程都有自己的內存空間,可執行代碼和唯一進程標識符(PID)。

每條線程並行執行不同的任務。不同的進程使用不同的內存空間(線程自己的堆棧),而所有的線程共享一片相同的內存空間(進程主內存)。別把它和棧內存搞混,每個線程都擁有單獨的棧內存用來存儲本地數據。

2、實現多線程的方式有哪些?

  • 繼承Thread類:Java單繼承,不推薦;
  • 實現Runnable接口:Thread類也是繼承Runnable接口,推薦;
  • 實現Callable接口:實現Callable接口,配合FutureTask使用,有返回值;
  • 使用線程池:複用,節約資源;
  • 更多方式可以參考我的文章使用Java Executor框架實現多線程

3、用Runnable還是Thread?

這個問題是上題的後續,大家都知道我們可以通過繼承Thread類或者調用Runnable接口來實現線程,問題是,那個方法更好呢?什麼情況下使用它?這個問題很容易回答,如果你知道Java不支持類的多重繼承,但允許你調用多個接口。所以如果你要繼承其他類,當然是調用Runnable接口好了。

  • Runnable和Thread兩者最大的區別是Thread是類而Runnable是接口,至於用類還是用接口,取決於繼承上的實際需要。Java類是單繼承的,實現多個接口可以實現類似多繼承的操作。
  • 其次, Runnable就相當於一個作業,而Thread纔是真正的處理線程,我們需要的只是定義這個作業,然後將作業交給線程去處理,這樣就達到了松耦合,也符合面向對象裏面組合的使用,另外也節省了函數開銷,繼承Thread的同時,不僅擁有了作業的方法run(),還繼承了其他所有的方法。
  • 當需要創建大量線程的時候,有以下不足:①線程生命週期的開銷非常高;②資源消耗;③穩定性。
  • 如果二者都可以選擇不用,那就不用。因爲Java這門語言發展到今天,在語言層面提供的多線程機制已經比較豐富且高級,完全不用在線程層面操作。直接使用Thread和Runnable這樣的“裸線程”元素比較容易出錯,還需要額外關注線程數等問題。建議:簡單的多線程程序,使用Executor。複雜的多線程程序,使用一個Actor庫,首推Akka。
  • 如果一定要在Runnable和Thread中選擇一個使用,選擇Runnable。

4、Thread 類中的start() 和 run() 方法有什麼區別?

這個問題經常被問到,但還是能從此區分出面試者對Java線程模型的理解程度。start()方法被用來啓動新創建的線程,而且start()內部調用了run()方法,JDK 1.8源碼中start方法的註釋這樣寫到:Causes this thread to begin execution; the Java Virtual Machine calls the run method of this thread.這和直接調用run()方法的效果不一樣。當你調用run()方法的時候,只會是在原來的線程中調用,沒有新的線程啓動,start()方法纔會啓動新線程,JDK 1.8源碼中註釋這樣寫:The result is that two threads are running concurrently: the current thread (which returns from the call to the start method) and the other thread (which executes its run method).。

new 一個 Thread,線程進入了新建狀態;調用 start() 方法,會啓動一個線程並使線程進入了就緒狀態,當分配到時間片後就可以開始運行了。start() 會執行線程的相應準備工作,然後自動執行 run() 方法的內容,這是真正的多線程工作。而直接執行 run() 方法,會把 run 方法當成一個 main 線程下的普通方法去執行,並不會在某個線程中執行它,所以這並不是多線程工作。

總結:調用 start 方法方可啓動線程並使線程進入就緒狀態,而 run 方法只是 thread 的一個普通方法調用,還是在主線程裏執行。

5、說說 sleep() 方法和 wait() 方法區別和共同點?

  • 兩者最主要的區別在於:sleep 方法沒有釋放鎖,而 wait 方法釋放了鎖 。
  • 兩者都可以暫停線程的執行。
  • Wait 通常被用於線程間交互/通信,sleep 通常被用於暫停執行。
  • wait() 方法被調用後,線程不會自動甦醒,需要別的線程調用同一個對象上的 notify() 或者 notifyAll() 方法。sleep() 方法執行完成後,線程會自動甦醒。

6、說說併發與並行的區別?

  • 併發: 同一時間段,多個任務都在執行 (單位時間內不一定同時執行);
  • 並行: 單位時間內,多個任務同時執行。

7、說說線程的生命週期和狀態?

Java 線程在運行的生命週期中的指定時刻只可能處於下面 6 種不同狀態的其中一個狀態(圖源《Java 併發編程藝術》4.1.4 節)。

image

線程在生命週期中並不是固定處於某一個狀態而是隨着代碼的執行在不同狀態之間切換。Java 線程狀態變遷如下圖所示(圖源《Java 併發編程藝術》4.1.4 節):

image

由上圖可以看出:線程創建之後它將處於 NEW(新建) 狀態,調用 start() 方法後開始運行,線程這時候處於 READY(可運行) 狀態。可運行狀態的線程獲得了 CPU 時間片(timeslice)後就處於 RUNNING(運行) 狀態。

操作系統隱藏 Java 虛擬機(JVM)中的 RUNNABLE 和 RUNNING 狀態,它只能看到 RUNNABLE 狀態(圖源:HowToDoInJava:Java Thread Life Cycle and Thread States),所以 Java 系統一般將這兩個狀態統稱爲 RUNNABLE(運行中) 狀態 。

image

當線程執行 wait()方法之後,線程進入 **WAITING(等待)**狀態。進入等待狀態的線程需要依靠其他線程的通知才能夠返回到運行狀態,而 TIME_WAITING(超時等待) 狀態相當於在等待狀態的基礎上增加了超時限制,比如通過 sleep(long millis)方法或 wait(long millis)方法可以將 Java 線程置於 TIMED WAITING 狀態。當超時時間到達後 Java 線程將會返回到 RUNNABLE 狀態。當線程調用同步方法時,在沒有獲取到鎖的情況下,線程將會進入到 BLOCKED(阻塞) 狀態。線程在執行 Runnable 的run()方法之後將會進入到 TERMINATED(終止) 狀態。

8、什麼是線程死鎖?

多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於線程被無限期地阻塞,因此程序不可能正常終止。

如下圖所示,線程 A 持有資源 2,線程 B 持有資源 1,他們同時都想申請對方的資源,所以這兩個線程就會互相等待而進入死鎖狀態。

image

下面通過一個例子來說明線程死鎖,代碼模擬了上圖的死鎖的情況 (代碼來源於《併發編程之美》):

public class DeadLockDemo {
    private static Object resource1 = new Object();//資源 1
    private static Object resource2 = new Object();//資源 2
​
    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "線程 1").start();
​
        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "線程 2").start();
    }
}

輸出:

Thread[線程 1,5,main]get resource1
Thread[線程 2,5,main]get resource2
Thread[線程 1,5,main]waiting get resource2
Thread[線程 2,5,main]waiting get resource1

線程 A 通過 synchronized (resource1) 獲得 resource1 的監視器鎖,然後通過 Thread.sleep(1000);讓線程 A 休眠 1s 爲的是讓線程 B 得到執行然後獲取到 resource2 的監視器鎖。線程 A 和線程 B 休眠結束了都開始企圖請求獲取對方的資源,然後這兩個線程就會陷入互相等待的狀態,這也就產生了死鎖。上面的例子符合產生死鎖的四個必要條件。

學過操作系統的朋友都知道產生死鎖必須具備以下四個條件:

  1. 互斥條件:該資源任意一個時刻只由一個線程佔用。
  2. 請求與保持條件:一個進程因請求資源而阻塞時,對已獲得的資源保持不放。
  3. 不剝奪條件:線程已獲得的資源在末使用完之前不能被其他線程強行剝奪,只有自己使用完畢後才釋放資源。
  4. 循環等待條件:若干進程之間形成一種頭尾相接的循環等待資源關係。

9、如何避免線程死鎖?

我們只要破壞產生死鎖的四個條件中的其中一個就可以了。

  • 破壞互斥條件:這個條件我們沒有辦法破壞,因爲我們用鎖本來就是想讓他們互斥的(臨界資源需要互斥訪問)。
  • 破壞請求與保持條件:一次性申請所有的資源。
  • 破壞不剝奪條件:佔用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它佔有的資源。
  • 破壞循環等待條件:靠按序申請資源來預防。按某一順序申請資源,釋放資源則反序釋放。破壞循環等待條件。

我們對線程 2 的代碼修改成下面這樣就不會產生死鎖了。

new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "線程 2").start();

輸出:

Thread[線程 1,5,main]get resource1
Thread[線程 1,5,main]waiting get resource2
Thread[線程 1,5,main]get resource2
Thread[線程 2,5,main]get resource1
Thread[線程 2,5,main]waiting get resource2
Thread[線程 2,5,main]get resource2
​
Process finished with exit code 0

我們分析一下上面的代碼爲什麼避免了死鎖的發生?

線程 1 首先獲得到 resource1 的監視器鎖,這時候線程 2 就獲取不到了。然後線程 1 再去獲取 resource2 的監視器鎖,可以獲取到。然後線程 1 釋放了對 resource1、resource2 的監視器鎖的佔用,線程 2 獲取到就可以執行了。這樣就破壞了破壞循環等待條件,因此避免了死鎖。

10、什麼是死鎖,活鎖?

  • 死鎖:多個線程都無法獲得資源繼續執行。可以通過避免一個線程獲取多個鎖;一個鎖佔用一個資源;使用定時鎖;數據庫加解鎖在一個連接中。
  • 死鎖的必要條件:環路等待,不可剝奪,請求保持,互斥條件
  • 活鎖:線程之間相互謙讓資源,都無法獲取所有資源繼續執行。

11、Java中CyclicBarrier 和 CountDownLatch有什麼不同?

CyclicBarrier 和 CountDownLatch 都可以用來讓一組線程等待其它線程。與 CyclicBarrier 不同的是,CountdownLatch 不能重新使用。

  • CountDownLatch是一種靈活的閉鎖實現,可以使一個或者多個線程等待一組事件發生。閉鎖狀態包括一個計數器,改計數器初始化爲一個正數,表示需要等待的事件數量。countDown方法遞減計數器,表示有一個事件發生了,而await方法等待計數器到達0,表示所有需要等待的事情都已經發生。如果計數器的值非零,那麼await就會一直阻塞知道計數器的值爲0,或者等待的線程中斷,或者等待超時。
  • CyclicBarrier適用於這樣的情況:你希望創建一組任務,他們並行地執行工作,然後在進行下一個步驟之前等待,直至所有任務都完成。它使得所有的並行任務都將在柵欄出列隊,因此可以一致的向前移動。這非常像CountDownLatch,只是CountDownLatch是隻觸發一次的事件,而CyclicBarrier可以多次重用。

12、Java中的同步集合與併發集合有什麼區別?

  • 同步集合與併發集合都爲多線程和併發提供了合適的線程安全的集合,不過併發集合的可擴展性更高。在Java1.5之前程序員們只有同步集合來用且在多線程併發的時候會導致爭用,阻礙了系統的擴展性。Java5介紹了併發集合像ConcurrentHashMap,不僅提供線程安全還用鎖分離和內部分區等現代技術提高了可擴展性。
  • 同步容器是線程安全的。同步容器將所有對容器狀態的訪問都串行化,以實現他們的線程安全性。這種方法的代價是嚴重降低併發性,當多個線程競爭容器的鎖時,吞吐量將嚴重降低。併發容器是針對多個線程併發訪問設計的,改進了同步容器的性能。通過併發容器來代替同步容器,可以極大地提高伸縮性並降低風險。

13、你如何在Java中獲取線程堆棧?

對於不同的操作系統,有多種方法來獲得Java進程的線程堆棧。當你獲取線程堆棧時,JVM會把所有線程的狀態存到日誌文件或者輸出到控制檯。在Windows你可以使用Ctrl + Break組合鍵來獲取線程堆棧,Linux下用kill -3命令。你也可以用jstack這個工具來獲取,它對線程id進行操作,你可以用jps這個工具找到id。

14、Java中ConcurrentHashMap的併發度是什麼?

  • ConcurrentHashMap把實際map劃分成若干部分來實現它的可擴展性和線程安全。這種劃分是使用併發度獲得的,它是ConcurrentHashMap類構造函數的一個可選參數,默認值爲16,這樣在多線程情況下就能避免爭用。
  • 併發度可以理解爲程序運行時能夠同時更新ConccurentHashMap且不產生鎖競爭的最大線程數,實際上就是ConcurrentHashMap中的分段鎖個數,即Segment[]的數組長度。ConcurrentHashMap默認的併發度爲16,但用戶也可以在構造函數中設置併發度。當用戶設置併發度時,ConcurrentHashMap會使用大於等於該值的最小2冪指數作爲實際併發度(假如用戶設置併發度爲17,實際併發度則爲32)。運行時通過將key的高n位(n = 32 – segmentShift)和併發度減1(segmentMask)做位與運算定位到所在的Segment。segmentShift與segmentMask都是在構造過程中根據concurrency level被相應的計算出來。
  • 如果併發度設置的過小,會帶來嚴重的鎖競爭問題;如果併發度設置的過大,原本位於同一個Segment內的訪問會擴散到不同的Segment中,CPU cache命中率會下降,從而引起程序性能下降。

15、Java中的同步集合與併發集合有什麼區別?

  • 同步集合與併發集合都爲多線程和併發提供了合適的線程安全的集合,不過併發集合的可擴展性更高。在Java1.5之前程序員們只有同步集合來用且在多線程併發的時候會導致爭用,阻礙了系統的擴展性。Java5介紹了併發集合像ConcurrentHashMap,不僅提供線程安全還用鎖分離和內部分區等現代技術提高了可擴展性。
  • 同步容器是線程安全的。同步容器將所有對容器狀態的訪問都串行化,以實現他們的線程安全性。這種方法的代價是嚴重降低併發性,當多個線程競爭容器的鎖時,吞吐量將嚴重降低。併發容器是針對多個線程併發訪問設計的,改進了同步容器的性能。通過併發容器來代替同步容器,可以極大地提高伸縮性並降低風險。

16、Thread類中的yield方法有什麼作用?

  • Yield方法可以暫停當前正在執行的線程對象,讓其它有相同優先級的線程執行。它是一個靜態方法而且只保證當前線程放棄CPU佔用而不能保證使其它線程一定能佔用CPU,執行yield()的線程有可能在進入到暫停狀態後馬上又被執行。
  • 線程讓步:如果知道已經完成了在run()方法的循環的一次迭代過程中所需的工作,就可以給線程調度機制一個暗示:你的工作已經做得差不多了,可以讓別的線程使用CPU了。這個暗示將通過調用yield()方法來做出(不過這只是一個暗示,沒有任何機制保證它將會被採納)。當調用yield()時,也是在建議具有相同優先級的其他線程可以運行。
  • yield()的作用是讓步。它能讓當前線程由“運行狀態”進入到“就緒狀態”,從而讓其它具有相同優先級的等待線程獲取執行權;但是,並不能保證在當前線程調用yield()之後,其它具有相同優先級的線程就一定能獲得執行權;也有可能是當前線程又進入到“運行狀態”繼續運行!

17、什麼是ThreadLocal變量?

ThreadLocal是Java裏一種特殊的變量。每個線程都有一個ThreadLocal就是每個線程都擁有了自己獨立的一個變量,競爭條件被徹底消除了。它是爲創建代價高昂的對象獲取線程安全的好方法,比如你可以用ThreadLocal讓SimpleDateFormat變成線程安全的,因爲那個類創建代價高昂且每次調用都需要創建不同的實例所以不值得在局部範圍使用它,如果爲每個線程提供一個自己獨有的變量拷貝,將大大提高效率。首先,通過複用減少了代價高昂的對象的創建個數。其次,你在沒有使用高代價的同步或者不變性的情況下獲得了線程安全。線程局部變量的另一個不錯的例子是ThreadLocalRandom類,它在多線程環境中減少了創建代價高昂的Random對象的個數。

ThreadLocal是一種線程封閉技術。ThreadLocal提供了get和set等訪問接口或方法,這些方法爲每個使用該變量的線程都存有一份獨立的副本,因此get總是返回由當前執行線程在調用set時設置的最新值。

** 18、Java內存模型是什麼?**

Java內存模型規定和指引Java程序在不同的內存架構、CPU和操作系統間有確定性地行爲。它在多線程的情況下尤其重要。Java內存模型對一個線程所做的變動能被其它線程可見提供了保證,它們之間是先行發生關係。這個關係定義了一些規則讓程序員在併發編程時思路更清晰。比如,先行發生關係確保了:

  • 線程內的代碼能夠按先後順序執行,這被稱爲程序次序規則。
  • 對於同一個鎖,一個解鎖操作一定要發生在時間上後發生的另一個鎖定操作之前,也叫做管程鎖定規則。
  • 前一個對volatile的寫操作在後一個volatile的讀操作之前,也叫volatile變量規則。
  • 一個線程內的任何操作必需在這個線程的start()調用之後,也叫作線程啓動規則。
  • 一個線程的所有操作都會在線程終止之前,線程終止規則。
  • 一個對象的終結操作必需在這個對象構造完成之後,也叫對象終結規則。
  • 可傳遞性

我強烈建議大家閱讀《Java併發編程實踐》第十六章來加深對Java內存模型的理解。

19、Java中的volatile 變量是什麼?

volatile是一個特殊的修飾符,只有成員變量才能使用它。在Java併發程序缺少同步類的情況下,多線程對成員變量的操作對其它線程是透明的。volatile變量可以保證下一個讀取操作會在前一個寫操作之後發生,就是上一題的volatile變量規則。

Java語言提供了一種稍弱的同步機制,即volatile變量,用來確保將變量的更新操作通知到其他線程。當把變量聲明爲volatile類型後,編譯器和運行時都會注意到這個變量是共享的,因此不會將變量上的操作和其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的時候總會返回最新寫入的值。

在訪問volatile變量時不會執行加鎖操作,因此也不會使執行線程阻塞,因此volatile變量是一種比synchronized關鍵字更輕量級的同步機制。

加鎖機制既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性。

20、volatile 變量和 atomic 變量有什麼不同?

這是個有趣的問題。首先,volatile 變量和 atomic 變量看起來很像,但功能卻不一樣。Volatile變量可以確保先行關係,即寫操作會發生在後續的讀操作之前, 但它並不能保證原子性。例如用volatile修飾count變量那麼 count++ 操作就不是原子性的。而AtomicInteger類提供的atomic方法可以讓這種操作具有原子性如getAndIncrement()方法會原子性的進行增量操作把當前值加一,其它數據類型和引用變量也可以進行相似操作。

21、Java中Runnable和Callable有什麼不同?

  • Runnable和Callable都代表那些要在不同的線程中執行的任務。Runnable從JDK1.0開始就有了,Callable是在JDK1.5增加的。它們的主要區別是Callable的 call() 方法可以返回值和拋出異常,而Runnable的run()方法沒有這些功能。Callable可以返回裝載有計算結果的Future對象。
  • Runnable是執行工作的獨立任務,但是它不返回任何值。如果希望任務在完成的時候能夠返回一個值,那麼可以實現Callable接口而不是Runnable接口。在Java SE5中引入的Callable是一種具有類型參數的泛型,它的類型參數表示的是從方法call()(而不是run())中返回的值,並且必須使用ExecutorService.submit()方法調用它。submit()方法會產生Future對象,它用Callable返回結果的特定類型進行了參數化。

22、哪些操作釋放鎖,哪些不釋放鎖?

  • sleep(): 釋放資源,不釋放鎖,進入阻塞狀態,喚醒隨機線程,Thread類方法。
  • wait(): 釋放資源,釋放鎖,Object類方法。
  • yield(): 不釋放鎖,進入可執行狀態,選擇優先級高的線程執行,Thread類方法。
  • 如果線程產生的異常沒有被捕獲,會釋放鎖。

23、如何正確的終止線程?

  • 使用共享變量,要用volatile關鍵字,保證可見性,能夠及時終止。
  • 使用interrupt()和isInterrupted()配合使用。

24、interrupt(), interrupted(), isInterrupted()的區別?

  • interrupt():設置中斷標誌;
  • interrupted():響應中斷標誌並復位中斷標誌;
  • isInterrupted():響應中斷標誌;

25、synchronized的鎖對象是哪些?

  • 普通方法是當前實例對象;
  • 同步方法快是括號中配置內容,可以是類Class對象,可以是實例對象;
  • 靜態方法是當前類Class對象。
  • 只要不是同一個鎖,就可以並行執行,同一個鎖,只能串行執行。
  • 更多參考我的文章Java中Synchronized關鍵字簡介(譯)

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

  1. volatile只能使用在變量上;而synchronized可以在類,變量,方法和代碼塊上。
  2. volatile至保證可見性;synchronized保證原子性與可見性。
  3. volatile禁用指令重排序;synchronized不會。
  4. volatile不會造成阻塞;synchronized會。

27、什麼是緩存一致性協議?

因爲CPU是運算很快,而主存的讀寫很忙,所以在程序運行中,會複製一份數據到高速緩存,處理完成在將結果保存主存.

這樣存在一些問題,在多核CPU中多個線程,多個線程拷貝多份的高速緩存數據,最後在計算完成,刷到主存的數據就會出現覆蓋

所以就出現了緩存一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從內存重新讀取。

28、Synchronized關鍵字、Lock,並解釋它們之間的區別?

Synchronized 與Lock都是可重入鎖,同一個線程再次進入同步代碼的時候.可以使用自己已經獲取到的鎖

Synchronized是悲觀鎖機制,獨佔鎖。而Locks.ReentrantLock是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因爲衝突失敗就重試,直到成功爲止。ReentrantLock適用場景

某個線程在等待一個鎖的控制權的這段時間需要中斷

需要分開處理一些wait-notify,ReentrantLock裏面的Condition應用,能夠控制notify哪個線程,鎖可以綁定多個條件。

具有公平鎖功能,每個到來的線程都將排隊等候。

29、Volatile如何保證內存可見性?

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存。
  • 當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

30、 Java中什麼是競態條件?

競態條件會導致程序在併發情況下出現一些bugs。多線程對一些資源的競爭的時候就會產生競態條件,如果首先要執行的程序競爭失敗排到後面執行了,那麼整個程序就會出現一些不確定的bugs。這種bugs很難發現而且會重複出現,因爲線程間的隨機競爭。

31、爲什麼wait, notify 和 notifyAll這些方法不在thread類裏面?

明顯的原因是JAVA提供的鎖是對象級的而不是線程級的,每個對象都有鎖,通過線程獲得。如果線程需要等待某些鎖那麼調用對象中的wait()方法就有意義了。如果wait()方法定義在Thread類中,線程正在等待的是哪個鎖就不明顯了。簡單的說,由於wait,notify和notifyAll都是鎖級別的操作,所以把他們定義在Object類中因爲鎖屬於對象。

32、Java中synchronized 和 ReentrantLock 有什麼不同?

相似點:

這兩種同步方式有很多相似之處,它們都是加鎖方式同步,而且都是阻塞式的同步,也就是說當如果一個線程獲得了對象鎖,進入了同步塊,其他訪問該同步塊的線程都必須阻塞在同步塊外面等待,而進行線程阻塞和喚醒的代價是比較高的.

區別:

這兩種方式最大區別就是對於Synchronized來說,它是java語言的關鍵字,是原生語法層面的互斥,需要jvm實現。而ReentrantLock它是JDK 1.5之後提供的API層面的互斥鎖,需要lock()和unlock()方法配合try/finally語句塊來完成。

Synchronized進過編譯,會在同步塊的前後分別形成monitorenter和monitorexit這個兩個字節碼指令。在執行monitorenter指令時,首先要嘗試獲取對象鎖。如果這個對象沒被鎖定,或者當前線程已經擁有了那個對象鎖,把鎖的計算器加1,相應的,在執行monitorexit指令時會將鎖計算器就減1,當計算器爲0時,鎖就被釋放了。如果獲取對象鎖失敗,那當前線程就要阻塞,直到對象鎖被另一個線程釋放爲止。

由於ReentrantLock是java.util.concurrent包下提供的一套互斥鎖,相比Synchronized,ReentrantLock類提供了一些高級功能,主要有以下3項:

  • 等待可中斷,持有鎖的線程長期不釋放的時候,正在等待的線程可以選擇放棄等待,這相當於Synchronized來說可以避免出現死鎖的情況。
  • 公平鎖,多個線程等待同一個鎖時,必須按照申請鎖的時間順序獲得鎖,Synchronized鎖非公平鎖,ReentrantLock默認的構造函數是創建的非公平鎖,可以通過參數true設爲公平鎖,但公平鎖表現的性能不是很好。
  • 鎖綁定多個條件,一個ReentrantLock對象可以同時綁定對個對象。

33、Synchronized 用過嗎,其原理是什麼?

這是一道 Java 面試中幾乎百分百會問到的問題,因爲只要是程序員就一定會通過或者接觸過Synchronized。

答:Synchronized 是由 JVM 實現的一種實現互斥同步的一種方式,如果 你查看被 Synchronized 修飾過的程序塊編譯後的字節碼,會發現, 被 Synchronized 修飾過的程序塊,在編譯前後被編譯器生成了monitorenter 和 monitorexit 兩 個 字 節 碼 指 令 。

這兩個指令是什麼意思呢?

在虛擬機執行到 monitorenter 指令時,首先要嘗試獲取對象的鎖: 如果這個對象沒有鎖定,或者當前線程已經擁有了這個對象的鎖,把鎖 的計數器 +1;當執行 monitorexit 指令時將鎖計數器 -1;當計數器 爲 0 時,鎖就被釋放了。如果獲取對象失敗了,那當前線程就要阻塞等待,直到對象鎖被另外一 個線程釋放爲止。

Java 中 Synchronize 通過在對象頭設置標記,達到了獲取鎖和釋放 鎖的目的。

34、上面提到獲取對象的鎖,這個“鎖”到底是什麼?如何確定對象的鎖?

答:“鎖”的本質其實是 monitorenter 和 monitorexit 字節碼指令的一 個 Reference 類型的參數,即要鎖定和解鎖的對象。我們知道,使用Synchronized 可以修飾不同的對象,因此,對應的對象鎖可以這麼確 定:

1. 如果 Synchronized 明確指定了鎖對象,比如 Synchronized(變量 名)、Synchronized(this) 等,說明加解鎖對象爲該對象。

2. 如果沒有明確指定:

  • 若 Synchronized 修飾的方法爲非靜態方法,表示此方法對應的對象爲 鎖對象;
  • 若 Synchronized 修飾的方法爲靜態方法,則表示此方法對應的類對象 爲鎖對象。

注意,當一個對象被鎖住時,對象裏面所有用 Synchronized 修飾的 方法都將產生堵塞,而對象裏非 Synchronized 修飾的方法可正常被 調用,不受鎖影響。

35、什麼是可重入性,爲什麼說 Synchronized 是可重入鎖?

先來看一下維基百科關於可重入鎖的定義:

若一個程序或子程序可以“在任意時刻被中斷然後操作系統調度執行另外一段代碼,這段代碼又調用了該子程序不會出錯”,則稱其爲可重入(reentrant或re-entrant)的。即當該子程序正在運行時,執行線程可以再次進入並執行它,仍然獲得符合設計時預期的結果。與多線程併發執行的線程安全不同,可重入強調對單個線程執行時重新進入同一個子程序仍然是安全的。

通俗來說:當線程請求一個由其它線程持有的對象鎖時,該線程會阻塞,而當線程請求由自己持有的對象鎖時,如果該鎖是重入鎖,請求就會成功,否則阻塞。

要證明synchronized是不是可重入鎖,我們先來看一段代碼:

package com.mzc.common.concurrent.synchronize;
​
/**
 * <p class="detail">
 * 功能: 證明synchronized爲什麼是可重入鎖
 * </p>
 *
 * @author Moore
 * @ClassName Super class.
 * @Version V1.0.
 * @date 2020.02.07 15:34:12
 */
public class SuperClass {
​
    public synchronized void doSomething(){
        System.out.println("father is doing something,the thread name is:"+Thread.currentThread().getName());
    }
}
package com.mzc.common.concurrent.synchronize;
​
/**
 * <p class="detail">
 * 功能: 證明synchronized爲什麼是可重入鎖
 * </p>
 *
 * @author Moore
 * @ClassName Sub class.
 * @Version V1.0.
 * @date 2020.02.07 15:34:41
 */
public class SubClass extends SuperClass {
​
    public synchronized void doSomething() {
        System.out.println("child is doing doSomething,the thread name is:" + Thread.currentThread().getName());
        // 調用自己類中其他的synchronized方法
        doAnotherThing();
    }
​
    private synchronized void doAnotherThing() {
        // 調用父類的synchronized方法
        super.doSomething();
        System.out.println("child is doing anotherThing,the thread name is:" + Thread.currentThread().getName());
    }
​
    public static void main(String[] args) {
        SubClass child = new SubClass();
        child.doSomething();
    }
}

通過運行main方法,先一下結果:

child is doing doSomething,the thread name is:main
father is doing something,the thread name is:main
child is doing anotherThing,the thread name is:main

因爲這些方法輸出了相同的線程名稱,表明即使遞歸使用synchronized也沒有發生死鎖,證明其是可重入的。

還看不懂?那我就再解釋下!

這裏的對象鎖只有一個,就是 child 對象的鎖,當執行 child.doSomething 時,該線程獲得 child 對象的鎖,在 doSomething 方法內執行 doAnotherThing 時再次請求child對象的鎖,因爲synchronized 是重入鎖,所以可以得到該鎖,繼續在 doAnotherThing 裏執行父類的 doSomething 方法時第三次請求 child 對象的鎖,同樣可得到。如果不是重入鎖的話,那這後面這兩次請求鎖將會被一直阻塞,從而導致死鎖。

所以在 java 內部,同一線程在調用自己類中其他 synchronized 方法/塊或調用父類的 synchronized 方法/塊都不會阻礙該線程的執行。就是說同一線程對同一個對象鎖是可重入的,而且同一個線程可以獲取同一把鎖多次,也就是可以多次重入。因爲java線程是基於“每線程(per-thread)”,而不是基於“每調用(per-invocation)”的(java中線程獲得對象鎖的操作是以線程爲粒度的,per-invocation 互斥體獲得對象鎖的操作是以每調用作爲粒度的)。

重入鎖實現可重入性原理或機制是:每一個鎖關聯一個線程持有者和計數器,當計數器爲 0 時表示該鎖沒有被任何線程持有,那麼任何線程都可能獲得該鎖而調用相應的方法;當某一線程請求成功後,JVM會記下鎖的持有線程,並且將計數器置爲 1;此時其它線程請求該鎖,則必須等待;而該持有鎖的線程如果再次請求這個鎖,就可以再次拿到這個鎖,同時計數器會遞增;當線程退出同步代碼塊時,計數器會遞減,如果計數器爲 0,則釋放該鎖。

36、JVM 對 Java 的原生鎖做了哪些優化?

在 Java 6 之前,Monitor 的實現完全依賴底層操作系統的互斥鎖來 實現,也就是我們剛纔在問題二中所闡述的獲取/釋放鎖的邏輯。

由於 Java 層面的線程與操作系統的原生線程有映射關係,如果要將一 個線程進行阻塞或喚起都需要操作系統的協助,這就需要從用戶態切換 到內核態來執行,這種切換代價十分昂貴,很耗處理器時間,現代 JDK中做了大量的優化。一種優化是使用自旋鎖,即在把線程進行阻塞操作之前先讓線程自旋等待一段時間,可能在等待期間其他線程已經解鎖,這時就無需再讓線程 執行阻塞操作,避免了用戶態到內核態的切換。

現代 JDK 中還提供了三種不同的 Monitor 實現,也就是三種不同的鎖:

  • 偏向鎖(Biased Locking)
  • 輕量級鎖
  • 重量級鎖

這三種鎖使得 JDK 得以優化 Synchronized 的運行,當 JVM 檢測 到不同的競爭狀況時,會自動切換到適合的鎖實現,這就是鎖的升級、 降級。

  • 當沒有競爭出現時,默認會使用偏向鎖。JVM 會利用 CAS 操作,在對象頭上的 Mark Word 部分設置線程ID,以表示這個對象偏向於當前線程,所以並不涉及真正的互斥鎖,因 爲在很多應用場景中,大部分對象生命週期中最多會被一個線程鎖定, 使用偏斜鎖可以降低無競爭開銷。
  • 如果有另一線程試圖鎖定某個被偏斜過的對象,JVM 就撤銷偏斜鎖, 切換到輕量級鎖實現。
  • 輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功, 就使用普通的輕量級鎖;否則,進一步升級爲重量級鎖。

37、爲什麼說 Synchronized 是非公平鎖?

答:非公平主要表現在獲取鎖的行爲上,並非是按照申請鎖的時間前後給等 待線程分配鎖的,每當鎖被釋放後,任何一個線程都有機會競爭到鎖, 這樣做的目的是爲了提高執行性能,缺點是可能會產生線程飢餓現象。

38、爲什麼說 Synchronized 是一個悲觀鎖?樂觀鎖的實現原理 又是什麼?什麼是 CAS,它有什麼特性?

答:Synchronized 顯然是一個悲觀鎖,因爲它的併發策略是悲觀的:不管是否會產生競爭,任何的數據操作都必須要加鎖、用戶態核心態轉 換、維護鎖計數器和檢查是否有被阻塞的線程需要被喚醒等操作。

隨着硬件指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略。先進行操作,如果沒有其他線程徵用數據,那操作就成功了; 如果共享數據有徵用,產生了衝突,那就再進行其他的補償措施。這種 樂觀的併發策略的許多實現不需要線程掛起,所以被稱爲非阻塞同步。

樂觀鎖的核心算法是 CAS(Compareand Swap,比較並交換),它涉 及到三個操作數:內存值、預期值、新值。當且僅當預期值和內存值相 等時纔將內存值修改爲新值。這樣處理的邏輯是,首先檢查某塊內存的值是否跟之前我讀取時的一 樣,如不一樣則表示期間此內存值已經被別的線程更改過,捨棄本次操 作,否則說明期間沒有其他線程對此內存值操作,可以把新值設置給此 塊內存。

CAS 具有原子性,它的原子性由CPU 硬件指令實現保證,即使用JNI 調用 Native 方法調用由 C++ 編寫的硬件級別指令,JDK 中提 供了 Unsafe 類執行這些操作。

39、樂觀鎖一定就是好的嗎?

答:樂觀鎖避免了悲觀鎖獨佔對象的現象,同時也提高了併發性能,但它也 有缺點:

  • 樂觀鎖只能保證一個共享變量的原子操作。如果多一個或幾個變量,樂 觀鎖將變得力不從心,但互斥鎖能輕易解決,不管對象數量多少及對象 顆粒度大小。
  • 長時間自旋可能導致開銷大。假如 CAS 長時間不成功而一直自旋,會 給 CPU 帶來很大的開銷。
  • ABA 問題。CAS 的核心思想是通過比對內存值與預期值是否一樣而判 斷內存值是否被改過,但這個判斷邏輯不嚴謹,假如內存值原來是 A, 後來被一條線程改爲 B,最後又被改成了 A,則 CAS 認爲此內存值並 沒有發生改變,但實際上是有被其他線程改過的,這種情況對依賴過程 值的情景的運算結果影響很大。解決的思路是引入版本號,每次變量更新都把版本號加一。

40、談一談AQS框架。

AQS(AbstractQueuedSynchronizer 類)是一個用來構建鎖和同步器 的框架,各種Lock 包中的鎖(常用的有 ReentrantLock、 ReadWriteLock) , 以 及 其 他 如 Semaphore、 CountDownLatch, 甚 至是早期的 FutureTask 等,都是基於 AQS 來構建。

  1. AQS 在內部定義了一個 volatile int state 變量,表示同步狀態:當線 程調用 lock 方法時 ,如果 state=0,說明沒有任何線程佔有共享資源 的鎖,可以獲得鎖並將 state=1;如果 state=1,則說明有線程目前正在 使用共享變量,其他線程必須加入同步隊列進行等待。
  2. AQS 通過 Node 內部類構成的一個雙向鏈表結構的同步隊列,來完成線 程獲取鎖的排隊工作,當有線程獲取鎖失敗後,就被添加到隊列末尾。Node 類是對要訪問同步代碼的線程的封裝,包含了線程本身及其狀態叫waitStatus(有五種不同 取值,分別表示是否被阻塞,是否等待喚醒, 是否已經被取消等),每個 Node 結點關聯其 prev 結點和 next 結 點,方便線程釋放鎖後快速喚醒下一個在等待的線程,是一個 FIFO 的過 程。Node 類有兩個常量,SHARED 和 EXCLUSIVE,分別代表共享模式和獨 佔模式。所謂共享模式是一個鎖允許多條線程同時操作(信號量Semaphore 就是基於 AQS 的共享模式實現的),獨佔模式是同一個時 間段只能有一個線程對共享資源進行操作,多餘的請求線程需要排隊等待 ( 如 ReentranLock) 。
  3. AQS 通過內部類 ConditionObject 構建等待隊列(可有多個),當Condition 調用 wait() 方法後,線程將會加入等待隊列中,而當Condition 調用 signal() 方法後,線程將從等待隊列轉移動同步隊列中進行鎖競爭。
  4. AQS 和 Condition 各自維護了不同的隊列,在使用 Lock 和Condition 的時候,其實就是兩個隊列的互相移動。

41、ReentrantLock 是如何實現可重入性的?

答:ReentrantLock 內部自定義了同步器 Sync(Sync 既實現了 AQS, 又實現了 AOS,而 AOS 提供了一種互斥鎖持有的方式),其實就是 加鎖的時候通過 CAS 算法,將線程對象放到一個雙向鏈表中,每次獲 取鎖的時候,看下當前維護的那個線程 ID 和當前請求的線程 ID 是否 一樣,一樣就可重入了。

42、Java中Semaphore是什麼?

Java中的Semaphore是一種新的同步類,它是一個計數信號。從概念上講,從概念上講,信號量維護了一個許可集合。如有必要,在許可可用前會阻塞每一個 acquire(),然後再獲取該許可。每個 release()添加一個許可,從而可能釋放一個正在阻塞的獲取者。但是,不使用實際的許可對象,Semaphore只對可用許可的號碼進行計數,並採取相應的行動。信號量常常用於多線程的代碼中,比如數據庫連接池。

package com.mzc.common.concurrent;
​
import java.util.concurrent.Semaphore;
​
/**
 * <p class="detail">
 * 功能: Semaphore Test
 * </p>
 *
 * @author Moore
 * @ClassName Test semaphore.
 * @Version V1.0.
 * @date 2020.02.07 20:11:00
 */
public class TestSemaphore {
​
    static class Worker extends Thread{
        private int num;
        private Semaphore semaphore;
        public Worker(int num,Semaphore semaphore){
            this.num = num;
            this.semaphore = semaphore;
        }
        @Override
        public void run() {
            try {
                // 搶許可
                semaphore.acquire();
                Thread.sleep(2000);
                // 釋放許可
                semaphore.release();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
​
    public static void main(String[] args) {
        // 機器數目,即5個許可
        Semaphore semaphore = new Semaphore(5);
        // 8個線程去搶許可
        for (int i = 0; i < 8; i++){
            new Worker(i,semaphore).start();
        }
    }
}

43、Java 中的線程池是如何實現的?

  • 在 Java 中,所謂的線程池中的“線程”,其實是被抽象爲了一個靜態 內部類 Worker,它基於 AQS 實現,存放在線程池的HashSet workers 成員變量中;
  • 而需要執行的任務則存放在成員變量 workQueue(BlockingQueue workQueue)中。這樣,整個線程池實現的基本思想就是:從 workQueue 中不斷取出 需要執行的任務,放在 Workers 中進行處理。

44、線程池中的線程是怎麼創建的?是一開始就隨着線程池的啓動創建好的嗎?

答:顯然不是的。線程池默認初始化後不啓動 Worker,等待有請求時才啓動。每當我們調用 execute() 方法添加一個任務時,線程池會做如下判 斷:

  • 如果正在運行的線程數量小於 corePoolSize,那麼馬上創建線程運行這個任務;
  • 如果正在運行的線程數量大於或等於 corePoolSize,那麼將這個任務放入隊列;
  • 如果這時候隊列滿了,而且正在運行的線程數量小於maximumPoolSize,那麼還是要創建非核心線程立刻運行這個任務;
  • 如果隊列滿了,而且正在運行的線程數量大於或等於maximumPoolSize,那麼線程池會拋出異常RejectExecutionException。

當一個線程完成任務時,它會從隊列中取下一個任務來執行。當一個線程無事可做,超過一定的時間(keepAliveTime)時,線程池會判斷。

如果當前運行的線程數大於 corePoolSize,那麼這個線程就被停掉。所以線程池的所有任務完成後,它最終會收縮到 corePoolSize 的大小。

45、什麼是競爭條件?如何發現和解決競爭?

兩個線程同步操作同一個對象,使這個對象的最終狀態不明——叫做競爭條件。競爭條件可以在任何應該由程序員保證原子操作的,而又忘記使用synchronized的地方。

唯一的解決方案就是加鎖。

Java有兩種鎖可供選擇:

  • 對象或者類(class)的鎖。每一個對象或者類都有一個鎖。使用synchronized關鍵字獲取。 synchronized加到static方法上面就使用類鎖,加到普通方法上面就用對象鎖。除此之外synchronized還可以用於鎖定關鍵區域塊(Critical Section)。 synchronized之後要制定一個對象(鎖的攜帶者),並把關鍵區域用大括號包裹起來。synchronized(this){// critical code}。
  • 顯示構建的鎖(java.util.concurrent.locks.Lock),調用lock的lock方法鎖定關鍵代碼。

46、很多人都說要慎用 ThreadLocal,談談你的理解,使用ThreadLocal 需要注意些什麼?

答:使 用 ThreadLocal 要 注 意 remove!

ThreadLocal 的實現是基於一個所謂的 ThreadLocalMap,在ThreadLocalMap 中,它的 key 是一個弱引用。通常弱引用都會和引用隊列配合清理機制使用,但是 ThreadLocal 是 個例外,它並沒有這麼做。這意味着,廢棄項目的回收依賴於顯式地觸發,否則就要等待線程結 束,進而回收相應 ThreadLocalMap!這就是很多 OOM 的來源,所 以通常都會建議,應用一定要自己負責 remove,並且不要和線程池配 合,因爲 worker 線程往往是不會退出的。

參考資料:https://www.cnblogs.com/jxldjsn/p/10872154.html

參考資料:https://www.cnblogs.com/sgh1023/p/10297322.html

參考資料:https://blog.csdn.net/u011780616/article/details/95339236

image

更多面試資料請關注我的公衆號“碼之初”或者“ma_zhichu”,希望所有鄉親們面試無憂,前程似錦。

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