Java併發程式設計實戰--筆記三
第8章:執行緒池的使用
// 在單執行緒Executor中任務發生死鎖(不要這麼做)
public class ThreadDeadlock {
ExecutorService exec = Executors.newSingleThreadExecutor();
public class RenderPageTask implements Callable<String> {
public String call() throws Exception {
Future<String> header, footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec.submit(new LoadFileTask("footer.html"));
String page = renderBody();
// 將發生死鎖——由於任務在等待子任務的結果
return header.get() + page + footer.get();
}
}
} // 單執行緒Executor,現在在執行RenderPageTask,而它依賴的兩個LoadFileTask卻會一直在等待被Executor執行。
每當提交了一個有依賴性的Executor任務時,要清楚地知道可能會出現執行緒“飢餓”死鎖,因此需要在程式碼或配置Executor的配置檔案中記錄執行緒池的大小限制或配置限制。
對於計算密集型的任務,在擁有Ncpu個處理器的系統上,當執行緒池的大小為Ncpu+1時,通常能實現最優的利用率。(即使當計算密集型的執行緒偶爾由於頁缺失故障或者其他原因而暫停時,這個“額外”的執行緒也能確保CPU的時鐘週期不會被浪費。)對於包含I/O操作或者其他阻塞操作的任務,由於執行緒並不會一直執行,因此執行緒池的規模應該更大。
基本大小也就是執行緒池的目標大小,即在沒有任務執行時執行緒池的大小(在建立ThreadPoolExecutor初期,執行緒並不會立即啟動,而是等到有任務提交時才會啟動,除非呼叫prestartAllCoreThreads),並且只有在工作佇列滿了的情況下才會建立超出這個數量的執行緒。執行緒池的最大大小表示可同時活動的執行緒數量的上限。如果某個執行緒的空閒時間超過了存活時間,那麼將被標記為可回收的,並且當執行緒池的當前大小超過了基本大小時,這個執行緒將被終止。
【開發人員以免有時會將執行緒池的基本大小設定為零,從而最終銷燬工作者執行緒以免阻礙JVM的退出。然而,如果線上程池中沒有使用SynchronousQueue作為其工作佇列(例如在newCachedThreadPool中就是如此,它的核心池設為0,但它的任務佇列使用的是SynchronousQueue),那麼這種方式將產生一些奇怪的行為。如果執行緒池中的執行緒數量等於執行緒池的基本大小,那麼僅當在工作佇列已滿的情況下ThreadPoolExecutor才會建立新的執行緒。因此,如果執行緒池的基本大小為零並且其工作佇列有一定的容量,那麼當把任務提交給該執行緒池時,只有當執行緒池的工作佇列被填滿後,才會開始執行任務,而這種行為通常不是我們所希望的。在Java6中,可以通過allowCoreThreadTimeOut來使執行緒池中的所有執行緒超時。對於一個大小有限的執行緒池並且在該執行緒池中包含了一個工作佇列,如果希望和這個執行緒池在沒有任務的情況下能銷燬所有的執行緒,那麼可以啟用這個特性並將基本大小設定為零。】
對於非常大的或者無界的執行緒池,可以通過使用SynchronousQueue來避免任務排隊,以及直接將任務從生產者移交給工作者執行緒。SynchronousQueue不是一個真正的佇列,而是一種線上程之間進行移交的機制。要將一個元素放入SynchronousQueue中,必須有另一個執行緒正在等待接受這個元素。如果沒有執行緒正在等待,並且執行緒池的當前大小小於最大值,那麼ThreadPoolExecutor將建立一個新的執行緒,否則根據飽和策略,這個任務將被拒絕。使用直接移交將更高效,因為任務會直接移交給執行它的執行緒,而不是被首先放在佇列中,然後由工作者執行緒從佇列中提取該任務。只有當執行緒池是無界的活著可以拒絕任務時,SynchronousQueue才有實際價值。在newCachedThreadPool工廠方法中就使用了SynchronousQueue。
當有界佇列被填滿後,飽和策略開始發揮作用。ThreadPoolExecutor的飽和策略可以通過呼叫setRectedExecutionHandler來修改。(如果某個任務被提交到一個已被關閉的Executor時,也會用到飽和策略。)JDK提供了幾種不同的RejectedExecutionHandler實現,每種實現都包含有不同的飽和策略:AbortPolicy、CallerRunsPolicy、DiscardPolicy和DiscardOldestPolicy。
飽和策略
“中止(Abort)“策略是預設的飽和策略,該策略將丟擲未檢查的RejectedExecutionException。呼叫者可以捕獲這個異常,然後根據需求編寫自己的處理程式碼。當新提交的任務無法儲存到佇列中等待執行時”,“拋棄”策略會悄悄拋棄該任務。“拋棄最舊的”策略則會拋棄下一個將被執行的任務,然後嘗試重新提交新的任務。(如果工作佇列是一個優先佇列,那麼“拋棄最舊的”策略將導致拋棄優先順序最高的任務,因此最好不要將“拋棄最舊的”飽和策略和優先佇列放在一起使用。)
“呼叫者執行”策略實現了一種調節機制,該策略既不會拋棄任務,也不會丟擲異常,而是將某些任務回退到呼叫者,從而降低新任務的流量。它不會線上程池的某個執行緒中執行新提交的任務,而是在一個呼叫了execute的執行緒中執行該任務。
我們可以將WebServer示例修改為使用有界佇列和“呼叫者執行”飽和策略,當執行緒池中的所有執行緒都被佔用,並且工作佇列被填滿後,下一個任務會在呼叫execute時在主執行緒中執行。由於執行任務需要一定的時間,因此主執行緒至少在一段時間內不能提交任何任務,從而使得工作者執行緒有時間來處理完正在執行的任務。在這期間,主執行緒不會呼叫accept,因此到達的請求將被儲存在TCP層的佇列中而不是在應用程式的佇列中。如果持續過載,那麼TCP層將最終發現它的請求佇列被填滿,因此同樣會開始拋棄請求。當伺服器過載時,這種過載情況會逐漸向外蔓延開來——從執行緒池到工作佇列到應用程式再到TCP層,最終到達客戶端,導致伺服器在高負載下實現一種平緩的效能降低。
如果在應用和程式中需要利用安全策略來控制對某些特殊程式碼庫的訪問許可權,那麼可以通過Executor中的privilegedThreadFactory工廠來定製自己的執行緒工廠。通過這種方式創建出來的執行緒,將與建立privilegedThreadFactory的執行緒擁有相同的訪問許可權、AccessControlContext和contextClassLoader。如果不使用privilegedThreadFactory,執行緒池建立的執行緒將從在需要新執行緒時呼叫execute或submit的客戶端程式碼中繼承訪問許可權,從而導致令人困惑的安全性異常。
如果需要提交一個任務集並等待它們完成,那麼可以使用ExecutorService.invokeAll,並且在所有任務都執行完成後呼叫CompletionService來獲取結果。
第9章:圖形使用者介面應用程式(略)
第10章:避免活躍性危險
如果所有執行緒一固定的順序來獲取的鎖,那麼在程式中就不會出現鎖鎖順序死鎖問題。
/**
* Returns the same hash code for the given object as
* would be returned by the default method hashCode(),
* whether or not the given object's class overrides
* hashCode().
* The hash code for the null reference is zero.
*
* @param x object for which the hashCode is to be calculated
* @return the hashCode
* @since JDK1.1
*/
public static native int identityHashCode(Object x);
如果某些任務需要等待其他任務的結果,那麼這些任務往往是產生執行緒飢餓死鎖的主要來源,有界執行緒池/資源池與相互依賴的任務不能一起使用。
在使用細粒度鎖的程式中,可以通過使用一種兩階段策略來檢查程式碼中的死鎖:首先,找出在什麼地方將獲取多個鎖(使這個集合儘量小),然後對所有這些例項進行全域性分析,從而確保它們在整個程式中獲取鎖的順序都保持一致。儘可能地使用開放呼叫,這能極大地簡化分析過程。如果所有的呼叫都是開放呼叫,那麼要發現獲取多個鎖的例項是非常簡單的,可以通過程式碼審查,或者藉助自動化的原始碼分析工具。
有一項技術可以檢測死鎖和從死鎖中恢復過來,即顯式使用Lock類中的定時tryLock功能來代替內建鎖機制。當使用內建鎖時,只要沒有獲得鎖,就會永遠等待下去,而顯式鎖則可以指定一個超時時限,在等待超過該時間後tryLock會返回一個失敗資訊。如果超時時限比獲取鎖的時間要長很多,那麼就可以在發生某個意外情況後重新獲得控制權。
當定時鎖失敗時,你並不需要知道失敗的原因。或許是因為發生了死鎖,或許某個執行緒在持有鎖時錯誤地進入了無限迴圈,還可能是某個操作的執行時間遠遠超過了你的預期。然而,至少你能記錄所發生的次數,以及關於這次操作的其他有用資訊,並通過一種更為平緩的方式來重新啟動計算,而不是關閉整個程序。
在Thread API中定義的執行緒優先順序只是作為執行緒排程的參考。在Thread API中定義了10個優先順序,JVM根據需要將它們對映到作業系統的排程優先順序。這種對映是在特定平臺相關的,因此在某個作業系統中兩個不同的Java優先順序可能被對映到同一個優先順序,而在另一個作業系統中則可能被對映到另一個不同的優先順序。在某些作業系統中,如果優先順序的數量少於10個,那麼有多個Java優先順序會被對映到同一個優先順序。
通常,我們儘量不要改變執行緒的優先順序。只要改變了執行緒的優先順序,程式的行為就將與平臺相關,並且會導致發生飢餓問題的風險。你經常能發現某個程式會在一些奇怪的地方呼叫Thread.sleep或Thread.yield,這是因為該程式視圖克服優先順序調整問題或響應性問題,並試圖讓低優先順序的執行緒執行那個更多地時間。
除飢餓以外的另一個問題是糟糕的響應性,如果在GUI應用程式中使用了後臺執行緒,那麼這種問題是很常見的。在第9章中開發了一個框架,併發執行時間較長的任務放到後臺執行緒中執行,從而不會使使用者介面失去響應。但CPU密集型的後臺任務仍然可能對響應性造成影響,因為它們會與事件執行緒共同競爭CPU的時鐘週期。如果由其他執行緒完成的工作都是後臺任務,那麼應該降低它們的優先順序,從而提高前臺程式的響應性。
第11章:效能與可伸縮性
可伸縮性指的是:當增加計算資源時(例如CPU、記憶體、儲存容量或I/O頻寬),程式的吞吐量或者處理能力響應地增加。
避免不成熟的優化。首先使程式正確,然後再提高執行速度——如果它還執行得不夠快。
在對效能的調優時,一定要有明確的效能需求(這樣才能知道什麼時候需要調優,以及什麼時候應該停止)。
以測試為基準,不要猜測。
在所有併發程式中都包含一些序列部分。如果你認為你的程式中不存在序列部分,那麼可以再仔細檢查一遍。
要想知道序列部分是如何隱藏在應用程式的架構中,可以比較當增加執行緒時吞吐量的變化,並根據觀察到的可伸縮性變化來推斷序列部分中的差異。
在評估一個演算法時,要考慮演算法在數百個或數千個處理器的情況下的效能表現,從而對可能出現的可伸縮性侷限有一定程度的認識。
切換上下文需要一定的開銷,而線上程排程過程中需要訪問由作業系統和JVM共享的資料結構。應用程式、作業系統以及JVM都使用一組相同的CPU。在JVM和作業系統的程式碼中消耗越多的CPU時鐘週期,應用程式的可用CPU時鐘週期就越少。當上下文切換的開銷並不只是包含JVM和作業系統的開銷,當一個新的執行緒被切換進來時,它所需要的資料可能不在當前處理器的本地快取中,因此上下文切換將導致一些快取缺失,因而執行緒在首次排程執行時會更加緩慢。這就是為什麼排程器會為每個可執行的執行緒分配一個最小執行時間,即使有許多其他的執行緒正在等待執行:它將上下文切換的開銷分攤到更多不會中斷的執行時間上,從而提高整體的吞吐量(以損失響應性為代價)。
當執行緒由於等待某個發生競爭的鎖而被阻塞時,JVM通常會將這個執行緒掛起,並允許它被交換出去。如果執行緒頻繁發生阻塞, 那麼它們將無法使用完整的排程時間片。在程式中發生越多的阻塞(包括阻塞I/O,等待獲取發生競爭的鎖,或者在條件變數上等待),與CPU密集型的程式就會發生越多的上下文交換,從而增加排程開銷,並因此而降低吞吐量。
上下文切換的實際開銷會隨著平臺的不同而變化,然而按照經驗來看:在大多數通用的處理器中 ,上下文切換的開銷相當於5000~10000個時鐘週期,也就是幾微秒。
UNIX系統的vmstat命令和Windows系統的perfmon工具都能報告上下文切換次數以及在核心中執行時間所佔比例等資訊。如果核心佔用率較高(超過10%),那麼通常表示排程活動發生得很頻繁,這很可能是由I/O或競爭鎖導致的阻塞引起的。
不要過度擔心非競爭同步帶來的開銷。這個基本的機制已經非常快了,並且JVM還能進行額外的優化以進一步降低或消除開銷。因此,我們應該將優化重點放在那些發生鎖競爭的地方。
在併發程式中,對可伸縮性的最主要威脅就是獨佔方式的資源鎖。
有三種方式可以降低鎖的競爭:
1、減少鎖的持有時間
2、降低鎖的請求頻率
3、使用帶有協調機制的獨佔鎖,這些機制允許更高的併發性.
如果在鎖上存在適中而不是激烈的競爭時,通過將一個鎖分解為兩個鎖,能最大限度地提升效能。如果對競爭並不激烈的鎖進行分解,那麼在效能和吞吐量等方面帶來的提升將非常有限,但是也會提高效能隨著競爭提高而下降的拐點值。對競爭適中的鎖進行分解時,實際上是把這些鎖轉變為非競爭的鎖,從而有效地提高效能和可伸縮性。
在某些情況下,可以將鎖分解技術進一步擴充套件為結一組獨立物件上的鎖進行分解,這種情況被稱為鎖分段。例如:ConcurrentHashMap的實現中使用了一個包含16個鎖的陣列,每個鎖保護所有雜湊桶的1/16,其中第N個雜湊桶由第(N mod 16)個鎖來保護。假設雜湊函式具有合理的分佈性,並且關鍵字能夠實現均勻分佈,那麼這大約能把對於鎖的請求減少到原來的1/16。
與採用單個鎖來實現獨佔訪問相比,要獲取多個鎖來實現獨佔訪問將更加困難並且開銷更高。通常,在執行一個操作時最多隻需獲取一個鎖,但在某些情況下需要加鎖整個容器,例如當ConcurrentHashMap需要擴充套件對映範圍,以及重新計算鍵值的雜湊值要分佈到更大的桶集合中時,就需要獲取分段鎖集合中所有的鎖。
如果程式採用鎖分段或分解技術,那麼一定要表現出在鎖上的競爭頻率高於在鎖保護的資料上發生競爭的頻率。
當每個操作都請求多個變數時,鎖的粒度將很難降低。這是在效能與可伸縮性之間相互制衡的另一個方面,一些常見的優化措施,例如將一些反覆計算的結果快取起來,都會引入一些”熱點域“,而這些熱點域往往會限制可伸縮性。
當實現HashMap時,你需要考慮如何在size方法中計算Map中的元素數量。最簡單的方法就是,在每次呼叫時都統計一次元素的數量。一種常見的優化措施是,在插入和移除元素時更新一個計數器,雖然這在put和remove等方法中略微增加了一些開銷,以確保計數器是最新的值,但這把size方法的開銷從O(n)降低到O(1)。
在單執行緒或者採用完全同步的實現中,使用一個獨立的計算器能很好地提高類似size和isEmpty這些方法的執行速度,但卻導致更難以提升實現的可伸縮性,因為每個修改map的操作都需要更新這個共享的計數器。即使使用鎖分段技術來實現雜湊鏈,那麼在對計數器的訪問進行同步時,也會重新導致在使用獨佔鎖時存在的可伸縮性問題。一個看似效能優化的措施——快取size操作的結果,已經變成了一個可伸縮性問題。在這種情況下,計數器也被稱為熱點域,因為每個導致元素數量發生變化的操作都需要訪問它。
為了避免這個問題,ConcurrentHashMap中的size將對每個分段進行列舉並將每個分段中的元素數量相加,而不是維護一個全域性計數。為了避免列舉每個元素,ConcurrentHashMap為每個分段都維護一個獨立的計數,並通過每個分段的鎖來維護這個值。
如果所有CPU的利用率並不均勻(有些CPU在忙碌地執行,而其他CPU卻並非如此),那麼你的首要目標就是進一步找出程式中的並行性。不均勻的利用率表明大多數計算都是由一小組執行緒完成的,並且應用程式沒有利用其他的處理器。
如果CPU沒有得到充分利用,那麼需要找出其中的原因。通常由以下幾種原因:
1、負載不充足。測試的程式中可能沒有足夠多的負載,因而還可以在測試時增加負載,並檢查利用率、響應時間和服務時間等指標的變化。如果產生足夠多的負載使應用程式達到飽和,那麼可能需要大量的計算機能耗,並且問題可能在於客戶端系統是否具有足夠的能力,而不是被測試系統。
2、I/O密集。可以通過iostat或perfmon來判斷某個應用程式是否是磁碟I/O密集型的,或者通過監測網路的通訊流量級別來判斷它是否需要高頻寬。
3、外部限制。如果應用程式依賴於外部服務,例如資料庫或Web服務,那麼效能瓶頸可能並不在你自己的程式碼中。可以使用某個分析工具或資料庫管理工具來判斷在等待外部服務的結果時需要多少時間。
4、鎖競爭。使用分析工具可以知道在程式中存在何種程度的鎖競爭,以及在哪些鎖上存在”激烈的競爭“。然而,也可以通過其他一些方式來獲得相同的資訊,例如隨機取樣,觸發一些執行緒轉儲並在其中查詢在鎖上發生競爭的執行緒。如果執行緒由於等待某個鎖而被阻塞,那麼線上程轉儲資訊中將存在相應的棧幀,其中包含的資訊形如”waiting to lock monitor…“。非競爭的鎖很少會出現線上程轉儲中,而對於競爭激烈的鎖,通常至少會有一個執行緒在等待獲取它,因此線上程轉儲中頻繁出現。
通常,物件分配操作的開銷比同步的開銷更低。
第12章 併發程式的測試(略)
個人微信公眾號: