JUC學習筆記——進程與執行緒

JUC學習筆記——進程與執行緒

在本系列內容中我們會對JUC做一個系統的學習,本片將會介紹JUC的進程與執行緒部分

我們會分為以下幾部分進行介紹:

  • 進程與執行緒
  • 並發與並行
  • 同步與非同步
  • 執行緒詳解

進程與執行緒

在這一小節我們將簡單介紹進程與執行緒

進程

首先我們來簡單了解一下程式:

  • 程式由指令和數據組成,我們必須將指令載入至 CPU,數據載入至記憶體。在指令運行過程中還需要用到磁碟、網路等設備。

接下來我們才能講解進程的定義:

  • 進程就是用來載入指令、管理記憶體、管理 IO 的
  • 當一個程式被運行,從磁碟載入這個程式的程式碼至記憶體,這時就開啟了一個進程。
  • 進程就可以視為程式的一個實例。大部分程式可以同時運行多個實例進程,也有的程式只能啟動一個實例進程。

執行緒

我們來簡單介紹一下執行緒:

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

兩者區別

我們來介紹一下進程與執行緒之間的區別:

  • 進程基本上相互獨立的,而執行緒存在於進程內,是進程的一個子集

  • 進程擁有共享的資源,如記憶體空間等,供其內部的執行緒共享

  • 執行緒更輕量,執行緒上下文切換成本一般上要比進程上下文切換低

此外兩者的通訊方式也不相同:

  • 進程通訊:同一台電腦的進程通訊稱為 IPC;不同電腦之間的進程通訊,需要通過網路,並遵守共同的協議,例如 HTTP
  • 執行緒通訊:執行緒通訊相對簡單,因為它們共享進程內的記憶體,一個例子是多個執行緒可以訪問同一個共享變數

並發與並行

在這一小節我們將簡單介紹並發與並行

並發

首先我們需要了解一下任務調度器:

  • 單核 cpu 下,執行緒實際還是 串列執行 的。
  • 作業系統中有一個組件叫做任務調度器,將 cpu 的時間片分給不同的程式使用。

那麼我們的並發實際上就是根據任務調度器來工作的:

  • 並發是藉助任務調度器,在一段時間段內將CPU分給多個執行緒使用,但由於切換快時間短,所以被看作是同時進行
  • 一般會將這種 執行緒輪流使用 CPU 的做法稱為並發
  • 實際上並發的情況是:微觀串列,宏觀並行

通俗來講:

  • 並發(concurrent)是同一時間應對(dealing with)多件事情的能力

例如下圖:

CPU 時間片 1 時間片 2 時間片 3 時間片 4
core 執行緒 1 執行緒 2 執行緒 3 執行緒 4

並行

並行的概念就相對而言比較簡單了:

  • 並行就是藉助多核CPU,確確實實地在同一時間執行多個進程

通俗來講:

  • 並行(parallel)是同一時間動手做(doing)多件事情的能力

例如下圖:

CPU 時間片 1 時間片 2 時間片 3 時間片 4
core 1 執行緒 1 執行緒 1 執行緒 3 執行緒 3
core 2 執行緒 2 執行緒 4 執行緒 2 執行緒 4

同步與非同步

在這一小節我們將簡單介紹並發與並行

同步與非同步概念

首先我們來簡單介紹一下同步與非同步:

  • 需要等待結果返回才能繼續執行的操作就是同步操作
  • 不需要等待結果返回就可以繼續執行的操作就是非同步操作

另外同步操作還有另一個概念:

  • 在多執行緒中,表示多執行緒的步調一致

同步與非同步選擇方法

我們的同步與非同步的選擇通常會決定程式的運行速度,因而選擇同步或非同步是非常重要的

我們先來介紹同步與非同步的實現方式:

  • 同步就是在一個執行緒內完全執行所有命令
  • 非同步可以在多執行緒中實現,當一個執行緒執行複雜操作比較耗時時,另一個執行緒可以執行其他簡單操作

我們再來介紹同步與非同步的選擇方法:

  • 針對比較繁瑣的操作,我們通常會單獨創建一個新執行緒來進行處理,避免阻塞主執行緒
  • Tomcat裡面的非同步Servlet也是非同步操作,讓用戶執行緒處理耗時較長的操作,避免阻塞Tomcat的工作執行緒
  • UI程式中,開執行緒進行其他操作,避免UI執行緒

我們分別給出同步非同步的程式碼展示:

// 同步程式碼

package cn.itcast.n2;

import cn.itcast.Constants;
import cn.itcast.n2.util.FileReader;
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.Sync")
public class Sync {

