Java 併發編程解析 | 關於Java領域中的線程機制,我們應該知道的那些事?

蒼穹之邊,浩瀚之摯,眰恦之美; 悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》

寫在開頭

我們都知道,經過多年的發展和無數Java開發者的不懈努力,Java已經由一門單純的計算機編程語言,逐漸演變成一套強大的以及仍在可持續發展中的技術體系平臺。

雖然,Java設計者們根據不同的技術規範,把Java劃分爲3種結構獨立且又彼此依賴的技術體系,分別是Java SE,Java EE 以及Java ME,其中Java EE 在廣泛應用在企業級開發領域中。

除了包括Java API組件外,其衍生和擴充了Web組件,事務組件,分佈式組件,EJB組件,消息組件等,並且持續發展到如今,其中,雖然有許多組件現如今不再適用,但是許多組件在我們日常開發工作中,扮演着同樣重要的角色和依舊服務着我們日新月異的業務需求。

綜合Java EE的這些技術,我們可以根據我們的實際需要和滿足我們的業務需求的情況下,可以快速構建出一個具備高性能,結構嚴謹且相對穩定的應用平臺,雖然現在雲原生時代異軍突起許多基於非Java的其他技術平臺,但是在分佈式時代,Java EE是用於構建SOA架構的首先平臺,甚至基於SpringCloud構建微服務應用平臺也離不開Java EE 的支撐。

個人覺得,Java的持續發展需要感謝Google,正是起初Google將Java作爲Android操作系統的應用層編程語言,使得Java可以在PC時代和移動互聯網時代得到快速發展,可以用於手持設備,嵌入式設備,個人PC設備,高性能的集羣服務器和大型機器平臺。

當然,Java的發展也不是一帆風順的,也曾被許多開發者詬病和嫌棄,但是就憑Java在行業裏能否覆蓋的場景來說,對於它的友好性和包容性,這不由讓我們心懷敬意。其中,除了Java有豐富的內置API供我們使用外,尤其Java對於併發編程的支持,也是我們最難以釋懷的,甚至是我們作爲Java開發者最頭疼的問題所在。

雖然,併發編程這個技術領域已經發展了半個世紀了,相關的理論和技術紛繁複雜。那有沒有一種核心技術可以很方便地解決我們的併發問題呢?今天,我們就來一起走進Java領域的併發編程的核心——Java線程機制。

基本概述

在Java中,對於Java語言層面的線程,我們基本都不會太陌生,甚至耳熟能詳。但是在此之前,我們先來探討一下,什麼是管程技術?Java 語言在 1.5 之前,提供的唯一的併發原語就是管程,而且 1.5 之後提供的 SDK 併發包,也是以管程技術爲基礎的。除此之外,其中C/C++、C# 等高級語言也都支持管程。

關於管程

管程(Monitor)是指定義了一個數據結構和能爲併發進程所執行的一組操作,這組操作能同步進程和改變管程中的數據。主要是指提供了一種機制,線程可以臨時放棄互斥訪問,等待某些條件得到滿足後,重新獲得執行權恢復它的互斥訪問。

所謂管程,指的是管理共享變量以及對共享變量的操作過程,讓他們支持併發。翻譯爲 Java 領域的語言,就是管理類的成員變量和成員方法,讓這個類是線程安全的。

基本定義

首先,系統中的各種硬件資源和軟件資源均可用數據結構抽象地描述其資源特性,即用少量信息和對該資源所執行的操作來表徵該資源,而忽略它們的內部結構和實現細節。

其次,可以利用共享數據結構抽象地表示系統中的共享資源,並且將對該共享數據結構實施的特定操作定義爲一組過程。進程對共享資源的申請、釋放和其它操作必須通過這組過程,間接地對共享數據結構實現操作。

然後,對於請求訪問共享資源的諸多併發進程,可以根據資源的情況接受或阻塞,確保每次僅有一個進程進入管程,執行這組過程,使用共享資源,達到對共享資源所有訪問的統一管理,有效地實現進程互斥。

最後,代表共享資源的數據結構以及由對該共享數據結構實施操作的一組過程所組成的資源管理程序共同構成了一個操作系統的資源管理模塊,我們稱之爲管程,管程被請求和釋放資源的進程所調用。

綜上所述,管程(Monitor)是指定義了一個數據結構和能爲併發進程所執行的一組操作,這組操作能同步進程和改變管程中的數據。主要是指提供了一種機制,線程可以臨時放棄互斥訪問,等待某些條件得到滿足後,重新獲得執行權恢復它的互斥訪問。

基本組成

由上述的定義可知,管程由四部分組成:

  • 管程的名稱;
  • 局部於管程的共享數據結構說明;
  • 對該數據結構進行操作的一組過程;
  • 對局部於管程的共享數據設置初始值的語句

實際上,管程中包含了面向對象的思想,它將表徵共享資源的數據結構及其對數據結構操作的一組過程,包括同步機制,都集中並封裝在一個對象內部,隱藏了實現細節。

