純乾貨!二十八道BATJ大廠Java崗之"多線程與併發"面試題分享

純乾貨!二十八道BATJ大廠Java崗之

年底了,又到了跳槽季啦,該刷題走起了。這裏總結了一些被問到可能會懵逼的面試真題,有需要的可以看下~

一、進程與線程

進程是資源分配的最小單位,線程是cpu調度的最小單位。線程也被稱爲輕量級進程。

  • 所有與進程相關的資源,都被記錄在PCB中
  • 進程是搶佔處理及的調度單位;線程屬於某個進程,共享其資源

一個 Java 程序的運行是 main 線程和多個其他線程同時運行。

二、Thread中的start和run方法的區別

  • 調用start()方法會創建一個新的子線程並啓動
  • run()方法只是Thread的一個普通方法的調用,還是在主線程裏執行。

三、Thread和Runnable是什麼關係?

Thread是實現了Runnable接口的類,是的run支持多線程。

因java類的單一繼承原則,推薦多使用Runnable接口

四、如何給run()方法傳參?

  • 構造函數傳參
  • 成員變量傳參
  • 回調函數傳參

五、如何實現處理線程的返回值?

實現的方式主要有三種:

主線程等待法

/*
private String value;
public void run() {
    try {
        Thread.currentThread().sleep(5000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    value = "we have data now";
}*/

CycleWait cw = new CycleWait();
Thread t = new Thread(cw);
t.start();
  while (cw.value == null){
          Thread.currentThread().sleep(100);//如果value一直爲空,則線程一直sleep
        }

使用Thread類的join()阻塞當前線程,以等待子線程處理完畢

t.join();

通過Callable接口實現:通過FutureTask Or 線程池獲取

六、線程的狀態?

  • 新建(NEW):創建後尚未啓動的線程的狀態
  • 運行(Runnable):包含Running和Ready
  • 無限期等待(Waiting):不會被分配CPU執行時間,需要顯式被喚醒

沒有設置Timeout參數的Object.wait()方法。
沒有設置Timeout參數的Thread.join()方法。
LockSupport.park()方法。

  • 限期等待(Timed Waiting):在一定時間後會由系統自動喚醒

Thread.sleep()方法。
設置了Timeout參數的Object.wait()方法。
設置了Timeout參數的Thread.join()方法。
LockSupport.parkNanos()方法。
LockSupport.parkUntil()方法。

  • 阻塞(blocked):等待獲取排它鎖
  • 結束:已終止線程的狀態,線程已經結束執行

七、sleep和wait

  • sleep是Thread類的方法,wait是Object類中定義的方法
  • sleep方法可以在任何地方使用
  • wait方法只能在synchronized方法或者synchronized塊中使用

最本質的區別

  • Thread.sleep只會讓出CPU,不會導致鎖行爲的改變(不會釋放鎖)
  • Object.wait不僅讓出CPU,還會釋放已經佔有的同步資源鎖

八、notify和notifyAll的區別

  • notifyAll會讓所有處於等待池的線程全部進入鎖池去競爭獲取鎖的機會
  • notify會隨機選取一個處於等待池中的線程進入鎖池去競爭獲取鎖的機會。

九、yield函數

當調用Thread.yield()函數時,會給線程調度器一個當前線程願意讓出CPU使用的暗示,但是線程調度器可能會忽略這個暗示

十、中斷函數interrupt()

已經被拋棄的方法

通過調用stop()方法停止線程

目前使用的方法

調用interrupt(),通知線程應該中斷了

  1. 如果線程處於被阻塞狀態,那麼線程將立即退出被阻塞狀態,並拋出一個InterruptedException異常
  2. 如果線程處於正常活動狀態,那麼會將該線程的中斷標誌設置爲true。被設置中斷標誌的線程將繼續正常運行,不受影響

需要被調用的線程配合中斷

  1. 在正常運行任務時,經常檢查本線程的中斷標誌位,如果被設置了中斷標誌就自行停止線程。
  2. 如果線程處於正常活動狀態,那麼會將該線程的中斷標誌設置爲true。被設置中斷標誌的線程將繼續正常運行,不受影響

十一、synchronized

線程安全問題的主要誘因

  • 存在共享數據(也稱臨界資源)
  • 存在多條線程共同操作這些共享數據

