Java併發程式設計

他是醫你的藥發表於2021-09-19

Java併發程式設計

1. 程式與執行緒

1.1 程式與執行緒

程式

  • 程式由指令和資料組成,但這些指令要執行,資料要讀寫,就必須將指令載入至 CPU,資料載入至記憶體。在指令執行過程中還需要用到磁碟、網路等裝置。程式就是用來載入指令、管理記憶體、管理 IO 的
  • 當一個程式被執行,從磁碟載入這個程式的程式碼至記憶體,這時就開啟了一個程式
  • 程式就可以視為程式的一個例項。大部分程式可以同時執行多個例項程式(例如記事本、畫圖、瀏覽器等),也有的程式只能啟動一個例項程式(例如網易雲音樂、360 安全衛士等)

執行緒

  • 一個程式之內可以分為一到多個執行緒。
  • 一個執行緒就是一個指令流,將指令流中的一條條指令以一定的順序交給 CPU 執行
  • Java 中,執行緒作為最小排程單位,程式作為資源分配的最小單位。 在 windows 中程式是不活動的,只是作為執行緒的容器

二者對比

  • 程式基本上相互獨立的,而執行緒存在於程式內,是程式的一個子集
  • 程式擁有共享的資源,如記憶體空間等,供其內部的執行緒共享
  • 程式間通訊較為複雜
    • 同一臺計算機的程式通訊稱為 IPC
    • 不同計算機之間的程式通訊,需要通過網路,並遵守共同的協議,例如 HTTP
  • 執行緒通訊相對簡單,因為它們共享程式內的記憶體,一個例子是多個執行緒可以訪問同一個共享變數
  • 執行緒更輕量,執行緒上下文切換成本一般上要比程式上下文切換低

1.2 並行與併發

單核 cpu 下,執行緒實際還是 序列執行 的。作業系統中有一個元件叫做任務排程器,將 cpu 的時間片分給不同的程式使用,只是由於 cpu 線上程間(時間片很短)的切換非常快,人類感覺是 同時執行 的 。總結為一句話就是: 微觀序列,巨集觀並行
一般會將這種 執行緒輪流使用 CPU 的做法稱為併發

引用 Rob Pike 的一段描述:

  • 併發(concurrent)是同一時間應對(dealing with)多件事情的能力
  • 並行(parallel)是同一時間動手做(doing)多件事情的能力

2. Java執行緒

2.1 建立和執行執行緒

  • 一:使用 Thread (繼承Thread或匿名內部類重寫run方法)

    // 構造方法的引數是給執行緒指定名字,推薦
    Thread t1 = new Thread("t1") {
        @Override
        // run 方法內實現了要執行的任務
        public void run() {
            log.debug("hello");
        }
    };
    t1.start();
    
  • 二:使用 Runnable 配合 Thread

    把【執行緒】和【任務】(要執行的程式碼)分開

    • Thread 代表執行緒
    • Runnable 可執行的任務(執行緒要執行的程式碼)
    Runnable task2 = new Runnable() {
        @Override
        public void run() {
            log.debug("hello");
        }
    };
    // 引數1 是任務物件; 引數2 是執行緒名字,推薦
    Thread t2 = new Thread(task2, "t2");
    t2.start();
    

    Java8以後可以使用lambda精簡程式碼

    // 建立任務物件
    Runnable task2 = () -> log.debug("hello");
    // 引數1 是任務物件; 引數2 是執行緒名字,推薦
    Thread t2 = new Thread(task2, "t2");
    t2.start();
    

    Thread 與 Runnable

    public class Thread implements Runnable {
        /* Make sure registerNatives is the first thing <clinit> does. */
        private static native void registerNatives();
        static {
            registerNatives();
        }
    
        private volatile String name;
        private int            priority;
        private Thread         threadQ;
        private long           eetop;
    }
    

    小結:

    • Runnable 介面把執行緒和任務分開了
    • 用 Runnable 更容易與執行緒池等高階 API 配合
    • 用 Runnable 讓任務類脫離了 Thread 單繼承體系,更靈活
  • 三:FutureTask 配合 Thread

    FutureTask 能夠接收 Callable 型別的引數,用來處理有返回結果的情況

    // 建立任務物件
    FutureTask<Integer> task3 = new FutureTask<>(() -> {
        log.debug("hello");
        return 100;
    });
    // 引數1 是任務物件; 引數2 是執行緒名字,推薦
    new Thread(task3, "t3").start();
    // 主執行緒阻塞,同步等待 task 執行完畢的結果
    Integer result = task3.get();
    log.debug("結果是:{}", result);
    

2.2 原理-執行緒執行

棧與棧幀
Java 虛擬機器棧

我們都知道 JVM 中由堆、棧、方法區所組成,其中棧記憶體是給誰用的呢?其實就是執行緒,每個執行緒啟動後,虛擬機器就會為其分配一塊棧記憶體。

  • 每個棧由多個棧幀(Frame)組成,對應著每次方法呼叫時所佔用的記憶體
  • 每個執行緒只能有一個活動棧幀,對應著當前正在執行的那個方法

執行緒上下文切換
因為以下一些原因導致 cpu 不再執行當前的執行緒,轉而執行另一個執行緒的程式碼

  • 執行緒的 cpu 時間片用完
  • 垃圾回收
  • 有更高優先順序的執行緒需要執行
  • 執行緒自己呼叫了 sleep、yield、wait、join、park、synchronized、lock 等方法

當 Context Switch 發生時,需要由作業系統儲存當前執行緒的狀態,並恢復另一個執行緒的狀態,Java 中對應的概念就是程式計數器(Program Counter Register),它的作用是記住下一條 jvm 指令的執行地址,是執行緒私有的

  • 狀態包括程式計數器、虛擬機器棧中每個棧幀的資訊,如區域性變數、運算元棧、返回地址等
  • Context Switch 頻繁發生會影響效能

2.3 常見方法

