Java併發編程筆記摘要

    多線程設計的目的是爲了更多的榨取服務器硬件的性能,但是線程仍然會給運行時帶來一定程度的開銷。上下文切換——當調度程序臨時掛起當前運行的線程時,另 外一個線程開始運行——這在多個線程組成的應用程序中是很頻繁的,並且帶來巨大的系統開銷:保存和恢復線程執行的上下文,離開執行現場,並且 CPU  的時間會花費在對線程的調度而不是運行上。當線程共享數據的時候,它們必須使用的同步機制,這個機制會限制編譯器的優化,能夠清空或鎖定內存和高速緩存,並在共享內存的總線上創建同步通信。

      無論何時,只要有多餘一個的線程訪問給定的狀態變量,而且其中某個線程會寫入該變量,此時必須使用同步來協調線程對該變量的訪問。

      在沒有正確同步的情況下,如果多個線程訪問了同一個變量,有三種方法可以安全訪問它: 不要跨線程共享變量;使狀態變量爲不可變;在任何訪問狀態變量的時候使用同步。

      通常簡單性與性能之間是相互牽制的。實現一個同步策略時,不要過早地爲了性能而犧牲簡單性。(因爲你不能預測到該邏輯是否爲性能的瓶頸。大多數的性能是由架構決定的,而非單獨的某塊邏輯。)

      有些耗時的計算或操作,比如網絡或者控制檯 IO  ,難以快速完成。執行這些操作期間不要佔有鎖。

      在沒有同步的情況下,不能保證讀線程及時地讀取其他線程寫入的值。(因爲線程有自己的工作內存,沒有同步不能保證工作內存的值即時同步到主存中)

      Java存儲模型要求獲取和存儲操作都爲原子的,但是對於非  volatile  的  long  和  double  變量,  JVM  允許將  64  位的讀或寫劃分爲兩個  32  位的操作。如果讀和寫發生在不同的線程,這種情況讀取一個非  volatile  類型  long  就可能出現得到一個值爲高  32  位和另一個值的低  32  位。因此對於  long  和  double  值在多線程的共享的情況下,應該聲明爲  volatile  類型。

      鎖不僅僅是關於同步與互斥的,也是關於 內存可見 的。爲了保證所有線程都能夠看到共享的,可變變量的最新值,讀取和寫入線程必須使用公共的鎖進行同步。

      當一個域被聲明爲 volatile  類型後,編譯器與運行時會監視這個變量:它是共享的,而且對它的操作不會與其他的內存操作一起被重排(保證內存的可見性)。  volatile  變量不會緩存在寄存器或者緩存在對其他處理器隱藏的地方,所以,讀取  volatile  類型的變量時,總會返回最新值,當然這也是會損耗一小部分性能。

      加鎖可以保證可見性與原子性; volatile  變量只能保證可見性。

      在中等強度的負載水平下,“每任務每線程”方法是對順序化 執行的良好改進。只要請求的到達速度尚未超出服務器的請求處理能力,那麼這種方法可以同時帶來更快的響應性和更大的吞吐量。在實際生產環境中,這方法存在 一些實際的缺陷:線程生命週期的開銷,線程的創建和關閉會消耗一些系統資源;資源消耗量,當運行的線程數多於可用的處理器數時,大量空閒線程會佔用更多的 內存,給垃圾回收器帶來壓力,而且大量線程在競爭 CPU  資源時,還會產生其他性能開銷;穩定性,可創建線程的數量,依不同平臺會有不同的限制,同時也受到  JVM  的啓動參數、  Thread  的構造函數中請求的棧大小等因素的影響,以及底層操作系統線程的限制。

      在線程池中執行任務線程,這種方法有很多“每任務每線程” 無法比擬的優勢。重用存在的線程,而不是創建新的線程,這可以在處理多請求時抵消線程創建、消亡產生的開銷。另一項好處就是,在請求到達時,工作者線程通 常已經存在,用於創建線程的等待時間並不會延遲任務的執行,因此提高了響應性。通過適當地調製線程池的大小,你可以得到足夠多的線程以保持處理器忙碌,同時可以還防止過多的線程相互競爭資源,導致應用程序耗盡內存或者失敗。在線程池中,一般不建議使用 ThreadLocal   線程變量,因爲容易引起一些內存泄露。

