計算機程式的思維邏輯 (65) - 執行緒的基本概念

swiftma發表於2017-02-13

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (65) - 執行緒的基本概念

在之前的章節中,我們都是假設程式中只有一條執行流,程式從main方法的第一條語句逐條執行直到結束。從本節開始,我們討論併發,在程式中建立執行緒來啟動多條執行流,併發和執行緒是一個複雜的話題,本節,我們先來討論Java中執行緒的一些基本概念。

建立執行緒

執行緒表示一條單獨的執行流,它有自己的程式執行計數器,有自己的棧。下面,我們通過建立執行緒來對執行緒建立一個直觀感受,在Java中建立執行緒有兩種方式,一種是繼承Thread,另外一種是實現Runnable介面,我們先來看第一種。

繼承Thread

Java中java.lang.Thread這個類表示執行緒,一個類可以繼承Thread並重寫其run方法來實現一個執行緒,如下所示:

public class HelloThread extends Thread {
    
    @Override
    public void run() {
        System.out.println("hello");
    }
}
複製程式碼

HelloThread這個類繼承了Thread,並重寫了run方法。run方法的方法簽名是固定的,public,沒有引數,沒有返回值,不能丟擲受檢異常。run方法類似於單執行緒程式中的main方法,執行緒從run方法的第一條語句開始執行直到結束。

定義了這個類不代表程式碼就會開始執行,執行緒需要被啟動,啟動需要先建立一個HelloThread物件,然後呼叫Thread的start方法,如下所示:

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

我們在main方法中建立了一個執行緒物件,並呼叫了其start方法,呼叫start方法後,HelloThread的run方法就會開始執行,螢幕輸出:

hello
複製程式碼

為什麼呼叫的是start,執行的卻是run方法呢?start表示啟動該執行緒,使其成為一條單獨的執行流,背後,作業系統會分配執行緒相關的資源,每個執行緒會有單獨的程式執行計數器和棧,作業系統會把這個執行緒作為一個獨立的個體進行排程,分配時間片讓它執行,執行的起點就是run方法。

如果不呼叫start,而直接呼叫run方法呢?螢幕的輸出並不會發生變化,但並不會啟動一條單獨的執行流,run方法的程式碼依然是在main執行緒中執行的,run方法只是main方法呼叫的一個普通方法。

怎麼確認程式碼是在哪個執行緒中執行的呢?Thread有一個靜態方法currentThread,返回當前執行的執行緒物件:

public static native Thread currentThread();
複製程式碼

每個Thread都有一個id和name:

public long getId()
public final String getName()
複製程式碼

這樣,我們就可以判斷程式碼是在哪個執行緒中執行的,我們在HelloThead的run方法中加一些程式碼:

@Override
public void run() {
    System.out.println("thread name: "+ Thread.currentThread().getName());
    System.out.println("hello");
}
複製程式碼

如果在main方法中通過start方法啟動執行緒,程式輸出為:

thread name: Thread-0
hello
複製程式碼

如果在main方法中直接呼叫run方法,程式輸出為:

thread name: main
hello
複製程式碼

呼叫start後,就有了兩條執行流,新的一條執行run方法,舊的一條繼續執行main方法,兩條執行流併發執行,作業系統負責排程,在單CPU的機器上,同一時刻只能有一個執行緒在執行,在多CPU的機器上,同一時刻可以有多個執行緒同時執行,但作業系統給我們遮蔽了這種差異,給程式設計師的感覺就是多個執行緒併發執行,但哪條語句先執行哪條後執行是不一定的。當所有執行緒都執行完畢的時候,程式退出。

實現Runnable介面

通過繼承Thread來實現執行緒雖然比較簡單,但我們知道,Java中只支援單繼承,每個類最多隻能有一個父類,如果類已經有父類了,就不能再繼承Thread,這時,可以通過實現java.lang.Runnable介面來實現執行緒。

Runnable介面的定義很簡單,只有一個run方法,如下所示:

public interface Runnable {
    public abstract void run();
}
複製程式碼

一個類可以實現該介面,並實現run方法,如下所示:

public class HelloRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println("hello");
    }
}    
複製程式碼

僅僅實現Runnable是不夠的,要啟動執行緒,還是要建立一個Thread物件,但傳遞一個Runnable物件,如下所示:

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

無論是通過繼承Thead還是實現Runnable介面來實現執行緒,啟動執行緒都是呼叫Thread物件的start方法。

