多執行緒基礎入門

farsun發表於2021-09-09

什麼是執行緒

說到執行緒,不得不提程式,對於程式相信大家都不陌生。比如當我們啟動qq的時候,作業系統會給qq程式建立一個程式,啟動桌面版微信,作業系統也會給微信建立一個程式,同理,java程式啟動後,也會建立一個程式。

圖片描述

根據狹義的定義,程式就是正在執行程式的抽象

話說回來,那什麼是執行緒呢?

在某些程式內部,還需要同時執行一些子任務,比如在一個Java程式中,後臺除了執行正常使用者程式碼之外,可能還需要執行緒在後臺執行垃圾回收,即時編譯等,我們稱這些子任務為執行緒。我們可以理解執行緒為一種輕量級的程式,它們有自己的程式計數器、棧以及區域性變數等,可以被作業系統進行排程,而且相比程式而言,執行緒建立、上下文切換的代價都更小,所以現代作業系統都是以執行緒作為排程的最小單位。

程式和執行緒的關係

  • 程式是系統進行資源分配基本單位,執行緒是系統排程的基本單位

  • 一個程式可以包含一個或多個執行緒。

  • 同一個程式所有執行緒可共享該程式的資源,如記憶體空間等。

  • 程式之間通訊較為複雜

    • 同一臺計算機內部的程式通訊,稱為IPC(Inter-Process Communication)

    • 不同計算機之間程式通訊,需要透過網路,遵循共同的協議,如TCP/IP

  • 執行緒之間通訊比較方便,因為同一個程式中所有執行緒共享記憶體,比如多個執行緒可以訪問同一個共享變數。

​ 我們可以把程式理解成一個營業的酒店,而執行緒就是酒店的老闆及工作人員如大堂經理、保潔阿姨、保安、廚師等。酒店的老闆及工作人員,都能共用酒店的資源。一個酒店再怎麼樣,就算沒有任何工作人員,也必須有一個老闆才行。

並行和併發

cpu執行程式碼是一條一條順序執行的,但是,即便是單核CPU,也可以同時執行多個任務。這是因為作業系統會讓多個任務輪流交替執行,每個任務執行若干時間,執行完後切換到下一個任務執行,這個過程非常快,造成一種同時執行的假象,這種在宏觀上同時執行,微觀上交替執行的現象,我們稱之為***併發(Concurrency)***。

當然,對於擁有多核CPU的計算機而言,是可以允許多個CPU同時執行不同任務的,比如在CPU1執行Word,在CPU2上執行QQ音樂聽歌,這種真正意義上的同時執行,我們稱為***並行(Parallelism)***。

引用golang語言創造者Rob Pike的一段話:

  • 併發是同一時間,應對(dealing with)多件事情的能力。
  • 並行,是同一時間動手做(doing)多件事情的能力。

併發和並行的區別,有點類似於以下場景:

高速公路上設有收費站,假設有兩條道路,但是收費通道只有一個,這時兩個道路的車,就需要交替排隊進入同一個收費入口進行收費,這種情況類似於併發,假如有兩個收費入口,兩條道的車都在自己的收費入口收費,這種情況類似於並行。

圖片描述

建立執行緒的方式

在java中建立並使用執行緒非常簡單,只需要建立一個執行緒物件,並呼叫其start方法,就可以了,比如下面這樣:

public class CreateThread {
    public static void main(String[] args) {
        Thread t1 = new Thread();
        t1.start();
    }
}

當我們執行這段程式的時候,jvm實際首先會建立一個主執行緒,用來執行main()方法,然後在執行main()方法的第3行程式碼時,會建立再次建立執行緒t1,在第4行透過start(),啟動執行緒t1。不過這個執行緒啟動後,實際並沒有執行任何程式碼就結束了,如果我們希望執行緒啟動後能執行指定程式碼,可以透過以下兩種方式:

繼承Thread類並重寫run方法

public class ExtendThread {

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

    public static class MyThread extends Thread{
        @Override
        public void run() {
            Debug.debug("我是執行緒t1");
        }
    }
}

上述方法可以簡寫成匿名內部類的形式:

public static void main(String[] args) {
  Thread t1 = new Thread(){
    @Override
    public void run() {
      Debug.debug("我是執行緒t1");
    }
  };
  t1.start();
}

建立執行緒時傳入Runnable物件

public class RunnableThread {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                Debug.debug("我是執行緒t2");
            }
        };
        Thread t2 = new Thread(runnable);
        t2.start();
    }
}

從java8開始,這種方式可以使用lamda表示式簡寫

