第2~5章是原書的第二部分,介紹多線程的基礎知識,分別是:
- 第2章:線程安全性
- 第3章:共享對象
- 第4章:組合對象的線程安全性
- 第5章:線程安全組件
第2章介紹線程安全性的基本概念,內容如下。
狀態
每當我們談及線程安全的話題,都會想到Thread, Lock這些詞語,不過這些都是線程安全相關的工具,就像建築橋樑所用的釘子、磚頭。線程安全真正關注的核心問題是“正確地管理程序狀態”,更具體是“管理共享可變狀態”。
狀態可簡單理解爲內存數據,在java世界裏,狀態就是指存儲在狀態變量裏的數據,狀態變量可以是對象實例的字段,也可能是類的靜態字段。對象的狀態包括任何影響該對象外部行爲的字內部數據,因此,一個對象的狀態不僅包括於它的直接基本類型字段的狀態,也包括了它所的引用的其他對象的狀態。
並不是對象的所有字段都是它的狀態,如果某個字段並不影響對象的外部行爲,那麼就不是對象狀態的一部分。
在java裏,狀態往往是以對象的形式存在,爲了敘述方便,下文會在適當的場合用“對象”這個稱謂來代替“狀態”。
共享可變狀態
這裏的共享是指對象可在多個線程中被訪問,可變指在對象生命週期中它的狀態可能發生變化。如果對象只會被一個線程使用,那麼就不會有線程安全問題;同樣,如果對象是不可變的,那麼即使被多個線程訪問,也不會有安全問題。
這個概念提供了兩個指引:
1、一方面我們應該多使用非共享,或不可變的對象,以降低線程安全的風險;
2、另一方面,我們必須對共享可變對象採用某種線程安全手段。
多個線程訪問一個共享可變對象,如果不採取線程同步措施,可能導致數據崩潰或其他錯誤行爲。
什麼是線程安全
我們經常聽到一種說法,“一個線程安全的對象,可以在多線程中被安全地使用”,這其實是一句循環解釋的廢話。我們需要藉助“正確性”來定義線程的安全性,正確性可從兩方面定義:
-
不變式約束 (invarianty)
一個對象的狀態必然要滿足某些狀態約束,比如一個地理座標對象,內含經緯度座標值(x,y),顯然它必然滿足一些條件才能成爲一個有效的座標值。 -
滿足後置條件(postcondition)
對象在某個狀態下,執行了一個操作,需要到達一個正確的新狀態;比如向一個空的集合add一個元素,那麼集合的狀態必須是”非空“。
於是,我們可以這樣定義線程安全性:一個對象是線程安全的,指當對象被多個線程訪問時,無論線程被如何調度,線程之間的代碼執行如何交錯,它都能滿足“正確性”要求。
無狀態對象
假設一個對象沒有任何影響外部行爲的字段,這叫做無狀態對象。因爲線程安全是關於狀態的,所以無狀態對象天然就是線程安全的。
此類對象的典型是:工具算法類。
對象的線程安全性如何?
一個對象,我們是考慮它的線程安全屬性,不僅取決該對象的狀態及行爲,更重要是該對象將會被如何使用。是否要將一個對象設計爲線程安全的,是一個設計階段的決定;幾乎所有的GUI Framework相關對象都不是線程安全的,因爲
這裏的設計決策就是“只能在單一線程操作GUI組件”。
將一個非線程安全的對象改造爲線程安全,幾乎相當於重寫;雖然設計線程安全對象需要大量線程同步的技術,但遵循面向對象設計規範是一個良好的開端。
操作的原子性
下面是一段具備記錄執行次數的Servlet實現代碼:
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
++count;
}
}
有一定java基礎的人一定會判定,該類不是線程安全的,因爲++count
不是一個原子操作,CPU實際執行了”加載變量、加法計算、寫入變量“三個指令,在多線程環境下,count記錄的次數可能會比實際發生的要少。
那麼UnsafeCountingFactorizer是否是線程安全的呢?從技術角度來講,肯定不是。在具體應用場景下,取決於你對正確性的定義,如果我們只想大致記錄一下該servlet執行的次數,不關注精確性,那麼可以認爲它是線程安全的。
競爭條件
如果程序的正確性取決於多個線程之間指令執行的相對順序,那麼說明存在競爭條件。換句話說,程序能否正確運行需要看運氣,競爭一詞形象地描述了多個線程的相對執行順序的不確定性。
上面UnsafeCountingFactorizer就存在一個競爭條件,這是典型的"read->modify->write“競爭條件。另外一個常見的競爭條件是"check->then->act":檢查某個條件然後執行某個操作,線程競爭可能導致執行操作時條件不再滿足。
下面的延遲初始化單例模式實現,包含“check->then->act”模式的競爭條件:
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null)
instance = new ExpensiveObject();
return instance;
}
}
組合操作
競爭條件存在的根源在於組合操作的“非原子性”,對於UnsafeCountingFactorizer來說,如果"read->modify->write“這個組合操作是原子操作,那麼競爭條件就不復存在。
這裏的原子操作是一個相對的概念:一個操作A和操作B(AB可能等價)互爲原子操作,意味着當一個線程在執行A時,那麼一定沒有另外一個線程正在執行B,反過來亦然。
Atomic類
UnsafeCountingFactorizer只包含一個int類型的狀態,可以使用Atomic類型來封裝它,使得它的更新操作成爲原子操作。
public class UnsafeCountingFactorizer implements Servlet {
private AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
count.incrementAndGet();
}
}
jdk的java.util.concurrent.atomic包含一系列Atomic*類型,來支持基本類型的原子修改操作。但是如果對象狀態涉及多個字段,那麼Atomic類型就無能爲力了。
鎖
假如有一個提供計算服務的servlet,它接受一個long參數,並返回一個long型的計算結果;由於這個計算是一個很耗時的操作,我們準備在servlet裏緩存最後一次計算結果,如果恰好發生連續的、相同參數的調用,可直接使用緩存的結果。
public class UnsafeCacheServlet implements Servlet {
private AtomicLong param = new AtomicLong(0);
private AtomicLong result = new AtomicLong(0);
public void service(ServletRequest req, ServletResponse resp) {
long p = extractFromRequest(req);
if (p==param.get()) {
writeToResponse(resp,result.get())
} else {
long r = computeResult(p);
result.set(r)
writeToResponse(resp,r)
}
}
}
儘管Servlet使用了線程安全的字段類型,但是它自身不是線程安全的,因爲param和result之間存在約束關係,並不互相獨立,必須確保在同一個原子操作中修改二者。
監視器鎖(monitor lock)
Monitor鎖是java內置的一種鎖,它的用法如下:
synchronized (lock) {
// Access or modify shared state guarded by lock
}
synchronized使用的lock是任意的java對象,使用同一個lock對象的synchronized代碼塊互爲原子操作。任意一個線程在進入這個代碼塊會先加鎖,如果另個一線程正擁有lock監視鎖,線程會等待後者釋放鎖。當線程離開這個代碼塊時,就會立刻釋放鎖,即使發生異常。
synchronized可以加在方法上,表示以當前對象爲鎖,對整個方法體加鎖,用該方法可以輕易地將UnsafeCacheServlet變成線程安全的。
public class SafeCacheServlet implements Servlet {
private long param = 0L;
private long result = 0L;
public synchronized void service(ServletRequest req, ServletResponse resp) {
long p = extractFromRequest(req);
if (p==param) {
writeToResponse(resp,result)
} else {
long r = computeResult(p);
param = p;
result = r;
writeToResponse(resp,r)
}
}
}
-
互斥性
監視器鎖具備互斥性,也就是同一時刻,只有同一個線程持有監視器鎖。 -
可重入性(Reentrancy)
如果線程正持有一個監視器鎖,那麼該線程可進入同一鎖對象保護的代碼塊,這說明監視器鎖是可重入的。可重入鎖,是針對線程加鎖,而不是針對方法調用加鎖,這點很重要,便於我們封裝代碼,不至於引起意外的死鎖。
用鎖保護狀態
在SafeCacheServlet的例子中,我們用synchronized關鍵字使得對param和result的修改成爲原子操作,假設我們增加以下方法允許外部訪問緩存結果:
public class SafeCacheServlet implements Servlet {
private long param = 0;
private long result = 0;
public synchronized long getResultIfCached(int p) {
if (param==p) {
return result;
}
return -1
}
}
儘管方法getResultIfCached沒有修改狀態,但是仍然需要加上synchronized關鍵字,否則可能返回錯誤的結果。這說明,我們必須將所有對某個狀態的訪問操作,都加上同一個鎖,才能保證線程安全性。
鎖是用來保護狀態的,一個或一組狀態用一個鎖來保護,所有涉及該(組)狀態的讀寫操作都必須先加鎖。
在實際的設計中,必須非常清楚地指明:狀態使用哪一個鎖來保護的,否則代碼難以維護。
鎖的性能風險
由於SafeCacheServlet對service整個方法加鎖,相當於拒絕了該方法的併發訪問,在一個多核CPU的機器上,這是一段性能很差勁的代碼。我們可以按以下方式改進性能:
public class SafeCacheServlet implements Servlet {
private long param = 0L;
private long result = 0L;
public void service(ServletRequest req, ServletResponse resp) {
long p = extractFromRequest(req);
synchronized(this) {
if (p==param) {
writeToResponse(resp,result)
}
}
long r = computeResult(p);
synchronized(this) {
param = p;
result = r;
}
writeToResponse(resp,r)
}
}
改進的方式是縮小了鎖覆蓋的代碼範圍,我們再次提醒自己,需要保護的不是代碼而是狀態,所以上面將訪問狀態的兩段代碼分別用鎖覆蓋。
上面的改進之所以是必要的,是因爲computeResult是一個耗時的操作,否則將同步代碼塊一拆爲二未必能提升性能,畢竟加鎖&釋放鎖也有些許性能消耗。所以在實際工作中,所要同步的方法如果沒有包含IO操作、複雜耗時操作,或其他可能導致阻塞的操作,更推薦將鎖加在方法上。