方法名 static 功能說明 注意
start() 啟動一個新執行緒,在新的執行緒執行 run 方法中的程式碼 start 方法只是讓執行緒進入就緒,裡面程式碼不一定立刻執行(CPU 的時間片還沒分給它)。每個執行緒物件的start方法只能呼叫一次,如果呼叫了多次會出現IllegalThreadStateException
run() 新執行緒啟動後會呼叫的方法 如果在構造 Thread 物件時傳遞了 Runnable 引數,則執行緒啟動後會呼叫 Runnable 中的 run 方法,否則預設不執行任何操作。但可以建立 Thread 的子類物件,來覆蓋預設行為
join() 等待執行緒執行結束
join(long n) 等待執行緒執行結束,最多等待 n毫秒
getId() 獲取執行緒長整型的 id id 唯一
getName() 獲取執行緒名
setName(String) 修改執行緒名
getPriority() 獲取執行緒優先順序
setPriority(int) 修改執行緒優先順序 java中規定執行緒優先順序是1~10 的整數,較大的優先順序能提高該執行緒被 CPU 排程的機率
getState() 獲取執行緒狀態 Java 中執行緒狀態是用 6 個 enum 表示,分別為:NEW, RUNNABLE, BLOCKED, WAITING,TIMED_WAITING, TERMINATED
isInterrupted() 判斷是否被打斷 不會清除 打斷標記
isAlive() 執行緒是否存活(還沒有執行完畢)
interrupt() 打斷執行緒 如果被打斷執行緒正在 sleep,wait,join 會導致被打斷的執行緒丟擲 InterruptedException,並清除 打斷標記 ;如果打斷的正在執行的執行緒,則會設定 打斷標記 ;park 的執行緒被打斷,也會設定 打斷標記
interrupted() static 判斷當前執行緒是否被打斷 會清除 打斷標記
currentThread() static 獲取當前正在執行的執行緒
sleep(long n) static 讓當前執行的執行緒休眠n毫秒,休眠時讓出 cpu的時間片給其它執行緒
yield() static 提示執行緒排程器讓出當前執行緒對CPU的使用 主要是為了測試和除錯
  1. start 與 run

    結果都是執行了run方法中的程式碼,但是呼叫的執行緒不同

    注意:

    • 直接呼叫 run 是在主執行緒中執行了 run方法,沒有啟動新的執行緒
    • 使用 start 是啟動新的執行緒,通過新的執行緒間接執行 run 中的程式碼
  2. sleep 與 yield

    sleep

    1. 呼叫 sleep 會讓當前執行緒從 Running 進入 Timed Waiting 狀態(阻塞)
    2. 其它執行緒可以使用 interrupt 方法打斷正在睡眠的執行緒,這時 sleep 方法會丟擲 InterruptedException
    3. 睡眠結束後的執行緒未必會立刻得到執行
    4. 建議用 TimeUnit 的 sleep 代替 Thread 的 sleep 來獲得更好的可讀性

    yield

    1. 呼叫 yield 會讓當前執行緒從 Running 進入 Runnable 就緒狀態,然後排程執行其它執行緒
    2. 具體的實現依賴於作業系統的任務排程器

    執行緒優先順序

    • 執行緒優先順序會提示(hint)排程器優先排程該執行緒,但它僅僅是一個提示,排程器可以忽略它
    • 如果 cpu 比較忙,那麼優先順序高的執行緒會獲得更多的時間片,但 cpu 閒時,優先順序幾乎沒作用
    Runnable task1 = () -> {
        int count = 0;
        for (;;) {
            System.out.println("---->1 " + count++);
        }
    };
    Runnable task2 = () -> {
        int count = 0;
        for (;;) {
            // Thread.yield();
            // Thread.sleep();
            System.out.println(" ---->2 " + count++);
        }
    };
    Thread t1 = new Thread(task1, "t1");
    Thread t2 = new Thread(task2, "t2");
    // t1.setPriority(Thread.MIN_PRIORITY);
    // t2.setPriority(Thread.MAX_PRIORITY);
    t1.start();
    t2.start();
    
  3. join

    static int r1 = 0;
    static int r2 = 0;
    public static void main(String[] args) throws InterruptedException {
        test2();
    }
    private static void test2() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            sleep(1);
            r1 = 10;
        });
        Thread t2 = new Thread(() -> {
            sleep(2);
            r2 = 20;
        });
        long start = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        long end = System.currentTimeMillis();
        log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
    }
    

    分析如下

    • 第一個 join:等待 t1 時, t2 並沒有停止, 而在執行
    • 第二個 join:1s 後, 執行到此, t2 也執行了 1s, 因此也只需再等待 1s
  4. interrupt

    打斷 sleep,wait,join 的執行緒並重置中斷狀態為 false

    測試發現 LockSupport.park() 阻塞也會受中斷狀態的影響,即 interrupt 也能打斷 park 中的執行緒,但是區別是其並不會重置中斷狀態為 false ,這就會導致打斷一次後不使用 interrupted 來重置狀態的話我們的 park/unpark 就沒用了

    這幾個方法都會讓執行緒進入阻塞狀態

    打斷 sleep 的執行緒, 會清空打斷狀態,以 sleep 為例

    private static void test1() throws InterruptedException {
        Thread t1 = new Thread(()->{
            sleep(1);
        }, "t1");
        t1.start();
        sleep(0.5);
        t1.interrupt();
        log.debug(" 打斷狀態: {}", t1.isInterrupted());
    }
    

    輸出

    java.lang.InterruptedException: sleep interrupted
        at java.lang.Thread.sleep(Native Method)
        at java.lang.Thread.sleep(Thread.java:340)
        at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
        at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
        at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
        at java.lang.Thread.run(Thread.java:745)
    21:18:10.374 [main] c.TestInterrupt - 打斷狀態: false
    

    打斷正常執行的執行緒

    打斷正常執行的執行緒, 不會清空打斷狀態

    private static void test2() throws InterruptedException {
        Thread t2 = new Thread(()->{
            while(true) {
                Thread current = Thread.currentThread();
                boolean interrupted = current.isInterrupted();
                if(interrupted) {
                    log.debug(" 打斷狀態: {}", interrupted);
                    break;
                }
            }
        }, "t2");
        t2.start();
        sleep(0.5);
        t2.interrupt();
    }
    

    輸出

    20:57:37.964 [t2] c.TestInterrupt - 打斷狀態: true
    

    打斷 park 執行緒

    打斷 park 執行緒, 不會清空打斷狀態

    private static void test3() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            log.debug("park...");
            LockSupport.park();
            log.debug("unpark...");
            log.debug("打斷狀態:{}", Thread.currentThread().isInterrupted());
        }, "t1");
        t1.start();
        sleep(0.5);
        t1.interrupt();
    }
    

    輸出

    21:11:52.795 [t1] c.TestInterrupt - park...
    21:11:53.295 [t1] c.TestInterrupt - unpark...
    21:11:53.295 [t1] c.TestInterrupt - 打斷狀態:true
    

    如果打斷標記已經是true,則park會失效

    private static void test4() {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                log.debug("park...");
                LockSupport.park();
                log.debug("打斷狀態:{}", Thread.currentThread().isInterrupted());
            }
        });
        t1.start();
        sleep(1);
        t1.interrupt();
    }
    

    輸出

    21:13:48.783 [Thread-0] c.TestInterrupt - park...
    21:13:49.809 [Thread-0] c.TestInterrupt - 打斷狀態:true
    21:13:49.812 [Thread-0] c.TestInterrupt - park...
    21:13:49.813 [Thread-0] c.TestInterrupt - 打斷狀態:true
    21:13:49.813 [Thread-0] c.TestInterrupt - park...
    21:13:49.813 [Thread-0] c.TestInterrupt - 打斷狀態:true
    21:13:49.813 [Thread-0] c.TestInterrupt - park...
    21:13:49.813 [Thread-0] c.TestInterrupt - 打斷狀態:true
    21:13:49.813 [Thread-0] c.TestInterrupt - park...
    21:13:49.813 [Thread-0] c.TestInterrupt - 打斷狀態:true
    

    可以使用 Thread.interrupted() 清除打斷狀態,不然我們打斷一次後 park/unpark 就沒用了

  5. 不推薦方法

    方法名 功能說明
    stop() 停止執行緒執行
    suspend() 掛起(暫停)執行緒執行
    resume() 恢復執行緒執行

2.4 主執行緒與守護執行緒

預設情況下,Java 程式需要等待所有執行緒都執行結束,才會結束。有一種特殊的執行緒叫做守護執行緒,只要其它非守護執行緒執行結束了,即使守護執行緒的程式碼沒有執行完,也會強制結束。

例:

log.debug("開始執行...");
Thread t1 = new Thread(() -> {
    log.debug("開始執行...");
    sleep(2);
    log.debug("執行結束...");
}, "daemon");
// 設定該執行緒為守護執行緒
t1.setDaemon(true);
t1.start();
sleep(1);
log.debug("執行結束...");

輸出

08:26:38.123 [main] c.TestDaemon - 開始執行...
08:26:38.213 [daemon] c.TestDaemon - 開始執行...
08:26:39.215 [main] c.TestDaemon - 執行結束...

注意:

  • 垃圾回收器執行緒就是一種典型的守護執行緒

    畢竟,使用者程式都執行結束了,還回收垃圾幹嘛

2.5 Java執行緒狀態

Java API層面對應執行緒有六種狀態

image-20210718220951789

  • NEW 執行緒剛被建立,但是還沒有呼叫 start() 方法
  • RUNNABLE 當呼叫了 start() 方法之後,注意,Java API 層面的 RUNNABLE 狀態涵蓋了 作業系統 層面的【可執行狀態】、【執行狀態】和【阻塞狀態】(由於 BIO 導致的執行緒阻塞,在 Java 裡無法區分,仍然認為是可執行)
  • BLOCKEDWAITINGTIMED_WAITING 都是 Java API 層面對【阻塞狀態】的細分
  • TERMINATED 當執行緒程式碼執行結束

反正這裡需要注意的事 BLOCKED 和我們的兩個 WAITING 狀態分別對應的是 Monitor 中的 EntryListWaitSet,也就是說處於 wait 或者 sleep 狀態的執行緒在 WaitSet 中,是沒資格爭奪鎖的,但是 BLOCKED 狀態的執行緒是可以去爭奪鎖的

順帶說一句,synchronized 是非公平鎖,也就是說不存在先來後到之說,只要有鎖空閒,那麼 EntryList 裡面的執行緒誰搶到算誰的,當然,TIMED_WAITING 時間一到或者是 WAITING 被喚醒那麼第一時間也是去搶鎖,搶不到就去 EntryList 裡等下一次鎖空閒

3. 共享模型--管程

作業系統使用訊號量解決併發問題,Java選擇使用管程(Monitor)解決併發問題。訊號量和管程是等價的,可以使用訊號量實現管程,也可以使用管程實現訊號量。

管程就是指管理共享變數,以及對共享變數的相關操作。具體到 Java 語言中,管程就是管理類的成員變數和方法,讓這個類是執行緒安全的。

3.1 共享問題

問題

兩個執行緒對初始值為 0 的靜態變數一個做自增,一個做自減,各做 5000 次,結果是 0 嗎?

static int counter = 0;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            counter++;
        }
    }, "t1");
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            counter--;
        }
    }, "t2");
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.debug("{}",counter);
}

問題分析

