Java進程執行緒

2020-08-10 10:47:46

Java進程執行緒

1.進程與執行緒

1.1 引入

現代操作系統(Windows,macOS,Linux)都可以執行多工。多工就是同時執行多個任務。

CPU執行程式碼都是一條一條順序執行的,但是,即使是單核cpu,也可以同時執行多個任務。因爲操作系統執行多工實際上就是讓CPU對多個任務輪流交替執行。

例如,假設我們有語文、數學、英語3門作業要做,每個作業需要30分鐘。我們把這3門作業看成是3個任務,可以做1分鐘語文作業,再做1分鐘數學作業,再做1分鐘英語作業:

這樣輪流做下去,在某些人眼裏看來,做作業的速度就非常快,看上去就像同時在做3門作業一樣。

就像大家現在的狀態一樣,是不是爲了期末考而進行多進程複習呢?

類似的,操作系統輪流讓多個任務交替執行,例如,讓瀏覽器執行0.001秒,讓QQ執行0.001秒,再讓音樂播放器執行0.001秒,在人看來,CPU就是在同時執行多個任務。

即使是多核CPU,因爲通常任務的數量遠遠多於CPU的核數,所以任務也是交替執行的。

這樣,你們可以開啓工作管理員看看。

1.2 進程

在計算機中,我們把一個任務稱爲一個進程,瀏覽器就是一個進程,視訊播放器是另一個進程,類似的,音樂播放器和Word都是進程。

某些進程內部還需要同時執行多個子任務。例如,我們在使用Word時,Word可以讓我們一邊打字,一邊進行拼寫檢查,同時還可以在後台進行列印,我們把子任務稱爲執行緒。

總結:所以進程與執行緒的關係是:一個進程裏面可以包含一個或者多個執行緒。

就像你在做作業一樣,手寫着字,大腦還在思考一樣。

操作系統排程的最小任務單位其實不是進程,而是執行緒。

因爲同一個應用程式,既可以有多個進程,也可以有多個執行緒,因此,實現多工的方法,有以下幾種:

多進程模式(每個進程只有一個執行緒)

多執行緒模式(一個進程有多個執行緒)

多進程+多執行緒模式(複雜度最高)

1.3 進程 vs 執行緒

進程和執行緒是包含關係,但是多工既可以由多進程實現,也可以由單進程內的多執行緒實現,還可以混合多進程+多執行緒。

多進程的缺點在於:

  • 建立進程比建立執行緒開銷大,尤其是在Windows系統上;
  • 進程間通訊比執行緒間通訊要慢,因爲執行緒間通訊就是讀寫同一個變數,速度很快。

而多進程的優點在於:

多進程穩定性比多執行緒高,因爲在多進程的情況下,一個進程崩潰不會影響其他進程,而在多執行緒的情況下,任何一個執行緒崩潰會直接導致整個進程崩潰。

1.4 多執行緒

Java語言內建了多執行緒支援:一個Java程式實際上是一個JVM進程,JVM進程用一個主執行緒來執行main()方法,在main()方法內部,我們又可以啓動多個執行緒。此外,JVM還有負責垃圾回收的其他工作執行緒等。

對於大多數Java程式來說,我們說多工,實際上是說如何使用多執行緒實現多工。

和單執行緒相比,多執行緒程式設計的特點在於:多執行緒經常需要讀寫共用數據,並且需要同步。例如,播放電影時,就必須由一個執行緒播放視訊,另一個執行緒播放音訊,兩個執行緒需要協調執行,否則畫面和聲音就不同步。因此,多執行緒程式設計的複雜度高,偵錯更困難。

Java多執行緒程式設計的特點又在於:

  • 多執行緒模型是Java程式最基本的併發模型;
  • 後續讀寫網路、數據庫、Web開發等都依賴Java多執行緒模型。

因此,必須掌握Java多執行緒程式設計才能 纔能繼續深入學習其他內容。

2.執行緒建立

Java語言內建了多執行緒支援。當Java程式啓動的時候,實際上是啓動了一個JVM進程,然後,JVM啓動主執行緒來執行main()方法。在main()方法中,我們又可以啓動其他執行緒。

要建立一個新執行緒非常容易,我們需要範例化一個Thread範例,然後呼叫它的start()方法:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread();
        t.start(); // 啓動新執行緒
    }
}

但是這個執行緒啓動後實際上什麼也不做就立刻結束了。我們希望新執行緒能執行指定的程式碼,有以下幾種方法:

方法一:從Thread派生一個自定義類,然後覆寫run()方法:

public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start(); // 啓動新執行緒
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

執行上述程式碼,注意到start()方法會在內部自動呼叫範例的run()方法。

方法二:建立Thread範例時,傳入一個Runnable範例:

public class test {
    public static void main(String[] args) {
         Thread thread = new Thread(new MyThread());
         thread.start();
    }

}
class MyThread implements Runnable{
    public void run(){
        System.out.println("Thread is coming");
    }
}

方法三:使用Java8出來的lambda表達式:

public class test {
    public static void main(String[] args) {
         Thread thread = new Thread(()->{
             System.out.println("Thread is coming");
         });
         thread.start();
    }

}

方法四:我們還可以使用匿名內部類進行存取:

public class test {
    public static void main(String[] args) {
         Thread thread = new Thread(){
         public void run(){
             System.out.println("Thread is coming");
         }
         };
         thread.start();
    }

}

有童鞋會問,使用執行緒執行的列印語句,和直接在main()方法執行有區別嗎?

區別大了去了。我們看以下程式碼:

public class Main {
    public static void main(String[] args) {
        System.out.println("main start...");
        Thread t = new Thread() {
            public void run() {
                System.out.println("thread run...");
                System.out.println("thread end.");
            }
        };
        t.start();
        System.out.println("main end...");
    }
}

我們用藍色表示主執行緒,也就是main執行緒,main執行緒執行的程式碼有4行,首先列印main start,然後建立Thread物件,緊接着呼叫start()啓動新執行緒。當start()方法被呼叫時,JVM就建立了一個新執行緒,我們通過範例變數t來表示這個新執行緒物件,並開始執行。

接着,main執行緒繼續執行列印main end語句,而t執行緒在main執行緒執行的同時會併發執行,列印thread runthread end語句。

run()方法結束時,新執行緒就結束了。而main()方法結束時,主執行緒也結束了。

我們再來看執行緒的執行順序:

  1. main執行緒肯定是先列印main start,再列印main end
  2. t執行緒肯定是先列印thread run,再列印thread end

總結:執行緒主要是執行順序不一樣,首先是main函數執行緒執行,纔會來到我們建立的新執行緒執行。

但是,除了可以肯定,main start會先列印外,main end列印在thread run之前、thread end之後或者之間,都無法確定。因爲從t執行緒開始執行以後,兩個執行緒就開始同時執行了,並且由操作系統排程,程式本身無法確定執行緒的排程順序。

要模擬併發執行的效果,我們可以線上程中呼叫Thread.sleep(),強迫當前執行緒暫停一段時間:

sleep()傳入的參數是毫秒。調整暫停時間的大小,我們可以看到main執行緒和t執行緒執行的先後順序。

要特別注意:直接呼叫Thread範例的run()方法是無效的:

public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.run();
    }
}

class MyThread extends Thread {
    public void run() {
        System.out.println("hello");
    }
}

直接呼叫run()方法,相當於呼叫了一個普通的Java方法,當前執行緒並沒有任何改變,也不會啓動新執行緒。上述程式碼實際上是在main()方法內部又呼叫了run()方法,列印hello語句是在main執行緒中執行的,沒有任何新執行緒被建立。