封裝於管程內部的數據結構僅能被封裝於管程內部的過程所訪問,任何管程外的過程都不能訪問它;反之,封裝於管程內部的過程也僅能訪問管程內的數據結構。

所有進程要訪問臨界資源時,都只能通過管程間接訪問,而管程每次只准許一個進程進入管程,執行管程內的過程,從而實現了進程互斥。

基本特點

管程是一種程序設計語言的結構成分,它和信號量有同等的表達能力,從語言的角度看,管程主要有以下特點:

  • 模塊化,即管程是一個基本程序單位,可以單獨編譯;
  • 抽象數據類型,指管程中不僅有數據,而且有對數據的操作;
  • 信息屏蔽,指管程中的數據結構只能被管程中的過程訪問,這些過程也是在管程內部定義的,供管程外的進程調用,而管程中的數據結構以及過程(函數)的具體實現外部不可見。
基本模型

在管程的發展史上,先後出現過三種不同的管程模型,分別是:Hasen 模型、Hoare 模型和 MESA 模型。其中,現在廣泛應用的是 MESA 模型,並且 Java 管程的實現參考的也是 MESA 模型。

接下來,我們就針對幾種管程模型分別來簡單的說明一下,它們之間的區別。

假設有這樣一個進程同步機制中的問題:如果進程P1因x條件處於阻塞狀態,那麼當進程P2執行了x.signal操作喚醒P1後,進程P1和P2此時同時處於管程中了,這是不被允許的,那麼如何確定哪個執行哪個等待?

一般來說,我們都會採用如下兩種方式來進行處理:

  • 第一種方式:假如進程 P2進行等待,直至進程P1離開管程或者等待另一個條件
  • 第二種方式:假如進程 P1進行等待,直至進程P2離開管程或者等待另一個條件

綜上所述,三種不同的管程模型採取的方式如下:

1.Hasen 模型

Hansan管程模型,採用了基於兩種的折中處理。主要是規定管程中的所有過程執行的signal操作是過程體的最後一個操作,於是,進程P2執行完signal操作後立即退出管程,因此進程P1馬上被恢復執行。

2.Hoare 模型

Hoare 管程模型,採用第一種方式處理。只要進程 P2進行等待,直至進程P1離開管程或者等待。

3.MESA 模型

MESA 管程模型,採用第二種方式處理。只要進程 P1進行等待,直至進程P2離開管程或者等待。

基本實現

在併發編程領域,有兩大核心問題:互斥和同步。其中:

  • 互斥(Mutual Exclusion),即同一時刻只允許一個線程訪問共享資源
  • 同步(Synchronization),即線程之間如何通信、協作

這兩大問題,管程都是能夠解決的。主要是由於信號量機制是一種進程同步機制,但每個要訪問臨界資源的進程都必須自備同步操作wait(S)和signal(S)。

這樣大量同步操作分散到各個進程中,可能會導致系統管理問題和死鎖,在解決上述問題的過程中,便產生了新的進程同步工具——管程。其中:

  • 信號量(Semaphere):操作系統提供的一種協調共享資源訪問的方法。和用軟件實現的同步比較,軟件同步是平等線程間的的一種同步協商機制,不能保證原子性。而信號量則由操作系統進行管理,地位高於進程,操作系統保證信號量的原子性。

  • 管程(Monitor):解決信號量在臨界區的 PV 操作上的配對的麻煩,把配對的 PV 操作集中在一起,生成的一種併發編程方法。其中使用了條件變量這種同步機制。

綜上所述,這也是Java中,最常見的鎖機制的實現方案,即最典型的實現就是ReenTrantLock爲互斥鎖(Mutex Lock) 和synchronized 爲同步鎖(Synchronization Lock)。

具體表現

熟悉Java中synchronized 關鍵詞的都應該知道,它是Java語言爲開發者提供的同步工具,主要用來解決多線程併發執行過程中數據同步的問題,主要有wait()、notify()、notifyAll() 這三個方法。其中,最關鍵的實現是,當我們在代碼中聲明synchronized 之後,其被聲明部分代碼編譯之後會生成一對monitorenter和monitorexit指令來指定某個同步塊。

在JVM執行指令過程中,一般當遇到monitorenter指令表示獲取互斥鎖時,而當遇到monitorexit指令表示要釋放互斥鎖,這就是synchronized在Java層面實現同步機制的過程。除此之外,如果是獲取鎖失敗,則會將當前線程放入到阻塞讀隊列中,當其他線程釋放鎖時,再通知阻塞讀隊列中的線程去獲取鎖。

由此可見,我們可以知道的是,synchronized 代碼塊是由一對 monitorenter/monitorexit 指令實現的,Monitor 對象是同步的基本實現單元。

準確的說,JVM一般通過Monitor來實現monitorenter和monitorexit指令,而且Monitor 對象包括一個阻塞隊列和一個等待隊列。其中,阻塞隊列用來保存鎖競爭失敗的線程,並且它處於阻塞狀態,而等待隊列則用來保存synchronized 代碼塊中調用wait方法後放置的隊列,其調用wait方法後會通知阻塞隊列。