    public static void main(String[] args) {
        FileReader.read(Constants.MP4_FULL_PATH);
        log.debug("do other things ...");
    }

}

// 非同步程式碼

package cn.itcast.n2;

import cn.itcast.Constants;
import cn.itcast.n2.util.FileReader;
import lombok.extern.slf4j.Slf4j;


@Slf4j(topic = "c.Async")
public class Async {

    public static void main(String[] args) {
        new Thread(() -> FileReader.read(Constants.MP4_FULL_PATH)).start();
        log.debug("do other things ...");
    }

}

同步與非同步實際使用

我們通常採用非同步操作來實現應用的速度提升:

// 例如我們有下面三個操作

計算 1 花費 10 ms
計算 2 花費 11 ms
計算 3 花費 9 ms
匯總需要 1 ms

如果我們採用主執行緒的同步操作來實現:

// 如果是串列執行,那麼總共花費的時間是 10 + 11 + 9 + 1 = 31ms

但是如果我們採用三個CPU的非同步操作來實現:

// 但如果是四核 cpu,各個核心分別使用執行緒 1 執行計算 1,執行緒 2 執行計算 2,執行緒 3 執行計算 3
// 那麼 3 個執行緒是並行的,花費時間只取決於最長的那個執行緒運行的時間,即 11ms 最後加上匯總時間只會花費 12ms

下面我們給出同步非同步的實際使用規則:

  • 多核多執行緒速度快於單核多執行緒(非同步進行速度較快)
  • 單核多執行緒速度慢於單核單執行緒(執行緒切換也需要耗費時間)

但是單核多執行緒也並非是一無是處:

  • 單核 cpu 下,多執行緒不能實際提高程式運行效率,只是為了能夠在不同的任務之間切換
  • 不同執行緒輪流使用cpu ,不至於一個執行緒總佔用 cpu,別的執行緒沒法幹活

此外多核 cpu 可以並行跑多個執行緒,但能否提高程式運行效率還是要分情況的 :

  • 有些任務,經過精心設計,將任務拆分,並行執行,當然可以提高程式的運行效率。但不是所有計算任務都能拆分
  • 也不是所有任務都需要拆分,任務的目的如果不同,談拆分和效率沒啥意義

最後就是我們的IO操作部分:

  • IO 操作不佔用 cpu,只是我們一般拷貝文件使用的是【阻塞 IO】
  • 這時相當於執行緒雖然不用 cpu,但需要一直等待 IO 結束,沒能充分利用執行緒
  • 所以才有後面的【非阻塞 IO】和【非同步 IO】優化

執行緒詳解

這一小節我們將詳細介紹執行緒的具體內容

創建和運行執行緒

我們下面將介紹三種創建和運行執行緒的方法

直接使用 Thread

我們可以直接使用Thread來創建和運行執行緒:

// 創建執行緒對象
Thread t = new Thread() {
    public void run() {
        // 要執行的任務
    }
};
// 啟動執行緒
t.start();

我們再給出一個實際例子:

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

我們給出實際輸出結果:

// 我們會注意到:前面標記了[t1]執行緒~
19:19:00 [t1] c.ThreadStarter - hello

使用 Runnable 配合 Thread

這裡我們將Thread裡面的方法採用Runnable類型的方法來代替:

// 創建Runnable類型的方法
Runnable runnable = new Runnable() {
    public void run(){
        // 要執行的任務
    }
};
// 創建執行緒對象
Thread t = new Thread( runnable );
// 啟動執行緒
t.start(); 

我們給出一個實際例子:

// 創建任務對象
Runnable task2 = new Runnable() {
    @Override
    public void run() {
        log.debug("hello");
    }
};
// 參數1 是任務對象; 參數2 是執行緒名字,推薦
Thread t2 = new Thread(task2, "t2");
t2.start();

其結果為:

// 結果正常
9:19:00 [t2] c.ThreadStarter - hello

除此之外,我們在JDK8之後,我們可以採用函數式介面Lambda來簡化Runnable的書寫:

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

甚至我們都不用定義task,來直接採用Lambda方法書寫Thread中的task:

// 參數1 是任務對象; 參數2 是執行緒名字,推薦
Thread t2 = new Thread(() -> log.debug("hello"), "t2");
t2.start();

底層簡單解釋:

  • 至於Thread為什麼能夠直接調用Runnable
  • Thread在接收Runnable類型後,會將其賦值在this.target
  • 而Thread的run方法會先來判斷是否存在target,如果存在就直接採用target方法

最後我們介紹一下使用Runnable的好處:

  • 方法1 是把執行緒和任務合併在了一起,方法2 是把執行緒和任務分開了
  • 用 Runnable 更容易與執行緒池等高級API 配合
  • 用 Runnable 讓任務類脫離了 Thread 繼承體系,更靈活