public class test {
    public static void main(String[] args) {
        Thread thread = new Thread(()->{
            try{
                Thread.sleep(50);
                System.out.println("Thread start");
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            System.out.println("Thread end");
        });
        thread.start();
        try{
            Thread.sleep(25);
            System.out.println("main start");
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("main end");
    }

}

執行緒的優先順序

可以對執行緒設定優先順序,設定優先順序的方法是:

Thread.setPriority(int n) // 1~10, 預設值5

優先順序高的執行緒被操作系統排程的優先順序較高,操作系統對高優先順序執行緒可能排程更頻繁,但我們決不能通過設定優先順序來確保高優先順序的執行緒一定會先執行。

3.執行緒狀態

在Java程式中,一個執行緒物件只能呼叫一次start()方法啓動新執行緒,並在新執行緒中執行run()方法。一旦run()方法執行完畢,執行緒就結束了。因此,Java執行緒的狀態有以下幾種:

  • New:新建立的執行緒,尚未執行;
  • Runnable:執行中的執行緒,正在執行run()方法的Java程式碼;
  • Blocked:執行中的執行緒,因爲某些操作被阻塞而掛起;
  • Waiting:執行中的執行緒,因爲某些操作在等待中;
  • Timed Waiting:執行中的執行緒,因爲執行sleep()方法正在計時等待;
  • Terminated:執行緒已終止,因爲run()方法執行完畢。

當執行緒啓動後,它可以在RunnableBlockedWaitingTimed Waiting這幾個狀態之間切換,直到最後變成Terminated狀態,執行緒終止。

執行緒終止的原因有:

  • 執行緒正常終止:run()方法執行到return語句返回;
  • 執行緒意外終止:run()方法因爲未捕獲的異常導致執行緒終止;
  • 對某個執行緒的Thread範例呼叫stop()方法強制終止(強烈不推薦使用)。

一個執行緒還可以等待另一個執行緒直到其執行結束。例如,main執行緒在啓動t執行緒後,可以通過t.join()等待t執行緒結束後再繼續執行:

main執行緒對執行緒物件t呼叫join()方法時,主執行緒將等待變數t表示的執行緒執行結束,即join就是指等待該執行緒結束,然後才繼續往下執行自身執行緒。所以,上述程式碼列印順序可以肯定是main執行緒先列印startt執行緒再列印hellomain執行緒最後再列印end

public class test {
    public static void main(String[] args) throws InterruptedException {
       Thread thread = new Thread(()->{
           System.out.println("Run");
       });
        System.out.println("start");
        thread.start();
        thread.join();
        System.out.println("end");
    }

}

由於執行緒執行方法是先執行主執行緒,再執行我們現在加入的執行緒。如果沒有join,Run方法只有在主執行緒結束以後才執行的。

如果t執行緒已經結束,對範例t呼叫join()會立刻返回。此外,join(long)的過載方法也可以指定一個等待時間,超過等待時間後就不再繼續等待。

4.中斷執行緒

如果執行緒需要執行一個長時間任務,就可能需要能中斷執行緒。中斷執行緒就是其他執行緒給該執行緒發一個信號,該執行緒收到信號後結束執行run()方法,使得自身執行緒能立刻結束執行。

中斷一個執行緒非常簡單,只需要在其他執行緒中對目標執行緒呼叫interrupt()方法,目標執行緒需要反覆 反復檢測自身狀態是否是interrupted狀態,如果是,就立刻結束執行。

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(1); // 暫停1毫秒
        t.interrupt(); // 中斷t執行緒
        t.join(); // 等待t執行緒結束
        System.out.println("end");
    }
}

class MyThread extends Thread {
    public void run() {
        int n = 0;
        while (! isInterrupted()) {
            n ++;
            System.out.println(n + " hello!");
        }
    }
}

interrupt()方法僅僅向t執行緒發出了「中斷請求」,至於t執行緒是否能立刻響應,要看具體程式碼。而t執行緒的while回圈會檢測isInterrupted(),所以上述程式碼能正確響應interrupt()請求,使得自身立刻結束執行run()方法。

如果執行緒處於等待狀態,例如,t.join()會讓main執行緒進入等待狀態,此時,如果對main執行緒呼叫interrupt()join()方法會立刻拋出InterruptedException,因此,目標執行緒只要捕獲到join()方法拋出的InterruptedException,就說明有其他執行緒對其呼叫了interrupt()方法,通常情況下該執行緒應該立刻結束執行。

我們又看一個程式碼:

package com.java.LOL;

public class test {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(100); // 暫停1毫秒
        t.interrupt(); // 中斷t執行緒
        t.join(); // 等待t執行緒結束
        System.out.println("end");
    }
}

class MyThread extends Thread {
    Thread t = new HelloThread();
    public void run() {
        t.start();
        try{
            t.join();
        }catch (InterruptedException e){
            System.out.println(e.getMessage());
        }
        t.interrupt();
    }
}
class HelloThread extends Thread{
    public void run(){
        int n = 0;
        while (! isInterrupted()) {
            n++;
            System.out.println("我錯了!"+n);
            try{
                Thread.sleep(500);
            }catch (InterruptedException e){
                
                break;
            }
        }

    }
}

這段程式碼主要是執行緒巢狀,我們在第一個執行緒裏面又巢狀了第二個執行緒,但是程式碼量有點大,我們可以簡化一下:

另一個常用的中斷執行緒的方法是設定標誌位。我們通常會用一個running標誌位來標識執行緒是否應該繼續執行,在外部執行緒中,通過把HelloThread.running置爲false,就可以讓執行緒結束:

我們來看一段程式碼:

package com.java.LOL;

public class test {
    public static void main(String[] args) throws InterruptedException {
       MyThread thread = new MyThread();
       thread.start();
       Thread.sleep(50);
       thread.running = false;
    }
}

class MyThread extends Thread{
    public volatile boolean running = true;
    public void run(){
        int n = 0;
        while(running){
            n++;
            System.out.println("我錯了!"+n);
        }
        System.out.println("end!");
    }
}

注意到HelloThread的標誌位boolean running是一個執行緒間共用的變數。執行緒間共用變數需要使用volatile關鍵字標記,確保每個執行緒都能讀取到更新後的變數值。

volatile關鍵字:是Java併發程式設計中比較重要的一個關鍵字。和synchronized不同,volatile是一個變數修飾符,只能用來修飾變數。無法修飾方法及程式碼塊等。

volatile的用法比較簡單,只需要在宣告一個可能被多執行緒同時存取的變數時,使用volatile修飾就可以了。

其實就是執行緒共用了。

這會導致如果一個執行緒更新了某個變數,另一個執行緒讀取的值可能還是更新前的。

因此,volatile關鍵字的目的是告訴虛擬機器:

  • 每次存取變數時,總是獲取主記憶體的最新值;
  • 每次修改變數後,立刻回寫到主記憶體。

volatile關鍵字解決的是可見性問題:當一個執行緒修改了某個共用變數的值,其他執行緒能夠立刻看到修改後的值。

可以增加執行緒進行值的穩定性。

5.執行緒守護

Java程式入口就是由JVM啓動main執行緒,main執行緒又可以啓動其他執行緒。當所有執行緒都執行結束時,JVM退出,進程結束。

如果有一個執行緒沒有退出,JVM進程就不會退出。所以,必須保證所有執行緒都能及時結束。

但是我們有可能一不小心就進入了死回圈。

class TimerThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println(LocalTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

如果這個執行緒不結束,JVM進程就無法結束。

我們要怎麼結束這個執行緒呢?

我們就引入了一個守護執行緒的概念。

守護執行緒是指爲其他執行緒服務的執行緒。在JVM中,所有非守護執行緒都執行完畢後,無論有沒有守護執行緒,虛擬機器都會自動退出。

因此,JVM退出時,不必關心守護執行緒是否已結束。

如何建立守護執行緒呢?方法和普通執行緒一樣,只是在呼叫start()方法前,呼叫setDaemon(true)把該執行緒標記爲守護執行緒:

Thread t = new MyThread();
t.setDaemon(true);
t.start();

在守護執行緒中,編寫程式碼要注意:守護執行緒不能持有任何需要關閉的資源,例如開啓檔案等,因爲虛擬機器退出時,守護執行緒沒有任何機會來關閉檔案,這會導致數據丟失。

我們看一個程式碼:

package com.java.LOL;

import java.time.LocalTime;

public class test {
    public static void main(String[] args) throws InterruptedException {
       MyThread thread = new MyThread();
       thread.setDaemon(true);
       thread.run();
    }
}

class MyThread extends Thread{
    public void run(){
        while(true){
            System.out.println(LocalTime.now());
            try{
                Thread.sleep(1000);
            }catch (InterruptedException e){
                break;
            }
        }
    }
}

總結一下:

守護執行緒是爲其他執行緒服務的執行緒;

所有非守護執行緒都執行完畢後,虛擬機器退出;

守護執行緒不能持有需要關閉的資源(如開啓檔案等)。

6.執行緒同步

當多個執行緒同時執行時,執行緒的排程由操作系統決定,程式本身無法決定。因此,任何一個執行緒都有可能在任何指令處被操作系統暫停,然後在某個時間段後繼續執行。

這個時候,有個單執行緒模型下不存在的問題就來了:如果多個執行緒同時讀寫共用變數,會出現數據不一致的問題。

看一個程式碼:

public class Main {
    public static void main(String[] args) throws Exception {
        var add = new AddThread();
        var dec = new DecThread();
        add.start();
        dec.start();
        add.join();
        dec.join();
        System.out.println(Counter.count);
    }
}

class Counter {
    public static int count = 0;
}

class AddThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) { Counter.count += 1; }
    }
}

class DecThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) { Counter.count -= 1; }
    }
}

我們有可能希望值是0,但是會出現多種情況。

原因在於執行緒執行是jvm隨機呼叫的一個過程,這是因爲對變數進行讀取和寫入時,結果要正確,必須保證是原子操作。原子操作是指不能被中斷的一個或一系列操作。

這說明多執行緒模型下,要保證邏輯正確,對共用變數進行讀寫時,必須保證一組指令以原子方式執行:即某一個執行緒執行時,其他執行緒必須等待

通過加鎖和解鎖的操作,就能保證3條指令總是在一個執行緒執行期間,不會有其他執行緒會進入此指令區間。

可見,保證一段程式碼的原子性就是通過加鎖和解鎖實現的。Java程式使用synchronized關鍵字對一個物件進行加鎖:

synchronized(lock) {
    n = n + 1;
}

synchronized保證了程式碼塊在任意時刻最多隻有一個執行緒能執行。我們把上面的程式碼用synchronized改寫如下:

package com.java.LOL;

public class test {
    public static void main(String[] args) throws InterruptedException {
        Thread1 thread1 = new Thread1();
        Thread2 thread2 = new Thread2();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(Counter.count);
    }
}

class Counter{
    public static final Object lock = new Object();
    public static int count = 0;
}

class Thread1 extends Thread{
    public void run(){
        for (int i = 0; i < 10000; i++) {
            synchronized (Counter.lock){
                Counter.count++;
            }
        }
    }
}

class Thread2 extends Thread{
    public void run(){
        for (int i = 0; i < 10000; i++) {
            synchronized (Counter.lock){
                 Counter.count--;
            }
        }
    }
}

結果就對了。

synchronized(Counter.lock) { // 獲取鎖
    ...
} // 釋放鎖

它表示用Counter.lock範例作爲鎖,兩個執行緒在執行各自的synchronized(Counter.lock) { ... }程式碼塊時,必須先獲得鎖,才能 纔能進入程式碼塊進行。執行結束後,在synchronized語句塊結束會自動釋放鎖。這樣一來,對Counter.count變數進行讀寫就不可能同時進行。上述程式碼無論執行多少次,最終結果都是0。

這個就相當於一個鎖,就是依次進行相關執行緒。

使用synchronized解決了多執行緒同步存取共用變數的正確性問題。但是,它的缺點是帶來了效能下降。因爲synchronized程式碼塊無法併發執行。此外,加鎖和解鎖需要消耗一定的時間,所以,synchronized會降低程式的執行效率。

方法:

我們在上述程式碼上面進行改進:

public class Counter {
    private int count = 0;

    public void add(int n) {
        synchronized(this) {
            count += n;
        }
    }

    public void dec(int n) {
        synchronized(this) {
            count -= n;
        }
    }

    public int get() {
        return count;
    }
}

這樣一來,執行緒呼叫add()dec()方法時,它不必關心同步邏輯,因爲synchronized程式碼塊在add()dec()方法內部。並且,我們注意到,synchronized鎖住的物件是this,即當前範例,這又使得建立多個Counter範例的時候,它們之間互不影響,可以併發執行:

var c1 = Counter();
var c2 = Counter();

// 對c1進行操作的執行緒:
new Thread(() -> {
    c1.add();
}).start();
new Thread(() -> {
    c1.dec();
}).start();

// 對c2進行操作的執行緒:
new Thread(() -> {
    c2.add();
}).start();
new Thread(() -> {
    c2.dec();
}).start();

如果一個類被設計爲允許多執行緒正確存取,我們就說這個類就是「執行緒安全」的(thread-safe),上面的Counter類就是執行緒安全的。Java標準庫的java.lang.StringBuffer也是執行緒安全的。

還有一些不變類,例如StringIntegerLocalDate,它們的所有成員變數都是final,多執行緒同時存取時只能讀不能寫,這些不變類也是執行緒安全的。

最後,類似Math這些只提供靜態方法,沒有成員變數的類,也是執行緒安全的。

除了上述幾種少數情況,大部分類,例如ArrayList,都是非執行緒安全的類,我們不能在多執行緒中修改它們。

我們再回過頭來看看counter程式碼:

當我們鎖住的是this範例時,實際上可以用synchronized修飾這個方法。下面 下麪兩種寫法是等價的:

public void add(int n) {
    synchronized(this) { // 鎖住this
        count += n;
    } // 解鎖
}
public synchronized void add(int n) { // 鎖住this
    count += n;
} // 解鎖

因此,用synchronized修飾的方法就是同步方法,它表示整個方法都必須用this範例加鎖。

對於static方法,是沒有this範例的,因爲static方法是針對類而不是範例。但是我們注意到任何一個類都有一個由JVM自動建立的Class範例,因此,對static方法新增synchronized,鎖住的是該類的Class範例。

public class Counter {
    public static void test(int n) {
        synchronized(Counter.class) {
            ...
        }
    }
}

7.死鎖

一個執行緒在獲取一個鎖以後還可以再獲得另外一把鎖。

public void add(int m) {
    synchronized(lockA) { // 獲得lockA的鎖
        this.value += m;
        synchronized(lockB) { // 獲得lockB的鎖
            this.another += m;
        } // 釋放lockB的鎖
    } // 釋放lockA的鎖
}

public void dec(int m) {
    synchronized(lockB) { // 獲得lockB的鎖
        this.another -= m;
        synchronized(lockA) { // 獲得lockA的鎖
            this.value -= m;
        } // 釋放lockA的鎖
    } // 釋放lockB的鎖
}

在獲取多個鎖的時候,不同線程獲取多個不同對象的鎖可能導致死鎖。對於上述程式碼,執行緒1和執行緒2如果分別執行add()dec()方法時:

  • 執行緒1:進入add(),獲得lockA
  • 執行緒2:進入dec(),獲得lockB

隨後:

  • 執行緒1:準備獲得lockB,失敗,等待中;
  • 執行緒2:準備獲得lockA,失敗,等待中。

此時,兩個執行緒各自持有不同的鎖,然後各自試圖獲取對方手裏的鎖,造成了雙方無限等待下去,這就是死鎖。

總結:死鎖就是線上程裏面加入了不同對象的鎖以後就導致程式無法執行的鎖。

死鎖發生後,沒有任何機制 機製能解除死鎖,只能強制結束JVM進程。

因此,在編寫多執行緒應用時,要特別注意防止死鎖。因爲死鎖一旦形成,就只能強制結束進程。

那麼我們應該如何避免死鎖呢?答案是:執行緒獲取鎖的順序要一致。即嚴格按照先獲取lockA,再獲取lockB的順序,改寫dec()方法如下:

public void dec(int m) {
    synchronized(lockA) { // 獲得lockA的鎖
        this.value -= m;
        synchronized(lockB) { // 獲得lockB的鎖
            this.another -= m;
        } // 釋放lockB的鎖
    } // 釋放lockA的鎖
}

8.wait和notify

在Java程式中,synchronized解決了多執行緒競爭的問題。例如,對於一個工作管理員,多個執行緒同時往佇列中新增任務,可以用synchronized加鎖:

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
    }

    public synchronized String getTask() {
        while (queue.isEmpty()) {
        }
        return queue.remove();
    }
}

上述程式碼看上去沒有問題:getTask()內部先判斷佇列是否爲空,如果爲空,就回圈等待,直到另一個執行緒往佇列中放入了一個任務,while()回圈退出,就可以返回佇列的元素了。

但實際上while()回圈永遠不會退出。因爲執行緒在執行while()回圈時,已經在getTask()入口獲取了this鎖,其他執行緒根本無法呼叫addTask(),因爲addTask()執行條件也是獲取this鎖。

對於上述TaskQueue,我們先改造getTask()方法,在條件不滿足時,執行緒進入等待狀態:

public synchronized String getTask() {
    while (queue.isEmpty()) {
        this.wait();
    }
    return queue.remove();
}

當一個執行緒執行到getTask()方法內部的while回圈時,它必定已經獲取到了this鎖,此時,執行緒執行while條件判斷,如果條件成立(佇列爲空),執行緒將執行this.wait(),進入等待狀態。

這裏的關鍵是:wait()方法必須在當前獲取的鎖物件上呼叫,這裏獲取的是this鎖,因此呼叫this.wait()

呼叫wait()方法後,執行緒進入等待狀態,wait()方法不會返回,直到將來某個時刻,執行緒從等待狀態被其他執行緒喚醒後,wait()方法纔會返回,然後,繼續執行下一條語句。

現在我們面臨第二個問題:如何讓等待的執行緒被重新喚醒,然後從wait()方法返回?答案是在相同的鎖物件上呼叫notify()方法。我們修改addTask()如下:

public synchronized void addTask(String s) {
    this.queue.add(s);
    this.notify(); // 喚醒在this鎖等待的執行緒
}