解決問題的根本辦法:同一時刻有且只有一個線程在操作共享數據,其他線程必須等到該線程處理完數據後再對貢獻數據進行操作。

互斥鎖的特性

  • 互斥性:即在同一時間只允許一個線程持有某個對象鎖,通過這種特性來實現多線程的協調機制,這樣同一時間只有一個線程對需要同步的代碼塊(複合操作)進行訪問。互斥性也稱爲操作的原子性。
  • 可見性:必須確保在鎖被釋放之前,對共享變量所做的修改,對於隨後獲得該鎖的另一個線程是可見的(即在獲得鎖時應該獲得最新共享變量的值),否則另一個線程可能是在本地緩存的某個副本上繼續操作,從而引起不一致。(一致性???paxos???raft???)

根據獲取鎖的分類:獲取對象鎖和獲取類鎖

  • 獲取對象鎖的兩種用法
  1. 同步代碼塊(synchronized(this),synchronized(類實例對象)),鎖是小括號()中的實例對象。
  2. 同步非靜態方法(synchronized method),鎖是當前對象的實例對象。
  • 獲取類鎖的兩種用法
  1. 同步代碼塊(synchronized(類.class)),鎖是小括號()中的類對象(Class對象)。
  2. 同步靜態方法(synchronized static method),鎖是當前對象的類對象(Class對象)

對象鎖和類鎖的總結

  1. 有線程訪問對象的同步代碼塊時,另外的線程可以訪問該對象的非同步代碼塊;
  2. 若鎖住的是同一個對象,一個線程在訪問對象的同步代碼塊時,另一個訪問對象的同步代碼塊的線程會被阻塞;
  3. 若鎖住的是同一個對象,一個線程在訪問對象的同步方法時,另一個訪問對象的同步方法的線程會被阻塞;
  4. 若鎖住的是同一個對象,一個線程在訪問對象的同步代碼塊時,另一個訪問對象的同步方法的線程會被阻塞;,反之亦然;
  5. 同一個類的不同對象的對象鎖互不干擾;
  6. 類鎖由於也是一種特殊的對象鎖,因此表現和上述1、2、3、4一致,而由於一個類只有一把對象鎖,所以同一個類的不同對象使用類鎖將會是同步的;
  7. 類鎖和對象鎖互不干擾。

十二、synchronized的底層實現原理

1. 實現synchronized的基礎

  • java對象頭
  • Monitor

2. 對象在內存中的佈局

  • 對象頭
  • 實例數據
  • 對齊填充

對象頭的結構

java的對象頭由以下三部分組成:

  1. Mark Word
  2. 指向類的指針
  3. 數組長度(只有數組對象纔有)

Mark Word Mark Word記錄了對象和鎖有關的信息,當這個對象被synchronized關鍵字當成同步鎖時,圍繞這個鎖的一系列操作都和Mark Word有關。

Mark Word在32位JVM中的長度是32bit,在64位JVM中長度是64bit。

Mark Word在不同的鎖狀態下存儲的內容不同,在32位JVM中是這麼存的:

JVM一般是這樣使用鎖和Mark Word的:

  1. 當沒有被當成鎖時,這就是一個普通的對象,Mark Word記錄對象的HashCode,鎖標誌位是01,是否偏向鎖那一位是0。
  2. 當對象被當做同步鎖並有一個線程A搶到了鎖時,鎖標誌位還是01,但是否偏向鎖那一位改成1,前23bit記錄搶到鎖的線程id,表示進入偏向鎖狀態。
  3. 當線程A再次試圖來獲得鎖時,JVM發現同步鎖對象的標誌位是01,是否偏向鎖是1,也就是偏向狀態,Mark Word中記錄的線程id就是線程A自己的id,表示線程A已經獲得了這個偏向鎖,可以執行同步鎖的代碼。
  4. 當線程B試圖獲得這個鎖時,JVM發現同步鎖處於偏向狀態,但是Mark Word中的線程id記錄的不是B,那麼線程B會先用CAS操作試圖獲得鎖,這裏的獲得鎖操作是有可能成功的,因爲線程A一般不會自動釋放偏向鎖。如果搶鎖成功,就把Mark Word裏的線程id改爲線程B的id,代表線程B獲得了這個偏向鎖,可以執行同步鎖代碼。如果搶鎖失敗,則繼續執行步驟5。
  5. 偏向鎖狀態搶鎖失敗,代表當前鎖有一定的競爭,偏向鎖將升級爲輕量級鎖。JVM會在當前線程的線程棧中開闢一塊單獨的空間,裏面保存指向對象鎖Mark Word的指針,同時在對象鎖Mark Word中保存指向這片空間的指針。上述兩個保存操作都是CAS操作,如果保存成功,代表線程搶到了同步鎖,就把Mark Word中的鎖標誌位改成00,可以執行同步鎖代碼。如果保存失敗,表示搶鎖失敗,競爭太激烈,繼續執行步驟6。
  6. 輕量級鎖搶鎖失敗,JVM會使用自旋鎖,自旋鎖不是一個鎖狀態,只是代表不斷的重試,嘗試搶鎖。從JDK1.7開始,自旋鎖默認啓用,自旋次數由JVM決定。如果搶鎖成功則執行同步鎖代碼,如果失敗則繼續執行步驟7。
  7. 自旋鎖重試之後如果搶鎖依然失敗,同步鎖會升級至重量級鎖,鎖標誌位改爲10。在這個狀態下,未搶到鎖的線程都會被阻塞。

Monitor(管程):每個java對象天生自帶了一把看不見的鎖

Monitor鎖的競爭、獲取與釋放

十三、自旋鎖

  • 許多情況下,共享數據的所狀態持續時間較短,切換線程不值得。
  • 通過讓線程執行忙循環等待鎖的釋放,不讓出cpu。
  • 缺點:若鎖被其他線程長時間佔用,會帶來許多性能上的開銷·

十四、自適應自旋鎖

  • 自旋的次數不再固定
  • 由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定

十五、鎖消除

JIT編譯時,對運行上下文進行掃描,去除不可能存在競爭的鎖。

十六、鎖粗化

通過擴大鎖的範圍,避免反覆的加鎖解鎖

十七、synchronized的四種狀態

無鎖、偏向鎖、輕量級鎖、重量級鎖

鎖膨脹方向:無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖

偏向鎖:減少同一線程獲取鎖的代價

大多數情況下,鎖不存在多線程競爭,總是由同一線程多次獲得

核心思想

如果一個線程獲得了鎖,那麼鎖就進入了偏向模式,此時Mark Word的結構也變爲偏向鎖結構,當該線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程只需要檢查Mark Word的所標記位爲偏向鎖以及當前線程ID等於Mark Word的ThreadID即可,這樣就省去了大量有關鎖申請的操作。

#十八、輕量級鎖

​ 輕量級鎖是由偏向鎖升級來的,偏向鎖運行在一個線程進入同步塊的情況下,當第二個線程加入鎖爭用的時候,偏向鎖就會升級爲輕量級鎖。

適用場景:線程交替執行同步塊

​ 若存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹爲重量級鎖

十九、鎖的內存語義

當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。

當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效。從而使得被監視器保護的臨界區代碼必須要從主內存中去讀取共享變量。

二十、ReenTrantLock

ReentrantLock 是 java.util.concurrent(J.U.C)包中的鎖。

public class LockExample {

    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 確保釋放鎖,從而避免發生死鎖。
        }
    }
}
public static void main(String[] args) {
    LockExample lockExample = new LockExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}

1. 鎖的實現

synchronized 是 JVM 實現的,而 ReentrantLock 是 JDK 實現的。

2. 性能

新版本 Java 對 synchronized 進行了很多優化,例如自旋鎖等,synchronized 與 ReentrantLock 大致相同。

3. 等待可中斷

當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改爲處理其他事情。

ReentrantLock 可中斷,而 synchronized 不行。

4. 公平鎖

公平鎖是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。

synchronized 中的鎖是非公平的,ReentrantLock 默認情況下也是非公平的,但是也可以是公平的。

5. 鎖綁定多個條件

一個 ReentrantLock 可以同時綁定多個 Condition 對象。

二十一、線程池

1. 爲什麼要用線程池?

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