使用FutureTask 配合 Thread(了解即可)

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

// 創建任務對象(Integer是返回對象)
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);

我們給出結果:

19:22:27 [t3] c.ThreadStarter - hello
19:22:27 [main] c.ThreadStarter - 結果是:100

我們給出簡單解釋:

  • FutureTask內置了一個Callable對象,初始化方法將指定的Callable賦給這個對象。
  • FutureTask實現了Runnable介面,並重寫了Run方法,在Run方法中調用了Callable中的call方法,並將返回值賦值給outcome變數
  • get方法就是取出outcome的值。

多執行緒運行狀況

我們給出單核CPU運行多執行緒時不斷切換進程的狀況展示:

// 下述的操作不受我們控制,誰先調用,調用多久都不是我們管控的

@Slf4j(topic = "c.TestMultiThread")
public class TestMultiThread {

    public static void main(String[] args) {
        new Thread(() -> {
            while(true) {
                log.debug("running");
            }
        },"t1").start();
        new Thread(() -> {
            while(true) {
                log.debug("running");
            }
        },"t2").start();
    }
}

我們直接給出結果展示:

23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running

查看進程執行緒方法

由於不同系統的查看方法不同,我們主要介紹三種類型查看方法

Window

  • 任務管理器可以查看進程和執行緒數,也可以用來殺死進程
  • tasklist 查看進程 :tasklist| findstr (查找關鍵字)
  • taskkill 殺死進程:taskkill /F(徹底殺死)/PID(進程PID)

Linux

  • ps -fe 查看所有進程
  • ps -fT -p 查看某個進程(PID)的所有執行緒
  • kill 殺死進程 top 按大寫 H 切換是否顯示執行緒
  • top -H -p 查看某個進程(PID)的所有執行緒

Java

  • jps 命令查看所有 Java 進程
  • jstack 查看某個 Java 進程(PID)的所有執行緒狀態
  • jconsole 來查看某個 Java 進程中執行緒的運行情況(圖形介面)

執行緒運行底層解釋

我們將會介紹兩個與執行緒底層運行相關的原理

棧與棧幀

下面我們來介紹一下與進程息息相關的底層原理:

  • 棧:存放棧幀的個體,每個執行緒具有一個單獨的棧來存放多個棧幀
  • 棧幀:方法的全部記憶體佔用,每個方法使用時的全部記憶體都用一個棧幀來表示

同時棧和棧幀也有一定限制:

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

我們給出一個簡單的程式碼展示:

// 這裡展現的是單執行緒,目前只有一個棧!

package cn.itcast.n3;

public class TestFrames {
    
    // 首先main方法被調用,所以main方法先進入棧中,在main方法執行結束後被拋出棧
    public static void main(String[] args) {
        method1(10);
    }

    // 由於main方法調用了method1,所以棧中在存有main棧幀的同時也將method1棧幀調入,在method1方法執行完畢後拋出
    private static void method1(int x) {
        int y = x + 1;
        Object m = method2();
        System.out.println(m);
    }

    // 由於method1方法調用了method2,所以棧中在存有main,method1棧幀的同時也將method2棧幀調入,method2方法執行完畢後拋出
    private static Object method2() {
        Object n = new Object();
        return n;
    }
}

// 這裡展現的是多執行緒,主執行緒和執行緒t1獨自各佔有一個棧,互不影響!

package cn.itcast.n3;

public class TestFrames {
    // 這裡會產生兩個棧,兩個棧互不影響,兩個棧都會順序調用main,method1,method2棧幀,順序不定
    public static void main(String[] args) {
        Thread t1 = new Thread(){
            @Override
            public void run() {
                method1(20);
            }
        };
        t1.setName("t1");
        t1.start();
        method1(10);
    }

    private static void method1(int x) {
        int y = x + 1;
        Object m = method2();
        System.out.println(m);
    }

    private static Object method2() {
        Object n = new Object();
        return n;
    }
}

執行緒上下文切換

我們再來介紹一下上下文切換:

  • 當出現一些狀況時,系統會自動將CPU的使用權進行切換,交付給不同的執行緒進行使用

上下文切換措施:

  • 要由作業系統保存當前執行緒的狀態,並恢復另一個執行緒的狀態,
  • Java 中對應的就是程式計數器,它的作用是記住下一條 jvm 指令的執行地址,是執行緒私有的
  • 狀態包括程式計數器、虛擬機棧中每個棧幀的資訊,如局部變數、操作數棧、返回地址等

