06-多執行緒

十問發表於2020-09-30

一、多執行緒

1. 1 併發與並行

  • 併發:指兩個或多個任務在同一時間段內發生。
  • 並行:指兩個或多個任務在同一時刻發生(同時發生)。

嚴格意義上來說,並行的多個任務是真實的同時執行,而對於併發來說,這個過程只是交替的,一會執行任務一,一會兒又執行任務二,系統會不停地在兩者間切換。但對於外部觀察者來說,即使多個任務是序列併發的,也會造成是多個任務並行執行的錯覺。

實際上,如果系統內只有一個CPU,而現在而使用多執行緒或者多執行緒任務,那麼真實環境中這些任務不可能真實並行的,畢竟一個CPU一次只能執行一條指令,這種情況下多執行緒或者多執行緒任務就是併發的,而不是並行,作業系統會不停的切換任務。真正的併發也只能夠出現在擁有多個CPU的系統中(多核CPU)。

1. 2 執行緒與程式

  • 程式:是指一個記憶體中執行的應用程式。每個程式都有一個獨立的記憶體空間;系統執行一個程式即是一個程式從建立、執行到消亡的過程。

  • 執行緒:執行緒是程式中的一個執行單元,負責當前程式中程式的執行,一個程式至少包含一個執行緒。

    簡而言之:一個程式執行後至少有一個程式,一個程式中可包含多個執行緒 。

程式:

執行緒排程:

  • 分時排程

    所有執行緒輪流使用 CPU 的使用權,平均分配每個執行緒佔用 CPU 的時間。

  • 搶佔式排程

    優先順序高的執行緒先使用 CPU,如果執行緒的優先順序相同,那麼會隨機選擇一個使用CPU(執行緒隨機性),Java中都為搶佔式排程

1. 3 Thread建立執行緒

java.lang.Thread類代表執行緒,所有的執行緒物件都必須是Thread類或其子類的例項。該類中定義了有關執行緒的一些方法:

構造方法:

public Thread():分配一個新的執行緒物件。
public Thread(String name):分配一個指定名字的新的執行緒物件。
public Thread(Runnable target):分配一個帶有指定目標新的執行緒物件。
public Thread(Runnable target,String name):分配一個帶有指定目標新的執行緒物件並指定名字。

常用方法:

public String getName():獲取當前執行緒名稱。
public void start():導致此執行緒開始執行; Java虛擬機器呼叫此執行緒的run方法。
public void run():此執行緒要執行的任務在此處定義程式碼。
public static void sleep(long millis):使當前正在執行的執行緒以指定的毫秒數暫停(暫時停止執行)。
public static Thread currentThread():返回對當前正在執行的執行緒物件的引用。

通過繼承Thread類來建立並啟動多執行緒的步驟如下:

  1. 定義Thread類的子類,並重寫該類的run()方法,該run()方法的方法體就代表了執行緒需要完成的任務,因此把run()方法稱為執行緒執行體。
  2. 建立Thread子類的例項,即建立了執行緒物件。
  3. 呼叫執行緒物件的start()方法來啟動該執行緒。

測試類:

public class ThreadTest {
    public static void main(String[] args) {
        System.out.println("main執行緒開始執行");
        //建立自定義執行緒物件
        MyThread mt = new MyThread("子執行緒");
        //開啟新執行緒
        mt.start();
        //在主方法中執行for迴圈
        for (int i = 0; i < 10; i++) {
            System.out.println("main執行緒列印:"+i);
        }
    }
}

自定義執行緒類:

public class MyThread extends Thread{
    // 定義指定執行緒名稱的構造方法
    public MyThread(String name) {
        //呼叫父類的String引數的構造方法,指定執行緒的名稱
        super(name);
    }
    /**
     * 重寫run方法,完成該執行緒執行的邏輯
     */
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(getName()+"正在執行!列印:"+i);
        }
    }
}

程式啟動執行main方法,java虛擬機器啟動一個程式,主執行緒main啟動,隨後呼叫MyThread物件的start方法啟動子執行緒。

在棧記憶體中,其實每一個執行執行緒都有一片自己所屬的棧記憶體空間,進行方法的壓棧和彈棧。

06-多執行緒

當行執行緒的任務結束,執行緒自動在棧記憶體中釋放;當所有的執行執行緒都執行結束,程式也就結束了。

1. 4 Runnable建立執行緒

採用java.lang.Runnable開啟執行緒也很常見,我們只需要重寫介面中的run方法即可。

步驟如下:

1. 定義Runnable介面的實現類,並重寫該介面的run()方法,該run()方法的方法體同樣是該執行緒的執行緒執行體。
2. 建立Runnable實現類的例項,並以此例項作為Thread的target來建立Thread物件,該Thread物件才是真正
的執行緒物件。
3. 呼叫執行緒物件的start()方法來啟動執行緒。

