多線程系列(一) -線程技術入門知識講解

一、簡介

在很多場景下,我們經常聽到採用多線程編程,能顯著的提升程序的執行效率。例如執行大批量數據的插入操作,採用單線程編程進行插入可能需要 30 分鐘,採用多線程編程進行插入可能只需要 5 分鐘就夠了。

既然多線程編程技術如此厲害,那什麼是多線程呢?

在介紹多線程之前,我們還得先講講進程和線程的概念。

二、進程和線程

2.1、什麼是進程?

從計算機角度來講,進程是操作系統中的基本執行單元,也是操作系統進行資源分配和調度的基本單位,並且進程之間相互獨立,互不干擾

例如,我們windows電腦中的 Chrome 瀏覽器是一個進程、WeChat 也是一個進程,正在操作系統中運行的.exe都可以理解爲一個進程。

2.2、什麼是線程?

關於線程,比較官方的定義是,線程是進程中的⼀個執⾏單元,也是操作系統能夠進行運算調度的最小單位,負責當前進程中程序的執⾏。同時⼀個進程中⾄少有⼀個線程,⼀個進程中也可以有多個線程,它們共享這個進程的資源,擁有多個線程的程序,我們也稱爲多線程編程。

舉個例子,Chrome 瀏覽器和 WeChat 是兩個進程,Chrome 瀏覽器進程裏面有很多線程,例如 HTTP 請求線程、事件響應線程、渲染線程等等,線程的併發執行使得在瀏覽器中點擊一個新鏈接從而發起 HTTP 請求時,瀏覽器還可以響應用戶的其它事件。

2.3、進程和線程的關係

