前言
Java的多線程是一把雙刃劍,使用好它可以使我們的程序更高效,但是出現並發問題時,我們的程序將會變得非常糟糕。並發編程中需要注意三方面的問題,分別是安全性、活躍性和性能問題。
安全性問題
我們經常說這個方法是線程安全的、這個類是線程安全的,那么到底該怎么理解線程安全呢?
要給線程安全性定一個非常明確的定義是比較復雜的。越正式的定義越復雜,也就越難理解。但是不管怎樣,在線程安全性定義中,最核心的概念還是正確性,可以簡單的理解為程序按照我們期望的執行。
正確性的含義是:某個類的行為與其規范完全一致。線程的安全性就可以理解為:當多個線程訪問某個類時,這個類始終都能表現出正確的行為,那么就稱這個類是線程安全的。
我們要想編寫出線程安全的程序,就需要避免出現並發問題的三個主要源頭:原子性問題、可見性問題和有序性問題。(前面的文章介紹了規避這三個問題的方法)當然也不是所有的代碼都需要分析這三個問題,只有存在共享數據並且該數據會發生變化,即有多個線程會同時讀寫同一個數據時,我們才需要同步對共享變量的操作以保證線程安全性。
這也暗示了,如果不共享數據或者共享數據狀態不發生變化,那么也可以保證線程安全性。
綜上,我們可以總結出設計線程安全的程序可以從以下三個方面入手:
- 不在線程之間共享變量。
- 將共享變量設置為不可變的。
- 在訪問共享變量時使用同步。
我們前面介紹過使用Java中主要的同步機制synchronized關鍵字來協同線程對變量的訪問,synchronized提供的是一種獨占的加鎖方式。同步機制除了synchronized內置鎖方案,還包括volatile類型變量,顯式鎖(Explicit Lock)以及原子變量。而基於一二點的技術方案有線程本地存儲(Thread Local Storage, LTS)、不變模型等(后面會介紹)。
數據競爭
當多個線程同時訪問一個數據,並且至少有一個線程會寫這個數據時,如果我們不采用任何 同步機制協同這些線程對變量的訪問,那么就會導致並發問題。這種情況我們叫做數據競爭(Data Race)。
例如下面的例子就會發生數據競爭。
public class Test {
private long count = 0;
void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
}
當多個線程調用add10K()時,就會發生數據競爭。但是我們下面使用synchronized同步機制就可以來防止數據競爭。
public class Test {
private long count = 0;
synchronized long get(){
return count;
}
synchronized void set(long v){
count = v;
}
void add10K() {
int idx = 0;
while(idx++ < 10000) {
set(get()+1);
}
}
}
競態條件
但是此時的add10K()方法並不是線程安全的。
假設count=0, 當兩個線程同時執行get()方法后,get()方法會返回相同的值0,兩個線程執行get()+1操作,結果都是1,之后兩個線程再將結果1寫入了內存。本來期望的是2,但是結果卻是1。(至於為什么會同時?我當初腦袋被“阻塞”好一會兒才反應過來,哈哈,╮(~▽~)╭,看來不能熬夜寫博客。因為如果實參需要計算那么會先被計算,然后作為函數調用的參數傳入。這里get()會先被調用,等其返回了才會調用set(),所以一個線程調用完了get()后,另一個線程可以馬上獲取鎖調用get()。這也就會造成兩個線程會得到相同的值。)
這種情況,我們稱為競態條件(Race Condition)。競態條件,是指程序的執行結果依賴線程執行的順序 。
上面的例子中,如果兩個線程完全同時執行,那么結果是1;如果兩個線程是前后執行,那么結果就是2。在並發環境里,線程的執行順序是不確定的,如果程序存在競態條件問題,那么就意味着程序執行的結果是不確定的,而執行結果不確定就是一個大問題。
我們前面講並發bug源頭時,也介紹過競態條件。由於不恰當的執行時序而導致的不正確的結果。要避免競態條件問題,就必須在某個線程修改該變量時,通過某種方式防止其他線程使用這個變量,從而確保其他線程只能在修改操作完成之前或者之后讀取和修改狀態,而不是在修改狀態的過程中。
解決這個例子的競態條件問題,我們可以介紹過的加鎖機制來保證:其他線程只能在修改操作完成之前或者之后讀取和修改狀態,而不是在修改狀態的過程中。
public class Test {
private long count = 0;
synchronized long get(){
return count;
}
synchronized void set(long v){
count = v;
}
void add10K() {
int idx = 0;
while(idx++ < 10000) {
synchronized(this){
set(get()+1);
}
}
}
}
所以面對數據競爭和競態條件我們可以使用加鎖機制來保證線程的安全性!
活躍性問題
安全性的含義是“永遠不發生糟糕的事情”,而活躍性則關注另外一個目標,即“某件正確的事情最終會發生”。 當某個操作無法繼續執行下去時,就會發生活躍性問題。
在串行程序中,活躍性問題的形式之一便是無意中造成的無限循環。從而使循環之后的代碼無法被執行。而線程將會帶來其他的一些活躍性問題,例如我們前面所講的死鎖,以及我們下面將要介紹的飢餓和活鎖。
飢餓
飢餓(Starvation)指的是線程無法訪問到所需要的資源而無法執行下去的情況。
引發飢餓最常見的資源便是CPU時鍾周期。如果Java應用程序中對線程的優先級使用不當,或者在持有鎖時執行一些無法結束的結構(例如無限循環或者無限制地等待某個資源),那么也可能導致飢餓,因為其他需要這個鎖的線程無法得到它。
通常,我們盡量不要改變線程的優先級,在大部分並發應用程序中,可以使用默認的線程優先級。只要改變了線程的優先級,程序的行為就將與平台相關,並且可能導致發生飢餓問題的風險(例如優先級高的線程會一直獲取資源,而低優先級的線程則將一直無法獲取到資源)。
當某個程序會在一些奇怪的地方調用Thread.sleep或Thread.yield,那是這個程序在試圖克服優先級調整問題或響應性問題,並試圖讓低優先級的線程執行更多的時間。
飢餓問題的實質可以用孔子老人家說過的一句話來總結:不患寡而患不均。
解決飢餓問題,有以下三種方案:
- 保證資源充足。
- 公平地分配資源。
- 避免持有鎖的線程長時間執行。
這三個方案中,方案一和方案三的適用場景比較有限,因為很多場景下,資源的稀缺性是沒辦法解決的,持有鎖的線程執行的時間也很難縮短。所以,方案二的適用場景會多一點。在並發編程里,我們可以使用公平鎖來公平的分配資源。所謂公平鎖,是一種FIFO方案,線程的等待是有順序的,排在等待隊列前面的線程會優先獲得資源。
活鎖
活鎖(Livelock)是另一種形式的活躍性問題,它和死鎖很相似,但是它卻不會阻塞線程。活鎖盡管不會阻塞線程,但也不能繼續執行,因為線程將不斷重復執行相同的操作,而且總會失敗。
活鎖通常發生在處理事務消息的應用程序中:如何不能成功地處理某個消息,那么消息處理機制將回滾整個事務,並將它重新放置到隊列的開頭。如果消息處理器在處理某種特定的消息時存在錯誤並導致它失敗,那么每當這個消息從隊列中取出並傳遞到存在錯誤的處理器時,都會發生事務回滾。由於這個消息又被放到隊列開頭,因此處理器將被反復調用,並返回相同的處理結果。(有時候也被稱為毒葯消息,Poison Message。)雖然處理消息的線程沒有被阻塞,但也無法執行下去。這種形式的活鎖,通常由過度的錯誤恢復代碼造成,因為它錯誤地將不可修復的錯誤作為可修復的錯誤。
當多個相互協作的線程都對彼此進行響應從而修改各自的狀態,並使得任何一個線程都無法繼續執行時,就發生了活鎖。 這就好比兩個過於禮貌的人在半路上相遇,為了不相撞,他們彼此都給對方讓路,結果導致他們又相撞。他們如此反復下一,便造成了活鎖問題。
解決這種活鎖問題,我們在重試機制中引入隨機性。即,讓他們在謙讓時嘗試等待一個隨機的時間。如此,他們便不會相撞而順序通行。我們在以太網協議的二進制指數退避算法中,也可以看到引入隨機性降低沖突和反復失敗的好處。在並發應用程序中,通過等待隨機長度的時間和回退可以有效避免活鎖的發生。
性能問題
與活躍性問題密切相關的是性能問題。活躍性意味着某件正確的事情最終會發生,但卻不夠好,因為我們通常希望正確事情盡快發生。性能問題包括多個方面,例如服務時間過長,響應不靈敏,吞吐量過低,資源消耗過高,或者可伸縮性降低等。與活躍性和安全性一樣,在多線程程序中不僅存在與單線程程序相同的性能問題,而且還存在由於實現線程而引入的其他性能問題。
我們使用多線程的目的是提升程序的整體性能,但是與單線程的方法相比,使用多個線程總會引入一些額外的性能開銷。造成這些開銷的操作包括:線程之間的協調(如加鎖、內存同步等),增加上下文切換,線程的創建和銷毀,以及線程的調度等。如果我們多度地使用線程,那么這些開銷可能超過由於提高吞吐量、響應性或者計算能力所帶來的性能提升。另一方面,一個並發設計很糟糕的程序,其性能甚至比完成相同功能的串行程序性能還要低。
想要通過並發來獲得更好的性能就需要做到:更有效地利用現有處理資源,以及在出現新的處理資源時使程序盡可能地利用這些新資源。
下面我們將介紹如何評估性能、分析多線程帶來的額外開銷以及如何減少這些開銷。
性能和可伸縮性
應用程序的性能可以采用多個指標來衡量,例如服務時間、延遲時間、吞吐量、效率、可伸縮性以及容量等。其中一些指標(服務時間、等待時間)用於衡量程序的“運行速度”,即某個指定的任務單元需要“多快”才能處理完成。另一些指標(生產量、吞吐量)用於程序的“處理能力”,即在計算資源一定的情況下,能完成“多少”工作。
可伸縮性指的是:當增加計算資源(例如CPU、內存、存儲容量或者I/O帶寬)時,程序的吞吐量或者處理能力相應地增加。在對可伸縮性調優時,目的是將設法將問題的計算並行化,從而能夠利用更多的計算資源來完成更多的任務。而我們傳統的對性能調優,目的是用更小的代價完成相同的工作,例如通過緩存來重用之前的計算結果。
Amdahl定律
大多數的並發程序都是由一系列的並行工作和串行工作組成。
Amdahl定律描述的是:在增加計算資源的情況下,程序在理論上能夠實現最高加速比,這個值取決於程序中可並行組件與串行組件所占比重。簡單點說,Amdahl定律代表了處理器並行運算之后效率提升的能力。
假定F是必須被串行執行的部分,那么根據Amdahl定律,在包含N個處理器的機器中,最高加速比為:
當N趨近於無窮大時,最高加速比趨近於\(\frac{1}{F}\) 。因此,如果程序有50%的計算需要串行執行,那么最高加速比只能是2,而不管有多個線程可用。無論我們采用什么技術,最高也就只能提升2倍的性能。
Amdahl定律量化了串行化的效率開銷。在擁有10個處理器的系統中,如果程序中有10%的部分需要串行執行,那么最高加速比為5.3(53%的使用率),在擁有100個處理器的系統中,加速比可以達到9.2(92%的使用率)。但是擁有無限多的處理器,加速比也不會到達10。
如果能准確估計出執行過程中穿行部分所占的比例,那么Amdahl定律就可以量化當有更多計算資源可用時的加速比。
線程引入的開銷
在多個線程的調度和協調過程中都需要一定的性能開銷。所以我們要保證,並行帶來的性能提升必須超過並發導致的開銷,不然這就是一個失敗的並發設計。下面介紹並發帶來的開銷。
上下文切換
如果主線程是唯一的線程,那么它基本上不會被調度出去。如果可運行的線程數目大於CPU的數量,那么操作系統最終會將某個正在運行的線程調度出來,從而使其他線程能夠使用CPU。這將導致一次上下文切換,在這個過程中,將保存當前運行線程的執行上下文,並將新調度進來的線程的執行上下文設置為當前上下文。
切換上下文需要一定的開銷,而在線程調度過程中需要訪問由操作系統和JVM共享的數據結構。上下文切換的開銷不止包含JVM和操作系統的開銷。當一個新的線程被切換進來時,它所需要的數據可能不在當前處理器的本地緩存中,因此上下文切換將導致一些緩存缺失(丟失局部性),因而線程在首次調度運行時會更加緩慢。
調度器會為每個可運行的線程分配一個最小執行時間,即使有許多其他的線程正在等待執行:這是為了將上下文切換的開銷分攤到更多不會中斷的執行時間上,從而提高整體的吞吐量(以損失響應性為代價)。
當線程被頻繁的阻塞時,也可能會導致上下文切換,從而增加調度開銷,降低吞吐量。因為,當線程由於沒有競爭到鎖而被阻塞時,JVM通常會將這個線程掛起,並允許它被交換出去。
上下文切換的實際開銷會隨着平台的不同而變化,按照經驗來看:在大多數通用的處理器上,上下文切換的開銷相當於5000~10000個時鍾周期,也就是幾微秒。
內存同步
同步操作的性能開銷包括多個方面。在synchronized和volatile提供的可見性保證中可能會使用一些特殊指令,即內存柵欄(也就是我們前面文章介紹過的內存屏障)。內存柵欄可以刷新緩存,使緩存無效,刷新硬件的寫緩沖,以及停止執行管道。內存柵欄可能同樣會對性能帶來間接的影響,因為它們將抑制一些編譯器優化操作。在內存柵欄中,大多數的操作都是不能被重排序的。
在評估同步操作帶來的性能影響時,需要區分有競爭的同步和無競爭的同步。現代的JVM可以優化一些不會發生競爭的鎖,從而減少不必要的同步開銷。
synchronized(new Object()){...}
JVM會通過逃逸分析優化掉以上的加鎖。
所以,我們應該將優化重點放在那些發生鎖競爭的地方。
某個線程的同步可能會影響其他線程的性能。同步會增加共享內存總線上的通信量,總線的帶寬是有限的,並且所有的處理器都將共享這條總線。如果有多個線程競爭同步帶寬,那么所有使用了同步的線程都會受到影響。
阻塞
非競爭的同步可以完全在JVM中處理,而競爭的同步可能需要操作系統的介入,從而增加系統的開銷。在鎖上發生競爭時,競爭失敗的線程會被阻塞。JVM在實現阻塞行為時,可以采用自旋等待(Spin-Waitiin,指通過循環不斷地嘗試獲取鎖,直到成功)或者通過操作系統掛起被阻塞的線程。這兩種方式的效率高低,取決於上下文切換的開銷以及在成功獲取鎖之前需要等待的時間。如果等待時間短,就采用自旋等待方式;如果等待時間長,則適合采用線程掛起的方式。JVM會分析歷史等待時間做選擇,不過,大多數JVM在等待鎖時都只是將線程掛起。
線程被阻塞掛起時,會包含兩次的上下文切換,以及所有必要的操作系統操作和緩存操作。
減少鎖的競爭
串行操作會降低可伸縮性,並且上下文切換也會降低性能。當在鎖上發生競爭時會同時導致這兩種問題,因此減少鎖的競爭能夠提高性能和可伸縮性。
在對某個獨占鎖保護的資源進行訪問時,將采用串行方式——每次只有一個線程能訪問它。如果在鎖上發生競爭,那么將限制代碼的可伸縮性。
在並發程序中,對可伸縮性的最主要的威脅就是獨占方式的資源鎖。
有兩個因素將影響在鎖上發生競爭的可能性:鎖的請求頻率和每次持有該鎖的時間。(Little定律)
如果二者的乘積很小,那么大多數獲取鎖的操作都不會發生競爭,因此在該鎖上的競爭不會對可伸縮性造成嚴重影響。
下面介紹降低鎖的競爭程度的方案。
縮小鎖的范圍
降低發生競爭的可能性的一種有效方式就是盡可能縮短鎖的持有時間。例如,可以將一些與鎖無關的代碼移除代碼塊,尤其是那些開銷較大的操作,以及可能被阻塞的操作(I/O操作)。
盡管縮小同步代碼塊能提高可伸縮性,但同步代碼塊也不能太小,因為會有一些復合操作需要以原子操作的方式進行,這時就必須在同一同步塊中。
減小鎖的粒度
另一種減少鎖的持有時間的方式便是降低線程請求鎖的頻率(從而減小發生競爭的可能性)。這可以通過鎖分解和鎖分段等技術來實現,這些技術中將采用多個相互獨立的鎖來保護相互獨立的狀態變量,從而改變這些變量在之前由單個鎖來保護的情況。這些技術能縮小鎖操作的粒度,並能實現更高的可伸縮性。但是需要注意,使用的鎖越多,也就越容易發生死鎖。
鎖分解
如果一個鎖需要保護多個相互獨立的狀態變量,那么可以將這個鎖分解為多個鎖,並且每個鎖只保護一個變量,從而提高可伸縮性,並最終降低每個鎖被請求的頻率。
例如,如下的程序我們便可以進行鎖分解。(例子來自《Java並發編程實踐》)
@ThreadSafe // 該注解表示該類是線程安全的
public class ServerStatus {
// @GuardedBy(xxx)表示該狀態變量是由xxx鎖保護
@GuardedBy("this") public final Set<String> users;
@GuardedBy("this") public final Set<String> queries;
public ServerStatusBeforeSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
public synchronized void addUser(String u) {
users.add(u);
}
public synchronized void addQuery(String q) {
queries.add(q);
}
public synchronized void removeUser(String u) {
users.remove(u);
}
public synchronized void removeQuery(String q) {
queries.remove(q);
}
}
以上程序表示的是某個數據庫服務器的部分監視接口,該數據庫維護了當前已經登錄的用戶以及正在執行的請求。當一個用戶登錄、注銷、開始查詢或者結束查詢時,都會調用相應的add或者remove方法來更新ServerStatus對象。這兩種類型信息是完全獨立的,因此,我們可以嘗試用鎖分解來提升該程序的性能。
@ThreadSafe
public class ServerStatus{
@GuardedBy("users") public final Set<String> users;
@GuardedBy("queries") public final Set<String> queries;
public ServerStatusAfterSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
public void removeUser(String u) {
synchronized (users) {
users.remove(u);
}
}
public void removeQuery(String q) {
synchronized (users) {
queries.remove(q);
}
}
}
我們將原來的ServerStatus分解,使用新的細粒度鎖來同步對狀態變量的維護。減少了鎖的競爭,提升了性能。
鎖分段
把一個競爭激烈的鎖分解為兩個鎖時,這兩個鎖可能都存在激烈的競爭。在上面的鎖分解例子中,並不能進一步對鎖進行分解。
在某些情況下,可以將鎖分解技術進一步擴展為對一組獨立對象上的鎖進行分解,這種情況被稱為鎖分段。
例如,ConcurrentHashMap的實現中使用了一個包含16個鎖的數組,每個鎖保護所有散列桶的\(\frac{1}{16}\) ,其中第N個散列桶由第(N mod 16)個鎖來保護。
假設散列函數具有合理的分布性,並且關鍵字能夠實現均勻分布,那么這大約能把對於鎖的請求減少到原來的\(\frac{1}{16}\) 。正是因為這項技術,使用ConcurrentHashMap可以支持多大16個並發的寫入器。
鎖分段的一個劣勢在於:需要獲取多個鎖來實現獨占訪問將更加困難且開銷更高。例如當ConcurrentHashMap需要擴展映射范圍,以及重新計算鍵值的散列值需要分不到更大的桶集合中時,就需要獲取所有分段鎖。
下面的代碼展示了在基於散列的Map中使用鎖分段的技術。它擁有N_LOCKS個鎖,並且每個鎖保護散列桶的一個子集。大多數方法都只需要獲得一個鎖,如get(),而有些方法則需要獲取到所有的鎖,但不要求同時獲得,如clear()。(例子來自《Java並發編程實踐》)
@ThreadSafe
public class StripedMap {
// Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node {
Node next;
Object key;
Object value;
}
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++)
locks[i] = new Object();
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next)
if (m.key.equals(key))
return m.value;
}
return null;
}
public void clear() {
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
}
一些代替獨占鎖的方法
除了縮小鎖的范圍、減少請求鎖的粒度,還有第三種降低鎖的影響的技術就是放棄使用獨占鎖。
使用一些無鎖的算法或者數據結構來管理共享狀態。例如,使用並發容器、讀-寫鎖、不可變對象以及原子變量。
后面也會陸續介紹這些方案。
小結
結合我們前面講的並發知識,我們現在可以從微觀和宏觀來理解並發編程。在微觀上,設計並發程序時我們要考慮到原子性、可見性和有序性問題。跳出微觀,從宏觀上來看,我們設計程序,要考慮到到線程的安全性、活躍性以及性能問題。我們在做性能優化的前提是要保證線程安全性,如果會優化后出現並發問題,那么結果將會與我們的預期背道而馳。
參考:
[1]極客時間專欄王寶令《Java並發編程實戰》
[2]Brian Goetz.Tim Peierls. et al.Java並發編程實戰[M].北京:機械工業出版社,2016