執行緒的基本屬性和方法

id和name

前面我們提到,每個執行緒都有一個id和name,id是一個遞增的整數,每建立一個執行緒就加一,name的預設值是"Thread-"後跟一個編號,name可以在Thread的構造方法中進行指定,也可以通過setName方法進行設定,給Thread設定一個友好的名字,可以方便除錯。

優先順序

執行緒有一個優先順序的概念,在Java中,優先順序從1到10,預設為5,相關方法是:

public final void setPriority(int newPriority)
public final int getPriority()
複製程式碼

這個優先順序會被對映到作業系統中執行緒的優先順序,不過,因為作業系統各不相同,不一定都是10個優先順序,Java中不同的優先順序可能會被對映到作業系統中相同的優先順序,另外,優先順序對作業系統而言更多的是一種建議和提示,而非強制,簡單的說,在程式設計中,不要過於依賴優先順序。

狀態

執行緒有一個狀態的概念,Thread有一個方法用於獲取執行緒的狀態:

public State getState()
複製程式碼

返回值型別為Thread.State,它是一個列舉型別,有如下值:

public enum State {
  NEW,
  RUNNABLE,
  BLOCKED,
  WAITING,
  TIMED_WAITING,
  TERMINATED;
}
複製程式碼

關於這些狀態,我們簡單解釋下:

  • NEW: 沒有呼叫start的執行緒狀態為NEW
  • TERMINATED: 執行緒執行結束後狀態為TERMINATED
  • RUNNABLE: 呼叫start後執行緒在執行run方法且沒有阻塞時狀態為RUNNABLE,不過,RUNNABLE不代表CPU一定在執行該執行緒的程式碼,可能正在執行也可能在等待作業系統分配時間片,只是它沒有在等待其他條件
  • BLOCKED、WAITING、TIMED_WAITING:都表示執行緒被阻塞了,在等待一些條件,其中的區別我們在後續章節再介紹

Thread還有一個方法,返回執行緒是否活著:

public final native boolean isAlive()
複製程式碼

執行緒被啟動後,run方法執行結束前,返回值都是true。

是否daemo執行緒

Thread有一個是否daemo執行緒的屬性,相關方法是:

public final void setDaemon(boolean on)
public final boolean isDaemon()
複製程式碼

前面我們提到,啟動執行緒會啟動一條單獨的執行流,整個程式只有在所有執行緒都結束的時候才退出,但daemo執行緒是例外,當整個程式中剩下的都是daemo執行緒的時候,程式就會退出。

daemo執行緒有什麼用呢?它一般是其他執行緒的輔助執行緒,在它輔助的主執行緒退出的時候,它就沒有存在的意義了。在我們執行一個即使最簡單的"hello world"型別的程式時,實際上,Java也會建立多個執行緒,除了main執行緒外,至少還有一個負責垃圾回收的執行緒,這個執行緒就是daemo執行緒,在main執行緒結束的時候,垃圾回收執行緒也會退出。

sleep方法

Thread有一個靜態的sleep方法,呼叫該方法會讓當前執行緒睡眠指定的時間,單位是毫秒:

public static native void sleep(long millis) throws InterruptedException;
複製程式碼

睡眠期間,該執行緒會讓出CPU,但睡眠的時間不一定是確切的給定毫秒數,可能有一定的偏差,偏差與系統定時器和作業系統排程器的準確度和精度有關。

睡眠期間,執行緒可以被中斷,如果被中斷,sleep會丟擲InterruptedException,關於中斷以及中斷處理,我們後續章節再介紹。

yield方法

Thread還有一個讓出CPU的方法:

public static native void yield();
複製程式碼

這也是一個靜態方法,呼叫該方法,是告訴作業系統的排程器,我現在不著急佔用CPU,你可以先讓其他執行緒執行。不過,這對排程器也僅僅是建議,排程器如何處理是不一定的,它可能完全忽略該呼叫。

join方法

在前面HelloThread的例子中,HelloThread沒執行完,main執行緒可能就執行完了,Thread有一個join方法,可以讓呼叫join的執行緒等待該執行緒結束,join方法的宣告為:

public final void join() throws InterruptedException
複製程式碼

在等待執行緒結束的過程中,這個等待可能被中斷,如果被中斷,會丟擲InterruptedException。