關於進程和線程,可能上面的解釋過於抽象,還是很難理解,下面是一段出自阮一峯老師博客文章的介紹,可能描述不是非常嚴謹,但是足夠形象,有助於我們對它們關係的理解。

  • 1.我們都知道,計算機的核心是 CPU,它承擔了所有的計算任務。它就像一座工廠,時刻在運行;(CPU 類似於工廠
  • 2.假定工廠的電力有限,一次只能供給一個車間使用。也就是說,一個車間開工的時候,其他車間都必須停工。背後的含義就是,單個 CPU 一次只能運行一個任務;
  • 3.進程就好比工廠的車間,它代表 CPU 所能處理的單個任務。任一時刻,CPU 總是運行一個進程,其他進程處於非運行狀態;(進程類似於車間
  • 4.一個車間裏,可以有很多工人。他們協同完成一個任務;
  • 5.線程就好比車間裏的工人。一個進程可以包括多個線程;(線程類似於工人
  • 6.車間的空間是工人們共享的,比如許多房間是每個工人都可以進出的。這象徵一個進程的內存空間是共享的,每個線程都可以使用這些共享內存;(每個線程共享進程下的內存資源
  • 7.一個防止他人進入的簡單方法,就是門口加一把鎖。先到的人鎖上門,後到的人看到上鎖,就在門口排隊,等鎖打開再進去。這就叫"互斥鎖"(Mutual exclusion,縮寫 Mutex),防止多個線程同時讀寫某一塊內存區域;(多個線程下可以通過互斥鎖,實現資源獨佔
  • 8.還有些房間,可以同時容納 n 個人,比如廚房。也就是說,如果人數大於 n,多出來的人只能在外面等着。這好比某些內存區域,只能供給固定數目的線程使用;
  • 9.這時的解決方法,就是在門口掛 n 把鑰匙。進去的人就取一把鑰匙,出來時再把鑰匙掛回原處。後到的人發現鑰匙架空了,就知道必須在門口排隊等着了。這種做法叫做 "信號量"(Semaphore),用來保證多個線程不會互相沖突。(多個線程下可以通過信號量,實現互不衝突

不難看出,互斥鎖 Mutex 是信號量 semaphore 的一種特殊情況(n = 1時)。也就是說,完全可以用後者替代前者。但是,因爲 Mutex 較爲簡單,且效率高,所以在必須保證資源獨佔的情況下,還是採用這種方式。

2.4、爲什麼要引入線程?

早期的操作系統都是以進程作爲獨立運行的基本單位的,直到後期計算機科學家們又提出了更小的能獨立運行的基本單位,也就是線程。

那爲什麼要引入線程呢?我們只需要記住這句話:線程又稱爲迷你進程,但是它比進程更容易創建,也更容易撤銷

引入線程之後,可以將複雜的操作進一步分解,讓程序的執行效率進一步提升

舉個例子,進程就如同一個隨時揹着糧草和機槍的士兵,這樣肯定會造成士兵的執行戰鬥的速度。因此,一個簡單想法就是:分配兩個人來執行,一個士兵負責隨時揹着糧草,另一個士兵負責抗機槍戰鬥,這樣執行戰鬥的速度會大幅提升。這些輕裝上陣的士兵,可以理解爲我們上文提到的線程!

從計算機角度來說,由於創建或撤銷進程時,系統都要爲之分配或回收資源,如內存空間、I/O 設備等,需要較大的時間和空間開銷。

爲了減少進程切換的開銷,把進程作爲資源分配單位和調度單位這兩個屬性分開處理,即進程還是作爲資源分配的基本單位,但是把調度執行與切換的責任交給線程,即線程成爲獨立調度的基本單位,它比進程更容易(更快)創建,也更容易撤銷。

一句話總結就是:引入線程前,進程是資源分配和獨立調度的基本單位。引入線程後,進程是資源分配的基本單位,線程是獨立調度的基本單位,線程也是進程中的⼀個執⾏單元。

三、創建線程的方式

在 Java 裏面,創建線程有以下兩種方式:

  • 繼承java.lang.Thread類,重寫run()方法
  • 實現java.lang.Runnable接口,然後通過一個java.lang.Thread類來啓動

不管是哪種方式,所有的線程對象都必須是Thread類或其⼦類的實例,每個線程的作⽤是完成⼀定的任務,實際上就是執⾏⼀段程序流,即⼀段順序執⾏的代碼,任務執行完畢之後就結束了。

在 Java 中,通過Thread類來創建並啓動線程的步驟如下:

  • 1.定義Thread類的⼦類,並重寫該類的run()方法
  • 2.通過Thread子類,初始化線程對象
  • 3.通過線程對象,調用start()方法啓動線程

下面我們具體來看看創建線程的代碼實踐。

3.1、繼承 Thread 類,重寫 run 方法介紹

/**
 * 創建一個 Thread 子類
 */
public class Thread0 extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 當前線程:" + Thread.currentThread().getName() + ",正在運行");
        }
    }
}
/**
 * 創建一個測試類
 */
public class ThreadTest0 {

    public static void main(String[] args) {
        // 初始化一個線程對象,然後啓動線程
        Thread0 thread0 = new Thread0();
        thread0.start();

        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 當前線程:" + Thread.currentThread().getName() + ",正在運行");
        }
    }
}

輸出結果:

2023-08-23 17:58:03:726 當前線程:Thread-0,正在運行
2023-08-23 17:58:03:727 當前線程:Thread-0,正在運行
2023-08-23 17:58:03:726 當前線程:main,正在運行
2023-08-23 17:58:03:727 當前線程:Thread-0,正在運行
2023-08-23 17:58:03:727 當前線程:main,正在運行
2023-08-23 17:58:03:728 當前線程:Thread-0,正在運行
2023-08-23 17:58:03:728 當前線程:main,正在運行
2023-08-23 17:58:03:728 當前線程:Thread-0,正在運行
2023-08-23 17:58:03:728 當前線程:main,正在運行
2023-08-23 17:58:03:728 當前線程:main,正在運行

從執行時間上可以看到,main線程和Thread-0線程交替運行,效果十分明顯!

所謂的多線程,其實就是兩個及以上線程的代碼可以同時運行,而不必一個線程需要等待另一個線程內的代碼執行完纔可以運行。

對於單核 CPU 來說,是無法做到真正的多線程的;但是對於多核 CPU 來說,在一段時間內,可以執行多個任務的,由於 CPU 執行代碼時間很快,所以兩個線程的代碼交替執行看起來像是同時執行的一樣,具體執行某段代碼多少時間,就和分時機制系統有關了。

分時機制系統,簡單的說,就是將 CPU 時間劃分爲多個時間片,操作系統以時間片爲單位來執行各個線程的代碼,越好的 CPU 分出的時間片越小。

例如某個時段, CPU 將 1 秒劃分成 50 個時間片,1 個時間片耗時 20 ms,每個時間片均進行線程切換,也就是說 1 秒可以執行 50 個任務,給人的感覺好像計算機能同時處理多件事情,其實是 CPU 執行任務速度太快給人產生的錯覺感。

3.2、實現 Runnable 接口,然後通過 Thread 類來啓動介紹

/**
 * 實現 Runnable 接口
 */
public class Thread2 implements Runnable{

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 當前線程:" + Thread.currentThread().getName() + ",正在運行");
        }
    }
}
/**
 * 創建一個測試類
 */
public class ThreadTest2 {

    public static void main(String[] args) {
        // 通過一個Thread來啓動線程
        Thread thread2 = new Thread(new Thread2());
        thread2.start();

        for (int i = 0; i < 5; i++) {
            String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
            System.out.println(time + " 當前線程:" + Thread.currentThread().getName() + ",正在運行");
        }
    }
}

輸出結果:

2023-08-23 18:30:28:664 當前線程:Thread-0,正在運行
2023-08-23 18:30:28:666 當前線程:Thread-0,正在運行
2023-08-23 18:30:28:666 當前線程:Thread-0,正在運行
2023-08-23 18:30:28:664 當前線程:main,正在運行
2023-08-23 18:30:28:666 當前線程:Thread-0,正在運行
2023-08-23 18:30:28:667 當前線程:Thread-0,正在運行
2023-08-23 18:30:28:668 當前線程:main,正在運行
2023-08-23 18:30:28:668 當前線程:main,正在運行
2023-08-23 18:30:28:668 當前線程:main,正在運行
2023-08-23 18:30:28:668 當前線程:main,正在運行

效果跟上面介紹的一樣,如果循環的打印次數越多,效果越明顯!

四、線程狀態

下圖是一張從操作系統角度劃分的線程模型狀態!

線程被分爲五種狀態,各個狀態說明如下:

  • 1.新建狀態:表示創建了一個新的線程對象,例如Thread thread = new Thread()
  • 2.就緒狀態:比如調用線程的start()方法,就會處於就緒狀態,也被稱爲可執行狀態,隨時可能被 CPU 調度執行
  • 3.運行狀態:獲得了 CPU 時間片,執行程序代碼。需要注意的是,線程只能從就緒狀態進入到運行狀態
  • 4.阻塞狀態:因爲某種原因出現了阻塞,線程放棄對 CPU 的使用權,停止執行,直到阻塞事件結束,重新進入就緒狀態纔有可能再次被 CPU 調度。
  • 5.結束狀態:線程裏面的方法正常執行結束或者因爲某種異常退出了,則該線程結束生命週期

針對操作系統的線程模型,Java 進行部分封裝和擴充,JVM 中的線程狀態總共有六種,它們之間的關係,可以用如下圖來表示:

各個狀態說明如下:

  • 1.新建狀態(NEW):新創建了一個線程對象
  • 2.運行狀態(RUNNABLE):Java 線程中將就緒狀態和運行中兩種狀態,籠統的稱爲“運行”。線程對象創建後,調用了該對象的start()方法,該線程處於就緒狀態,獲得 CPU 時間片後變爲運行中狀態
  • 3.阻塞狀態(BLOCKED):因爲某種原因,線程放棄對 CPU 的使用權,停止執行,直到進入就緒狀態纔有可能再次被 CPU 調度。比如線程在獲得synchronized同步鎖失敗後,會把線程放入鎖池中,線程進入同步阻塞狀態。
  • 4.等待狀態(WAITING):處於這種狀態的線程不會被分配 CPU 執行時間,它們要等待被顯式地喚醒,否則會處於無限期等待的狀態。比如運行狀態的線程執行wait方法,會把線程放在等待隊列中,直到被喚醒或者因異常自動退出
  • 5.超時等待狀態(TIMED_WAITING):處於這種狀態的線程不會被分配 CPU 執行時間,不過無須無限期等待被其他線程顯式地喚醒,在到達一定時間後它們會自動喚醒。比如運行狀態的線程執行Thread.sleep(1000)方法,當到達目標時間後,會自動喚醒或者因異常自動退出
  • 6.終止狀態(TERMINATED):表示該線程已經執行完畢,處於終止狀態的線程不具備繼續運行的能力

五、小結

本文主要圍繞進程和線程的一些基礎知識,進行簡單的入門知識總結。

線程的特徵和進程差不多,進程有的它基本都有。

相對於進程而言,線程更加的輕量化,主要承擔任務的執行工作,優點如下:

  • 一個進程中可以同時擁有多個線程,這些線程共享該進程的資源。我們知道進程間的通信必須請求操作系統服務(因爲 CPU 要切換到內核態),開銷很大。而同進程下的線程間通信,無需操作系統干預,開銷更小
  • 線程間可以併發執行任務,線程間的併發比進程的開銷更小,系統併發性更好
  • 在多 CPU 環境下,各個線程也可以分派到不同的 CPU 上並行執行
  • 通過多線程編程,可以顯著的提升程序任務的執行效率

不過線程也有缺點:

  • 當程序編程不合理,多個線程發生較長時間的等待或資源競爭時,可能會出現死鎖
  • 等候使用共享資源時可能會造成程序的運行速度變慢。這些共享資源主要是獨佔性的資源,如打印機、IO 設備等

總的來說,進程和線程各有各優勢,站在操作系統的設計角度而言,可以歸結爲以下幾點:

  • 採用多進程方式,可以保證多個任務同時運行;
  • 採用多線程方式,可以將單個任務分成不同的部分進行執行;
  • 提供協調機制,防止進程之間和線程之間產生衝突,同時允許進程之間和線程之間共享資源,以充分的利用系統資源

整篇內容難免有描述不對的地方,歡迎網友留言指出!

六、參考

1、飛天小牛肉 - 五分鐘掃盲:進程與線程基礎必知

2、潘建南 - Java線程的6種狀態及切換

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