多執行緒實用講解

smartsean發表於2018-02-02

寫在前面的話:

這篇部落格是我從這裡“轉載”的,為什麼轉載兩個字加“”呢?因為這絕不是簡單的複製貼上,我花了五六個小時對其中每一行的程式碼都有認真的練習,對其中的一些小錯誤進行調整,並且重新排版,希望通過本篇部落格可以讓自己對 Java 多執行緒有更好的理解,同時也希望能夠幫助正在學習多執行緒的你。

此文只能說是 Java 多執行緒的一個入門,其實Java裡頭執行緒完全可以寫一本書了,但是如果最基本的你都沒掌握好,又怎麼能更上一個臺階呢?如果你覺得此文很簡單,那推薦你看看Java併發包的的執行緒池(Java 併發程式設計與技術內幕:執行緒池深入理解),或者看這個專欄:Java 併發程式設計與技術內幕。你將會對 Java 裡頭的高併發場景下的執行緒有更加深刻的理解

本文主要講了 Java 中多執行緒的使用方法、執行緒同步、執行緒資料傳遞、執行緒狀態及相應的一些執行緒函式用法、概述等。在這之前,首先讓我們來了解下在作業系統中程式和執行緒的區別:

程式:每個程式都有獨立的程式碼和資料空間(程式上下文),程式間的切換會有較大的開銷,一個程式包含1--n個執行緒。(程式是資源分配的最小單位)

執行緒:同一類執行緒共享程式碼和資料空間,每個執行緒有獨立的執行棧和程式計數器(PC),執行緒切換開銷小。(執行緒是 cpu 排程的最小單位)

  • 執行緒和程式一樣分為五個階段:建立、就緒、執行、阻塞、終止。
  • 多程式是指作業系統能同時執行多個任務(程式)。
  • 多執行緒是指在同一程式中有多個順序流在執行。

在 Java 中要想實現多執行緒,有兩種手段:

  • 一種是繼承 Thread 類;
  • 一種是實現 Runnable 介面.

其實準確來講,應該有三種,還有一種是實現 Callable 介面,並與 Future、執行緒池結合使用,此文不講這個,有興趣看這裡 Java 併發程式設計與技術內幕:Callable、Future、FutureTask、CompletionService

一、擴充套件java.lang.Thread類

這裡繼承 Thread 類的方法是比較常用的一種,如果說你只是想重新開啟一條執行緒。沒有什麼其它特殊的要求,那麼可以使用 Thread ,(筆者推薦使用 Runnable ,後頭會說明為什麼)。下面來看一個簡單的例項:

/**
 * Created by Sean on 2017/5/9.
 */
class Thread1 extends Thread {
    private String threadName; // 用於標示不同的執行緒

    public Thread1(String threadName) {
        this.threadName = threadName;
    }

