Java 併發程式設計系列之帶你瞭解多執行緒
早期的計算機不包含作業系統,它們從頭到尾執行一個程式,這個程式可以訪問計算機中的所有資源。在這種情況下,每次都只能執行一個程式,對於昂貴的計算機資源來說是一種嚴重的浪費。
作業系統出現後,計算機可以執行多個程式,不同的程式在單獨的程序中執行。作業系統負責為各個獨立的程序分配各種資源。並且不同的程序間可以通過一些通訊機制來交換資料,比如:套接字、訊號處理器、共享記憶體、訊號量等。
一、瞭解多執行緒
1.1 程序與執行緒
想必大家都聽說過這兩個名詞,它們之間有什麼聯絡與不同呢?
記得當時上作業系統課時,書上有這麼一句話:程序是獨立擁有 cpu 資源的最 小單位,執行緒是接受 cpu 排程的最小單位。
程序中可以同時存在多個程式控制流程的執行緒,執行緒會共享程序範圍內的資源,因此一個程序可以有多個執行緒。打個比方,程序就像一個大工廠,而執行緒就是工廠的工人,多個工人負責協同完成工廠的任務。
1.2 多執行緒的優勢
- 發揮多處理器的強大能力
- 簡化建模過程
- 簡化非同步事件處理機制
- 響應更靈敏的使用者介面
1.3 多執行緒安全性問題
多執行緒是一把雙刃劍,使用多執行緒時意味著對開發人員有一定的技術要求。可見學會了多執行緒技術,就能寫出更優雅的程式碼了,哈哈~
多個執行緒同時訪問意味著“共享”變數,這些“共享”變數往往在其生命週期內是可變的,這樣以來就極易出現多執行緒安全性問題。
什麼是執行緒安全,這裡給一個比較準確的定義:當多個執行緒訪問某各類時,這個類始終都能表現出正確的行為,那麼就稱這個類是執行緒安全的。
二、Java 記憶體模型
為什麼要在多執行緒專題裡涉及到 Java 記憶體模型呢?我覺得它可以幫助我們更好的理解多執行緒的工作機制。
比如很多人都聽說過或瞭解過 volatile
關鍵字,都知道它能保證記憶體可見性問題,但是理解起來總是太過抽象。如果你瞭解了 Java 記憶體模型,它可以很好的幫助你理解這些問題。
Java 記憶體模型規定了所有的變數都儲存在主記憶體中(Main Memory)中(可以類比為物理硬體的主記憶體)。每條執行緒都有自己的工作記憶體(Working Memory),執行緒的工作記憶體中儲存了主記憶體中的變數的拷貝,執行緒對變數的所有操作(讀取、賦值)都必須在自己的工作記憶體中進行,而不能直接讀寫主記憶體中的變數。
不同的執行緒之間無法訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。
如果你對 Java 記憶體區域瞭解的話,很容易就會想工作記憶體、主記憶體與 Java 記憶體區域中的堆、棧、方法區是否有一定關係呢?
如果在例項變數的角度來看,主記憶體對應於 Java 堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分割槽域。從更低層次來看,主記憶體直接對應於物理硬體的記憶體。
三、解決執行緒安全性問題
使用 Java 建立執行緒的幾種方式這裡就不再贅述了,我們來了解幾種處理多執行緒安全性問題的方法。
3.1 synchronized 關鍵字
Java 提供了一種內建的鎖機制(synchronized
關鍵字)來保證原子性與記憶體可見性。關於原子性與記憶體可見性問題會在講述 volatile
關鍵字時詳細的介紹,感興趣的可以關注後面的文章。
Java 的內建鎖是一種互斥鎖,意味著最多隻有一個執行緒能持有這種鎖。當執行緒 A 和執行緒 B 同時訪問臨界資源,如果執行緒 A 獲取鎖,執行緒 B 就必須等待或阻塞,A 不釋放 B 就只能等待下去。
由於每次只有一個執行緒執行內建鎖內的程式碼,因此被 synchronized
關鍵字保護的臨界資源會以原子(一組不可分割的單元)的方式執行,多個執行緒間執行時不會受到干擾,原子性與資料庫事務有相同的含義。
synchronized
關鍵字可以用來修飾方法和程式碼塊,如果修飾非靜態方法和同步程式碼塊,使用的鎖是當前物件,如果修飾靜態方法和靜態程式碼塊,使用的是當前類的 Class 物件作為鎖。
使用方式如下
public class SyncTest {
private Integer num = 1;
public int numAutoIncrement() {
synchronized (this) {
return num ++;
}
}
public static void main(String[] args) {
SyncTest syncTest = new SyncTest();
// 開啟 10 個執行緒
for (int i = 0; i < 10; i++) {
new Thread(() ->
System.out.println(Thread.currentThread().getName()
+ ":" + syncTest.numAutoIncrement())
).start();
}
}
}
3.2 使用原子類
jdk1.5 之後 java.util.concurrent.atomic
包下引入了諸如 AtomicInteger
、AtomicLong
等特殊的原子性變數類。
這些變數類底層使用 CAS 演算法與 volatile
關鍵字確保對所有的計數器操作都能保證原子性與可見性,也就解決了多執行緒安全問題。
使用方式如下
public class SyncTest {
private AtomicInteger atomicInteger = new AtomicInteger(1);
public int numAutoIncrement() {
return atomicInteger.getAndIncrement();
}
public static void main(String[] args) {
SyncTest syncTest = new SyncTest();
for (int i = 0; i < 10; i++) {
new Thread(() ->
System.out.println(Thread.currentThread().getName()
+ ":" + syncTest.numAutoIncrement())
).start();
}
}
}
3.3 使用顯示 Lock 鎖
jdk1.5 之前,解決多執行緒共享物件訪問的機制只有 synchronized
和 volatile
。jdk1.5 新增了一種新的機制:ReentrantLock
。ReentrantLock
並不是為替代內建鎖而生的,當內建鎖機制不適用時,可以考慮使用這種更靈活的加鎖機制。
使用方式如下
public class SyncTest {
private Lock lock = new ReentrantLock();
private Integer num = 1;
public int numAutoIncrement() {
lock.lock();
try {
return num++;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
SyncTest syncTest = new SyncTest();
for (int i = 0; i < 10; i++) {
new Thread(() ->
System.out.println(Thread.currentThread().getName()
+ ":" + syncTest.numAutoIncrement())
).start();
}
}
}
從上面的程式碼可以看出來,使用顯示鎖機制相對內建鎖要麻煩一些,使用時要注意必須在 finally
語句塊中釋放鎖,否則當出現異常時,獲取到的鎖永遠不會被釋放,在程式碼中存在這種情況無疑是一種定時炸彈。
顯示鎖相較於內建鎖還提供了等待可中斷、公平與非公平等功能,當然還有一個優點是顯示鎖更加靈活。
PS:
這篇博文中很多知識點拿出來都可以單獨寫一篇文章,這裡只是總結了一部分重要的知識點,先讓大家對多執行緒有一個感性的認識。後面會陸續的補充併發程式設計系列的文章。如果大家覺得寫得還行的話,請持續關注後面的文章。
參考資料
《Java 併發程式設計實戰》
《深入理解 Java 虛擬機器》