什么是線程
現代操作系統在運行一個程序時,會為其創建一個進程,例如,我們啟動一個 JAVA 程序,系統就會創建一個 Java 進程,在一個進程里可以創建多個線程,這些線程擁有自己的計數器、堆棧和局部變量等屬性,引入線程的概念可以將一個進程的資源分配和執行調度分開,并且能夠訪問共享的內存變量,如內存地址和文件 I/O 等,線程是計算機中比進程更輕量級的調度執行單元,也是系統調度的最小單元,也叫輕量級進程(Light Weight Process, LWP),CPU 在這些線程上高速切換,讓使用者感覺到這些線程在同時執行。
一個 Java 程序從 mAIn() 方法開始執行,然后按照既定的代碼邏輯執行,看似沒有其他線程參與,但實際上 Java 程序天生就是多線程程序,因為執行 main() 方法的是一個名稱為 main 的線程。我們通過一段代碼看下一個普通的 Java 程序包含哪些線程。
public class thread {
public static void main(String[] args) {
// 獲取Java線程管理
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 僅獲取線程和線程堆棧信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍歷線程信息,僅僅打印線程 ID 和線程名稱信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "]" + threadInfo.getThreadName());
}
}
}
圖片
可以看到,一個 Java 程序的運行不僅僅是 main() 方法的運行,而是 main 線程和多個其他線程的同時運行。
線程的實現
主流的操作系統都提供了線程實現。Java 語言則提供了在不同硬件和操作系統平臺下對線程操作統一處理的能力。在 Java 中,每個已經執行 start() 方法且尚未結束的 java.lang.Thread 類的實例代表一個線程。
查看 JDK 的 Thread 類可以看到 Thread 類與大部分 Java API 有明顯的差異,它的關鍵方法都被聲明為 Native。在 Java API 中,Native 方法通常意味著該方法沒有使用或無法使用平臺無關的手段來實現(說明需要操作的是很底層的東西了,已經脫離了 Java 語言層面的范疇)。
圖片
實現線程主要有 3 種方式:使用內核線程實現( 1:1 實現)、使用用戶線程實現( N:1 實現)和使用用戶線程加輕量級進程混合實現( N:M 實現)。
1、內核線程實現(1:1實現)
內核線程(Kernel-Level Thread, KLT)是由操作系統內核直接支持的線程,內核通過操縱調度器(Scheduler)對線程進行調度,并負責將線程的任務映射到各個處理器上。下圖中 KLT 線程上面都有一個 LWP 與之對應,每個內核線程可以視為內核的一個分身,這樣操作系統就能夠同時處理多個任務,從而支持多線程。
程序一般不會直接使用內核線程,而是使用內核線程的一種高級接口——輕量級進程(Light Weight Process, LWP)。輕量級進程是我們通常所說的線程,由于每個輕量級進程都由一個內核線程支持,因此只有先支持內核線程,才能有輕量級進程。輕量級進程與內核線程之間是一對一的關系,稱為一對一的線程模型。
由于內核線程的支持,每個輕量級進程都成為一個獨立的調度單元,即使有一個輕量級進程在系統調用中阻塞了,也不會影響整個進程繼續工作。但輕量級進程也有一些局限性:由于是基于內核線程實現的,各種線程操作,如創建、析構及同步,都需要進行系統調用。而系統調用的代價相對較高,需要在用戶態(User Mode)和內核態(Kernel Mode)之間來回切換。其次,每個輕量級進程都需要有一個內核線程的支持,因此輕量級進程要消耗一定的內核資源(例如內核線程的棧空間),因此一個系統支持輕量級進程的數量是有限的。
圖片
輕量級進程與內核線程之間1:1的示意圖
2、用戶線程實現( N:1 實現)
用戶線程是指完全建立在用戶空間線程庫之上的線程實現,系統內核對其不可感知。即所有的用戶線程都會對應到一個內核線程中,用戶線程的創建、同步、銷毀和調度完全在用戶空間中完成,無需內核的幫助。如果程序實現得當,這些線程無需切換到內核模式,從而實現快速且低開銷的操作。它們還可以支持更多的線程數量,因此在高性能數據庫等場景中經常使用用戶線程。進程與用戶線程之間的關系采用一對多的線程模型。
使用用戶線程的優勢在于不需要系統內核的支持。然而劣勢在于它們也缺乏系統內核的支持,所有線程操作都需要用戶程序自己處理。需要考慮線程創建、切換和調度等問題。在某一線程被阻塞時,會導致整個所屬進程阻塞。Java 曾經使用過用戶線程,但最終放棄了使用它們。但是比如 Golang、Erlang 等一些新的、以高并發為賣點的變成語言普遍支持了用戶線程。
進程與用戶線程之間N:1的關系示意圖
3、用戶線程加輕量級進程混合實現( N:M 實現)
內核線程和用戶線程結合的實現方式。在這種混合實現中,用戶線程和輕量級進程同時存在。用戶線程仍然完全建立在用戶空間中,因此創建、切換和銷毀用戶線程的操作仍然是廉價的,并且可以同時支持大量的用戶線程。操作系統提供對輕量級進程的支持,它們充當用戶線程和內核線程之間的橋梁。這樣可以利用內核提供的線程調度和處理器映射功能。用戶線程的系統調用通過輕量級進程來處理,大大降低了整個進程被完全阻塞的風險。在這種混合模型中,用戶線程和輕量級進程的比例可以變化,形成一個 N:M 的關系。
許多 UNIX 系列的操作系統都提供了 N:M 的線程模型實現。這些操作系統上的應用也相對更容易應用 N:M 的線程模型。
用戶線程與輕量級進程之間N:M的關系示意圖
Java 線程的實現
操作系統支持怎么樣的線程模型,很大程度上會影響上面的 Java 虛擬機的線程是怎么映射的,JVM 規范里面沒有規定,必須使用哪一種模型。線程模型主要影響線程的并發規模和操作成本,對于 Java 程序的編碼和運行過程來說,這些差異都是透明的, Java 作為上層應用,其實是感知不到上面三種模型之間的區別的,即開發者無需關注具體的線程模型細節。
在 JDK 1.2 之前,Java 線程使用的是稱為“綠色線程”(Green Threads)的用戶級線程實現。但是在 JDK 1.3 起,線程模型被替換為基于操作系統原生線程模型的實現方式,即采用 1:1 的線程模型。
Java SE 最常用的 JVM 是 Oracle/Sun 研發的 HotSpot VM。在這個 JVM 的較新版本所支持的所有平臺上,它都是使用 1:1 線程模型的——除了 Solaris 之外,它是個特例。也就是說一個 Java 線程是直接通過一個操作系統原生線程來實現的,中間并沒有額外的間接結構。而且 HotSpot VM 自己也不干涉線程的調度,全權交給底下的 OS 去處理。
Java 線程調度
線程調度是指系統為線程分配處理器使用權的過程,主要調度方式有兩種,分別是協同式線程調度(Cooperative Threads-Scheduling)和搶占式線程調度(Preemptive Threads-Scheduling)。
如果在多線程系統中使用協同式調度,每個線程的執行時間由線程自身控制。在完成工作后,線程需要主動通知系統切換到另一個線程。協同式多線程的主要優勢在于簡單性,由于線程切換由線程自身知曉,因此不存在線程同步問題。協同式調度也存在明顯的缺點。線程的執行時間無法控制,如果一個線程出現問題并且沒有通知系統切換線程,整個進程可能會無限期地被阻塞。
如果一個多線程系統采用搶占式調度,系統會為每個線程分配執行時間,線程切換不由線程自身決定(在 Java 中,Thread.yield() 可以讓出執行時間,但線程本身無法控制獲取執行時間)。在這種線程調度實現中,線程的執行時間由系統控制,不會出現一個線程阻塞整個進程的情況。Java 使用搶占式調度作為其線程調度機制。如果一個進程遇到問題,我們可以使用“任務管理器”終止該進程,而不會導致系統崩潰。
說到計算調度這里還要說一下 CPU 時間片
在單個處理器的時期,操作系統就能處理多線程并發任務。處理器給每個線程分配 CPU 時間片(Time Slice),線程在分配獲得的時間片內執行任務。CPU 時間片是 CPU 分配給每個線程執行的時間段,一般為幾十毫秒。在這么短的時間內線程互相切換,我們根本感覺不到,所以看上去就好像是同時進行的一樣。
時間片決定了一個線程可以連續占用處理器運行的時長。當一個線程的時間片用完了,或者因自身原因被迫暫停運行了,這個時候,另外一個線程(可以是同一個線程或者其它進程的線程)就會被操作系統選中,來占用處理器。這種一個線程被暫停剝奪使用權,另外一個線程被選中開始或者繼續運行的過程就叫做上下文切換。
上下文切換
當一個線程讓出 CPU 時間片時,它需要記錄下整個執行上下文,以便在恢復執行時從上次離開的地方繼續。這包括變量、計算結果、程序計數器等等。就像是對線程的運行環境進行快照,這樣當它重新獲得 CPU 時間時,可以通過檢索保存的數據快速恢復先前的執行上下文。這個過程被稱為“上下文切換”。
在一個擁有多個 CPU 的系統中,操作系統以循環方式將 CPU 分配給不同的線程。這導致上下文切換更加頻繁,特別是在跨不同 CPU 進行上下文切換時,比單個 CPU 內的上下文切換更加昂貴。
在操作系統中,上下文切換可以發生在進程之間或線程之間。在多線程編程的背景下,我們主要關注線程之間上下文切換的性能影響。現在,讓我們探討一下多線程中上下文切換的原因。但在此之前,讓我們先了解一下系統線程的生命周期狀態。
圖片
系統線程主要有“新建”(NEW)、“就緒”(RUNNABLE)、“運行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五種狀態。到了 Java 層面它們都被映射為了 NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINADTED 等 6 種狀態。
在這個運行過程中,線程由 RUNNABLE 轉為非 RUNNABLE 的過程就是線程上下文切換。一個線程的狀態由 RUNNING 轉為 BLOCKED ,再由 BLOCKED 轉為 RUNNABLE ,然后再被調度器選中執行,這就是一個上下文切換的過程。多線程的上下文切換實際上就是由多線程兩個運行狀態的互相切換導致的。
那么在線程運行時,線程狀態由 RUNNING 轉為 BLOCKED 或者由 BLOCKED 轉為 RUNNABLE,是怎么誘發的呢?
系統線程切換可以由多種情況下誘發,包括但不限于以下幾種情況:
- 時間片耗盡:當一個線程的時間片用盡時,操作系統會強制切換到另一個線程,以確保公平地分配 CPU 時間給其他線程。
- 高優先級線程搶占:如果有一個優先級更高的線程需要執行,操作系統會中斷當前線程的執行,并切換到優先級更高的線程。
- 阻塞操作:當一個線程執行阻塞操作(如等待 I/O 完成、等待鎖釋放等)時,操作系統會將該線程置于阻塞狀態,并切換到其他可執行的線程,以充分利用 CPU 資源。
- 線程同步:當多個線程需要訪問共享資源時,可能需要進行線程同步操作,如互斥鎖、信號量等。在這種情況下,當一個線程獲取到同步資源時,其他線程可能需要等待,從而引發線程切換。
- 中斷處理:當一個硬件中斷或軟件中斷發生時,操作系統會中斷當前線程的執行,并轉而處理中斷事件,這可能導致線程切換。這些情況下,操作系統會根據調度算法和優先級規則來決定切換到哪個線程,并通過保存和恢復線程的上下文來實現線程切換。
我們可以分兩種情況來分析,一種是程序本身觸發的切換,這種我們稱為自發性上下文切換,另一種是由系統或者虛擬機誘發的非自發性上下文切換。
接下來我們看一段代碼,來對比串聯執行和并發執行的速度
package com.yuyy.test;
public class DemoApplication {
public static void main(String[] args) {
// 運行多線程
MultiThreadTester test1 = new MultiThreadTester();
test1.Start();
// 運行單線程
SerialTester test2 = new SerialTester();
test2.Start();
}
static class MultiThreadTester extends ThreadContextSwitchTester {
@Override
public void Start() {
long start = System.currentTimeMillis();
MyRunnable myRunnable1 = new MyRunnable();
Thread[] threads = new Thread[4];
// 創建多個線程
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(myRunnable1);
threads[i].start();
}
for (int i = 0; i < 4; i++) {
try {
// 等待一起運行完
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
long end = System.currentTimeMillis();
System.out.println("multi thread exec time: " + (end - start) + "s");
System.out.println("counter: " + counter);
}
// 創建一個實現Runnable的類
class MyRunnable implements Runnable {
public void run() {
while (counter < 100000000) {
synchronized (this) {
if(counter < 100000000) {
increaseCounter();
}
}
}
}
}
}
// 創建一個單線程
static class SerialTester extends ThreadContextSwitchTester{
@Override
public void Start() {
long start = System.currentTimeMillis();
for (long i = 0; i < count; i++) {
increaseCounter();
}
long end = System.currentTimeMillis();
System.out.println("serial exec time: " + (end - start) + "s");
System.out.println("counter: " + counter);
}
}
static abstract class ThreadContextSwitchTester {
public static final int count = 100000000;
public volatile int counter = 0;
public void increaseCounter() {
this.counter += 1;
}
public abstract void Start();
}
}
執行之后,看一下兩者的時間測試結果:串聯的執行速度比并發的執行速度要快。這就是因為線程的上下文切換導致了額外的開銷。
線程的優先級
雖然 Java 線程調度由系統自動處理,但我們仍然可以“建議”系統為某些線程分配更多的執行時間,而為其他線程分配較少的執行時間。這可以通過設置線程優先級來實現。Java 語言提供了 10 個級別的線程優先級。當兩個線程同時處于 Ready 狀態時,優先級較高的線程更有可能被系統選擇執行,其實就是讓高優先級的線程獲得更多的CPU 時間片。
設置優先級有助于”線程規劃期“確定在下一次選擇哪一個線程來優先執行,設置線程優先級使用 setPriority() 方法
但是,線程優先級并不總是可靠的,因為 Java 線程最終是通過映射到底層操作系統的原生線程來實現的。因此,線程調度仍然取決于操作系統。盡管許多操作系統提供了線程優先級的概念,但它們不一定直接對應于 Java 線程優先級。例如,Solaris 擁有 2,147,483,648(2^32)個優先級級別,而 windows 只有 7 個。如果操作系統的優先級級別多于 Java,將它們映射是相對簡單的,可以在它們之間留下一些空位。然而,如果操作系統的優先級級別少于 Java,可能會出現多個優先級映射到同一級別的情況。
下圖顯示了 Java 線程優先級與 Windows 線程優先級之間的對應關系,不包括 THREAD_PRIORITY_IDLE,因為它在 Windows 平臺的 JDK 中未使用。因此如果在 Java 程序中對兩個線程設置的優先級分別是 3 和 4 那么對于Windows 來說他們的優先級還是一致的。還有例如 Windows 系統中存在一個叫做“優先級推進器”的功能,大致作用是當系統發現一個線程被執行的特別頻繁的時候,可能會越過線程優先級去為它分配執行時間,從而減少線程頻繁切換而帶來的性能損耗。因此我們在程序中并不能判斷同樣為就緒狀態且優先級一致的多個線程系統會先執行哪一個。
總結
對于任何支持多線程的計算機語言來說,深入理解線程及寫好多線程程序,都是一個巨大的挑戰。本主要簡述 Java 線程與操作系統線程之間的關系。java 中的線程和操作系統中的線程分別存在于虛擬機和操作系統中,一個 Java 線程是直接通過一個操作系統線程來實現的。其中還有很多值得深挖的點。大家有興趣的話,可以仔細研究一下。
參考文檔
深入理解Java虛擬機(第3版)