程式碼如下:

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            // getName方法:獲取執行緒的名字
            // currentThread方法:獲取當前所處的執行緒的物件
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }
}
public class RunnableTest {
    public static void main(String[] args) {
        //建立自定義類物件 執行緒任務物件
        MyRunnable mr = new MyRunnable();
        //建立執行緒物件
        Thread t = new Thread(mr, "子執行緒");
        t.start();
        for (int i = 0; i < 10; i++) {
            System.out.println("主執行緒 " + i);
        }
    }
}

在啟動的多執行緒的時候,需要先通過Thread類的構造方法Thread(Runnable target) 構造出物件,然後呼叫Thread
物件的start()方法來啟動子執行緒。

實際上所有的多執行緒程式碼都是通過執行Thread的start()方法來執行的。因此,不管是繼承Thread類還是實現
Runnable介面來實現多執行緒,最終還是通過Thread的物件的API來控制執行緒的,熟悉Thread類的API是進行多執行緒
程式設計的基礎。

Tips:Runnable物件僅僅作為Thread物件的target,Runnable實現類裡包含的run()方法僅作為執行緒執行體。
而實際的執行緒物件依然是Thread例項,只是該Thread執行緒負責執行其target的run()方法。

實現Runnable介面比繼承Thread類更具優勢:

  1. Runnable介面適合資源共享。
  2. 可以避免java中的單繼承的侷限性。
  3. 增加程式的健壯性,實現解耦操作,程式碼可以被多個執行緒共享,程式碼和執行緒獨立。
  4. 執行緒池只能放入實現Runnable或Callable類執行緒,不能直接放入繼承Thread的類。
擴充:在Java中,每次程式執行至少啟動2個執行緒。一個是main主執行緒,一個是垃圾收集執行緒。因為每當使用java命令執行 Java 程式時,實際上都會啟動一個Java虛擬機器程式。

1. 5 匿名內部類建立執行緒

使用執行緒的內匿名內部類方式,可以方便的實現每個執行緒執行不同的執行緒任務操作。

public class NoNameInnerClassThread {
    public static void main(String[] args) {
        // 1. 繼承thread類實現多執行緒
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println(Thread.currentThread().getName() + "--"
                            + i);
                }
            }
        }.start();

        // 2. 實現runnable藉口,建立多執行緒並啟動
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println(Thread.currentThread().getName() + "--"
                            + i);
                }
            }
        }) {
        }.start();

        // 3. Thread匿名內部類的裡面再一次重寫run方法
        // 在實際執行時的結果是 "World--i"。以thread的run方法為準,但是此處無意義。
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println("Hello" + "--" + i);
                }
            }
        }) {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println("World" + "--" + i);
                }
            }
        }.start();
    }
}

二、執行緒安全

2. 1 Java記憶體模型(JMM)

在瞭解執行緒安全之前先來了解Java的記憶體模型,搞清楚執行緒是怎樣工作的。

JMM(Java Memory Model),是一種基於計算機記憶體模型(定義了共享記憶體系統中多執行緒程式讀寫操作行為的規範),遮蔽了各種硬體和作業系統的訪問差異的,保證了Java程式在各種平臺下對記憶體的訪問都能保證效果一致的機制及規範。保證共享記憶體的原子性可見性有序性

執行緒 0 、執行緒 1 和執行緒 2分別對主記憶體的變數進行讀寫操作。其中主記憶體中的變數共享變數,也就是說此變數只此一份,多個執行緒間共享。但是執行緒不能直接讀寫主記憶體的共享變數,每個執行緒都有自己的工作記憶體,執行緒需要讀寫主記憶體的共享變數時需要先將該變數拷(read)到自己的工作記憶體,然後在自己的工作記憶體中對該變數進行所有操作,執行緒工作記憶體對變數副本完成操作之後需要將結果同步至主記憶體(write)。

Tips:執行緒的工作記憶體是執行緒私有記憶體,執行緒間無法互相訪問對方的工作記憶體。

JMM存在三大特性:原子性可見性有序性

原子性

對共享記憶體的操作必須是要麼全部執行直到執行結束,且中間過程不能被任何外部因素打斷,要麼就不執行。

可見性

多執行緒操作共享記憶體時,執行結果能夠及時的同步到共享記憶體,確保其他執行緒對此結果及時可見。

有序性

程式的執行順序按照程式碼順序執行,在單執行緒環境下,程式的執行都是有序的,但是在多執行緒環境下,JMM 為了效能優化,編譯器和處理器會對指令進行重排,程式的執行會變成無序。

就這樣,JMM 規定了何時以及如何做執行緒工作記憶體與主記憶體之間的資料同步。

