從零開始:一文讀懂Java并發編程核心基礎
- 一. 為什么需要并發編程?
- 二. 并發編程的“另一面”:挑戰與代價
- 2.1 頻繁的上下文切換
- 2.2 線程安全問題(如:死鎖)
- 三. 夯實基礎:必須掌握的核心概念與操作
- 3.1 厘清基本概念
- 3.2 創建你的第一個線程
- 方式一:繼承 `Thread` 類
- 方式二:實現 `Runnable` 接口 (推薦)
- 方式三:實現 `Callable` 接口 (可獲取返回值)
- 3.3 線程的生命周期:狀態轉換
- 3.4 線程間的“對話”:基本操作
- `sleep()` 與 `wait()` 的經典對比
- `join()` - 線程的協作
- `interrupt()` - 優雅的通知機制
- `yield()` - 主動的讓步
- 3.5 默默的守護者:Daemon線程
為什么需要用到并發?凡事總有好壞兩面,這其中的權衡(trade-off)是什么,也就是說并發編程具有哪些缺點?以及在進行并發編程時,我們應該了解和掌握的核心概念又是什么?這篇文章將主要圍繞這三個問題,為你揭開Java并發編程的神秘面紗。
一. 為什么需要并發編程?
你可能會奇怪,我們討論的是軟件編程,為什么會扯到硬件的發展?這要從著名的“摩爾定律”說起。
在很長一段時間里,摩爾定律預示著單核處理器的計算能力會呈指數級增長。然而,大約在2004年,物理極限的瓶頸開始顯現,單純提升單核頻率變得異常困難。聰明的硬件工程師們轉變了思路:不再追求單個計算單元的極致速度,而是將多個計算單元整合到一顆CPU中。這就是“多核CPU”時代的到來。
如今,家用的i7處理器擁有8個甚至更多的核心已是常態,服務器級別的CPU核心數則更為龐大。硬件已經鋪好了路,但如何才能榨干這些核心的性能呢?
答案就是并發編程。
頂級計算機科學家Donald Ervin Knuth曾半開玩笑地評價:“在我看來,并發這種現象或多或少是由于硬件設計者無計可施了,他們將摩爾定律的責任推給了軟件開發者。”
這句評價一語中的。正是多核CPU的普及,催生了并發編程的浪潮。通過并發編程,我們可以:
- 充分利用多核CPU的計算能力:將復雜的計算任務分解,讓多個核心同時工作,從而大幅提升程序性能。想象一下處理一張高清圖片,如果串行處理數百萬個像素點會非常耗時,但如果將圖片分成幾塊,交由不同的核心并行處理,速度就會成倍提升。
- 方便進行業務拆分,提升應用響應速度:在很多業務場景中,并發是天生的需求。例如,在網上購物下單時,系統需要同時完成檢查庫存、生成訂單、扣減優惠券、通知物流等多個操作。如果這些操作串行執行,用戶需要等待很長時間。而通過并發技術,這些操作可以被拆分到不同的線程中“同時”進行,極大地縮短了用戶的等待時間,提升了體驗。
正是這些顯著的優點,使得并發編程成為現代軟件開發者必須掌握的關鍵技能。
二. 并發編程的“另一面”:挑戰與代價
既然并發編程如此強大,我們是否應該在所有場景下都使用它呢?答案顯然是否定的。它是一把雙刃劍,在帶來性能提升的同時,也引入了新的復雜性和挑戰。
2.1 頻繁的上下文切換
在我們看來,多個線程似乎是“同時”執行的,但這在單核CPU上只是一種宏觀上的錯覺。CPU會為每個線程分配一個極短的時間片(通常是幾十毫秒),然后快速地在不同線程間輪換。這個切換過程,被稱為上下文切換。
切換時,系統需要保存當前線程的運行狀態(如程序計數器、寄存器值等),以便下次輪到它時能恢復現場。這個保存和恢復的過程本身是有性能開銷的。如果線程數量過多,或者切換過于頻繁,上下文切換消耗的時間甚至可能超過線程真正執行任務的時間,導致程序性能不升反降。
如何減少上下文切換?
- 無鎖并發編程:例如
ConcurrentHashMap
的分段鎖思想,讓不同線程處理不同數據段,減少鎖競爭。 - CAS算法:Java的
Atomic
包使用了CAS(比較并交換)這種樂觀鎖機制,它在很多場景下能避免加鎖帶來的阻塞和上下文切換。 - 使用最少線程:創建適量的線程,避免大量線程處于空閑等待狀態。
- 使用協程:在單線程內實現多任務調度,這是更輕量級的“線程”。
2.2 線程安全問題(如:死鎖)
這是并發編程中最棘手、也最容易出錯的地方。當多個線程訪問共享資源(也稱為“臨界區”)時,如果沒有恰當的同步機制,就可能導致數據錯亂、狀態不一致,甚至出現死鎖。
死鎖是指兩個或多個線程無限期地互相等待對方釋放資源,導致所有相關的線程都無法繼續執行。
來看一個經典的死鎖示例:
public class DeadLockDemo {private static final String resource_a = "資源A";private static final String resource_b = "資源B";public static void main(String[] args) {Thread threadA = new Thread(() -> {synchronized (resource_a) {System.out.println(Thread.currentThread().getName() + " 獲得了 " + resource_a);try {// 等待一會兒,確保threadB能獲得resource_bThread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + " 嘗試獲得 " + resource_b + "...");synchronized (resource_b) {System.out.println(Thread.currentThread().getName() + " 獲得了 " + resource_b);}}}, "線程A");Thread threadB = new Thread(() -> {synchronized (resource_b) {System.out.println(Thread.currentThread().getName() + " 獲得了 " + resource_b);System.out.println(Thread.currentThread().getName() + " 嘗試獲得 " + resource_a + "...");synchronized (resource_a) {System.out.println(Thread.currentThread().getName() + " 獲得了 " + resource_a);}}}, "線程B");threadA.start();threadB.start();}
}
在這個例子中,線程A獲得了resource_a
的鎖,然后嘗試去獲得resource_b
的鎖;而線程B同時獲得了resource_b
的鎖,并嘗試獲得resource_a
的鎖。兩者互相持有對方需要的鎖,并等待對方釋放,從而陷入了永久的等待,形成了死鎖。
我們可以使用JDK自帶的jps
和jstack
命令來診斷這種情況,jstack
會明確地告訴你“Found 1 deadlock”。
如何避免死鎖?
- 避免一個線程同時獲取多個鎖:盡量減少鎖的持有范圍和時間。
- 保證加鎖順序:確保所有線程都按照相同的順序來獲取鎖。
- 使用定時鎖:使用
lock.tryLock(timeout)
,當等待超時后線程可以主動放棄,而不是無限期阻塞。 - 將鎖和資源隔離:對于數據庫鎖,確保加鎖和解鎖在同一個數據庫連接中完成。
三. 夯實基礎:必須掌握的核心概念與操作
了解了并發的優缺點后,讓我們深入到實踐層面,看看在Java中到底該如何使用和操作線程。
3.1 厘清基本概念
-
同步 vs 異步 (Synchronous vs Asynchronous):這通常用來描述一次方法調用。
- 同步:調用方發起調用后,必須原地等待被調用方法執行完畢并返回結果,才能繼續執行后續代碼。就像你去實體店買東西,必須排隊、付款、拿到商品后才能離開。
- 異步:調用方發起調用后,不等待結果,立即返回并繼續執行后續代碼。被調用的方法在后臺執行,完成后通過回調、通知等方式告訴調用方。就像網購,你下單后就可以去做別的事了,快遞到了會通知你去取。
-
并發 vs 并行 (Concurrency vs Parallelism):
- 并發:指多個任務在一段時間內都得到了執行,它們在宏觀上是“同時”發生的,但在微觀上可能是通過時間片快速交替執行的。好比一個人在同時處理做飯、接電話、看孩子三件事,他需要不停地在任務間切換。
- 并行:指多個任務在同一時刻真正地同時執行。這必須在多核CPU上才能實現。好比三個人,一人做飯,一人接電話,一人看孩子,他們是真正在同一時間做著不同的事。
-
阻塞 vs 非阻塞 (Blocking vs Non-blocking):這通常用來形容線程間的相互影響。
- 阻塞:一個線程的操作導致它自身被掛起,等待某個條件滿足(如等待I/O完成、等待獲取鎖)。在此期間,它不會占用CPU。
- 非阻塞:一個線程的操作不會導致自身被掛起,無論操作是否成功都會立即返回。
3.2 創建你的第一個線程
一個Java程序從main()
方法啟動時,JVM就已經創建了多個線程(如主線程、垃圾回收線程等)。要在我們自己的程序中創建線程,主要有以下三種方式:
方式一:繼承 Thread
類
這是最直接的方式,通過繼承Thread
并重寫run()
方法來定義任務。
class MyThread extends Thread {@Overridepublic void run() {System.out.println("通過繼承Thread類創建線程");}
}// 使用
MyThread thread = new MyThread();
thread.start(); // 必須調用start()來啟動新線程
- 優點:實現簡單,易于理解。
- 缺點:Java是單繼承的,如果你的類已經繼承了其他類,就無法再繼承
Thread
,這極大地限制了其靈活性。
方式二:實現 Runnable
接口 (推薦)
這是更常用、也更受推薦的方式。它將“任務”(Runnable
)和“執行任務的載體”(Thread
)解耦開來。
class MyRunnable implements Runnable {@Overridepublic void run() {System.out.println("通過實現Runnable接口創建線程");}
}// 使用
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
- 優點:
- 任務與線程解耦,結構更清晰。
- 避免了單繼承的限制,你的任務類還可以繼承其他類。
- 多個線程可以共享同一個
Runnable
實例,方便實現資源共享。
- 缺點:代碼比方式一稍微多一點。
方式三:實現 Callable
接口 (可獲取返回值)
Runnable
的run()
方法沒有返回值,也不能拋出受檢異常。如果你的任務需要一個執行結果或可能拋出異常,Callable
是更好的選擇。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;class MyCallable implements Callable<String> {@Overridepublic String call() throws Exception {Thread.sleep(2000); // 模擬耗時任務return "通過實現Callable接口返回的結果";}
}// 使用
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(new MyCallable());System.out.println("主線程繼續做其他事情...");// 在需要結果時,調用get()方法阻塞等待
String result = future.get();
System.out.println(result);
executor.shutdown();
- 優點:
- 可以獲得任務的返回值。
- 可以向外拋出異常。
- 說明:
Callable
通常與線程池(ExecutorService
)配合使用,submit()
方法返回一個Future
對象,你可以用它來跟蹤任務狀態并獲取結果。
3.3 線程的生命周期:狀態轉換
一個Java線程在其生命周期中,會經歷多種狀態的變遷。這些狀態定義在java.lang.Thread.State
枚舉中。
NEW
(新建):new Thread()
之后,但還未調用start()
。RUNNABLE
(可運行): 調用start()
后,線程進入就緒隊列,等待CPU調度。它可能正在運行,也可能在等待運行。BLOCKED
(阻塞): 線程等待獲取一個synchronized
監視器鎖。WAITING
(無限等待): 線程調用Object.wait()
、Thread.join()
等方法后進入此狀態,需要被其他線程顯式喚醒。TIMED_WAITING
(計時等待): 與WAITING
類似,但有超時限制,時間到了會自動返回RUNNABLE
狀態。TERMINATED
(終止):run()
方法執行完畢或因異常退出。
3.4 線程間的“對話”:基本操作
sleep()
與 wait()
的經典對比
sleep()
是讓線程“睡一會”,而wait()
是讓線程“等通知”。這是面試高頻題,也是理解線程協作的關鍵。
特性 | Thread.sleep(long millis) | Object.wait() |
---|---|---|
所屬類 | Thread (靜態方法) | Object (實例方法) |
鎖的釋放 | 不釋放對象鎖 | 釋放對象鎖 |
使用前提 | 任何地方都可以調用 | 必須在synchronized 代碼塊或方法中 |
喚醒方式 | 時間到期后自動喚醒 | 需要其他線程調用notify() 或notifyAll() |
join()
- 線程的協作
join()
方法允許一個線程等待另一個線程執行完成。就像接力賽跑,你必須等前一個隊友跑完把接力棒交給你,你才能開始跑。
// 在main線程中
Thread worker = new Thread(() -> {System.out.println("工作線程正在處理任務...");try { Thread.sleep(3000); } catch (InterruptedException e) {}
});
worker.start();
worker.join(); // main線程會在這里暫停,直到worker線程執行完畢
System.out.println("工作線程已結束,主線程繼續執行。");
interrupt()
- 優雅的通知機制
interrupt()
并非強制中斷線程,而是一種協作式的“打招呼”機制。它會設置目標線程的中斷標志位。
- 如果線程正在
sleep
、wait
或join
,它會立即被喚醒并拋出InterruptedException
,同時清除中斷標志位。 - 如果線程正在正常運行,它需要自己通過
Thread.currentThread().isInterrupted()
來檢查這個標志,并決定如何響應。
final Thread busyThread = new Thread(() -> {while (true) {} // 死循環,消耗CPU
}, "busyThread");busyThread.start();
busyThread.interrupt(); // 設置中斷標志// 等待片刻后檢查
System.out.println("busyThread isInterrupted: " + busyThread.isInterrupted()); // 輸出: true
yield()
- 主動的讓步
yield()
是一個靜態方法,它會向線程調度器暗示:當前線程愿意讓出CPU,給其他同等優先級的線程一個執行機會。但這僅僅是一個建議,調度器可能會忽略它,所以它不保證當前線程一定會暫停。
3.5 默默的守護者:Daemon線程
Java中的線程分為兩類:用戶線程(User Thread)和守護線程(Daemon Thread)。
- 用戶線程:是我們平時創建的普通線程,執行系統的業務邏輯。
- 守護線程:在后臺運行,為其他線程(主要是用戶線程)提供服務。最典型的例子就是垃圾回收(GC)線程。
當JVM中所有的用戶線程都執行完畢后,無論是否還有守護線程在運行,JVM都會退出。
Thread daemonThread = new Thread(() -> {while (true) {System.out.println("我是守護線程,正在后臺守護...");try {Thread.sleep(1000);} catch (InterruptedException e) {}}
});daemonThread.setDaemon(true); // 必須在start()之前設置
daemonThread.start();System.out.println("Main線程即將結束...");
// Main線程(用戶線程)結束后,JVM會退出,daemonThread也會隨之終止
一個重要的注意事項:守護線程在JVM退出時會被強制終止,其finally
代碼塊不保證一定會被執行。因此,不要在守護線程的finally
中執行關鍵的資源釋放操作。