使用notifyAll()將喚醒所有當前正在this鎖等待的執行緒,而notify()只會喚醒其中一個(具體哪個依賴操作系統,有一定的隨機性)。

9.Lock

我們在使用執行緒裏面也許還有不安全,我們還可以加入方法:Lock介面,我們使用ReentrantLock。

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count;

    public void add(int n) {
        lock.lock();
        try {
            count += n;
        } finally {
            lock.unlock();
        }
    }
}

因爲synchronized是Java語言層面提供的語法,所以我們不需要考慮異常,而ReentrantLock是Java程式碼實現的鎖,我們就必須先獲取鎖,然後在finally中正確釋放鎖。

使用ReentrantLock比直接使用synchronized更安全,可以替代synchronized進行執行緒同步。

答案是使用Condition物件來實現waitnotify的功能。

我們仍然以TaskQueue爲例,把前面用synchronized實現的功能通過ReentrantLockCondition來實現:

class TaskQueue {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}

Condition提供的await()signal()signalAll()原理和synchronized鎖物件的wait()notify()notifyAll()是一致的,並且其行爲也是一樣的:

  • await()會釋放當前鎖,進入等待狀態;
  • signal()會喚醒某個等待執行緒;
  • signalAll()會喚醒所有等待執行緒;
  • 喚醒執行緒從await()返回後需要重新獲得鎖。

此外,和tryLock()類似,await()可以在等待指定時間後,如果還沒有被其他執行緒通過signal()signalAll()喚醒,可以自己醒來:

Condition可以替代waitnotify

Condition物件必須從Lock物件獲取。

這個是Java5以後有的。

10.讀寫鎖

使用ReadWriteLock可以解決執行緒讀寫問題,因爲前面問題導致了一個東西:只能讀不能寫,它保證:

  • 只允許一個執行緒寫入(其他執行緒既不能寫入也不能讀取);
  • 沒有寫入時,多個執行緒允許同時讀(提高效能)。

ReadWriteLock實現這個功能十分容易。我們需要建立一個ReadWriteLock範例,然後分別獲取讀鎖和寫鎖:

public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加寫鎖
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 釋放寫鎖
        }
    }

    public int[] get() {
        rlock.lock(); // 加讀鎖
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 釋放讀鎖
        }
    }
}

把讀寫操作分別用讀鎖和寫鎖來加鎖,在讀取時,多個執行緒可以同時獲得讀鎖,這樣就大大提高了併發讀的執行效率。

11.stampedLock

要進一步提升併發執行效率,Java 8引入了新的讀寫鎖:StampedLock

StampedLockReadWriteLock相比,改進之處在於:讀的過程中也允許獲取寫鎖後寫入!這樣一來,我們讀的數據就可能不一致,所以,需要一點額外的程式碼來判斷讀的過程中是否有寫入,這種讀鎖是一種樂觀鎖。

程式碼範例:

public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 獲取寫鎖
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 釋放寫鎖
        }
    }

    public double distanceFromOrigin() {
        long stamp = stampedLock.tryOptimisticRead(); // 獲得一個樂觀讀鎖
        // 注意下面 下麪兩行程式碼不是原子操作
        // 假設x,y = (100,200)
        double currentX = x;
        // 此處已讀取到x=100,但x,y可能被寫執行緒修改爲(300,400)
        double currentY = y;
        // 此處已讀取到y,如果沒有寫入,讀取是正確的(100,200)
        // 如果有寫入,讀取是錯誤的(100,400)
        if (!stampedLock.validate(stamp)) { // 檢查樂觀讀鎖後是否有其他寫鎖發生
            stamp = stampedLock.readLock(); // 獲取一個悲觀讀鎖
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 釋放悲觀讀鎖
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

注意到首先我們通過tryOptimisticRead()獲取一個樂觀讀鎖,並返回版本號。

如果在讀取過程中有寫入,版本號會發生變化,驗證將失敗。在失敗的時候,我們再通過獲取悲觀讀鎖再次讀取。由於寫入的概率不高,程式在絕大部分情況下可以通過樂觀讀鎖獲取數據,極少數情況下使用悲觀讀鎖獲取數據。

至於Concurrent集合,大家就看看官網:https://www.liaoxuefeng.com/wiki/1252599548343744/1306581060812834

執行緒池:https://www.liaoxuefeng.com/wiki/1252599548343744/1306581130018849