2. 2 執行緒安全

我們先來看一個例項:

假設電影院要電影“花木蘭”的100張票,3售票視窗同時賣 “花木蘭”電影票。

模擬100張票:

public class Ticket implements Runnable {
    private int ticket = 100;

    @Override
    public void run() {
        while (true) {
            if (ticket > 0) {
                //出票操作
                try {
                    //模擬出票間隔時間
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //獲取當前執行緒物件的名字
                String name = Thread.currentThread().getName();
                System.out.println(name + "正在賣:" + ticket--);
            }
        }
    }
}

測試類:

public class TicketTest {
    public static void main(String[] args) {
        //建立執行緒任務物件
        Ticket ticket = new Ticket();
        //建立三個視窗物件
        Thread t1 = new Thread(ticket, "視窗1");
        Thread t2 = new Thread(ticket, "視窗2");
        Thread t3 = new Thread(ticket, "視窗3");
        //同時賣票
        t1.start();
        t2.start();
        t3.start();
    }
}

結果中有一部分這樣現象:

發現程式出現了兩個問題:

  1. 出現了很多相同的票數,比如 4與9 這張票都被賣了兩回。

  2. 出現了不存在的票,比如 0 票。

知道JMM記憶體模型之後,我們可以很輕鬆的分析出問題的癥結所在。

在多執行緒場景下,圖上三個執行緒 同時來操做共享記憶體裡的同一個變數(ticket),主記憶體內的此變數資料容易被破壞。也就是說主記憶體內的此變數不是執行緒安全的。

我們都知道完成一次 ticket-- 相當於執行了:

int tmp = ticket - 1;
ticket = tmp;

在多執行緒環境下就會出現在執行完 int tmp = ticket - 1; 這行程式碼時就發生了執行緒切換,當執行緒再次切回來的時候,ticket 就會被重複賦值,導致出現上面的執行結果,出現相同票數或者不存在的票數。

Tips:執行緒安全問題都是由全域性變數及靜態變數引起的。若每個執行緒中對全域性變數、靜態變數只有讀操作,而無寫操作,一般來說,這個全域性變數是執行緒安全的;若有多個執行緒同時執行寫操作,一般都需要考慮執行緒同步,否則的話就可能影響執行緒安全。

2. 2 執行緒同步

要解決上述多執行緒併發訪問一個資源的安全性問題,Java 提供了一系列的關鍵字和類。

(1)Synchronized關鍵字

synchronized是Java中的關鍵字,是一種同步鎖。它修飾的物件有以下幾種方式:

  • 同步程式碼塊,只對這個{ }區塊的資源實行互斥訪問,作用的物件是呼叫這個程式碼塊的物件。

格式:

synchronized(同步鎖){
	// 需要同步操作的程式碼
}

同步鎖:物件的同步鎖只是一個概念,可以想象為在物件上標記了一個鎖。

  1. 鎖物件 可以是任意型別。
  2. 多個執行緒物件 要使用同一把鎖。

Tips:在任何時候,最多允許一個執行緒擁有同步,誰拿到鎖就進入程式碼塊,其他的執行緒只能等待 (BLOCKED)。

例項:

public class Ticket implements Runnable {
    private int ticket = 100;

    Object lock = new Object();

    @Override
    public void run() {
        while (true) {
            synchronized (lock) {
                if (ticket > 0) {
                    try {
                        Thread.sleep(50);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    String name = Thread.currentThread().getName();
                    System.out.println(name + "正在賣:" + ticket--);
                }
            }
        }
    }

  • 同步方法,保證有執行緒執行該方法的時候,其他執行緒只能在方法外等著,作用的物件是呼叫這個程式碼塊的物件。

    格式:

    public synchronized void method(){
    	// 可能會產生執行緒安全問題的程式碼
    }
    

    Tips:對於非static方法,同步鎖就是this。 對於static方法,我們使用當前方法所在類的位元組碼物件(類名.class)。

public class Ticket implements Runnable {
    private int ticket = 100;

    @Override
    public void run() {
        while (true) {
            sellTicket();
        }
    }

    private synchronized void sellTicket() {
        if (ticket > 0) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            String name = Thread.currentThread().getName();
            System.out.println(name + "正在賣:" + ticket--);
        }
    }
}

(2)Lock

java.util.concurrent.locks.Lock機制提供了比synchronized程式碼塊和synchronized方法更廣泛的鎖定操作, 同步程式碼塊/同步方法具有的功能Lock都有,除此之外更強大,更體現物件導向。

Lock鎖也稱同步鎖,加鎖與釋放鎖方法化了,如下:

  • public void lock() :加同步鎖。
  • public void unlock() :釋放同步鎖。
public class Ticket implements Runnable {
    private int ticket = 100;

    Lock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
            lock.lock();
            if (ticket > 0) {
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                String name = Thread.currentThread().getName();
                System.out.println(name + "正在賣:" + ticket--);
            }
            lock.unlock();
        }
    }
}

三、生命週期

3. 1 概述

當執行緒被建立並啟動以後,它既不是一啟動就進入了執行狀態,也不是一直處於執行狀態。線上程的生命週期中,

有幾種狀態呢?在API中java.lang.Thread.State列舉出了六種執行緒狀態:

執行緒狀態導致狀態發生條件
NEW(新建)執行緒剛被建立,但是並未啟動。還未呼叫start方法。
Runnable(可 執行)執行緒可以在JVM中執行的狀態,可能正在執行自己程式碼,也可能沒有,這取決於操 作系統處理器。
Blocked(鎖阻塞)當一個執行緒試圖獲取一個物件鎖,而該物件鎖被其他的執行緒持有,則該執行緒進入Blocked狀 態;當該執行緒持有鎖時,該執行緒將變成Runnable狀態。
Waiting(無限 等待)一個執行緒在等待另一個執行緒執行一個(喚醒)動作時,該執行緒進入Waiting狀態。進入這個 狀態後是不能自動喚醒的,必須等待另一個執行緒呼叫notify或者notifyAll方法才能夠喚醒。
Timed Waiting(計時 等待)同waiting狀態,有幾個方法有超時引數,呼叫他們將進入Timed Waiting狀態。這一狀態 將一直保持到超時期滿或者接收到喚醒通知。帶有超時引數的常用方法有Thread.sleepObject.wait
Teminated( 終止)因為run方法正常退出而死亡,或者因為沒有捕獲的異常終止了run方法而死亡。
06-多執行緒

3. 2 新建狀態(NEW)

即用new關鍵字建立一個執行緒,這個執行緒就處於新建狀態

Tips:實現Runnable介面或者繼承Thread類來建立執行緒。

3. 3 執行狀態(RUNNABLE)

RUNNABLE狀態的執行緒,可以理解為其正在JVM中”執行“。這個"執行",不一定是真的在執行, 也有可能是在等待CPU資源。

所以分為就緒和執行兩種狀態。

(1)就緒狀態(READY)

當執行緒物件呼叫了start()方法之後,執行緒處於就緒狀態,就緒意味著該執行緒已獲得執行所需的所有資源,只要CPU分配執行權就能執行。

注意

  • 不允許對一個執行緒多次使用start方法。
  • 執行緒執行完成之後,不能試圖用start將其喚醒。

其他狀態 —>就緒

  • 執行緒呼叫start(),新建狀態轉化為就緒狀態。
  • 執行緒sleep(long)時間到,等待狀態轉化為就緒狀態。
  • 阻塞式IO操作結果返回,執行緒變為就緒狀態。
  • 其他執行緒呼叫join()方法,結束之後轉化為就緒狀態。
  • 執行緒物件拿到物件鎖之後,也會進入就緒狀態。

(2)執行狀態(RUNNING)

執行緒獲取到CPU執行權後,真正開始執行run()方法的執行緒執行體時,意味著該執行緒就已經處於執行狀態對於單處理器,一個時刻只能有一個執行緒處於執行狀態。

執行狀態轉變為就緒狀態的情形:

  • 執行緒失去處理器資源。執行緒不一定完整執行的,執行到一半,說不定就被別的執行緒搶走了。
  • 呼叫yield()靜態方法,暫時暫停當前執行緒,讓系統的執行緒排程器重新排程一次,它自己完全有可能再次執行。

3. 4 阻塞狀態(BLOCKED)

阻塞狀態表示執行緒正等待監視器鎖

  • 執行緒等待進入synchronized同步方法。
  • 執行緒等待進入synchronized同步程式碼塊。

例如,執行緒A和執行緒B使用同一鎖,如果執行緒A獲取到鎖,執行緒A進入到Runnable狀態,那麼執行緒B就進入到Blocked鎖阻塞狀態;當執行緒B取得到鎖,就會從阻塞狀態轉變為就緒狀態。

3. 5 等待狀態(WAITING)

執行緒處於等待狀態表示它需要等待其他執行緒的喚醒才能繼續執行。

  • 當前執行緒中呼叫wait()、join()、park()函式時,當前執行緒就會進入等待態。

  • 進入等待態的執行緒會釋放CPU執行權,並釋放資源(如:鎖)

舉例:

public class WaitingTest {
    public static Object obj = new Object();

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    synchronized (obj) {
                        try {
                            System.out.println(Thread.currentThread().getName() + "=== 呼叫wait方法,進入WAITING狀態,釋放鎖物件");
                            obj.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + "=== 從WAITING狀態醒來,獲取到鎖物件,繼續執行");
                    }
                }
            }
        }, "執行緒A").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) { //每隔3秒 喚醒一次
                    try {
                        System.out.println(Thread.currentThread().getName() + "‐‐‐ 等待3秒鐘");
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (obj) {
                        System.out.println(Thread.currentThread().getName() + "‐‐‐ 獲取到鎖物件, 呼叫notify方法,釋放鎖物件");
                        obj.notify();
                    }
                }
            }
        }, "執行緒B").start();
    }
}

執行結果:

執行緒A=== 呼叫wait方法,進入WAITING狀態,釋放鎖物件
執行緒B‐‐‐ 等待3秒鐘
執行緒B‐‐‐ 獲取到鎖物件, 呼叫notify方法,釋放鎖物件
執行緒B‐‐‐ 等待3秒鐘
執行緒A=== 從WAITING狀態醒來,獲取到鎖物件,繼續執行
... ... 

通過上述案例我們會發現,執行緒A在呼叫了某個物件的 Object.wait方法後,需要會等待執行緒B呼叫此物件的
Object.notify()方法將其喚醒。

其實WAITING狀態並不是一個執行緒的操作,它體現的是多個執行緒間的通訊,可以理解為多個執行緒之間的協作關係,多個執行緒會爭取鎖,同時相互之間又存在協作關係。比如A,B執行緒,如果A執行緒在RUNNABLE(可執行)狀態中呼叫了wait()方法,那麼執行緒A就進入了WAITING狀態,同時失去了同步鎖。假如此時執行緒B獲取到了同步鎖,在執行狀態中呼叫了notify()方法,那麼就會將無限等待的A執行緒喚醒。需要注意的是,執行緒A獲取到鎖物件後就進入RUNNABLE(可執行)狀態;如果沒有獲取鎖物件,那麼就進入到BLOCKED(阻塞狀態)。

執行—>等待

  • 當前執行緒執行過程中,其他執行緒呼叫join方法,當前執行緒將會進入等待狀態。
  • 當前執行緒物件呼叫wait()方法。
  • LockSupport.park():出於執行緒排程的目的禁用當前執行緒

等待—>就緒

  • 等待的執行緒被其他執行緒物件喚醒notify()notifyAll()
  • LockSupport.unpark(Thread),與上面park方法對應,給出許可證,解除等待狀態

3. 6 超時等待狀態(TIMED_WAITING)

區別於WAITING,它可以在指定的時間自行返回,到了超時時間後自動進入阻塞佇列,開始競爭鎖。

例如上面買電影票的案例中,模擬出票間隔時間,run方法中新增了sleep語句Thread.sleep(100)。它強制當前正在執行的執行緒休眠(暫停執行),以“減慢執行緒”。而這個”休眠狀態“,也就是所謂的超時等待狀態(TIMED_WAITING)。

超時等待狀態(TIMED_WAITING)與WAITING態一樣,並不是因為被動請求不到資源,而是主動進入的"休眠"狀態,並且進入後釋放CPU執行權;

執行—>超時等待

  • 呼叫靜態方法,Thread.sleep(long)
  • 執行緒物件呼叫wait(long)方法
  • 其他執行緒呼叫指定時間的join(long)
  • LockSupport.parkNanos()
  • LockSupport.parkUntil()

再看一個案例:

實現一個計數器,計數到100,在每個數字之間暫停1秒,每隔10個數字輸出一個字串。

public class TimeWaitingTest extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if ((i) % 10 == 0) {
                System.out.println("開啟執行緒數為:" + i);
            }
            System.out.print(i);
            try {
                Thread.sleep(1000);
                System.out.print(" 執行緒睡眠1秒!\n");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) {
        new TimeWaitingTest().start();
    }
}

執行結果:

開啟執行緒數為:0
0 執行緒睡眠1秒!
1 執行緒睡眠1秒!
2 執行緒睡眠1秒!
3 執行緒睡眠1秒!
4 執行緒睡眠1秒!
5 執行緒睡眠1秒!
6 執行緒睡眠1秒!
7 執行緒睡眠1秒!
8 執行緒睡眠1秒!
9 執行緒睡眠1秒!
開啟執行緒數為:10
10 執行緒睡眠1秒!
... ...

可見,執行緒睡眠到時間就會自動甦醒,並返回到Runnable(可執行)中的就緒狀態。

上面提到,呼叫yield()靜態方法,也能暫停當前執行緒,那其與sleep()有何區別呢?

  • sleep(long)方法會使執行緒轉入超時等待狀態,時間到了之後才會轉入就緒狀態。而yield()方法不會將執行緒轉入等待,而是強制執行緒進入就緒狀態。
  • 使用sleep(long)方法需要處理異常InterruptedException ,而yield()不用。
