聊聊 Java 多線程(1)-什麼是多線程

目前,多線程編程可以說是在大部分平臺和應用上都需要實現的一個基本需求。本系列文章就來對 Java 平臺下的多線程編程知識進行講解,從概念入門底層實現上層應用都會涉及到,預計一共會有五篇文章,希望對你有所幫助😎😎

本篇文章是第一篇,先來介紹下 Java 多線程的基礎概念以及需要面對的挑戰,是後續文章的敲門磚

一、多線程編程

假設存在三個事件(事件A、事件B、事件C)需要我們完成,每個事件均包含一定的前置處理時間和等待完成時間,即每個事件均需要先處理一定時間,處理完成後再等待一段時間,等待過後該事件就算作已完成了。那麼,我們就可以採用三種不同的方式來完成這三個事件:

  • 串行。按照順序依次來處理三個事件,待某個事件處理且等待結束後再處理下一個事件。這種方式需要消耗一個人力
  • 併發。先處理事件 A,當事件 A 的前置處理完成後,轉而來處理事件 B,當事件 B 的前置處理完成後,轉而來處理事件 C,最後就只要等待三個事件結束即可。這種方式需要消耗一個人力
  • 並行。三個事件分別轉交由三個人進行同時處理。這種方式需要消耗三個人力

從直觀上看,串行的處理效率最低,耗時最長,在每次等待事件完成的時間段內人力都被白白消耗了。並行的處理效率最高,耗時最短,理論上總的所需耗時取決於用時最長的那個事件,但其需要的人力成本也最高。併發的處理效率和耗時長短均介於串行和並行之間,需要的人力成本和串行持平(均低於並行)

從以上假設的情景映射到軟件世界

  • 併發就是在一段時間內以交替的方式來完成多個任務,使用多個線程來分別處理不同的任務,即使在單個處理器的情況下也可以通過時間片切換的技術來實現在一個時間段內運行多個線程,因此即使只有一個處理器也可以實現併發
  • 並行就是以同時處理的方式來完成多個任務,使用多個線程來分別處理不同的任務,然後將多個線程分別轉交給不同的處理器進行運行,因此並行需要有多個處理器纔可以實現
  • 而在現實情況下,程序需要同時執行的線程數量往往是遠多於處理器的數量,併發纔是我們的主要實現目標。因此,我們可以這麼理解:實現多線程編程的過程就是將任務的執行方式由串行改爲併發的過程,即實現併發化,以此來儘量提高程序和硬件的運行效率

那使用多線程有什麼好處呢?

  • 對於計算機來說,其處理器的運算速度相比存儲和通信子系統要快了幾個數量級,如果只採用單線程,那麼當線程在處理磁盤 I/O、數據庫訪問等任務時,處理器就被閒置着沒有活幹了,這就造成了很大的性能浪費。此時就可以通過採用多線程來使處理器儘量處於運轉狀態,儘量利用到其運算能力
  • 此處,對於一個服務端,衡量其性能高低好壞的一個重要標準之一是每秒事務處理數(TPS)的大小,它代表着一秒內服務端平均能響應的請求總數。服務端可能會在極小的時間段內收到多個請求,服務端的 TPS 就和程序的併發能力(即同時處理多項任務的能力)有着密切關聯
  • 再比如,在 Android 應用開發中,系統規定了只有 main 線程纔可以進行 UI 繪製和刷新,如果不將耗時操作(IO讀寫、網絡請求等)放到子線程進行處理,那麼用戶對應用的 UI 操作行爲(點擊屏幕、滑動列表等)很大可能就會由於無法及時被 main 線程處理,導致應用似乎被卡住了,最終用戶可能就會放棄使用該應用了。所以,使用多線程編程可以最大限度地利用系統提供的處理能力,提高程序的吞吐率和響應性,避免性能浪費

使用多線程就一定就能提高效率嗎?這不一定

  • 採用多線程後,各個線程間可能會相互競爭系統資源,例如處理器時間片、排他鎖、帶寬、硬盤讀寫等,而資源往往是有限且每次只能由一個線程使用的,併發編程的最終效益就往往受限於資源的有限分配,多個線程爭用同一個排他性資源就會帶來線程上下文切換甚至死鎖等問題
  • 例如,當採用多個線程來分段下載某個網絡文件以此來希望減少下載耗時。由於帶寬大小是固定的,使用多個線程同時進行下載首先就會拉低每個線程的平均可用帶寬大小,每個線程下載到的單份資源也需要通過硬盤讀寫合併成一個完整的文件,每段資源的合併需要通過調度程度來按順序寫入,維護調度順序的過程也是有着性能的消耗,多個線程進行 IO 讀寫也會加大發生線程上下文切換的次數。因此,某些情況下采用多線程可能會顯得“並不那麼值得”,需要我們根據實際情況來衡量使用

二、進程與線程

  • 程序(Program)是對指令、數據及其組織形式的描述,是一種靜態的概念
  • 進程(Process)是程序的運行實例,每個被啓動的程序就對應運行於操作系統上的一個進程,是一種動態的概念。進程是程序向操作系統申請資源(內存空間、文件句柄等)的基本單位,也是操作系統進行資源調度和資源分配的基本單位。運行一個 Java 程序實質上就是啓動了一個 Java 虛擬機進程
  • 線程(Thread)是進程中可獨立執行的最小單位,也是操作系統能夠進行運算調度的最小單位,也被稱爲輕量級進程。每個線程總是包含於特定的進程內,一個進程可以包含多個線程且至少包含一個線程,線程是進程中的實際運行單位。同一個進程中的所有線程共享該進程中的資源(內存空間、文件句柄等)
  • 任務(Task)即線程所要完成的邏輯計算。線程在創建之初的目的就是爲了讓其來執行特定的邏輯計算,其所要完成的工作就稱爲該線程的任務