上下文切換時機:

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

但是我們需要注意:

  • Context Switch 頻繁發生會影響性能

執行緒方法詳解

這一小節我們將介紹執行緒的各種方法

執行緒方法總述

我們首先給出執行緒的全部方法一覽:

方法 功能 說明
public void start() 啟動一個新執行緒;Java虛擬機調用此執行緒的run方法 start 方法只是讓執行緒進入就緒,裡面程式碼不一定立刻 運行(CPU 的時間片還沒分給它)。每個執行緒對象的 start方法只能調用一次,如果調用了多次會出現 IllegalThreadStateException
public void run() 執行緒啟動後調用該方法 如果在構造 Thread 對象時傳遞了 Runnable 參數,則 執行緒啟動後會調用 Runnable 中的 run 方法,否則默 認不執行任何操作。但可以創建 Thread 的子類對象, 來覆蓋默認行為
public void setName(String name) 給當前執行緒取名字
public void getName() 獲取當前執行緒的名字。執行緒存在默認名稱:子執行緒是Thread-索引,主執行緒是main
public static Thread currentThread() 獲取當前執行緒對象,程式碼在哪個執行緒中執行
public static void sleep(long time) 讓當前執行緒休眠多少毫秒再繼續執行。Thread.sleep(0) : 讓作業系統立刻重新進行一次cpu競爭
public static native void yield() 提示執行緒調度器讓出當前執行緒對CPU的使用 主要是為了測試和調試
public final int getPriority() 返回此執行緒的優先順序
public final void setPriority(int priority) 更改此執行緒的優先順序,常用1 5 10 java中規定執行緒優先順序是1~10 的整數,較大的優先順序 能提高該執行緒被 CPU 調度的機率
public void interrupt() 中斷這個執行緒,異常處理機制
public static boolean interrupted() 判斷當前執行緒是否被打斷,清除打斷標記
public boolean isInterrupted() 判斷當前執行緒是否被打斷,不清除打斷標記
public final void join() 等待這個執行緒結束
public final void join(long millis) 等待這個執行緒死亡millis毫秒,0意味著永遠等待
public final native boolean isAlive() 執行緒是否存活(還沒有運行完畢)
public final void setDaemon(boolean on) 將此執行緒標記為守護執行緒或用戶執行緒
public long getId() 獲取執行緒長整型 的 id id 唯一
public state getState() 獲取執行緒狀態 Java 中執行緒狀態是用 6 個 enum 表示,分別為: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
public boolean isInterrupted() 判斷是否被打 斷 不會清除 打斷標記

start與run

我們首先來介紹執行緒的兩個相似的啟動方法:

// 首先我們採用start:start方法是啟動該執行緒,執行緒開始運行

public static void main(String[] args) {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug(Thread.currentThread().getName());
            FileReader.read(Constants.MP4_FULL_PATH);
        }
    };
    t1.start();
    log.debug("do other things ...");
}

// 然後我們直接使用run方法:run方法是調用該執行緒的run方法,實際是main執行緒在運行t1執行緒的run方法

public static void main(String[] args) {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug(Thread.currentThread().getName());
            FileReader.read(Constants.MP4_FULL_PATH);
        }
    };
    t1.run();
    log.debug("do other things ...");
}

同時我們可以通過查看執行緒狀態來判斷start和run的區別:

// 這裡我們僅對start判斷

public static void main(String[] args) {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug("running...");
        }
    };
    System.out.println(t1.getState());
    t1.start();
    System.out.println(t1.getState());
}

// 我們可以注意到main狀態從就緒狀態切換為Runnable
NEW
RUNNABLE
03:45:12.255 c.Test5 [t1] - running...

所以我們給出小結:

  • 直接調用 run 是在主執行緒中執行了 run,沒有啟動新的執行緒

  • 使用 start 是啟動新的執行緒,通過新的執行緒間接執行 run 中的程式碼

sleep 與 yield

我們首先給出sleep的相關解釋:

  • 調用 sleep 會讓當前執行緒從 Running 進入 Timed Waiting 狀態(阻塞)
  • 其它執行緒可以使用 interrupt 方法打斷正在睡眠的執行緒,這時 sleep 方法會拋出 InterruptedException
  • 睡眠結束後的執行緒未必會立刻得到執行
  • 建議用 TimeUnit 的 sleep 代替 Thread 的 sleep 來獲得更好的可讀性 。其底層還是sleep方法
  • 在循環訪問鎖的過程中,可以加入sleep讓執行緒阻塞時間,防止大量佔用cpu資源

