java併發程式設計基礎——同步機制
執行緒安全章節我們分析了併發程式設計遇到的常見問題,並在文章的最後提到如何解決併發問題,其中提到了通過同步機制來解決共享變數有狀態問題。
同步概念
同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。
- 在共享記憶體併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。
- 在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。
Java的併發採用的是共享記憶體模型,需要程式設計師顯示的指定某個方法或某段程式碼需要執行緒之間互斥執行。
同步的目的:在多執行緒程式設計裡面,一些敏感共享資源不允許被多個執行緒同時訪問,此時就使用同步訪問技術,保證資料在任何時刻,最多有一個執行緒訪問,以保證資料的完整性。
java中同步方法
從廣義來說,java平臺提供的同步機制有鎖、volatile關鍵字、final關鍵字、wait/notifyAll以及併發包下的工具類如:Semaphore、Condition等。下面就其中常見同步機制進行說明:
鎖概述
利用鎖對共享變數提供保障,一個執行緒訪問共享資料前必須申請相應的鎖。當執行緒獲得某個鎖,稱該執行緒為鎖的持有執行緒,一個鎖一次只能被一個執行緒持有。鎖的持有執行緒可以對該鎖保護的共享變數進行訪問,並在訪問結束後釋放相應的鎖。
鎖的持有執行緒在獲取鎖之後和釋放鎖之前這段時間執行的程式碼被稱為臨界區。共享變數只允許在臨界區內進行訪問,臨界區一次只能被一個執行緒執行。具體可以用下圖示意:
Java平臺的鎖包括內部鎖——synchronized關鍵字實現和顯式鎖通過java.concurrent.locks.Lock介面實現。
synchronized
Java平臺中任何一個物件都有唯一一個與之關聯的鎖,被稱為監視器(Monitor)或內部鎖。這個內部鎖通過synchronized關鍵字實現的。synchronized有三種常用的方式如下:
class X {
// 修飾非靜態方法
synchronized void foo() {
// 臨界區
}
// 修飾靜態方法
synchronized static void bar () {
// 臨界區
}
// 修飾程式碼塊
Object obj = new Object();
void baz() {
synchronized(obj) {
// 臨界區
}
}
}
複製程式碼
- 修改非靜態方法鎖定的是當前物件例項this
- 修飾靜態方法鎖定的是當前類的Class物件
- 修飾程式碼塊鎖定的是obj物件
使用synchronized一定要搞清楚自己鎖定的物件是誰,保護的共享變數是誰。
synchronized原理
首先我們反編譯synchronized使用中給出的示例的程式碼如下圖所示,其中標註出了monitorenter和monitorexit兩個位元組碼指令,這兩個指令都需要一個reference型別的引數來指明要鎖定和解鎖的物件。
java程式中如果synchronized明確指定了物件引數,那麼就是這個物件的reference;如果沒有明確指出,則根據synchronized修飾的例項方法還是類方法來確定,如果是類方法則取Class物件作為鎖物件。總結下來,鎖物件分為如下兩類- synchronized(this)以及非static的synchronized方法,則鎖定呼叫物件本身
- static修飾的靜態方法以及synchronized(xxx.class),則鎖定類的Class物件,因為一個類的Class物件只有一個,所以該類的所有相關物件都共享一把鎖。
根據jvm規範,在執行monitorenter指令,執行緒首先要嘗試獲取reference對應的物件鎖
- 如果該物件鎖沒有被鎖定佔有,或者改執行緒之前已經擁有了該物件鎖,則把鎖的計數器加1。
- 相應地,在執行monitorexit指令時會將該物件鎖計數器減1,當計數器為0時,鎖就被釋放了。其他被這個物件鎖阻塞的執行緒可以嘗試去獲取這個物件鎖的所有權。
Lock
javaSE5之後,並法包新增了Lock介面用來實現鎖功能,它提供了與synchronized類似的同步功能,只是使用的時候需要顯示的獲取和釋放鎖,同時這些介面也提供了synchronized不具備的特性如下表所示:
特性 | 描述 |
---|---|
嘗試非阻塞獲取鎖 | 當前執行緒嘗試獲取鎖,如果這一刻鎖沒有被其他執行緒獲取到,則成功獲取持有鎖 ,不會阻塞等待鎖釋放 |
被中斷的獲取鎖 | 與synchronized不同,獲取到鎖的執行緒能夠響應中斷,當獲取到的鎖的執行緒被中斷時,中斷異常將會被丟擲,同時鎖會被釋放 |
超時獲取鎖 | 介面在指定的截止時間之前獲取鎖,如果截止時間到了依舊無法獲取鎖,則返回 |
對應上述特性的程式碼:
// 支援中斷的 API
void lockInterruptibly() throws InterruptedException;
// 支援超時的 API
boolean tryLock(long time,TimeUnit unit) throws InterruptedException;
// 支援非阻塞獲取鎖的 API
boolean tryLock();
複製程式碼
ReentrantLock重入鎖
ReentrantLock是Lock介面一種常見的實現,它是支援重進入的鎖即表示在呼叫lock()方法時,已經獲取鎖的執行緒能夠再次呼叫lock()方法而不被阻塞。同時,該鎖還支援獲取鎖時的公平與非公平的選擇。 最後,ReentrantLock是排他鎖,該鎖在同一時刻只允許一個執行緒來訪問。 關於公平與非公平幾點說明:
- 如果在絕對時間上,先對於鎖進行獲取的請求一定先被滿足,那麼這個鎖就是公平的,反之就是非公平的。
- 公平的獲取鎖也就是等待時間最久的執行緒優先獲取到鎖。ReentrantLock的建構函式來控制是否為公平鎖。
- 通常情況下,公平鎖保證了獲取鎖按照FIFO原則,而代價就是大量的執行緒切換,導致效能下降。而非公平有可能導致部分執行緒飢餓,但是保證了更大的吞吐量。
ReentrantLock通用使用模式如下注意主動釋放鎖:
private final Lock rtl = new ReentrantLock();
// 獲取鎖
rtl.lock();
try {
// 臨界區
} finally {
// 保證鎖能釋放
rtl.unlock();
}
複製程式碼
讀寫鎖
前面提到的ReentrantLock是排他鎖,該鎖在同一時刻只允許一個執行緒來訪問,而讀寫鎖在同一時刻允許可以有多個執行緒來訪問,但在寫執行緒訪問時,所有的讀執行緒和其他寫執行緒被阻塞。 讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,其中讀鎖是一個共享鎖可以被多個執行緒同時獲取,而寫鎖是一個支援衝進入的排它鎖。讀寫鎖例項ReentrantReadWriteLock有以下特性:
- 公平性選擇:和ReentrantLock類似
- 重進入:可重入鎖,特別注意寫執行緒在獲取寫鎖之後能夠再次獲取寫鎖,同時也可以獲取讀鎖。
- 鎖降級:遵循獲取寫鎖,獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級為讀鎖
- 任何一個執行緒持有一個讀鎖的時候,其他任何執行緒都無法獲取相應鎖的寫鎖。這保證了讀執行緒在讀取共享變數期間沒有其他執行緒能夠對其進行更新
public class Cache<K,V> {
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private ReentrantReadWriteLock.ReadLock r = rwl.readLock();
private ReentrantReadWriteLock.WriteLock w = rwl.writeLock();
private Map<K,V> cache = new HashMap();
public V getKey(K key) {
V result = null;
r.lock();
try {
result = cache.get(key);
} finally {
r.unlock();
}
if (result != null) {
return result;
}
w.lock();
try {
result = cache.get(key);
if (result == null) {
// db查獲取value
V v = null;
result = v;
putValue(key,v);
}
} finally {
w.unlock();
}
return result;
}
public V putValue(K key,V value) {
w.lock();
try {
return cache.put(key,value);
}finally {
w.unlock();
}
}
}
複製程式碼
該示例中使用非執行緒安全的HashMap作為快取實現,通過使用讀寫鎖來保證執行緒的安全。分析程式碼,在讀取操時需要獲取讀鎖為共享鎖支援多執行緒同時訪問不被阻塞。在寫操作時,首先獲取寫鎖,當獲取寫鎖後,其他執行緒對於讀鎖和寫鎖的獲取均被阻塞,只有寫鎖被釋放後,其他操作才可以繼續,這樣也保證了所有讀操作都是最新資料。
輕量級同步volatile
volatile關鍵字常被稱為輕量級鎖,其作用和鎖的作用有相同的地方:保證可見性和有序性。具體分析可以見java記憶體模型裡面有詳細分析。