多線程編程是一種以線程爲基本抽象單位的編程範式(Praadigm)。現代計算機操作系統幾乎都支持多任務處理,多任務處理有兩種不同的類型:基於進程的多任務處理基於線程的多任務處理

  • 基於進程的多任務處理指操作系統支持同時運行多個程序,進程是調度程序能夠調度的最小代碼單元。進程是重量級的任務,每個進程需要有自己的地址空間,進程間通信開銷很大而且有很多限制,從一個進程上下文切換到另一個進程上下文的開銷也很大
  • 基於線程的多任務處理意味着單個進程可以同時執行多個任務,線程是調度程序能夠調度的最小代碼單元。基於線程的多任務處理需要的開銷要比基於進程的多任務處理小得多。線程是輕量級的任務,它們共享同個進程下的資源,線程間通信的開銷不大,並且同個進程下的不同線程上下文間的切換所需要的的開銷要比不同進程上下文間的切換小得多

基於進程的多任務處理是由操作系統來實現並管理的,一般的程序開發接觸不到這個層面。而基於線程的多任務處理則可以由程序開發者自己來實現並進行管理。可以說,多線程編程的一個目的就是爲了實現基於線程的多任務處理

Java 對多線程提供了內置支持。Java 標準類庫中的 java.lang.Thread 類就是對線程這個概念的抽象實現,提供了在不同的硬件和操作系統平臺上對線程操作的統一處理,屏蔽了不同的硬件和操作系統的差異性

Java 本身是一個多線程的平臺,即使開發者沒有主動創建線程,此時進程內還是使用到了多個線程(例如,還存在 GC 線程)。所謂的單線程編程往往指的是在程序中開發者沒有主動創建線程

三、Thread

Java 標準類庫 java.lang.Thread 是 Java 平臺對線程這個概念的抽象實現,Thread 類或者其子類的一個實例就是一個線程

1、線程的屬性

屬性 含義
編號(ID) long。用於標識不同的線程,不同的線程擁有不同的編號,在 Java 虛擬機的單次生命週期內具有唯一性
名稱(Name) String。用於區分不同的線程,方便開發和調試時定位問題,在 Java 虛擬機的單次生命週期內可以重複
類別(Daemon) boolean。值爲 true 表示該線程爲守護線程,否則爲用戶線程
優先級(Priority) int。Java 定義了 1~10 的 10 個優先級,默認值爲 5

按照線程是否會阻止 Java 虛擬機正常停止,Java 中的線程分爲用戶線程守護線程。用戶線程會阻止 Java 虛擬機的正常停止,即一個 Java 虛擬機只有在其所有用戶線程都運行結束的情況下才能正常停止。而守護線程不會影響 Java 虛擬機的正常停止,即使應用程序中還有守護線程在運行也不影響 Java 虛擬機的正常停止。因此,守護線程適合用於執行一些重要性不是很高的任務。但如果 Java 虛擬機是被強制停止或者由於異常被停止的話,用戶線程也無法阻止 Java 虛擬機的停止

Java 線程的優先級屬性本質上只是一個給線程調度器的提示信息,以便於線程調度器決定優先調度哪些線程運行,優先級高的線程理論上會獲得更多的處理器使用時間,但線程調度器並不保證一定按照線程優先級的高低來調度線程。此外, JVM 所在的操作系統可能會忽略甚至主動來修改我們對線程的優先級配置,且如果線程的優先級設置不當,甚至有可能導致線程永遠無法得到運行,即產生線程飢餓

如果在線程 A 中創建了線程 B,那麼線程 B 就稱爲線程 A 的子線程,線程 A 就稱爲線程 B 的父線程。由於 Java 虛擬機創建的 main 線程(主線程)負責執行 Java 程序的入口方法 main() 方法,因此 main 方法中創建的線程都是 main 線程的子線程或間接子線程。此外,一個線程是否是守護線程默認與其父線程相同,線程優先級的默認值也與其父線程的優先級相同。需要注意的是,雖然線程間具有這類父子關係,但是它們並不會相互影響對方的生命週期,一方線程生命週期的結束(不管是正常結束還是異常停止)並不影響另一個線程繼續運行

2、線程的方法

方法 說明
static Thread currentThread() 返回當前的執行線程對象
static void yield() 使當前線程主動放棄其處理器的佔用,但不一定會使當前線程暫停
static void sleep(long) 使當前線程休眠指定時間
void start() 啓動線程
void join() 若線程 A 調用線程 B 的 join() 方法,則線程 A 會暫停運行,直到線程 B 運行結束
void suspend() 暫停線程,進入睡眠狀態(已廢棄)
void resume() 喚醒線程,和 suspend() 成對使用(已廢棄)
void stop() 停止線程(已廢棄)

Thread.suspend()Thread.resume() 是 Thread 類提供的用於暫停和喚醒線程的方法,用於在某些運行條件不滿足的時候暫停執行任務,後續在運行條件滿足的時候喚醒線程繼續執行任務,但現在均已廢棄

3、線程的狀態

線程在它的整個生命週期內會先後處於不同狀態,也可能會在多個狀態間來回切換。對於給定的線程實例,可以使用 Thread.getState() 方法獲取線程的狀態,該方法返回 Thread.State 枚舉類型值,用於標明在調用該方法時線程所處的當前狀態,返回值包含以下幾種可能:

狀態
NEW 線程處於新建狀態,還沒有調用 start() 方法
RUNNABLE 線程要麼當前正在執行,要麼在獲得處理器時間片之後就可以執行
BLOCKED 線程因爲發起了阻塞式操作,或者正在等待需要的資源時被掛起
WAITING 線程因爲等待某些動作而掛起執行。例如,因爲調用了 wait() 或 join() 方法所以處於這種狀態,這種狀態下的線程需要由外部其它線程來喚醒自身
TIMED_WAITING 線程掛起一段指定的時間,當達到指定的時間到達後,線程就會轉爲 RUNNABLE 狀態。例如當調用 sleep(long) 、wait(long) 、 join(long) 等方法時就會處於這種狀態
TERMINATED 已經運行結束的線程就處於此狀態

