Java 并發(fā)編程:理解進程和線程
最早的計算機只有每次用戶給出指令,計算機就執(zhí)行一次,沒有指令時就會停止運行。很多時候,計算機就這樣閑置著。
批處理操作系統(tǒng)
后來,批處理操作系統(tǒng)出現(xiàn)了,它能夠一次接受一系列指令,然后按順序執(zhí)行。用戶可以將想要執(zhí)行的程序寫在磁帶上,然后讓計算機讀取并執(zhí)行,將輸出結果寫在另一盤磁帶上。
批處理操作系統(tǒng)在一定程度上提高了計算機的效率,但由于批處理操作系統(tǒng)的指令執(zhí)行模式仍然是串行的,內存中始終只有一個程序在運行,后續(xù)程序需要等待前一個程序執(zhí)行完成后才能開始執(zhí)行。而前一個程序有時會因 I/O 操作、網(wǎng)絡等原因阻塞,所以批處理操作效率并不高。
進程
隨著技術的發(fā)展,人們對計算機性能的要求越來越高,現(xiàn)有的批處理操作系統(tǒng)已無法滿足人們的需求。批處理操作系統(tǒng)的瓶頸在于內存中只有一個程序,那么內存中能否有多個程序呢?顯然這是可以的。于是就提出了進程的概念。
進程是應用程序在內存中分配的空間,即正在運行的程序。例如,運行一個 Java 程序的本質就是啟動一個 Java 虛擬機進程,這意味著一個正在運行的 Java 程序就是一個 Java 虛擬機進程。
例如,以下是一個簡單的 Java 程序:
public class SimpleJavaApp {
public static void main(String[] args) {
System.out.println("Hello world!");
}
}
操作系統(tǒng)可以同時運行多個進程,如 chrome、QQ 等,它們互不干擾。同時,進程會保存程序在每個時刻的運行狀態(tài)。
此時,CPU 使用時間片輪轉調度算法來運行進程:CPU 為每個進程分配一個時間段,稱為時間片。如果時間片結束時進程仍在運行,則將該進程掛起,并將 CPU 分配給另一個進程(這個過程稱為上下文切換)。如果進程在時間片結束前阻塞或完成,則 CPU 立即切換,無需等待時間片用完。
當一個進程被掛起時,它會保存當前進程的狀態(tài)(進程標識、進程使用的資源等)。下次切換回來時,會根據(jù)之前保存的狀態(tài)進行恢復并繼續(xù)執(zhí)行。
使用進程和時間片輪轉調度算法的操作系統(tǒng)在宏觀層面上似乎在同一時間段內運行多個進程;雖然并發(fā)在宏觀層面上看起來有多個任務在執(zhí)行,但實際上,對于單核 CPU,在任何特定時刻只有一個任務占用 CPU 資源。
并發(fā)與并行
剛剛提到了并發(fā),這里簡要解釋一下并發(fā)與并行的區(qū)別。
并行:在同一時刻,多個任務在多個 CPU 上同時執(zhí)行。
并發(fā):在同一時刻,多個任務在單個 CPU 上交替執(zhí)行。
線程
雖然進程的出現(xiàn)大大提高了操作系統(tǒng)的性能,但隨著時間的推移,人們不滿于一個進程一次只能做一件事。如果一個進程有多個子任務,這些子任務只能一個接一個地執(zhí)行,這極大地影響了效率。
例如,當你使用瀏覽器瀏在下載文件時不能繼續(xù)瀏覽其他網(wǎng)頁,這顯然無法滿足人們的需求。
這些子任務能否同時執(zhí)行呢?因此,人們提出了線程的概念,讓一個線程執(zhí)行一個子任務。這樣一個進程就可以包含多個線程,每個線程負責一個單獨的子任務。
例如,在以下用 Java 實現(xiàn)的多線程代碼中,在主線程中開啟了兩個線程,分別負責兩個任務,一個打印“Hello world”,一個打印“Hello java”。
class SayHelloThread extends Thread {
public void run() {
System.out.println("Hello");
}
}
class SayJavaThread extends Thread {
public void run() {
System.out.println("Java");
}
}
public class MultiThreadJavaApp {
public static void main(String[] args) throws InterruptedException {
SayHelloThread sayHelloThread = new SayHelloThread();
SayJavaThread sayJavaThread = new SayJavaThread();
sayHelloThread.start();
sayJavaThread.start();
// 主線程睡眠
Thread.sleep(5000);
}
}
輸出:
Hello
Java
上述輸出結果并不總是相同,因為sayHelloThread并不總是先獲得執(zhí)行機會。
在使用線程后,下載文件時,讓負責下載功能的線程執(zhí)行下載。用戶瀏覽網(wǎng)頁時,執(zhí)行負責瀏覽網(wǎng)頁功能的線程。然后,通過時間片輪轉,用戶可以感覺到兩個功能同時在運行。
進程與線程的區(qū)別
進程和線程的引入極大地提高了操作系統(tǒng)的性能,那么它們之間有什么區(qū)別呢?
- 資源占用:進程是操作系統(tǒng)分配資源的基本單位,它包含程序執(zhí)行的一個實例,包括代碼、數(shù)據(jù)和系統(tǒng)資源(如內存、文件、設備等)。每個進程都有獨立的內存空間和系統(tǒng)資源,互不干擾。線程是操作系統(tǒng)調度的基本單位,即 CPU 分配時間的單位。多個線程共享同一進程的內存空間和系統(tǒng)資源。
- 數(shù)據(jù)交換:進程獨立執(zhí)行,有自己的地址空間和系統(tǒng)資源,通過進程間通信(IPC)進行數(shù)據(jù)交換。線程是進程內的一條執(zhí)行路徑。同一進程中的多個線程共享內存空間和系統(tǒng)資源,所以線程之間的數(shù)據(jù)交換更簡單、快速。
- 開銷:由于進程有獨立的內存空間和系統(tǒng)資源,創(chuàng)建和銷毀進程的開銷相對較大,需要保存和恢復更多的上下文信息。線程共享進程的內存空間和系統(tǒng)資源,所以創(chuàng)建和銷毀線程的開銷較小,只需要保存和恢復少量上下文信息。
- 并發(fā):進程是獨立的執(zhí)行單元,有自己的調度算法,在并發(fā)條件下更穩(wěn)定可靠。由于線程共享進程資源,線程之間的調度和同步更復雜,需要更多注意處理并發(fā)條件。這也是我們后續(xù)文章將重點關注的問題。
基于上述差異,我們可以看到,對于一個進程內的多個任務實現(xiàn)并發(fā),最好的方法是使用多個線程而不是多個進程,但需要特別注意并發(fā)邏輯的處理。
上下文切換
上下文切換(有時也稱為進程切換或任務切換)是指 CPU 從一個進程(或線程)切換到另一個進程(或線程)。上下文是指在某個時間點 CPU 寄存器和程序計數(shù)器的內容。
其中,寄存器是 CPU 內部少量的訪問速度很快的存儲,通常用于存儲和訪問計算過程中的中間值,以提高計算機程序的運行速度。
程序計數(shù)器是一個專用寄存器,用于指示 CPU 當前正在執(zhí)行的指令序列中的位置,存儲的值是正在執(zhí)行的指令的位置或下一條要執(zhí)行的指令的位置,具體實現(xiàn)取決于特定系統(tǒng)。
CPU 通過為每個線程分配 CPU 時間片并通過時間片輪轉調度算法執(zhí)行任務來實現(xiàn)多線程機制。當一個任務執(zhí)行完一個時間片時,它將切換到下一個任務,在切換之前,會保存前一個任務的狀態(tài),以便下次切換回該任務時可以重新加載該任務的狀態(tài)。因此,從保存到重新加載一個任務的過程就是一次上下文切換。
例如,現(xiàn)在線程 A 的時間片已執(zhí)行完,需要切換到線程 B,需要以下三個步驟:
- 先掛起線程 A,并將其狀態(tài)從 CPU 保存到內存中。
- 在內存中,獲取下一個線程 B 的上下文,并將其恢復到 CPU 的寄存器中以執(zhí)行線程 B。
- 當線程 B 執(zhí)行完畢,根據(jù)程序計數(shù)器指向的位置恢復線程 A。
上下文切換通常計算密集,這意味著此操作會消耗大量 CPU 時間,所以線程不是越多越好。如何減少系統(tǒng)中的上下文切換次數(shù)是提高多線程性能的關鍵問題。我們將在未來的文章中討論這個問題。