java併發程式設計實戰筆記(部分實戰未看,老舊章節跳過)

何時夕陽發表於2017-06-07

終於把這本經典的Java併發書看完了,雖然之前看的Thinking in Java和Effective Java裡面都有併發的章節,但是這本書講的更加深入,併發是Java程式設計師拋不開的一個話題,所以看一看這本書對我們是極其有幫助的。當然這本書寫了挺久的,裡面有些東西可能落伍了,比如說GUI程式設計。所以我認為用處不大的章節都選擇性跳過了。還有就是在TIJ和EJ裡面講到過的內容也跳過了,沒看過前面兩本書的同學可以看看我略過的章節。最後就是有幾個實戰內容,感覺目前我的層次還達不到那麼高,寫起來可能體會不深就放一放,等工作了一段時間之後在回過頭來看看,加深感悟。

執行緒安全性

1.什麼是執行緒安全

  • 1.當多執行緒訪問某個類的時候,這個類始終表現出預期中正確的行為,那麼這個類就可以被稱為執行緒安全的類。
  • 2.執行緒安全的類中封裝了必要的同步機制,客戶端在呼叫的時候不需要進一步採取同步措施
  • 3.當一個類既不包含域又不包含任何其他類中域的引用可以稱為無狀態的類,這種類一定是執行緒安全的。

2.原子性

當一個物件被多執行緒使用了,而我們用一個int計算這個物件被呼叫了多少次,此時這個物件就是非執行緒安全。因為int的遞增操作並不是原子性的,可能int在一個執行緒遞增了一半,該物件就切換到了另一個執行緒中執行了,此時該物件就會產生與我們預期不符的行為。

  • 1.競態條件:當一個操作的正確性,取決於多個執行緒交替執行的時序的時候,這就叫競態條件。如我們上面舉的例子,int的遞增的正確性取決於某個執行緒是否會在另一個執行緒操作到一半的時候就進行操作。常見的競態條件就是先檢查後執行,如某個執行緒的操作插入到另一個執行緒的檢查和執行操作之間。
  • 2.延遲初始化中的競態條件:
    • 1.延遲初始化是一種先檢查後執行的競態條件,如單例在多執行緒下在用到的時候才初始化:先檢查單例是否為null,在初始化單例
    • 2.前面舉的int遞增的例子是另一種競態條件:讀取-修改-寫入,只要在某個環節被排程到其他執行緒,類就會產生與預期不相符的行為。
  • 3.複合操作:我們前面舉的競態條件,說白了就是一系列前後關聯的操作————複合操作,一旦這一系列操作被打斷,就是執行緒不安全。所以我們需要將這一系列操作通過java的機制改成原子操作。

3.加鎖機制

