Java築基——多線程(狀態、安全以及通信)

1. 引入

在學習多線程之前,我們需要先學習一些基本概念:

多核 CPU 和多 CPU

多核 CPU 是在一枚處理器(CPU)中集成兩個或多個完整的計算引擎(核心),不同的核通過 L2 cache 進行通信,存儲和外設通過總線與CPU 通信。

多CPU 是多個物理 CPU,CPU 通過總線進行通信,效率比較低。

無論多個計算核是在多個 CPU 芯片上還是在單個 CPU 芯片上,我們稱之爲多核或多處理器系統。

CPU 核心數和線程數的關係

核心數和線程數是 1:1 的關係,也就是說 4 核的 CPU 可以同時運行 4 個線程。需要注意的是,這裏說的同時是指單位時間內可以處理 4 個線程。

英特爾的多線程技術是在CPU內部僅複製必要的資源、讓兩個線程可同時運行;在一單位時間內處理兩個線程的工作,模擬實體雙核心、雙線程運作。

所以,4 核的 CPU 採用如果採用了超線程技術,那麼它可以同時運行 8 個線程。

CPU 時間片輪轉調度算法

CPU 時間片輪轉調度算法,又叫 RR 調度(Round-Robin,RR),它是專門爲分時系統設計的。

在這個算法中,將一個較小的時間單元定義爲時間量或時間片。時間片的大小通常是 10~100 ms。就緒隊列作爲循環隊列。CPU 調度程序循環整個就緒隊列,爲每個進程分配不超過一個時間片的 CPU。

爲了實現 RR 調度,我們再次將就緒隊列視爲進程的 FIFO 隊列。新進程添加到就緒隊列的尾部。CPU 調度程序從就緒隊列中選擇第一個進程,將定時器設置在一個時間片後中斷,最後分派這個進程。

接下來,有兩種情況可能發生。進程可能只需少於時間片的 CPU 執行。對於這種情況,進程本身會自動釋放 CPU。調度程序接着處理就緒隊列的下一個進程。否則,如果當前運行進程的 CPU 執行大於一個時間片,那麼定時器會中斷,進而中斷操作系統。然後,進行上下文切換(從一個任務切換到另一個任務),再將進程加到就緒隊列的尾部(從這點看,RR 調度是搶佔式的),接着 CPU 調度程序會選擇就緒隊列內的下一個進程。

RR 算法的性能很大程度取決於時間片的大小。時間片設置的太短,會導致大量的上下文切換,降低 CPU 效率;時間片設置太長,可能會引起對短的交互請求的響應變差。

關於時間片輪轉調度算法可以查看資料: 時間片輪轉(RR)調度算法(詳解版)。這裏介紹地比較詳細。

進程和線程

進程是操作系統進行資源分配的最小單位。一個程序至少有一個進程。線程是程序執行的最小單位,進程中的一個負責程序執行的控制單元(執行路徑)。一個線程就是在進程中的一個單一的順序控制流。一個進程至少有一個線程。

進程有自己獨立的地址空間,在進程啓動時,系統就給它分配了地址空間,建立數據表來維護代碼段、堆棧段和數據段,這種操作非常昂貴。而線程是共享進程中的數據的,使用相同的地址空間,因此 CPU 切換一個線程的花費遠比進程小很多,而且創建一個線程的開銷也比進程小很多(這是因爲線程基本上不擁有系統資源,只擁有在運行中必不可少的資源,如程序計數器,一組寄存器和棧)。

線程之間的通信更方便,同一進程下的線程共享全局變量、靜態變量等數據,而進程之間的通信需要使用 IPC 接口,包括管道、消息排隊、共用內存以及套接字等。

並行和併發

並行(Parallel)是“並排行走”或“同時實行”,在計算機操作系統中指,一組程序按照獨立異步的速度執行,無論是微觀還是宏觀,程序都是一起執行的。
併發(Concurrent),是指一個時間段中有幾個程序都處於已啓動運行到運行完畢之間,且這幾個程序都是在同一個處理機上運行,但任一個時刻點上只有一個程序在處理機上運行。

討論併發的時候一定要加個單位時間,也就是說單位時間內的併發量是多少,離開了單位時間談論併發是沒有意義的。

2. 爲什麼使用併發編程?

使用併發編程可以發揮多處理器的強大能力

我們可以讓操作系統同時運行多個任務,比如一邊在瀏覽器裏瀏覽文章,一邊在 Word 裏做筆記,還可以一邊聽着 MP3,這裏就有 3 個任務在運行。

使用併發編程可以構建響應更靈敏的用戶界面

比如我們在網頁上看電影,一邊又在下載電影,這裏就需要用到兩個線程:一個線程用於播放,一個線程用於下載。試想只有一個線程實現的話,就要麼先在網頁上看電影,等看完後再去下載;要麼先下載好,再去網頁上看電影。

可能會有同學說,我在一個線程裏同時執行播放電影和下載電影不行嗎?這樣播放電影的界面會非常卡。其實,我們還可以用 Android 手機的應用來說明,我們知道應用裏有一個主線程,也叫UI線程,這個線程負責處理用戶的操作響應及界面的繪製,現在用戶在屏幕上點擊按鈕下載一個 MP3 文件,這時如果仍在主線程單獨去處理這個下載任務,那麼用戶在屏幕上做點擊,滑動等操作會很卡,這非常影響用戶體驗。所以,這種情況下,Android 會彈出 ANR (應用無響應)的提示框。當然,最佳的做法是開啓一個工作線程,單獨去處理下載 MP3 文件的任務,等到下載任務完成後,通知主線程,這樣用戶就可以得知下載已完成。這樣的好處,就是保持用戶界面靈敏,及時響應。

異步化,模塊化代碼

比如,我們應用裏有登錄,數據上報,下載,這些其實都是一個一個的任務,把它們放在單獨的線程裏來執行,不僅可以使用戶界面靈敏,而且實現了模塊化。模塊化怎麼理解呢?比如,登錄任務,在公司的幾個應用裏都用到了登錄功能,那麼我們把登錄做一下封裝,提供一些調用接口及回調接口供其他同學使用,這就是模塊化。模塊化有什麼好處?避免了其他同學再去開發一遍,也可以方便地對這個模塊進行測試以及定位問題。

3. Java 如何實現併發編程?

實現併發編程的方式有:多進程模式;多線程模式;多進程+多線程模式。

Java語言內置了對多線程的支持:運行一個 Java 程序實際上是在運行一個 JVM 進程,JVM 進程在主線程裏執行 main() 方法;在 main() 方法內部,又可以啓動多個線程。JVM 還會開啓其他工作線程,如垃圾回收線程等。

所以,Java 是採用多線程模式實現併發編程的。

不過,需要特別說明的是,多線程模式下,會出現線程安全問題。

下面的部分包括從開啓線程的方式,線程的狀態,引出線程的安全問題,解決線程安全問題的一系列方案,線程間通信一一介紹。

4. 開啓線程的方式

聲明繼承 Thread 的類

步驟如下:

  1. 定義一個繼承 Thread 的子類;
  2. 在子類中覆蓋 Thread 中的 run 方法;
  3. 創建子類對象得到線程對象;
  4. 調用線程對象的 start 方法啓動線程。

下面是演示代碼:

// 1, 定義一個繼承 Thread 的子類
class MyThread extends Thread {
    // 2, 在子類中覆蓋 Thread 中的 run 方法
    @Override
    public void run() {
        System.out.println("I am executing a heavy task.");
    }
}
public class Create1 {
    public static void main(String[] args) {
        // 3, 創建子類對象得到線程對象
        Thread myThread = new MyThread();
        // 4, 調用線程對象的 start 方法啓動線程
        myThread.start();
    }
}
/**
 打印結果:
 I am executing a heavy task.
 */

聲明實現 Runnable 接口的類

步驟如下:

  1. 定義實現 Runnable 接口的類;
  2. 在實現類中覆蓋 Runnable 接口的 run 方法;
  3. 通過 Thread 類創建線程對象,把 Runnable 接口的實現類通過 Thread 的構造方法進行傳遞;
  4. 調用線程對象的 start 方法啓動線程。

下面是演示代碼:

// 1. 定義實現 Runnable 接口的類;
class MyRunnable implements Runnable {
    // 2. 在實現類中覆蓋 Runnable 接口的 run 方法;
    @Override
    public void run() {
        System.out.println("I am executing a heavy task.");
    }
}
public class Create2 {
    public static void main(String[] args) {
        // 3. 通過 Thread 類創建線程對象,把 Runnable 接口的實現類
        // 通過 Thread 的構造方法進行傳遞;
        Thread thread = new Thread(new MyRunnable());
        // 4. 調用線程對象的 start 方法啓動線程。
        thread.start();
    }
}
/*
打印結果:
I am executing a heavy task.
 */

需要注意的地方

一個線程實例只能調用 start 方法一次

調用多次,會拋出如下異常:

Exception in thread "main" java.lang.IllegalThreadStateException

這一點的原因,從 Thread 類的 start 方法源碼可以得出來。