這裏借用《Java併發編程的藝術》提到的來說一下使用線程池的好處:

  • 降低資源消耗。 通過重複利用已創建的線程降低線程創建和銷燬造成的消耗。
  • 提高響應速度。 當任務到達時,任務可以不需要的等到線程創建就能立即執行。
  • 提高線程的可管理性。 線程是稀缺資源,如果無限制的創建,不僅會消耗系統資源,還會降低系統的穩定性,使用線程池可以進行統一的分配,調優和監控。

2. 實現Runnable接口和Callable接口的區別

如果想讓線程池執行任務的話需要實現的Runnable接口或Callable接口。 Runnable接口或Callable接口實現類都可以被ThreadPoolExecutor或ScheduledThreadPoolExecutor執行。兩者的區別在於 Runnable 接口不會返回結果但是 Callable 接口可以返回結果。

備註: 工具類Executors可以實現Runnable對象和Callable對象之間的相互轉換。(Executors.callable(Runnable task)或Executors.callable(Runnable task,Object resule))。

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

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

4. 如何創建線程池

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

Executors 返回線程池對象的弊端如下:

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

方式一:通過Executor 框架的工具類Executors來實現 我們可以創建三種類型的ThreadPoolExecutor

  • FixedThreadPool : 該方法返回一個固定線程數量的線程池。該線程池中的線程數量始終不變。當有一個新的任務提交時,線程池中若有空閒線程,則立即執行。若沒有,則新的任務會被暫存在一個任務隊列中,待有線程空閒時,便處理在任務隊列中的任務。
  • SingleThreadExecutor: 方法返回一個只有一個線程的線程池。若多餘一個任務被提交到該線程池,任務會被保存在一個任務隊列中,待線程空閒,按先入先出的順序執行隊列中的任務。
  • CachedThreadPool: 該方法返回一個可根據實際情況調整線程數量的線程池。線程池的線程數量不確定,但若有空閒線程可以複用,則會優先使用可複用的線程。若所有線程均在工作,又有新的任務提交,則會創建新的線程處理任務。所有線程在當前任務執行完畢後,將返回線程池進行復用。

二十二、volatile關鍵字

在 JDK1.2 之前,Java的內存模型實現總是從主存(即共享內存)讀取變量,是不需要進行特別的注意的。而在當前的 Java 內存模型下,線程可以把變量保存本地內存比如機器的寄存器)中,而不是直接在主存中進行讀寫。這就可能造成一個線程在主存中修改了一個變量的值,而另外一個線程還繼續使用它在寄存器中的變量值的拷貝,造成數據的不一致。

要解決這個問題,就需要把變量聲明爲volatile,這就指示 JVM,這個變量是不穩定的,每次使用它都到主存中進行讀取。

說白了, volatile 關鍵字的主要作用就是保證變量的可見性然後還有一個作用是防止指令重排序。

二十三、synchronized 關鍵字和 volatile 關鍵字的區別

synchronized關鍵字和volatile關鍵字比較

  • volatile關鍵字是線程同步的輕量級實現,所以volatile性能肯定比synchronized關鍵字要好。但是volatile關鍵字只能用於變量而synchronized關鍵字可以修飾方法以及代碼塊。synchronized關鍵字在JavaSE1.6之後進行了主要包括爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖以及其它各種優化之後執行效率有了顯著提升,實際開發中使用 synchronized 關鍵字的場景還是更多一些。
  • 多線程訪問volatile關鍵字不會發生阻塞,而synchronized關鍵字可能會發生阻塞
  • volatile關鍵字能保證數據的可見性,但不能保證數據的原子性。synchronized關鍵字兩者都能保證。
  • volatile關鍵字主要用於解決變量在多個線程之間的可見性,而 synchronized關鍵字解決的是多個線程之間訪問資源的同步性。

二十四、ThreadLocal

通常情況下,我們創建的變量是可以被任何一個線程訪問並修改的。如果想實現每一個線程都有自己的專屬本地變量該如何解決呢? JDK中提供的ThreadLocal類正是爲了解決這樣的問題。 ThreadLocal類主要解決的就是讓每個線程綁定自己的值,可以將ThreadLocal類形象的比喻成存放數據的盒子,盒子中可以存儲每個線程的私有數據。

