《Java併發編程實踐》二(2):什麼是線程安全

第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操作、複雜耗時操作,或其他可能導致阻塞的操作,更推薦將鎖加在方法上。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章