可以用更加通俗的語言來描述線程的生命週期:

  1. 新建狀態-NEW

    使用 new 關鍵字建立一個線程對象後,線程就處於新建狀態,一個已創建但還未啓動的線程就處於此狀態。線程會保持這個狀態直到被調用 Thread.start() 方法

  2. 就緒狀態-RUNNABLE

    當線程對象調用了 start() 方法之後,線程就會進入就緒狀態。該狀態可以看成一個複合狀態,它包含兩個子狀態:READYRUNNING。前者表示線程處於就緒隊列中,當被線程調度器選中調度後就可以正式運行,變爲 RUNNING 狀態。後者表示線程正處於運行中,其 run() 方法對應的指令正在由處理器執行。當執行線程的 yiedId() 方法時,其狀態可能會由 RUNNING 切換爲 READY

  3. 阻塞狀態-BLOCKED

    當線程發起一個阻塞式的 IO 操作或者是在申請一個由其它線程持有的獨佔資源時,該線程就會處於此狀態。處理 BLOCKED 狀態的線程不會佔用 CPU 資源。當其目標行爲或者是目標資源被滿足後,就可以切換爲 RUNNABLE 狀態

  4. 無限期等待狀態-WAITING

    當線程的運行需要滿足某些執行條件而當前並不滿足時,通常就會通過讓該線程主動調用 Object.wait()Thread.join() 等類似方法將線程切換爲 WAITING 狀態。當前狀態是 WAITING 的線程處於暫停運行的狀態,需要外部其它線程通過 Object.notify() 等方法來主動喚醒該線程

  5. 限期等待狀態-TIMED_WAITING

    TIMED_WAITING 狀態和 WAITING 狀態類似。區別在於 TIMED_WAITING 狀態並非是線程本身完全無限制地進行等待,其等待行爲帶有指定時間範圍的限制,當在指定時間內沒有完成該線程所期望的特定操作時,該線程就會轉爲 RUNNABLE 狀態。可以通過 Object.wait(long) 方法來使線程切換爲 TIMED_WAITING 狀態

  6. 終止狀態-TERMINATED

    當一個線程完成自身任務或者由於其它原因被迫終止時,線程就會切換到終止狀態,至此線程的整個生命週期就結束了

由於一個線程在其整個生命週期內只能被啓動一次,所以線程也只會處於一次 NEW 狀態和一次 TERMINATED 狀態。對於一個多線程系統來說,最理想的情況就是所有已啓動且未結束的線程能一直處於 RUNNING 狀態,但這是不可能實現的。在現實場景下線程會在多個狀態間來回切換,且線程從 RUNNABLE 狀態轉換爲 BLOCKED、WAITING 和 TIMED_WAITING 這幾個狀態中的任何一個時都意味着發生了線程上下文切換

[站外圖片上傳中...(image-caa03a-1609259599700)]

4、線程組

線程組(ThreadGroup)用來表示一組相關聯的線程,它是 Thread 類包含的一個內部屬性,可以通過 Thread.getThreadGroup()來獲取該值。線程與線程組之間的關係類似於文件系統中文件與文件夾之間的關係,一個線程組可以包含多個線程以及其它線程組

如果在創建線程時沒有顯示指定線程所屬的線程組的話,在默認情況下線程就被歸類於其父線程所屬的線程組下。從 Thread 類的以下方法就可以看出來:

    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        ····

        Thread parent = currentThread();
        SecurityManager security = System.getSecurityManager();
        if (g == null) {
            /* Determine if it's an applet or not */

            /* If there is a security manager, ask the security manager
               what to do. */
            if (security != null) {
                //默認也是返回 Thread.currentThread().getThreadGroup()
                g = security.getThreadGroup();
            }

            /* If the security doesn't have a strong opinion of the matter
               use the parent thread group. */
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }
        ····
    
    }

Java 虛擬機在創建 main 線程(所有線程的父線程)的時候會爲其自動指定一個線程組,因此任何一個線程都有一個線程組與之相關聯。且一個線程組的父線程組默認是在聲明該線程組時所在線程的線程組,但並非所有線程組均有父線程組,最頂層線程組就不包含父線程組

/**
 * 作者:leavesC
 * 時間:2020/8/12 22:10
 * 描述:
 * GitHub:https://github.com/leavesC
 */
fun main() {
    val mainThreadGroup = Thread.currentThread().threadGroup
    println(mainThreadGroup)
    println("mainThreadGroup.parent: " + mainThreadGroup.parent)
    println("mainThreadGroup.parent.parent: " + mainThreadGroup.parent.parent)
    val thread = Thread()
    println(thread.threadGroup)
    val thread2 = Thread(ThreadGroup("otherThreadGroup"), "thread")
    println(thread2.threadGroup)
//    java.lang.ThreadGroup[name=main,maxpri=10]
//    mainThreadGroup.parent: java.lang.ThreadGroup[name=system,maxpri=10]
//    mainThreadGroup.parent.parent: null
//    java.lang.ThreadGroup[name=main,maxpri=10]
//    java.lang.ThreadGroup[name=otherThreadGroup,maxpri=10]
}

ThreadGroup 本身存在設計缺陷問題,目前的使用場景有限,日常開發中可以無需理會

5、線程異常捕獲

在很多時候,我們會通過創建一個線程池來執行任務,而當某個任務由於拋出異常導致其執行線程異常終止時,我們就需要對這種異常情況進行上報以便後續分析。要實現這個效果,就需要能夠收到線程被異常終止時的事件通知,這就需要用到 Thread.setUncaughtExceptionHandler(UncaughtExceptionHandler) 方法

通過該方法我們可以在異常發生時且線程被停止前獲取到相應的 Thread 對象和 Throwable 實例

/**
 * 作者:leavesC
 * 時間:2020/8/12 22:14
 * 描述:
 * GitHub:https://github.com/leavesC
 */