如果你創建了一個ThreadLocal變量,那麼訪問這個變量的每個線程都會有這個變量的本地副本,這也是ThreadLocal變量名的由來。他們可以使用 get() 和 set() 方法來獲取默認值或將其值更改爲當前線程所存的副本的值,從而避免了線程安全問題。

import java.text.SimpleDateFormat;
import java.util.Random;

public class ThreadLocalExample implements Runnable{

     // SimpleDateFormat 不是線程安全的,所以每個線程都要有自己獨立的副本
    private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));

    public static void main(String[] args) throws InterruptedException {
        ThreadLocalExample obj = new ThreadLocalExample();
        for(int i=0 ; i<10; i++){
            Thread t = new Thread(obj, ""+i);
            Thread.sleep(new Random().nextInt(1000));
            t.start();
        }
    }

    @Override
    public void run() {
        System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
        try {
            Thread.sleep(new Random().nextInt(1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //formatter pattern is changed here by thread, but it won't reflect to other threads
        formatter.set(new SimpleDateFormat());

        System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
    }

}

Output:

Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = yy-M-d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = yy-M-d ah:mm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = yy-M-d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = yy-M-d ah:mm
Thread Name= 4 formatter = yy-M-d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = yy-M-d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 6 formatter = yy-M-d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = yy-M-d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = yy-M-d ah:mm
Thread Name= 9 formatter = yy-M-d ah:mm

原理

從 Thread類源代碼入手。

public class Thread implements Runnable {
 ......
//與此線程有關的ThreadLocal值。由ThreadLocal類維護
ThreadLocal.ThreadLocalMap threadLocals = null;

//與此線程有關的InheritableThreadLocal值。由InheritableThreadLocal類維護
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
 ......
}

從上面Thread類 源代碼可以看出Thread 類中有一個 threadLocals 和 一個 inheritableThreadLocals 變量,它們都是 ThreadLocalMap 類型的變量,我們可以把 ThreadLocalMap 理解爲ThreadLocal 類實現的定製化的 HashMap。默認情況下這兩個變量都是null,只有當前線程調用 ThreadLocal 類的 set或get方法時才創建它們,實際上調用這兩個方法的時候,我們調用的是ThreadLocalMap類對應的 get()、set() 方法。

ThreadLocal類的set()方法

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

通過上面這些內容,我們足以通過猜測得出結論:最終的變量是放在了當前線程的 ThreadLocalMap 中,並不是存在 ThreadLocal 上,ThreadLocal 可以理解爲只是ThreadLocalMap的封裝,傳遞了變量值。 ThrealLocal 類中可以通過Thread.currentThread()獲取到當前線程對象後,直接通過getMap(Thread t)可以訪問到該線程的ThreadLocalMap對象。

每個Thread中都具備一個ThreadLocalMap,而ThreadLocalMap可以存儲以ThreadLocal爲key的鍵值對。 比如我們在同一個線程中聲明瞭兩個 ThreadLocal 對象的話,會使用 Thread內部都是使用僅有那個ThreadLocalMap 存放數據的,ThreadLocalMap的 key 就是 ThreadLocal對象,value 就是 ThreadLocal 對象調用set方法設置的值。 ThreadLocal 是 map結構是爲了讓每個線程可以關聯多個 ThreadLocal變量。這也就解釋了 ThreadLocal 聲明的變量爲什麼在每一個線程都有自己的專屬本地變量。

ThreadLocalMap是ThreadLocal的靜態內部類。

二十五、ThreadLocal 內存泄露問題

ThreadLocalMap 中使用的 key 爲 ThreadLocal 的弱引用,而 value 是強引用。所以,如果 ThreadLocal 沒有被外部強引用的情況下,在垃圾回收的時候會 key 會被清理掉,而 value 不會被清理掉。這樣一來,ThreadLocalMap 中就會出現key爲null的Entry。假如我們不做任何措施的話,value 永遠無法被GC 回收,這個時候就可能會產生內存泄露。ThreadLocalMap實現中已經考慮了這種情況,在調用 set()、get()、remove() 方法的時候,會清理掉 key 爲 null 的記錄。使用完 ThreadLocal方法後 最好手動調用remove()方法。

 static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

弱引用介紹

如果一個對象只具有弱引用,那麼就類似於可有可無的生活用品。弱引用與軟引用的區別在於:只具有弱引用的對象擁有更短暫的生命週期。在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。不過,由於垃圾回收器是一個優先級很低的線程, 因此不一定會很快發現那些只具有弱引用的對象。
弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果弱引用所引用的對象被垃圾回收,Java虛擬機就會把這個弱引用加入到與之關聯的引用隊列中去。

二十六、java 線程方法join的簡單總結

1. 作用

Thread類中的join方法的主要作用就是同步,它可以使得線程之間的並行執行變爲串行執行。具體看代碼:

public class JoinTest {
    public static void main(String [] args) throws InterruptedException {
        ThreadJoinTest t1 = new ThreadJoinTest("小明");
        ThreadJoinTest t2 = new ThreadJoinTest("小東");
        t1.start();
        /**join的意思是使得放棄當前線程的執行,並返回對應的線程,例如下面代碼的意思就是:
         程序在main線程中調用t1線程的join方法,則main線程放棄cpu控制權,並返回t1線程繼續執行直到線程t1執行完畢
         所以結果是t1線程執行完後,纔到主線程執行,相當於在main線程中同步t1線程,t1執行完了,main線程纔有執行的機會
         */
        t1.join();
        t2.start();
    }

}
class ThreadJoinTest extends Thread{
    public ThreadJoinTest(String name){
        super(name);
    }
    @Override
    public void run(){
        for(int i=0;i<1000;i++){
            System.out.println(this.getName() + ":" + i);
        }
    }
}

上面程序結果是先打印完小明線程,在打印小東線程;  

上面註釋也大概說明了join方法的作用:在A線程中調用了B線程的join()方法時,表示只有當B線程執行完畢時,A線程才能繼續執行。注意,這裏調用的join方法是沒有傳參的,join方法其實也可以傳遞一個參數給它的,具體看下面的簡單例子:

public class JoinTest {
    public static void main(String [] args) throws InterruptedException {
        ThreadJoinTest t1 = new ThreadJoinTest("小明");
        ThreadJoinTest t2 = new ThreadJoinTest("小東");
        t1.start();
        /**join方法可以傳遞參數,join(10)表示main線程會等待t1線程10毫秒,10毫秒過去後,
         * main線程和t1線程之間執行順序由串行執行變爲普通的並行執行
         */
        t1.join(10);
        t2.start();
    }

}
class ThreadJoinTest extends Thread{
    public ThreadJoinTest(String name){
        super(name);
    }
    @Override
    public void run(){
        for(int i=0;i<1000;i++){
            System.out.println(this.getName() + ":" + i);
        }
    }
}

上面代碼結果是:程序執行前面10毫秒內打印的都是小明線程,10毫秒後,小明和小東程序交替打印。

所以,join方法中如果傳入參數,則表示這樣的意思:如果A線程中掉用B線程的join(10),則表示A線程會等待B線程執行10毫秒,10毫秒過後,A、B線程並行執行。需要注意的是,jdk規定,join(0)的意思不是A線程等待B線程0秒,而是A線程等待B線程無限時間,直到B線程執行完畢,即join(0)等價於join()。

2. join與start調用順序問題

上面的討論大概知道了join的作用了,那麼,如果 join在start前調用,會出現什麼後果呢?先看下面的測試結果

public class JoinTest {
    public static void main(String [] args) throws InterruptedException {
        ThreadJoinTest t1 = new ThreadJoinTest("小明");
        ThreadJoinTest t2 = new ThreadJoinTest("小東");
        /**join方法可以在start方法前調用時,並不能起到同步的作用
         */
        t1.join();
        t1.start();
        //Thread.yield();
        t2.start();
    }

}
class ThreadJoinTest extends Thread{
    public ThreadJoinTest(String name){
        super(name);
    }
    @Override
    public void run(){
        for(int i=0;i<1000;i++){
            System.out.println(this.getName() + ":" + i);
        }
    }
}

上面代碼執行結果是:小明和小東線程交替打印。

所以得到以下結論:join方法必須在線程start方法調用之後調用纔有意義。這個也很容易理解:如果一個線程都沒有start,那它也就無法同步了。

3. join方法實現原理

有了上面的例子,我們大概知道join方法的作用了,那麼,join方法實現的原理是什麼呢?

其實,join方法是通過調用線程的wait方法來達到同步的目的的。例如,A線程中調用了B線程的join方法,則相當於A線程調用了B線程的wait方法,在調用了B線程的wait方法後,A線程就會進入阻塞狀態,具體看下面的源碼:

public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

從源碼中可以看到:join方法的原理就是調用相應線程的wait方法進行等待操作的,例如A線程中調用了B線程的join方法,則相當於在A線程中調用了B線程的wait方法,當B線程執行完(或者到達等待時間),B線程會自動調用自身的notifyAll方法喚醒A線程,從而達到同步的目的。

二十七、線程安全

多個線程不管以何種方式訪問某個類,並且在主調代碼中不需要進行同步,都能表現正確的行爲。

​ 線程安全有以下幾種實現方式:

1. 不可變

不可變(Immutable)的對象一定是線程安全的,不需要再採取任何的線程安全保障措施。只要一個不可變的對象被正確地構建出來,永遠也不會看到它在多個線程之中處於不一致的狀態。多線程環境下,應當儘量使對象成爲不可變,來滿足線程安全。

​ 不可變的類型:

  • final 關鍵字修飾的基本數據類型
  • String
  • 枚舉類型
  • Number 部分子類,如 Long 和 Double 等數值包裝類型,BigInteger 和 BigDecimal 等大數據類型。但同爲 Number 的原子類 AtomicInteger 和 AtomicLong 則是可變的。

對於集合類型,可以使用 Collections.unmodifiableXXX() 方法來獲取一個不可變的集合。

public class ImmutableExample {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
        unmodifiableMap.put("a", 1);
    }
}

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
    at ImmutableExample.main(ImmutableExample.java:9)

Collections.unmodifiableXXX() 先對原始的集合進行拷貝,需要對集合進行修改的方法都直接拋出異常。

public V put(K key, V value) {
    throw new UnsupportedOperationException();
}

2. 互斥同步
​ synchronized 和 ReentrantLock。

3. 非阻塞同步

互斥同步最主要的問題就是線程阻塞和喚醒所帶來的性能問題,因此這種同步也稱爲阻塞同步。

互斥同步屬於一種悲觀的併發策略,總是認爲只要不去做正確的同步措施,那就肯定會出現問題。無論共享數據是否真的會出現競爭,它都要進行加鎖(這裏討論的是概念模型,實際上虛擬機會優化掉很大一部分不必要的加鎖)、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作。

①. CAS

隨着硬件指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略:先進行操作,如果沒有其它線程爭用共享數據,那操作就成功了,否則採取補償措施(不斷地重試,直到成功爲止)。這種樂觀的併發策略的許多實現都不需要將線程阻塞,因此這種同步操作稱爲非阻塞同步。

樂觀鎖需要操作和衝突檢測這兩個步驟具備原子性,這裏就不能再使用互斥同步來保證了,只能靠硬件來完成。硬件支持的原子性操作最典型的是:比較並交換(Compare-and-Swap,CAS)。CAS 指令需要有 3 個操作數,分別是內存地址 V、舊的預期值 A 和新值 B。當執行操作時,只有當 V 的值等於 A,纔將 V 的值更新爲 B。

②. AtomicInteger

J.U.C 包裏面的整數原子類 AtomicInteger 的方法調用了 Unsafe 類的 CAS 操作。

以下代碼使用了 AtomicInteger 執行了自增的操作。

private AtomicInteger cnt = new AtomicInteger();

public void add() {
    cnt.incrementAndGet();
}

以下代碼是 incrementAndGet() 的源碼,它調用了 Unsafe 的 getAndAddInt() 。

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代碼是 getAndAddInt() 源碼,var1 指示對象內存地址,var2 指示該字段相對對象內存地址的偏移,var4 指示操作需要加的數值,這裏爲 1。通過 getIntVolatile(var1, var2) 得到舊的預期值,通過調用 compareAndSwapInt() 來進行 CAS 比較,如果該字段內存地址中的值等於 var5,那麼就更新內存地址爲 var1+var2 的變量爲 var5+var4。

可以看到 getAndAddInt() 在一個循環中進行,發生衝突的做法是不斷的進行重試。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

③. ABA

如果一個變量初次讀取的時候是 A 值,它的值被改成了 B,後來又被改回爲 A,那 CAS 操作就會誤認爲它從來沒有被改變過。

J.U.C 包提供了一個帶有標記的原子引用類 AtomicStampedReference 來解決這個問題,它可以通過控制變量值的版本來保證 CAS 的正確性。大部分情況下 ABA 問題不會影響程序併發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。

4. 無同步方案
​ 要保證線程安全,並不是一定就要進行同步。如果一個方法本來就不涉及共享數據,那它自然就無須任何同步措施去保證正確性。

①. 棧封閉

多個線程訪問同一個方法的局部變量時,不會出現線程安全問題,因爲局部變量存儲在虛擬機棧中,屬於線程私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}