當然,在 Java 6 之前,Monitor 的實現完全是依靠操作系統內部的互斥鎖,因爲需要進行用戶態到內核態的切換,所以同步操作是一個無差別的重量級操作。

這並不意味着,Java是提供信號量這種編程原語來支持解決併發問題的,雖然在《操作系統原理》中,我們知道用信號量能解決所有併發問題,但是在Java中並不是這樣的。

其實,最根本的原因,就是Java 採用的是管程技術,synchronized 關鍵字及 wait()、notify()、notifyAll() 這三個方法都是管程的組成部分。而管程和信號量是等價的,所謂等價指的是用管程能夠實現信號量,也能用信號量實現管程。

特別指出的是,相對於synchronized來說,ReentrantLock主要有以下幾個特點:

  • 從鎖獲取粒度上來看,比synchronized較爲細,主要表現在是鎖的持有是以線程爲單位而不是基於調用次數。
  • 從線程公平性上來看,ReentrantLock 可以設置公平性(fairness),能減少線程“飢餓”的發生。
  • 從使用角度上來看,ReentrantLock 可以像普通對象一樣使用,所以可以利用其提供的各種便利方法,進行精細的同步操作,甚至是實現 synchronized 難以表達的用例。
  • 從性能角度上來看,synchronized 早期的實現比較低效,對比 ReentrantLock,大多數場景性能都相差較大。雖然在 Java 6之後 中對其進行了非常多的改進,但在高競爭情況下,ReentrantLock 仍然有一定優勢。

綜上所述,我我相信你對Java中的管程技術已經有了一個明確的認識。接下來,我們便來進入今天的主題——Java線程機制。

關於線程

在早期的操作系統中,執行任務被抽象爲進程(Process)。其中,進程是操作系統運行和調度的基本單元。

隨着計算機技術的不斷髮展,由於進程開銷資源較大,以進程爲調度單位的方式逐漸產生弊端。因此,計算機先進工作者(科學家)們在進程的基礎上,提出了線程(Thead)的概念。

線程是進程中的運行單位,可以把線程看作輕量級的進程。計算機CPU會按照某種策略爲每一個線程分配一定的時間片去執行。

進程是指程序的一次動態執行過程,計算機中正在執行的程序就是進程,每一個程序都對對應着各自的一個進程。

一個進程包含了從代碼加載完畢到執行完成的一個完成過程,是操作系統中資源分配的最小單位。

線程是比進程更小的執行單元,是計算機CPU調度和分配的基本單位。

每一個進程都會至少包含一個線程,而一個線程只屬於一個進程。

每一個進程都有自己的資源,一個進程內的所有線程都共享這個進程所包含的資源。

每一個線程可以對所屬進程的所有資源進行調度和運算,其中,線程可以是操作系統內核來控制調度,也可以是由用戶程序來控制調度。

基本定義

現代計算機,從組成部分上來看,大體可以分爲硬件和軟件兩個部分。硬件是基礎,而軟件是運行在硬件之上的程序。

其中,軟件可以分爲操作系統和應用程序:

  • 操作系統(Operation System):專注於對硬件的支持和交互管理並提供一個運行環境給應用程序使用
  • 應用程序(Application Program):能實現若干功能且運行在操作系統中的軟件

由於線程可以由操作系統內核和用戶程序來控制調度,因此按照操作系統和應用程序兩個層次來分類。

線程可以主要分爲內核線程和 用戶線程(應用線程)兩類,其中:

  • 內核線程(Kernel Thread):由操作系統內核支持和管理的線程,內核線程的創建,啓動,同步,銷燬,切換等均由操作系統完成。
  • 用戶(應用線程,Applciation Thread)線程(User Thread) :用戶(應用)線程的管理工作在用戶(應用)空間完成,它完全建立在用戶(應用)空間的線程庫上,由內核支持但不由內核管理,內核也無法感知用戶線程的存在。用戶(應用)線程的創建,啓動,同步,銷燬,切換等均在在用戶(應用)空間完成,不用切換到內核。

從Java領域來看,Java語言編譯後的字節碼(Byte Code) 運行在JVM (Java 虛擬機)上,其中JVM其實是一個進程,所以Java屬於應用程序層。

我們都知道,Java的線程類爲:java.lang.Thread,當任務不能在當前線程中執行時,我們就會去創建一個Thread對象。

我們在Java層通過new 關鍵字創建一個Thread對象,然後調用start()方法啓動該線程,那麼從線程的角度來看,主要可以分爲:

  • Java應用程序層線程(Java Application Thread ):主要是Java語言編程的程序創建的Thread線程對象,屬於用戶空間
  • Java虛擬機層線程(Java JVM Thread ):主要是Java虛擬機中包含且支持和管理的線程,屬於用戶空間,
  • 操作系統層線程(OS Thread):根據操作系統的實際情況而定的抽象表示,主要是看操作系統和庫是否支持和管理的線程,一般Linux主要通過pthread庫來實現,早期版本不支持。