以上的結果可能是正數、負數、零。為什麼呢?因為 Java 中對靜態變數的自增,自減並不是原子操作,要徹底理解,必須從位元組碼來進行分析
例如對於 i++ 而言(i 為靜態變數),實際會產生如下的 JVM 位元組碼指令:

getstatic i // 獲取靜態變數i的值
iconst_1 // 準備常量1
iadd // 自增
putstatic i // 將修改後的值存入靜態變數i

而對應 i-- 也是類似:

getstatic i // 獲取靜態變數i的值
iconst_1 // 準備常量1
isub // 自減
putstatic i // 將修改後的值存入靜態變數i

臨界區

  • 一個程式執行多個執行緒本身是沒有問題的
  • 問題出在多個執行緒訪問共享資源
    • 多個執行緒讀共享資源其實也沒有問題
    • 在多個執行緒對共享資源讀寫操作時發生指令交錯,就會出現問題
  • 一段程式碼塊內如果存在對共享資源的多執行緒讀寫操作,稱這段程式碼塊為臨界區

例如,下面程式碼中的臨界區

static int counter = 0;
static void increment()
    // 臨界區
{
    counter++;
}
static void decrement()
    // 臨界區
{
    counter--;
}

競態條件

多個執行緒在臨界區內執行,由於程式碼的執行序列不同而導致結果無法預測,稱之為發生了競態條件

Java記憶體模型JMM

Java記憶體模型中規定所有共享變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對共享變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將共享變數從主記憶體拷貝的自己的工作記憶體空間,然後對其進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數,工作記憶體中儲存著主記憶體中的變數副本拷貝,工作記憶體是每個執行緒的私有資料區域,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,其簡要訪問過程如下圖

image-20210718231011173

3.2 synchronized解決方案

為了避免臨界區的競態條件發生,有多種手段可以達到目的。

  • 阻塞式的解決方案:synchronized,Lock
  • 非阻塞式的解決方案:原子變數

首先我們使用阻塞式的解決方案 synchronized,來解決上述問題,即俗稱的【物件鎖】,它採用互斥的方式讓同一時刻至多隻有一個執行緒能持有【物件鎖】,其它執行緒再想獲取這個【物件鎖】時就會阻塞住。這樣就能保證擁有鎖的執行緒可以安全的執行臨界區內的程式碼,不用擔心執行緒上下文切換。

  • 如果synchronized加在一個類的普通方法上,那麼相當於synchronized(this),即對物件加鎖。

  • 如果synchronized載入一個類的靜態方法上,那麼相當於synchronized(Class物件),即對類加鎖。

3.3 變數的執行緒安全分析

成員變數和靜態變數是否執行緒安全?

  • 如果它們沒有共享,則執行緒安全
  • 如果它們被共享了,根據它們的狀態是否能夠改變,又分兩種情況
    • 如果只有讀操作,則執行緒安全
    • 如果有讀寫操作,則這段程式碼是臨界區,需要考慮執行緒安全

區域性變數是否執行緒安全?

  • 區域性變數是執行緒安全的
  • 但區域性變數引用的物件則未必
    • 如果該物件沒有逃離方法的作用訪問,它是執行緒安全的
    • 如果該物件逃離方法的作用範圍,需要考慮執行緒安全

常見執行緒安全類

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的類

這裡說它們是執行緒安全的是指多個執行緒呼叫它們同一個例項的某個方法時,是執行緒安全的。也可以理解為它們的每個方法是原子的,但注意它們多個方法的組合不是原子的。

String、Integer等不可變類,由於其內部存放資料的屬性都定義為final,因此是不可修改的,也就是說每次修改其實就是重新建立替換,因此是執行緒安全的。

3.4 Monitor概念

Java中的每個物件都與一個monitor(管程)關聯,執行緒可以lockunlock monitor。 一次只能有一個執行緒在monitor上持有鎖。

物件頭

HotSpot物件的記憶體佈局

img

即:普通物件的物件頭包含Mark Word和型別指標,如果是陣列那就多一個陣列長度這一項

​ 至於Mark Word的結構的話,則是對應著不同鎖狀態有著不同內容

Monitor原理

Monitor 被翻譯為監視器或管程

每個 Java 物件都可以關聯一個 Monitor 物件,如果使用 synchronized 給物件上鎖(重量級)之後,該物件頭的Mark Word 中就被設定指向 Monitor 物件的指標

執行 monitorenter 指令就是執行緒試圖去獲取 Monitor 的所有權,搶到了就是成功獲取鎖了;執行 monitorexit 指令則是釋放了Monitor的所有權。

Monitor 結構如下

image-20210719221507375

  • 剛開始 Monitor 中 Owner 為 null
  • 當 Thread-2 執行 synchronized(obj) 就會將 Monitor 的所有者 Owner 置為 Thread-2,Monitor中只能有一個 Owner
  • 在 Thread-2 上鎖的過程中,如果 Thread-3,Thread-4,Thread-5 也來執行 synchronized(obj),就會進入EntryList 中 BLOCKED
  • Thread-2 執行完同步程式碼塊的內容,然後喚醒 EntryList 中等待的執行緒來競爭鎖,競爭的時是非公平的(誰搶到算誰的)
  • 圖中 WaitSet 中的 Thread-0,Thread-1 是之前獲得過鎖,但是後來被 waitsleep 等休眠的

注意:

  • synchronized 必須是進入同一個物件的 monitor 才有上述的效果
  • 不加 synchronized 的物件不會關聯監視器,不遵從以上規則

WaitSet和EntryList區別?

  • WaitSet 中的執行緒在沒有被喚醒之前是沒有權利去爭奪鎖的使用權的,被喚醒後可以去爭奪鎖,沒爭取到就待在 EntryList
  • EntryList 中的執行緒每次都能去爭奪執行緒使用權,其實就是嘗試成為 Monitor 的 owner(因為synchronized是非公平鎖)

ObjectMonitor

在HotSpot虛擬機器中,Monitor是基於C++的ObjectMonitor類實現的,其主要成員包括:

  • _owner:指向持有ObjectMonitor物件的執行緒
  • _WaitSet:存放處於wait狀態的執行緒佇列,即呼叫wait()方法的執行緒
  • _EntryList:存放處於等待鎖 block 狀態的執行緒佇列
  • _count:約為WaitSet 和 _EntryList 的節點數之和
  • _cxq: 多個執行緒爭搶鎖,會先存入這個單向連結串列
  • _recursions: 記錄重入次數

3.5 synchronized原理

static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
    synchronized (lock) {
        counter++;
    }
}

對應位元組碼

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized開始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 將 lock物件 MarkWord 置為 Monitor 指標
6: getstatic #3 // <- i
9: iconst_1 // 準備常數 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 將 lock物件 MarkWord 重置, 喚醒 EntryList
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 將 lock物件 MarkWord 重置, 喚醒 EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return

所以用人話解釋一下synchronized的底層原理:

  • monitorenter

    執行緒執行monitorenter指令時嘗試獲取monitor的所有權

    1. 如果monitor的記錄數(ObjectMonitor_recursions欄位)為0,則該執行緒進入monitor,然後將記錄數置為1,該執行緒即為monitor的所有者。

    2. 如果執行緒已經佔有該monitor_owner指向當前執行緒),只是重新進入,則進入monitor的記錄數加1。

    3. 如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態(進入EntryList),直到monitor的進入數為0,再重新嘗試獲取monitor的所有權。

  • monitorexit

    執行monitorexit的執行緒必須是monitor持有者

    指令執行時,monitor的記錄數減1,如果減1後記錄數為0,那執行緒退出monitor,不再是這個monitor的持有者。其他被這個monitor阻塞的執行緒可以嘗試去獲取這個 monitor 的所有權。

注意:
方法級別的 synchronized 不會在位元組碼指令中有所體現,方法的同步並沒有通過指令monitorentermonitorexit來完成,不過相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor, 其實本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過位元組碼來完成。

3.6 synchronized進階

小故事
故事角色:

  • 老王 - JVM
  • 小南 - 執行緒
  • 小女 - 執行緒
  • 房間 - 物件
  • 房間門上 - 防盜鎖 - 重量級鎖-MarkWord(正常情況下記錄的事物件hashcode 和 gc 分代年齡) 被替換為Monitor地址
  • 房間門上 - 小南書包 - 輕量級鎖-MarkWord 被替換為鎖記錄地址
  • 房間門上 - 刻上小南大名 - 偏向鎖-MarkWord 記錄 執行緒ID
  • 批量重刻名 - 一個類的偏向鎖撤銷到達 20 閾值
  • 不能刻名字 - 批量撤銷該類物件的偏向鎖,設定該類不可偏向