join方法還有一個變體,可以限定等待的最長時間,單位為毫秒,如果為0,表示無期限等待:

public final synchronized void join(long millis) throws InterruptedException
複製程式碼

在前面的HelloThread示例中,如果希望main執行緒在子執行緒結束後再退出,main方法可以改為:

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

過時方法

Thread類中還有一些看上去可以控制執行緒生命週期的方法,如:

public final void stop()
public final void suspend()
public final void resume()
複製程式碼

這些方法因為各種原因已被標記為了過時,我們不應該在程式中使用它們。

共享記憶體及問題

共享記憶體

前面我們提到,每個執行緒表示一條單獨的執行流,有自己的程式計數器,有自己的棧,但執行緒之間可以共享記憶體,它們可以訪問和操作相同的物件。我們看個例子,程式碼如下:

public class ShareMemoryDemo {
    private static int shared = 0;
    
    private static void incrShared(){
        shared ++;
    }
    
    static class ChildThread extends Thread {
        List<String> list;
        
        public ChildThread(List<String> list) {
            this.list = list;
        }

        @Override
        public void run() {
            incrShared();
            list.add(Thread.currentThread().getName());
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        List<String> list = new ArrayList<String>();
        Thread t1 = new ChildThread(list);
        Thread t2 = new ChildThread(list);
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        
        System.out.println(shared);
        System.out.println(list);
    }
}
複製程式碼

在程式碼中,定義了一個靜態變數shared和靜態內部類ChildThread,在main方法中,建立並啟動了兩個ChildThread物件,傳遞了相同的list物件,ChildThread的run方法訪問了共享的變數shared和list,main方法最後輸出了共享的shared和list的值,大部分情況下,會輸出期望的值:

2
[Thread-0, Thread-1]
複製程式碼

通過這個例子,我們想強調說明執行流、記憶體和程式程式碼之間的關係。

  • 該例中有三條執行流,一條執行main方法,另外兩條執行ChildThread的run方法。
  • 不同執行流可以訪問和操作相同的變數,如本例中的shared和list變數。
  • 不同執行流可以執行相同的程式程式碼,如本例中incrShared方法,ChildThread的run方法,被兩條ChildThread執行流執行,incrShared方法是在外部定義的,但被ChildThread的執行流執行,在分析程式碼執行過程時,理解程式碼在被哪個執行緒執行是很重要的
  • 當多條執行流執行相同的程式程式碼時,每條執行流都有單獨的棧,方法中的引數和區域性變數都有自己的一份。

當多條執行流可以操作相同的變數時,可能會出現一些意料之外的結果,我們來看下。

競態條件

所謂競態條件(race condition)是指,當多個執行緒訪問和操作同一個物件時,最終執行結果與執行時序有關,可能正確也可能不正確,我們看一個例子:

public class CounterThread extends Thread {
    private static int counter = 0;

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            counter++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        int num = 1000;
        Thread[] threads = new Thread[num];
        for (int i = 0; i < num; i++) {
            threads[i] = new CounterThread();
            threads[i].start();
        }

        for (int i = 0; i < num; i++) {
            threads[i].join();
        }

        System.out.println(counter);
    }
}
複製程式碼

這段程式碼容易理解,有一個共享靜態變數counter,初始值為0,在main方法中建立了1000個執行緒,每個執行緒對counter迴圈加1000次,main執行緒等待所有執行緒結束後輸出counter的值。

期望的結果是100萬,但實際執行,發現每次輸出的結果都不一樣,一般都不是100萬,經常是99萬多。為什麼會這樣呢?因為counter++這個操作不是原子操作,它分為三個步驟:

  1. 取counter的當前值
  2. 在當前值基礎上加1
  3. 將新值重新賦值給counter

兩個執行緒可能同時執行第一步,取到了相同的counter值,比如都取到了100,第一個執行緒執行完後counter變為101,而第二個執行緒執行完後還是101,最終的結果就與期望不符。

怎麼解決這個問題呢?有多種方法:

  • 使用synchronized關鍵字
  • 使用顯式鎖
  • 使用原子變數

關於這些方法,我們在後續章節再介紹。

記憶體可見性

多個執行緒可以共享訪問和操作相同的變數,但一個執行緒對一個共享變數的修改,另一個執行緒不一定馬上就能看到,甚至永遠也看不到,這可能有悖直覺,我們來看一個例子。

public class VisibilityDemo {
    private static boolean shutdown = false;
    