public class LambdaRunnableThread {
    public static void main(String[] args) {
        Thread t2 = new Thread(() -> Debug.debug("我是執行緒t2"));
        t2.start();
    }
}

那麼,這兩種寫法更推薦哪種呢?一般而言下,更建議使用第二種方法,因為

  • java裡面類是單繼承的,使用介面的方式,可以避開這種限制。
  • 有利於任務拆分。如果一個任務需要拆分成很多小任務,不必為每個任務建立一個執行緒。
  • 將任務的建立和執行解耦,一個執行緒生產任務,可以交給其他執行緒去執行。

run()和start()的區別

需要特別注意的是,run()start()的區別,執行緒建立完成後,執行start()方法,才會真正啟動執行緒去併發執行任務,而run()只是一個普通的例項方法,沒有啟動執行緒的作用。

public class StartAndRunTest {
    public static void main(String[] args) {
        Debug.debug("我是執行緒:{}",Thread.currentThread().getName());
        Thread t2 = new Thread(() -> Debug.debug("我是執行緒:{}",Thread.currentThread().getName()),"t2");
        t2.run();
    }
}

以上程式碼中Thread.currentThread().getName()會列印當前執行執行緒的名字。這段程式碼首先會列印主執行緒的名字,然後建立執行緒t2,接著啟動執行緒t2,t2執行緒啟動後執行其任務程式碼,會列印出正在執行該程式碼的執行緒名字也就是t2,其執行結果如下:

2021-03-07 20:07:05 [main] 我是執行緒:main
2021-03-07 20:07:05 [t2] 我是執行緒:t2

如果我們把程式碼第5行改成: t2.run(),就會輸出下面的結果了:

2021-03-07 20:07:19 [main] 我是執行緒:main
2021-03-07 20:07:19 [main] 我是執行緒:main

因為run()方法並沒有真正啟動執行緒t1,只是在主執行緒中呼叫了t2執行緒的一個普通方法。

執行緒的常用api

名稱 型別 作用
sleep(long millis) 靜態方法 使當前執行緒休眠millis毫秒
yield 靜態方法 當前執行緒讓出cpu
join 執行緒例項方法 等待直到某個執行緒執行完畢再執行後續程式碼
join(long millis) 執行緒例項方法 等待某個執行緒執行完畢再執行後續程式碼,最多等待millis毫秒

sleep

sleep(long millis)是一個靜態方法,其作用是使當前執行緒進入休眠millis毫秒,比如下面這個例子,我們讓執行緒休眠5s再執行

public class SleepTest {

    public static void main(String[] args) {
        new Thread(() -> {
            Debug.debug("開始執行");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Debug.debug("執行結束");
        },"t1").start();
    }
}

執行結果如下:

2021-03-07 20:09:24 [t1] 開始執行
2021-03-07 20:09:29 [t1] 執行結束

需要注意的是,sleep()會丟擲InterruptedException異常,這個異常的作用是讓我們可以中斷正在休眠中的執行緒。

yield

yield的翻譯過來是屈服、讓步的意思,由此可以看出,yield()方法的作用是讓當前執行緒主動讓出cpu,從執行狀態變成就緒狀態,相當於是把執行機會讓給其他執行緒,但不一定能成功讓出。打個簡單的比方,就像是你在排隊買車票,本來輪到你了,這時後面有個人因為時間比較趕,於是你非常紳士的把位置讓給他。那麼這個方法的應用場景是什麼呢?看了該方法的註釋,發現原來這個方法實際上很少有機會用到,主要用於程式碼除錯,復現bug。

/**
  * A hint to the scheduler that the current thread is willing to yield
  * its current use of a processor. The scheduler is free to ignore this
  * hint.
  *
  * 

Yield is a heuristic attempt to improve relative progression * between threads that would otherwise over-utilise a CPU. Its use * should be combined with detailed profiling and benchmarking to * ensure that it actually has the desired effect. * *

It is rarely appropriate to use this method. It may be useful * for debugging or testing purposes, where it may help to reproduce * bugs due to race conditions. It may also be useful when designing * concurrency control constructs such as the ones in the * {@link java.util.concurrent.locks} package. */

public static native void yield();

join

在多執行緒應用中,假如執行緒A的輸入依賴於執行緒B的輸出結果,此時,執行緒A就需要等待執行緒B執行完畢再繼續執行,我們可以使用jdk提供的join()方法來實現這種執行緒之間的協作。如下所示有兩個join方法:

public final void join() throws InterruptedException 
public final synchronized void join(long millis) throws InterruptedException