小南要使用房間保證計算不被其它人干擾(原子性),最初,他用的是防盜鎖,當上下文切換時,鎖住門。這樣,即使他離開了,別人也進不了門,他的工作就是安全的
但是,很多情況下沒人跟他來競爭房間的使用權。小女是要用房間,但使用的時間上是錯開的,小南白天用,小女晚上用。每次上鎖太麻煩了,有沒有更簡單的辦法呢?
小南和小女商量了一下,約定不鎖門了,而是誰用房間,誰把自己的書包掛在門口,但他們的書包樣式都一樣,因此每次進門前得翻翻書包,看課本是誰的,如果是自己的,那麼就可以進門,這樣省的上鎖解鎖了。萬一書包不是自己的,那麼就在門外等,並通知對方下次用鎖門的方式
後來,小女回老家了,很長一段時間都不會用這個房間。小南每次還是掛書包,翻書包,雖然比鎖門省事了,但仍然覺得麻煩。
於是,小南乾脆在門上刻上了自己的名字:【小南專屬房間,其它人勿用】,下次來用房間時,只要名字還在,那麼說明沒人打擾,還是可以安全地使用房間。如果這期間有其它人要用這個房間,那麼由使用者將小南刻的名字擦掉,升級為掛書包的方式

同學們都放假回老家了,小南就膨脹了,在 20 個房間刻上了自己的名字,想進哪個進哪個。後來他自己放假回老家了,這時小女回來了(她也要用這些房間),結果就是得一個個地擦掉小南刻的名字,升級為掛書包的方式。老王覺得這成本有點高,提出了一種批量重刻名的方法,他讓小女不用掛書包了,可以直接在門上刻上自己的名字
後來,刻名的現象越來越頻繁,老王受不了了:算了,這些房間都不能刻名了,只能掛書包

輕量級鎖

輕量級鎖的使用場景:如果一個物件雖然有多執行緒要加鎖,但加鎖的時間是錯開的(也就是沒有競爭),那麼可以使用輕量級鎖來優化。

輕量級鎖對使用者是透明的,即語法仍然是 synchronized
假設有兩個方法同步塊,利用同一個物件加鎖

static final Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步塊 A
        method2();
    }
}
public static void method2() {
    synchronized( obj ) {
        // 同步塊 B
    }
}
  • 首先,還未加鎖之前:

    物件頭中的 MarkWord 還是老樣子,同時每個執行緒的棧幀中存在一個鎖記錄結構,此時兩個是互無關係的兩個結構

    image-20210724123449796

    • 執行synchronized程式碼加鎖:

      鎖記錄中 Object reference 指向鎖物件,並嘗試用 CAS自旋 替換 Object 的 Mark Word,將 Mark Word 的值存入鎖記錄

      image-20210724181029995

    • CAS成功:

      物件頭的MarkWord為鎖記錄地址和狀態00

      棧幀中的鎖記錄為原來的MarkWord(HashCode ...01)和指向鎖物件的引用

      image-20210724181216599

    • CAS失敗:

      • 如果是其它執行緒已經持有了該 Object 的輕量級鎖,這時表明有競爭,進入鎖膨脹過程

      • 如果是自己執行了 synchronized 鎖重入,那麼再新增一條 Lock Record 作為重入的計數

        image-20210724183511447

    • 當退出 synchronized 程式碼塊(解鎖時)如果有取值為 null 的鎖記錄,表示有重入,這時重置鎖記錄,表示重入計數減一

    • 當退出 synchronized 程式碼塊(解鎖時)鎖記錄的值不為 null,這時使用 CAS 將 Mark Word 的值恢復給物件頭

      • 成功,則解鎖成功
      • 失敗,說明輕量級鎖進行了鎖膨脹或已經升級為重量級鎖,進入重量級鎖解鎖流程

    注意,到這裡的話,我們對 synchronized的可重入 就有三種解釋(其實是兩種,偏向和輕量是一樣的)了:

    • 重量級鎖可重入原理就是加鎖時如果執行緒已經佔有該monitor_owner指向當前執行緒),則鎖重入,即monitor記錄數字段加1

    • 如果是輕量鎖可重入,則是加鎖時CAS失敗(因為 MarkWord 被換走了)並且指向自己,因此重入時在新增一條鎖記錄作為計數新新增的鎖記錄取值為 null(注意這個重入鎖記錄取值為null很關鍵,這是我們判斷是否還處於重入狀態的依據)

    • 如果是偏向鎖可重入,執行緒第一次成功加鎖時,會在物件頭和執行緒的棧幀中的鎖記錄中儲存所偏向的執行緒id,重入時直接鎖記錄增加即可。

      可以發現,偏向鎖和輕量鎖都要使用鎖記錄的,那麼既然感覺差不多,偏向鎖到底優化了啥?

      前面我們說了,輕量鎖每次都需要 CAS 操作來嘗試替換MarkWord 再根據成功與否來決定是否新增鎖記錄,但是偏向鎖儘管也有鎖記錄,卻不需要再CAS替換了,只需要判斷是否執行緒ID是自己,所以相對而言輕鬆很多

      就這麼看我也沒感覺出重量鎖相較於這兩個有啥特別重的地方,那麼重量鎖到底重在哪?

      我們知道重量鎖和Monitor管程有關,其提供了各種複雜的操作,例如可以控制執行緒的喚醒阻塞、執行緒的阻塞佇列等等,而我們的輕量和偏向從實現方式來看貌似不具有這種能力,當然,重量鎖能夠如此強大,自然是需要消耗更多的效能,因為需要藉助系統的核心功能,同時維護這麼多東西也需要消耗效能!

      可是為什麼我反正都是加的synchronized關鍵字,還是能用上重量鎖的全部功能例如阻塞、喚醒呢?

      廢話,你喵的鎖用那些方法的時候就說明有執行緒出現競爭,這時候鎖已經是膨脹成重量鎖了

偏向鎖

輕量級鎖在沒有競爭時(就自己這個執行緒),每次重入仍然需要執行 CAS 操作。
Java 6 中引入了偏向鎖來做進一步優化:

只有第一次使用 CAS 將執行緒 ID 設定到物件的 Mark Word 頭,之後發現這個執行緒 ID 是自己的就表示沒有競爭,不用重新 CAS。以後只要不發生競爭,這個物件就歸該執行緒所有。

注意
處於偏向鎖的物件解鎖後,執行緒 id 仍儲存於物件頭中

一個物件建立時:

  • 如果開啟了偏向鎖(預設開啟),那麼物件建立後,markword最後 3 位為 101
  • 偏向鎖是預設是延遲的,不會在程式啟動時立即生效,如果想避免延遲,可以加 VM 引數 -XX:BiasedLockingStartupDelay=0 來禁用延遲
  • 如果沒有開啟偏向鎖,那麼物件建立後,markword 最後 3 位為 001,這時它的 hashcode、age 都為 0,第一次用到hashcode 時才會賦值

偏向鎖撤銷:

  • 呼叫hashcode:偏向鎖的物件 MarkWord 中儲存的是執行緒 id,如果呼叫hashCode 會導致偏向鎖被撤銷(因為hashcode無了)
  • 其它執行緒使用物件:當有其它執行緒使用偏向鎖物件時,會將偏向鎖升級為輕量級鎖
  • 呼叫wait/notify:當執行緒呼叫 wait/notify 時,證明已經不是一個執行緒在使用鎖了,當然會鎖膨脹,撤銷偏向鎖

批量重定向:

如果物件雖然被多個執行緒訪問,但沒有競爭,這時偏向了執行緒 T1 的物件仍有機會重新偏向 T2,重偏向會重置物件的 Thread ID
當撤銷偏向鎖閾值超過 20 次後,jvm 會這樣覺得,我是不是偏向錯了呢,於是會在給這些物件加鎖時重新偏向至加鎖執行緒

批量撤銷:

當撤銷偏向 鎖閾值超過 40 次後,Jvm 會這樣覺得,自己確實偏向錯了,根本就不該偏向。於是整個類的所有物件都會變為不可偏向的,新建的物件也是不可偏向的

3.7 Jvm鎖優化

鎖消除

鎖消除就是字面意思,虛擬機器會根據自己的程式碼檢測結果取消一些加鎖邏輯。虛擬機器通過檢測會發現一些程式碼中不可能出現資料競爭,但是程式碼中又有加鎖邏輯,為了提高效能,就消除這些鎖。如果一段程式碼中,在堆上的所有資料都不會被其他執行緒訪問到,那就可以把它們當成執行緒私有資料,自然就不需要同步加鎖了。

鎖粗化