Tips:sleep()中指定的時間是執行緒暫停執行的最短時間,不能保證該執行緒睡眠到時後就開始立刻執行。

3. 7 終止狀態(TERMINATED)

執行緒的終止,表示執行緒已經執行完畢。前面已經說了,已經消亡的執行緒不能通過start再次喚醒。

  • run()和call()執行緒執行體中順利執行完畢,執行緒正常終止
  • 執行緒丟擲一個沒有捕獲的Exception或Error。

需要注意的是:主線成和子執行緒互不影響,子執行緒並不會因為主執行緒結束就結束。

四、等待喚醒機制

4. 1 執行緒間通訊

概念:多個執行緒併發處理同一個資源,而任務卻不同時,就需要執行緒通訊來幫助解決執行緒之間對同一個變數的使用或操作

比如:執行緒A用來生產茶葉,執行緒B用來消費茶葉,茶葉可以理解為同一資源,執行緒A與執行緒B處理的動作,一個是生產,一個是消費,那麼執行緒A與執行緒B之間就存線上程通訊問題。

為什麼要處理執行緒間通訊:

多個執行緒併發執行時, 在預設情況下CPU是隨機切換執行緒的,當我們需要多個執行緒來共同完成一件任務,並且我們希望他們有規律的執行, 那麼多執行緒之間需要一些協調通訊,以此來幫我們達到多執行緒共同操作一份資料。

如何保證執行緒間通訊有效利用資源:

多個執行緒在處理同一個資源,並且任務不同時,需要執行緒通訊來幫助解決執行緒之間對同一個變數的使用或操作。 就是多個執行緒在操作同一份資料時, 避免對同一共享變數的爭奪。也就是我們需要通過一定的手段使各個執行緒能有效的利用資源。而這種手段即—— 等待喚醒機制。

4. 2 等待喚醒機制

(1)什麼是等待喚醒機制

這是多個執行緒間的一種協作機制。談到執行緒我們經常想到的是執行緒間的競爭(race),比如去爭奪鎖,但這並不是故事的全部,執行緒間也會有協作機制。就好比我們在公司中與同事關係,可能存在在晉升時的競爭,但更多時候是一起合作以完成某些任務。

wait/notify 就是執行緒間的一種協作機制。

就是在一個執行緒進行了規定操作後,就進入等待狀態(wait()), 等待其他執行緒執行完他們的指定程式碼過後再將其喚醒(notify())。在有多個執行緒進行等待時, 如果需要,可以使用notifyAll()來喚醒所有的等待執行緒。

(2)等待喚醒中的方法

等待喚醒機制就是用於解決執行緒間通訊的問題的,使用到的3個方法的含義如下:

  1. wait:執行緒不再活動,不再參與排程,釋放它對鎖的擁有權,這時的執行緒狀態即是 WAITING。它還要等著別的執行緒執行一個特別的動作,也即是“通知(notify)”在這個物件上等待的執行緒從WAITING狀態中釋放出來,重新進入到排程佇列(ready queue)中
  2. notify:喚醒一個等待當前物件的鎖的執行緒。喚醒在此物件監視器上等待的單個執行緒。
  3. notifyAll:喚醒在此物件監視器上等待的所有執行緒。

注意:

哪怕只通知了一個等待的執行緒,被通知執行緒也不能立即恢復執行,因為它當初中斷的地方是在同步塊內,而此刻它已經不持有鎖,所以它需要再次嘗試去獲取鎖(很可能面臨其它執行緒的競爭),成功後才能在當初呼叫 wait 方法之後的地方恢復執行。

總結如下:

  • 如果能獲取鎖,執行緒就從 WAITING 狀態變成 RUNNABLE 狀態;
  • 否則,從 wait set 出來,又進入 entry set,執行緒就從 WAITING 狀態又變成 BLOCKED 狀態。

(3)呼叫wait和notify方法需要注意的細節

  • wait方法與notify方法必須要由同一個鎖物件呼叫。因為對應的鎖物件可以通過notify喚醒使用同一個鎖物件呼叫的wait方法後的執行緒。

  • wait方法與notify方法是屬於Object類的方法的。因為鎖物件可以是任意物件,而任意物件的所屬類都是繼承了Object類的。

  • wait方法與notify方法必須要在同步程式碼塊或者是同步函式中使用。因為必須要通過鎖物件呼叫這2個方法。

4. 3 生產者與消費者模式

**線上程世界裡,生產者就是生產資料的執行緒,消費者就是消費資料的執行緒。**在多執行緒開發當中,如果生產者處理速度很快,而消費者處理速度很慢,那麼生產者就必須等待消費者處理完,才能繼續生產資料。同樣的道理,如果消費者的處理能力大於生產者,那麼消費者就必須等待生產者。為了解決這個問題於是引入了生產者和消費者模式。