其中,在Hotspot JVM 中的 Java 線程與原生操作系統線程有直接的映射關係。當線程本地存儲、緩衝區分配、同步對象、棧、程序計數器等準備好以後,就會創建一個操作系統原生線程。

Java 線程結束,原生線程隨之被回收。操作系統負責調度所有線程,並把它們分配到任何可用的 CPU 上。

當原生線程初始化完畢,就會調用 Java 線程的 run() 方法。當線程結束時,會釋放原生線程和 Java 線程的所有資源。

一般在Hotspot JVM 後臺運行的系統線程主要有下面幾方面:

  • 虛擬機線程(VM thread):這個線程等待 JVM 到達安全點操作出現。這些操作必須要在獨立的線程裏執行,因爲當堆修改無法進行時,線程都需要 JVM 位於安全點。這些操作的類型有:stop-theworld
  • 垃圾回收、線程棧 dump、線程暫停、線程偏向鎖(biased locking)解除。
  • 週期性任務線程: 這線程負責定時器事件(也就是中斷),用來調度週期性操作的執行。
  • GC 線程: 這些線程支持 JVM 中不同的垃圾回收活動。
  • 編譯器線程: 這些線程在運行時將字節碼動態編譯成本地平臺相關的機器碼。
  • 信號分發線程: 這個線程接收發送到 JVM 的信號並調用適當的 JVM 方法處理。

由此可見,Java層到內層層的線程創建的大致流程:java.lang.Thread(Java應用程序層)—>Java Thread(JVM 層)->OS Thread(操作系統層)->pthread(根據操作系統的情況而定)->內核線程(Kernel Thread)。

基本模型

由於Java 中,JVM主要是由C/C++實現,所以Java層線程最終還是會映射到JVM層線程,而Java層的線程到操作系統層線程就得需要看具體的JVM的具體實現來決定。

一般來說,我們都把用戶線程看作更高層面的線程,而內核線程則向用戶線程提供支持。

由此可見,用戶線程和內核線程之間必然存在一定的映射關係,不同的操作系統可能採取不同的映射方式。

一般來說,按照映射方式來看,主要可以分爲:多對一映射(用戶級方式),一對一映射(內核級方式) 和多對多映射(組合方式)3種方式。其中:

1. 多對一映射(用戶級方式)

多對一映射是指多個用戶線程被映射到一個內核線程上。每一個進程都對應着一個內核線程,進程內的所有線程也都對應着該內核線程。

多對一映射模型是指多條用戶線程映射同一條內核線程的情況,其中用戶線程由庫調度器進行調度,而內核線程由操作系統調度器來完成。

對於用戶線程而言,其會按照一定的策略輪流執行,具體的調度算法有庫調度器完成。

任意一個時刻每一個進程中都只有一個用戶線程被執行,它們的執行都由用戶態的代碼完成切換。

在不支持線程的操作系統中有庫來實現線程控制,用戶線程創建,銷燬,切換的開銷代價比內核線程小。

因此,這種模式特點主要有兩點:

  • 首先,可以節省內核態到用戶態切換的開銷
  • 其次,線程的數量不會受到內核線程的限制

但是,因爲線程切換的工作是由用戶態的代碼完成的,所以一個進程內,如果當一條線程發生阻塞時,與該內核線程對應的進程內的其他所有的用戶線程也會一起陷入阻塞。

2. 一對一映射(內核級方式)

一對一映射是指每個用戶線程都會被影射到一個內核線程上,用戶的整個生命週期都綁定到所映射的內核線程上。一個進程內可以有一個用戶線程和至少一個用戶線程,都對應着各自一個和至少一個內核線程,進程內的所有線程也都一一對應着各自內核線程。

一對一映射模型是指一條用戶線程對應着內核中的一條線程的情況,其中用戶線程由庫調度器進行調度,而內核線程由操作系統調度器來完成,而Java中採用的就是這種模型。

在這種方式下,多個CPU能並行執行同一個進程內的多個線程。

如果進程內的某個線程被阻塞,就可以切換到該進程的其他線程繼續執行,並且能切換執行其他進程的線程。

一對一映射模型是真正意義上的並行執行,因爲這種模型下,創建一條Java的Thread線程是真正的在內核中創建並映射了一條內核線程的,執行過程中,一條線程不會因爲另外一條線程的原因而發生阻塞等情況。

不過因爲是每一個用線程都需要對應一個內核線程,這種直接映射內核線程的模式,所以數量會存在上限。

並且同一個核心中,多條線程的執行需要頻繁的發生上下文切換以及內核態與用戶態之間的切換,所以如果線程數量過多,切換過於頻繁會導致線程執行效率下降。

3. 多對多映射(組合方式)

多對多映射是指將一對一映射(內核級方式)和多對一映射(用戶級方式)組合起來,通過綜合兩者優點來形成的一種映射方式。該方式在用戶空間創建,銷燬,切換,調度線程,但是進程中的多個用戶線程會被影射到若干個內核線程上。