不能混淆了 start 方法和 run 方法

  • 它們的所屬不同:start 方法是屬於 Thread 類的方法,run 方法是屬於 Runnable 接口的方法;
  • 它們的定位不同:在 Thread 對象上調用 start 方法才能創建並開啓線程,正因爲調用了 start 方法,線程才從無到有,從有到啓動,它內部調用了 start0() 這個 native 方法,而run 方法僅僅是封裝了需要執行的代碼,這是一個普通方法本有的作用。
  • 它們的調用不同:在開啓線程時,start 方法是需要手動調用來創建並開啓線程的,而 線程的run 方法是由虛擬機調用的。

兩種開啓線程方式的區別

源碼上的區別:

  • 繼承Thread : 由於子類重寫了Thread類的run(), 當調用start()時, 直接找子類的run()方法;
  • 實現Runnable :構造函數中傳入了Runnable的引用, 賦值給成員變量Runnable targetstart()調用run()方法時內部判斷成員變量Runnable的引用是否爲空,不爲空編譯時看的是Runnablerun(),運行時執行的是子類的 run() 方法。
    // 這是 Thread 類的代碼。
    private Runnable target;
    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }
    

使用上的區別:

  • 繼承Thread類:可以直接使用Thread類中的方法,代碼簡單;但是如果已經有了父類,就不能用這種方法,這是因爲 Java 中的類不支持多繼承;
  • 實現 Runnable 接口:將線程的任務從線程的子類中分離出來,進行了單獨的封裝。按照面向對象的思想將任務封裝成了對象。這個是思想的變化。即使自己定義的線程類有了父類也沒關係,因爲有了父類也可以實現接口,而且接口是可以多實現的,避免了 Java 中單繼承的缺點。但是,不能直接使用Thread中的方法需要先獲取到線程對象後,才能得到Thread的方法,代碼複雜。

5. 線程的狀態

在這裏插入圖片描述
線程的狀態究竟有幾種,網上有多個版本,說的都有道理;但其實 Java 已經幫我們定義好了線程的狀態。
我們看一下 Thread 類中的枚舉類 State ,它包含 6 種狀態:

public enum State {
	NEW,
	RUNNABLE,
	BLOCKED,
	WAITING,
	TIMED_WAITING,
	TERMINATED;
}
  • NEW(新建狀態)

    在創建了 Thread 對象後還沒有調用 start() 方法時所處的狀態,這時只是一個堆內存中的對象而已。

  • RUNNABLE(可運行狀態)

    線程對象調用 start() 方法後,它會被線程調度器執行,也就是交給操作系統來執行,這整個狀態叫 RUNNABLE。處在這個狀態的線程正在 JVM 裏面執行,但也可能正在等待操作系統(例如處理器)分配資源。

    所以 RUNNABLE 內部有兩個狀態:Ready 就緒狀態和 Running 運行狀態。

    • Ready 狀態

      Ready 狀態是說線程目前處於 CPU 的等待隊列裏,在等待 CPU 執行。這時線程對象具備 CPU 執行資格(具備 CPU 執行資格是指線程對象可以被 CPU 處理,正在處理隊列中排隊),但是不具備 CPU 執行權(具備 CPU 執行權指的是獲取了 CPU 的時間片,正在執行 run() 方法中的代碼)。

      這裏舉個例子來說明 CPU 執行資格和 CPU 執行權:去園區餐廳喫飯需要有園區的餐卡纔可以,這時就可以說有卡的員工能夠在園區排隊打飯,也就是說具備打飯的資格;有卡正在打飯的員工是具備打飯的資格並且具備打飯的執行權;那麼,沒有卡的外來人員,不能在園區排隊打飯,也就是說沒有打飯的資格,當然也不可能去打飯,也就是說沒有打飯的執行權。
      大家一定要理解 CPU 執行資格和 CPU 執行權,因爲下面會用它們來區分線程的一些狀態。

    • Running 狀態

      Running 狀態是說線程正在被 CPU 執行。這時線程具備 CPU 的執行資格並且具備 CPU 的執行權,具體來說,線程正處於 CPU 分配的時間片內,執行着 run() 方法裏的代碼。

  • BLOCKED 阻塞狀態

    線程正在等待獲取監視器鎖對象,就處於阻塞狀態。處於受阻塞狀態的某一線程正在等待監視器鎖,以便進入一個同步的塊/方法,或者在調用 Object.wait() 之後再次進入同步的塊/方法。

  • WAITING 等待狀態

  • TIMED_WAITING 定時等待狀態

  • TERMINATED消亡狀態

    run() 方法結束的時候,也就是線程任務完成的時候,就自然進入了消亡狀態;
    當調用了線程對象的 stop()(這個方法已經標記爲 @Deprecated,不建議使用的方法) 方法後,好比是因爲不可抗力進入消亡狀態;
    至於 thread.setDaemon(true) 是指的後臺線程,調用這句代碼需要在線程的 start() 方法之前調用,把線程設置爲後臺線程。後臺線程,是在程序運行時在後臺提供一種通用服務的線程,它並不屬於程序中不可缺少的部分。因此,當所有的非後臺線程結束時,程序就終止了,並且會殺死進程中所有的後臺線程。

另外,需要說明一下上面圖中提到的方法:

join 方法

 public final synchronized void join(long millis)
    throws InterruptedException

這是 Thread 類的一個成員方法,會拋出 InterruptedException。比如現在有線程 A 和線程 B,A,B 線程已經開啓,現在在 A 線程的 run 方法裏,調用 B線程的 join() 方法,這時 A 線程就會被掛起,直到 B 線程結束了才恢復。直觀地理解就是,B 線程插到 A 線程之前執行。
下面是演示代碼:

class Task implements Runnable {
    private Thread joiner;
    public Task(Thread joiner) {
        this.joiner = joiner;
    }
    @Override
    public void run() {
        try {
            joiner.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " is doing task.");
    }
}
public class JoinDemo {
    public static void main(String[] args) {
        Thread joiner = Thread.currentThread();
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(new Task(joiner), "Thread " + i);
            thread.start();
            joiner = thread;
        }
        System.out.println(Thread.currentThread().getName() + " is doing task.");
    }
}

打印結果:

main is doing task.
Thread 0 is doing task.
Thread 1 is doing task.
Thread 2 is doing task.
Thread 3 is doing task.
Thread 4 is doing task.

可以看到,小號線程都是在大號線程前面執行,這不是一種偶然,每次運行都是這樣的結果。這是因爲調用了 joiner.join() 方法。可以嘗試一下,把 joiner.join() 註釋掉,打印出來的結果一定不能保證每次都一樣。

sleep 方法:

public static native void sleep(long millis) 
	throws InterruptedException;

這是 Thread 類中的一個靜態方法,會拋出 InterruptedException。表示使任務中止執行給定的時間。

yield 方法:

public static native void yield();

這是 Thread 類的一個靜態方法,沒有異常拋出。執行這個方法,表明當前線程已經執行完最重要的任務,現在給線程調度器建議:切換給其他線程執行。

interrupt 方法:

public void interrupt()
public static boolean interrupted()

Thread 類中有一個靜態的 interrupted() 方法和一個interrupt() 成員方法。關於它們的不同,後面會做說明。

wait 方法:
Object 類中:

public final void wait() throws InterruptedException
public final native void wait(long timeout) throws InterruptedException;
public final void wait(long timeout, int nanos) throws InterruptedException

表示等待某個條件發生變化。需要強調一下的是,這個方法是 Object 類中的方法,而不是 Thread 類中的方法。
notify/notifyAll 方法:
Object 類中:

public final native void notify();
public final native void notifyAll();

表示通知條件已發生變化。同樣地,需要強調一下的是,這個方法是 Object類中的方法,而不是 Thread 類中的方法。

6. 線程安全問題

6.1 線程安全問題的原因

我們從一個多窗口賣票的例子來做一下說明線程安全問題:
火車站售票大廳有 4 個售票窗口可以售票,現有 10 張票待售。現在考慮一下,使用程序實現這個賣票的過程。

思考一下:10 張票待出售,這是任務,任務在程序裏是 Runnable 的實現類;4 個售票窗口是用來執行售票任務,在程序中就是線程。

程序實現如下:

class Ticket implements Runnable {
    private int num = 10;
    @Override
    public void run() {
        while (true) {
            if (num > 0) {
            	// 線程逗留點1
                try {
                	// 售票需要時間,這裏給 100 ms。
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 線程逗留點2
                System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
            }
        }
    }
}

public class TicketDemo {
    public static void main(String[] args) {
        Ticket t = new Ticket();
        Thread window1 = new Thread(t, "Window1");
        Thread window2 = new Thread(t, "Window2");
        Thread window3 = new Thread(t, "Window3");
        Thread window4 = new Thread(t, "Window4");
        window1.start();
        window2.start();
        window3.start();
        window4.start();
    }
}

運行一下上面的程序,可以發現打印結果並非是每次一樣的。
這裏以一次的運行結果爲例,來說明問題:

Window4.....sell.....Ticket#10
Window1.....sell.....Ticket#8
Window3.....sell.....Ticket#9
Window2.....sell.....Ticket#10
Window1.....sell.....Ticket#7
Window2.....sell.....Ticket#6
Window3.....sell.....Ticket#5
Window4.....sell.....Ticket#4
Window1.....sell.....Ticket#3
Window4.....sell.....Ticket#2
Window3.....sell.....Ticket#1
Window2.....sell.....Ticket#0
Window1.....sell.....Ticket#-1
Window4.....sell.....Ticket#-2

觀察上面的打印信息,不難發現一些不對勁兒的地方:
Ticket#10 這張票,被 Window4 和 Window2 各售出一次;更嚴重的是,竟然賣出了 Ticket#0,Ticker#-1,Ticket#-2 這種根本就不存在的票。

這是什麼原因呢?我們就打印日誌的最後四行來分析:

Window3.....sell.....Ticket#1
Window2.....sell.....Ticket#0
Window1.....sell.....Ticket#-1
Window4.....sell.....Ticket#-2

run 方法裏的 if 語句單獨拿出來:

if (num > 0) {
  	 // 線程執行站點1
      try {
      	// 售票需要時間,這裏給 100 ms。
          Thread.sleep(100);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
      // 線程執行站點2
      System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
}

爲方便說明,在上面的代碼中加入了線程站點 1,線程站點 2。它們的含義是線程在這兩處可能被給到 CPU 時間片,或者被剝奪 CPU 時間片。
Window3 首先拿到了 CPU 時間片,它執行到了打印語句的地方;與此同時,Window2,Window1,Window4 都到達了線程執行站點1,它們被剝奪了 CPU 時間片。在 Window3 執行完打印語句後,這時 num 的值已經是 0 了;
這時 Window2 獲取了 CPU 時間片,繼續執行它的代碼,打印出 0 號票,這時num 的值已經是-1
之後,Window1 獲取了 CPU 時間片,繼續執行它的代碼,打印出 -1 號票,這時 num 的值已經是 -2 了;
之後,Window4 獲取到了 CPU 時間片,繼續執行它的代碼,打印出 -2 號票。

上面分析了導致結果異常的原因,就是多個線程同時執行了相同的任務,對票數這一數據操作導致的。
試想一下,如果只有一個窗口在賣票,還會出現輸出結果異常嗎?
我們可以把 Window2,Window3,Window4 這幾個窗口關掉,僅留下 Window1,打印一下,結果如下:

Window1.....sell.....Ticket#10
Window1.....sell.....Ticket#9
Window1.....sell.....Ticket#8
Window1.....sell.....Ticket#7
Window1.....sell.....Ticket#6
Window1.....sell.....Ticket#5
Window1.....sell.....Ticket#4
Window1.....sell.....Ticket#3
Window1.....sell.....Ticket#2
Window1.....sell.....Ticket#1

這可不是偶然的結果,因爲一個線程執行任務,數據只有它自己在操作,不會出現異常情況。
那麼,多線程執行同一個任務就一定會出現問題嗎?並不是的,如果多線程所執行的任務只是一行打印語句,當然不會有問題。問題在於任務裏面包含了多行代碼。

所以,這裏總結一下,線程安全問題產生的原因
第一點,多個線程在操作共享的數據;
第二點,操作共享數據的線程代碼有多行。

這兩點是且的關係。

在本例中,共享的數據就是 Ticket 對象。

6.2 線程安全問題解決辦法

如果有一種機制,能使一個線程在操作多行代碼時,其他線程不能再去操作這些多行代碼;只有當這個線程操作完這些多行代碼後,其他線程才能夠去操作這些多行代碼。這樣就能好比把這些多行代碼打包成一個整體,就好像“一行代碼”一樣。這樣就不會出現線程安全問題了。
在 Java 中,已經存在這樣的機制,這種機制是通過關鍵字 synchronized 來完成的。

6.2.1 同步代碼塊

同步代碼塊的寫法如下:

sychronized(對象) {
	需要被同步的代碼;
}

其中,sychronized 是一個關鍵字,後面跟着一個括號裏,是對象,起鎖的作用,大括號裏就是需要同步的代碼。這裏面關鍵的是用什麼對象來作鎖,識別哪些是需要被同步的代碼。

Java 通過提供synchronized 關鍵字的形式,對於防止資源衝突提供了內置的支持。當任務要執行被 synchronized 關鍵字保護的代碼片段的時候,會先檢查鎖是否可用,可用的話就獲取鎖,執行代碼片段,再釋放鎖;不可用的話就不能獲取鎖,也就不能執行代碼片段,此時任務處於阻塞狀態。

回到賣票的例子裏,我們使用一個 new Object(); 對象來作鎖,需要同步的代碼是:

if (num > 0) {
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
}

完整的代碼如下:

class Ticket implements Runnable {
    private int num = 10;
    private Object obj = new Object();
    @Override
    public void run() {
        while (true) {
            synchronized (obj) {
                if (num > 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
                }
            }
        }
    }
}

再次多次運行程序,可以看到輸出結果都是正常的。

6.2.2 同步函數

我們把需要同步的多行代碼,封裝在一個函數 sellTicket 裏面,代碼就是這樣的:

class Ticket implements Runnable {
    private int num = 10;
    private Object obj = new Object();

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

    private void sellTicket() {
        if (num > 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
        }
    }
}

注意,目前我們並沒有做同步處理,運行時必然是存在線程安全問題的。現在我們對 sellTicket() 裏面的代碼作同步處理,如下:

private void sellTicket() {
    synchronized (obj) {
        if (num > 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
        }
    }
}

這樣同樣解決了線程安全問題。但是,我們注意到,sellTicket 是對需要同步的多行代碼進行了封裝,而同步代碼塊同樣是對多行代碼進行了封裝,實現了同步。既然它們都是封裝,難道不能合併嗎?
可以的,這就是同步函數的寫法:

private synchronized void sellTicket() {
    if (num > 0) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
    }
}

就是把 sychronized 關鍵字寫在函數聲明裏面,這和上面同步代碼塊的寫法作用是一樣的,同步函數可以說是同步代碼寫法的簡寫形式。

6.2.3 靜態同步函數

在同步函數的聲明上添加 static 關鍵字,這就是靜態同步函數:

private static synchronized void sellTicket() {
    if (num > 0) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
    }
}

在本例中,還需要把 num 變量聲明爲 static 類型。
靜態同步函數同樣可以達到目的。

6.2.4 同步代碼塊,同步函數,靜態同步函數的區別

既然三者都可以實現同步的目的,那麼它們之間有什麼區別呢?
它們的區別在於它們持有的鎖不一樣。
我們知道,同步代碼塊的鎖可以是任意的對象
同步函數使用的鎖是 this
靜態同步函數使用的鎖是該函數所在類的字節碼文件對象,即類名.class。

6.2.5 同步的優點和缺點

同步的好處:解決了線程的安全問題;
同步的弊端:相對降低了效率,因爲同步外的線程都會判斷同步鎖;
同步的前提:同步中必須有多個線程並使用同一個鎖。多個線程需要在同一個鎖當中。

6.2.6 死鎖的例子

如果此時有一個線程 A,需要按照先獲得鎖 1 再獲得鎖 2 的的順序獲得鎖,而在此同時又有另外一個線程B,按照先獲得鎖 2 再鎖 1 的順序獲得鎖,這種情況就會造成死鎖,誰也無法拿到對方的鎖。

class TaskA implements Runnable {

    @Override
    public void run() {
        while (true) {
            synchronized (DeadLockDemo.lock1) {
                System.out.println(Thread.currentThread().getName() + " do something.");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (DeadLockDemo.lock2) {
                    System.out.println(Thread.currentThread().getName() + " do other thing.");
                }
            }
        }
    }
}

class TaskB implements Runnable {

    @Override
    public void run() {
        while (true) {
            synchronized (DeadLockDemo.lock2) {
                System.out.println(Thread.currentThread().getName() + " do something.");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (DeadLockDemo.lock1) {
                    System.out.println(Thread.currentThread().getName() + " do other thing.");
                }
            }
        }

    }
}
public class DeadLockDemo {
    public static Object lock1 = new Object();
    public static Object lock2 = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(new TaskA(), "ThreadA");
        Thread threadB = new Thread(new TaskB(), "ThreadB");

        threadA.start();
        threadB.start();

    }
}

打印結果1:

ThreadB do something.
ThreadA do something.

打印結果2:

ThreadA do something.
ThreadB do something.

這裏分析一下爲什麼會造成死鎖?就第一種打印結果來說明。
main() 方法中開啓了 ThreadAThreadB 之後,ThreadB 首先獲得 CPU 的執行權,開始執行 TaskBrun() 方法:獲取 DeadLockDemo.lock2 這把鎖,打印出 ThreadB do something. ,接着就調用 Thread.sleep(100); 開始休眠 100 ms。

到這裏,需要特別說一下 sleep() 方法的作用:當調用 sleep() 方法之後,當前線程會釋放 CPU 的執行權,但是不會釋放鎖。

回到我們的例子,調用 Thread.sleep(100); 之後,當前線程 ThreadB 會釋放 CPU 的執行權,但是不會釋放它持有的鎖DeadLockDemo.lock2

