併發程式設計基礎(上)

CoderBear發表於2019-05-04

從我開始寫部落格到現在,已經寫了不少關於併發程式設計的了,差不多還有一半內容整個併發程式設計系列就結束了,而今天這篇部落格是比較簡單的,只是介紹下併發程式設計的基礎知識( = =!其實,對於大神來說,前面所有部落格都是基礎)。本來我不太想寫這篇部落格,因為這篇部落格的很多內容都是以記憶為主,而且網上也有大把大把的部落格,都寫的相當不錯,但是我最終決定還是要寫一寫,因為沒有這篇部落格,併發程式設計系列就不能算是一個完整的系列。

什麼是執行緒

說到執行緒,不得不說到程式,因為執行緒是無法單獨存在的,它只是程式中的一部分。

程式是程式碼在資料集合上的一次執行活動,是系統進行資源分配和排程的基本單位。執行緒則是程式的一個執行路徑,一個程式中至少有一個執行緒。作業系統在分配系統資源的時候,會把CPU資源分配給執行緒,因為真正執行工作,需要佔用CPU執行的是執行緒,所以也可以說執行緒是CPU分配的基本單位。

在Java中,我們啟動一個main函式,就啟動了一個JVM的程式,而main函式所在的執行緒被稱為“主執行緒”。 每個執行緒都有一個叫“程式計數器”的私有的記憶體區域,用來記錄當前執行緒下一個要執行的指令地址,為什麼要把程式計數器設計成私有的呢?因為執行緒是佔用CPU的基本單位,而CPU一般是使用時間片輪轉的方式來讓執行緒佔有的,所以當某個執行緒的時間片用完後,要讓出CPU,等下一次獲得時間片了,再繼續執行。那麼執行緒怎麼知道之前的程式執行到哪裡了呢?就是靠程式計數器。另外需要注意的是,如果執行的是native方法,那麼程式計數器記錄的是undefined地址。

執行緒的三種建立方式與區別

執行緒有三種建立方式,分別是

  1. 繼承Thread類並重寫run方法;
  2. 實現Runnable介面的run方法;
  3. 實現Callable泛型介面,並實現call方法。
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("run");
    }
}


public class MyTest {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}
複製程式碼
class MyRannable implements Runnable {
    @Override
    public void run() {
        System.out.println("run");
    }
}

public class MyTest {
    public static void main(String[] args) {
        MyRannable myRannable = new MyRannable();
        new Thread(myRannable).start();
    }
}
複製程式碼
class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        return "MyCallable";
    }
}

public class MyTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        FutureTask<String>futureTask=new FutureTask<>(new MyCallable());
        new Thread(futureTask).start();
        System.out.println(futureTask.get());
    }
}
複製程式碼

相信前面兩種方式不用多說,大家都懂。我們現在來看看第三種方式,首先定義了MyCallable類,並實現了Callable介面的call方法。在main方法中建立了FutureTask物件,傳入了MyCallable物件,然後用Thread包裝了FutureTask物件,隨後啟動,最後用futureTask提供的get方法獲取結果,獲取結果這一步是阻塞的。

在面試中,經常會問如下的問題:

  • 呼叫Thread的start方法會發生什麼事情,執行緒會馬上執行嗎? 不會,呼叫Thread的start方法,執行緒沒有馬上執行,它處於“就緒”的狀態,需要獲得CPU資源後才會執行。
  • 呼叫Thread的start和run方法,有什麼區別? 呼叫start方法,才會真正開啟新的執行緒執行run中的方法,而呼叫run方法,只是和呼叫普通方法一樣,不會開啟執行緒。
  • 上面三種方式的區別是什麼,優缺點? 只有Thread才是真正的執行緒,其他兩種方法都需要被Thread包裝才可以成為執行緒,在run方法中,可以使用this來獲得當前執行緒,不需要使用Thread.currentThread(),缺點在於Java是單繼承的,如果繼承了Thread類,就沒有辦法繼承其他類了,這是比較致命的,還有一個致命的缺點:無法交給執行緒池管理。 Runnable是介面,所以實現了Runnable介面,還可以繼承其他的類,但是必須被Thread類包裝才可以成為執行緒,可以被執行緒池管理。 以上兩種方法都是沒有返回值的,所以第三種方式Callable出現了,也可以被執行緒池管理,同樣的,也必須被Thread類包裝才可以成為執行緒。