多對多映射模型就可以避免上面一對一映射模型和多對一映射模型帶來的弊端,也就是多條用戶線程映射多條內核線程,這樣即可以避免一對一映射模型的切換效率問題和數量限制問題,也可以避免多對一映射模型的阻塞問題。

每一個內核線程負責與之綁定的若干用戶線程,進程中的某個線程發生系統阻塞並不會導致整個進程阻塞,而阻塞該內核線程內的所對應的若干用戶線程,其他線程依舊可以照常執行。

同時,因爲用戶線程數量比內核線程數量多,所以能有效減少內核線程開銷。

基本實現

在java中,Java官方提供了三種方式來幫助我們實現一個線程,其中:

  • 第一種方式:繼承 Thread 對象:extends Thread
// 自定義線程對象
class ApplicationThread extends Thread { 
    public void run() { 
    // 線程需要執行的代碼 
    ...... 
    }
}

其中,Thread 類本質上是實現了Runnable 接口的一個實例,代表一個線程的實例。啓動線程的唯一方
法就是通過Thread 類的start()實例方法。start()方法是一個native 方法,它將啓動一個新線
程,並執行run()方法。

  • 第二種方式:實現 Runnable 接口(無返回值):implements Runnable
// 實現Runnable接口
class ApplicationThread implements Runnable {
    @Override 
    public void run() { 
    // 線程需要執行的代碼 
    ......
    }
}

其中,如果自己的類已經extends 另一個類,就無法直接extends Thread,此時,可以實現一個Runnable 接口。

  • 第三種方式:實現Callable 接口(有返回值):implements Callable
// 實現Runnable接口
class ApplicationThread implements Callable {
    @Override 
    public void run() { 
    // 線程需要執行的代碼 
    ......
    }
}

其中,執行Callable 任務後,可以獲取一個Future 的對象,在該對象上調用get 就可以獲取到Callable 任務返回的Object對象。

  • 第四種方式:基於線程池方式創建:線程和數據庫連接這些資源都是非常寶貴的資源。那麼每次需要的時候創建,不需要的時候銷
    毀,是非常浪費資源的。那麼我們就可以使用緩存的策略,也就是使用線程池。

Java 裏面線程池的頂級接口是Executor,但是嚴格意義上講Executor 並不是一個線程池,而只是一個執行線程的工具。真正的線程池接口是ExecutorService。

Java主要提供了newCachedThreadPool,newFixedThreadPool,newScheduledThreadPool以及newSingleThreadExecutor 等4種線程池。

目前業界線程池的設計,普遍採用的都是生產者 - 消費者模式。線程池的使用方是生產者,線程池本身是消費者。

Java 併發包裏提供的線程池,比較強大且複雜。Java 提供的線程池相關的工具類中,最核心的是 ThreadPoolExecutor,通過名字你也能看出來,它強調的是 Executor,而不是一般意義上的池化資源。

ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime,
  TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler) 

對於這些參數的意義,我們可以把線程池類比爲一個項目組,而線程就是項目組的成員。其中:

  • corePoolSize:表示線程池保有的最小線程數。
  • maximumPoolSize:表示線程池創建的最大線程數。
  • keepAliveTime & unit:一個線程如果在一段時間內,都沒有執行任務,說明很閒,keepAliveTime 和 unit 就是用來定義這個“一段時間”的參數。也就是說,如果一個線程空閒了keepAliveTime & unit這麼久,而且線程池的線程數大於 corePoolSize ,那麼這個空閒的線程就要被回收。
  • workQueue:工作隊列。
  • threadFactory:通過這個參數你可以自定義如何創建線程名稱。
  • handler:通過這個參數你可以自定義任務的拒絕策略。

其中,Java在ThreadPoolExecutor 已經提供了以下 4 種策略:

  • CallerRunsPolicy:提交任務的線程自己去執行該任務
  • AbortPolicy:默認的拒絕策略,會 throws RejectedExecutionException
  • DiscardPolicy:直接丟棄任務,沒有任何異常拋出
  • DiscardOldestPolicy:丟棄最老的任務,其實就是把最早進入工作隊列的任務丟棄,然後把新任務加入到工作隊列

同時, Java 在 1.6 版本還增加了 allowCoreThreadTimeOut(boolean value) 方法,表示可以讓所有線程都支持超時。

調度方式

由於CPU的計算頻率非常高,每秒計算數十億次,因此可以將CPU的時間從毫秒的維度進行分段,每一小段叫作一個CPU時間片。

目前操作系統中主流的線程調度方式是:基於CPU時間片方式進行線程調度。

線程只有得到CPU時間片才能執行指令,處於執行狀態,沒有得到時間片的線程處於就緒狀態,等待系統分配下一個CPU時間片。

由於時間片非常短,在各個線程之間快速地切換,因此表現出來的特徵是很多個線程在“同時執行”或者“併發執行”。

在Javs多視程環境中,爲了保證所有線程都能按照一定的策略執行,JVM 需要有一個線程調變器支持工作。