對相同物件多次加鎖,導致執行緒發生多次重入,可以使用鎖粗化方式來優化,這裡不是鎖膨脹那種改變鎖,而是改變範圍

原始碼

public void method(String s1, String s2){
    synchronized(this){
        System.out.println("s1");
    }
    synchronized(this){
        System.out.println("s1");
    }
}

鎖粗化

public void method(String s1, String s2){
    synchronized(this){
        System.out.println("s1");
    // }
    // synchronized(this){
        System.out.println("s1");
    }
}

3.8 Wait/Notify

API 介紹:

  • obj.wait() 讓進入 object 監視器的執行緒到 waitSet 等待
  • obj.notify() 在 object 上正在 waitSet 等待的執行緒中挑一個喚醒
  • obj.notifyAll() 讓 object 上正在 waitSet 等待的執行緒全部喚醒

wait() 方法會釋放物件的鎖,進入 WaitSet 等待區,從而讓其他執行緒就機會獲取物件的鎖。無限制等待,直到notify 為止
wait(long n) 有時限的等待, 到 n 毫秒後結束等待,或是被 notify

原理

image-20210725022725427

  • Owner 執行緒發現條件不滿足,呼叫 wait 方法,即可進入 WaitSet 變為 WAITING 狀態
  • BLOCKED 和 WAITING 的執行緒都處於阻塞狀態,不佔用 CPU 時間片
  • BLOCKED 執行緒會在 Owner 執行緒釋放鎖時喚醒
  • WAITING 執行緒會在 Owner 執行緒呼叫 notify 或 notifyAll 時喚醒,但喚醒後並不意味者立刻獲得鎖,仍需進入EntryList 重新競爭

這麼看來,WAITING狀態還是要比BLOCKED低一點

sleep(long n) 和 wait(long n) 的區別:

  • sleep 是 Thread 方法,而 wait 是 Object 的方法
  • sleep 不需要強制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
  • sleep 在睡眠的同時,不會釋放物件鎖的,但 wait 在等待的時候會釋放物件鎖
  • 它們的狀態 TIMED_WAITING

3.9 Park/Unpark

LockSupport 類中的方法

// 暫停當前執行緒
LockSupport.park();
// 恢復某個執行緒的執行
LockSupport.unpark(暫停執行緒物件)

可以先park再unpark

Thread t1 = new Thread(() -> {
    log.debug("start...");
    sleep(1);
    log.debug("park...");
    LockSupport.park();
    log.debug("resume...");
},"t1");
t1.start();
sleep(2);
log.debug("unpark...");
LockSupport.unpark(t1);

也可以先unpark再park

Thread t1 = new Thread(() -> {
    log.debug("start...");
    sleep(2);
    log.debug("park...");
    LockSupport.park();
    log.debug("resume...");
}, "t1");
t1.start();
sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);