執行緒的狀態

  • 新建:當建立Thread的例項後,此執行緒進行新建狀態。如:Thread t1 = new Thread() 。(但是也有一些部落格對這個提出了強烈的反對,認為new Thread()只是建立了一個普通的Java物件而已,和執行緒或者執行緒的狀態八竿子打不著,不過認為建立Thread例項後,執行緒就處於新建狀態的說法確實是主流)
  • 就緒:當呼叫了start方法後,執行緒不會馬上執行,此時執行緒的狀態是“就緒”,等待分配CPU資源。
  • 執行:執行緒獲得CPU資源後,真正開始執行。
  • 死亡:當執行緒執行結束後,進入“死亡”狀態,處於此狀態的執行緒永遠都不會再次進入“就緒”。
  • 阻塞:由於某種原因導致正在執行的執行緒讓出CPU並暫停自己的執行,就進入了“阻塞”的狀態,比如呼叫執行緒的sleep方法,物件的wait方法等。當滿足條件被返回後,執行緒重新進入“就緒”的狀態,再次等待分配CPU資源。

關於死亡和阻塞狀態,其實說的不太完整,因為除了執行緒執行結束後這種“自然死亡”,還有一個情況,就是被stop了,但是Java已經不推薦使用stop等操作了,所以就忘記吧,阻塞也是同樣的道理,也不推薦使用suspend方法了,也忘記它把。

執行緒通知與等待

在Java中,每個物件都繼承了Object類,而在Object類中提供了通知和等待的操作,所以每個物件都有這樣的操作,既然是執行緒的通知與等待,為什麼要把它定義在Object類中?因為Java提供的鎖,鎖的是物件,而不是方法或是執行緒,所以自然要定義在Object類中。

wait

當一個執行緒呼叫共享變數的wait方法後,該執行緒會被阻塞掛起,直到發生以下的兩個事情才返回:

  1. 其他執行緒呼叫了該物件的notify或者notifyAll方法;
  2. 其他執行緒呼叫了該執行緒的interrupt方法,該執行緒會被返回,並且丟擲InterruptedException異常。
class MyRunnable implements Runnable {
    Object object=new Object();

    @Override
    public void run() {
        try {
           synchronized (object){
               object.wait();
               System.out.println("run");
           }
        } catch (InterruptedException e) {
            System.out.println("被中斷了");
            e.printStackTrace();
        }
    }
}

public class MyTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread = new Thread(new MyRunnable());
        thread.start();
        thread.interrupt();
    }
}
複製程式碼

執行結果:

被中斷了
java.lang.InterruptedException
	at java.lang.Object.wait(Native Method)
	at java.lang.Object.wait(Object.java:502)
	at com.codebear.MyRunnable.run(MyTest.java:13)
	at java.lang.Thread.run(Thread.java:748)
複製程式碼

首先新建了一個子執行緒,子執行緒內部獲取了object的監視器鎖,隨後呼叫object的wait方法阻塞當前執行緒,主執行緒呼叫interrupt方法中斷子執行緒,子執行緒被返回,並且產生了異常。

這也就是為什麼我們在呼叫共享變數的wait方法的時候,Java“死皮賴臉”的要我們對異常進行處理的原因:

image.png

呼叫wait方法後,還會釋放對共享變數的監視器鎖,讓其他執行緒可以進入臨界區:

class MyRunnable implements Runnable {
    @Override
    public void run() {
        try {
            synchronized (MyRunnable.class) {
                System.out.println("我是" + Thread.currentThread().getName() + ",我進入了臨界區");
                MyRunnable.class.wait();
                Thread.sleep(Integer.MAX_VALUE);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("run");
    }
}

public class MyTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread1 = new Thread(myRunnable);
        thread1.start();
        Thread thread2 = new Thread(myRunnable);
        thread2.start();
    }
}
複製程式碼

執行結果:

我是Thread-1,我進入了臨界區
我是Thread-0,我進入了臨界區
複製程式碼

可以很清楚的看到,兩個執行緒都進入了臨界區。 執行緒A獲取了共享物件的監視器鎖後,進入了臨界區,執行緒B只能等待,執行緒A呼叫了共享物件的wait方法後,釋放了共享物件的監視器鎖,讓執行緒B也可以獲得共享變數的監視器鎖,並且進入臨界區。

在呼叫共享變數的wait方法前,必須先對該共享變數進行synchronized操作,否則會丟擲IllegalMonitorStateException異常:

class MyRunnable implements Runnable {
    Object object = new Object();

    @Override
    public void run() {
        try {
            object.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("run");
    }
}

public class MyTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread = new Thread(new MyRunnable());
        thread.start();
        thread.interrupt();
    }
}
複製程式碼

執行結果:

Exception in thread "Thread-0" java.lang.IllegalMonitorStateException
	at java.lang.Object.wait(Native Method)
	at java.lang.Object.wait(Object.java:502)
	at com.codebear.MyRunnable.run(MyTest.java:12)
	at java.lang.Thread.run(Thread.java:748)
複製程式碼

另外需要注意的是,一個執行緒雖然從阻塞掛起的狀態到就緒的狀態,但是可能其他執行緒並沒有喚醒它,這就是虛假喚醒,雖然虛假喚醒在實踐中很少發生,但是防患於未然,比較嚴謹的做法就是在wait方法外面,包裹一個while迴圈,while迴圈的條件就是檢測是否滿足了被喚醒的條件,這樣即使虛假喚醒發生了,該執行緒被返回了,由於被while包裹了,發現並沒有滿足被喚醒的條件,又會被再次wait。 如下所示:

  while(是否滿足了被喚醒的條件) {
     object.wait();
  }
複製程式碼

notify

wait方法是將當前執行緒阻塞掛起,那麼必定有一個方法是喚醒此執行緒的,就像沉睡的白雪公主也在等待王子的到來,將她喚醒一樣。 被喚醒的執行緒不能馬上從wait方法處返回,並且繼續執行,因為還需要再次獲取共享變數的監視器鎖(因為呼叫wait方法後,已經釋放了監視器,所以這裡需要再次獲取)。 如果有多個執行緒都呼叫了共享變數的wait方法而被阻塞掛起,那麼呼叫notify方法後,只會隨機喚醒其中一個執行緒。 還有一點尤其需要注意:當呼叫共享變數的notify方法後,並沒有釋放共享變數的監視器鎖,只有退出臨界區或者呼叫wait方法後,才會釋放共享變數的監視器鎖,我們可以做一個實驗:

class CodeBearRunnable implements Runnable {

    private Object object = new Object();