這個調度器定義了線程測度的策略,通過特定的機制爲多個線分配CPU的使用權,線程調度器中一般包含多種調度策略算法,由這些算法來決定CPU的分配。

除此之外,每個線程還有自己的優先級(比如有高,中、低級別)調度算法會通過這些優先級來實現優先機制。

常見線程的調度模型目前主要分爲兩種:(分時)協同式調度模型和搶佔式調度模型。

  • 搶佔式調度:
    • 系統按照線程優先級分配CPU時間片
    • 優先級高的線程優先分配CPU時間片,如果所有就緒線程的優先級相同,那麼會隨機選擇一個,優先級高的線程獲取的CPU時間片相對多一些。
    • 每個或程的執行時間和或候的切換高由調度落控劃,調度器按照某種略爲每個線穆分配執行時間,
    • 調度器可能會爲每個線整樣分配相的執行時間,也可能爲某些特定線程分配較長的執行時間,甚至在極準情況下還可能不給某熱線程分!執行時同片,從而導致某技線相得不到執行,
    • 在搶佔式調支機制下,一個線程的堵事不會導致整個進程堵客
  • (分時)協同式調度:
    • 系統平均分配CPU的時間片,所有線程輪流佔用CPU,即在時間片調度的分配上所有線程“人人平等”。
    • 某一線相執行完後會主動通知調度器切換現下一個線程上繼續執行。
    • 在這種模式下,線程的執行時間由線程本身控物,也就是說線程的切換點是可以預先知道的。
    • 在這種模式下,如果某個錢程的邏輯輯存在問題,則可能導致系統運行到一半就阻塞了,最終會導致整個進程阻塞,甚至更糟可能導致整個系統崩潰。

由於目前大部分操作系統都是使用搶佔式調度模型進行線程調度,Java的線程管理和調度是委託給操作系統完成的,與之相對應,Java的線程調度也是使用搶佔式調度模型,因此Java的線程都有優先級。

主要是 因爲Java的線程調度涉及JVM的實現,JVM規範中規定每個線程都有各自的優先級,且優先級越高,則越優先執行。

但是,優先級越高並不代表能獨佔執行時間,可能優先級越高得到的執行時間越長,反之,優先級越低的線程得到執行時間越短,但不會出現不分配執行時間的情況。

假如有若干個線程,我們想讓一些線程擁有更多的執行時間或者少分配點執行時間,那麼就可以通過設置線程的優先級來實現。

所有處於可執行狀態的線程都在一個隊列中,且每個線程都有自己的優先級,JVM 線程調度器會根據優先級來決定每次的執行時間和執行頻率。

但是,優先級高的線程一定會先執行嗎?我們能否在 Java 程序中通過優先級值的大小來控制線程的執行順序呢?

答案是肯定不能的。主要是因爲影響線程優先級語義的因素有很多,具體如下:

  • 不同版本的操作系統和 JVM 都可能會產生不同的行爲
  • 優先級對於不同的操作系統調度器來說可能有不同的語義;有些操作系統的調度器不支持優先級
  • 對於操作系統來說,線程的優先級存在“全局”和“本地”之分,不同進程的優先級一般相互獨立
  • 不同的操作系統對優先級定義的值不一樣,Java 只定義了 1~10
  • 操作系統常常會對長時間得不到運行的線程給予增加一定的優先級
  • 操作系統的線程調度器可能會在線程發生等待時有一定的臨時優先級調整策略

JVM 線程調度器的調度策略決定了上層多線程的運行機制,每個線程執行的時間都由它分配管理。

調度器將按照線程優先級對線程的執行時間進行分配,優先級越高得到的 CPU執行時間越長,執行頻率也可能更大。

Java把線程優先級分爲10個級別,線程在創建時如果沒有明確聲明優先級,則使用默認優先級。

Java定義了 Thread.MIN_PRIORITY、Thread.NORM PRIORITY和 Thread.MAXPRIORITY這3個常量,分別代表最小優先級值(1)、默認優先級值(5)和最大優先級值(10)。

此外,由於JVM 的實現是以宿主操作系統爲基礎的,所以Java各優先級與不同操作系統的原生線程優先級必然存在着某種映射關係,這樣才能夠封裝所有操作系統的優先級來提供統一的優先級語義。

一般情況下,在Linux中可能要與-20~19之間的優先級值進行映射,而Windows系統則有9個優先級要映射。

生命週期

在 Java 領域,實現併發程序的主要手段就是多線程。線程是本身就是操作系統裏的一個概念,不同的開發語言如 Java、C# 等都對其進行了封裝,但是萬變不離操作系統。

Java 語言裏的線程本質上就是操作系統的線程,它們是一一對應的。

在操作系統層面,線程也有“生老病死”,專業的說法叫有生命週期。對於有生命週期的事物,要學好它,思路非常簡單,只要能搞懂生命週期中各個節點的狀態轉換機制即可。

雖然不同的開發語言對於操作系統線程進行了不同的封裝,但是對於線程的生命週期這部分,基本上是雷同的。

通用的線程生命週期基本上可以用 初始狀態、可運行狀態、運行狀態、休眠狀態和終止狀態等“五態模型”來描述。