無參的join表示A執行緒會無限等待直到執行緒B執行完畢,而有參的join方法,會等待直到最大超時時間,超出這個時間後哪怕執行緒B還在執行,就不再繼續等待。透過下面這個簡單的例子,我們來驗證一下join()的作用:

public class JoinMain {

    public static void main(String[] args) throws InterruptedException {
        MyTask myTask = new MyTask();
        Thread t = new Thread(myTask);
        t.start();
        //t.join();
        System.out.println(myTask.result);
    }

    private static class MyTask implements Runnable{
        private int result;

        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            result = 100;
        }
    }
}

這段程式碼的執行結果是0,而不是100,因為main執行緒執行第7行程式碼的時候,執行緒t還處於休眠狀態,此時result還是等於0,當我們去掉第7行的註釋,main執行緒等待t執行緒執行完畢,設定了result的值,才執行第8行程式碼,結果就是100了。

守護執行緒

java執行緒可以分為守護執行緒和非守護執行緒,在java程式中,一旦非守護執行緒全部執行完畢,即便守護執行緒還沒執行完,該程式也會強行終止。顧名思義,守護執行緒和它的名字一樣,就是在後臺默默的做一些系統工作,比如java程式中的垃圾回收執行緒、JIT執行緒就是守護執行緒,正因為如此,當系統中沒有其他執行緒後,守護執行緒也就失去了存在的意義,無事可做,整個程式自然也就應該結束了。守護執行緒可以在建立執行緒後透過setDeamon()進行設定。舉個例子:

public class DaemonThreadMain {
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            Debug.debug("開始執行");
            Sleep.seconds(3);//睡眠3s
            Debug.debug("執行結束");
        },"t1");
//        t.setDaemon(true);
        t.start();
        Sleep.seconds(1);
        Debug.debug("執行結束");
    }
}

執行結果如下:

2021-03-06 20:47:59 [t1] 開始執行
2021-03-06 20:48:00 [main] 執行結束
2021-03-06 20:48:02 [t1] 執行結束

可以看出,執行緒t1執行耗時3s,main執行緒耗時1s,main執行緒1s後就執行完畢退出了,而t1執行緒作為非守護執行緒,在主執行緒結束後,依然是過了3s後才執行完畢。現在我們把第8行t.setDaemon(true)這行程式碼去掉註釋,最終執行結果如下:

2021-03-06 20:52:29 [t1] 開始執行
2021-03-06 20:52:30 [main] 執行結束

可以看出,由於t1是守護執行緒,主執行緒退出後,執行緒t1就退出而沒有往下執行了。

需要注意的是,setDameon()方法必須線上程開始也就是呼叫start()執行之前呼叫,否則會報以下異常:

Exception in thread "main" java.lang.IllegalThreadStateException
	at java.lang.Thread.setDaemon(Thread.java:1359)
	at com.taoge.demos.DaemonThreadMain.main(DaemonThreadMain.java:20)

附錄-工具類 Sleep & Debug

sleep

/**
 * Sleep 是對Thread.sleep的簡單封裝
 *
 * @author chentao
 * @date 2021/3/6
 */
public final class Sleep {
    public static void sleep(TimeUnit unit, long duration){
        try {
            unit.sleep(duration);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    public static void sleepInterruptibly(TimeUnit unit, long duration) throws InterruptedException{
        unit.sleep(duration);
    }
    
    public static void millis(long millis){
        sleep(TimeUnit.MILLISECONDS, millis);
    }
    
    public static void seconds(long seconds){
        sleep(TimeUnit.SECONDS, seconds);
    }

}

Debug

/**
 * Debug類是對System.out.println的簡單封裝,便於列印出類似這樣格式化的日誌:
 * 2021-03-07 20:11:06 [t1] 這是測試
 * 包含了日期時間、執行緒名稱和自定義的列印內容
 * @author chentao
 * @date 2021/3/4
 */
public class Debug {
    private static SimpleDateFormat format = new SimpleDateFormat();

    static {
        format.applyPattern("yyyy-MM-dd HH:mm:ss");
    }

    public static void debug(String msg, Object... params){
        for (Object param : params) {
            msg = msg.replaceFirst("\{\}",param.toString());
        }
        System.out.println(format.format(new Date())+" ["+Thread.currentThread().getName()+"] "+msg);
    }
}

原始碼地址

> 文章所有程式碼都放在github上
>
> github.com/ThomasChant/jucDemos

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4289/viewspace-2797975/,如需轉載,請註明出處,否則將追究法律責任。

相關文章