    @Override
    public void run() {
        synchronized (object) {
            object.notify();
            System.out.println("我是" + Thread.currentThread().getName() + LocalDateTime.now());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class NotifyTest {
    public static void main(String[] args) {
        CodeBearRunnable codeBearRunnable = new CodeBearRunnable();
        new Thread(codeBearRunnable).start();
        new Thread(codeBearRunnable).start();
    }
}
複製程式碼

執行結果:

我是Thread-02019-04-28T18:12:19.195
我是Thread-12019-04-28T18:12:22.196
複製程式碼

我們來分析下程式碼:當執行緒A獲取了共享變數的監視器鎖,進入了臨界區,呼叫共享變數的notify方法,列印出當前的時間,隨後sleep當前執行緒3秒。如果notify方法會釋放鎖,那麼執行緒B列印出來時間和執行緒A列印出來的時間應該相差不大,但是可以很清楚的看到,列印出來的時間相差了3秒,說明了執行緒A呼叫共享變數的notify方法後,並沒有釋放共享變數的鎖,只有退出了臨界區,才釋放了共享變數的鎖。

notifyAll

如果有多個執行緒都呼叫了共享變數的wait方法而被阻塞掛起,那麼呼叫notifyAll方法後,所有執行緒都會被喚醒。

最後,我們用一個常見的面試題來熟悉下wait/notify的應用:兩個執行緒交替列印奇偶數:

class MyRunnable implements Runnable {
    static private int i = 0;

    @Override
    public void run() {
        try {
            while (i < 100) {
                synchronized (MyRunnable.class) {
                    MyRunnable.class.notify();
                    MyRunnable.class.wait();
                    System.out.println("我是" + Thread.currentThread() + ":" + i++);
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class MyTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread1 = new Thread(new MyRunnable());
        thread1.start();

        Thread thread2 = new Thread(new MyRunnable());
        thread2.start();
    }
}
複製程式碼

執行結果:

image.png

join

在開發中,我們經常會遇到這樣的需求:等待某些事情都完成後,才可以繼續執行。比如旅遊網站查詢某個產品的航班,航班可以分為去程和返程,我們可以開兩個執行緒同時查詢去程和返程的航班,等他們的結果都返回後,再執行其他操作。

class GoRunnable implements Runnable {
    @Override
    public void run() {
       System.out.println("查詢去程航班");
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class ReturnRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("查詢返程航班");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class MyTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread1 = new Thread(new GoRunnable());
        thread1.start();

        Thread thread2 = new Thread(new ReturnRunnable());
        thread2.start();
        System.out.println("開始查詢航班,現在的時間是"+ LocalDateTime.now());
        thread1.join();
        thread2.join();
        System.out.println("航班查詢完畢,現在的時間是"+ LocalDateTime.now());
    }
}
複製程式碼

執行結果:

查詢去程航班
查詢返程航班
開始查詢航班,現在的時間是2019-04-28T21:18:05.719
航班查詢完畢,現在的時間是2019-04-28T21:18:10.654
複製程式碼

如果是同步查詢,那麼查詢航班的耗時應該在(5+3)秒左右,現在利用執行緒+join方法,兩個執行緒同時執行,耗時5秒左右(取決於慢的那個),在實際專案中,可以提升使用者的體驗,大幅提高查詢的效率。

這裡僅僅是演示join的功能,如果在實際專案中遇到這樣的場景應該不會用join這麼“粗糙”的方法。

讓我們再來看看當join遇到interrupt方法會擦出怎樣的火花:

class GoRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("查詢去程航班");
        for (; ; ) {
        }
    }
}

public class MyTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread1 = new Thread(new GoRunnable());
        thread1.start();
        Thread.currentThread().interrupt();
        try {
            thread1.join();
        } catch (InterruptedException e) {
            System.out.println("主執行緒" + e.toString());
        }
    }
}
複製程式碼

執行結果:

主執行緒java.lang.InterruptedException
查詢去程航班
複製程式碼

子執行緒內部是一個死迴圈,執行子執行緒後,中斷主執行緒,在主執行緒中的thread1.join處丟擲了異常。但是需要注意的是,因為中斷的是主執行緒,所以是在主執行緒中丟擲異常,這裡我用try包住thread1.join()只是為了更好的展現錯誤,其實這裡並不強制要求對異常進行捕獲。

本來想用一篇部落格就結束併發程式設計基礎的,但是寫起來才發現想多了,一是想把每個知識點都說的清楚一點,並給出各種例子來幫助大家更好的理解,二是併發程式設計基礎的知識點確實挺多的,所以還是分兩篇部落格來吧。

相關文章