思維導圖整理Java併發基礎

三分惡發表於2021-02-03

話不多說,先上圖。

併發基礎

1、基本概念

欲說執行緒,必先說程式。

  • 程式:程式是程式碼在資料集合上的一次執行活動,是系統進行資源分配和排程的基本單位。
  • 執行緒:執行緒是程式的一個執行路徑,一個程式中至少有一個執行緒,程式中的多個執行緒共享程式的資源。

作業系統在分配資源時是把資源分配給程式的, 但是 CPU 資源比較特殊,它是被分配到執行緒的,因為真正要佔用CPU執行的是執行緒,所以也說執行緒是 CPU分配的基本單位

在Java中,當我們啟動 main 函式其實就啟動了一個JVM程式,而 main 函式在的執行緒就是這個程式中的一個執行緒,也稱主執行緒。

示意圖如下:

程式程式執行緒關係

一個程式中有多個執行緒,多個執行緒共用程式的堆和方法區資源,但是每個執行緒有自己的程式計數器和棧。

2、執行緒建立和執行

Java中建立執行緒有三種方式,分別為繼承Thread類、實現Runnable介面、實現Callable介面。

  • 繼承Thread類,重寫run()方法,呼叫start()方法啟動執行緒
public class ThreadTest {

    /**
     * 繼承Thread類
     */
    public static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("This is child thread");
        }
    }

    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
    }
}
  • 實現 Runnable 介面run()方法
public class RunnableTask implements Runnable {
    public void run() {
        System.out.println("Runnable!");
    }

    public static void main(String[] args) {
        RunnableTask task = new RunnableTask();
        new Thread(task).start();
    }
}

上面兩種都沒有返回值。

  • 實現Callable介面call()方法,這種方式可以通過FutureTask獲取任務執行的返回值
public class CallerTask implements Callable<String> {
    public String call() throws Exception {
        return "Hello,i am running!";
    }