在解決生產者消費者問題時,常用方法之一就是使用 Object 的 wait/notify 的等待喚醒機制。

06-多執行緒

這裡我們用到了 2 個佇列:

  • 同步佇列:對應執行緒狀態中的RUNNABLE,執行緒處於準備就緒,等著可以搶佔資源。
  • 等待佇列:對應於我講的執行緒狀態中的 WAITING,也就是等待狀態。

實現:

這裡我首先建了一個簡單的 Product 類,用來表示生產和消費的產品。

public class Product {
    private String name;

    public Product(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

建立生產者類:

public class Producer implements Runnable {
    private Queue<Product> queue;
    private int maxCapacity;

    public Producer(Queue<Product> queue, int maxCapacity) {
        this.queue = queue;
        this.maxCapacity = maxCapacity;
    }

    @Override
    public void run() {
        synchronized (queue) {
            while (queue.size() == maxCapacity) {
                try {
                    System.out.println("生產者" + Thread.currentThread().getName() + "Queue 已滿,WAITING");
                    wait();
                    System.out.println("生產者" + Thread.currentThread().getName() + "退出等待");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (queue.size() == 0) { //佇列裡的產品從無到有,需要通知在等待的消費者
                queue.notifyAll();
            }
            Integer i = new Random().nextInt(50);
            queue.offer(new Product("產品" + i.toString()));
            System.out.println("生產者" + Thread.currentThread().getName() + "生產了產品" + i.toString());
        }
    }
}

建立消費者類:

public class Consumer implements Runnable {

    private Queue<Product> queue;
    private int maxCapacity;

    public Consumer(Queue queue, int maxCapacity) {
        this.queue = queue;
        this.maxCapacity = maxCapacity;
    }

    @Override
    public void run() {
        synchronized (queue) {
            while (queue.isEmpty()) {
                try {
                    System.out.println("消費者" + Thread.currentThread().getName() + "Queue已空,WAITING");
                    wait();
                    System.out.println("消費者" + Thread.currentThread().getName() + "退出等待");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (queue.size() == maxCapacity) {
                queue.notifyAll();
            }

            Product product = queue.poll();
            System.out.println("消費者" + Thread.currentThread().getName() + "消費了" + product.getName());
        }
    }
}

開啟多執行緒:

public class TreadTest {
    public static void main(String[] args) {
        Queue<Product> queue = new ArrayDeque<>();
        for (int i = 0; i < 10; i++) {
            new Thread(new Producer((Queue<Product>) queue, 10)).start();
            new Thread(new Consumer((Queue) queue, 10)).start();
        }
    }
}

測試結果:

生產者Thread-0生產了產品35
消費者Thread-1消費了產品35
生產者Thread-2生產了產品43
消費者Thread-3消費了產品43
消費者Thread-5Queue已空,WAITING
生產者Thread-6生產了產品17
生產者Thread-8生產了產品39
消費者Thread-7消費了產品17
生產者Thread-10生產了產品17
生產者Thread-12生產了產品3
消費者Thread-13消費了產品39
生產者Thread-14生產了產品10
消費者Thread-17消費了產品17
生產者Thread-16生產了產品8
消費者Thread-19消費了產品3
生產者Thread-4生產了產品29
消費者Thread-9消費了產品10
消費者Thread-11消費了產品8
消費者Thread-15消費了產品29
生產者Thread-18生產了產品33

五、執行緒池

5. 1 概述

  • 執行緒池:其實是一種執行緒使用模式,解決執行緒頻繁開啟、關閉帶來的效能開銷問題。使用執行緒池的好處是減少在建立和銷燬執行緒上所花的時間以及系統資源的開銷,解決資源不足的問題。如果不使用執行緒池,有可能造成系統建立大量同類執行緒而導致消耗完記憶體或者“過度切換”的問題。

執行緒資源必須通過執行緒池提供,不允許在應用中自行顯式建立執行緒。

5. 2 執行緒池原理

Java裡面執行緒池的頂級介面是java.util.concurrent.Executor,但是嚴格意義上講Executor並不是一個執行緒池,而只是一個執行執行緒的工具。真正的執行緒池介面是java.util.concurrent.ExecutorService

java.util.concurrent.Executors執行緒工廠類裡面提供了一些靜態工廠,生成一些常用的執行緒池。

Executors類中主要建立執行緒池的方法如下:

  • Executors.newCachedThreadPool():無限執行緒池。
  • Executors.newFixedThreadPool(nThreads):建立固定大小的執行緒池。
  • Executors.newSingleThreadExecutor():建立單個執行緒的執行緒池。

檢視三種方式建立的原始碼,發現都是通過ThreadPoolExecutor類來實現執行緒池的建立。

return new ThreadPoolExecutor(nThreads, nThreads,
                              0L, TimeUnit.MILLISECONDS,
                              new LinkedBlockingQueue<Runnable>());

檢視ThreadPoolExecutor類:


檢視建立執行緒的 API:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          RejectedExecutionHandler handler) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), handler);
}

這幾個核心引數的作用:

  • corePoolSize 為執行緒池的基本大小。
  • maximumPoolSize 為執行緒池最大執行緒大小。
  • keepAliveTimeunit 則是執行緒空閒後的存活時間。
  • workQueue 用於存放任務的阻塞佇列。
  • handler 當佇列和最大執行緒池都滿了之後的飽和策略。

在實際的運用中,提交一個任務到執行緒池中,核心的邏輯就是 execute() 函式。

在具體分析前,得先知悉執行緒池的狀態轉換。檢視原始碼,發現定義有如下幾類狀態:

private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;
  • RUNNING 自然是執行狀態,指可以接受任務執行佇列裡的任務
  • SHUTDOWN 指呼叫了 shutdown() 方法,不再接受新任務了,但是佇列裡的任務得執行完畢。
  • STOP 指呼叫了 shutdownNow() 方法,不再接受新任務,同時拋棄阻塞佇列裡的所有任務並中斷所有正在執行任務。
  • TIDYING 所有任務都執行完畢,在呼叫 shutdown()/shutdownNow() 中都會嘗試更新為這個狀態。
  • TERMINATED 終止狀態,當執行 terminated() 後會更新為這個狀態。

那麼 execute() 核心到底做了什麼:

public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();									------ 1	
        if (workerCountOf(c) < corePoolSize) {              ------ 2
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {     ------ 3
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))    ------ 4
                reject(command);
            else if (workerCountOf(recheck) == 0)           ------ 5
                addWorker(null, false);
        }
        else if (!addWorker(command, false))				------ 6
            reject(command);
    }
  1. 獲取當前執行緒池的狀態。
  2. 當前執行緒數量小於 corePoolSize時建立一個新的執行緒執行。
  3. 如果當前執行緒處於執行狀態,並且寫入阻塞佇列成功。
  4. 雙重檢查,再次獲取執行緒狀態;如果執行緒狀態變了(非執行狀態)就需要從阻塞佇列移除任務,並嘗試判斷執行緒是否全部執行完畢。同時執行拒絕策略。
  5. 如果當前執行緒池為空就新建立一個執行緒並執行。
  6. 如果在第三步的判斷為非執行狀態,嘗試新建執行緒,如果失敗則執行拒絕策略。