我們都知道java中有原子變數,那麼是不是說對於一個類,我們只需要把所有的域都變成原子變數,這個類就是執行緒安全的呢?很顯然並不是,我們在2中提到了競態條件,當一系列原子變數的操作是前後關聯的,那麼這一系列的操作就是競態條件,此時我們如果不進行處理,那麼這個類就不是執行緒安全的

  • 1.內建鎖:
    • 1.每個物件和Class物件都有一個內建鎖,一個鎖只有一個執行緒能夠持有。
    • 2.物件鎖用於非static方法,Class物件鎖用於static方法。
    • 3.執行緒1進入到synchronized塊1中,執行緒1就持有了synchronized塊1中傳入的鎖,執行緒1執行出synchronized塊1就會釋放鎖,執行緒2如果執行到傳入同樣鎖的synchronized塊2,就會停下來等待執行緒1執行出synchronized塊1。
    • 4.說白了整個synchronized塊就是一個原子操作,所以我們可以將競態條件放入synchronized塊中,這樣一來該類這一系列操作就變成執行緒安全的了。
  • 2.重入:當一個執行緒多次試圖進入同一個鎖的synchronized塊會出現什麼情況呢?
    • 1.如果沒有重入的話,那麼該執行緒就將等待自己完成synchronized塊中的操作,此時這個執行緒就會產生死鎖(即該執行緒永遠也出不了synchronized塊,而且其他執行緒試圖獲取這個鎖的時候也會被阻塞停止,整個程式就會崩潰
    • 2.重入即當一個執行緒獲取了某個鎖之後,在沒有釋放鎖的前提下又獲取鎖是可行的。可以這樣理解當鎖無執行緒獲取,內部計數為0,某個執行緒獲取了,計數為1,若該執行緒重複獲取該鎖,獲取一次計數加一,當該執行緒跑出一個synchronized塊計數減一。

物件的共享

同步不僅能實現原子操作,同步還有一個重要的方面就是記憶體可見性的實現。記憶體可見性表示,在一個共享變數被修改了之後,其他執行緒能夠立即觀察到該變數的修改後的值。如果不同步的話這一點是做不到的。

1.可見性

  • 1.首先我們需要知道的是,java的執行緒都有自己獨立的快取,執行緒之間進行共享變數的互動是通過自身和快取和主存的互動實現的。
  • 2.如果執行緒的每次更改快取都刷入主存,主存每次被一個執行緒的快取修改,都通知所有的執行緒重新整理自身的快取的話,那樣就太不經濟了。
  • 3.由於1和2,就會產生一個現象:當執行緒1修改了一個共享變數之後,執行緒2獲取的共享變數還是更改前的值。即執行緒1更改共享變數並沒有刷入主存,或者執行緒2並沒有去主存中獲取到新的共享變數,或以上兩者皆有
  • 4.為了解決記憶體可見性我們可以使用volatile關鍵字和同步這兩種方式
  • 5.非原子的64為操作:雖然java要求變數的讀和寫都必須是原子操作,但是64位的long和double會被分為兩次32位的存取操作。此時需要使用volatile關鍵字。

2.釋出與逸出

  • 1.釋出:使得一個物件能被作用域以外的物件訪問,比如將該物件的引用設為static或者在非private方法中返回該引用
  • 2.逸出:由於釋出一個物件的內部狀態可能會破壞封裝性和不變性導致執行緒不安全,在這種情況下被稱為物件逸出
  • 3.在釋出某物件的時候可能會間接釋出本不想釋出的物件,如一個private的陣列,一旦被髮布,其中儲存的物件也會被髮布
  • 4.如果在內部類中使用了外圍類的方法,那麼外圍類也會被髮布,這個被稱為this逸出。例如在一個建構函式中執行一個執行緒,在該類還沒構造完畢的時候,該類就對執行緒可見了,那麼此時就會出現 構造還沒完成就釋出物件的問題 。這個問題會導致執行緒不安全。

3.執行緒封閉

  • 1.執行緒封閉是指,物件被封閉在一個執行緒中,這樣一來就不需要對物件進行同步了。
  • 2.像Android中所有的view更新的操作都要在主執行緒進行,我們可以說View物件是執行緒封閉的物件。
  • 3.Ad-hoc執行緒封閉:沒有語言特性保證,只能是程式設計師自己保證。如volatile,只要保證沒有其他執行緒對其進行寫操作,那麼就能保證本執行緒對其寫操作會會通知到別的執行緒
  • 4.棧封閉:區域性變數只要不釋出到其他執行緒中,就是棧封閉的。、
  • 5.ThreadLocal:每個執行緒都會有一個變數的不同版本,內部實現類似於Map<Thread,T>。

4.不變性

  • 1.不可變物件一定是執行緒安全的
  • 2.不可變物件滿足下列條件:
    • 1.所有域是final的,域內部的域也是final的
    • 2.所有域不可改變
    • 3.this沒有在構造的時候逸出

5.安全釋出

  • 1.如果僅僅將物件引用儲存在public域之中,並不算安全釋出,因為可見性問題,該物件可能在其他執行緒是沒有構建好的
  • 2.正確的物件被破壞:當如1一樣釋出一個物件的時候,會有執行緒1在使用該物件中途,執行緒2改變該物件的狀態,使得執行緒1丟擲異常。
  • 3.不可變物件與初始化安全性:如果1和2中的物件是不可變的,那麼就不會出現2的情況了。
  • 4.安全的釋出可變物件:要安全的釋出一個可變物件,需要使得物件的引用和狀態同時對其他所有的執行緒可見,有以下幾種方式
    • 1.在靜態初始化物件引用,因為JVM的類載入過程中是同步的
    • 2.對物件引用使用volatile或AtomicReference
    • 3.將物件引用放入final域中
    • 4.對物件引用加鎖
  • 5.安全地共享物件:在釋出一個物件的時候需要明確指出該物件的多執行緒共享規則:
    • 1.是執行緒封閉?:只能由一個執行緒擁有
    • 2.是隻讀共享?:只能併發讀
    • 3.是執行緒安全共享?:類內部實現了同步,可以隨意使用
    • 4.是保護物件?:類內部沒有實現同步,需要使用者在外部同步

物件組合

1.設計執行緒安全的類

  • 1.如何判斷一個類是否是執行緒安全的?
    • 1.找出構成物件狀態的所有變數:即該物件所有會變的域
    • 2.找出約束物件狀態變數的不變性條件:即所有狀態變數變化的區域
    • 3.建立物件狀態的併發訪問管理策略:即根據對所有狀態變數建立同步策略
  • 2.收集同步需求:比如變數的範圍,比如變數當前的狀態是否和之前的狀態有關等等

2.例項封閉

  • 1.當已知某個非執行緒安全的物件的所有呼叫路徑的時候,可以將其封裝在一個執行緒安全的類中使用
  • 2.java監視器模式:1就是這個模式,將所有可變物件都封裝起來,使用自身的鎖來保護可變物件。HashTable就是這樣實現的,但是這只是簡單的粗粒度封裝,但是如果要提供效能,需要進行細粒度封裝。除了使用內建鎖,還能使用私有物件鎖,這樣能讓客戶端獲取不到保護可變物件的鎖,但是又能讓客戶端通過公有方法使用它。

3.執行緒安全性的委託

  • 1.可以通過委託機制,將執行緒安全性質委託給執行緒安全類,如ConcurrentHashMap
  • 2.當委託失效的時候:如果一個類中多個執行緒安全物件中有複合的不變性條件的話,那麼還是得在類中進行同步

基礎構建模組

1.同步容器類

  • Vector和HashTable都是早期的同步類。
  • 1.同步容器類的問題:有些符合操作可能會在其他元素併發修改的時候出問題如:迭代、跳轉(找到當前元素的下一個元素)和條件運算(若沒有則新增)。
    • 1.對Vector多執行緒進行getLast()和deleteLast()的時候,由於這兩個方法不是同步的,所以可能會出現在getLast()中間插入deleteLast()操作,導致陣列邊界異常
    • 2.為了解決1的問題,可以對這兩個操作加上鎖
    • 3.同樣在對Vector進行迭代的時候,也會出現1中的問題。也需要加鎖
  • 2.迭代器和ConcurrentModificationException:由於一些併發容器並沒有對 在迭代期間容器進行修改 的情況進行設想,所以他們採用了“即時失敗”的策略,即迭代期間如果容器被修改了,那麼就丟擲ConcurrentModificationException。
    • 1.為了在迭代期間不丟擲異常,可以對整個迭代進行加鎖
    • 2.如果在迭代期間進行加鎖了,一旦容器規模比較大,就會出現效能問題。
    • 3.如果不希望在迭代期間進行容器加鎖,可以採取克隆的方式,將克隆出來的容器封閉在本地,對克隆的容器進行迭代,這樣就不會出現問題了。
  • 3.隱藏迭代器:為了在迭代的時候丟擲異常,我們會選擇在所有的迭代中進行加鎖,但是有些情況下我們沒有進行迭代,而java類庫實現的時候會對容器進行迭代。如容器的toString()方法,這樣一來還是會丟擲異常。

2.併發容器

  • java1.5提供了併發容器來代替同步容器,增強了效能,也提供了常用的同步複合操作,避免了Vector中的情況
  • 1.ConcurrentHashMap:不是採用HashTable的加鎖方式,採用的是分段鎖,併發迭代修改期間不加鎖也不會丟擲異常。一般使用這個併發,只有要加鎖獨佔Map的時候才放棄他。
  • 2.額外的原子Map操作:由於ConcurrentHashMap不能被客戶端加鎖獨佔,所以客戶端不能建立新的原子操作,但是一些常用的複合操作,ConcurrentMap中已經實現了
  • 3.CopyOnWriteArrayList

3.阻塞佇列和生產者消費者模式

  • 各種BlockingQueue的使用

4.阻塞方法和中斷方法

  • 1.當io、等待鎖、sleep和wait的時候執行緒會被阻塞掛起。
  • 2.如果一個方法會丟擲一個InterruptedException表示這個方法是一個阻塞的方法,如果這個方法被中斷,那麼其會被儘快結束執行
  • 3.Thread提供了interrupt方法,方便查詢執行緒是否被中斷了。
  • 4.當在一個執行緒中丟擲一箇中斷異常的時候,有兩種選擇:
    • 1.向上丟擲異常
    • 2.如果在Runnable中的話,已經不能丟擲異常了,此時需要捕獲這個異常,然後可以停止執行緒,也可以通過interrupt方法恢復中斷

5.同步工具

  • 1.閉鎖:在閉鎖狀態結束之前,其他所有的執行緒都不能進行操作,直至閉鎖結束。
    • 1.例如一個執行緒完畢之後,其他依賴這個執行緒的執行緒才執行
    • 2.CountDownLatch:使得多個執行緒等待一組事件的發生,其中有一個計數器,表示還剩多少事件。呼叫await的執行緒會阻塞到計數器為0的時候
  • 2.FutureTask:呼叫get的執行緒,會阻塞到Callable的結果產生。
  • 3.訊號量:
  • 4.欄柵:CyclicBarrier

6.構建高效可伸縮的結果快取

  • 實戰內容,在專案中有涉獵,以後再看

任務執行

1.線上程中執行任務

  • 1.序列執行任務,太浪費cpu
  • 2.為每個任務開闢執行緒,太浪費資源

2.Executor框架

  • 1.Executor是一個介面
  • 2.基於生產者消費者模式,提交任務為生產者,執行任務的執行緒為消費者
  • 3.執行緒池:Executors提供了一系列執行緒池;
  • 4.Executor的生命週期:jvm只有在所有非守護執行緒退出之後才會退出,所以終止Executors是個問題
    • 1.Executor繼承了ExecutorService,其中有一些管理生命週期的方法
    • 2.ExecutorService有三種狀態:執行、關閉、終止。由於Executor中的任務是非同步執行的,在某個時刻可能有些任務被放在任務佇列裡沒有執行,有些則正在執行。
    • 3.shutdwon方法呼叫後,表示Executor已關閉不再接受新任務,但是以前的任務執行完畢之後才會變成終止狀態
    • 4.shutdwonNow方法呼叫後則是:直接變成終止狀態,無論是執行還是沒執行的任務都會被取消

取消與關閉

1.任務取消

  • 1.如果是一個迴圈任務,那麼可以在條件中加上一個flag,當flag為否的時候退出迴圈
  • 2.在有些情況下如果迴圈任務中呼叫了一個阻塞方法,那麼可能要花費一定時間才能退出,甚至一直無法退出
  • 3.在2的情況下,我們可以使用中斷來將執行緒終結
    • 1.線上程1呼叫執行緒2的中斷表示:執行緒1希望執行緒2在適合的情況下停止當前工作(注意執行緒2不是立即停下來,即非搶佔式)
    • 2.對於阻塞庫中的方法如sleep、wait,會在呼叫前檢查該執行緒是否被中斷,如果中斷那麼就會清除中斷,丟擲InterruptedException,我們只需要在裡面取消阻塞操作即可取消任務。
    • 3.如果執行緒處於非中斷狀態,如一直while迴圈,那麼可以在while條件中判斷是否產生中斷,若產生就退出迴圈
    • 4.通過interrupt可以將中斷狀態取消,如果在捕獲到異常後希望繼續進行別的阻塞庫中的操作,可以使用這個
    • 5.Future可以通過cancel來取消
    • 6.同步io、socket io、非同步io,這幾個情況雖然是阻塞方法,但是線上程中斷的時候並不會丟擲InterruptedException,但是我們可以通過讓這些方法丟擲異常來達到同樣的效果,如關閉socket,關閉流等等
    • 7.當一個執行緒在獲取鎖,此時用上面任意的方法都不能取消任務,此時可以使用Lock#lockInterruptibly
    • 8.我們可以將6中的方式封裝成非標準的取消任務的方式。

2.停止基於執行緒的服務

  • 1.只要執行緒存在的時間大於執行緒建立的時間,就必須為其提供生命週期的方法,如停止執行緒
  • 2.關閉生產者-消費者的一種方式是:”毒丸”物件,即一旦消費者獲取了 某個特定的物件,那麼就表示處理可以停止,消費者執行緒可以關閉了
  • 3.shutdownNow的侷限性:當強制關閉ExecutorService的時候,我們無法知道哪些任務正在執行,哪些任務還沒執行。如果我們想儲存此時的狀態就無從下手此時我們可以實現AbstractExecutorService,然後將具體操作委託給一個ExecutorService,但是在實現中記錄在shutdownNow時,還沒執行的任務。

3.處理非正常的執行緒終止

  • 1.一個執行緒在丟擲異常之後,如果沒進行處理就會被終止,雖然會報異常,但是我們通常很難被發覺
  • 2.解決1的一個方法是,在整個執行緒的最外面catch異常,然後處理異常。這種方式比直接結束整個程式好一點,但是有安全性問題因為這裡丟擲異常的時候可能整個程式會受影響。
  • 3.我們可以主動檢測異常:通過實現Thread.UncaughtExceptionHandler介面,將未捕獲的異常寫入異常log之中,或者進行其他恢復性的操作
  • 4.只有通過execute提交任務,才會進行3中的方式。如果通過submit,那麼程式會認為異常是返回的一部分,如用submit執行一個Future

4.JVM關閉

  • 1.關閉鉤子:通過Runtime.addShutdownHook註冊的一系列清理執行緒將會被呼叫進行資源的清理。
    • 1.如果此時還有執行緒在執行,那麼所有執行緒會併發執行。
    • 2.當所有鉤子執行緒執行完畢,jvm會執行終結器。
    • 3.如果終結器或鉤子執行緒沒有執行完,那麼關閉程式將會被掛起,此時jvm需要被強行關閉
    • 4.jvm被強行關閉時,應用執行緒會被強行結束,但是鉤子執行緒不會被關閉
  • 2.守護執行緒:這些執行緒不會影響jvm關閉

避免活躍性危險

1.死鎖

  • 1.哲學家就餐問題:
    • 1.互斥條件:一個資源每次只能被一個程式使用。
    • 2.請求與保持條件:一個程式因請求資源而阻塞時,對已獲得的資源保持不放。
    • 3.不剝奪條件:程式已獲得的資源,在末使用完之前,不能強行剝奪。
    • 4.迴圈等待條件:若干程式之間形成一種頭尾相接的迴圈等待資源關係。

2.死鎖的避免和診斷

  • 1.支援定時鎖:使用Lock代替內建鎖,可以指定一個獲取鎖等待的時限
  • 2.通過執行緒轉儲來分析死鎖

3.其他活躍性危險

  • 1.飢餓:一個執行緒很長時間訪問不到其需要的資源,如對執行緒優先順序設定不對,導致一個執行緒很長時間獲取不到cpu時間片
  • 2.活鎖

效能與可伸縮性

1.執行緒引入的開銷

  • 1.上下文切換:執行緒被阻塞會被JVM掛起,如果經常阻塞則無法完整排程時間片,從而增加上下文切換的時間
  • 2.記憶體同步:使用synchronized或者volatile關鍵字會將所有執行緒的本地快取重新整理,此時會消耗時間
  • 3.阻塞:執行緒被阻塞會被掛起,此時就會多了兩個上下文切換的時間,所以少阻塞

2.減少鎖的競爭

  • 三種方法降低鎖的競爭程度:1.減少鎖持有時間 2.降低鎖請求頻率 3.使用可協調的獨佔鎖
  • 1.縮小鎖範圍:將臨界區的程式碼數量降到最小,尤其是io操作等會阻塞執行緒的操作
  • 2.減小鎖的粒度:減少執行緒請求同一把鎖的頻率,也就是將鎖分解成多個鎖
  • 3.鎖分段:將一個競爭激烈的鎖分成多個鎖,可能還是會競爭很激烈。將一組獨立物件上的鎖進行分解,被稱為鎖分段,如將一個Map桶讓16個鎖保護一個鎖保護N/16個桶,那麼併發寫的效能就能提升16倍。這樣的壞處就是:獨佔訪問需要獲取多個鎖,更困難,開銷更大
  • 4.避免熱點域?
  • 5.一些代替獨佔鎖的方式:併發容器、讀寫鎖、不可變物件和原子變數

顯式鎖

1.Lock和ReentrantLock

  • 1.Lock提供了可輪詢、定時以及中斷鎖獲取的功能,其他方面和內建鎖類似,需要在try final裡面釋放鎖
  • 2.輪詢鎖和定時鎖:可以通過tryLock來輪詢避免,也可以通過定時鎖來避免死鎖

2.公平性

  • 1.建立ReentrantLock的時候,可以設定鎖的公平性,預設是非公平鎖
    • 1.公平鎖:按照執行緒排隊的先後獲取鎖
    • 2.非公平鎖:一個執行緒要獲取鎖,但還沒放入請求鎖的佇列,此時鎖可以用了,那麼這個執行緒就可以插隊
  • 2.內建鎖和Lock之間的選擇:內建鎖簡潔,Lock可以作為高階工具使用

3.讀寫鎖

  • 1.不對讀讀進行加鎖,適用於大量讀的操作

原子變數與非阻塞同步機制

1.鎖的劣勢

  • 1.一個執行緒因為鎖的掛起和喚醒開銷比較大。
  • 2.volatile是一種輕量級同步機制,但是如果需要依賴舊值,就會出現同步錯誤

2.硬體對併發的支援

  • 1.獨佔鎖是一種悲觀鎖,對於細粒度操作可以採用樂觀的方式:在更新過程中判讀是否有其他執行緒干擾,如果有這個操作就失敗,而不是拒絕這個操作。
  • 2.比較交換:CAS指令可以檢測其他執行緒的干擾,使得不用鎖也可以實現原子的讀-改-寫操作
  • 3.java1.5後在底層實現了CAS操作,一些原子變數就是用的這個機制

3.原子變數類

  • 1.原子變數是一種更好的volatile
  • 2.原子變數比鎖的效能更好

4.非阻塞演算法

  • 實戰,以後看

什麼是記憶體模型

  • 1.每個執行緒有自己的本地快取,本地快取有各個共享變數的副本,所有執行緒的快取都會和主村進行雙向通訊,但不是實時的。
  • 2.為了讓更多指令進行併發,在位元組碼編譯和位元組碼轉指令的時候會進行指令重排,也就是說沒有必然先後關係的程式碼,最終執行的時候先後順序是不一定的。
  • 3.程式碼的先後順序有一個原則:Happens-Before
    • 1.程式順序規則:程式中A在B前面,執行緒中A在B前面
    • 2.監視器鎖規則:監視器鎖的解鎖必須在同一監視器鎖加鎖之前
    • 3.volatile規則:volatile變數寫入操作必須在對該變數讀操作之前
    • 4.執行緒啟動規則:Thread#start()必須在該執行緒中任何操作之前
    • 5.執行緒結束規則:執行緒中所有操作都要在其他執行緒檢測到該執行緒結束之前執行
    • 6.終結器規則:物件建構函式必須在啟動該物件的終結器之前完成
    • 7.中斷規則:執行緒1呼叫執行緒2的中斷,必須在中斷執行緒檢測interrupt之前執行
    • 8.傳遞性:A在B前面,B在C前面,那麼A在C前面


相關文章