fun main() {
    val runnable = Runnable {
        for (i in 4 downTo 0) {
            println(100 % i)
            Thread.sleep(100)
        }
    }
    val thread = Thread(runnable, "otherName")
    thread.setUncaughtExceptionHandler { t, e ->
        println("threadName: " + t.name)
        println("exc: $e")
    }
    thread.start()
}
0
1
4
2
4
0
0
1
0
0
threadName: otherName
exc: java.lang.ArithmeticException: / by zero

ThreadGroup 本身也實現了 UncaughtExceptionHandler 接口,所以如果 Thread 對象不包含關聯的 UncaughtExceptionHandler 實例的話,則會將異常交由 ThreadGroup 來進行處理

從 Thread 類的以下邏輯就可以看出來

public class Thread implements Runnable {

    private ThreadGroup group;

    private volatile UncaughtExceptionHandler uncaughtExceptionHandler;

    private void dispatchUncaughtException(Throwable e) {
        getUncaughtExceptionHandler().uncaughtException(this, e);
    }

    public UncaughtExceptionHandler getUncaughtExceptionHandler() {
        return uncaughtExceptionHandler != null ?
            uncaughtExceptionHandler : group;
    }
    
}

ThreadGroup 默認情況下會將異常交由其父線程組進行處理,而對於不包含父線程組的線程組對象(頂層線程組),則會將異常交由 Thread 類的 defaultUncaughtExceptionHandler 進行處理。所以,我們可以通過 Thread 的靜態方法 setDefaultUncaughtExceptionHandler 方法來爲程序設置一個全局的默認異常處理器

public class ThreadGroup implements Thread.UncaughtExceptionHandler {

    public void uncaughtException(Thread t, Throwable e) {
        if (parent != null) {
            //當有父線程組時,則將異常交由父線程組來處理
            parent.uncaughtException(t, e);
        } else {
            //當父線程組不存在時,則嘗試將異常交由 DefaultUncaughtExceptionHandler 來處理
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) {
                ueh.uncaughtException(t, e);
            } else if (!(e instanceof ThreadDeath)) {
                System.err.print("Exception in thread \""
                                 + t.getName() + "\" ");
                e.printStackTrace(System.err);
            }
        }
    }
    
}

public class Thread implements Runnable {

    private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;
    
    public static UncaughtExceptionHandler getDefaultUncaughtExceptionHandler(){
        return defaultUncaughtExceptionHandler;
    }
    
    //設置全局默認的異常處理器
    public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkPermission(
                new RuntimePermission("setDefaultUncaughtExceptionHandler")
                    );
        }

         defaultUncaughtExceptionHandler = eh;
     }
    
}

所以,當線程由於異常而終止時,UncaughtExceptionHandler 實例的選擇優先級從高到低分別是:

  1. Thread.uncaughtExceptionHandler
  2. ThreadGroup.uncaughtExceptionHandler
  3. Thread.defaultUncaughtExceptionHandler

6、線程工廠

在項目中先後需要使用到多個線程是一個普遍的需求,而如果每次均簡單的通過 new Thread() 來創建線程的話,在出現問題時就很難定位問題所在。所以 Java 標準庫也提供了創建線程的工廠方法,即 ThreadFactory 接口

public interface ThreadFactory {

    Thread newThread(Runnable r);
    
}

ThreadFactory 提供了將要執行的任務 Runnable 與要創建的 Thread 相關聯的方法,即我們可以通過 ThreadFactory 來標明 Thread 要執行的具體任務、爲 Thread 設置一個有具體含義的名字、設置 Thread 的運行優先級等

例如,Executors 內部就包含了一個 DefaultThreadFactory,通過 threadNumber 自增的方式爲每一個創建的線程設置了特定的線程名、確保線程是用戶線程、確保線程的優先級爲正常級別

static class DefaultThreadFactory implements ThreadFactory {
        private static final AtomicInteger poolNumber = new AtomicInteger(1);
        private final ThreadGroup group;
        private final AtomicInteger threadNumber = new AtomicInteger(1);
        private final String namePrefix;

        DefaultThreadFactory() {
            SecurityManager s = System.getSecurityManager();
            group = (s != null) ? s.getThreadGroup() :
                                  Thread.currentThread().getThreadGroup();
            namePrefix = "pool-" +
                          poolNumber.getAndIncrement() +
                         "-thread-";
        }

        public Thread newThread(Runnable r) {
            Thread t = new Thread(group, r,
                                  namePrefix + threadNumber.getAndIncrement(),
                                  0);
            if (t.isDaemon())
                t.setDaemon(false);
            if (t.getPriority() != Thread.NORM_PRIORITY)
                t.setPriority(Thread.NORM_PRIORITY);
            return t;
        }
    }

而對於我們項目自己定義的線程池,使用 ThreadFactory 的一個比較有意義的用處是:爲線程設置關聯的 UncaughtExceptionHandler,這在提高系統的健壯性方面是很有好處的

7、線程的執行時機

我們可以簡單地理解爲運行一個線程就是要求 Java 虛擬機來調用 Runnable 對象的 run() 方法,從而使得線程的任務處理邏輯得以被執行。但我們通過 Thread.start() 啓動一個線程並不意味着線程就能夠馬上被執行,線程的具體執行時機由線程調度器來決定,執行時機具有不確定性,甚至可能會由於線程活性故障而永遠無法運行。此外,由於 run() 方法是 public 的,所以它也可以由外部主動來調用執行,但此時其任務就是由當前的運行線程來執行,這在大多數時候都是沒有實際意義的

而不管是通過什麼方式來創建線程,當線程的 run() 方法執行結束時(不管是正常結束還是由於異常而中斷運行),線程的生命週期也就走到末尾了,其佔用的資源會在後續被 Java 虛擬機垃圾回收。而且,線程是一次性資源,我們無法通過再次調用 start() 方法來重新啓動線程,當多次調用該方法時會拋出 IllegalThreadStateException