由此可以窺探出執行緒池工作原理:

06-多執行緒

5. 3 執行緒池的使用

這裡定義了一個使用執行緒池物件的方法如下:

  • public Future<?> submit(Runnable task):獲取執行緒池中的某一個執行緒物件,並執行

    Future介面:用來記錄執行緒任務執行完畢後產生的結果。

使用執行緒池中執行緒物件的步驟:

  1. 建立執行緒池物件。
  2. 建立Runnable介面子類物件。(task)
  3. 提交Runnable介面子類物件。(take task)
  4. 關閉執行緒池(一般不做)。

Runnable實現類程式碼:

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("獲取到執行緒: " + Thread.currentThread().getName());
        System.out.println(Thread.currentThread().getName() + "執行緒回到執行緒池");
    }
}

執行緒池測試類:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @Author: Kai
 * @DateTime: 2020/09/30 17:12
 * @Description: TODO
 */
public class ThreadPoolTest {

    public static void main(String[] args) {
        // 1.建立執行緒池物件
        ExecutorService pool= Executors.newFixedThreadPool(2);
        // 2.建立Runnable例項物件
        MyRunnable mr = new MyRunnable();
        // 3.從執行緒池中獲取執行緒物件,呼叫MyRunnable中的run()
        pool.submit(mr);
        // 再獲取個執行緒物件,呼叫MyRunnable中的run()
        pool.submit(mr);

        // 注意:submit方法呼叫結束後,程式並不終止,是因為執行緒池控制了執行緒的關閉。
        // 將使用完的執行緒又歸還到了執行緒池中
        // 4.關閉執行緒池
        pool.shutdown();
    }
}

執行結果:

獲取到執行緒: pool-1-thread-2
獲取到執行緒: pool-1-thread-1
pool-1-thread-1執行緒回到執行緒池
pool-1-thread-2執行緒回到執行緒池

參考資料

一文搞懂併發和並行

圖解 Java 執行緒安全

Java:執行緒的六種狀態及轉化

「生產者 - 消費者」模型

如何優雅的使用和理解執行緒池

相關文章