特點
與 Object 的 wait/notify 相比

  • wait,notify 和 notifyAll 必須配合鎖物件一起使用,而 park,unpark 不必(自己內部有一個互斥鎖_mutex
  • park和unpark以執行緒為單位精準阻塞和喚醒執行緒,notify是隨機喚醒一個執行緒,而notifyAll喚醒所有的等待執行緒
  • park/unpark 可以先 unpark,而 wait/notify 不能先 notify

原理

每個執行緒都有自己的一個 Parker 物件,由三部分組成 _counter標誌 , _cond阻塞佇列 和 _mutex互斥鎖

  1. 呼叫park的時候,執行緒看一下_counter是不是0,是0,就阻塞在_cond裡,並再賦值一下0給_counter
    呼叫unpark,將_counter賦值為1,並喚醒_cond裡的執行緒,然後再把_counter置為0,執行緒恢復執行。
  2. 先呼叫unpark的情況,先置為1,再呼叫park時,發現_counter是1,不需要阻塞,繼續執行,並把_counter置為0。

3.10 interrupt/isInterrupted()

interrupt

Thread.interrupt() 方法: 作用是改變執行緒的中斷狀態位,即設定為 true。

interrupt()方法只是改變中斷狀態,不會中斷一個正在執行的執行緒

如果執行緒被 Object.wait Thread.joinThread.sleep 三種方法之一阻塞,此時呼叫該執行緒的 interrupt() 方法,那麼該執行緒將丟擲一個 InterruptedException 中斷異常(該執行緒必須事先預備好處理此異常),從而提早地終結被阻塞狀態如果執行緒沒有被阻塞,這時呼叫 interrupt()將不起作用,直到執行到 wait() sleep() join()時,才馬上會丟擲 InterruptedException

注意這裡的 interrupt() 只是改變中斷狀態位,而我們的 wait join sleep 會感知這個狀態位,一旦變為 true 就丟擲異常,並把狀態為該回 false 從而導致對應的阻塞狀態終止,同時 wait 是與鎖有關的,被終端不意味著馬上就能執行接下來的程式碼,還是要去 monitorEntryList 裡搶奪鎖

isInterrupted

this.interrupted():測試當前執行緒是否已經中斷(靜態方法)並且將狀態位置為 false

this.isInterrupted():測試執行緒是否已經中斷,但是不能清除狀態標識。

3.11 執行緒狀態變化

image-20210725034759020

NEW --> RUNNABLE

  • 當呼叫 t.start() 方法時,由 NEW --> RUNNABLE

RUNNABLE <--> WAITING

  1. 執行緒用 synchronized(obj) 獲取了物件鎖後

    • 呼叫 obj.wait() 方法時,t 執行緒從 RUNNABLE --> WAITING,執行緒進入WaitSet
    • 呼叫 obj.notify()obj.notifyAll()t.interrupt()
      • 競爭鎖成功,t 執行緒從 WAITING --> RUNNABLE,成為鎖的owner
      • 競爭鎖失敗,t 執行緒從 WAITING --> BLOCKED,待在EntryList
  2. 當前執行緒呼叫 join() 方法時,當前執行緒從 RUNNABLE --> WAITING

    • 注意是當前執行緒在t 執行緒物件的監視器上等待

    執行緒執行結束,或呼叫了當前執行緒的 interrupt() 時,當前執行緒從 WAITING --> RUNNABLE

  3. 當前執行緒呼叫 LockSupport.park() 方法會讓當前執行緒從 RUNNABLE --> WAITING

RUNNABLE <--> TIMED_WAITING

  1. 執行緒用 synchronized(obj) 獲取了物件鎖後
    • 呼叫 obj.wait(long n) 方法時,執行緒從 RUNNABLE --> TIMED_WAITING
    • 執行緒等待時間超過了 n 毫秒,或呼叫 obj.notify()obj.notifyAll()t.interrupt()
      • 競爭鎖成功,執行緒從 TIMED_WAITING --> RUNNABLE
      • 競爭鎖失敗,執行緒從 TIMED_WAITING --> BLOCKED
  2. 當前執行緒呼叫 t.join(long n) 方法時,當前執行緒從 RUNNABLE --> TIMED_WAITING
  3. 當前執行緒呼叫 Thread.sleep(long n) ,當前執行緒從 RUNNABLE --> TIMED_WAITING
    當前執行緒等待時間超過了 n 毫秒,當前執行緒從 TIMED_WAITING --> RUNNABLE

RUNNABLE <--> BLOCKED

執行緒用 synchronized(obj) 獲取了物件鎖時如果競爭失敗,從 RUNNABLE --> BLOCKED

RUNNABLE <--> TERMINATED

當前執行緒所有程式碼執行完畢,進入 TERMINATED

3.12 執行緒活躍性

死鎖

執行緒死鎖是指兩個或兩個以上的執行緒互相持有對方所需要的資源,由於互斥鎖的特性,一個執行緒持有一個資源,或者說獲得一個鎖,在該執行緒釋放這個鎖之前,其它執行緒是獲取不到這個鎖的,而且會一直死等下去,因此這便造成了死鎖。

活鎖

活鎖出現在兩個執行緒互相改變對方的結束條件,最後誰也無法結束

飢餓

一個執行緒由於優先順序太低,始終得不到 CPU 排程執行,也不能夠結束

3.13 ReentrantLock

相對於 synchronized 它具備如下特點

  • 可中斷

    t1.interrupt()

  • 可以設定超時時間

    lock.tryLock(1, TimeUnit.SECONDS)

  • 可以設定為公平/非公平鎖

    預設非公平,可設定公平

    ReentrantLock lock = new ReentrantLock(false);

  • 支援多個條件變數
    ReentrantLock 支援多個條件變數的,即我們可以定義多個條件變數,類似多個WaitSet,這樣我們就能對鎖住的執行緒分開喚醒了

與 synchronized 一樣,都支援可重入
基本語法

// 獲取鎖
reentrantLock.lock();
try {
    // 臨界區
} finally {
    // 釋放鎖
    reentrantLock.unlock();
}

4. 共享模型--記憶體

4.1 Java記憶體模型

JMM 即Java Memory Model,它定義了主存、工作記憶體抽象概念,底層對應著 CPU 暫存器、快取、硬體記憶體、CPU 指令優化等。
JMM 體現在以下幾個方面

  • 原子性 - 保證指令不會受到執行緒上下文切換的影響
  • 可見性 - 保證指令不會受 cpu 快取的影響
  • 有序性 - 保證指令不會受 cpu 指令並行優化的影響

Java記憶體模型說白了就是一個規範

規定了所有共享變數都儲存在主記憶體(堆記憶體),主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體(棧記憶體)中進行,首先要將變數從主記憶體拷貝的自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數,工作記憶體中儲存著主記憶體中的變數副本拷貝,工作記憶體是每個執行緒的私有資料區域,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成

4.2 原子性和可見性

原子性問題:首先我們需要指導,一條程式指令的原子性是不需要我們擔心的,即它不會受到多執行緒或者其它條件的干擾,但是程式碼塊或 i++ 這樣的執行了多條指令的程式碼的原子性就無法保證了,我們可以使用 synchronized 來保證,但是有一說一,太重了。

可見性問題:當兩個執行緒都在使用同一個變數的時候,由於 JIT 有時會將頻繁使用的變數值快取到自己的工作記憶體中減少對主存中變數的訪問,因此在另一個執行緒修改了變數之後,可能導致當前執行緒沒有能夠及時獲取到變數的最新取值。

解決方法:volatile(易變關鍵字)

它可以用來修飾成員變數和靜態成員變數,它可以避免執行緒從自己的工作快取中查詢變數的值,必須到主存中獲取它的值,執行緒操作 volatile 變數都是直接操作主存

注意,我們使用System.out.println()之後其實就和加上了 volatile 關鍵字一樣的效果。此外,synchronized 也是可以保證可見性的。

4.3 有序性

JVM 會在不影響正確性的前提下,調整語句的執行順序

static int i;
static int j;
// 在某個執行緒內執行如下賦值操作
i = ...;
j = ...;

上面程式碼,無論是先執行i = xx還是j = xx都沒啥影響,因此最終底層對這兩段程式碼的執行順序是不確定的!
這種特性稱之為『指令重排』,多執行緒下『指令重排』會影響正確性。

現代處理器會設計將指令劃分為了更小的階段,例如取指令 讀指令 等,在不改變程式結果的前提下,這些指令的各個階段可以通過重排序和組合來實現指令級並行

解決方法:還是他喵的 volatile 這個關鍵字結合 CAS 簡直不要太好用

4.4 volatile原理

前面說了 volatile 可以保證程式碼的有序性和可見性,注意沒有原子性哈,那個是要靠加鎖來保證的
volatile底層原理:記憶體屏障

  • 對 volatile 變數的寫指令後會加入寫屏障
  • 對 volatile 變數的讀指令前會加入讀屏障

如何保證可見性?

寫屏障(sfence)保證在該屏障之前的,對共享變數的改動,都同步到主存當中

讀屏障(lfence)保證在該屏障之後,對共享變數的讀取,載入的是主存中最新資料

如何保證有序性?

寫屏障會確保指令重排序時,不會將寫屏障之前的程式碼排在寫屏障之後

讀屏障會確保指令重排序時,不會將讀屏障之後的程式碼排在讀屏障之前

4.5 happen-before

happens-before 規定了對共享變數的寫操作對其它執行緒的讀操作可見,它是可見性與有序性的一套規則總結。包含了以下規則,同時離開了下列規則的話,Jvm 並不能保證一個共享變數的寫對另一個共享變數可見

程式次序規則:在一個執行緒內一段程式碼的執行結果是有序的。就是還會指令重排,但是隨便它怎麼排,結果是按照我們程式碼的順序生成的不會變

管程鎖定規則:就是無論是在單執行緒環境還是多執行緒環境,對於同一個鎖來說,一個執行緒對這個鎖解鎖之後,另一個執行緒獲取了這個鎖都能看到前一個執行緒的操作結果!(管程是一種通用的同步原語,synchronized就是管程的實現)

volatile變數規則:就是如果一個執行緒先去寫一個volatile變數,然後一個執行緒去讀這個變數,那麼這個寫操作的結果一定對讀的這個執行緒可見

執行緒啟動規則:在主執行緒A執行過程中,啟動子執行緒B,那麼執行緒A在啟動子執行緒B之前對共享變數的修改結果對執行緒B可見

執行緒終止規則:在主執行緒A執行過程中,子執行緒B終止,那麼執行緒B在終止之前對共享變數的修改結果線上程A中可見。也稱執行緒join()規則

執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()檢測到是否發生中斷

傳遞性規則:這個簡單的,就是happens-before原則具有傳遞性,即hb(A, B) hb(B, C),那麼hb(A, C)

物件終結規則:這個也簡單的,就是一個物件的初始化的完成,也就是建構函式執行的結束一定 happens-before它的finalize()方法。

4.6 執行緒安全單例習題

單例模式有很多實現方法,餓漢、懶漢、靜態內部類、列舉類,試分析每種實現下獲取單例物件(即呼叫getInstance)時的執行緒安全,並思考註釋中的問題

餓漢式:類載入就會導致該單例項物件被建立
懶漢式:類載入不會導致該單例項物件被建立,而是首次使用該物件時才會建立

實現一:

// 問題1:為什麼加 final
// 問題2:如果實現了序列化介面, 還要做什麼來防止反序列化破壞單例
public final class Singleton implements Serializable {
    // 問題3:為什麼設定為私有? 是否能防止反射建立新的例項?
    private Singleton() {}
    // 問題4:這樣初始化是否能保證單例物件建立時的執行緒安全?
    private static final Singleton INSTANCE = new Singleton();
    // 問題5:為什麼提供靜態方法而不是直接將 INSTANCE 設定為 public, 說出你知道的理由
    public static Singleton getInstance() {
        return INSTANCE;
    }
    public Object readResolve() {
        return INSTANCE;
    }
}

實現二:

// 問題1:列舉單例是如何限制例項個數的
// 問題2:列舉單例在建立時是否有併發問題
// 問題3:列舉單例能否被反射破壞單例
// 問題4:列舉單例能否被反序列化破壞單例
// 問題5:列舉單例屬於懶漢式還是餓漢式
// 問題6:列舉單例如果希望加入一些單例建立時的初始化邏輯該如何做
enum Singleton {
    INSTANCE;
}

實現三:

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    // 分析這裡的執行緒安全, 並說明有什麼缺點
    public static synchronized Singleton getInstance() {
        if( INSTANCE != null ){
            return INSTANCE;
        }
        INSTANCE = new Singleton();
        return INSTANCE;
    }
}

實現四:DCL

public final class Singleton {
    private Singleton() { }
    // 問題1:解釋為什麼要加 volatile ?
    private static volatile Singleton INSTANCE = null;
    // 問題2:對比實現3, 說出這樣做的意義
    public static Singleton getInstance() {
        if (INSTANCE != null) {
            return INSTANCE;
        }
        synchronized (Singleton.class) {
            // 問題3:為什麼還要在這裡加為空判斷, 之前不是判斷過了嗎
            if (INSTANCE != null) { // t2
                return INSTANCE;
            }
            INSTANCE = new Singleton();
            return INSTANCE;
        }
    }
}

實現五:

public final class Singleton {
    private Singleton() { }
    // 問題1:屬於懶漢式還是餓漢式
    private static class LazyHolder {
        static final Singleton INSTANCE = new Singleton();
    }
    // 問題2:在建立時是否有併發問題
    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

5. 共享模型--無鎖

問題:
我們有一個 Amount 金額類,其中有一個 withdraw 扣款方法,該方法呼叫時執行 balance -= amount 命令,咋一看沒問題,畢竟也就一行程式碼,但是多執行緒場景下是會有問題的!
我們對該行程式碼的位元組碼指令進行分析,首先需要將 balanceamount 裝載進運算元棧,運算完成後再寫回區域性變數表。
瞭解了位元組碼指令就能看出什麼問題了,多執行緒下,肯定會發生指令交錯執行的情況,這樣最終得到的資料結果就會有異常。

解決思路:

  • 鎖:這個就不用多說,加鎖就完事了,前面也說過,volatile可以保證可見性和有序性,而程式碼的原子性就可以加鎖來保證了
  • 無鎖:不加鎖還能有什麼方法,不用想,肯定是底層用C++等語言實現了某種方法

5.1 CAS 與 volatile

CAS

CAS 全稱是 compare and swap,是一種用於在多執行緒環境下實現同步功能的機制。CAS 操作包含三個運算元 -- 記憶體位置、預期數值和新值。CAS 的實現邏輯是將記憶體位置處的數值與預期數值相比較,若相等,則將記憶體位置處的值替換為新值。若不相等,則不做任何操作。在 Java 中,Java 並沒有直接實現 CAS,而是通過 C++ 內聯彙編的形式實現的。然後Java通過本地方法棧來呼叫。

volatile

volatile 前面不是講過了麼?為什麼這裡又提到了?因為我們的 CAS 必須藉助 volatile 才能讀取到共享變數的最新值來實現【比較並交換】的效果。

無鎖--效率高

  • 無鎖情況下,即使重試失敗,執行緒始終在高速執行,沒有停歇,而 synchronized 會讓執行緒在沒有獲得鎖的時候,發生上下文切換,進入阻塞。
  • 但無鎖情況下,因為執行緒要保持執行,需要額外 CPU 的支援,雖然不會進入阻塞,但可能由於沒有分到時間片,仍然會進入可執行狀態,還是會導致上下文切換。

CAS 特點

結合 CAS 和 volatile 可以實現無鎖併發,適用於執行緒數少、多核 CPU 的場景下。
CAS 是基於樂觀鎖的思想:最樂觀的估計,不怕別的執行緒來修改共享變數,就算改了也沒關係,再重試。
synchronized 是基於悲觀鎖的思想:防著其它執行緒來修改共享變數,使用時不允許任何執行緒訪問

CAS 體現的是無鎖併發、無阻塞併發

  • 因為沒有使用 synchronized,所以執行緒不會陷入阻塞,這是效率提升的因素之一
  • 但如果競爭激烈,可以想到重試必然頻繁發生,反而效率會受影響

5.2 原子整數

J.U.C 併發包提供了:AtomicBoolean AtomicInteger AtomicLong

AtomicInteger 為例

AtomicInteger i = new AtomicInteger(0);
// 獲取並自增(i = 0, 結果 i = 1, 返回 0),類似於 i++
System.out.println(i.getAndIncrement());
// 自增並獲取(i = 1, 結果 i = 2, 返回 2),類似於 ++i
System.out.println(i.incrementAndGet());
// 自減並獲取(i = 2, 結果 i = 1, 返回 1),類似於 --i
System.out.println(i.decrementAndGet());
// 獲取並自減(i = 1, 結果 i = 0, 返回 1),類似於 i--
System.out.println(i.getAndDecrement());
// 獲取並加值(i = 0, 結果 i = 5, 返回 0)
System.out.println(i.getAndAdd(5));
// 加值並獲取(i = 5, 結果 i = 0, 返回 0)
System.out.println(i.addAndGet(-5));
// 獲取並更新(i = 0, p 為 i 的當前值, 結果 i = -2, 返回 0)
// 其中函式中的操作能保證原子,但函式需要無副作用
System.out.println(i.getAndUpdate(p -> p - 2));
// 更新並獲取(i = -2, p 為 i 的當前值, 結果 i = 0, 返回 0)
// 其中函式中的操作能保證原子,但函式需要無副作用
System.out.println(i.updateAndGet(p -> p + 2));
// 獲取並計算(i = 0, p 為 i 的當前值, x 為引數1, 結果 i = 10, 返回 0)
// 其中函式中的操作能保證原子,但函式需要無副作用
// getAndUpdate 如果在 lambda 中引用了外部的區域性變數,要保證該區域性變數是 final 的
// getAndAccumulate 可以通過 引數1 來引用外部的區域性變數,但因為其不在 lambda 中因此不必是 final
System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
// 計算並獲取(i = 10, p 為 i 的當前值, x 為引數1, 結果 i = 0, 返回 0)
// 其中函式中的操作能保證原子,但函式需要無副作用
System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));

5.3 原子引用

原子引用:AtomicReference AtomicMarkableReference AtomicStampedReference

我們需要解決併發問題的操作不只是對整數的修改,有時我們的物件修改讀取也是要進行原子操作的,例如我們的數值封裝類或者 BigDecimal 因此我們可以使用原子引用

示例:

AtomicReference<BigDecimal> ref = new AtomicReference<>();
ref.compareAndSet(new BigDecimal(1),new BigDecimal(2));

5.4 ABA 問題

問題:什麼叫 ABA 問題?顧名思義,由 A 到 B 再到 A ,我們前面說過,CAS 本質是比較再替換,那麼這裡的 A 已經被人修改兩次了,我們直接 CAS 是感知不到的。

解決方法:對要修改的值新增一個版本號,每次修改就讓版本號同時改變,AtomicStampedReference 可以給原子引用加上版本號,追蹤原子引用整個的變化過程,如: A - B - A - C ,通過AtomicStampedReference,我們可以知道,引用變數中途被更改了幾次。
但是有時候,並不關心引用變數更改了幾次,只是單純的關心是否更改過,所以就有了 AtomicMarkableReference

5.5 原子陣列

原子數值:AtomicIntegerArray AtomicLongArray AtomicReferenceArray

5.6 欄位更新器

欄位更新器:AtomicReferenceFieldUpdater AtomicIntegerFieldUpdater AtomicLongFieldUpdater

利用欄位更新器,可以針對物件的某個域(Field)進行原子操作,只能配合 volatile 修飾的欄位使用,否則會出現異常

Exception in thread "main" java.lang.IllegalArgumentException: Must be volatile type

示例:

public class Test5 {
    private volatile int field;
    public static void main(String[] args) {
        AtomicIntegerFieldUpdater fieldUpdater =
            AtomicIntegerFieldUpdater.newUpdater(Test5.class, "field");
        Test5 test5 = new Test5();
        fieldUpdater.compareAndSet(test5, 0, 10);
        // 修改成功 field = 10
        System.out.println(test5.field);
        // 修改成功 field = 20
        fieldUpdater.compareAndSet(test5, 10, 20);
        System.out.println(test5.field);
        // 修改失敗 field = 20
        fieldUpdater.compareAndSet(test5, 10, 30);
        System.out.println(test5.field);
    }
}

5.7 原子累加器

阿里《Java開發手冊》嵩山版提到過這樣一條建議:

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

以上內容共有兩個重點:

  1. 類似於 count++ 這種非一寫多讀的場景使用 volatile 解決不了併發問題;
  2. 如果是 JDK8 推薦使用 LongAdder 而非 AtomicLong 來替代 volatile,因為 LongAdder 的效能更好。

LongAdder與AtomicLong分析
此處省略

效能提升的原因很簡單,就是在有競爭時,設定多個累加單元,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]... 最後將結果彙總。這樣它們在累加時操作的不同的 Cell 變數,因此減少了 CAS 重試失敗,從而提高效能。

6. 共享模型--不可變

問題:
下面的程式碼在執行時,由於 SimpleDateFormat 不是執行緒安全的

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        try {
            log.debug("{}", sdf.parse("1951-04-21"));
        } catch (Exception e) {
            log.error("{}", e);
        }
    }).start();
}

有很大機率出現 java.lang.NumberFormatException 或者出現不正確的日期解析結果,因為指令並不是原子性的,多執行緒指令交錯會出現問題,其它非執行緒安全的資料結構的併發問題也多是此類原因

解決:

  • 同步鎖:首先我們可能想到的是加鎖來保證整個過程的原子性,但是毫無疑問,對效能有很大損失
  • 不可變:如果一個物件不能夠修改其內部狀態(屬性),那麼它就是執行緒安全的,因為不存在併發修改

不可變設計

我們熟知的 String 還有其它封裝類都是不可變設計

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    /** Cache the hash code for the string */
    private int hash; // Default to 0
    // ...
}

可以看見,String 內部是一個使用 final 修飾的字元陣列(Jdk 9變成了 byte 陣列)

final 的使用

  • 屬性用 final 修飾保證了該屬性是隻讀的,不能修改
  • 類用 final 修飾保證了該類中的方法不能被覆蓋,防止子類無意間破壞不可變性

保護性拷貝:

我們使用 String 的時候還是用上了很多修改的方法,可是前面明明說了 String 是不可變的,那是怎麼回事?其實在構造新字串物件時,會生成新的 char[] value,對內容進行復制 。這種通過建立副本物件來避免共享的手段稱之為【保護性拷貝

final 關鍵字底層原理

final 變數的設定與獲取原理和 volatile 關鍵字類似,都會新增上讀屏障寫屏障

無狀態

在 web 階段學習時,設計 Servlet 時為了保證其執行緒安全,都會有這樣的建議,不要為 Servlet 設定成員變數,這種沒有任何成員變數的類是執行緒安全的