四、多線程安全

實現多線程編程不是簡單地聲明多個 Thread 對象並啓動就可以的了,在現實場景中,多個線程間往往是需要完成數據交換和行爲交互等各種複雜操作的,而不是簡單地“各行其是”。相比單線程,使用多線程會帶來許多在單線程下不存在或者根本不用考慮的問題

1、競態

先來看一個簡單的例子。假設存在一個商店 Shop,其初始商店數量爲零。存在四十個生產者 Producer 爲其生產商品,每個 Producer 會各自爲商店提供一個商品。那麼,理論上當所有 Producer 生產完畢後,Shop 的商品數量 goodsCount 應該是四十

可是,運行以下代碼後你會發現實際數量大概率是會少於四十

/**
 * 作者:leavesC
 * 時間:2020/8/3 22:18
 * 描述:
 * GitHub:https://github.com/leavesC
 */
class Shop(var goodsCount: Int) {

    fun produce() {
        goodsCount++
    }

}

class Producer(private val shop: Shop) : Thread() {

    override fun run() {
        sleep(100)
        if (shop.goodsCount < 40) {
            shop.produce()
        }
        println("over")
    }

}

fun main() {
    val shop = Shop(0)
    val threads = mutableListOf<Thread>()
    for (i in 1..40) {
        threads.add(Producer(shop))
    }
    threads.forEach {
        it.start()
    }
    //保證所有 Producer Thread 都執行完畢會再執行之後的語句
    threads.forEach {
        it.join()
    }
    println("shop.goodsCount: " + shop.goodsCount)
}

以上代碼就使用到了多個線程,單個 Producer 線程的行爲邏輯是獨立的,而多個 Producer 線程的行爲邏輯對於 Shop 來說是互相交錯且先後順序不確定的,這就有可能導致一種情況:兩個 Producer 同時判斷到當前商品數量是十,然後同時爲商店生產第十一件商品(shop.goodsCount++),最終就導致某個 Producer 生產的第十一號商品被另一個 Producer 覆蓋了,兩個 Producer 只生產了一件商品,即數據更新無效/更新丟失

shop.goodsCount++ 這條語句雖然看起來像是一個不可分割的操作(原子操作),但它實際上相當於如下僞代碼所表示的三個指令的組合:

load(shop.goodsCount , r1) //指令1,將變量 shop.goodsCount 的值從內存讀到寄存器 r1
increment(r1) //指令2,將寄存器 r1 的值加1
store(shop.goodsCount , r1) //指令3,將寄存器 r1 的內容寫入變量 shop.goodsCount 所對應的內存空間

多個 Producer 線程可能會同時各自執行上述指令。例如,假設當前 goodsCount 是十,Producer1 和 Producer2 同時執行到指令1,兩個線程將 goodsCount 讀到各自處理器的寄存器上,即每個線程會各自擁有一份副本數據,然後對各自寄存器的值進行自增加一的操作,當執行到指令三時,由於 goodsCount 所在的內存空間是特定的,所以兩個 Producer 線程對內存空間上 goodsCount 的值的回傳會存在相互覆蓋的情況。即原本最終結果應該是遞增加二的行爲最終卻只有遞增加一

以上是多線程編程中經常會遇到的一個現象,即競態。競態是指計算的正確性依賴於相對時間順序或者線程的交錯。競態並不一定就會導致計算結果不正確,它只是不排除計算結果時而正確時而錯誤的可能


從上述代碼中可以總結出競態的兩種模式:read-modify-write(讀-改-寫)check-then-act(檢測後行動)

read-modify-write 的步驟即:讀取一個共享變量的值,然後根據該值進行一些計算、接着根據計算結果更新共享變量的值。例如,上述代碼中的 goodsCount++ 就是這種模式,相當於如下僞代碼所表示的三個指令的組合

load(shop.goodsCount , r1) //指令1,將變量 shop.goodsCount 的值從內存讀到寄存器 r1
increment(r1) //指令2,將寄存器 r1 的值加1
store(shop.goodsCount , r1) //指令3,將寄存器 r1 的內容寫入變量 shop.goodsCount 所對應的內存空間

線程 A 在執行完指令1,開始執行或者正在執行指令2時,線程 B 可能已經執行完了指令3,這使得線程 A 當前持有的共享變量 shop.goodsCount 是舊值,當線程 A 執行完指令3時,這就使得線程 B 對共享變量的更新被覆蓋了,即造成了更新丟失


check-then-act 的步驟即:讀取一個共享變量的值,根據該變量的值決定下一步的動作是什麼。例如,以下代碼就是這種模式

if (shop.goodsCount < 40) { //操作1
    shop.produce() //操作2
}

線程 A 在執行完操作1,開始執行操作2之前,線程 B 可能已經更新了共享變量 shop.goodsCount 的值導致 if 語句中的條件變爲不成立,可此時線程 A 依然會執行操作2,這是因爲線程 A 此時並不知道共享變量已經被更新且導致運行條件不成立了

從上述分析中我們可以總結出競態產生的一般條件。設 Q1 和 Q2 是併發訪問共享變量 V 的兩個操作,這兩個操作並非都是讀操作。如果一個線程在執行 Q1 期間另外一個線程同時執行 Q2,那麼無論 Q2 是操作還是寫操作都會導致競態。從這個角度來看,競態可以看做是由於訪問(讀取、更新)同一組共享變量的多個線程所執行的操作被相互交錯而導致的。而上述代碼中遇到的更新丟失讀到髒數據問題就是由於競態的存在而導致的

需要注意的是,競態的產生前提是涉及到了多個線程和共享變量。如果系統僅包含單個線程,或者不涉及共享變量,那麼就不會產生競態。對於局部變量(包括形式參數和方法體內定義的變量),由於不同的線程訪問的是各自的那一份局部變量,因此局部變量的使用不會導致競態

2、線程安全性