    static class HelloThread extends Thread {
        @Override
        public void run() {
            while(!shutdown){
                // do nothing
            }
            System.out.println("exit hello");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new HelloThread().start();
        Thread.sleep(1000);
        shutdown = true;
        System.out.println("exit main");
    }
}
複製程式碼

在這個程式中,有一個共享的boolean變數shutdown,初始為false,HelloThread在shutdown不為true的情況下一直死迴圈,當shutdown為true時退出並輸出"exit hello",main執行緒啟動HelloThread後睡了一會,然後設定shutdown為true,最後輸出"exit main"。

期望的結果是兩個執行緒都退出,但實際執行,很可能會發現HelloThread永遠都不會退出,也就是說,在HelloThread執行流看來,shutdown永遠為false,即使main執行緒已經更改為了true。

這是怎麼回事呢?這就是記憶體可見性問題。在計算機系統中,除了記憶體,資料還會被快取在CPU的暫存器以及各級快取中,當訪問一個變數時,可能直接從暫存器或CPU快取中獲取,而不一定到記憶體中去取,當修改一個變數時,也可能是先寫到快取中,而稍後才會同步更新到記憶體中。在單執行緒的程式中,這一般不是個問題,但在多執行緒的程式中,尤其是在有多CPU的情況下,這就是個嚴重的問題。一個執行緒對記憶體的修改,另一個執行緒看不到,一是修改沒有及時同步到記憶體,二是另一個執行緒根本就沒從記憶體讀。

怎麼解決這個問題呢?有多種方法:

  • 使用volatile關鍵字
  • 使用synchronized關鍵字或顯式鎖同步

關於這些方法,我們在後續章節再介紹。

執行緒的優點及成本

優點

為什麼要建立單獨的執行流?或者說執行緒有什麼優點呢?至少有以下幾點:

  • 充分利用多CPU的計算能力,單執行緒只能利用一個CPU,使用多執行緒可以利用多CPU的計算能力。
  • 充分利用硬體資源,CPU和硬碟、網路是可以同時工作的,一個執行緒在等待網路IO的同時,另一個執行緒完全可以利用CPU,對於多個獨立的網路請求,完全可以使用多個執行緒同時請求。
  • 在使用者介面(GUI)應用程式中,保持程式的響應性,介面和後臺任務通常是不同的執行緒,否則,如果所有事情都是一個執行緒來執行,當執行一個很慢的任務時,整個介面將停止響應,也無法取消該任務。
  • 簡化建模及IO處理,比如,在伺服器應用程式中,對每個使用者請求使用一個單獨的執行緒進行處理,相比使用一個執行緒,處理來自各種使用者的各種請求,以及各種網路和檔案IO事件,建模和編寫程式要容易的多。

成本

關於執行緒,我們需要知道,它是有成本的。建立執行緒需要消耗作業系統的資源,作業系統會為每個執行緒建立必要的資料結構、棧、程式計數器等,建立也需要一定的時間。

此外,執行緒排程和切換也是有成本的,當有當量可執行執行緒的時候,作業系統會忙於排程,為一個執行緒分配一段時間,執行完後,再讓另一個執行緒執行,一個執行緒被切換出去後,作業系統需要儲存它的當前上下文狀態到記憶體,上下文狀態包括當前CPU暫存器的值、程式計數器的值等,而一個執行緒被切換回來後,作業系統需要恢復它原來的上下文狀態,整個過程被稱為上下文切換,這個切換不僅耗時,而且使CPU中的很多快取失效,是有成本的。

當然,這些成本是相對而言的,如果執行緒中實際執行的事情比較多,這些成本是可以接受的,但如果只是執行本節示例中的counter++,那相對成本就太高了。

另外,如果執行的任務都是CPU密集型的,即主要消耗的都是CPU,那建立超過CPU數量的執行緒就是沒有必要的,並不會加快程式的執行。

小結

本節,我們介紹了Java中執行緒的一些基本概念,包括如何建立執行緒,執行緒的一些基本屬性和方法,多個執行緒可以共享記憶體,但共享記憶體也有兩個重要問題,一個是競態條件,另一個是記憶體可見性,最後,我們討論了執行緒的一些優點和成本。

針對共享記憶體的兩個問題,下一節,我們討論Java的一個解決方案 - synchronized關鍵字。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (65) - 執行緒的基本概念

相關文章