Java 語言中線程共有六種狀態,分別是:NEW(初始化狀態)RUNNABLE(可運行 / 運行狀態)BLOCKED(阻塞狀態)WAITING(無時限等待)TIMED_WAITING(有時限等待)TERMINATED(終止狀態)。

其實在操作系統層面,Java 線程中的 BLOCKED、WAITING、TIMED_WAITING 是一種狀態,即前面我們提到的休眠狀態。也就是說只要 Java 線程處於這三種狀態之一,那麼這個線程就永遠沒有 CPU 的使用權。

其中,BLOCKED、WAITING、TIMED_WAITING 可以理解爲線程導致休眠狀態的三種原因。那具體是哪些情形會導致線程從 RUNNABLE 狀態轉換到這三種狀態呢?而這三種狀態又是何時轉換回 RUNNABLE 的呢?以及 NEW、TERMINATED 和 RUNNABLE 狀態是如何轉換的?

1. RUNNABLE 與 BLOCKED 的狀態轉換

只有一種場景會觸發這種轉換,就是線程等待 synchronized 的隱式鎖。synchronized 修飾的方法、代碼塊同一時刻只允許一個線程執行,其他線程只能等待,這種情況下,等待的線程就會從 RUNNABLE 轉換到 BLOCKED 狀態。而當等待的線程獲得 synchronized 隱式鎖時,就又會從 BLOCKED 轉換到 RUNNABLE 狀態。

2. RUNNABLE 與 WAITING 的狀態轉換

總體來說,有三種場景會觸發這種轉換,其中:

  • 第一種場景,獲得 synchronized 隱式鎖的線程,調用無參數的 Object.wait() 方法。其中,wait() 方法我們在上一篇講解管程的時候已經深入介紹過了,這裏就不再贅述。
  • 第二種場景,調用無參數的 Thread.join() 方法。其中的 join() 是一種線程同步方法,例如有一個線程對象 thread A,當調用 A.join() 的時候,執行這條語句的線程會等待 thread A 執行完,而等待中的這個線程,其狀態會從 RUNNABLE 轉換到 WAITING。當線程 thread A 執行完,原來等待它的線程又會從 WAITING 狀態轉換到 RUNNABLE。
  • 第三種場景,調用 LockSupport.park() 方法。其中的 LockSupport 對象,也許你有點陌生,其實 Java 併發包中的鎖,都是基於它實現的。調用 LockSupport.park() 方法,當前線程會阻塞,線程的狀態會從 RUNNABLE 轉換到 WAITING。調用 LockSupport.unpark(Thread thread) 可喚醒目標線程,目標線程的狀態又會從 WAITING 狀態轉換到 RUNNABLE。
3. RUNNABLE 與 TIMED_WAITING 的狀態轉換

有五種場景會觸發這種轉換,其中:

  • 調用帶超時參數的 Thread.sleep(long millis) 方法。
  • 獲得 synchronized 隱式鎖的線程,調用帶超時參數的 Object.wait(long timeout) 方法。
  • 調用帶超時參數的 Thread.join(long millis) 方法。
  • 調用帶超時參數的 LockSupport.parkNanos(Object blocker, long deadline) 方法。
  • 調用帶超時參數的 LockSupport.parkUntil(long deadline) 方法。
4. 從 NEW 到 RUNNABLE 的狀態

Java 剛創建出來的 Thread 對象就是 NEW 狀態,而創建 Thread 對象主要有兩種方法:

  • 首先,第一種方式是繼承 Thread 對象,重寫 run() 方法
// 自定義線程對象
class ApplicationThread extends Thread { 
    public void run() { 
    // 線程需要執行的代碼 
    ...... 
    }
}
// 創建線程對象
ApplicationThread applicationThread = new ApplicationThread();
  • 其次,另一種方式是實現 Runnable 接口,重寫 run() 方法,並將該實現類作爲創建 Thread 對象的參數
// 實現Runnable接口
class ApplicationThread implements Runnable {
    @Override 
    public void run() { 
    // 線程需要執行的代碼 
    ......
    }
}
// 創建線程對象
Thread thread = new Thread(new ApplicationThread());

NEW 狀態的線程,不會被操作系統調度,因此不會執行。Java 線程要執行,就必須轉換到 RUNNABLE 狀態。從 NEW 狀態轉換到 RUNNABLE 狀態很簡單,只要調用線程對象的 start() 方法即可。

5. 從 RUNNABLE 到 TERMINATED

線程執行完 run() 方法後,會自動轉換到 TERMINATED 狀態,當然如果執行 run() 方法的時候異常拋出,也會導致線程終止。有時候我們需要強制中斷 run() 方法的執行。

一般來說, run() 方法訪問一個很慢的網絡,我們等不下去了,想終止怎麼辦呢?

Java 的 Thread 類裏面倒是有個 stop() 方法,不過已經標記爲 @Deprecated,所以不建議使用了。正確的姿勢其實是調用 interrupt() 方法。