    @Override
    public void run() {
        super.run();
        for (int i = 0; i < 5; i++) {
            System.out.println(threadName + "執行,此時的 i = " + i);
            try {
                sleep((int) Math.random() * 10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Main {

    public static void main(String[] args) {
        System.out.println("Hello World!");
        Thread1 thread1 = new Thread1("我是A執行緒");
        Thread1 thread2 = new Thread1("我是B執行緒");
        thread1.start();
        thread2.start();
    }
}

複製程式碼

執行結果:

執行結果

說明:

程式啟動執行 main 時候, java 虛擬機器啟動一個程式,主執行緒 main 在 main() 呼叫時候被建立。隨著呼叫 Thread1 的兩個物件的 start 方法,另外兩個執行緒也啟動了,這樣,整個應用就在多執行緒下執行。

注意:

start() 方法的呼叫後並不是立即執行多執行緒程式碼,而是使得該執行緒變為可執行態(Runnable),什麼時候執行是由作業系統決定的。

從程式執行的結果可以發現,多執行緒程式是亂序執行。因此,只有亂序執行的程式碼才有必要設計為多執行緒。

Thread.sleep() 方法呼叫目的是不讓當前執行緒獨自霸佔該程式所獲取的 CPU 資源,以留出一定時間給其他執行緒執行的機會。

實際上所有的多執行緒程式碼執行順序都是不確定的,每次執行的結果都是隨機的。

此外 start() 方法重複呼叫的話,會出現java.lang.IllegalThreadStateException異常。

比如把 Main 類程式碼改成下面:

public class Main {

    public static void main(String[] args) {
        Thread1 thread1 = new Thread1("我是A執行緒");
        thread1.start();
        thread1.start();
    }
}
複製程式碼

結果如下:

重複呼叫 start() 異常

二、實現 java.lang.Runnable 介面

採用 Runnable 也是非常常見的一種,我們只需要重寫 run() 即可。下面也來看個例項。

使用繼承 Thread 實現共享的錯誤示範

/**
 * Created by Sean on 2017/5/9.
 */


class Thread2 implements Runnable {

    private String threadName;

    public Thread2(String threadName) {
        this.threadName = threadName;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(threadName + "執行,此時的 i = " + i);
            try {
                Thread.sleep((int) Math.random() * 10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Main2 {
    public static void main(String[] args) {
        new Thread(new Thread2("我是在A執行緒中")).start();
        new Thread(new Thread2("我是在B執行緒中")).start();
    }
}

複製程式碼

結果:

執行結果

說明:

Thread2 類是通過實現 Runnable 介面,使該類有了多執行緒類的特徵, run() 方法是多執行緒程式的一個約定,所有的多執行緒程式碼都在 run() 方法裡面, 事實上, Thread 類也是實現了 Runnable 介面的類。

在啟動實現了 Runnable 介面的類的多執行緒的時候,需要先通過 Thread 類的構造方法 Thread(Runanable target) 構造出 Thread 物件,然後呼叫 Thread 物件的 start() 方法來開啟執行緒,執行 run() 方法裡面的多執行緒程式碼(這個run() 方法不需要開發者手動呼叫,會在作業系統分給該執行緒時間片的時候自動執行

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

三、Thread和Runnable的區別

如果一個類繼承 Thread,則不適合資源共享。但是如果實現了 Runable 介面的話,則很容易的實現資源共享。

上面這句話是原部落格裡面給出的,我認為是有瑕疵的。

以賣票程式為例,下面來說明為什麼這樣說:

使用繼承 Thread 的方式共享的錯誤示例

/**
 * Created by Sean on 2017/5/9.
 */
class Thread3 extends Thread {
    private String threadName;
    private int ticket = 5;

    public Thread3(String threadName) {
        this.threadName = threadName;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            synchronized (this) {
                if (ticket > 0){
                    System.out.println(threadName + "執行,此時的 i = " + i+" 剩餘票數" + this.ticket--);
                }
            }
            try {
                sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Main3 {
    public static void main(String[] args){
        Thread3 thread1 = new Thread3("我是在A執行緒中");
        Thread3 thread2 = new Thread3("我是在B執行緒中");
        Thread3 thread3 = new Thread3("我是在C執行緒中");
        thread1.start();
        thread2.start();
        thread3.start();
    }
}

複製程式碼

結果:

執行結果

從上面的結果可以看到,開啟了三個執行緒,每個執行緒都賣了5張票,這明顯是不合理的,接下來看看用 Runnable 來實現共享 5 張票的例子

使用實現 Runnable 實現共享票數

import static java.lang.Thread.currentThread;
import static java.lang.Thread.sleep;

/**
 * Created by Sean on 2017/5/9.
 */
class Thread4 implements Runnable {

    private int ticket = 5;

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            synchronized (this) {
                if (ticket > 0) {
                    System.out.println(currentThread().getName() + "執行,此時的 i = " + i + " 剩餘票數" + this.ticket--);
                }
            }
            try {
                sleep(400);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Main4 {
    public static void main(String[] args) {
        Thread4 thread1 = new Thread4();
        new Thread(thread1, "我是在A執行緒中").start();
        new Thread(thread1, "我是在B執行緒中").start();
        new Thread(thread1, "我是在C執行緒中").start();
    }
}

複製程式碼

執行結果:

執行結果

可以看到,我們用實現 Runnable 介面的方式實現了資源的共享。

那麼我們使用繼承 Thread 的方式就真的沒法實現資源共享嗎?

答案是 NO!

往下看。

使用繼承 Thread 的方式共享的正確示例

我們先看下面的程式碼:

/**
 * Created by Sean on 2017/5/9.
 */
class Thread5 extends Thread {

    private int ticket = 5;

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            synchronized (this) {
                if (ticket > 0) {
                    System.out.println(currentThread().getName() + "執行,此時的 i = " + i + " 剩餘票數" + this.ticket--);
                }
            }
            try {
                sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Main5 {
    public static void main(String[] args) {
        Thread5 thread1 = new Thread5();
        new Thread(thread1, "我是在A執行緒中").start();
        new Thread(thread1, "我是在B執行緒中").start();
        new Thread(thread1, "我是在C執行緒中").start();
    }
}
複製程式碼

執行結果:

執行結果

可以看到,雖然我們使用了繼承 Thread 的方式來實現執行緒類,最後我們也同樣實現了多執行緒中資源的共享。

從而可以判斷,原部落格的話是有一定錯誤的。

下面看一下總結:

實現 Runnable 介面比繼承 Thread 類所具有的優勢:

  1. 可以避免java中的單繼承的限制
  2. 執行緒池只能放入實現 Runable 或 callable 類執行緒,不能直接放入繼承 Thread 的類

兩者都有的:

  1. 適合多個相同的程式程式碼的執行緒去處理同一個資源
  2. 增加程式的健壯性,程式碼可以被多個執行緒共享,程式碼和資料獨立

提醒一下大家: main() 方法其實也是一個執行緒,在 java 中所有的執行緒都是同時啟動的,至於什麼時候啟動,哪個執行緒先執行,完全是看哪個執行緒先從 cpu 哪裡獲取時間片資源。

此外:在 java 中,每次程式執行至少啟動兩個執行緒, 一個是 main 執行緒, 一個是垃圾回收執行緒。因為每當使用 java 命令執行一個類的時候,實際上都會啟動一個 JVM ,每一個 JVM 實際就是在作業系統中啟動了一個程式。

四、執行緒狀態轉換

下面的這個圖非常重要!你如果看懂了這個圖,那麼對於多執行緒的理解將會更加深刻!

執行緒狀態轉換圖

學過作業系統的同學應該看起來很容易的,畢竟當初考試的時候這一塊是個重點,沒少複習這一塊。

  • 新建狀態(New):新建立了一個執行緒物件
  • 就緒狀態(Runnable):執行緒物件建立後,其他執行緒呼叫了該物件的 start() 方法,該狀態的執行緒位於可執行的執行緒池中,變為可執行狀態,這個時候,只要獲取了 cpu 的執行權,就可以執行,進入執行狀態。
  • 執行狀態(Running): 就緒狀態的執行緒從 cpu 獲得了執行權之後,便可進入此狀態,執行 run() 方法裡面的程式碼。
  • 阻塞狀態(Blocked):阻塞狀態是執行緒因為某種原因失去了 cpu 的使用權,暫時停止執行,一直等到執行緒進入就緒狀態,才有機會轉到執行狀態,阻塞一般分為下面三種:
    • 等待阻塞 :執行的執行緒執行了 wait() 方法, JVM 會把該執行緒放入執行緒等待池中,(wait() 會釋放持有的鎖 )
    • 同步阻塞:執行的執行緒在獲取物件的同步鎖時,如果該同步鎖被其他執行緒佔用,這時此執行緒是無法執行的,那麼 JVM 就會把該執行緒放入鎖池中,導致阻塞
    • 其他阻塞:執行的執行緒執行 sleep() 或者 join() 方法,或者發出了 I/O 請求,JVM 會把該執行緒置為阻塞狀態,當 sleep() 狀態超時、join() 等待執行緒終止或者超時、或者 I/O 處理完畢時,執行緒會重新進入就緒狀態,(注意:sleep() 是不會釋放本身持有的鎖的)
  • 死亡狀態(Dead):執行緒執行完了之後或者因為程式異常退出了 run() 方法,結束該執行緒的生命週期。

五、執行緒排程

1. 調整執行緒優先順序

Java 執行緒有優先順序,優先順序高的執行緒會獲得較多的執行機會,Java 執行緒的優先順序用整數表示,取值範圍是 1~10 ,Thread 類有以下三個靜態常量:

static int MAX_PRIORITY      執行緒可以具有的最高優先順序,取值為10。  
static int MIN_PRIORITY       執行緒可以具有的最低優先順序,取值為1。  
static int NORM_PRIORITY   分配給執行緒的預設優先順序,取值為5。 
複製程式碼

Thread 類的 setPriority() 和 getPriority() 分別用於設定和獲取執行緒的優先順序。

  • 每個執行緒都有預設的優先順序。主執行緒的預設優先順序為Thread.NORM_PRIORITY。
  • 執行緒的優先順序有繼承關係,比如A執行緒中建立了B執行緒,那麼B將和A具有相同的優先順序。
  • JVM提供了10個執行緒優先順序,但與常見的作業系統都不能很好的對映。如果希望程式能移植到各個作業系統中,應該僅僅使用Thread類有以下三個靜態常量作為優先順序,這樣能保證同樣的優先順序採用了同樣的排程方式。

2. 執行緒睡眠

Thread.sleep(long millis) 方法,使執行緒轉到阻塞狀態。millis 引數設定睡眠的時間,以毫秒為單位。當睡眠結束後,就轉為就緒(Runnable)狀態。sleep() 平臺移植性好。

3. 執行緒等待

Object 類中的 wait() 方法,導致當前的執行緒等待,直到其他執行緒呼叫此物件的 notify() 方法或 notifyAll() 喚醒方法。這個兩個喚醒方法也是 Object 類中的方法,行為等價於呼叫 wait(0) 一樣。

4. 執行緒讓步

Thread.yield()方法,暫停當前正在執行的執行緒物件,把執行機會讓給相同或者更高優先順序的執行緒。

5. 執行緒加入

join() 方法,等待其他執行緒終止,在當前執行緒中呼叫一個執行緒的 join() 方法,則當前執行緒轉為阻塞狀態,回到另一個執行緒結束,當前執行緒再由阻塞狀態變為就緒狀態,等待 cpu 的寵幸。

6. 執行緒喚醒

Object 類中的 notify() 方法,喚醒在此物件監視器上等待的單個執行緒,如果所有執行緒都在此物件上等待,則會選擇喚醒其中一個執行緒,選擇是任意的,並在對實現做出決定時發生,執行緒通過呼叫其中一個 wait() 方法,在物件的監視器上等待,直到當前的執行緒放棄此物件上的鎖定,才能繼續執行被喚醒的執行緒,被喚醒的執行緒將以常規方式與在該物件上主動同步的其他所有執行緒進行競爭。

例如:喚醒的執行緒在作為鎖定此物件的下一個執行緒方面沒有可靠的特權或者劣勢,

類似的方法還有 notifyAll() ,喚醒再次監視器上等待的所有執行緒,

注意: Thread 中 suspend() 和 resume() 兩個方法已經在 JDK 1.5 中廢除,此處不做介紹,因為有死鎖傾向。

六、常用函式說明

1. sleep(long millis): 在指定的毫秒數內讓當前正在執行的執行緒休眠(暫停執行)

2. join() 指等待t執行緒終止。

使用方式

Thread6 thread1 = new Thread6();
thread1.start();
thread1.join(); 
複製程式碼

為什麼要用join()方法

很多情況下,主執行緒生成並啟動了子執行緒,如果子執行緒需要大量的耗時運算,主執行緒往往將於子執行緒結束之前結束,但是如果主執行緒處理完了其他事務後,需要用到子執行緒返回的結果,也就是需要主執行緒需要在子執行緒結束後再結束,這時候就要用到 join() 方法。

先看下不加 join() 的程式碼:

/**
 * Created by Sean on 2017/5/9.
 */
class Thread6 extends Thread {

    @Override
    public void run() {
        super.run();
        System.out.println(Thread.currentThread().getName() + "執行開始!");
        for (int i = 0; i < 5; i++) {
            System.out.println(currentThread().getName() + "======>" + i);
            try {
                sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName() + "執行結束!");
    }
}

public class Main6 {
    public static void main(String[] args) throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "執行緒執行開始!");
        Thread6 thread1 = new Thread6();
        Thread6 thread2 = new Thread6();
        thread1.setName("執行緒A");
        thread2.setName("執行緒B");
        thread1.start();
        thread2.start();
        System.out.println("這時thread1 和 thread2 都執行完畢之後才能執行主執行緒列印此句話因為兩個子執行緒都被主執行緒呼叫了join() 方法");
        System.out.println(Thread.currentThread().getName() + "執行緒執行結束!");
    }
}

複製程式碼

結果:

執行結果

從結果中可以看到我們列印的 main 執行緒執行結束之後,兩個子執行緒才開始執行,這和上面說的是對照的,

下面演示下等待兩個子執行緒結束之後再結束主執行緒:

/**
 * Created by Sean on 2017/5/9.
 */
class Thread6 extends Thread {

    @Override
    public void run() {
        super.run();
        System.out.println(Thread.currentThread().getName() + "執行開始!");
        for (int i = 0; i < 5; i++) {
            System.out.println(currentThread().getName() + "======>" + i);
            try {
                sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName() + "執行結束!");
    }
}

public class Main6 {
    public static void main(String[] args) throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "執行緒執行開始!");
        Thread6 thread1 = new Thread6();
        Thread6 thread2 = new Thread6();
        thread1.setName("執行緒A");
        thread2.setName("執行緒B");
        thread1.start();
        try {
            thread1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread2.start();
        try {
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("這時thread1 和 thread2 都執行完畢之後才能執行主執行緒列印此句話因為兩個子執行緒都被主執行緒呼叫了join() 方法");
        System.out.println(Thread.currentThread().getName() + "執行緒執行結束!");
    }
}

複製程式碼

執行結果:

執行結果

這個時候不論執行多少遍,都是主執行緒等待子執行緒結束後才結束。

如果主執行緒的執行需要依賴於子執行緒中的完整資料的時候,這種方法就可以很好的確保兩個執行緒的同步性。

3. yield():暫停當前正在執行的執行緒物件,並執行其他執行緒。

注意:yield() 應該做的是讓當前執行執行緒回到可執行狀態(就緒狀態),以允許具有相同優先順序的其他執行緒獲得執行機會。因此,使用 yield() 的目的是讓相同優先順序的執行緒之間能適當的輪轉執行。但是,實際中無法保證 yield() 達到讓步目的,因為讓步的執行緒還有可能被執行緒排程程式再次選中。

結論:yield() 從未導致執行緒轉到等待/睡眠/阻塞狀態。在大多數情況下,yield() 將導致執行緒從執行狀態轉到可執行狀態(就緒狀態),但有可能沒有效果。可看上面的圖。

看下面的例子:

/**
 * yield()的用法
 * Created by Sean on 2017/5/9.
 */
class Thread7 extends Thread {

    @Override
    public void run() {
        for (int i = 1; i <= 50; i++) {
            System.out.println("" + this.getName() + "-----" + i);
            // 當i為30時,該執行緒就會把CPU時間讓掉,讓其他或者自己的執行緒執行(也就是誰先搶到誰執行)
            if (i == 30) {
                this.yield();
            }
        }
    }
}

public class Main7 {
    public static void main(String[] args) {
        Thread7 thread1 = new Thread7();
        Thread7 thread2 = new Thread7();
        thread1.setName("A執行緒");
        thread2.setName("B執行緒");
        thread1.start();
        thread2.start();
    }
}

複製程式碼

執行結果:

第一種情況:A執行緒當執行到30時會CPU時間讓掉,這時A執行緒搶到 CPU 的時間片執行。 第二種情況:B執行緒當執行到30時會CPU時間讓掉,這時A執行緒搶到 CPU 的時間片執行。 第二種情況:從一開始就交替執行,當到30的時候進行一次讓步。

sleep()和yield()的區別

  • sleep() 使當前執行緒進入停滯狀態,所以執行 sleep() 的執行緒在指定的時間內肯定不會被執行
  • yield() 只是使當前執行緒重新回到可執行狀態,所以執行 yield() 的執行緒有可能在進入到可執行狀態後馬上又被執行。

sleep 方法使當前執行中的執行緒睡眼一段時間,進入不可執行狀態,這段時間的長短是由程式設定的,yield 方法使當前執行緒讓出 CPU 佔有權,但讓出的時間是不可設定的。實際上,yield() 方法對應瞭如下操作:先檢測當前是否有相同優先順序的執行緒處於同可執行狀態,如有,則把 CPU 的佔有權交給此執行緒,否則,繼續執行原來的執行緒。所以yield()方法稱為“退讓”,它把執行機會讓給了同等優先順序的其他執行緒

另外,sleep 方法允許較低優先順序的執行緒獲得執行機會,但 yield() 方法執行時,當前執行緒仍處在可執行狀態,所以,不可能讓出較低優先順序的執行緒些時獲得 CPU 佔有權。在一個執行系統中,如果較高優先順序的執行緒沒有呼叫 sleep 方法,又沒有受到 I\O 阻塞,那麼,較低優先順序執行緒只能等待所有較高優先順序的執行緒執行結束,才有機會執行。

4. setPriority(): 更改執行緒的優先順序。

MIN_PRIORITY = 1
NORM_PRIORITY = 5
MAX_PRIORITY = 10
用法:
Thread4 t1 = new Thread4("t1");
Thread4 t2 = new Thread4("t2");
t1.setPriority(Thread.MAX_PRIORITY);
t2.setPriority(Thread.MIN_PRIORITY);
複製程式碼

5. interrupt()

不要以為它是中斷某個執行緒!它只是線執行緒傳送一箇中斷訊號,讓執行緒在無限等待時(如死鎖時)能丟擲丟擲,從而結束執行緒,但是如果你吃掉了這個異常,那麼這個執行緒還是不會中斷的!

6. wait() 暫停執行緒,釋放 cpu 控制權,同時釋放物件鎖的控制

Obj.wait() 與 Obj.notify() 必須要與 synchronized(Obj) 一起使用,也就是 wait 與 notify 是針對已經獲取了 Obj 鎖進行操作,從語法角度來說就是 Obj.wait()、Obj.notify 必須在 synchronized(Obj){...} 語句塊內。從功能上來說 wait 就是說執行緒在獲取物件鎖後,主動釋放物件鎖,同時本執行緒休眠。直到有其它執行緒呼叫物件的notify()喚醒該執行緒,才能繼續獲取物件鎖,並繼續執行。相應的 notify() 就是對物件鎖的喚醒操作。但有一點需要注意的是 notify() 呼叫後,並不是馬上就釋放物件鎖的,而是在相應的 synchronized(){} 語句塊執行結束,自動釋放鎖後, JVM會在wait() 物件鎖的執行緒中隨機選取一執行緒,賦予其物件鎖,喚醒執行緒,繼續執行。這樣就提供了線上程間同步、喚醒的操作。 Thread.sleep() 與 Object.wait() 二者都可以暫停當前執行緒,釋放 CPU 控制權,主要的區別在於 Object.wait() 在釋放 CPU 同時,釋放了物件鎖的控制。     單單在概念上理解清楚了還不夠,需要在實際的例子中進行測試才能更好的理解。對 Object.wait() 、Object.notify() 的應用最經典的例子,應該是三執行緒列印 ABC 的問題了吧,這是一道比較經典的面試題,題目要求如下:     建立三個執行緒,A執行緒列印 10 次 A、B 執行緒列印 10 次 B、C 執行緒列印 10 次 C,要求執行緒同時執行,交替列印 10 次 ABC 。這個問題用 Object 的 wait() , notify() 就可以很方便的解決。程式碼如下:

/**
 * wait() 練習
 * Created by Sean on 2017/5/9.
 */
class Thread8 implements Runnable {

    private String name;
    private Object prev;
    private Object self;

    private Thread8(String name, Object prev, Object self) {
        this.name = name;
        this.prev = prev;
        this.self = self;
    }

    @Override
    public void run() {
        int count = 10;
        while (count > 0) {
            synchronized (prev) {   //上一個物件鎖,先申請上一個物件的鎖,如果上個執行緒釋放物件鎖,則獲取該物件鎖
                synchronized (self) {   // 當前物件鎖
                    System.out.print(name + ((count == 1 && name.equals("C")) ? "" : "->"));
                    count--;
                    self.notify(); // 喚醒下一個等待執行緒
                }
                try {
                    prev.wait();// 釋放當前執行緒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Object a = new Object();
        Object b = new Object();
        Object c = new Object();
        Thread8 threadA = new Thread8("A", c, a);//c是上個物件,a是當前物件
        Thread8 threadB = new Thread8("B", a, b);//a是上個物件,b是當前物件
        Thread8 threadC = new Thread8("C", b, c);//b是上個物件,c是當前物件
        new Thread(threadA).start();
        Thread.sleep(100);  //確保按順序A、B、C執行
        new Thread(threadB).start();
        Thread.sleep(100);  //確保按順序A、B、C執行
        new Thread(threadC).start();
        Thread.sleep(100);  //確保按順序A、B、C執行
    }
}
複製程式碼

輸出結果:

輸出結果

先來解釋一下其整體思路,從大的方向上來講,該問題為三執行緒間的同步喚醒操作,主要的目的就是 ThreadA -> ThreadB -> ThreadC -> ThreadA 迴圈執行三個執行緒。為了控制執行緒執行的順序,那麼就必須要確定喚醒、等待的順序,所以每一個執行緒必須同時持有兩個物件鎖,才能繼續執行。一個物件鎖是 prev ,就是前一個執行緒所持有的物件鎖。還有一個就是自身物件鎖。主要的思想就是,為了控制執行的順序,必須要先持有 prev 鎖,也就前一個執行緒要釋放自身物件鎖,再去申請自身物件鎖,兩者兼備時列印,之後首先呼叫 self.notify() 釋放自身物件鎖,喚醒下一個等待執行緒,再呼叫 prev.wait() 釋放 prev 物件鎖,終止當前執行緒,等待迴圈結束後再次被喚醒。

通過上面程式碼可以看到, A、B、C 都被順序列印了十次,過程是這樣的:

  1. 列印A:A 執行緒先執行,A 執行緒持有 C、A對像鎖,因為C物件鎖對應上一個列印的執行緒,A 物件鎖對應自己列印的執行緒。然後在自身物件鎖中synchronized (self) { }執行完之後喚醒下一個列印執行緒,然後在上一個物件鎖synchronized (prev) { }中暫停執行緒、釋放 CPU 的控制權,同時釋放 C 物件鎖的控制權
  2. 列印B:拿到執行緒 A 釋放的 A 對像鎖,然後獲取自身的 B 物件鎖,重複上面“列印A”的步驟
  3. 列印C:拿到執行緒 B 釋放的 B 對像鎖,然後獲取自身的 C 物件鎖,重複上面“列印A”的步驟
  4. 列印A:拿到執行緒 C 釋放的 C 對像鎖,然後獲取自身的 A 物件鎖,重複上面“列印A”的步驟 . . . . . 這樣一直執行到程式結束全部列印完畢。

wait() 和 sleep() 區別

  1. 共同點:
  • 他們都是在多執行緒的環境下,都可以在程式的呼叫處阻塞指定的毫秒數,並返回。
  • wait()和sleep()都可以通過interrupt()方法 打斷執行緒的暫停狀態 ,從而使執行緒立刻丟擲InterruptedException。

如果執行緒A希望立即結束執行緒B,則可以對執行緒B對應的Thread例項呼叫interrupt方法。如果此刻執行緒B正在wait/sleep /join,則執行緒B會立刻丟擲InterruptedException,在catch() {} 中直接return即可安全地結束執行緒。 需要注意的是,InterruptedException是執行緒自己從內部丟擲的,並不是interrupt()方法丟擲的。對某一執行緒呼叫 interrupt()時,如果該執行緒正在執行普通的程式碼,那麼該執行緒根本就不會丟擲InterruptedException。但是,一旦該執行緒進入到 wait()/sleep()/join()後,就會立刻丟擲InterruptedException 。

  1. 不同點:
  • Thread類的方法:sleep(),yield()等 Object的方法:wait()和notify()等
  • 每個物件都有一個鎖來控制同步訪問。Synchronized關鍵字可以和物件的鎖互動,來實現執行緒的同步。 sleep方法沒有釋放鎖,而wait方法釋放了鎖,使得其他執行緒可以使用同步控制塊或者方法。
  • wait,notify和notifyAll只能在同步控制方法或者同步控制塊裡面使用,而sleep可以在任何地方使用

所以sleep()和wait()方法的最大區別是:     sleep()睡眠時,保持物件鎖,仍然佔有該鎖;     而wait()睡眠時,釋放物件鎖。 但是wait()和sleep()都可以通過interrupt()方法打斷執行緒的暫停狀態,從而使執行緒立刻丟擲InterruptedException(但不建議使用該方法)

sleep() 方法

sleep()使當前執行緒進入停滯狀態(阻塞當前執行緒),讓出CUP的使用、目的是不讓當前執行緒獨自霸佔該程式所獲的CPU資源,以留一定時間給其他執行緒執行的機會;

sleep()是Thread類的Static(靜態)的方法;因此他不能改變物件的機鎖,所以當在一個Synchronized塊中呼叫Sleep()方法是,執行緒雖然休眠了,但是物件的機鎖並木有被釋放,其他執行緒無法訪問這個物件(即使睡著也持有物件鎖)。

在sleep()休眠時間期滿後,該執行緒不一定會立即執行,這是因為其它執行緒可能正在執行而且沒有被排程為放棄執行,除非此執行緒具有更高的優先順序。

wait() 方法

wait()方法是Object類裡的方法;當一個執行緒執行到wait()方法時,它就進入到一個和該物件相關的等待池中,同時失去(釋放)了物件的機鎖(暫時失去機鎖,wait(long timeout)超時時間到後還需要返還物件鎖);其他執行緒可以訪問;

wait()使用notify或者notifyAlll或者指定睡眠時間來喚醒當前等待池中的執行緒。

wiat()必須放在synchronized block中,否則會在program runtime時扔出”java.lang.IllegalMonitorStateException“異常。

七、常見執行緒名詞解釋以及常用方法

1. 名詞解釋

  • 主執行緒:JVM 呼叫程式 main() 所產生的執行緒。
  • 當前執行緒:這個是容易混淆的概念。一般指通過 Thread.currentThread() 來獲取的程式。
  • 後臺執行緒:指為其他執行緒提供服務的執行緒,也稱為守護執行緒。JVM 的垃圾回收執行緒就是一個後臺執行緒。使用者執行緒和守護執行緒的區別在於,是否等待主執行緒依賴於主執行緒結束而結束
  • 前臺執行緒:是指接受後臺執行緒服務的執行緒,其實前臺後臺執行緒是聯絡在一起,就像傀儡和幕後操縱者一樣的關係。傀儡是前臺執行緒、幕後操縱者是後臺執行緒。由前臺執行緒建立的執行緒預設也是前臺執行緒。可以通過 isDaemon() 和 setDaemon() 方法來判斷和設定一個執行緒是否為後臺執行緒。

2. 執行緒類的一些常用方法:

  • sleep():強迫一個執行緒睡眠N毫秒。
  • isAlive(): 判斷一個執行緒是否存活。
  • join(): 等待執行緒終止。
  • activeCount(): 程式中活躍的執行緒數。
  • enumerate(): 列舉程式中的執行緒。
  • currentThread(): 得到當前執行緒。
  • isDaemon(): 一個執行緒是否為守護執行緒。
  • setDaemon(): 設定一個執行緒為守護執行緒。(使用者執行緒和守護執行緒的區別在於,是否等待主執行緒依賴於主執行緒結束而結束)
  • setName(): 為執行緒設定一個名稱。
  • wait(): 強迫一個執行緒等待。
  • notify(): 通知一個執行緒繼續執行。
  • setPriority(): 設定一個執行緒的優先順序。
  • getPriority()::獲得一個執行緒的優先順序。

八、執行緒同步

1、synchronized關鍵字的作用域

  1. 是某個物件例項內,synchronized aMethod(){} 可以防止多個執行緒同時訪問這個物件的 synchronized 方法(如果一個物件有多個 synchronized 方法,只要一個執行緒訪問了其中的一個 synchronized 方法,其它執行緒不能同時訪問這個物件中任何一個 synchronized 方法)。這時,不同的物件例項的 synchronized 方法是不相干擾的。也就是說,其它執行緒照樣可以同時訪問相同類的另一個物件例項中的 synchronized 方法;
  2. 是某個類的範圍,synchronized static aStaticMethod{} 防止多個執行緒同時訪問這個類中的 synchronized static 方法。它可以對類的所有物件例項起作用。

2、synchronized關鍵字實現互斥訪問

除了方法前用 synchronized 關鍵字, synchronized 關鍵字還可以用於方法中的某個區塊中,表示只對這個區塊的資源實行互斥訪問。用法是: synchronized(this){/*區塊*/},它的作用域是當前物件;

3. 不能繼承

synchronized 關鍵字是不能繼承的,也就是說,基類的方法 synchronized f(){}在繼承類中並不自動是synchronized f(){},而是變成了 f(){} 。繼承類需要你顯式的指定它的某個方法為 synchronized 方法;

4. 用法

Java對多執行緒的支援與同步機制深受大家的喜愛,似乎看起來使用了synchronized關鍵字就可以輕鬆地解決多執行緒共享資料同步問題。到底如何?――還得對synchronized關鍵字的作用進行深入瞭解才可定論。

總的說來,synchronized關鍵字可以作為函式的修飾符,也可作為函式內的語句,也就是平時說的同步方法和同步語句塊。如果再細的分類,synchronized可作用於instance變數、object reference(物件引用)、static函式和class literals(類名稱字面常量)身上。

在進一步闡述之前,我們需要明確幾點:

  • 無論synchronized關鍵字加在方法上還是物件上,它取得的鎖都是物件,而不是把一段程式碼或函式當作鎖――而且同步方法很可能還會被其他執行緒的物件訪問。
  • 每個物件只有一個鎖(lock)與之相關聯。
  • 實現同步是要很大的系統開銷作為代價的,甚至可能造成死鎖,所以儘量避免無謂的同步控制。

接著來討論synchronized用到不同地方對程式碼產生的影響:

假設P1、P2是同一個類的不同物件,這個類中定義了以下幾種情況的同步塊或同步方法,P1、P2就都可以呼叫它們。

把synchronized當作函式修飾符時

示例程式碼如下:

Public synchronized void methodAAA()  
{  
      //….  
}  
複製程式碼

這也就是同步方法,那這時 synchronized 鎖定的是哪個物件呢?它鎖定的是呼叫這個同步方法物件。也就是說,當一個物件 P1 在不同的執行緒中執行這個同步方法時,它們之間會形成互斥,達到同步的效果。但是這個物件所屬的 Class 所產生的另一物件 P2 卻可以任意呼叫這個被加了 synchronized 關鍵字的方法。 上邊的示例程式碼等同於如下程式碼:

public void methodAAA()  
{  
synchronized (this)      //  (1)  
{  
       //…..  
}  
}  
複製程式碼

(1)處的this指的是什麼呢?它指的就是呼叫這個方法的物件,如P1。可見同步方法實質是將synchronized作用於object reference。――那個拿到了P1物件鎖的執行緒,才可以呼叫P1的同步方法,而對P2而言,P1這個鎖與它毫不相干,程式也可能在這種情形下襬脫同步機制的控制,造成資料混亂.

2. 同步塊

示例程式碼如下:

    public void method3(SomeObject so) {
        synchronized (so) {
            //…..  
        }
    }
複製程式碼

這時,鎖就是so這個物件,誰拿到這個鎖誰就可以執行它所控制的那段程式碼。當有一個明確的物件作為鎖時,就可以這樣寫程式,但當沒有明確的物件作為鎖,只是想讓一段程式碼同步時,可以建立一個特殊的 instance 變數(它得是一個物件)來充當鎖:

class Foo implements Runnable {
    private byte[] lock = new byte[0];  // 特殊的instance變數  

    public void methodA() {
        synchronized (lock) { //… }  
        }
//…..  
    }
}
複製程式碼

注:零長度的 byte 陣列物件建立起來將比任何物件都經濟――檢視編譯後的位元組碼:生成零長度的 byte[] 物件只需3條操作碼,而Object lock = new Object()則需要7行操作碼。

3. 將synchronized作用於static 函式

示例程式碼如下:

class Foo {
    public synchronized static void methodAAA()   // 同步的static 函式
    {
//….
    }

    public void methodBBB() {
        synchronized (Foo.class)   //  class literal(類名稱字面常量)
    }
}  
複製程式碼

程式碼中的 methodBBB() 方法是把 class literal 作為鎖的情況,它和同步的 static 函式產生的效果是一樣的,取得的鎖很特別,是當前呼叫這個方法的物件所屬的類(Class,而不再是由這個 Class 產生的某個具體物件了)。 記得在《Effective Java》一書中看到過將 Foo.class 和 P1.getClass() 用於作同步鎖還不一樣,不能用 P1.getClass() 來達到鎖這個 Class 的目的。 P1 指的是由 Foo 類產生的物件。 可以推斷:如果一個類中定義了一個 synchronized 的 static 函式A,也定義了一個 synchronized 的 instance 函式B,那麼這個類的同一物件 Obj 在多執行緒中分別訪問 A 和 B 兩個方法時,不會構成同步,因為它們的鎖都不一樣。 A 方法的鎖是 Obj 這個物件,而 B 的鎖是 Obj 所屬的那個 Class 。

總結

  1. 執行緒同步的目的是為了保護多個執行緒反問一個資源時對資源的破壞。
  2. 執行緒同步方法是通過鎖來實現,每個物件都有切僅有一個鎖,這個鎖與一個特定的物件關聯,執行緒一旦獲取了物件鎖,其他訪問該物件的執行緒就無法再訪問該物件的其他非同步方法
  3. 對於靜態同步方法,鎖是針對這個類的,鎖物件是該類的Class物件。靜態和非靜態方法的鎖互不干預。一個執行緒獲得鎖,當在一個同步方法中訪問另外物件上的同步方法時,會獲取這兩個物件鎖。
  4. 對於同步,要時刻清醒在哪個物件上同步,這是關鍵。
  5. 編寫執行緒安全的類,需要時刻注意對多個執行緒競爭訪問資源的邏輯和安全做出正確的判斷,對“原子”操作做出分析,並保證原子操作期間別的執行緒無法訪問競爭資源。
  6. 當多個執行緒等待一個物件鎖時,沒有獲取到鎖的執行緒將發生阻塞。
  7. 死鎖是執行緒間相互等待鎖鎖造成的,在實際中發生的概率非常的小。真讓你寫個死鎖程式,不一定好使,呵呵。但是,一旦程式發生死鎖,程式將死掉。

九、執行緒資料傳遞

在傳統的同步開發模式下,當我們呼叫一個函式時,通過這個函式的引數將資料傳入,並通過這個函式的返回值來返回最終的計算結果。但在多執行緒的非同步開發模式下,資料的傳遞和返回和同步開發模式有很大的區別。由於執行緒的執行和結束是不可預料的,因此,在傳遞和返回資料時就無法象函式一樣通過函式引數和 return 語句來返回資料。

1. 通過構造方法傳遞資料

在建立執行緒時,必須要建立一個 Thread 類的或其子類的例項。因此,我們不難想到在呼叫 start 方法之前通過執行緒類的構造方法將資料傳入執行緒。並將傳入的資料使用類變數儲存起來,以便執行緒使用(其實就是在 run 方法中使用)。下面的程式碼演示瞭如何通過構造方法來傳遞資料:

/**
 * Created by Sean on 2017/5/9.
 */
class Thread9 extends Thread {
    private String threadName;

    public Thread9(String threadName) {
        this.threadName = threadName;
    }

    public void run() {
        System.out.println("hello " + threadName);
    }
}

public class Main9 {
    public static void main(String[] args) {
        Thread9 thread = new Thread9("world");
        thread.start();
    }
}
複製程式碼

由於這種方法是在建立執行緒物件的同時傳遞資料的,因此,線上程執行之前這些資料就就已經到位了,這樣就不會造成資料線上程執行後才傳入的現象。如果要傳遞更復雜的資料,可以使用集合、類等資料結構。使用構造方法來傳遞資料雖然比較安全,但如果要傳遞的資料比較多時,就會造成很多不便。由於 Java 沒有預設引數,要想實現類似預設引數的效果,就得使用過載,這樣不但使構造方法本身過於複雜,又會使構造方法在數量上大增。因此,要想避免這種情況,就得通過類方法或類變數來傳遞資料。

2. 通過變數和方法傳遞資料

向物件中傳入資料一般有兩次機會:

  • 第一次機會是在建立物件時通過構造方法將資料傳入;
  • 另外一次機會就是在類中定義一系列的 public 的方法或變數(也可稱之為欄位)。然後在建立完物件後,通過物件例項逐個賦值。下面的程式碼是對Thread9 類的改版,使用了一個 setThreadName 方法來設定 threadName 變數:
/**
 * Created by Sean on 2017/5/9.
 */
class Thread9 extends Thread {
    private String threadName;

    public Thread9(String threadName) {
        this.threadName = threadName;
    }

    public String getThreadName() {
        return threadName;
    }

    public void setThreadName(String threadName) {
        this.threadName = threadName;
    }

    public void run() {
        System.out.println("hello " + threadName);
    }
}

public class Main9 {
    public static void main(String[] args) {
        Thread9 thread = new Thread9("world");
        thread.start();
    }
}
複製程式碼

3. 通過回撥函式傳遞資料

面討論的兩種向執行緒中傳遞資料的方法是最常用的。但這兩種方法都是 main 方法中主動將資料傳入執行緒類的。這對於執行緒來說,是被動接收這些資料的。然而,在有些應用中需要線上程執行的過程中動態地獲取資料,如在下面程式碼的 run 方法中產生了 3 個隨機數,然後通過 Work 類的 process 方法求這三個隨機數的和,並通過 Data 類的 value 將結果返回。從這個例子可以看出,在返回 value 之前,必須要得到三個隨機數。也就是說,這個 value 是無法事先就傳入執行緒類的。

/**
 * 回撥實現多執行緒傳遞資料
 * Created by Sean on 2017/5/9.
 */
class Data {
    public int value = 0;
}

class Work {
    public void process(Data data, Integer[] numbers) {
        for (int n : numbers) {
            data.value += n;
        }
    }
}


class Thread10 extends Thread {
    private Work work;

    public Thread10(Work work) {
        this.work = work;
    }

    @Override
    public void run() {
        super.run();
        Integer[] numbers = new Integer[3];
        java.util.Random random = new java.util.Random();
        Data data = new Data();
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = random.nextInt(100);
        }
        work.process(data,numbers);
        System.out.println(String.valueOf(numbers[0]) + "+" + String.valueOf(numbers[1]) + "+"
                + String.valueOf(numbers[2]) + "=" + data.value);
    }

    public static void main(String[] args)
    {
        Thread thread = new Thread10(new Work());
        thread.start();
    }
}
複製程式碼

多執行緒就寫到這裡了,基本都是按照這篇部落格敲的,每個貼的程式碼都是親自重寫、驗證,都是可執行的,雖然花了很多時間,但是自己對多執行緒有了更深層次的認識,希望這篇文章可以幫到大家。

你可以通過以下方式關注我:

  1. CSDN
  2. 掘金
  3. 個人部落格
  4. Github

相關文章