    public static void main(String[] args) {
        //建立非同步任務
        FutureTask<String> task=new FutureTask<String>(new CallerTask());
        //啟動執行緒
        new Thread(task).start();
        try {
            //等待執行完成,並獲取返回結果
            String result=task.get();
            System.out.println(result);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

3、常用方法

3.1、執行緒等待與通知

在Object類中有一些函式可以用於執行緒的等待與通知。

  • wait():當一個執行緒呼叫一個共享變數的 wait()方法時, 該呼叫執行緒會被阻塞掛起, 到發生下面幾件事情之一才返回 :(1) 執行緒呼叫了該共享物件 notify()或者 notifyAll()方法;(2)其他執行緒呼叫了該執行緒 interrupt() 方法,該執行緒丟擲InterruptedException異常返回。

  • wait(long timeout) :該方法相 wait() 方法多了一個超時引數,它的不同之處在於,如果一個執行緒呼叫共享物件的該方法掛起後,沒有在指定的 timeout ms時間內被其它執行緒呼叫該共享變數的notify()或者 notifyAll() 方法喚醒,那麼該函式還是會因為超時而返回。

  • wait(long timeout, int nanos),其內部呼叫的是 wait(long timout)函式。

上面是執行緒等待的方法,而喚醒執行緒主要是下面兩個方法:

  • notify() : 一個執行緒呼叫共享物件的 notify() 方法後,會喚醒一個在該共享變數上呼叫 wait 系列方法後被掛起的執行緒。 一個共享變數上可能會有多個執行緒在等待,具體喚醒哪個等待的執行緒是隨機的。

  • notifyAll() :不同於在共享變數上呼叫 notify() 函式會喚醒被阻塞到該共享變數上的一個執行緒,notifyAll()方法則會喚醒所有在該共享變數上由於呼叫 wait 系列方法而被掛起的執行緒。

如果有這樣的場景,需要等待某幾件事情完成後才能繼續往下執行,比如多個執行緒載入資源,需要等待多個執行緒全部載入完畢再彙總處理。Thread類中有一個join方法可實現。

3.2、執行緒休眠

Thread類中有一個靜態態的 sleep 方法,當一個個執行中的執行緒呼叫了Thread 的sleep方法後,呼叫執行緒會暫時讓出指定時間的執行權,也就是在這期間不參與 CPU 的排程,但是該執行緒所擁有的監視器資源,比如鎖還是持有不讓出的。指定的睡眠時間到了後該函式會正常返回,執行緒就處於就緒狀態,然後參與 CPU 的排程,獲取到 CPU 資源後就可以繼續執行。

3.3、讓出優先權

Thread 有一個靜態 yield 方法,當一個執行緒呼叫 yield 方法時,實際就是在暗示執行緒排程器當前執行緒請求讓出自己的CPU 使用,但是執行緒排程器可以無條件忽略這個暗示。

當一個執行緒呼叫 yield 方法時, 當前執行緒會讓出 CPU 使用權,然後處於就緒狀態,執行緒排程器會從執行緒就緒佇列裡面獲取一個執行緒優先順序最高的執行緒,當然也有可能會排程到剛剛讓出 CPU 的那個執行緒來獲取 CPU 行權。

3.4、執行緒中斷

Java 中的執行緒中斷是一種執行緒間的協作模式,通過設定執行緒的中斷標誌並不能直接終止該執行緒的執行,而是被中斷的執行緒根據中斷狀態自行處理。

  • void interrupt() :中斷執行緒,例如,當執行緒A執行時,執行緒B可以呼叫錢程interrupt() 方法來設定執行緒的中斷標誌為 true 並立即返回。設定標誌僅僅是設定標誌, 執行緒A實際並沒有被中斷, 會繼續往下執行。如果執行緒A因為呼叫了wait() 系列函式、 join 方法或者 sleep 方法阻塞掛起,這時候若執行緒 B呼叫執行緒A的interrupt()方法,執行緒A會在呼叫這些方法的地方丟擲InterruptedException異常而返回。

  • boolean isInterrupted() 方法: 檢測當前執行緒是否被中斷。

  • boolean interrupted() 方法: 檢測當前執行緒是否被中斷,與 isInterrupted 不同的是,該方法如果發現當前執行緒被中斷,則會清除中斷標誌。

4、執行緒狀態

上面整理了執行緒的建立方式和一些常用方法,可以用執行緒的生命週期把這些方法串聯起來。

在Java中,執行緒共有六種狀態:

狀態 說明
NEW 初始狀態:執行緒被建立,但還沒有呼叫start()方法
RUNNABLE 執行狀態:Java執行緒將作業系統中的就緒和執行兩種狀態籠統的稱作“執行”
BLOCKED 阻塞狀態:表示執行緒阻塞於鎖
WAITING 等待狀態:表示執行緒進入等待狀態,進入該狀態表示當前執行緒需要等待其他執行緒做出一些特定動作(通知或中斷)
TIME_WAITING 超時等待狀態:該狀態不同於 WAITIND,它是可以在指定的時間自行返回的
TERMINATED 終止狀態:表示當前執行緒已經執行完畢

執行緒在自身的生命週期中, 並不是固定地處於某個狀態,而是隨著程式碼的執行在不同的狀態之間進行切換,Java執行緒狀態變化如圖示:

Java執行緒狀態變化

5、執行緒上下文切換

使用多執行緒的目的是為了充分利用CPU,但要認識到,每個CPU同一時刻只能被一個執行緒使用。

執行緒切換-2020-12-16-2107

為了讓使用者感覺多個執行緒是在同時執行的, CPU 資源的分配採用了時間片輪轉也就是給每個執行緒分配一個時間片,執行緒在時間片內佔用 CPU 執行任務。當執行緒使用完時間片後,就會處於就緒狀態並讓出 CPU 讓其他執行緒佔用,這就是上下文切換。

image-20210202172806362

6、執行緒死鎖

死鎖是指兩個或兩個以上的執行緒在執行過程中,因爭奪資源而造成的互相等待的現象,在無外力作用的情況下,這些執行緒會一直相互等待而無法繼續執行下去。

image-20210202173326028

那麼為什麼會產生死鎖呢? 死鎖的產生必須具備以下四個條件:

  • 互斥條件:指執行緒對己經獲取到的資源進行它性使用,即該資源同時只由一個執行緒佔用。如果此時還有其它執行緒請求獲取獲取該資源,則請求者只能等待,直至佔有資源的執行緒釋放該資源。
  • 請求並持有條件:指一個 執行緒己經持有了至少一個資源,但又提出了新的資源請求,而新資源己被其它執行緒佔有,所以當前執行緒會被阻塞,但阻塞 的同時並不釋放自己已經獲取的資源。
  • 不可剝奪條件:指執行緒獲取到的資源在自己使用完之前不能被其它執行緒搶佔,只有在自己使用完畢後才由自己釋放該資源。
  • 環路等待條件:指在發生死鎖時,必然存在一個執行緒——資源的環形鏈,即執行緒集合 {T0,T1,T2,…… ,Tn} 中 T0 正在等待一 T1 佔用的資源,Tl1正在等待 T2用的資源,…… Tn 在等待己被 T0佔用的資源。

該如何避免死鎖呢?答案是至少破壞死鎖發生的一個條件

其中,互斥這個條件我們沒有辦法破壞,因為用鎖為的就是互斥。不過其他三個條件都是有辦法破壞掉的,到底如何做呢?

  • 對於“請求並持有”這個條件,可以一次性請求所有的資源。

  • 對於“不可剝奪”這個條件,佔用部分資源的執行緒進一步申請其他資源時,如果申請不到,可以主動釋放它佔有的資源,這樣不可搶佔這個條件就破壞掉了。

  • 對於“環路等待”這個條件,可以靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候可以先申請資源序號小的,再申請資源序號大的,這樣線性化後就不存在環路了。

7、執行緒分類

Java中的執行緒分為兩類,分別為 daemon 執行緒(守護執行緒)user 執行緒(使用者執行緒)

在JVM 啟動時會呼叫 main 函式,main函式所在的錢程就是一個使用者執行緒。其實在 JVM 內部同時還啟動了很多守護執行緒, 比如垃圾回收執行緒。

那麼守護執行緒和使用者執行緒有什麼區別呢?區別之一是當最後一個非守護執行緒束時, JVM會正常退出,而不管當前是否存在守護執行緒,也就是說守護執行緒是否結束並不影響 JVM退出。換而言之,只要有一個使用者執行緒還沒結束,正常情況下JVM就不會退出。

8、ThreadLocal

ThreadLocal是JDK 包提供的,它提供了執行緒本地變數,也就是如果你建立了ThreadLocal ,那麼訪問這個變數的每個執行緒都會有這個變數的一個本地副本,當多個執行緒操作這個變數時,實際操作的是自己本地記憶體裡面的變數,從而避免了執行緒安全問題。建立 ThreadLocal 變數後,每個執行緒都會複製 到自己的本地記憶體。

image-20210202182241538

可以通過set(T)方法來設定一個值,在當前執行緒下再通過get()方法獲取到原先設定的值。

下面來看一個ThreadLocal的使用例項:

public class ThreadLocalTest {
    //建立ThreadLocal變數
    static ThreadLocal<String> localVar = new ThreadLocal<String>();

    //列印函式
    static void print(String str) {
        //列印當前執行緒本地記憶體中localVar變數值
        System.out.println(str + ":" + localVar.get());
        //清除前執行緒本地記憶體中localVar變數值
        //localVar.remove();
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                //設定執行緒1中本地變數localVal的值
                localVar.set("執行緒1的值");
                //呼叫列印函式
                print("執行緒1");
                //列印本地變數的值
                System.out.println("執行緒1列印本地變數後:" + localVar.get());
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            public void run() {
                //設定執行緒2中本地變數localVal的值
                localVar.set("執行緒2的值");
                //呼叫列印函式
                print("執行緒2");
                //列印本地變數的值
                System.out.println("執行緒2列印本地變數後:" + localVar.get());
            }
        });

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

9、Java記憶體模型

在Java中,所有例項域、靜態域和陣列元素都儲存在堆記憶體中,堆記憶體線上程之間共享 。

Java執行緒之間的通訊由Java記憶體模型控制,Java記憶體模型決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。

從抽象的角度來看,Java記憶體模型定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。本地記憶體是Java記憶體模型的 一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。

Java記憶體模型的抽象示意如圖:

image-20210202194932184

在實際實現中執行緒的工作記憶體如下圖:

image-20210202200333041

10、synchronized

synchronized 塊是 Java 提供的一種原子性內建鎖, Java中的每個物件都可以把它當作同步鎖來使用,這些 Java內建的使用者看不到的鎖被稱為內部鎖,也作監視器鎖。

執行緒的執行程式碼在進入 synchronized 程式碼塊前會自動獲取內部鎖,這時候其他執行緒訪問該同步程式碼塊 被阻塞掛起。拿到內部鎖的執行緒會在正常退出同步程式碼塊或者丟擲異常後或者在同步塊呼叫了該內建鎖資源 wait系列方法時釋放該內建鎖。內建鎖是排它鎖,就是當一個執行緒獲取這個鎖後,其他執行緒必須等待該執行緒釋放鎖後才能獲取該鎖。

synchronized 的記憶體語義:這個記憶體語義就可以解決共享變數記憶體可見性問題,進入synchronized 塊的記憶體語義是把在synchronized 塊內使用到的變數從執行緒的工作記憶體中清除,這樣在 synchronized 塊內使用到該變數時就不會從執行緒的工作記憶體中獲取,而是直接從主記憶體中獲取。 退出 synchronized 塊的記憶體語義是把在 synchronized 塊內對共享變修改重新整理到主記憶體。

11、volatile

上面介紹了使用鎖的方式可以解決共享記憶體可見性問題,但是使用鎖太笨重,因為它會帶來執行緒上下文的切換開銷,對於解決記憶體可見性問題, Java 還提供了volatile種弱形式的同步,也就是使用 volatile 關鍵字, 該關鍵字可以確保對一個變數的更新對其他執行緒馬上可見

當一個變數被宣告為volatile時,執行緒在寫入變數時不會把值快取在暫存器或者其他地方,而是會把值重新整理回主記憶體,當其它執行緒讀取該共享變數,會從主記憶體重新獲取最新值,而不是使用當前執行緒的工作記憶體中的值。

volatile雖然提供了可見性保證,但並不保證操作的原子性。

12、Java 中的原子性操作

所謂原子性操作,是指執行一系列操作時,這些操作要麼全部執行,要麼全部不執行,不存在只執行其中一部分的情況。

例如在設計計數器一般都先讀取當前值,然後+1,再更新。這個過程是讀-改-寫的過程,如果不能保證這個過程是原子性的,那麼就會出現執行緒安問題。

那麼如何才能保證多個操作的原子性呢?最簡單的方法就是使用 synchronized 關鍵字進行同步。還可以用CAS操作。從Java 1.5開始,JDK的併發包裡也提供了一些類來支援原子操作。

synchronized 是獨佔鎖,沒有獲取內部鎖的執行緒會被阻塞掉,大大降級了併發性。

13、Java 中的 CAS 操作

在Java中, 鎖在併發處理中佔據了一席之地,但是使用鎖有有個不好的地方,就是當執行緒沒有獲取到鎖時會被阻塞掛起,這會導致執行緒上下文的切換和重新排程開銷。

Java 提供了非阻塞的 volatile 關鍵字來解決共享變數的可見性問題,這在一定程度上彌補了鎖帶來的開銷問題,但是 volatile 只能保 共享變數可見性,不能解決讀-改-寫等的原子性問題。

CAS即 Compre and Swap ,其是 JDK 提供的非阻塞原子性操作,它通過硬體保證了比較-更新操作的原子性。JDK 裡面的 Unsafe 類提供了一系列的compareAndSwap *方法,以 compareAndSwapLong 方法為例,看一下什麼是CAS操作。

  • boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update ): CAS 有四個運算元,分別為物件記憶體位置、 物件中 變數的偏移量、變數預期值和新的值 。其操作含義是:只有當物件 obj 中記憶體偏移量為 valueOffset 的變數預期值為 expect 的時候,才會將ecpect更新為update。 這是處理器提供的一個原子性指令。

CAS有個經典的ABA問題。因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化,則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它 的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A。

14、鎖的概述

14.1、樂觀鎖與悲觀鎖

樂觀鎖和悲觀鎖是在資料庫中引入的名詞,但是在併發包鎖裡面引入了類似的思想。

悲觀鎖指對資料被外界修改持保守態度,認為資料很容易就會被其他執行緒修改,所以在資料被處理前先對資料進行加鎖,並在整個資料處理過程中,使資料處於鎖定狀態。悲觀鎖的實現往往依靠資料庫提供的鎖機制,即在資料 ,在對資料記錄操作前給記錄排它鎖。如果獲取鎖失敗, 則說明資料正在被其它執行緒修改,當前執行緒則等待或者丟擲異常。 如果獲取鎖成功,則對記錄進行操作 ,然後提交事務後釋放排它鎖。

樂觀鎖相對悲觀鎖來說的,它認為資料在一般情況下不會造成衝突,所以在訪問記錄前不會加排它鎖,而在進行資料提交更新時,才會正式對資料衝 與否進行檢測 。具體來說,根據 update 返回的行數讓使用者決定如何去做 。

14.2、公平鎖與非公平鎖

根據執行緒獲取鎖的搶佔機制,鎖可以分為公平鎖和非公平鎖,公平鎖表示執行緒獲取鎖的順序是按照執行緒請求鎖的時間早晚來決定的,也就是最早請求鎖的執行緒將最早獲取到鎖。

而非公平鎖是在執行時闖入,也就是先來不一定先得。

ReentrantLock 提供了公平鎖和非公平鎖的實現:

  • 公平鎖: ReentrantLock pairLock =new eentrantLock(true)

  • 非公平鎖: ReentrantLock pairLock =new ReentrantLock(false) 。 建構函式不傳數,則預設是非公平鎖。

例如,假設執行緒A已經持有了鎖,這時候執行緒B請求該鎖其將被掛起 。當執行緒A釋放鎖後,假如當前有執行緒C也需要取該鎖,如果採用非公平鎖式,則根據執行緒排程策略 ,執行緒B和執行緒C兩者之一可能獲取鎖,這時候不需要任何其他干涉,而如果使用公平鎖則需要把C掛起,讓B獲取當前鎖。

在沒有公平性需求的前提下儘量使用非公平鎖,因為公平鎖會帶來效能開銷。

14.3、獨佔鎖與共享鎖

根據鎖只能被單個執行緒持有還是能被多個執行緒共同持有,鎖可以分為獨佔鎖和共享鎖。

獨佔鎖保證任何時候都只有一個執行緒能得到鎖, ReentrantLock 就是以獨佔方式實現的。

共享鎖則可以同時由多個執行緒持有 ,例如 ReadWriteLock讀寫鎖,它允許一個資源可以被多執行緒同時進行讀操作。

獨佔鎖是一種悲觀鎖,共享鎖是一種樂觀鎖。

14.4、可重入鎖

當一個執行緒要獲取一個被其他執行緒持有的獨佔鎖時,該執行緒會被阻塞。

那麼當 一個執行緒再次獲取它自己己經獲取的鎖時是否會被阻塞呢?如果不被阻塞,那麼我們說該鎖是可重入的,也就是隻要該執行緒獲取了該鎖,那麼可以無限次數(嚴格來說是有限次數)地進入被該鎖鎖住的程式碼。

14.5、自旋鎖

由於 Java 中的執行緒是與作業系統中的執行緒 一一對應的,所以當一個執行緒在獲取鎖(比如獨佔鎖)失敗後,會被切換到核心狀態而被掛起 。當該執行緒獲取到鎖時又需要將其切換到核心狀態而喚醒該執行緒。而從使用者狀態切換到核心狀態的開銷是比較大的,在一定程度上會影響併發效能。

自旋鎖則是,當前執行緒在獲取鎖時,如果發現鎖已經被其他執行緒佔有,它不馬上阻塞自己,在不放棄 CPU 使用權的情況下,多次嘗試獲取(預設次數是 10 ,可以使用 -XX:PreBlockSpinsh 引數設定該值),很有可能在後面幾次嘗試中其他執行緒己經釋放了鎖,如果嘗試指定的次數後仍沒有獲取到鎖則當前執行緒才會被阻塞掛起。由此看來自旋鎖是使用 CPU 時間換取執行緒阻塞與排程的開銷,但是很有可能這些 CPU 時間白白浪費了。

參考:

【1】:瞿陸續,薛賓田 編著 《併發程式設計之美》

【2】:極客時間 《Java併發程式設計實踐》

【3】:方騰飛等編著《Java併發程式設計的藝術》

相關文章