public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}
100
100

②. 線程本地存儲(Thread Local Storage)

如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行。如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。

符合這種特點的應用並不少見,大部分使用消費隊列的架構模式(如“生產者-消費者”模式)都會將產品的消費過程儘量在一個線程中消費完。其中最重要的一個應用實例就是經典 Web 交互模型中的“一個請求對應一個服務器線程”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多 Web 服務端應用都可以使用線程本地存儲來解決線程安全問題。

可以使用 java.lang.ThreadLocal 類來實現線程本地存儲功能。

對於以下代碼,thread1 中設置 threadLocal 爲 1,而 thread2 設置 threadLocal 爲 2。過了一段時間之後,thread1 讀取 threadLocal 依然是 1,不受 thread2 的影響。

public class ThreadLocalExample {
    public static void main(String[] args) {
        ThreadLocal threadLocal = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal.set(1);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(threadLocal.get());
            threadLocal.remove();
        });
        Thread thread2 = new Thread(() -> {
            threadLocal.set(2);
            threadLocal.remove();
        });
        thread1.start();
        thread2.start();
    }
}
1

爲了理解 ThreadLocal,先看以下代碼:

public class ThreadLocalExample1 {
    public static void main(String[] args) {
        ThreadLocal threadLocal1 = new ThreadLocal();
        ThreadLocal threadLocal2 = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal1.set(1);
            threadLocal2.set(1);
        });
        Thread thread2 = new Thread(() -> {
            threadLocal1.set(2);
            threadLocal2.set(2);
        });
        thread1.start();
        thread2.start();
    }
}