我們再給出yield的相關解釋:

  • 調用 yield 會讓當前執行緒從 Running 進入 Runnable 就緒狀態,然後調度執行其它執行緒;具體的實現依賴於作業系統的任務調度器
  • 加入當前執行緒中只有這一個方法,那麼停止該方法執行後仍舊執行該方法

我們採用程式碼來進行展示:

/*sleep狀態轉換:我們運行下面程式碼後可以看到其t1狀態從就緒狀態到Runnable到Timed Waiting*/

public static void main(String[] args) {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug("running...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                log.debug("wake up...");
                e.printStackTrace();
            }
        }
    };
    System.out.println(t1.getState());
    t1.start();
    System.out.println(t1.getState());
    Thread.sleep(2000);
    System.out.println(t1.getState());
}

/*sleep打斷睡眠執行緒,拋出異常:注意當睡眠時拋出異常後該程式的打斷狀態為false*/ 

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug("enter sleep...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                log.debug("wake up...");
                e.printStackTrace();
            }
        }
    };
    t1.start();

    Thread.sleep(1000);
    log.debug("interrupt...");
    t1.interrupt();
}

// 輸出結果為:
03:47:18.141 c.Test7 [t1] - enter sleep...
03:47:19.132 c.Test7 [main] - interrupt...
03:47:19.132 c.Test7 [t1] - wake up...
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at cn.itcast.test.Test7$1.run(Test7.java:14)
    
/*sleep採用TimeUnit的方法更具有程式碼可讀性*/
    
@Slf4j(topic = "c.Test8")
public class Test8 {

    public static void main(String[] args) throws InterruptedException {
        log.debug("enter");
        TimeUnit.SECONDS.sleep(1);
        log.debug("end");
//        Thread.sleep(1000);
    }
}

/*yield簡單測試:我們需要在Linux虛擬機中使用單核cpu來處理該程式碼,下面我們同時使用一個cpu處理兩個進程*/

@Slf4j(topic = "c.TestYield")
public class TestYield {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            int count = 0;
            for (;;) {
                System.out.println("---->1 " + count++);
            }
        };
        Runnable task2 = () -> {
            int count = 0;
            for (;;) {
                Thread.yield();
                System.out.println("---->2 " + count++);
            }
        };
        Thread t1 = new Thread(task1, "t1");
        Thread t2 = new Thread(task2, "t2");
        t1.start();
        t2.start();
    }
}

// 我們會發現task1的count數更多,因為輪到task2時,它會調用yield可能會導致當前執行緒停止從而去運行另一個執行緒
---->1 119199
---->2 101074

最後我們講解一個項目中常用的案例:

// 在沒有利用 cpu 來計算時,不要讓 while(true) 空轉浪費 cpu,這時可以使用 yield 或 sleep 來讓出 cpu 的使用權 給其他程式

while(true) {
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

// - 可以用 wait 或 條件變數達到類似的效果 
// - 不同的是,後兩種都需要加鎖,並且需要相應的喚醒操作,一般適用於要進行同步的場景 
// - sleep 適用於無需鎖同步的場景

// wait實現

synchronized(鎖對象) {
    while(條件不滿足) { 
        try {
            鎖對象.wait();
        } catch(InterruptedException e) {
            e.printStackTrace();
        }
    }
    // do sth...
}

// 條件變數實現

lock.lock();
try {
    while(條件不滿足) {
        try {
            條件變數.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // do sth...
} finally {
    lock.unlock();
}

Priority

下面我們來介紹執行緒的優先順序設置:

  • 首先優先順序的大小從1~10,默認為5
  • 優先順序只是起到一定影響作用,不會對執行緒的執行順序起到絕對作用,只是優先順序越高其獲得CPU可能性越大

我們給出簡單程式碼示例:

// 我們同樣需要在單核CPU下進行

@Slf4j(topic = "c.TestYield")
public class TestYield {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            int count = 0;
            for (;;) {
                System.out.println("---->1 " + count++);
            }
        };
        Runnable task2 = () -> {
            int count = 0;
            for (;;) {
                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();
    }
}

// 運行結果:我們會發現t2的執行次數明顯高於t1
---->1 283500
---->2 374389

join

首先我們需要了解join的作用:

  • 用於等待直至所指執行緒結束為止

我們採用一個簡單的例子進行解釋:

// 下述例子我們希望列印執行緒t1的r的值,但是我們都知道main和t1執行緒都是同時運行的,並且t1等待了1s,所以這時的r是沒有賦值的,為0

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    test1();
}
private static void test1() throws InterruptedException {
    log.debug("開始");
    Thread t1 = new Thread(() -> {
        log.debug("開始");
        sleep(1);
        log.debug("結束");
        r = 10;
    });
    t1.start();
    log.debug("結果為:{}", r);
    log.debug("結束");
}

// 但是我們可以選擇使用join方法來使主執行緒等待,知道t1執行緒結束後再去運行main執行緒

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    test1();
}
private static void test1() throws InterruptedException {
    log.debug("開始");
    Thread t1 = new Thread(() -> {
        log.debug("開始");
        sleep(1);
        log.debug("結束");
        r = 10;
    });
    t1.start();
    log.debug("結果為:{}", r);
    t1.join();
    log.debug("結果為:{}", r);
    log.debug("結束");
}

// 注意我們如果採用sleep或許也可以實現相同的效果,但是很難準確確定其實際執行緒的結束時刻,所以正常情況下無法使用sleep

此外我們還需要講解join的其他幾個性質:

  • 我們可以藉助join來實現執行緒之間的同步操作
  • 當多個執行緒都採用join時,我們需要等所有執行緒都結束後繼續運行
  • join是可以設置時效性的,當超過時則不再等待而是直接運行,若不超過就按照執行緒結束時間計算

我們通過幾個案例解釋上述性質:

// 藉助join完成執行緒之間的同步操作(其實前面第一個例子就是同步操作,我們需要先完成t1執行緒才能執行main執行緒的內容)

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    test1();
}
private static void test1() throws InterruptedException {
    log.debug("開始");
    Thread t1 = new Thread(() -> {
        log.debug("開始");
        sleep(1);
        log.debug("結束");
        r = 10;
    });
    t1.start();
    t1.join();
    log.debug("結果為:{}", r);
    log.debug("結束");
}