要做到安全,快速,可靠地停止任務或者線程並不容易。 Java  沒有提供任何機制,來安全地強迫線程停止手頭的工作。它提供 中斷——一個協作機制,是一個線程能夠要求另一個線程停止當前的工作 。中斷通常是實現取消最明智的選擇 。但是,並不是所有的阻塞方法或阻塞機制都響應中斷。 下面幾種情況不能感知中斷請求:  Java.io   中的同步  Socket I/O  ,  InputStream  和  OutputStream   中的  read  和  write  方法都不能響應中斷,但是可通過關閉底層的  Socket  ,可以讓  read  和  write  所阻塞的線程拋出一個  SocketException  ;  Selector  的異步  I/O  ,如果一個線程阻塞於  Seletor.select  方法,  close  方法會導致它拋出  ClosedSelectirException  以前返回;獲得鎖,如果一個線程在等待內部鎖,那麼如果不能確保它最終獲得鎖,並且作出足夠多的努力,讓你能夠以其他方式獲得它的注意,你是不能停止它的。然而,顯式  Lock  類提供了  lockInterruptibly  方法,允許你等待一個鎖,並仍然能夠響應中斷。

在應用程序中,守護線程不能替代對服務的生命週期恰當 良好的管理,因爲當 JVM  發現僅存在守護線程的時候,守護線程會自動退出,而且 不會執行  finally  塊的操作

當任務是同類的,獨立的時候,線程池纔會有最佳的工作表現。如果將耗時的與短期的任務混合在一起,除非線程池很大,否則會有“阻塞”的風險;如果提交的任務 要依賴於其他任務,除非池是無限的,否則有產生死鎖的風險。如需緩解耗時操作帶來的影響,可以限定任務等待資源的時間,這樣可以更快地將線程從任務中解放出來。

如果一個線程池過大,那麼線程對稀缺的 CPU  和內存資源的競爭,會導致內存的高使用量,還可能耗盡資源;如果過小,由於存在很多可用的處理器資源卻未在工作,會對吞吐量造成損失。

當一個有限隊列滿後,飽和策略開始起作用,飽和策略有幾種: “中止( AbortPolicy  )”策略 會引起  execute  拋出未檢查的  RejectedException  ,調用者可以捕獲這個異常,並作相應的處理; “遺棄(  DiscardPolicy  )”策略 會默認放棄這個任務;“ 遺棄最舊的  DiscardOldestPolicy  ”策略 選擇丟棄的任務,是本應該接下來執行的任務,該策略還會嘗試去重新提交新任務; “調用者運行(  CallerRunsPolicy  )”策略 的實現形式,既不會丟棄哪個任務,也不會拋出異常,它會把一些任務推回到調用者那裏,以此緩解新任務。

串行化會損害可伸縮性,上下文切換會損害性能,競爭性的鎖會同時導致這兩種損失,所以減少鎖的競爭能夠改進性能和可伸縮性 。影響所的競爭性有兩個原因: 鎖被請求的頻率,以及每次持有該鎖的時間。有三種方式可以減少鎖的競爭:減少持有鎖的時間;減少請求鎖的頻率;用協調機制取代獨佔鎖,從而允許更強的併發性。

縮減鎖的範圍(從而減少持有鎖的時間);減少鎖的粒度,可以通過分拆鎖(如果一個鎖守衛數量大於一,且相互獨立的狀態變量,你可以通過分拆鎖,使每一個鎖守護不同的變量,從而改進可伸縮性,結果是每個鎖被請求的頻率都減小了,比如 BlockQueue  )和分離鎖(把一個競爭激烈的鎖分拆成多個鎖的集合,並且它們歸屬於相互獨立的對象,比如  ConcurrentHashMap  )來實現;避免熱點域。分拆鎖和分離鎖能改進可伸縮性,因爲它們能夠使不同的線程操作不同的數據或者相同數據結構的不同部分,而不會發生相互干擾,當不同線程操作的數據不能分離時,就出現了熱點域,通常使用的優化方法是使用緩存,保存結果集,減少熱點域的競爭;用非獨佔或非阻塞鎖(自循環)來取代獨佔鎖。

