Java 多執行緒 | 併發知識問答總結

BWH_Steven發表於2021-03-16

寫在最前面

這個專案是從20年末就立好的 flag,經過幾年的學習,回過頭再去看很多知識點又有新的理解。所以趁著找實習的準備,結合以前的學習儲備,建立一個主要針對應屆生和初學者的 Java 開源知識專案,專注 Java 後端面試題 + 解析 + 重點知識詳解 + 精選文章的開源專案,希望它能伴隨你我一直進步!

說明:此專案內容參考了諸多博主(已註明出處),資料,N本書籍,以及結合自己理解,重新繪圖,重新組織語言等等所制。個人之力綿薄,或有不足之處,在所難免,但更新/完善會一直進行。大家的每一個 Star 都是對我的鼓勵 !希望大家能喜歡。

注:所有涉及圖片未使用網路圖床,文章等均開源提供給大家。

專案名: Java-Ideal-Interview

Github 地址: Java-Ideal-Interview - Github

Gitee 地址:Java-Ideal-Interview - Gitee(碼雲)

持續更新中,線上閱讀將會在後期提供,若認為 Gitee 或 Github 閱讀不便,可克隆到本地配合 Typora 等編輯器舒適閱讀

若 Github 克隆速度過慢,可選擇使用國內 Gitee 倉庫

一 多執行緒及併發知識問答總結

1. 基礎

1.1 什麼是程式?什麼是執行緒?(概念層面)

程式】是一段程式的執行過程,是系統執行程式的基本單位,也是系統進行資源分配和呼叫的獨立單位。

  • 即系統執行一個程式即是一個程式, 從建立,執行,到消亡的作用。
  • 多程式:在同一個時間段內可以執行多個任務,提高了 CPU 的使用率。

執行緒】是一個比程式更小的執行單位,是程式的一個執行單元,一個程式執行的過程中可以產生多個執行緒。

  • 多執行緒:一個應用程式有多條執行路徑,提高應用程式的使用率。

1.1.1 執行緒和程式的關係和區別?

聯絡:執行緒是程式劃分成更小的執行單位,即一個程式可以有多個執行緒。從 JVM 角度來看,多個執行緒共享程式的堆和方法區(JDK 1.8後變為元空間),但是每個執行緒擁有自己私有的程式計數器、虛擬機器棧、本地方法棧。

區別:各程式是獨立的存在的,而同一程式中的執行緒很可能會互相影響。執行緒切換時,要比程式開銷負擔小很多(所以被稱為輕量級程式),但是不利於資源的管理和保護,而程式則是相反的,開銷雖然大,卻利於管理保護。

說明:關於程式計數器、虛擬機器棧、本地方法棧等內容,會在 JVM 篇詳細講解。

1.2 什麼是序列,併發,並行?

序列:多個任務依次執行

  • 例子:汽車油不夠了,我給車加滿油,就去接你。

併發:同一時間段內,多個程式同時都在執行。

  • 例子:你給姐姐發微信,讓她幫你去驛站拿個快遞,然後又打電話給你媽,也讓她幫你去拿快遞。

並行:同一時間點,多個程式同時都在執行。

  • 例子:這是你的黃燜雞飯,這是我的餃子拼盤,我們兩一起吃飯吧。

理解推薦知乎此文 併發與並行的區別是什麼?

1.3 使用多執行緒/併發程式設計的原因?

一句話解釋:為了提高資源利用率,提高程式執行效率及速度

注:下述內容引用自 GitHub@JavaGuide ,Guide哥這個答案我感覺真的很精練了,尊重原創,注意出處喔~

先從總體上來說:

  • 從計算機底層來說: 執行緒可以比作是輕量級的程式,是程式執行的最小單位,執行緒間的切換和排程的成本遠遠小於程式。另外,多核 CPU 時代意味著多個執行緒可以同時執行,這減少了執行緒上下文切換的開銷。
  • 從當代網際網路發展趨勢來說: 現在的系統動不動就要求百萬級甚至千萬級的併發量,而多執行緒併發程式設計正是開發高併發系統的基礎,利用好多執行緒機制可以大大提高系統整體的併發能力以及效能。

再深入到計算機底層來探討:

  • 單核時代: 在單核時代多執行緒主要是為了提高 CPU 和 IO 裝置的綜合利用率。舉個例子:當只有一個執行緒的時候會導致 CPU 計算時,IO 裝置空閒;進行 IO 操作時,CPU 空閒。我們可以簡單地說這兩者的利用率目前都是 50%左右。但是當有兩個執行緒的時候就不一樣了,當一個執行緒執行 CPU 計算時,另外一個執行緒可以進行 IO 操作,這樣兩個的利用率就可以在理想情況下達到 100%了。
  • 多核時代: 多核時代多執行緒主要是為了提高 CPU 利用率。舉個例子:假如我們要計算一個複雜的任務,我們只用一個執行緒的話,CPU 只會一個 CPU 核心被利用到,而建立多個執行緒就可以讓多個 CPU 核心被利用到,這樣就提高了 CPU 的利用率。

1.4 多執行緒/併發程式設計帶來的問題

一句話解釋:可能會帶來死鎖,執行緒不安全,記憶體洩露等問題

  • 併發會導致資源共享和競爭,從而改變程式的執行速度,同時也會失去原有的時序關係

  • 如果併發程式不按照特定的規則和方法進行資源共享和競爭,則其執行結果將不可避免失去封閉性和可再現性

    • 失去封閉性:資源被共享了,可能會受到其他程式控制邏輯的影響,例如一個程式寫到儲存器中的資料可能被另一個程式修改
    • 失去可再現性:受到其他的因素干擾,初始條件一致,結果可能不一致

1.4.1 什麼是死鎖?

死鎖是指兩個或兩個以上的程式在執行過程中,由於競爭資源或者由於彼此通訊而造成的一種阻塞狀態。由於執行緒被無限期的阻塞,所以程式不可能被正常終止。

  • 如:執行緒 1 持有資源 1 ,執行緒 2 持有資源 2,但是兩個執行緒都想要請求對方的資源,但是雙方又互不釋放已有的資源,若無外力介入的情況下,就會陷入互相等待的死鎖狀態。

模擬死鎖狀態程式碼:

public class DeadLockDemo {
    /**
     * 資源 1
     */
    private static Object resource1 = new Object();
    /**
     * /資源 2
     */
    private static Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            // 【執行緒1】獲取到【資源1】的監視器鎖
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "獲取到資源 1");
                try {
                    // 休眠1s讓執行緒2獲取到資源2
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "等待獲取資源 2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "獲取到資源 2");
                }
            }
        }, "執行緒1").start();

        new Thread(() -> {
            // 【執行緒2】獲取到【資源2】的監視器鎖
            synchronized (resource2){
                System.out.println(Thread.currentThread() + "獲取到資源 2");
                try {
                    // 休眠1s讓執行緒1開始試著獲取資源2
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "等待獲取資源 1");
                synchronized (resource1){
                    System.out.println(Thread.currentThread() + "獲取到資源 1");
                }
            }
        },"執行緒2").start();
    }
}

執行結果:

Thread[執行緒1,5,main]獲取到資源 1
Thread[執行緒2,5,main]獲取到資源 2
Thread[執行緒1,5,main]等待獲取資源 2
Thread[執行緒2,5,main]等待獲取資源 1
// ... 進入死鎖狀態

上述程式碼中,因為【資源1】和【資源2】分別被【執行緒1】和【執行緒2】持有,雙方都想要對方的資源,因此陷入互相等待的狀態,即發生了死鎖現象。

如果想要解決這種狀態,一種方法,就是破壞他們之間的迴圈等待條件(下一個問題會講)

程式碼如下:

public class DeadLockDemo {
    /**
     * 資源 1
     */
    private static Object resource1 = new Object();
    /**
     * /資源 2
     */
    private static Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            // 【執行緒1】獲取到【資源1】的監視器鎖
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "獲取到資源 1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "等待獲取資源 2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "獲取到資源 2");
                }
            }
        }, "執行緒1").start();

        new Thread(() -> {
            // 【執行緒2】獲取到【資源1】的監視器鎖
            synchronized (resource1){
                System.out.println(Thread.currentThread() + "獲取到資源 1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "等待獲取資源 2");
                synchronized (resource2){
                    System.out.println(Thread.currentThread() + "獲取到資源 2");
                }
            }
        },"執行緒2").start();
    }
}

執行結果:

Thread[執行緒1,5,main]獲取到資源 1
Thread[執行緒1,5,main]等待獲取資源 2
Thread[執行緒1,5,main]獲取到資源 2
Thread[執行緒2,5,main]獲取到資源 1
Thread[執行緒2,5,main]等待獲取資源 2
Thread[執行緒2,5,main]獲取到資源 2

當【執行緒1】持有【資源1】後,【執行緒2】也去請求持有【執行緒1】,此時【資源1】被佔據了,所它只能等待,接著【執行緒1】去請求持有【執行緒2】,也可以獲取到,然後【執行緒1】釋放了對於【資源1】和【資源2】的持有狀態,【執行緒2】就可以去執行了。

1.4.1.1 產生死鎖的必備條件

  • 互斥條件:一個資源任意時刻只能被一個執行緒佔用。
  • 請求與保持條件:一個程式請求別的資源時,陷入阻塞狀態,但對自身持有的資源保持不放。
  • 不剝奪條件:執行緒已經獲得的資源只有自己使用完畢後釋放,不能被其他執行緒強行剝奪。
  • 迴圈等待條件:多個程式之間形成一種迴圈等待的資源關係。
    • 例如上述程式碼中的關係

1.4.2 什麼是執行緒安全?

執行緒安全的定義,就是多個執行緒去執行某一個類,這個類始終能表現出一種正常的行為。

例如:Spring 中 bean 預設是單例的,在其成員位置,如果定義一個有狀態(即需要進行資料儲存)的變數,在多執行緒狀況下,就是不安全的,如下程式碼肯定是不安全的:

public class AccountDaoImpl implements AccountDao {
	//定義一個類成員
    private int i = 1;
    
    public void addAccount() {
        System.out.println("新增使用者成功!");
        System.out.println(i);
        i++;
    }
}

1.4.2.2 如何思考或解決執行緒安全問題呢?

大部分執行緒安全問題,很少會自己顯式的去處理,因為大部分都有框架在背後操作,比如 SpringMVC、Druid 等等

比較簡單的判斷方式就是看看有沒有多個執行緒同時訪問同一個共享的變數。

可以考慮的方向如下:

  • 保證原子性:atomic 包下的類
  • 可見性:volatile 關鍵字
  • 執行緒的控制:CountDownLatch/Semaphore
  • 集合:java.util.concurrent 包下的類
  • synchronized 後還可考慮 lock 包

1.5 執行緒的狀態有哪幾種?

  • 新建狀態(NEW):執行緒被構建出來,進入新建狀態,呼叫 start() 方法可進入就緒態(READY)。

  • 執行狀態(RUNNABLE):執行狀態,作業系統隱藏了 JVM 中的就緒態(READY) 和 執行中狀態。(RUNNING) ,因此這裡籠統的稱兩者為,執行狀態(RUNNABLE)

    • 就緒態(READY):新建狀態(NEW)呼叫 start 方法進入。
    • 執行中狀態(RUNNING):就緒態獲取 CPU 的時間片後進入。
  • 阻塞狀態(BLOCKED):執行緒同步呼叫方法時,在沒有獲取到鎖的情況下進入阻塞狀態。

  • 等待狀態(WAITING):執行緒執行 wait() 方法後,進入等待狀態,進入此狀態後,表示當前執行緒需要等待其他執行緒做出一些特定動作(通知或者中斷)才可以回到執行狀態。

  • 超時等待狀態(TIME_WAITING):與等待狀態基本一致,不過可在指定的時間自行返回執行狀態。可通過 sleep(long millis) 或 wait(long millis) 進入此狀態

  • 終止狀態(TERMINATED):表示當前執行緒已經執行完畢了。

1.5.1 呼叫 start() 方法時會執行 run() 方法,為什麼不直接呼叫 run() 方法?

start() 方法用來啟動新建立的執行緒,使得執行緒從新建態進入就緒態,等待分配到時間片後就可以開始執行了。並且 start() 內部呼叫了 run() 方法。如果直接呼叫 run() 方法,它只會被當做一個普通方法,在原來的執行緒中去呼叫,沒有新的執行緒被啟動。

1.5.2 sleep() 方法和 wait() 方法的區別和共同點

  • 兩者都可以暫停執行緒的執行。
  • sleep() 方法沒有釋放鎖,而 wait() 方法釋放了鎖。
  • sleep() 方法一般用來暫停執行某個執行緒,wait() 方法一般用於執行緒間的通訊
  • sleep() 方法與 wait(long timeout) 類似,在方法執行完後,執行緒會自動甦醒。而 wait() 方法執行完後,需要別的執行緒呼叫同一個物件上的 notify() 或 notifyAll() 方法

1.6 什麼是上下文切換?

在單核處理器的時代,作業系統就已經可以進行多執行緒任務的處理了(多核 CPU 中,一個 CPU 的核心也只能被一個執行緒使用),處理器給每個執行緒分配時間片,執行緒就可以在自己的時間片沒有耗盡的前提下執行(因為時間片時間只有幾十毫秒左右,很短,所以看起來像同時進行)。在此之後,就會被剝奪處理器的使用權而被暫停執行,也就是切出的概念,反之下一個執行緒被選中佔用處理器開始或者繼續執行就是切入。而在切出和切入的過程中,當前任務會在切換到另一個任務之前儲存自己的狀態,以便下次可以再次切回這個任務的狀態。這個任務從儲存到恢復的過程就是一次上下文切換。

1.7 五種實現多執行緒的方式

1.7.1 繼承 Thread 類

  • 自定義 MyThread 類,繼承 Thread 類

  • 重寫 run()方法

public class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(getName() + ": " + i);
        }
    }
}

建立兩個執行緒,設定名字後啟動

public class ThreadTest {
    public static void main(String[] args) {
        // 建立兩個執行緒
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();
        // 設定執行緒名字
        thread1.setName("執行緒-1");
        thread2.setName("執行緒-2");
        // 開啟執行緒
        thread1.start();
        thread2.start();
    }
}

1.7.2 實現 Runnable介面

  • 自定義類 MyuRunnable 實現 Runnable 介面
  • 重寫 run() 方法
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
        }
    }
}
  • 建立 MyRunable 類的物件
  • 建立Thread類的物件,並把 myRunnable 物件作為構造引數傳遞
public class RunnableTest {
    public static void main(String[] args) {
        // 建立 MyRunnable 類的物件
        MyRunnable myRunnable = new MyRunnable();
        // 建立 Thread 類的物件,並把 myRunnable 物件作為構造引數傳遞
        Thread thread1 = new Thread(myRunnable, "執行緒-1");
        Thread thread2 = new Thread(myRunnable, "執行緒-2");
        // 開啟執行緒
        thread1.start();
        thread2.start();
    }
}

實現介面方式的好處

  • 可以避免由於Java單繼承帶來的侷限性

  • 適合多個相同程式的程式碼去處理同一個資源的情況,把執行緒同程式的程式碼,資料有效分離,較好的體現了物件導向的設計思想

如何理解------可以避免由於Java單繼承帶來的侷限性

  • 比如說,某個類已經有父類了,而這個類想實現多執行緒,但是這個時候它已經不能直接繼承 Thread 類了 (介面可以多實現 implements,但是繼承 extends 只能單繼承) ,它的父類也不想繼承 Thread 因為不需要實現多執行緒

1.7.3 實現 Callable 介面

注:Callable 相比較實現Runnable 介面的實現,方法可以有返回值,並且丟擲異常。

public class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        int result = 0;
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
            result += i;
        }
        return result;
    }
}

測試可以取出返回值

public class CallableTest {
    public static void main(String[] args) {
        // 使用 FutureTask 接收運算結果
        FutureTask<Integer> futureTask1 = new FutureTask<>(new MyCallable());
        FutureTask<Integer> futureTask2 = new FutureTask<>(new MyCallable());
        // 啟動執行緒
        new Thread(futureTask1).start();
        new Thread(futureTask2).start();
        try {
            // 取出運算結果
            Integer integer1 = futureTask1.get();
            Integer integer2 = futureTask2.get();
            // 列印結果
            System.out.println(integer1);
            System.out.println(integer2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

1.7.4 使用 Executors 建立執行緒池

注:執行緒池是一個重點問題,所以其中強調的一些方法和內容,都會在後面單獨設定問題描述。這裡只做最基本的實現

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
        }
    }
}

使用 execute 提交 不需要返回值的任務,下面會強調這個問題

public class ExecutorsTest {
    public static void main(String[] args) {
        // 通過 Executors 建立執行緒池
        ExecutorService executorService = Executors.newFixedThreadPool(8);
        MyRunnable myRunnable = new MyRunnable();
        // 啟動 5 個執行緒
        for (int i = 0; i < 5; i++){
            executorService.execute(myRunnable);
        }
        executorService.shutdown();
    }
}

1.7.5 使用 ThreadPoolExecutor 建立執行緒池(推薦)

public class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        int result = 0;
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
            result += i;
        }
        return result;
    }
}

使用 submit 提交需要返回值的任務

public class ThreadPoolExecutorTest {
    public static void main(String[] args) {
        // 使用 ThreadPoolExecutor 建立執行緒池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(8,
                16, 100, TimeUnit.MINUTES, new LinkedBlockingDeque<Runnable>(10));
        try {
            // 提交任務
            Future<?> future = threadPoolExecutor.submit(new MyCallable());
            // 拿到返回值
            System.out.println(future.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

1.8 Java 使用的是哪種執行緒排程模型

答:Java使用的是搶佔式排程模型

假如我們的計算機只有一個 CPU,那麼 CPU 在某一個時刻只能執行一條指令,執行緒只有得到 CPU時間片,也就是使用權,才可以執行指令。那麼Java是如何對執行緒進行呼叫的呢?

執行緒有兩種排程模型:

分時排程模型 :所有執行緒輪流使用 CPU 的使用權,平均分配每個執行緒佔用 CPU 的時間片

搶佔式排程模型 :優先讓優先順序高的執行緒使用 CPU,如果執行緒的優先順序相同,那麼會隨機選擇一個,優先順序高的執行緒獲取的 CPU 時間片相對多一些。

相關方法:

//返回執行緒物件的優先順序
public final int getPriority()
//更改執行緒的優先順序
public final void setPriority(int newPriority)
  • 執行緒預設優先順序是5。

  • 執行緒優先順序的範圍是:1-10。

  • 執行緒優先順序高僅僅表示執行緒獲取的 CPU時間片的機率高,但是要在次數比較多,或者多次執行的時候才能看到比較好的效果。

1.9 等待喚醒機制(生產者消費者問題)

在多執行緒的入門案例中,應該常常會使用電影院多個視窗賣票等案例,來演示多執行緒問題,但它其實還是有一定侷限的,即我們所假定的票數是一定的,但是實際生活中,往往是一種供需共存的狀態,例如去買早點,當消費者買走一些後,而作為生產者的店家就會補充一些商品,為了研究這一種場景,我們所要學習的就是Java的等待喚醒機制

生產者消費者問題(英語:Producer-consumer problem),也稱有限緩衝問題(英語:Bounded-buffer problem),是一個多程式同步問題的經典案例。該問題描述了共享固定大小緩衝區的兩個程式——即所謂的“生產者”和“消費者”——在實際執行時會發生的問題。生產者的主要作用是生成一定量的資料放到緩衝區中,然後重複此過程。與此同時,消費者也在緩衝區消耗這些資料。該問題的關鍵就是要保證生產者不會在緩衝區滿時加入資料,消費者也不會在緩衝區中空時消耗資料。

我們用通俗一點的話來解釋一下這個問題

Java使用的是搶佔式排程模型

  • A:如果消費者先搶到了CPU的執行權,它就會去消費資料,但是現在的資料是預設值,如果沒有意義,應該等資料有意義再消費。就好比買家進了店鋪早點卻還沒有做出來,買家就只能等做出來了再消費
  • B:如果生產者先搶到CPU的執行權,它就回去生產資料,但是,當它產生完資料後,還繼續擁有執行權,它還能繼續產生資料,這是不合理的,你應該等待消費者將資料消費掉,再進行生產。 這又好比,店鋪不能無止境的做早點,賣一些,再做,避免虧本

梳理思路

  • A:生產者 —— 先看是否有資料,有就等待,沒有就生產,生產完之後通知(喚醒)消費者來消費資料

  • B:消費者 —— 先看是否有資料,有就消費,沒有就等待,通知(喚醒)生產者生產資料

    • 喚醒——讓執行緒池中的執行緒具備執行資格

Object類提供了三個方法:

//等待
wait()
//喚醒單個執行緒
notify()
//喚醒所有執行緒
notifyAll()

注意:這三個方法都必須在同步程式碼塊中執行 (例如synchronized塊),同時在使用時必須標明所屬鎖,這樣才可以得出這些方法操作的到底是哪個鎖上的執行緒

下面我們寫一段簡單的程式碼來演示一下:

Student:學生類——消費的資料

public class Student {
    private String name;
    private int age;
    private boolean flag; // 預設情況是沒有資料(false),如果是true,說明有資料
    // 請自行補充 無參構造 get set toString 方法
}

Producer:生產者類——當沒有資料後,生產資料

public class Producer implements Runnable {
    private Student student;
    private int n = 0;

    public Producer(Student student) {
        this.student = student;
    }

    @Override
    public void run() {
        while (true) {
            synchronized (student) {
                // 判斷有沒有資料
                // 如果有資料,就wait
                if (student.isFlag()) {
                    try {
                        // t1等待,釋放鎖
                        student.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                // 這裡只是根據奇偶,使得每一次生成的資料不一樣,只生成一種也可
                if (n % 2 == 0) {
                    student.setName("張三");
                    student.setAge(22);
                } else {
                    student.setName("BWH");
                    student.setAge(25);
                }
                System.out.println(Thread.currentThread().getName() + " 生產了資料:" +student);
                n++;
                // 現在資料就已經存在了,修改標記
                student.setFlag(true);

                // 喚醒執行緒
                // 喚醒t2,喚醒並不表示你立馬可以執行,必須還得搶CPU的執行權
                student.notify();
            }
        }
    }
}

Consumer:消費者類——當存在資料時,消費資料

public class Consumer implements Runnable {
    private Student student;

    public Consumer(Student student) {
        this.student = student;
    }

    @Override
    public void run() {
        while (true) {
            synchronized (student) {
                // 如果沒有資料,就等待
                if (!student.isFlag()) {
                    try {
                        student.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() + " 消費了資料:" + student);
                // 修改標記
                student.setFlag(false);
                // 醒執行緒t1
                student.notify();
            }
        }
    }
}

測試一下:

public class StudentTest {
    public static void main(String[] args) {
        Student student = new Student();
		
        Producer producer = new Producer(student);
        Consumer consumer = new Consumer(student);

        Thread thread1 = new Thread(producer);
        Thread thread2 = new Thread(consumer);

        thread1.start();
        thread2.start();

    }
}

執行結果:

Thread-0 生產了資料:Student{name='張三', age=22, flag=false}
Thread-1 消費了資料:Student{name='張三', age=22, flag=true}
Thread-0 生產了資料:Student{name='BWH', age=25, flag=false}
Thread-1 消費了資料:Student{name='BWH', age=25, flag=true}
......

注:這裡只是給出了最簡單的一種方式,即生產一個資料,就去通知消費者去消費,消費結束後,消費者會通知生產者去生產資料。而且程式碼其實還可以優化,比如將鎖和通知的操作放到 Student 中去做,兩個 run() 方法中就會有很大的簡化。

2. 進階

2.1 CAS 相關

2.1.1 什麼是 CAS

2.1.1.1 無鎖的思想(悲觀和樂觀策略)

說道 CAS 就不得不提一下無鎖的思想,因為我們最常見的併發控制手段,其實就是加鎖,鎖就可以實現當前只有一個鎖可以訪問臨界區的資源,執行緒自然也安全。這其實就是一種悲觀策略,即它總是認為每次對臨界區的訪問都會發生衝突,所以只要有執行緒在訪問資源,其他執行緒都會被阻塞等待。

那樂觀鎖呢,就是它認為執行緒對資源訪問是不會有衝突的,所有執行緒都不需要等待,如果有衝突,就會用 CAS 技術鑑別衝突,如果衝突繼續發生,就重試直到沒有衝突。

**2.1.1.2 CAS 的概念和理解 **

CAS的全稱是 Compare-and-Swap,也就是比較並交換。

它包含了三個引數:V ,A, B

  • V:記憶體值
  • A:當前值(舊值)
  • B:要修改成的新值

CAS 在執行時,只有 V 和 A 的值相等的情況下,才會將 V 的值設定為 B,如果 V 和 A 不同,這說明可能其他執行緒已經做了更新操作,那麼當前執行緒值就什麼也不做,最後 CAS 返回的是 V 的值。

在多執行緒的的情況下,多個執行緒使用 CAS 操作同一個變數的時候,只有一個會成功,其他失敗的執行緒,就會繼續重試。

正是這種機制,使得 CAS 在沒有鎖的情況下,也能實現安全,同時這種機制在很多情況下,也會顯得比較高效。

Java中提供了一系列應用CAS操作的類,這些類位於 java.util.concurrent.atomic 包下,其中例如 AtomicInteger,該類可以看做是實現了 CA S操作的 Integer,累加操作的時候,就可以使用它就好了。

2.1.2 CAS 帶來的 ABA 問題

ABA 問題,其實很好理解,比如【執行緒1】讀取當前數的值為 66,但是 【執行緒2】將當前數的值修改為 666,接著 【執行緒3】又將當前數的值修改回 66。對於 【執行緒1】而言,它只看到 當前值 66 和 記憶體值 66 是一致的,根據其機制,就會允許修改。因為它眼中,其實這個值就沒有修改過,但是實際其已經被 【執行緒2】和 【執行緒3】修改過了,這也就是 ABA 問題。

解決方案:使用 AtomicStampedReference ,簡單的說,它就為我們提供了一個版本機制,比對就不單純看記憶體值,還要考慮版本號。

關於 Atomic 我們會在後面提到這個問題。

2.1.3 JDK 1.8 為什麼推薦使用 LongAdder 物件

13、【參考】volatile 解決多執行緒記憶體不可見問題。對於一寫多讀,是可以解決變數同步問題,但是如果多寫,同樣無法解決執行緒安全問題。如果是 count++操作,使用如下類實現: AtomicInteger count = new AtomicInteger(); count.addAndGet(1); 如果是 JDK8,推 薦使用 LongAdder 物件,比 AtomicLong 效能更好(減少樂觀鎖的重試次數)。

《阿里巴巴開發手冊》第 1 章 1.6 併發控制 第13點中提到:如果是 JDK8,推 薦使用 LongAdder 物件,比 AtomicLong 效能更好(減少樂觀鎖的重試次數)。

AtomicLong 做累加操作的時候,就是多個執行緒在操作同一個資源,只有一個執行緒可以成功,失敗的執行緒就會自旋重試,這個自旋就會成為效能的一個問題。

LongAdder 將資源進行了一個分散,將其分散到陣列後,之後每個執行緒只需要對自己所屬陣列的變數值進行操作,失敗次數就會降低。

2.2 synchronized 相關 ※

2.2.1 什麼是 synchronized ?

synchronized 是一種互斥鎖,它可以保證一次只能有一個執行緒進入被鎖住的方法/程式碼塊等。它解決了多個執行緒之間訪問資源的同步性問題。

2.2.2 synchronized 鎖能加在哪些位置上?

① 修飾例項方法:鎖的是物件例項

synchronized void method() {
	......
}

② 修飾靜態方法:鎖的是當前類的 Class 例項,會作用於此類的所有物件例項

synchronized void staic method() {
	......
}

③ 修飾程式碼塊:可鎖對也可鎖類,取決於引數是什麼

synchronized(this) {
	......
}

此處可聯想到單例模式中的雙重校驗鎖的原理以及 volatile 的問題

2.2.4 synchronized 鎖是重量級鎖嗎?

在 Java 早期版本,synchronized 屬於重量級鎖,效率也是低下的。這是因為,它加鎖是依賴作業系統的 mutex 相關指令實現的,而且Java 的執行緒是要對映到作業系統的原生執行緒上面的,即申請鎖資源都必須經過核心,執行系統呼叫。所以作業系統線上程切換的時候,都需要經過使用者到 --> 核心態的過程,而這個過程是比較的時間開銷是比較大的。

在 JDK 1.6 的版本後,官方對 synchronized 進行了一些優化,引入了偏向鎖和輕量級鎖等,在 JVM 層面就實現了加鎖的邏輯,不去依賴作業系統,所有就沒有使用者態和系統態切換的消耗。

2.2.4.1 可以介紹一下 JDK 1.6 之後 synchronized 的優化嗎?

JDK 1.6 以後引入偏向鎖、輕量級鎖、自旋鎖、鎖消除、鎖粗化等技術減少了鎖的開銷

所以鎖的狀態記錄一共有4種:無鎖、偏向鎖、輕量級鎖、重量級鎖。隨著競爭越來越激烈,鎖也會逐級升級,要注意:鎖只能升級,不能降級。

鎖升級的過程(包含了偏向鎖、輕量級鎖、重量級鎖的概念):

  • 很多情況下,鎖不僅不存在多執行緒競爭,而且一般都是同一個執行緒得到,如果每次都進行 CAS 操作,效能消耗就會比較嚴重,為了優化這種情況,引入了偏向鎖,即:當一個執行緒訪問物件並獲取到鎖的時候,會在物件頭的 Mark Word 裡儲存執行緒 ID ,以後只需要每次判斷執行緒ID 和 物件頭的 Mark Word 中儲存的執行緒ID 是否一致,一致則直接獲取鎖,就不需要 CAS 操作了。
  • 如果偏向鎖中的執行緒 ID 判斷不一致,則會通過 CAS 試著修改當前執行緒 ID,如果成功了,仍然可以獲取到鎖,但是如果失敗了,說明有競爭環境,此時升級為輕量級鎖。在輕量級鎖下,當前的執行緒會在棧幀下建立鎖記錄 Lock Record, Lock Record 會把 Mark Word 的資訊拷貝到剛才建立的鎖記錄中, 將鎖記錄 Owner 指標指向到加鎖的物件。當執行到同步程式碼時,CAS 試圖將 Mark Word 指向到執行緒棧幀的 Lock Record ,如果 CAS 修改成功了,就獲取到了輕量級鎖。
  • 如果修改失敗了,就會自旋,當自旋超過了一定次數,就會升級為重量級鎖。重量級鎖會使得當前除了擁有鎖的執行緒以外的執行緒全部阻塞

補充:物件在記憶體中的佈局分為三塊區域: 物件頭 + 示例資料 + 對齊填充

物件頭中包含兩部分: Mark Word + 型別指標(陣列物件, 還有一部分儲存陣列的長度)

  • Mark Word用於儲存物件自身的執行時資料,如HashCode, GC分代年齡,鎖狀態標誌, 執行緒持有的鎖, 偏向執行緒ID等等。

  • 型別指標指向物件的類後設資料, 虛擬機器通過這個指標確定該物件是哪個類的例項.

自旋鎖:是指當一個執行緒在獲取鎖的時候,如果鎖已經被其它執行緒獲取,那麼該執行緒將迴圈等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出迴圈。

鎖粗化:通常情況下,為了保證多執行緒間的有效併發,會要求每個執行緒持有鎖的時間儘可能短,但是大某些情況下,一個程式對同一個鎖不間斷、高頻地請求、同步與釋放,會消耗掉一定的系統資源,因為鎖的講求、同步與釋放本身會帶來效能損耗,這樣高頻的鎖請求就反而不利於系統效能的優化了,雖然單次同步操作的時間可能很短。鎖粗化就是告訴我們任何事情都有個度,有些情況下我們反而希望把很多次鎖的請求合併成一個請求,以降低短時間內大量鎖請求、同步、釋放帶來的效能損耗。

鎖消除:鎖消除是Java虛擬機器在JIT編譯期間,通過對執行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過鎖消除,可以節省毫無意義的請求鎖時間

部分引用參考:Java6及以上版本對synchronized的優化偏向鎖、輕量級鎖及重量級鎖synchronizedJava鎖消除和鎖粗化

2.2.4 synchronized 鎖的原理

JVM基於進入和退出Monitor物件來實現方法同步和程式碼塊同步, 但是兩者的實現細節是不一樣的。

首先是 synchronized 加在同步語句塊上

public class Demo1 {
	public void method() {
		synchronized (this) {
			System.out.println("synchronized");
		}
	}
}

執行反編譯 javap -c -s -v -l Demo1.class ,檢視相關位元組碼檔案

可以看到,程式碼塊同步通過使用 monitorenter monitorexit 指令實現的,monitorenter 為同步程式碼塊的開始位置,monitorexit 為結束位置。當執行到 monitorenter 指令的時候,執行緒就會去試圖獲取鎖,也就是物件監視器 monitor 的持有權。

  • 獲取鎖的時候,如果鎖的計數器為 0 則表示可以被獲取,獲取後再將鎖計數器設為 1
  • 釋放鎖的時候,再將鎖的計數器設為 0,表明鎖被釋放。
public class Demo2 {
	public synchronized void method() {
        System.out.println("synchronized");
    }
}

執行反編譯 javap -c -s -v -l Demo2.class ,檢視相關位元組碼檔案

同步方法中,沒有了 monitorenter monitorexit, 而使用了 ACC_SYNCHRONIZED 進行標識,代表此方法是一個同步方法

2.2.5 synchronized 和 ReentrantLock 的聯絡與區別

2.2.5.1 相同點

  • 兩者都是加鎖方式同步,而且都是阻塞式同步(即一個執行緒獲取到了物件鎖,進入同步塊,其他想要訪問此同步塊的執行緒都在外被阻塞等待)

  • 兩者都是可重入鎖,也就是說獲得到鎖後,當前執行緒還可以再次獲得該鎖,不可可重入鎖,會導致死鎖

2.2.5.2 不同點

  • 功能區別:synchronized 屬於 Java 關鍵字,是原生語法級別的互斥,依賴於 JVM,例如在 JDK 1.6 之後做的優化,都是在虛擬機器層面被優化的。而 ReentrantLock 是 JDK 1.5 版本之後出現的 API 層面(JDK 層面)的互斥鎖(需要 lock() 和 unlock() 方法配合 try/finally 語句塊來完成)你可以直接看到其原始碼
  • 靈活度,細粒度區別:synchronized 由編譯器保證加鎖和釋放,而 ReentrantLock 由自己來管理加鎖,以及釋放鎖,靈活,但也存在人為失誤的風險。 ReentrantLock 更加靈活,細粒度更高一些。
  • ReentrantLock 增加的幾個功能
    • 等待可中斷:持有鎖的執行緒長期不釋放的時候,正在等待的執行緒可以選擇放棄,轉去執行其他任務,可以通過 lock.lockInterruptibly() 來實現這個機制
    • 可構造公平鎖:synchronized 是非公平鎖,而 ReentrantLock 預設是非公平鎖,但是可以在構建的時候選擇建立非公平鎖(引數為 true)公平的意思就是指:先到先得。
    • synchronized 控制等待和喚醒需要結合加鎖物件的 wait() 、 notify() 和 notifyAll();ReentrantLock 控制等待和喚醒需要結合 Condition 的 await() 和 signal()、signalAll() 方法

**2.2.5.3 什麼時候用 ReentrantLock **

一般會在一些需要 synchronized 所沒有的特性的時候用,但是一般情況用 synchronized

2.3 volatile

2.3.1 volatile 能解決什麼問題

2.3.1.1 防止指令重排

首先,指令重排問題我們在單例模式中就遇到過,我直接把我當時文章中的一部分摘過來。

雙重鎖定程式碼:

當執行緒 A 和 B 同時訪問getLazy1(),執行到到 if (lazy1 == null) 這句的時候,同時判斷出 lazy1 == null,也就同時進入了 if 程式碼塊中,後面因為加了鎖,只有一個能先執行例項化的操作,例如 A 先進入,但是 後面的 B 進入後同樣也可以建立新的例項,就達不到單例的目的了,不信可以自己試一下

解決的方式就是再進行第二次的判斷

// 獲取本類例項的唯一全域性訪問點
public static Lazy1 getLazy1(){
    // 如果例項不存在則new一個新的例項,否則返回現有的例項
    if (lazy1 == null) {
        // 加鎖
        synchronized(Lazy1.class){
            // 第二次判斷是否為null
            if (lazy1 == null){
                lazy1 = new Lazy1();
            }
        }
    }
    return lazy1;
}
複製程式碼

指令重排問題:

這種在適當位置加鎖的方式,儘可能的降低了加鎖對於效能的影響,也能達到預期效果

但是這段程式碼,在一定條件下還是會有問題,那就是指令重排問題

指令重排序是JVM為了優化指令,提高程式執行效率,在不影響單執行緒程式執行結果的前提下,儘可能地提高並行度。

什麼意思呢?

首先要知道 lazy1 = new Lazy1(); 這一步並不是一個原子性操作,也就是說這個操作會分成很多步

  • ① 分配物件的記憶體空間
  • ② 執行建構函式,初始化物件
  • ③ 指向物件到剛分配的記憶體空間

但是 JVM 為了效率對這個步驟進行了重排序,例如這樣:

  • ① 分配物件的記憶體空間
  • ③ 指向物件到剛分配的記憶體空間,物件還沒被初始化
  • ② 執行建構函式,初始化物件

按照 ① ③ ② 的順序,當 A 執行緒執行到 ② 後,B執行緒判斷 lazy1 != null ,但是此時的 lazy1 還沒有被初始化,所以會出問題,並且這個過程中 B 根本執行到鎖那裡,配個表格說明一下:

Time ThreadA ThreadB
t1 A:① 分配物件的記憶體空間
t2 A:③ 指向物件到剛分配的記憶體空間,物件還沒被初始化
t3 B:判斷 lazy1 是否為 null
t4 B:判斷到 lazy1 != null,返回了一個沒被初始化的物件
t5 A:② 初始化物件

解決的方法很簡單——在定義時增加 volatile 關鍵字,避免指令重排

2.3.1.2 保證變數可見性

這一個問題就必須提到 JMM,也就是 Java 記憶體模型了。在 JDK 1.2 之前,Java 的記憶體模型都是從主存中讀取變數的。而現在版本的 Java 記憶體模型下,執行緒可以把變數儲存在本地記憶體中,例如暫存器,而不是直接在主存中進行讀寫,這樣會導致可能一個執行緒訪問修改主存資料,而另一個執行緒使用本地記憶體中的資料,資料就不一致了。

而新增變數的宣告為 volatile ,就是代表告訴 JVM 這個變數使共享寫不穩定的,每次都要去主存中去讀取。

2.3.2 synchronized 和 volatile 的區別

  • volatile 解決的是變數在多個執行緒之間的可見性,而 synchronized 解決的是多個執行緒之間訪問資源的同步性
  • volatile 是執行緒同步的輕量級實現,所效能要更好一些
  • volatile 只能用於變數,而 synchronized 可以修飾方法和程式碼塊
  • volatile 關鍵字能保證資料的可見性,但不能保證資料的原子性。synchronized 關鍵字兩者都能保證。

引用:GitHub@JavaGuide

2.4 AQS 相關

2.4.1 什麼是 AQS

AQS ,全稱為 AbstractQueuedSynchronizer,位於 java.util.concurrent.locks 包。它是一個用來構建鎖和同步器的框架,例如 ReentrantLockSemaphoreCountDownLatch 等等就是基於 AQS 的。

2.4.2 請你講講AQS的原理

AQS 的本質就是提供了一套模板,其內部即維護了一個先進先出的 CLH 佇列(雙向連結串列)以及一個 state 狀態變數,AQS 就是將每條請求共享資源的執行緒封裝成一個佇列中的節點,該節點標識著它當前的狀態,例如共享狀態還是獨享狀態,以及前驅後驅節點的資訊。

當資源被請求的時候,若資源空閒,則將當前請求資源的執行緒設定為有效執行緒,將共享資源設定為鎖定狀態,如果被請求的資源被佔用,那麼就需要一套阻塞等待以及被被醒時鎖分配的機制,這也就是 CLH 佇列的意義。

2.4.2.1 什麼是共享狀態和獨享狀態?

AQS 對於兩種資源的共享方式:

  • 獨佔方式:只有一個執行緒可以可以拿到鎖,例如 ReentrantLock ,可以細分為公平以及非公平兩種鎖
    • 公平鎖:在競爭的環境下,先到臨界區的執行緒比後到的先拿到鎖。在此處就是按照佇列中的順序排隊獲取鎖
    • 非公平鎖:誰先搶到就是誰的,後到臨界區的執行緒也可能先拿到鎖。
    • 公平鎖和非公平鎖的區別就是:是否會嘗試獲取鎖,如果嘗試獲取鎖,那肯定是非公平的,如果直接進入佇列,排隊等待那就是公平的
  • 共享方式:多個執行緒可以同時執行,如: CountDownLatchSemaphore

2.4.3 同步器的自定義以及常見實現

首先我們要知道構建一個自定義同步器的一般步驟是什麼

  • 繼承 AbstractQueuedSynchronizer 類,且重寫指定的方法(就是對於資源獲取和釋放的過程)
  • 將 AQS 組合在自定義組建的實現中,通過呼叫其模板方法(因為同步器的設計是基於方法模式的),其實也就是呼叫了你重寫的方法

PS:因為不同的同步器爭用共享資源的方式不同,所以自定義同步器只需要實現關於資源獲取與釋放的方法就可以了,關於具體執行緒等待,佇列維護等等內容,AQS 已經在背後實現好了。

需要重寫的方式介紹:

獨佔方式:

// 嘗試獲取資源,成功返回true,失敗返回false。
tryAcquire(int)
// 嘗試釋放資源,成功返回true,失敗返回false。
tryRelease(int)

共享方式:

// 嘗試獲取資源,正數即成功,且有剩餘資源。負數即失敗。0代表成功,但沒有剩餘資源。
tryAcquireShared(int)
// 嘗試釋放資源,成功返回true,失敗返回false。
tryReleaseShared(int)

額外:

// 該執行緒是否正在獨佔資源。只有用到condition才需要去實現它。
isHeldExclusively() 

**2.4.3.1 請說說 ReentrantLock 加鎖以及釋放鎖的過程 **

ReentrantLock 首先 state 初始化值是 0,也就是代表沒有鎖定,當【執行緒1】執行 lock() 的時候,就通過 tryAcquire(int) 獨佔該資源,然後將 state + 1,因為被獨佔了,所以後面的執行緒同樣去請求的時候都是市濱海的。知道 【執行緒1】執行 unlock() ,state = 0 的時候。

  • 不過由於可重入的概念,所以【執行緒1】持有資源的時候是可以重複獲取此鎖的 state 也會累加,要保證獲取多少次,就釋放多少次,保證 state 可以回到 0。

**2.4.3.2 請說說 CountDownLatch 加鎖以及釋放鎖的過程 **

CountDownLatch 會把任務分成很多個子執行緒去做,它 state 初始化就不是 0了,而是一個 n 值,也就是子執行緒的數量,每個子執行緒執行完後會執行一個 countDown(),然後 state - 1,等所有子執行緒都執行結束了,state 也就是 0了,然後執行 unpark() 主呼叫執行緒,然後主呼叫執行緒就會從 await() 返回,執行別的動作。

2.4.4 CountDownLatch 的使用場景

CountDownLatch 就是保證所有執行緒沒有執行結束之前,所有執行緒都阻塞在一個地方。

例如我們要處理某個任務,這幾個任務也沒什麼必要的順序,我們在這幾個任務全部處理結束後,還要統一做一些事情,所以,我們就可以使用 CountDownLatch ,每一個執行緒處理結束,就會把 count - 1 ,全部結束後,從 await() 返回,才會往後繼續執行別的業務邏輯。

注:程式碼中使用 ThreadPoolExecutor 構造方法建立執行緒池,這種方式是比較推薦的,後面執行緒池相關問題,也會細說。

import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.concurrent.*;

public class Test {
    
    private static final int CORE_POOL_SIZE = 8;
    private static final int MAX_POOL_SIZE = 16;
    private static final int BLOCKING_QUEUE_SIZE = 10;
    private static final long KEEP_ALIVE_TIME = 10L;

    private static final ThreadFactory guavaThreadFactory =
            new ThreadFactoryBuilder().setNameFormat("thread-pool-%d").build();

    private static final ExecutorService exec = new ThreadPoolExecutor(CORE_POOL_SIZE,
            MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS,
            new LinkedBlockingQueue<Runnable>(BLOCKING_QUEUE_SIZE), guavaThreadFactory);

    /**
     * 處理任務的數量
     */
    private static final int threadCount = 5;

    public static void main(String[] args) throws InterruptedException {

        final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        for (int i = 0; i < threadCount; i++) {
            final int currentNum = i;
            exec.execute(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " 正在處理任務:" + currentNum);
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    // 表示一個任務已經完成
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        exec.shutdown();
        System.out.println("任務全部處理完畢了!");
    }
}

執行結果:

thread-pool-0 正在處理任務:0
thread-pool-1 正在處理任務:1
thread-pool-2 正在處理任務:2
thread-pool-3 正在處理任務:3
thread-pool-4 正在處理任務:4
任務全部處理完畢了!

2.4.5 元件補充介紹

  • 倒數計時器(CountDownLatch ):用來協調多個執行緒之間的同步問題,即如上面的程式碼所示,一般用來控制執行緒的等待

  • 迴圈柵欄(CyclicBarrier):與 CountDownLatch 類似,都可以實現執行緒之間的等待,不過它的功能更加強大。它的字面思想為:讓多個執行緒達到屏障的時候就被阻塞,只有最後一個執行緒也到達屏障的時候,屏障才會開啟,才能繼續向後做。可以看出來和上面的 CountDownLatch 的感覺是非常相似的

  • 訊號量(Semaphore)允許多個執行緒同時訪問某個資源,可以與 synchronized 和 ReentrantLock 作對比,它們兩個都只一次允許一個執行緒訪問某個資源

2.5 ThreadLocal 相關

一般情況,我們建立的變數可以被任何一個執行緒訪問修改,但是 ThreadLocal 使得每個執行緒都可以擁有自己私有的區域性變數,這樣每個執行緒就可以訪問自己私有的這個值。實現了執行緒資料的隔離。

2.5.1 你在什麼場景用過 ThreadLocal 嗎

例子1

首先我們講一個比較巧妙的例子。例如在 Shiro + JWT 的許可權框架中,我們建立自定義的 Filter,來攔截所有的 HTTP 請求,它一個是把 Token 字串取出,另一個就是檢查 Token 的有效性,然後根據你設計的令牌重新整理機制,做出具體處理。當有新的 Token 被建立出來的時候,都會被儲存在 Redis 以及自定義的 ThreadLocalToken 類中。

為什麼這麼做呢,這是因為,我們的目的就是將新令牌傳遞到響應中去,返回給前端。雖然 我們自定義的 Filter 中提供了 doFilterInternal() 方法(因為繼承了 AuthenticatingFilter),它可以幫助我們把令牌放到響應中去,但是其操作是有點麻煩的,需要通過 IO 流讀取響應資料,然後把資料解析為 JSON,然後再放入新令牌。

但是如果我們定義一個 AOP 切面類,我們就可以通過環繞通知的方式,攔截到所有 自定義返回物件 ServerResponse ,然後再新增新令牌。這是比較簡單的,但是自定義的 Filter 和 AOP切面 之間沒有呼叫關係,我們需要想辦法將新令牌傳入。

這裡就可以使用 ThreadLocal ,因為在同一個執行緒中,ThreadLocal 裡面的資料讀寫是專屬私有的。而 自定義的 Filter 和 AOP 切面類,都是同一個執行緒執行的,中途不會更換執行緒,所以可以放心的把令牌放在 ThreadLocal 中,AOP 切面類取出令牌,然後新增到 ServerResponse 即可。

例子2

對時間進行格式化時, SimpleDateFormat 不是執行緒安全的,就可以用 ThreadLocal 裝載 SimpleDateFormat 物件,這樣每個執行緒就有自己專屬的 SimpleDateFormat 了

2.5.2 ThreadLocal 簡單原理

Thread 類

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

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

可以看到,關於 ThreadLocal 變數的值 threadLocals,被儲存在一個ThreadLocal 中一個 ThreadLocalMap 型別的容器中。進入 ThreadLocal 檢視發現 ThreadLocalMap 就是一個特殊定製化的 HashMap,通過 ThreadLocal 的 get set 獲取值的本質,就是呼叫了 ThreadLocalMap 的 get set 方法。

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

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

-----------------------------------------------------
// ThreadLocalMap 的 getEntry
private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

由上述可得:ThreadLocal 儲存的私有變數,最終儲存在了 ThreadLocalMap 中,ThreadLocalMap 可以儲存 ThreadLocal 為 key,Object 物件為 value 的鍵值對。

2.5.3 ThreadLocal 記憶體洩露問題

ThreadLocalMap 中的 key 為 ThreadLocal 是弱引用,而 value 是強引用。若 ThreadLocal 沒有被外界強引用,就會導致在垃圾回收的時候,被回收掉。但是強引用是不會被清理的。這就導致了 key 為 null 的 Entry 出現。一直這樣下去,就會出現記憶體洩露問題。

記憶體洩漏(Memory Leak)是指程式中已動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。—— 百度百科

但是 ThreadLocalMap 中已經考慮了這一點,在呼叫 get set remove 方法的時候,會清理 key 為null 的記錄。所以使用 ThreadLocal 結束後推薦手動呼叫 remove 方法。

2.5.3.1 四種引用型別的程度

JDK1.2之前,引用的概念就是,引用型別儲存的是一塊記憶體的起始地址,代表這是這塊記憶體的一個引用。

JDK1.2以後,細分為強引用、軟引用、弱引用、虛引用四種(逐漸變弱)

  • 強引用:垃圾回收器不會回收它,當記憶體不足的時候,JVM 寧願丟擲 OutOfMemoryError 錯誤,也不願意回收它。
  • 軟引用:只有在記憶體空間不足的情況下,才會考慮回收軟引用。
  • 弱引用:弱引用比軟引用宣告週期更短,在垃圾回收器執行緒掃描它管轄的記憶體區域的過程中,只要發現了弱引用物件,就會回收它,但是因為垃圾回收器執行緒的優先順序很低,所以,一般也不會很快發現並回收。
  • 虛引用:級別最低的引用型別,它任何時候都可能被垃圾回收器回收

2.7 執行緒池相關

2.7.1 什麼是執行緒池?為什麼要用它?

JVM 在 HotSpot 這種實現下,Java 執行緒是會一對一對映到核心執行緒上的,也就是說 Java 中執行緒的建立和回收,因為需要核心操作,所以需要依賴於真實的作業系統幫忙。這個開銷是很大的,有可能這些消耗比執行任務的時間和資源花費還多

這種問題,其實不只是線上程中出現,例如資料庫連線池等等都是這樣的,所以池化的思想早就在多處被應用。

執行緒池是提供了一種執行緒管理及複用的平臺,除此之外,它還儲存了一些基本的統計資訊。好處如下:

  • 降低資源消耗:通過複用執行緒,降低每次執行緒建立和銷燬造成的消耗。
  • 提高響應速度:任務不需要再等待執行緒建立的過程了,所以速度會大大提高。
  • 提高執行緒的可管理性。執行緒是稀缺資源,如果無限制的建立,不僅會消耗系統資源,還會降低系統的穩定性,使用執行緒池可以進行統一的分配,調優和監控。

2.7.2 實現 Runnable 介面和 Callable 介面有什麼不同

  • Runnable 自 JDK 1.0 後就存在,而 Callable 在 JDK 1.5 的版本後才引入。
  • 兩者最主要的區別就是:Runnable 介面不會返回結果和異常資訊,但是 Callable 介面可以返回結果和異常資訊

根據兩者 run 的定義和註釋就可以看出來了:

@FunctionalInterface
public interface Runnable {
    /**
     * When an object implementing interface <code>Runnable</code> is used
     * to create a thread, starting the thread causes the object's
     * <code>run</code> method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method <code>run</code> is that it may
     * take any action whatsoever.
     *
     * @see     java.lang.Thread#run()
     */
    public abstract void run();
}
@FunctionalInterface
public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

2.7.3 execute() 和 submit() 的區別

  • execute() 方法用於提交不需要返回值的任務,同樣也沒有辦法判斷任務是否被執行緒池執行成功了沒有。
  • submit() 方法用於提交需要返回值的物件,而且它會返回一個 Future 物件,用來判斷任務是否執行成功
    • 可通過 get() 方法獲取結果:會阻塞直到任務結束。
    • 也可通過 get(long timeout, timeUnit unit) 方法則阻塞指定時間後,立即返回,有的任務可能也沒有執行結束。

可以跳轉到 [1.7 五種實現多執行緒的方式](# 1.7 五種實現多執行緒的方式) 中執行緒池的兩種啟動多執行緒的方式,就有著這兩個方法的一個演示。

2.7.4 建立執行緒池的方式

《阿里巴巴 Java 開發手冊》第 1 章 程式設計規範, 第 6 節 併發處理, 第 4 條 給出了強制宣告,不允許使用 Executors ,要使用 ThreadPoolExecutor 的方式

【強制】執行緒池不允許使用 Executors 去建立,而是通過 ThreadPoolExecutor 的方式,這樣的處理方式讓寫的同學更加明確執行緒池的執行規則,規避資源耗盡的風險

說明:Executors 返回執行緒池物件的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor : 允許請求的佇列長度為 Integer.MAX_VALUE ,可能堆積大量的請求,從而導致 OOM。
  • CachedThreadPool 和 ScheduledThreadPool : 允許建立的執行緒數量為 Integer.MAX_VALUE ,可能會建立大量執行緒,從而導致 OOM。

2.7.4.1 使用 ThreadPoolExecutor 建立執行緒池

注:[2.4.4 CountDownLatch 的使用場景](# 2.4.4 CountDownLatch 的使用場景) 、[1.7 五種實現多執行緒的方式](# 1.7 五種實現多執行緒的方式) 有兩個簡單的示例

首先是建構函式:

我們直接拿最長,最全的講解就可以了:

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler){
    .......
}
  • corePoolSize:核心執行緒數

  • 核心執行緒會一直存活,即使沒有任務需要執行,當執行緒數小於核心執行緒數時,即使有執行緒空閒,執行緒池也會優先建立新執行緒處理。

  • maximumPoolSize:最大執行緒數

    • 當執行緒數 >= corePoolSize,且任務佇列已滿時,執行緒池會建立新執行緒來處理任務
    • 當執行緒數 = maxPoolSize,且任務佇列已滿時,執行緒池會跟根據拒絕策略進行相應的處理
  • keepAliveTime:執行緒空閒時間(s)

    • 當執行緒池中的執行緒數量大於 corePoolSize 的時候,如果這時沒有新的任務提交,核心執行緒外的執行緒不會立即銷燬,而是會等待,直到等待的時間超過了 keepAliveTime才會被回收銷燬
  • unit : keepAliveTime 的單位

  • workQueue : 當核心執行緒數達到最大時,新任務會放在佇列中排隊等待執行 ,佇列存放的資料大小跟分配的記憶體有關

  • threadFactory :建立專案的時候會用到,例如谷歌的 ThreadFactoryBuilder

  • handler:rejectedExecutionHandler,任務拒絕處理器

    • 當執行緒數已經達到最大數量 maximumPoolSize,同時 workQueue 佇列也已滿,會拒絕新任務

    • 當執行緒池被呼叫shutdown()後,會等待執行緒池裡的任務執行完畢,再shutdown。如果在呼叫shutdown()和執行緒池真正shutdown之間提交任務,會拒絕新任務

    • 處理策略有如下幾種:

      • AbortPolicy:拒絕新任務,丟擲異常 RejectedExecutionException 異常。

        CallerRunsPolicy:只要執行緒池沒有關閉,該策略會在呼叫者執行緒中,執行當前任務,這樣可以保證任務不會真的被丟棄,但是會導致效能會受到比較明顯的下降。

        DiscardPolicy:直接忽視丟棄,不去處理

        DiscardOldestPolicy:從佇列中踢出最先進入佇列的任務

2.7.4.2 使用 Executors 建立執行緒池

Executors 是 Executor 的一個工具類,也是用來建立執行緒池的一種方式,不過一般更推薦使用 ThreadPoolExecutor 方式

首先可以看到 Executors 提供給我們的執行緒池型別有這麼幾種

  • newCachedThreadPool: 建立一個具有彈性的執行緒池(可根據實際情況調整執行緒數量)。

    • 特點:彈性管理方式,有空閒執行緒,則優先複用空閒的執行緒,若沒有,則建立新執行緒處理任務,結束後返回執行緒池複用。

    • 缺點:執行緒無線增長,有記憶體溢位風險。

  • newFixedThreadPool : 建立一個固定執行緒數量的執行緒池。

    • 特點:固定大小的執行緒池,有新的任務提交時,執行緒池中如果有空閒執行緒,則執行。若沒有,這個新任務就會被暫存在一個任務佇列中,待有執行緒空閒時,便處理在任務佇列中的任務。
    • 缺點:不支援自定義拒絕策略,固定執行緒數量帶來的侷限,有時候並不是一件好事。
  • newScheduledThreadPool :建立一個固定執行緒數量的執行緒池,但可以定期執行任務。

    • 缺點:任務是單執行緒執行,失敗會影響到其他任務。
  • newSingleThreadExecutor: 建立只有一個執行緒的執行緒池。

    • 特點:單執行緒執行緒池,若多餘任務被提交到該執行緒池,任務會先被儲存在一個任務佇列中,直到執行緒空閒,按先入先出的順序執行佇列中的任務。
    • 缺點:不支援併發,一般用的不會太多

    Executors 其實背後也是用的 ThreadPoolExecutor 不過做了很多的限制。

    2.8 Atomic 原子類相關

    2.8.1 Atomic 原子類是什麼?

    Atomic 的中文為原子,即不可分割的一種最小單位。而原子類,就是指具有原子或原子操作特徵的類。

    2.8.2 JUC 包中的原子類有哪幾種

    基本型別

    • AtomicInteger:整形原子類
    • AtomicLong:長整型原子類
    • AtomicBoolean:布林型原子類

    陣列型別

    • AtomicIntegerArray:整形陣列原子類
    • AtomicLongArray:長整形陣列原子類
    • AtomicReferenceArray:引用型別陣列原子類

    引用型別

    • AtomicReference:引用型別原子類

    • AtomicStampedReference:原子更新帶有版本號的引用型別。

      • 可以解決 ABA 問題,跳轉 :[2.1.2 CAS 帶來的 ABA 問題](# 2.1.2 CAS 帶來的 ABA 問題)
    • AtomicMarkableReference :帶有標記位的引用型別

    • AtomicIntegerFieldUpdater:整形欄位的更新器

    • AtomicLongFieldUpdater:長整形欄位的更新器

    • AtomicReferenceFieldUpdater:引用型別欄位的更新器

相關文章