如果一個類在單線程環境下能夠正常運行,並且在多線程環境下也不需要考慮運行時環境下的調度和交替執行,使用方也不必爲其做多任何操作也能正常運行,那麼我們就說該類是線程安全的,即這個類具有線程安全性。反之,如果一個類在單線程環境下正常運行而在多線程環境下無法正常運行,那麼這個類就是非線程安全的。所以,只有非線程安全的類纔會導致競態

如果一個類不是線程安全的,我們就說它在多線程環境下存在多線程安全問題。以上定義也適用於多個線程間的共享數據

多線程安全問題概括來說表現爲三個方面:原子性、可見性、有序性

1、原子性

原子的字面意思即不可分割。對於涉及共享變量訪問的操作,若該操作從其執行線程以外的其它任意線程來看是不可分割的,那麼該操作就是原子操作,相應的就稱該操作具有原子性。所謂“不可分割”,是指訪問(讀、寫)某個共享變量的操作從其執行線程以外的任何線程來看,該操作要麼已經完成,要麼尚未發生,其它線程不會看到該操作執行了一部分的中間效果

例如,假設存在一個共享的全局變量 Shop 對象,其存在一個 update() 方法。當線程 A 執行 update() 方法時,在線程 A 執行完語句1之後而未執行語句2之前,此時線程 B 就會看到 goodsCount 已遞增加一而 clerk 還未遞增加一的這樣一箇中間效果。此時,我們就說 update() 方法作爲一個整體不具備原子性

class Shop(var goodsCount: Int, var clerk: Int) {

    fun update() {
        goodsCount++ //語句1
        clerk++ //語句2
    }

}

理解原子操作這個概念還需要注意以下兩點:

  • 原子操作是針對共享變量的操作而言的,僅涉及局部變量訪問的操作無所謂是否是原子性,或者可以直接將其看作成原子操作
  • 原子操作是從該操作的執行線程以外的其它線程的視角來描述的,也就是說它只在多線程環境下才有意義,所以可以將單線程環境下的所有操作均當做原子操作

總的來說,Java 中有兩種方式來提供原子性:

  • 第一種是使用鎖(Lock)。鎖具有排他性,它能夠保障共享變量在任意一個時刻只能夠被一個線程訪問。這就排除了多個線程在同一時刻訪問同一個共享變量而導致干擾與衝突的可能,從而消除了競態
  • 第二種是利用處理器提供的 CAS 指令。CAS 指令實現原子性的方式與鎖在本質上是相同的,差別在於鎖通常是在軟件這一層面實現的,而 CAS 是直接在硬件(處理器和內存)這一層次實現的,可以被看做“硬件鎖”

Java 語言規範規定了:在 Java 語言中,64 位以外的任何類型的變量的讀寫操作都是原子操作。而對於 long 和 double 等 64 位的數據類型的讀寫操作並不強制規定 Java 虛擬機必須保證其原子性,可以由 Java 虛擬機自己選擇是否要實現。因此在多線程併發讀寫同一 long/double 型共享變量的情況下,一個線程可能會讀取到其它線程更新該變量的“中間結果”。而之所以會有中間結果,是因爲對於 64 位的存儲空間的寫操作,虛擬機可能會將其拆解爲兩個步驟來實現,比如先寫低 32 位再寫高 32 位,從而導致外部線程讀取到一箇中間結果值。但這個問題也不需要特意關注,因爲目前商用 Java 虛擬機幾乎都選擇將 64 位數據的讀寫操作實現爲原子操作。此外,Java 語言規範也特別規定了用 volatile 關鍵字修飾的 long/double 型變量的讀寫操作具有原子性。

需要注意的是, volatile 關鍵字僅能保障變量讀寫操作的原子性,但並不能保障其它操作(例如 read-modify-write 、check-then-act)的原子性

2、可見性

在多線程環境下,一個線程對某個共享變量進行更新之後,後續訪問該變量的其它線程可能無法立即讀取到這個更新的結果,甚至永遠也無法讀取到,這體現了多線程安全性問題中的一個:可見性。可見性是指一個線程對共享變量的更新結果對於其它讀取相應共享變量的線程而言是否可見的問題。多線程程序在可見性方面存在問題意味着某些線程讀取到了舊數據,而這往往會導致我們的程序出現意想不到的問題

會存在可見性問題。一方面是由於 JIT 編譯器可能出於提高代碼運行效率考慮而自動對代碼進行一些“優化”,使得共享變量更新失效。一方面是由於處理器並不是直接對主內存中的共享變量進行訪問,而是會各自在自己的高速緩存上保留着對共享變量的一份副本,處理器直接訪問的是副本數據,對副本數據的修改需要同步回主內存後纔可以對其它處理器可見。所以一個處理器對共享變量的更新結果並不一定能立即同步到其它處理器上,這就導致了可見性問題的出現

對於同一個共享變量而言,一個線程更新了該變量的值之後,如果其它線程能夠讀取到這個更新後的值,那麼這個值就被稱爲該變量的相對新值。如果讀取這個共享變量的線程在讀取並使用該變量的時候其它線程無法更新該變量的值,那麼該線程讀取到的值就被稱爲該變量的最新值。可見性的保障僅僅意味着一個線程能夠讀取到共享變量的相對新值,而並不意味着線程能夠讀取到相應變量的最新值

可見性問題是由於使用了多線程所導致的,它與當前是單核處理器還是多核處理器無關。在單核處理器下,多線程併發是通過時間片分配技術來實現的,此時雖然多個線程都是運行在同個處理器上,但是由於在上下文切換的時候,一個線程對共享變量的修改會被當做其上下文信息保存起來,這也會導致另外一個線程無法立即讀取到該線程對共享變量的修改

3、有序性

在說有序性之前,需要先介紹下重排序