通常 CPU  沒有完全利用的原因有幾種:不充足的負載;  I/O  限制;外部限制;鎖競爭。

在早期的 JVM  版本中,對象的分配和垃圾回收是非常慢的,但是它們的性能在那之後又本質的提高。事實上,  java  中的分配現在已經比  C  語言中的  malloc  更快了。針對對象的“慢”生命週期,很多程序員都會選擇使用對象池技術,這項技術中,對象會被循環使用,而不是由垃圾回收並在需要的時候重新 分配。在併發的應用程序中,池化表現得更糟糕。當線程分配新的對象時,需要線程內部非常細微的協調,協調訪問池的數據結構的同步成爲了必然,由鎖的競爭產生的阻塞,其代價比直接分配的代價多幾百倍。所以對象池對性能優化有一定的侷限性。

在對 Java  程序做性能測試時,應避免幾個陷阱:避免在運行中的垃圾回收;當一個類被首次加載後,  JVM  會以解釋字節碼的方式執行,如果一個方法運行得足夠頻繁,動態編譯器最終會把它轉成本機代碼,當編譯完成後,執行方法將由解釋執行轉換成直接執行。

讀寫鎖的設計是用來進行性能改進的,使得特定情況下能夠有更好的併發性。在實踐中,當多處理器系統中,頻繁的訪問主要爲讀取數據結構的時候,讀寫鎖能夠改進性能;在其他情況下比獨佔鎖要稍差一點,這歸因於它更大的複雜性。

與基於鎖的方案相比,非阻塞算法(這種算法使用底層原子化的機器指令取代鎖,比如比較並交換 Compare and swap CAS  )的設計和實現都要複雜的多 但是它們在可伸縮性和活躍度上佔有很大的優勢,因爲非阻塞算法可以讓多個線程在競爭相同資源時不會發生阻塞,進一步而言,它們對死鎖具有免疫性。

在激烈的競爭下,鎖勝過原子變量 ,但是在真實的競爭條件下,原子變量會勝過鎖。這是因爲鎖通過掛起線程來響應競爭,減小了 CPU  的利用和共享內存總線上的同步通信量。 在中低程度的競爭下,原子化提供更好的可伸縮性;在高強度的競爭下,鎖能夠更好地幫助我們避免競爭。

CAS會出現  ABA  的問題,導致程序察覺不了變化,只能看到最終結果。

在缺少同步的情況下:編譯器生成指令的次序,可以不同於源代碼所指定的順序,而且編譯器還會把變量存儲在寄存器,而不是內存中;處理器可以亂序或者並行地執行指令;緩存會改變寫入提交到主內存的變量的次序;最後,存儲在處理器本地緩存中的值,對於其他處理器並不可見。 Java  語言規範規定了  JVM  要維護內部線程類是順序化語意:只要程序中的最終結果等同於它在嚴格的順序化環境中執行的結果,拿貨上述所有的行爲都是允許的。

JMM爲所有程序內部的動作定義了一個偏序關係,叫做  happens-before  。要想保證執行動作  B  的線程看到動作  A  的結果(無論  A  和  B  是否發生在同一個線程中),  A  和  B  之間就必須滿足  happens-before  關係,如果兩個操作之間並沒有依照  happens-before  關係排序,  JVM  可以對它們所以地重排序。正確的同步的程序會表現出順序的一致性,這就是說所有層序內部的動作會以固定的,全局的順序發生。

在沒有充分同步的情況下發佈一個對象,會導致另外的線程看到一個部分創建對象。新對象的初始化涉及到寫入變量——新對象的域。類似地,引用的發佈涉及到寫入另外一個變量——新對象的引用。如果你不能保證共享引用 happens-hefore  與另外的線程加載這個共享引用,那麼寫入新對象的引用與寫入對象域可以被重排序。在這種情況下,另一個線程可以看到對象引用的最新值,不過也看到一些或全部對象狀態的過期值——一個部分創建的對象。所以錯誤的惰性初始化會導致不正確的發佈。

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