那麼,stop() 和 interrupt() 方法的主要區別是什麼呢?

  • stop() 方法會真的殺死線程,不給線程喘息的機會,如果線程持有 ReentrantLock 鎖,被 stop() 的線程並不會自動調用 ReentrantLock 的 unlock() 去釋放鎖,那其他線程就再也沒機會獲得 ReentrantLock 鎖,這實在是太危險了。所以該方法就不建議使用了,類似的方法還有 suspend() 和 resume() 方法,這兩個方法同樣也都不建議使用。

  • interrupt() 方法僅僅是通知線程,線程有機會執行一些後續操作,同時也可以無視這個通知。

被 interrupt 的線程,是怎麼收到通知的呢?

  • 一種是異常:
  1. 線程 A 處於 WAITING、TIMED_WAITING 狀態時,如果其他線程調用線程 A 的 interrupt() 方法,會使線程 A 返回到 RUNNABLE 狀態,同時線程 A 的代碼會觸發 InterruptedException 異常。上面我們提到轉換到 WAITING、TIMED_WAITING 狀態的觸發條件,都是調用了類似 wait()、join()、sleep() 這樣的方法,我們看這些方法的簽名,發現都會 throws InterruptedException 這個異常。這個異常的觸發條件就是:其他線程調用了該線程的 interrupt() 方法。
  2. 當線程 A 處於 RUNNABLE 狀態時,並且阻塞在 java.nio.channels.InterruptibleChannel 上時,如果其他線程調用線程 A 的 interrupt() 方法,線程 A 會觸發 java.nio.channels.ClosedByInterruptException 這個異常;而阻塞在 java.nio.channels.Selector 上時,如果其他線程調用線程 A 的 interrupt() 方法,線程 A 的 java.nio.channels.Selector 會立即返回。
  • 另一種是主動檢測:
  1. 如果線程處於 RUNNABLE 狀態,並且沒有阻塞在某個 I/O 操作上,例如中斷計算圓周率的線程 A,這時就得依賴線程 A 主動檢測中斷狀態了。
  2. 如果其他線程調用線程 A 的 interrupt() 方法,那麼線程 A 可以通過 isInterrupted() 方法,檢測是不是自己被中斷。

寫在最後

首先,管程(Monitor)就是一對monitorenter和monitorexit指令組成的一個對象監視器。任何線程想要訪問該資源,就要排隊進入監控範圍。進入之後,接受檢查,不符合條件,則要繼續等待,直到被通知,然後繼續進入監視器。

在Java中,每個加鎖的對象都綁定着一個管程(監視器)。首先,線程訪問加鎖對象,就是去擁有一個監視器的過程,所有線程訪問共享資源,都需要先擁有監視器。其次,監視器至少有兩個等待隊列:一個是進入監視器的等待隊列,一個是條件變量對應的等待隊列。最後,當監視器要求的條件滿足後,位於條件變量下等待的線程需要重新排隊,等待通知再進入監視器。

其次,線程(Thread)是進程(Process)中的運行單位,可以把線程看作輕量級的進程。

線程按照操作系統和應用程序兩個層次來分類,主要分爲 內核線程(Kernel Thread)和用戶(應用線程,Applciation Thread)線程(User Thread) 。

在Java領域中,線程可以分爲:Java應用程序層線程(Java Application Thread ),Java虛擬機層線程(Java JVM Thread )和操作系統層線程(OS Thread)。

其中,Java層到內層層的線程創建的大致流程:java.lang.Thread(Java應用程序層)—>Java Thread(JVM 層)->OS Thread(操作系統層)->pthread(根據操作系統的情況而定)->內核線程(Kernel Thread)。

另外,線程按照映射方式來看,主要可以分爲:多對一映射(用戶級方式),一對一映射(內核級方式) 和多對多映射(組合方式)3種方式。

Java 語言中線程共有六種狀態,分別是:NEW(初始化狀態)RUNNABLE(可運行 / 運行狀態)BLOCKED(阻塞狀態)WAITING(無時限等待)TIMED_WAITING(有時限等待)TERMINATED(終止狀態)。

Java中實現線程的方式:繼承 Thread 對象:extends Thread,實現 Runnable 接口(無返回值):implements Runnable ,實現Callable 接口(有返回值):implements Callable,基於線程池方式創建等。

常見線程的調度模型目前主要分爲兩種:(分時)協同式調度模型和搶佔式調度模型,Java的線程調度也是使用搶佔式調度模型,因此Java的線程都有優先級。

Java 線程的調度機制由 JVM 實現,Java定義了 Thread.MIN_PRIORITY、Thread.NORM PRIORITY和 Thread.MAXPRIORITY這3個常量,分別代表最小優先級值(1)、默認優先級值(5)和最大優先級值(10)。

綜上所述,我想關於Java中的線程機制,看到這個地方,你一定樂然於胸,希望未來的我們更加優秀!

版權聲明:本文爲博主原創文章,遵循相關版權協議,如若轉載或者分享請附上原文出處鏈接和鏈接來源。

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