順序結構是結構化編程中的一種基本結構,它表示我們希望某個操作必須先於另外一個操作得以執行,但是在多核處理器環境下,這種操作執行順序可能是沒有保障的。編譯器和處理器可以在保證不影響單線程執行結果的前提下,對源代碼的指令進行重新排序執行,處理器可能不是完全依照程序的目標代碼所指定的順序來執行指令。另外,一個處理器上執行的多個操作,從其它處理器的角度來看其順序可能與目標代碼所指定的順序不一致。這種現象就叫做重排序。重排序是對內存訪問有關的操作(讀和寫)所做的一種優化,它可以在不影響單線程程序正確性的情況下提升程序的性能。但是,它可能對多線程程序的正確性產生影響,即它可能導致線程安全問題

重排序分爲以下幾種:

  • 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序
  • 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序
  • 內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行

有序性指的就是在什麼情況下一個處理器上運行的線程所執行的內存訪問順序在另外一個處理器上運行的其它線程看來是亂序的。所謂亂序,是指內存訪問操作的順序看起來是發生了變化

3、不安全的線程安全類

上文有提到如何定義一個類是否是線程安全的,Java 也提供了很多被稱爲線程安全的類,例如 java.util.Vector。Vector 類內部的 add()removeAt()size() 等很多方法都使用了 synchronize 進行修飾,保證了在多線程環境下的安全性,但這種同步保障也無法阻止開發者在邏輯層面上寫出不安全的代碼

例如,對於以下代碼。即使 add()removeAt() 兩個方法由於 Vector 類內部的同步處理,保障了兩個方法一定是串行執行的,但由於方法調用端缺少了額外的同步處理,導致調用端可能會讀取到一個過時的 vector.size 值,最終導致索引越界拋出 ArrayIndexOutOfBoundsException

所以說,線程安全類可能只是保障了其自身單次操作行爲的線程安全性,使得我們在調用的時候不需要進行額外的同步保障。但對於使用方的一些特定順序的連續調用,就還是需要在外部實現額外的同步手段來保證調用的正確性,否則就有可能用線程安全類寫出不安全的代碼

/**
 * 作者:leavesC
 * 時間:2020/8/26 22:52
 * 描述:
 * GitHub:https://github.com/leavesC
 */
private val vector = Vector<Int>()

private val threadNum = 5

fun main() {
    val addThreadList = mutableListOf<Thread>()
    for (i in 1..threadNum) {
        val thread = object : Thread() {
            override fun run() {
                for (item in 1..10) {
                    vector.add(item)
                }
            }
        }
        addThreadList.add(thread)
    }
    val printThreadList = mutableListOf<Thread>()
    for (i in 1..threadNum) {
        val thread = object : Thread() {
            override fun run() {
                for (index in 1..vector.size) {
                    vector.removeAt(i)
                }
            }
        }
        printThreadList.add(thread)
    }
    addThreadList.forEach {
        it.start()
    }
    printThreadList.forEach {
        it.start()
    }
    addThreadList.forEach {
        it.join()
    }
    printThreadList.forEach {
        it.join()
    }
}

4、上下文切換

併發的實現和是否擁有多個處理器無關,即使只有單個處理器也能夠通過處理器時間片分配技術來實現併發。操作系統通過給每個線程分配一小段佔有處理器使用權的時間來供其運行,然後在每個線程的運行時間結束後又快速切換到下一個線程來運行,多個線程以這種斷斷續續的方式來實現併發並完成各自的任務。一個線程被剝奪處理器的使用權並暫停運行的過程就被稱爲切出,被線程調度器選中來佔用處理器並運行的過程就被稱爲切入

操作系統會分出一個個時間片,每個線程每次運行會分配到若干個時間片,時間片決定了一個線程可以連續佔用處理器運行的時間長度,一般是隻有幾十毫秒,單處理器上的多線程就是通過這種時間片分配的方式來實現併發。當一個進程中的一個線程由於其時間片用完或者由於其自身的原因被迫或者主動暫停其運行時,另外一個線程(當前進程中的線程或者其它進程中的線程)就可以被線程調度器選中來佔用處理器並開始運行。這種一個線程被剝奪處理器的使用權並暫停運行,另外一個線程被賦予處理器的使用權並開始運行的過程就稱爲線程上下文切換

線程上下文切換是處理器個數遠小於系統所需要支持的併發線程數的現實場景下的必然產物。這也意味着在線程切出和切入的時候操作系統需要保存和恢復相應線程的進度信息,即需要保存切入和切出那一刻相應線程所執行的指令進行到什麼哪一步了。這個進度信息就被稱爲上下文

線程的生命週期狀態在 RUNNABLE 狀態與非 RUNNABLE 狀態之間切換的過程就是上下文切換的過程。當被暫停的線程被操作系統選中獲得繼續運行的機會時,操作系統會恢復之前爲該線程保存的上下文,以便其在此基礎上繼續完成其任務

按照導致上下文切換的因素來劃分,可以將上下文切換劃分爲自發性上下文切換非自發性上下文切換

  • 自發性上下文切換。這種情況是線程由於其自身原因導致的切出。從 Java 平臺的角度來看,一個線程在其運行過程中執行了以下任何一個操作都會引起自發性上下文切換
    • Thread.sleep()
    • Object.wait()
    • Thread.yieid()
    • Thread.join()
    • LockSupport.park()
    • I/O 操作
    • 等待被其它線程持有的鎖
  • 非自發性上下文切換。指線程由於線程調度器的原因被迫切出。這種情況往往是由於被切出的線程的時間片用完,或者有一個比被切出線程更高優先級的線程需要運行。此外,Java 虛擬機的垃圾回收動作也可能導致非自發性上下文切換,這是因爲垃圾回收器在執行 GC 的過程中可能需要暫停所有應用線程才能完成

系統在一段時間內產生的上下文切換次數越多,由此導致的處理器資源消耗也就越多,相應的這段時間內真正能夠用於執行目標代碼的處理器資源就越少,因此我們也需要考慮儘量減少上下文切換的次數,這在後續文章中會介紹

五、線程調度