因為成員變數儲存的資料也可以稱為狀態資訊,因此沒有成員變數就稱之為【無狀態】

7. 執行緒池

執行緒池的優勢:

  1. 降低系統資源消耗,通過重用已存在的執行緒,降低執行緒建立和銷燬造成的消耗;
  2. 提高系統響應速度,當有任務到達時,通過複用已存在的執行緒,無需等待新執行緒的建立便能立即執行;
  3. 方便執行緒併發數的管控。節省資源。

如何建立執行緒池:

Java 中建立執行緒池有以下兩種方式:

  • 通過 ThreadPoolExecutor 類建立(推薦)
  • 通過 Executors 類建立

其實這兩種方式在本質上是一種方式,都是通過 ThreadPoolExecutor 類的方式建立,因為 Executors 類呼叫了 ThreadPoolExecutor 類的方法。

7.1 ThreadPoolExecutor

image-20210801214747209

建立一個執行緒池:

public class MyThreadPool {

    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS,new ArrayBlockingQueue<>(20), new ThreadPoolExecutor.CallerRunsPolicy());

        for (int i = 1; i 5; i++) {
            // 建立WorkerThread物件
            Runnable worker = new Runnable(()->{});
            // 執⾏任務
            executor.execute(worker);
        }
    }

}

構造方法:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize 核心執行緒數目 (最多保留的執行緒數)

  • maximumPoolSize 最大執行緒數目

  • keepAliveTime 生存時間 - 針對救急執行緒

  • unit 時間單位 - 針對救急執行緒

  • workQueue 阻塞佇列,無剩餘執行緒時,新加入的任務進入此佇列等待

    常用的阻塞佇列:
    ArrayBlockingQueue;
    LinkedBlockingQueue;
    SynchronousQueue;
    
  • threadFactory 執行緒工廠 - 一般使用預設的執行緒建立工廠的方法 Executors.defaultThreadFactory()來建立執行緒。

  • handler 拒絕策略,阻塞佇列已滿並且無法建立新執行緒,就執行對應的拒絕策略

工作方式:

  • 執行緒池中剛開始沒有執行緒,當一個任務提交給執行緒池後,執行緒池會建立一個新執行緒來執行任務。

  • 當執行緒數達到 corePoolSize 並沒有執行緒空閒,這時再加入任務,新加的任務會被加入workQueue 佇列排隊,直到有空閒的執行緒。

  • 如果佇列選擇了有界佇列,那麼任務超過了佇列大小時,會建立 maximumPoolSize - corePoolSize 數目的執行緒來救急。

  • 如果執行緒到達 maximumPoolSize 仍然有新任務這時會執行拒絕策略。拒絕策略 Jdk 提供了 4 種實現方式。

    image-20210801220408491

    1. ThreadPoolExecutor.AbortPolicy: 丟棄任務並丟擲異常。
    2. ThreadPoolExecutor.DiscardPolicy:丟棄任務但不丟擲異常。
    3. ThreadPoolExecutor.DiscardOldestPolicy:丟棄佇列最前面的任務,然後重新嘗試執行任務
    4. ThreadPoolExecutor.CallerRunsPolicy:由呼叫執行緒處理該任務
  • 當高峰過去後,超過corePoolSize 的救急執行緒如果一段時間沒有任務做,需要結束節省資源,這個時間由keepAliveTimeunit 來控制。

  • 核心執行緒是不會自動釋放的

提交任務:

// 執行任務
void execute(Runnable command)
// 提交任務 task,用返回值 Future 獲得任務執行結果
<T> Future<T> submit(Callable<T> task)
// 提交 tasks 中所有任務
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
// 提交 tasks 中所有任務,帶超時時間
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit)
// 提交 tasks 中所有任務,哪個任務先成功執行完畢,返回此任務執行結果,其它任務取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
// 提交 tasks 中所有任務,哪個任務先成功執行完畢,返回此任務執行結果,其它任務取消,帶超時時間
<T> T invokeAny(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit)

關閉執行緒池:

/*
執行緒池狀態變為 SHUTDOWN
- 不會接收新任務
- 但已提交任務會執行完
- 此方法不會阻塞呼叫執行緒的執行
*/
void shutdown();

/*
執行緒池狀態變為 STOP
- 不會接收新任務
- 會將佇列中的任務返回
- 並用 interrupt 的方式中斷正在執行的任務
*/
List<Runnable> shutdownNow();

什麼?不是說 interrupt 只是改變中斷狀態位嗎?一般 wait sleep join都會感知該狀態位(其實 park 也可以,但是隻有它檢測到狀態位改變後不會給人改成 false),所以使用 interrupt 能夠中斷這些操作,但是為什麼還能中斷正在執行的執行緒?前面說過,interrupt 的出現能夠讓我們實現體面的停止執行緒,即我們可以一直檢測狀態位,如果狀態位改變了就執行對應的善後工作再手動 break

檢視 ThreadPoolExecutor 的執行任務的程式碼方法 runWorker

image-20210905115050485

可以看出,的確是在一個 while 迴圈中檢測我們的執行緒池的排程執行緒(對於執行緒池來說就是主執行緒)的狀態位,如果排程執行緒被 interrupt 打斷了,確保其中的任務執行緒都處於打斷狀態

7.2 Executors

  1. newFixedThreadPool 定長執行緒池

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
    

    特點:

    • 核心執行緒數 == 最大執行緒數(沒有救急執行緒被建立),因此也無需超時時間
    • 阻塞佇列是無界的,可以放任意數量的任務

    評價:適用於任務量已知,相對耗時的任務

  2. newCachedThreadPool 無限長執行緒池

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
    

    特點:

    • 核心執行緒數是 0, 最大執行緒數是 Integer.MAX_VALUE,救急執行緒的空閒生存時間是 60s
    • 佇列採用了 SynchronousQueue 實現特點是,它沒有容量,沒有執行緒來取是放不進去的,可近似看作沒有阻塞佇列,來者不拒,空閒釋放

    評價:整個執行緒池表現為執行緒數會根據任務量不斷增長,沒有上限,當任務執行完畢,空閒 1分鐘後釋放執行緒。 適合任務數比較密集,但每個任務執行時間較短的情況

  3. newSingleThreadExecutor 單執行緒執行

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
    

    使用場景:
    希望多個任務排隊執行。執行緒數固定為 1,任務數多於 1 時,會放入阻塞佇列排隊。任務執行完畢,這唯一的執行緒也不會被釋放,這樣可以保證所有的任務按序執行。

  4. newScheduledThreadPool 定長定時週期任務

    public static ScheduledExecutorService newScheduledThreadPool(
        int corePoolSize, ThreadFactory threadFactory) {
        return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
    }
    

    定長執行緒池,用來執行一些定時任務、週期任務

  5. newWorkStealingPool

    這個是建立的 ForkJoinPool

7.3 Fork/Join

概念
Fork/JoinJDK 1.7 加入的新的執行緒池實現,它體現的是一種分治思想,適用於能夠進行任務拆分的 cpu 密集型運算
所謂的任務拆分,是將一個大任務拆分為演算法上相同的小任務,直至不能拆分可以直接求解。跟遞迴相關的一些計算,如歸併排序、斐波那契數列、都可以用分治思想進行求解
Fork/Join 在分治的基礎上加入了多執行緒,可以把每個任務的分解和合並交給不同的執行緒來完成,進一步提升了運算效率
Fork/Join 預設會建立與 cpu 核心數大小相同的執行緒池

使用

提交給 Fork/Join 執行緒池的任務需要繼承 RecursiveTask(有返回值)或 RecursiveAction(沒有返回值)

例如下面定義了一個對 1~n 之間的整數求和的任務

class AddTask1 extends RecursiveTask<Integer> {
    int n;
    public AddTask1(int n) {
        this.n = n;
    }
    @Override
    public String toString() {
        return "{" + n + '}';
    }
    @Override
    protected Integer compute() {
        // 如果 n 已經為 1,可以求得結果了
        if (n == 1) {
            log.debug("join() {}", n);
            return n;
        }
        // 將任務進行拆分(fork)
        AddTask1 t1 = new AddTask1(n - 1);
        t1.fork();
        log.debug("fork() {} + {}", n, t1);
        // 合併(join)結果
        int result = n + t1.join();
        log.debug("join() {} + {} = {}", n, t1, result);
        return result;
    }
}

然後提交給 ForkJoinPool 來執行

public static void main(String[] args) {
    ForkJoinPool pool = new ForkJoinPool(4);
    System.out.println(pool.invoke(new AddTask1(5)));
}

參考教程

黑馬程式設計師-Java併發程式設計

相關文章