每個 Thread 都有一個 ThreadLocal.ThreadLocalMap 對象。

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

當調用一個 ThreadLocal 的 set(T value) 方法時,先得到當前線程的 ThreadLocalMap 對象,然後將 ThreadLocal->value 鍵值對插入到該 Map 中。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

get() 方法類似。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

ThreadLocal 從理論上講並不是用來解決多線程併發問題的,因爲根本不存在多線程競爭。

在一些場景 (尤其是使用線程池) 下,由於 ThreadLocal.ThreadLocalMap 的底層數據結構導致 ThreadLocal 有內存泄漏的情況,應該儘可能在每次使用 ThreadLocal 後手動調用 remove(),以避免出現 ThreadLocal 經典的內存泄漏甚至是造成自身業務混亂的風險。

③. 可重入代碼(Reentrant Code)

這種代碼也叫做純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程序不會出現任何錯誤。

可重入代碼有一些共同的特徵,例如不依賴於存儲在堆上的數據和公用的系統資源、用到的狀態量都由參數中傳入、不調用非可重入的方法等。

二十八、多線程開發良好的實踐

  • 給線程起個有意義的名字,這樣可以方便找 Bug。
  • 縮小同步範圍,從而減少鎖爭用。例如對於 synchronized,應該儘量使用同步塊而不是同步方法。
  • 多用同步工具少用 wait() 和 notify()。首先,CountDownLatch, CyclicBarrier, Semaphore 和 Exchanger 這些同步類簡化了編碼操作,而用 wait() 和 notify() 很難實現複雜控制流;其次,這些同步類是由最好的企業編寫和維護,在後續的 JDK 中還會不斷優化和完善。
  • 使用 BlockingQueue 實現生產者消費者問題。
  • 多用併發集合少用同步集合,例如應該使用 ConcurrentHashMap 而不是 Hashtable。
  • 使用本地變量和不可變類來保證線程安全。
  • 使用線程池而不是直接創建線程,這是因爲創建線程代價很高,線程池可以有效地利用有限的線程來啓動任務。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章