// 多個join需要等待所有執行緒完成
// 如下述,一個需要1s,一個需要2s,但由於同時進行,所以我們只需要2s就可以全部執行,然後再執行main

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是可以設置時效性的,當超過時則不再等待而是直接運行,若不超過就按照執行緒結束時間計算

static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
    test3();
}
public static void test3() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        // 這裡設置時間,若低於1.5s按sleep時間執行;若高於1.5s則在1.5s時執行
        sleep(1);
        r1 = 10;
    });
    long start = System.currentTimeMillis();
    t1.start();
    // 執行緒執行結束會導致 join 結束
    t1.join(1500);
    long end = System.currentTimeMillis();
    log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}

interrupt

我們首先來簡單介紹一下interrupt方法:

  • 翻譯過來就是打斷,本質是將執行緒的打斷標記設為true,並調用執行緒的三個parker對象(C++實現級別)unpark該執行緒。

我們來進行更詳細的介紹:

  • 打斷操作實際上只是通知,不是打斷
  • 打斷執行緒不等於中斷執行緒,有以下兩種情況:
  • 打斷正在運行中的執行緒並不會影響執行緒的運行,但如果執行緒監測到了打斷標記為true,可以自行決定後續處理。
  • 打斷阻塞中的執行緒會讓此執行緒產生一個InterruptedException異常,結束執行緒的運行。
  • 但如果該異常被執行緒捕獲住,該執行緒依然可以自行決定後續處理(終止運行,繼續運行,做一些善後工作等等)

總而言之我們的打斷其實主要分為兩種類型,我們採用程式碼解釋:

/* 正常運行情況下打斷,並不會影響執行緒正常運行,但是會將執行緒的打斷標記設置為true */

package cn.itcast.test;

import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.Test12")
public class Test12 {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            // 這裡就是一個正常運行的執行緒,我們在啟動後1s對他進行打斷
            while(true) {
                // 打斷不會直接對執行緒造成影響,但是會將打斷狀態interrupted變為true(由isInterrupted方法得到)
                boolean interrupted = Thread.currentThread().isInterrupted();
                // 然後我們可以自行根據打斷狀態來做對應處理!
                if(interrupted) {
                    log.debug("被打斷了, 退出循環");
                    break;
                }
            }
        }, "t1");
        t1.start();

        // 1s後實現打斷操作
        Thread.sleep(1000);
        log.debug("interrupt");
        t1.interrupt();
    }
}

// 產生結果為:程式不停止,但打斷狀態為true
20:57:37.964 [t2] c.TestInterrupt - 打斷狀態: true

/* 不正常狀態被打斷,例如sleep,yield,join,會使執行緒進入阻塞狀態,拋出異常,會清空打斷狀態,使其變為false */

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

最後我們介紹一個用於interrupt的思想模式之兩階段終止:

  • 我們需要在執行緒1調用執行緒2的interrupt方法,同時使執行緒2完成它的後續操作

我們首先給出其邏輯圖:

我們通過程式碼解釋:

/*首先利用isinterrupt方法來實現思想*/

// 我們需要書寫一個檢測類負責不斷檢測打斷狀態
class TPTInterrupt {
    private Thread thread;
    public void start(){
        thread = new Thread(() -> {
            // 首先不斷檢測
            while(true) {
                Thread current = Thread.currentThread();
                // 當我們發現被打斷時
                if(current.isInterrupted()) {
                    // 自行處理後續內容
                    log.debug("料理後事");
                    break;
                }
                // 當我們發現沒有被打斷時,我們將程式停止一段時間,並進行簡單處理
                try {
                    Thread.sleep(1000);
                    log.debug("將結果保存");
                } catch (InterruptedException e) {
                    // 這裡我們需要注意,如果存在sleep等操作就會導致拋出異常InterruptedException
                    // 但是拋出異常並不會導致程式結束,也不會導致打斷標記為true,
                    // 所以我們需要手動設置打斷標記為true,使其在下一次循環時,中斷程式
                    current.interrupt();
                }
                // 執行監控操作 
            }
        },"監控執行緒");
        thread.start();
    }
    public void stop() {
        thread.interrupt();
    }
}

// 我們在主程式調用:
TPTInterrupt t = new TPTInterrupt();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();

// 我們可以得到結果:
11:49:42.915 c.TwoPhaseTermination [監控執行緒] - 將結果保存
11:49:43.919 c.TwoPhaseTermination [監控執行緒] - 將結果保存
11:49:44.919 c.TwoPhaseTermination [監控執行緒] - 將結果保存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop 
11:49:45.413 c.TwoPhaseTermination [監控執行緒] - 料理後事

/*我們還可以手動設置一個參數來負責執行緒的關閉*/
    
// 停止標記用 volatile 是為了保證該變數在多個執行緒之間的可見性
// 我們的例子中,即主執行緒把它修改為 true 對 t1 執行緒可見
class TPTVolatile {
    private Thread thread;
    private volatile boolean stop = false;
    public void start(){
        thread = new Thread(() -> {
            while(true) {
                Thread current = Thread.currentThread();
                if(stop) {
                    log.debug("料理後事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("將結果保存");
                } catch (InterruptedException e) {
                }
                // 執行監控操作
            }
        },"監控執行緒");
        thread.start();
    }
    public void stop() {
        stop = true;
        thread.interrupt();
    }
}

// 我們在主程式中調用:
TPTVolatile t = new TPTVolatile();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();

// 我們得到下屬結果:
11:54:52.003 c.TPTVolatile [監控執行緒] - 將結果保存
11:54:53.006 c.TPTVolatile [監控執行緒] - 將結果保存
11:54:54.007 c.TPTVolatile [監控執行緒] - 將結果保存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop 
11:54:54.502 c.TPTVolatile [監控執行緒] - 料理後事
    
/*我們還可以通過打斷 park 執行緒來實現思想*/
    
// 首先我們簡單介紹一下park:當打斷標記為false時,park起到執行緒暫停作用;當打斷標記為true時,繼續運行
    
// 首先我們給出打斷標記為false的狀態
    
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() 清除打斷狀態
// Thread.interrupted()獲得當前打斷狀態,並將打斷狀態設置為false
// Thread.currentThread().isInterrupted())只能獲得當前打斷狀態,不會影響值
    
/*
此外還有一些不符合當前狀態的打斷方式,我們也簡單介紹一下:
- 使用執行緒對象的 stop() 方法停止執行緒 
  - stop 方法會真正殺死執行緒,如果這時執行緒鎖住了共享資源,那麼當它被殺死後就再也沒有機會釋放鎖, 其它執行緒將永遠無法獲取鎖 
- 使用 System.exit(int) 方法停止執行緒 
  - 目的僅是停止一個執行緒,但這種做法會讓整個程式都停止
*/

過時方法介紹

最後我們給出三個執行緒的過時方法簡單解釋:

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

主執行緒和守護執行緒

首先我們簡單介紹一下守護執行緒:

  • 守護執行緒是不重要的執行緒,當所有的主執行緒都完成時,無論守護執行緒是否結束都被迫結束

我們給出簡單示例:

// 主函數程式碼:

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 - 運行結束... 

我們可以簡單給出守護執行緒的一些實例:

  • 垃圾回收器執行緒就是一種守護執行緒
  • Tomcat 中的 Acceptor 和 Poller 執行緒都是守護執行緒,所以 Tomcat 接收到 shutdown 命令後,不會等待它們處理完當前請求

執行緒狀態

這一小節我們將介紹執行緒的兩種狀態形式

執行緒五種狀態

從作業系統的角度來講,執行緒具有五種狀態:

我們來簡單介紹一下:

  • 【初始狀態】僅是在語言層面創建了執行緒對象,還未與作業系統執行緒關聯
  • 【可運行狀態】(就緒狀態)指該執行緒已經被創建(與作業系統執行緒關聯),可以由 CPU 調度執行
  • 【運行狀態】指獲取了 CPU 時間片運行中的狀態
    • 當 CPU 時間片用完,會從【運行狀態】轉換至【可運行狀態】,會導致執行緒的上下文切換
  • 【阻塞狀態】
    • 如果調用了阻塞 API,如 BIO 讀寫文件,這時該執行緒實際不會用到 CPU,會導致執行緒上下文切換,進入 【阻塞狀態】
    • 等 BIO 操作完畢,會由作業系統喚醒阻塞的執行緒,轉換至【可運行狀態】
    • 與【可運行狀態】的區別是,對【阻塞狀態】的執行緒來說只要它們一直不喚醒,調度器就一直不會考慮 調度它們
  • 【終止狀態】表示執行緒已經執行完畢,生命周期已經結束,不會再轉換為其它狀態

執行緒六種狀態

從Java虛擬機的角度來看,將其分為六種狀態:

我們來簡單介紹一下:

  • NEW 執行緒剛被創建,但是還沒有調用 start() 方法
  • RUNNABLE 當調用了 start() 方法之後,注意,Java API 層面的 RUNNABLE 狀態涵蓋了 作業系統 層面的 【可運行狀態】、【運行狀態】和【阻塞狀態】(由於 BIO 導致的執行緒阻塞,在 Java 里無法區分,仍然認為 是可運行)
  • BLOCKED , WAITING , TIMED_WAITING 都是 Java API 層面對【阻塞狀態】的細分,後面會在狀態轉換一節 詳述
  • TERMINATED 當執行緒程式碼運行結束

我們給出相關程式碼進行解釋:

package cn.itcast.n3;

import lombok.extern.slf4j.Slf4j;

import java.io.IOException;

@Slf4j(topic = "c.TestState")
public class TestState {
    public static void main(String[] args) throws IOException {
        
        // 程式碼完善,但並未運行,屬於NEW狀態
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                log.debug("running...");
            }
        };

        // 程式碼完善且一直運行,屬於runnable狀態
        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                while(true) { // runnable

                }
            }
        };
        t2.start();

        // 執行一次結束,屬於執行緒執行完畢,TERMINATED 狀態
        Thread t3 = new Thread("t3") {
            @Override
            public void run() {
                log.debug("running...");
            }
        };
        t3.start();

        // 由sleep停止進程,屬於timed_waiting狀態
        Thread t4 = new Thread("t4") {
            @Override
            public void run() {
                synchronized (TestState.class) {
                    try {
                        Thread.sleep(1000000); // timed_waiting
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t4.start();

        // 由於等待其他進程結束而等待,屬於waiting狀態
        Thread t5 = new Thread("t5") {
            @Override
            public void run() {
                try {
                    t2.join(); // waiting
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        t5.start();

        // 由於等待鎖而等待,屬於blocked狀態
        Thread t6 = new Thread("t6") {
            @Override
            public void run() {
                synchronized (TestState.class) { // blocked
                    try {
                        Thread.sleep(1000000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t6.start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 輸出其狀態
        log.debug("t1 state {}", t1.getState());
        log.debug("t2 state {}", t2.getState());
        log.debug("t3 state {}", t3.getState());
        log.debug("t4 state {}", t4.getState());
        log.debug("t5 state {}", t5.getState());
        log.debug("t6 state {}", t6.getState());
        System.in.read();
    }
}

本章小結

我們簡單總結一下重點:

  • 執行緒創建
  • 執行緒重要 api,如 start,run,sleep,join,interrupt 等
  • 執行緒狀態
  • 應用方面
    • 非同步調用:主執行緒執行期間,其它執行緒非同步執行耗時操作
    • 提高效率:並行計算,縮短運算時間
    • 同步等待:join
    • 統籌規劃:合理使用執行緒,得到最優效果
  • 原理方面
    • 執行緒運行流程:棧、棧幀、上下文切換、程式計數器
    • Thread 兩種創建方式 的源碼
  • 模式方面
    • 終止模式之兩階段終止

結束語

到這裡我們JUC的進程與執行緒內容就結束了,希望能為你帶來幫助~

附錄

該文章屬於學習內容,具體參考B站黑馬程式設計師滿老師的JUC完整教程

這裡附上影片鏈接:01.001-為什麼學習並發_嗶哩嗶哩_bilibili