Java的多線程是一把雙刃劍,使用好它可使咱們的程序更高效,可是出現併發問題時,咱們的程序將會變得很是糟糕。併發編程中須要注意三方面的問題,分別是安全性、活躍性和性能問題。html
咱們常常說這個方法是線程安全的、這個類是線程安全的,那麼到底該怎麼理解線程安全呢?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定律表明了處理器並行運算以後效率提高的能力。
假定F是必須被串行執行的部分,那麼根據Amdahl定律,在包含N個處理器的機器中,最高加速比爲:
\[Speedup <= \frac{1}{F+\frac{(1-F)}{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