線程調度是指操作系統爲線程分配處理器使用權的過程。主要的調度方式有兩種:

  • 協同式線程調度。在這種策略下,線程的執行時機由線程本身來決定,線程通過主動通知系統切換到另一個線程的方式來讓出處理器的使用權。該策略的優點是實現簡單,可以通過精準控制線程的執行順序來避免線程安全性問題。缺點是可能會由於單個線程的代碼缺陷問題導致無法切換到下一個線程,最終導致進程被阻塞
  • 搶佔式線程調度。這也是 Java 平臺使用的線程調度策略。在這種策略下,由操作系統來決定當前處理器時間片交由哪個線程來使用,線程無法決定具體的運行時機和運行順序。雖然我們可以通過 Thread.yieid() 方法來讓出時間片,但是無法主動搶奪時間片,且雖然 Thread 類也提供了設置線程優先級的方法,但線程的具體執行順序還是取決於其運行系統。該策略的優點是不會由於一個線程的問題導致整個進程被阻塞,且提高了併發性。缺點是實現較爲複雜,且會帶來多線程安全性問題

六、資源爭用和資源調度

1、資源爭用

一次只能被一個線程佔用的資源稱爲排他性資源。常見的排他性資源包括鎖、處理器、文件等。由於資源的稀缺性或者資源本身的特性,我們往往需要在多個線程間共享同一個排他性資源。當一個線程佔用一個排他性資源而未釋放其對資源的所有權時,存在其它線程同時試圖訪問該資源的現象就被稱爲資源爭用,簡稱爭用。顯然,爭用是在併發環境下產生的一種現象,同時試圖訪問一個已經被其它線程佔用的排他性資源的線程數量越多,爭用的程度就越高,反之爭用的程度就越低。相應的爭用就被稱爲高爭用和低爭用

同一時間段內,處於運行狀態的線程數量越多,我們就稱併發的程度越高,簡稱高併發。雖然高併發加大了爭用的可能性,但是高併發未必就意味着高爭用,因爲線程並非就是一定會在某個時刻來一起申請資源,資源的申請操作對於多個線程來說可能是交錯開的,或者每個線程持有排他性資源的時間很短。多線程編程的理想情況就是高併發、低爭用

2、資源調度

當多個線程同時申請同一個排他性資源,申請資源失敗的線程往往是會存入一個等待隊列中,當後續資源被其持有線程釋放時,如果剛好有一個活躍線程來申請資源,此時選擇哪一個線程來獲取資源的獨佔權就是一個資源調度的過程,資源調度策略的一個重要屬性就是能否保證公平性。所謂公平性,是指資源的申請者是否嚴格按照申請順序而被授予資源的獨佔權。如果資源的任何一個先申請者總是能夠被比任何一個後申請者先獲得資源的獨佔權,那麼該策略就被稱爲公平調度策略。如果資源的後申請者可能比先申請者先獲得資源的獨佔權,那麼該策略就被稱爲非公平調度策略。注意,非公平調度策略往往只是不保證資源調度的公平性,即它只是允許不公平的資源調度現象,而不是表示它刻意造就不公平的資源調度

公平的資源調度策略不允許插隊現象的出現,資源申請者總是按照先來後到的順序獲得資源的獨佔權。如果當前等待隊列爲空,則來申請資源的線程可以直接獲得資源的獨佔權。如果等待隊列不爲空,那麼每個新到來的線程就被插入等待隊列的隊尾。公平的資源調度策略的優點是:每個資源申請者從開始申請資源到獲得相應資源的獨佔權所需時間的偏差會比較小,即每個申請者成功申請到資源所需的時間基本相同,且可以避免出現線程飢餓現象。缺點是吞吐率較低,爲了保證 FIFO 加大了發生線程上下文切換的可能性

非公平的資源調度策略則允許插隊現象。新到來的線程會直接嘗試申請資源,只有當申請失敗時纔會將線程插入等待隊列的隊尾。假設兩種多個線程一起競爭同一個排他性資源的場景:

  1. 當資源被釋放時,如果剛好有一個活躍線程來申請資源,該線程就可以直接搶佔到資源,而無需去喚醒等待隊列中的線程。這種場景相對公平調度策略就少了將新到來的線程暫停將等待隊列隊頭的線程喚醒的兩個操作,而資源也一樣有被得到使用
  2. 即使等待隊列中的某個線程已經被喚醒來試圖搶佔資源的獨佔權,如果新到來的活躍線程佔用資源的時間不長的話,那麼就有可能在被喚醒的線程開始申請資源之前,新到來的活躍線程已經釋放了對資源的獨佔權,從而不妨礙被喚醒的線程申請資源。這種場景也一樣避免了將新到來的線程暫停這麼一個操作

因此,非公平調度策略的優點主要有兩點:

  1. 吞吐率一般來說會比公平調度策略高,即單位時間內它可以爲更多的申請者調配資源
  2. 降低了發生上下文切換的概率

非公平調度策略的缺點主要有兩點:

  1. 由於允許插隊現象,極端情況下可能導致等待隊列中的線程永遠也無法獲得其所需的資源,即出現線程飢餓的活性故障現象
  2. 每個資源申請者從開始申請資源到獲得相應資源的獨佔權所需時間的偏差可能較大,即有的線程可能很快就能申請到資源,而有的線程則要經歷若干次暫停與喚醒才能成功申請到資源

綜上所訴,公平調度策略適用於資源被持有的時間較長或者線程申請資源的平均時間間隔較長的情形,或者要求申請資源所需的時間偏差較小的情況。總的來說,使用公平調度策略的開銷會比使用非公平調度策略的開銷要大,因此在沒有特別需求的情況下,應該默認使用非公平調度策略

七、參考的書籍

《Java 多線程編程實戰指南(核心篇)》

《深入理解 Java 虛擬機》

《Java 併發編程的藝術》

一個人走得快,一羣人走得遠,寫了文章就只有自己看那得有多孤單,只希望對你有所幫助😂😂😂

查看更多文章請點擊關注:字節數組

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