接着,線程 ThreadA 獲取了 CPU 的執行權,開始執行 TaskArun() 方法:獲取 DeadLockDemo.lock1 鎖,打印出 ThreadA do something.,接着調用 Thread.sleep(100); 開始休眠 100 ms,也就是說,線程 ThreadA 會釋放 CPU 的執行權,但是不釋放它持有的鎖 DeadLockDemo.lock1

100 ms 之後,ThreadAThreadB 休眠時間到了,就會繼續往下執行代碼,這時它們中的一個會獲取 CPU 的執行權,比如說是 ThreadA 獲取了 CPU 的執行權,它去獲取鎖 DeadLockDemo.lock2,但是這把鎖還被 ThreadB 持有,所以 ThreadA 無法獲得這把鎖,ThreadA 就不得不阻塞在這裏。

ThreadA 被阻塞後,ThreadB 就被 CPU 選中了,它從休眠的代碼後繼續執行,去獲取 DeadLockDemo.lock1 這把鎖,但是這把鎖還被 ThreadA 持有,所以 ThreadB 無法獲得這把鎖,ThreadB 就不得不阻塞在這裏。

7. 線程間通信

7.1 單開發單測試的例子

7.1.1 發佈 apk /測試 apk 的例子

在軟件開發過程中,對 apk 來說有兩個過程:一個是開發工程師發佈 apk,一個是測試工程師測試 apk。測試 apk 任務在發佈 apk 任務完成之前,是不能執行工作的;而發佈 apk 任務在發另一個 apk 之前,必須等待測試任務完成。

從面向對象思想的角度,apk 在程序裏就是一個對象,所以我們聲明 Apk.java,它目前有兩個屬性,apkName 表示應用名稱,versionName 表示版本名稱:

class Apk {
   String apkName;
   String versionName;
}

兩個過程:開發工程師發佈 apk,測試工程師測試 apk,在程序中就是兩個不同的任務,即兩個不同的 Runnable 實現類。分別命名爲 ReleaseApkRunnableTestApkRunnable,它們都是 Runnable 接口的實現類。
雖然任務是不同的,但是它們都要處理同一個 apk,即測試工程師測試的 apk 就是開發工程師發佈的 apk。所以,這兩個任務擁有共同的資源,即 Apk 對象,聲明如下:

Apk apk = new Apk();

在程序中,如何讓兩個任務共享這個 Apk 對象呢?這裏,採用通過任務聲明的構造函數把 Apk 對象分別注入到兩個任務中。
開發工程師執行發佈 apk 的任務,我們需要把這個任務放在 ReleaseApkRunnbalerun 方法裏面,代碼如下:

class ReleaseApkRunnable implements Runnable {
   private Apk apk;

   public ReleaseApkRunnable(Apk apk) {
       this.apk = apk;
   }

   @Override
   public void run() {
       int x = 0;
       while (true) {
           try {
               // 這 200 ms 當作開發時間
               TimeUnit.MILLISECONDS.sleep(200);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
           if (x % 2 == 0) {
               apk.apkName = "QQ";
               apk.versionName = "Overseas";
           } else {
               apk.apkName = "微信";
               apk.versionName = "國內版";
           }
           System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
           x++;
       }
   }
}

可以看到上面的代碼,通過構造函數傳參的方式,把 Apk 對象這個共同的資源傳遞進來;在 run 方法裏,就是開發工程師發佈 apk 的任務代碼:首先,使用 200 ms 的休眠代表開發時間,然後就開始打包,這裏有兩種包:QQ 的 Overseas 版,微信的國內版,最後發佈。
我們使用了一個 int x 來保證開發工程師發佈的包是按照 QQ 的 Overseas 版,微信的國內版這樣的順序一個一個發佈的。這一點是比較好理解的。

需要注意的是,上述的發佈過程包含在一個 while 無限循環裏。

測試工程師執行測試 apk 的任務,同樣地需要把測試 apk 的執行代碼放到 TestApkRunnablerun 方法中:

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 這 60 ms 作爲測試時間
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

同樣地,在上面的代碼中,通過構造函數傳參的方式,把 Apk 對象傳遞給 TestApkRunnable 類,在 run 方法內部,就是執行測試任務的代碼:首先,休眠 60ms 的時間作爲測試時間,之後,就發出測試結果,這裏都作測試通過處理。
同樣地,測試 apk 的過程包含在一個 while 無限循環裏面。

要運行ReleaseApkRunnableTestApkRunnable 這兩個任務,需要創建兩個線程,releaseThreadtestThread,並調用它們的 start 方法。測試代碼如下:

public class ApkDemo {
    public static void main(String[] args) {
        Apk apk = new Apk();
        Runnable releaseApkRunnable = new ReleaseApkRunnable(apk);
        Runnable testApkRunnable = new TestApkRunnable(apk);
        Thread releaseThread = new Thread(releaseApkRunnable);
        Thread testThread = new Thread(testApkRunnable);
        releaseThread.start();
        testThread.start();
    }
}

運行程序後,其中一次的打印結果如下(這裏只是截取一段日誌):

Test pass: null,null
Test pass: null,null
Test pass: null,null
Release apk: QQ,Overseas
Test pass: QQ,Overseas
Test pass: QQ,Overseas
Test pass: QQ,Overseas
Release apk: 微信,國內版
Test pass: 微信,國內版
Test pass: 微信,國內版
Test pass: 微信,國內版

從打印結果裏,我們看到:
在沒有任何 Apk 信息的情況下,測試工程師首先就開始了 3 次測試,也就是說,開發工程師還沒有帆布 Apk 包,測試工程師就進行了 3 次測試,這肯定是不對的。
開發工程師發佈了一個 QQ,Overseas 的 Apk 包,測試工程師居然進行了 3 次測試 QQ, Overseas 包的過程,這也是不對的。因爲,我們的設定是一次測試通過,不存在這種多次測試一個包的情況。

7.1.2 解決數據錯亂問題

還有一個問題,上面的例子沒有跑出來,就是由於線程不安全造成數據錯亂的問題。

回顧一下,線程安全問題產生的條件:
第一,多條線程操作共享數據;
第二,共享數據裏包含多條執行代碼。

看一下我們的代碼,ReleaseApkRunnableTestApkRunnable 都在操作共享數據 Apk 對象,滿足第一條;對共享數據的處理,包括給 apkNameversionName 賦值,以及打印語句,這裏麪包含了多行執行代碼,滿足第二條。所以,這個例子也是有線程安全問題。

這裏通過把 ReleaseApkRunnableTestApkRunnable 稍作修改,來驗證存在線程安全問題:

class ReleaseApkRunnable implements Runnable {
    private Apk apk;

    public ReleaseApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        int x = 0;
        while (true) {
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "國內版";
            }
            x++;
        }
    }
}

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

截取一段打印結果如下:

Test pass: QQ,國內版
Test pass: QQ,Overseas
Test pass: QQ,Overseas
Test pass: 微信,國內版
Test pass: 微信,國內版
Test pass: 微信,Overseas

這就證明了我們的例子確實存在線程安全問題。

解決線程安全問題,這裏採用同步代碼塊。

ReleaseApkRunnable 中的 run 方法調整如下:

public void run() {
    int x = 0;
    while (true) {
        synchronized (apk) {
            try {
                // 這 200 ms 當作開發時間
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "國內版";
            }
            System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
        }
        x++;
    }
}

TestApkRunnable 中的 run 方法調整如下:

public void run() {
    while (true) {
        synchronized (apk) {
            try {
                // 這 60 ms 作爲測試時間
                TimeUnit.MILLISECONDS.sleep(75);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

需要特別注意的是兩者使用的是同一個鎖,即 Apk 對象。這樣才能保證同步。可以測試,如果兩者使用的是不同的鎖,一定不能保證同步,也就是說一定還存在線程安全問題。

解決了線程安全問題之後,回到打印結果不正常的問題,這樣的結果是如何造成的呢?

可以回顧上面的代碼,開發工程師發佈 apk 的任務就是不停地按照一個 QQ,Overseas版,一個微信,國內版,這樣的次序不停地在發佈 apk;而測試工程師測試 apk 的任務就是不停地測試通過。這兩個任務彼此互不關心:開發工程師不管測試工程師是不是測試 apk 完畢,只管自己發佈 apk;測試工程師也不去看看開發工程師有沒有待測的 apk,只管傻乎乎地去執行測試。

它們之間沒有任何溝通,沒有協作造成了輸出結果不正常。

如何解決上面提到的問題呢?

7.1.3 嘗試解決線程不協作的問題

可以想到實際的工作中,是不會出現這些問題的。因爲開發工程師總是在確認測試工程師測試 apk 完畢後纔會發佈另一個 apk;測試工程師也會在知道有待測的 apk 的情況下,纔會去執行測試。

這裏我們給 Apk 對象添加一個字段:boolean isForTest;

class Apk {
    String apkName;
    String versionName;
    // 新增加的字段,默認是 false
    boolean isForTest = false; 
}

當開發工程師確認 isForTestfalse時,表示測試工程師沒有測試 apk,這時就會發布 apk,並且把 isForTest 設置爲 true,表示交付測試了;如果 isForTesttrue 時,表示測試工程師有測試 apk,開發工程師就不再執行發佈 apk 的代碼。

當測試工程師確認 isForTesttrue 時,表示現在有 apk 需要測試,就會執行測試 apk 代碼;如果 isForTestfalse,表示現在沒有 apk 需要測試,就不執行測試 apk 的代碼。

上面就是我們解決問題的思路,下面看代碼實現:

修改 ReleaseApkRunnable 中的 run 方法如下:

public void run() {
    int x = 0;
    while (true) {
        synchronized (apk) {
            if (apk.isForTest) {
            	// 測試工程師有apk在測試,不執行發佈apk的代碼
                continue;
            }
            try {
                // 這 200 ms 當作開發時間
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "國內版";
            }
            System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
            // 發佈apk後,設置 isForTest 爲 true,表示交付測試。
            apk.isForTest = true;
        }
        x++;
    }
}

修改 TestApkRunnablerun 方法如下:

public void run() {
    while (true) {
        synchronized (apk) {
            if (!apk.isForTest) {
            	// 沒有 apk 要測試,不執行下面測試 apk 的代碼。
                continue;
            }
            try {
                // 這 60 ms 作爲測試時間
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 測試完畢,把 isForTest 改爲 false,表明手頭沒有要測試的 apk。
            apk.isForTest = false;
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

好了,多次執行測試代碼,都可以得到正確的結果,如下面的截取日誌:

Release apk: QQ,Overseas
Test pass: QQ,Overseas
Release apk: 微信,國內版
Test pass: 微信,國內版

雖然打印輸出是正確的,但是程序本身還是存在很大的問題。

回顧一下代碼,開發工程師在判斷有測試 apk 的標記 isForTesttrue 時,是採用 continue 的方式跳過發佈 apk 的代碼,繼續下一輪循環;而測試工程師在判斷有要測試 apk 的標記 isForTestfalse 時,同樣採用 continue 的方式跳過測試 apk 的代碼,繼續下一輪循環。
如果開發工程師一直不發佈 apk,那麼測試工程師就要一直去循環:檢查 isForTest 的標記何時爲 true,以便去執行測試的代碼;如果測試工程師一直有 apk 待測,那麼開發工程師就要一直去循環:檢查 isForTest 的標記何時爲 false,以便去執行發佈 apk 的代碼。

這顯然是不正常的,開發工程師不可能不停地查看測試工程師是否測試完畢,測試工程師也不會一直詢問開發工程師發佈 apk 了沒有。這樣的話,等於在浪費時間。對於程序來說這會消耗寶貴的資源:它們都處於運行狀態,它們都需要 CPU 分配時間片。

我們知道,實際的工作中的情況是這樣的:

開發工程師在注意到測試工程師還有 apk 測試時,就知道這時不必去發佈 apk,這時等着就行了,如果測試工程師沒有 apk 在測試,那麼就發佈 一個 apk,並通知測試工程師:新的 apk 已發佈,請測試。這時,測試工程師收到通知,就開始執行測試 apk 的過程。

測試工程師在注意到開發工程師還沒有 apk 發佈時,就知道這時不必去測試 apk,這時等着就行了;如果開發工程師發佈了 apk,就去測試 apk,並通知開發工程師:apk 已測試完畢,測試通過。這時,開發工程師收到通知,就開始執行發佈 apk 的過程。

那麼,用程序如何實現呢?這就需要等待/喚醒機制。

7.2 等待/喚醒機制

7.2.1 初步代碼實現

在 Java 中,有對應的實現:

Object 類中的 wait() 方法:調用對象的 wait() 方法時,當前線程被掛起,而鎖會被釋放。在其他線程調用此對象的 notify() 方法或notifyAll() 方法前,當前線程就會處於等待狀態。這時線程釋放了 CPU 執行權,並釋放了 CPU 執行資格。

Object 類中的 notify() 方法:喚醒因調用對象的 wait() 方法的而被掛起的任務。如果有多個任務在此對象上等待,則會選擇喚醒一個任務。被喚醒的任務就具備了 CPU 執行資格。

需要注意的是,如果當前線程不是對象監視器的所有者,那麼調用對象監視器的 wait(),或notify() 方法會拋出 IllegalMonitorStateException。這個異常的含義是當一個線程本身不持有指定的對象監視器,卻試圖在這個對象監視器上等待,或者通知其他在這個對象監視器上等待的線程,這時就會拋出這個異常。換句話說,只有當前線程是對象監視器的所有者時,調用對象監視器的 wait()notify() 方法纔不會拋出 IllegalMonitorStateException。所以,我們必須在同步中,調用對象的 wait()notify() 方法。

我們來看代碼實現:

修改 ReleaseApkRunnablerun 方法如下:

public void run() {
    int x = 0;
    while (true) {
        synchronized (apk) {
            if (apk.isForTest) {
            	// 把 continue; 替換爲 apk.wait();
                try {
                    apk.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 這 200 ms 當作開發時間
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "國內版";
            }
            apk.isForTest = true;
            System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
            // 添加了通知,喚醒方法的調用
            apk.notify();
        }
        x++;
    }
}

修改的地方有兩處:一是把之前例子中的 continue; 替換爲了 apk.wait(),二是在同步代碼塊的最後一行,添加 apk.notify()

解釋一下改動的含義:

if 語句判斷有 apk 在測試時,就會進入 if 分支調用 apk.wait() ,這時開發工程師線程會被掛起,而 apk 這個鎖對象會被開發工程師線程釋放。開發工程師線程會一直等待,直到測試工程師通知他需要再發包爲止。

當開發工程師執行完發包任務後,就調用 apk.notify() 方法,這時就會通知在等待測試 apk 的測試工程師:新的 apk 已發佈,請測試。這將通知在對 wait() 的調用中被掛起的測試工程師線程繼續工作。在等待中的測試工程師就會收到通知繼續工作前,必須重新獲得之前因爲調用 apk.wait() 時釋放的鎖。

修改 TestApkRunnablerun 方法如下:

public void run() {
    while (true) {
        synchronized (apk) {
            if (!apk.isForTest) {
            	// 把 continue; 替換爲 apk.wait();
                try {
                    apk.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 這 60 ms 作爲測試時間
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
            apk.isForTest = false;
            // 添加了通知,喚醒方法的調用
            apk.notify();
        }
    }
}

修改的地方仍是兩處:一是把之前例子中的 continue; 替換爲了 apk.wait(),二是在同步代碼塊的最後一行,添加 apk.notify()

解釋一下改動的含義:

if 語句判斷沒有 apk 需要測試時,就會進入 if 分支調用 apk.wait() ,這時測試工程師線程會被掛起,而 apk 這個鎖對象會被測試工程師線程釋放。測試工程師線程會一直等待,直到開發工程師通知他有新包要測試爲止。

當測試工程師執行完測試任務後,就調用 apk.notify() 方法,這時就會通知在等待發布 apk 的開發工程師:apk 已測試完畢,測試通過。這將通知在對 wait() 的調用中被掛起的開發工程師線程繼續工作。在等待中的開發工程師就會收到通知繼續工作前,必須重新獲得之前因爲調用 apk.wait() 時釋放的鎖。

運行一下程序,結果是符合預期的。

總結一下,採用了等待/喚醒機制的例子與之前 7.1.3 中的實現相比,不再依靠循環來決定開發工程師何時發包,測試工程師何時測試,這會減少對 CPU 的無效佔用。

7.2.2 優化後的代碼實現

對 7.2.1 中的實現,優化爲同步函數的實現,這也是實際開發中的寫法。其實,就是進行了封裝而已。這樣的好處是,可以實現同步代碼的複用。

class Apk {
    private String apkName;
    private String versionName;
    private boolean isForTest = false;

    public synchronized void releaseApk(String apkName, String versionName) {
        if (isForTest) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 這 200 ms 當作開發時間
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.apkName = apkName;
        this.versionName = versionName;
        isForTest = true;
        System.out.println("Release apk: " + this);
        notify();
    }

    public synchronized void testApk() {
        if (!isForTest) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 這 60 ms 作爲測試時間
            TimeUnit.MILLISECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isForTest = false;
        System.out.println("Test Apk: " + this);
        notify();
    }

    @Override
    public String toString() {
        return apkName + "," + versionName;
    }
}

class ReleaseApkRunnable implements Runnable {
    private Apk apk;

    public ReleaseApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        int x = 0;
        while (true) {
            if (x % 2 == 0) {
                apk.releaseApk("QQ", "Overseas");
            } else {
                apk.releaseApk("微信", "國內版");
            }
            x++;
        }

    }
}

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            apk.testApk();
        }
    }
}

7.3 多開發多測試的例子

7.3.1 例子

由於公司業務的發展,一個開發加一個測試難以支撐,所以公司就新招一名開發以及一名測試。現在,有兩名開發工程師,兩名測試工程師。他們組成新的團隊,共同完成任務。

首先是在 main() 方法裏,增加了一條開發工程師線程,以及一條測試工程師線程,代碼如下:

Thread releaseThread1 = new Thread(releaseApkRunnable, "releaseThread1");
Thread releaseThread2 = new Thread(releaseApkRunnable, "releaseThread2");
Thread testThread1 = new Thread(testApkRunnable, "testThread1");
Thread testThread2 = new Thread(testApkRunnable, "testThread2");
releaseThread1.start();
releaseThread2.start();
testThread1.start();
testThread2.start();

可以看到,這裏通過 Thread 類的構造方法設置了線程的名字:releaseThread1releaseThread2testThread1testThread2。這樣設置後,通過 Thread.currentThread().getName() 獲取到的就是我們設置的名字,這樣可讀性更好。

其次,簡化了 ReleaseApkRunnablerun 方法發佈 QQ 應用:

while (true) {
    apk.releaseApk("QQ");
}

最後,在 Apk 類中,增加 code 字段,表示版本號,每次發佈新包,版本號都會在原來的基礎上加 1,代碼如下:

public synchronized void releaseApk(String name) {
    if (isForTest) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    try {
        // 這 200 ms 當作開發時間
        TimeUnit.MILLISECONDS.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    this.apkName = name +"-V"+ code;
    System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.apkName);
    code++;
    isForTest = true;
    notify();
}

完整代碼如下:

class Apk {
    private String apkName;
    private boolean isForTest = false;
    private int code = 1;
    public synchronized void releaseApk(String name) {
        if (isForTest) {
            try {
                wait(); // rt1
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 這 200 ms 當作開發時間
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.apkName = name +"-V"+ code;
        System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.apkName);
        code++;
        isForTest = true;
        notify();
    }

    public synchronized void testApk() {
        if (!isForTest) {
            try {
                wait(); // tt2, tt1
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 這 60 ms 作爲測試時間
            TimeUnit.MILLISECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " Test Apk: " + this.apkName);
        isForTest = false;
        notify();
    }
}

class ReleaseApkRunnable implements Runnable {
    private Apk apk;

    public ReleaseApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            apk.releaseApk("QQ");
        }
    }
}

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            apk.testApk();
        }
    }
}

public class ApkDemo {
    public static void main(String[] args) {
        Apk apk = new Apk();
        Runnable releaseApkRunnable = new ReleaseApkRunnable(apk);
        Runnable testApkRunnable = new TestApkRunnable(apk);
        Thread releaseThread1 = new Thread(releaseApkRunnable, "releaseThread1");
        Thread releaseThread2 = new Thread(releaseApkRunnable, "releaseThread2");
        Thread testThread1 = new Thread(testApkRunnable, "testThread1");
        Thread testThread2 = new Thread(testApkRunnable, "testThread2");
        releaseThread1.start();
        releaseThread2.start();
        testThread1.start();
        testThread2.start();
    }
}

人員增加後,工作流程還是一樣的:開發工程師先發布 apk,測試工程師才能測試 apk;測試工程師測試 apk 完畢後,開發工程師才能繼續發佈 apk。

7.3.2 分析打印輸出不正確的問題

運行一下代碼,查看現在是否還可以達到預期的效果。

這裏取出剛開始的一段打印日誌,大家看一下:

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
releaseThread2============>Release apk: QQ-V3
releaseThread1============>Release apk: QQ-V4
testThread1 Test Apk: QQ-V4

這段日誌裏就包含了兩個問題:

一,發佈了一個版本,測試了兩輪

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2

二,漏測試版本

releaseThread2============>Release apk: QQ-V3
releaseThread1============>Release apk: QQ-V4
testThread1 Test Apk: QQ-V4

這兩個問題都是十分嚴重的,嚴重違反了工作流程。

但是,我們的代碼確實做了同步處理,也使用等待/喚醒機制。爲什麼在增加了一個開發,一個測試後,就出問題了呢?

有問題看日誌,所以我們認真分析一下日誌。

先看第一段問題日誌:

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2

下面分析一下流程,步驟有些多,大家耐心一些啊:

應用剛啓動,releaseThread1 就獲取鎖,這時 isForTestfalse,不會進入 if (isForTest) 裏面,繼續執行發佈 apk 的代碼,打印出 releaseThread1============>Release apk: QQ-V1 這行日誌,修改 isForTest 的標記爲 true,然後調用了 notify() 方法,不過這時等待隊列中沒有線程,之後,releaseThread1 就結束任務執行,自動釋放了鎖。

接着,testThread2 獲取到了鎖,開始執行同步方法裏的代碼:首先判斷 if(!isForTest) 爲 false(因爲 releaseThread1 裏將 isForTest 改爲 true!isForTestfalse),不會進入 if 分支,繼續執行測試 apk 的代碼,打印出 testThread2 Test Apk: QQ-V1,修改 isForTest 標記爲 false,最後調用 notify() 方法,這時等待隊列中沒有線程,之後,testThread2 就結束了測試任務,自動釋放了鎖。

接着,testThread1 獲取到了鎖,開始執行同步方法裏的代碼,因爲此時 isForTestfalse,很快就調用鎖的 wait() 方法,這樣 testThread1 就被掛起,進入線程等待隊列,並且釋放了鎖。這時,等待隊列中是 testThread1

接着,testThread2 獲取到了鎖,開始執行同步方法裏的代碼,因爲此時 isForTestfalse,很快就調用鎖的 wait() 方法,這樣 testThread2 就被掛起,進入線程等待隊列,並且釋放了鎖。這時,等待隊列中有 testThread1testThread2

接着,releaseThread2 獲取到了鎖,這時 isForTestfalse,繼續執行發佈 apk 的任務,打印出 releaseThread2============>Release apk: QQ-V2,將 isForTest 標記改爲 true,調用 nofity() 方法,喚醒等待隊列中的一個線程。現在,等待隊列中有 testThread1testThread2。選中 testThread2 喚醒,testThread2 就具備了 CPU 執行資格。現在等待隊列中是 testThread1

接着,testThread2 獲取到鎖,isForTesttrue,繼續執行測試 apk 的代碼,打印出 testThread2 Test Apk: QQ-V2,把 isForTest改爲 false,調用 notify() 方法,喚醒等待隊列中的一個線程。現在等待隊列中是 testThread1testThread1 被喚醒,具備了 CPU 執行資格。這時,等待隊列中沒有線程。

接着,testThread2 獲取到鎖,這時 isForTestfalsetestThread2 調用鎖的 wait() 方法,進入等待線程隊列。這時等待隊列中是 testThread2

接着,testThread1 獲取到鎖,從被喚醒的地方開始往下執行,打印 testThread1 Test Apk: QQ-V2,把 isForTest 改爲 false,調用 notify()方法。這時等待隊列中是 testThread2,它被喚醒,具有 CPU 執行資格。當前等待隊列中沒有線程。

接着,testThread1 獲取到鎖,這時 isForTestfalse,很快 testThread1 又進入了等待隊列,並釋放鎖。這時等待隊列中是 testThread1

接着,testThread2 獲取到鎖,從被喚醒的地方開始往下執行,打印 testThread2 Test Apk: QQ-V2,把 isForTest 改爲 false,調用 notify()方法。

到這裏,我們看到 testThread1testThread2 輪流執行了測試 apk 的代碼,全然不去理會 isForTestfalse 這一判斷條件。這是因爲它們都是從被喚醒的地方開始往下執行的,不會再去判斷 if(!isForTest)

同樣地,可以去分析第二段問題日誌:

releaseThread2============>Release apk: QQ-V3
releaseThread1============>Release apk: QQ-V4
testThread1 Test Apk: QQ-V4

是由於沒有再去判斷 if(isForTest) 導致的。

那麼,怎樣才能多次回去判斷 !isForTestisForResult這兩個條件呢?自然是while循環。

現在,releaseApkRunnable 中的 iftestApkRunnable 中的 if 都改爲 while,如下:

while (!isForTest) {
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

7.3.3 分析死鎖問題

再次運行程序,發現出現了死鎖。
其中一次的日誌如下:

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
releaseThread1============>Release apk: QQ-V3
|(這一行是光標在閃動)

我們來分析一下原因:
分析的思路還是根據日誌,理流程。

程序啓動後,releaseThread1 獲取到鎖, isForTestfalse,執行發佈 apk 的代碼,打印出 releaseThread1============>Release apk: QQ-V1,把 isForTest 修改爲 true,調用 notify() 方法,沒有需要喚醒的線程,任務結束,釋放鎖。
接着,releaseThread1 再次獲取到鎖,isForTesttrue,調用鎖的 wait() 方法,releaseThread1 線程被掛起,進入等待隊列,並釋放鎖。這時等待隊列裏是 releaseThread1

接着,releaseThread2 獲取到鎖,isForTesttrue,調用鎖的 wait() 方法,releaseThread2 線程被掛起,進入等待隊列,並釋放鎖。這時等待隊列裏是 releaseThread1releaseThread2

接着,testThread2 獲取鎖,isForTesttrue,開始執行測試 apk 的代碼,打印 testThread2 Test Apk: QQ-V1,修改 isForTestfalse,調用 notify() 方法,喚醒等待隊列中的一個線程。等待隊列中現在是 releaseThread1releaseThread2。選中喚醒 releaseThread2,它具有 CPU 執行資格。等待隊列中現在只有 releaseThread1

接着,testThread1 獲取到鎖,isForTestfalse,調用鎖的 wait() 方法,testThread1 被掛起,進入等待隊列,並釋放鎖。這時等待隊列有: releaseThread1testThread1

接着,testThread2 獲取到鎖,isForTestfalse,調用鎖的 wait() 方法,testThread2 被掛起,進入等待隊列,並釋放鎖。這時等待隊列有: releaseThread1testThread1testThread2

接着,releaseThread2 獲取到鎖, isForTestfalse,執行發佈 apk 的代碼,打印出 releaseThread2============>Release apk: QQ-V2,把 isForTest 修改爲 true,調用 notify() 方法,喚醒 testThread2,任務結束,釋放鎖。目前,等待隊列中:releaseThread1testThread1

接着,releaseThread2 獲取到鎖,isForTesttrue,調用鎖的 wait() 方法,releaseThread2 線程被掛起,進入等待隊列,並釋放鎖。這時等待隊列裏是 releaseThread1testThread1releaseThread2

接着,testThread2 獲取鎖,isForTesttrue,開始執行測試 apk 的代碼,打印 testThread2 Test Apk: QQ-V2,修改 isForTestfalse,調用 notify() 方法,喚醒等待隊列中的一個線程。等待隊列中現在是 releaseThread1testThread1releaseThread2。選中喚醒 releaseThread1,它具有 CPU 執行資格。等待隊列中現在只有 ,testThread1releaseThread2

接着,testThread2 獲取到鎖,isForTestfalse,調用鎖的 wait() 方法,testThread2 被掛起,進入等待隊列,並釋放鎖。這時等待隊列有: testThread1releaseThread2testThread2

接着,releaseThread1 獲取到鎖, isForTestfalse,執行發佈 apk 的代碼,打印出 releaseThread1============>Release apk: QQ-V3,把 isForTest 修改爲 true,調用 notify() 方法,喚醒 releaseThread2,任務結束,釋放鎖。目前,等待隊列中:testThread1testThread2

接着,releaseThread2 獲取到鎖,isForTesttrue,調用鎖的 wait() 方法,releaseThread2 線程被掛起,進入等待隊列,並釋放鎖。這時等待隊列裏是testThread1testThread2releaseThread2

最後,releaseThread1 獲取到鎖,isForTesttrue,調用鎖的 wait() 方法,releaseThread1 線程被掛起,進入等待隊列,並釋放鎖。這時等待隊列裏是testThread1testThread2releaseThread2releaseThread1

到這裏,四個線程都在等待隊列中了。這就造成了死鎖。
我們看關鍵的倒數第三步,當時等待隊列中是 testThread1releaseThread2testThread2,調用鎖的 notify() 方法後,本該喚醒 testThread1testThread2 中的一個,但是卻喚醒了 releaseThread2。之後,就最終都進入了等待隊列。

爲什麼沒有去喚醒 testThread1testThread2 中的一個,而去喚醒了 releaseThread2 呢?

這是因爲調用鎖的 notify() 方法,當線程等待隊列中有多個時,會選擇其中一個喚醒,而選擇是隨機的,任意性的。

好吧。

問:能不能指定喚醒呢?再不濟,全部喚醒也可以啊。
答:指定喚醒目前還沒有,全部喚醒可以用 notifyAll()notifyAll() 可以喚醒所有的等待線程。

把代碼中的 notify() 替換爲 notifyAll() 方法重新測試,打印輸出符合流程要求了。截取一段如下:

releaseThread2============>Release apk: QQ-V270
testThread2 Test Apk: QQ-V270
releaseThread1============>Release apk: QQ-V271
testThread2 Test Apk: QQ-V271
releaseThread2============>Release apk: QQ-V272
testThread1 Test Apk: QQ-V272
releaseThread2============>Release apk: QQ-V273
testThread2 Test Apk: QQ-V273
releaseThread1============>Release apk: QQ-V274
testThread2 Test Apk: QQ-V274

7.3.4 如何實現指定喚醒?

這需要藉助 Java SE5 的 java.util.concurrent 類庫,這裏麪包含定義在 java.util.concurrent.locks 中的顯式的互斥機制。

我們知道,之前使用的synchronized 是一種隱式的互斥機制。

它們之間有什麼區別呢?

Lock 對象必須被顯式地創建、鎖定和釋放;而使用 synchronized 這樣的內建鎖,創建鎖,獲取鎖,釋放鎖都不需要手動調用。從這一點來看,Lock 對象的形式,代碼要比使用 synchronized 關鍵字時,需要寫更多的代碼,缺乏優雅性。

使用 synchronized 關鍵字的形式,包括同步代碼塊以及方法,它們僅僅是對代碼進行封裝,僅僅停留在代碼塊封裝,方法封裝這個概念上;而 Java SE5 中的 Lock 將同步和鎖封裝成了對象,包含了創建鎖,獲取鎖,釋放鎖這些行爲。從這裏,也就將使用 synchronized 關鍵字形式的內置鎖變爲顯式的,它可以顯式地創建鎖,獲取鎖以及釋放鎖。Lock 替代了 synchronized 方法和語句的使用,Condition 替代了 Object 監視器方法(waitnotifynotifyAll)的使用。

Lock 對象不使用塊結構,這樣失去了使用 synchronized 的方法和代碼塊的自動釋放鎖的功能,所以使用 Lock 對象時,必須把把釋放鎖的操作(unlock())放在 try - finally 語句的 finally 子句中。如下:

Lock l = ...; 
l.lock();
try {
    // access the resource protected by this lock
} finally {
    l.unlock();
}

好了,它們之間的區別先對比到這裏。更多的不同之處,我們會通過代碼來演示。

注意到,java.util.concurrent.locks 下的 Condition 替代了 Object 類的監視器方法(waitnotifynotifyAll)的使用。那麼,具體是怎麼替代的呢?

Condition 接口中有 signal() 方法,喚醒一個等待的線程,這可以和 Object 類中的notify()相對應;await() 方法,掛起一個任務,這可以和 Object 類中的 wait() 方法相對應;signalAll() 方法,喚醒所有等待線程,這可以和 Object 類中的 notifyAll() 方法相對應。

注意,說相對應,並不是等同。一個 Lock 對象可以有多個 Condition 對象,也就相應地有多組 await()signal()signalAll() 方法,而使用 synchronized 關鍵字形式,只能對應一個鎖對象,也僅僅有一組 wait()notify()notifyAll() 方法。通過調用 Condition 對象的 signal()signalAll() 方法來喚醒任務,喚醒的是被這個 Condition 對象自身所掛起的任務。

下面,我們準備把 7.3.3 中最後的例子使用 Lock 對象來實現:

class Apk {
    private String apkName;
    private boolean isForTest = false;
    private int code = 1;
    // 創建一個鎖對象
    private Lock lock = new ReentrantLock();
    // 在 lock 對象上獲取 Condition 實例
    private Condition condition = lock.newCondition();
    
	// 去掉了方法聲明中的 synchronized 關鍵字
    public void releaseApk(String name) {
    	// 替換了原來的 synchronized 的同步方法自動獲取鎖
        lock.lock();
        try {
            while (isForTest) {
                try {
                	// 替換了原來的 apk.wait()
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 這 200 ms 當作開發時間
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.apkName = name +"-V"+ code;
            System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.apkName);
            code++;
            isForTest = true;
            // 替換了原來的 apk.notifyAll();
            condition.signalAll();
        } finally {
        	// 替換了原來的 synchronized 的同步方法自動釋放鎖
            lock.unlock();
        }

    }
	// 去掉了方法聲明中的 synchronized 關鍵字
    public void testApk() {
        // 替換了原來的 synchronized 的同步方法自動獲取鎖
        lock.lock();
        try {
            while (!isForTest) {
                try {
                	// 替換了原來的 apk.wait();
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 這 60 ms 作爲測試時間
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " Test Apk: " + this.apkName);
            isForTest = false;
            // 替換了原來的 apk.signalAll();
            condition.signalAll();
        } finally {
        	// 替換了原來的 synchronized 的同步方法自動釋放鎖
            lock.unlock();
        }
    }
}

上面的代碼註釋已經很詳細了,把進行替換的地方一一進行了註釋說明。

這時,執行代碼,程序依然能夠達到預期的輸出。

同樣地,可以測驗:替換 7.3.2 的例子和 7.3.3 的死鎖例子,也可以復現問題。大家可以自己實測一下。

到這裏,大家可能會想:LockCondition 的作用也不過如此,之前使用 synchronized 同步方法不也一樣實現嗎?

目前看來,是這樣的。後面會說到 LockConditionsynchronized 方式靈活,強大的地方。

到這裏,我們證明了:Lock 替代了 synchronized 方法和語句的使用,Condition 替代了 Object 監視器方法的使用。

在對比 Locksynchronized 的區別時,我們知道,一個 Lock 可以綁定多個 Condition 對象。每個 Condition 對象擁有一組監聽器方法:await()signal()signalAll。並且,調用 Condition 對象的 signal()signalAll() 是喚醒被它自身掛起的任務。

也就是說,對於不同的 Condition 對象,誰掛起的任務,誰喚醒。

回到我們的例子中,有兩組任務:發佈 apk 的任務和測試 apk 的任務。這兩組任務需要經歷掛起,喚醒的操作。那麼,我們自然需要兩個 Condition 對象。

private Condition releaseCondition = lock.newCondition();
private Condition testCondition = lock.newCondition();

releaseCondition 負責發佈 apk 這個任務的掛起和喚醒;
testCondition 負責測試 apk 這個任務的掛起和喚醒。

public void releaseApk(String name) {
    lock.lock();
    try {
        while (isForTest) {
            try {
            	// 掛起發佈 apk 的任務
                releaseCondition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 這 200 ms 當作開發時間
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.apkName = name +"-V"+ code;
        System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.
        code++;
        isForTest = true;
        // 喚醒測試 apk 的任務
        testCondition.signal();
    } finally {
        lock.unlock();
    }
}
public void testApk() {
    lock.lock();
    try {
        while (!isForTest) {
            try {
            	// 掛起測試 apk 的任務
                testCondition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 這 60 ms 作爲測試時間
            TimeUnit.MILLISECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " Test Apk: " + this.apkName);
        isForTest = false;
        // 喚醒發佈 apk 的任務
        releaseCondition.signal();
    } finally {
        lock.unlock();
    }
}

這樣就實現了指定喚醒的目標。

7.4 多開發多測試的實例

目前,多開發多測試的例子還是與實際不相符:例子中兩個開發工程師,兩個測試工程師,卻是一個 apk 發佈,一個 apk 測試這樣的節奏在工作。這不是人浮於事嗎?

實際中,會有一個待測 apk 的集合,比如這個集合的大小是 5,
開發工程師在判斷集合不滿時,說明集合中還可以存放新的 apk,就發佈 apk;滿時就不發佈 apk。
測試工程師在判斷集合不空時,說明有 apk 待測,就從集合中取出一個,開始測試;爲空時,就不測試 apk.。

實現的代碼如下:

class Apk {
    private String apkName;
    private static int counter = 1;
    private final int code = counter++;
    public Apk(String apkName) {
        this.apkName = apkName;
    }

    @Override
    public String toString() {
        return "Apk: " + apkName + "-V" + code;
    }
}

class ApkBuffer {
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    final Apk[] items = new Apk[5];
    private int putptr, takeptr, count;

    public void put(Apk x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            System.out.println("ApkBuffer, put=======>" + x);
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public Apk take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            Apk x = items[takeptr];
            System.out.println("ApkBuffer: take<===================" + x);
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }

}
class ReleaseApkRunnable implements Runnable {
    private ApkBuffer apkBuffer;
    public ReleaseApkRunnable(ApkBuffer apkBuffer) {
        this.apkBuffer = apkBuffer;
    }

    @Override
    public void run() {
        while (true) {
            try {
                apkBuffer.put(new Apk("QQ"));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class TestApkRunnable implements Runnable {
    private ApkBuffer apkBuffer;

    public TestApkRunnable(ApkBuffer apkBuffer) {
        this.apkBuffer = apkBuffer;
    }

    @Override
    public void run() {
        while (true) {
            try {
                apkBuffer.take();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class ApkDemo {
    public static void main(String[] args) {
        Apk apk = new Apk("QQ");
        ApkBuffer apkBuffer = new ApkBuffer();
        Runnable releaseApkRunnable = new ReleaseApkRunnable(apkBuffer);
        Runnable testApkRunnable = new TestApkRunnable(apkBuffer);
        Thread releaseThread1 = new Thread(releaseApkRunnable, "releaseThread1");
        Thread releaseThread2 = new Thread(releaseApkRunnable, "releaseThread2");
        Thread testThread1 = new Thread(testApkRunnable, "testThread1");
        Thread testThread2 = new Thread(testApkRunnable, "testThread2");
        releaseThread1.start();
        releaseThread2.start();
        testThread1.start();
        testThread2.start();
    }
}

這段代碼不再詳細說明了。因爲核心代碼和之前的例子是一樣的。
需要說明的是:
Apk 類中:

 private static int counter = 1;
 private final int code = counter++;

這是爲了保證 apk 的 code 值按次序遞增。

8 線程的終止

Thread 類的 stop() 方法 :這個方法已過時,具有不安全性;
Thread 類的 suspend() 方法 :這個方法已過時,具有死鎖傾向。
使得 run() 方法結束:run() 方法裏有循環結構時,判斷循環標記不滿足時,就結束循環。這種方式在某些情況下不可靠。
我們通過一個小例子來說明:

class MyRunnable implements Runnable {
    private FlagBean flagBean;

    public MyRunnable(FlagBean flagBean) {
        this.flagBean = flagBean;
    }

    @Override
    public synchronized void run() {
        while (!flagBean.isFlag()) {
            try {
                wait();
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "...." + e);
            }
        }
    }
}
class FlagBean {
    private boolean flag;

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}
public class UseFlag {
    public static void main(String[] args) {
        FlagBean flagBean = new FlagBean();
        MyRunnable target = new MyRunnable(flagBean);
        Thread thread1 = new Thread(target);
        Thread thread2 = new Thread(target);
        thread1.start();
        thread2.start();
        int i = 0;
        while (true) {
            if (i >= 30) {
                flagBean.setFlag(true);
                break;
            }
            System.out.println(Thread.currentThread().getName() + "。。。" + (i++));
        }
    }
}

flagBeanflag 標記爲 true 時,就結束循環,完成 run() 方法的執行,線程也就該自然結束了。但是,在此之前,while 循環裏,兩個線程都執行到了 wait() 方法,它們都被掛起了,處於阻塞狀態。即便是改變了標記,因爲沒有執行喚醒的操作,也沒有機會再去判斷標記,進而結束循環。

需要注意的是我們在 run 方法上加上了 synchronized 關鍵字,這是一個同步方法。這是因爲我們在 run() 方法裏使用了 wait() 方法,這需要當前線程持有鎖對象。否則,會拋出 IllegalMonitorStateException

通過使用 Threadinterrupte() 方法來使等待中的任務結束。把線程從阻塞狀態中斷變爲就緒狀態。
例子如下:

class MyRunnable implements Runnable {
    private FlagBean flagBean;

    public MyRunnable(FlagBean flagBean) {
        this.flagBean = flagBean;
    }

    @Override
    public synchronized void run() {
        while (!flagBean.isFlag()) {
            try {
                wait();
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "...." + e);
                flagBean.setFlag(true);
            }
        }
    }
}

class FlagBean {
    private boolean flag;

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

public class UseInterrupt {
    public static void main(String[] args) {
        FlagBean flagBean = new FlagBean();
        MyRunnable target = new MyRunnable(flagBean);
        Thread thread1 = new Thread(target);
        Thread thread2 = new Thread(target);
        thread1.start();
        thread2.start();
        int i = 0;
        while (true) {
            if (i >= 30) {
                thread1.interrupt();
                thread2.interrupt();
                break;
            }
            System.out.println(Thread.currentThread().getName() + "。。。" + (i++));
        }
    }
}

9 需要區分的概念

sleep() 方法和 wait() 方法的區別

  • 所屬不同:sleep() 方法在 Thread 類中,wait() 方法在 Object 類中;
  • 參數不同:sleep() 方法必須指定時間,wait() 方法可以指定時間也可以不指定時間;
  • 在同步中,對 CPU 的執行權和鎖的處理不同:wait() 方法釋放 CPU 執行權,並且釋放鎖;sleep() 方法釋放 CPU 執行權,不釋放鎖。
  • Thread.yield() 被調用後,持有鎖的線程不會釋放鎖。

Thread.interrupted()isInterrupted() 方法的區別

public static boolean interrupted() {
   return currentThread().isInterrupted(true);
}
public boolean isInterrupted() {
  	return isInterrupted(false);
}

Thread.interrupted() 方法會清除 ClearInterruptedtrueisInterrupted() 方法不會清除 ClearInterrupted

公平鎖與非公平鎖
synchronized 內置鎖默認是非公平鎖,不可以更改;ReentrantLock 默認是非公平鎖,可以通過參數設置爲公平鎖。

可重入鎖
synchronized 內置鎖是可重入鎖,也就是說,一個 synchronized 修飾的方法 g(),獲取到鎖之後,進入方法體內,方法體內又去調用 g(),仍能夠獲取到鎖,而不會造成死鎖。

ReentrantLock 是可重入鎖。

排他鎖
synchronized 內置鎖是排他鎖,ReentrantLock 是排他鎖,ReadWriteLock 不是排他鎖。
排他鎖就是在同一時刻只能允許一個線程訪問。

文章涉及的代碼在https://github.com/jhwsx/Java_01_AdvancedFeatures/tree/master/src/com/java/advanced/features/concurrent

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章