Java併發編程藝術讀後感

第一章:併發編程的挑戰

1、即使是單核處理器也支持多線程執行代碼,CPU通過給每個線程分配CPU時間片來實現這個機制。

我們知道,一個線程在一個時刻只能運行在一個處理器核心上,那麼單核處理器支持多線程的意義在哪?

因爲線程任務可以分爲IO密集型或CPU密集型,當線程任務需要IO操作時,可以把CPU讓出給需要計算邏輯的線程。

所以即使是單核處理器執行多線程任務時,也有可能比單線程快。另外,單核處理器不會出現多核處理器中多線程出現的問題。

2、多核處理器執行多線程任務就一定比單線程快嗎?

答案同樣是不一定,我們知道CPU是通過給每個線程分配時間片來實現多線程的,當任務執行一個時間片後就會切換下一個任務。

但是,在切換前會保存上一個任務的狀態,以便下次切換回這個任務時,可以再加載這個任務的狀態,這個過程叫做上下文切換。

毫無疑問,上下文切換會耗費許多時間,所以當另起的線程的上下文切換的時間比其省去的計算時間多時,多線程就更慢。

3、我們再來思考一個問題,當線程數小於CPU核心數時,多線程是否一定比單線程快?

答案同樣是不一定,該問題的核心在於即使線程數小於CPU核心數,線程仍然會發送上下文切換。

因爲多線程的本質是用時間片實現的,每個線程的時間片用完後都會發生上下文切換,即使有多餘的CPU資源。

4、減少上下文切換的方法有無鎖併發編程、CAS算法、使用最少線程和使用協程。

使用最少線程和協程很好理解,使用無鎖併發編程爲啥能減少上下文切換次數呢?

因爲多線程競爭鎖時,如果線程沒有獲取到鎖,就會讓出時間片給其他線程,從而引起上下文切換。

同理,使用CAS更新數據也是爲了避免加鎖。

5、上下文切換監測工具有Lmbench3、vmstat。

第二章:Java併發機制的底層實現原理

在瞭解併發機制前,我們先來了解下圖(CPU內存模型):

 

書中有這麼一段話,爲了提高處理速度,處理器並不直接和內存通信,而是先將系統內存數據讀到內部緩存(L1,L2或其他),

但是操作完不知道何時會寫到內存,各個處理器保存中原數據的副本,很有可能不一樣,這就是併發的根本問題:緩存不一致。

所以,在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議:

每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是否過期了,當處理器發現自己緩存行對應的內存地址被修改,

就會將當前處理器的緩存行設置爲無效狀態,當處理器對這個數據進行修改操作時,會重新從系統內存中把數據讀到處理器緩存裏。

 1、volatile可見性的實現原理

有volatile修飾的共享變量進行寫操作時,jvm就會向處理器發送一條Lock前綴指令,該操作會引起處理器緩存回寫到內存,

另外,一個處理器的緩存回寫到內存會導致其他處理器的緩存無效,需要從內存讀取數據。

2、synchronized實現原理與應用

Java中每一個對象都可以作爲鎖。

  • 對於普通同步方法,鎖是當前實例對象。
  • 對於靜態同步方法,鎖是當前類的Class對象。
  • 對應同步方法塊,鎖是synchronized括號裏配置的對象。

從JVM規範中可以看到synchronized在JVM裏的實現原理,JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步。

代碼塊同步是使用Monitorenter和monitorexit指令實現的,而方法同步是使用另外一種方式實現的。

3、什麼是鎖,鎖存在哪裏?

synchronized用的鎖是存在Java對象頭裏的,名叫Mark Work,具體結構可參閱書籍。

4、鎖的四種狀態及轉換:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態

鎖的級別會隨着競爭而升級,但是不會降級,目的都是爲了提高獲取鎖和釋放鎖的效率。

5、鎖的優缺點對比

優點 缺點 適用場景
偏向鎖      加鎖和解鎖不需要額外消耗,和執行非同步方法相比僅存在納秒級別差距 如果線程間存在鎖競爭,會帶來額外鎖撤銷的消耗 只有一個線程訪問同步塊場景
輕量級鎖      競爭的線程不會阻塞,提高程序的響應速度 如果始終得不到鎖競爭的線程,使用自旋會消耗CPU

追求響應時間,同步執行速度非常快

重量級鎖      線程競爭不使用自旋,不會消耗CPU 線程阻塞,響應時間緩慢 追求吞吐量、同步塊執行速度較快

6、Java如何實現原子操作

在Java中可以通過鎖和循環CAS的方式來實現原子操作,JVM中的CAS利用了處理器提高的CMPXCHG指令實現的。

7、CAS實現原子操作的三大問題

Ⅰ、ABA問題:假如一個數據原來是A,變成了B,又變成了A。

ABA問題的解決思路是使用版本號,每次變量更新時把版本號加一。

從Java1.5開始,JDK的Atomic包裏提供了一個類AtomicStampedReferenc來解決ABA問題,解決思路就是使用版本號。

Ⅱ、循環時間長開銷大。

自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支持處理器提高的pause指令,那麼效率會有一定提升。

Ⅲ、只能保證一個共享變量的原子操作

從Java1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,可以把多個變量放在一個對象裏進行CAS操作。

8、除了偏向鎖,JVM實現鎖的方式都用了循環CAS

即當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當他退出同步塊時使用循環CAS釋放鎖。

第三章:Java內存模型

1、線程之間如何通信以及線程之間如何同步?

通信是指線程之間以何種機制來交換消息,在命令式編程中,線程之間通信方式有兩種:共享內存和消息傳遞。

同步是指程序中用於控制線程間操作發生相對順序的機制。

在共享內存模型中,通信是隱式的,同步時顯式的。共享內存模型是指線程間通過寫-讀內存中的公共狀態來通信或同步。

在消息傳遞模型中,通信是顯示的,同步是隱式的。消息傳遞模型是指線程間通過發送消息來通信或同步。

Java的併發採用的是共享內存模型。

2、Java內存模型

同CPU內存模型一樣,Java也有自己的內存模型。

Ⅰ、CPU內存模型

Ⅱ、Java內存模型

在Java中,所有的實例域、靜態域和數組元素都存儲在堆內存中,而堆內存在線程之間是可以共享的(共享變量)。

局部變量、方法定義參數和異常處理器參數存儲在棧中,不會再線程之間共享,它們不會存在內存可見性的問題。

Java線程之間的通信由Java內存模型(簡稱JMM)控制,JMM決定一個線程堆共享變量的寫入何時對另一個線程可見。

3、volatile重排序

在執行程序時,爲了提供性能,編譯器和處理器常常會對指令做重排序。

通常爲了遵循as-if-serial語義,編譯器和處理器都不會對存在數據依賴關係(控制依賴除外)的操作做重排序。

as-if-serial語義是指:不管怎麼重排序,(單線程)程序的執行結果不能被改變。

重排序分三種類型。

Ⅰ、編譯器優化重排序:編譯器在不改變單線程程序語義的情況下,可以重新安排語句的執行順序。

Ⅱ、指令級並行重排序:如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

Ⅲ、內存系統重排序:因爲處理器使用緩存讀寫緩衝區,所以加載和存儲有可能亂序執行。

爲了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序(非編譯器)

內存屏障類型表(Load是讀,Store是寫)
屏障類型 指令示例 說明
LoadLoad             Load1;LoadLoad;Load2             確保Load1數據的裝載先於Load2及所有後續裝置指令的裝載。
StoreStore           Store1;StoreStore;Store2         確保Store1數據對其他處理器可見(刷新到內存)先於Store2及所有後續存儲指令的存儲。
LoadStore            Load;LoadStore;Store               確保Load數據裝載先於Store及所有後續存儲指令刷新到內存。

StoreLoad           

Store;StoreLoad;Load              

確保Store數據對其他處理器可見(刷新到內存)先於Load及所有後續裝載指令的裝載。

StoreLoad會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之後,

才執行該屏障之後的內存訪問指令(存儲和裝載指令)。

StoreLoad是全能型屏障,不僅僅只是確保Store數據對其他處理器可見(刷新到內存)先於Load及後續裝載指令的裝載。

因爲它還會使該屏障之前的所有內存訪問指令包括讀和寫等完成之後,才執行該屏障之後的內存訪問指令

而由於是訪問內存,所以共享變量需要是volatile修飾

另外值得注意的是,刷新內存並非只刷新volatile變量,而是該線程對應的本地內存中所有的共享變量。

 接下來我們看看JMM針對編譯器制定的重排序規則:

是否能重排序 第二個操作
第一個操作 普通讀/寫 volatile讀 volatile寫
普通讀/寫 YES YES NO
volatile讀 NO NO NO
volatile寫 YES NO NO

爲了實現JMM針對編譯器制定的重排序規則能應用於處理器,編譯器會在指令序列中插入內存屏障來禁止處理器重排序(似乎不完整)。

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。
  • 在每個volatile讀操作的後面插入一個LoadStore屏障。

仔細分析後可以看到當第一個操作時普通讀,第二個操作時普通寫時,StoreStore屏障並不能禁止它們之間的重排序。

另外我們對內存屏障的理解最好還是基於內存屏障類型表,感覺指令序列示意圖上對內存屏障指令的解釋缺了點什麼。

4、未同步程序的執行特性

對於未同步或未正確同步的多線程程序,JMM只提供最小安全性:

線程執行時讀取到的值要麼是之前某個線程寫入的值,要麼是默認值(0,Null,False)。

爲了實現最小安全性,JVM在堆上分配對象時,首先會對內存空間進行清零,然後纔會在上面分配對象(JVM內部會同步這兩個操作)。

另外,JMM不保證對64位的long型和double型變量的寫操作具有原子性。

因爲在32位的機器上保證對64位數據的寫操作具有原子性,會有比較大的開銷。

爲了照顧這種處理器,Java語言鼓勵但是不強求JVM對64位的long型變量和double型變量的寫操作具有原子性。

當JVM在這種機器上運行時,可能會把對64位long/double的寫操作拆爲兩個32位的寫操作來執行。

在JSR-133之前舊的內存模型中,一個64位long/double型變量的讀/寫操作可以拆分爲兩個32位的讀/寫操作來執行。

從JSR-133內存模型開始(JDK5開始),僅僅允許把一個64位long/double型變量的寫操作拆分爲兩個32位的寫操作來執行。

5、volatile寫-讀的內存語義

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量的值刷新到主內存。
  • 當讀一個volatile變量時,JMM會把該線程對應的本地內存(緩存)置爲無效,線程接下來從主內存中讀取共享變量。

6、內存屏障解決了重排序導致的什麼問題?舉個例子(待寫)

 我們在第二章知道了內存語義的實現原理,它成功解決了CPU緩存不一致的問題。那麼內存屏障是怎麼解決多線程下重排序的問題呢?

正向分析有點難,我們通過編譯器制定的重排序規則來反向分析,先來總結一下:

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。
  • 當第一個操作是volatile寫時,第二個操作是volatile讀時,不能重排序。

現在我們假設使用的是一個volatile變量,它讀取的都是最新的數據,在重排序的情況下會引發什麼問題:

 7、鎖的內存語義

  • 當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。
  • 當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效,從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。

以ReentrantLock爲例,它的實現依賴於Java同步框架AbstractQueuedSynchronizer(簡稱AQS)。

AQS使用一個整型的volatile變量(命名爲state)來維護同步狀態,這個volatile變量是ReentrantLock內存語義實現的關鍵。

 

ReentantLock分爲公平鎖和非公平鎖,另外ReentrantLock是可重入鎖。

這裏僅對概念講解一下:

公平鎖:誰等待的時間最長誰優先獲得鎖,也就是符合FIFO原則。

非公平鎖:誰先搶到鎖,鎖就是誰的。通常來說,剛釋放鎖的線程更有機會再次獲得鎖。

可重入鎖:任意線程在獲取到鎖之後(沒有釋放鎖)能夠再次獲取該鎖而不會被鎖所阻塞,俄羅斯套娃。

8、final域重排序

對於final域,編譯器和處理器要遵守兩個重排序規則。(具體示例可以參閱書籍)

  • 在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
  • 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

Ⅰ、寫final域的重排序規則

寫final域的重排序規則禁止把final域的寫重排序到構造函數之外。這個規則包含以下兩個方面:

JMM禁止編譯器把final域的寫重排序到構造函數之外。

編譯器會在final域的寫之後,構造函數return之前,插入一個StoreStore屏障,從而禁止處理器把final域的寫重排序到構造函數之外。

Ⅱ、讀final的重排序規則

讀final域的重排序規則是,在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操作。

(注意這個規則僅針對處理器),編譯器會在讀final域操作的前面插入插入一個LoadLoad屏障。

因爲初次讀對象引用與初次讀該對象包含的final域,這兩個操作之間存在間接依賴關係。

由於編譯器遵守間接依賴關係,因此編譯器不會重排序這兩個操作。大多數處理器也會遵守間接依賴,該規則就是針對少數處理器的。

9、concurrent包的實現

由於Java的CAS同時具有volatile讀和volatile寫的內存語義,因此Java線程之間的通信現在有了下面四種方式。

  • A線程寫volatile變量,隨後B線程讀這個volatile變量。
  • A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。
  • A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。
  • A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量

Java的CAS會使用現代處理器上提供的高效機器級別的原子指令,這些原子指令以原子的方式對內存執行讀-改-寫操作。

這是多處理器中實現同步的關鍵。同時,volatile變量的讀/寫和CAS可以實現線程之間的通信。

把這些特性整合在一起,就形成了整個concurrent包得以實現的基石。

如果我們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式。

首先,聲明共享變量爲volatile。

然後,使用CAS原子條件更新來實現線程之間的同步。

同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間等待通信。

10、雙重檢查鎖定與延遲優化

在Java多線程程序中,有時候需要採用延遲初始化來降低初始化類和創建對象的開銷。

以單例模式爲例:

public class Singleton {

    private volatile static Singleton instance;
    
    public static Singleton getInstance() {
        if(instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

現在想想爲什麼會需要volatile關鍵字?

在代碼instance = new Singleton()處其實可以分爲三行代碼:

//正確順序
memory = allocate();//1:分配對象內存空間
ctorInstance(memory);//2:初始化對象
instance = memory;//3:設置instance指向剛分配的內存地址

//重排序順序
memory = allocate();
instance = memory;
ctorInstance(memory);

如果發生重排序後,那麼其他線程讀到的單例將會是還沒有初始的對象。

另外單例模式還有另外一種寫法:

JVM在類的初始化階段(即在Class被加載後,且被線程使用前),會執行類的初始化。

在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。

初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態字段。

根據Java語言規範,在首次發生下列任意一種情況時,一個類型爲T的類或接口將被立即初始化。

  • T是一個類,而且一個T類型的實例被創建。
  • T是一個類,且T中聲明的一個靜態方法被調用。
  • T中聲明的一個靜態字段被賦值。
  • T中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段。
  • T是一個頂級類,而且一個斷言語句嵌套在T內部被執行。
public class Singleton {

    private static final Singleton instance = new Singleton();
        
    public static Singleton getInstance() {
        return instance;
    }
}

如果我們要獲取Singleton,可以通過Singleton.getInstance()的方式,這樣我們將符合第二條規範。

public class InstanceFactory {

    private static class InstanceHolder{
        public static Instance instance = new Instance();
    }
public static Instance getInstance(){ return InstanceHolder.instance; } }

這種單例模式符合第四條規範,因爲訪問的實例是Instance類,所以不符合第一條規範。

同樣,由於是讀訪問instance實例,所以不符合第三條規範。

 第四章:Java併發編程基礎

1、爲什麼IO密集型線程優先級需要高於CPU密集型線程?

原文中說到:

優先級高的線程分配時間片的數量要多於優先級低的線程。設置線程優先級時:

針對頻繁阻塞(休眠或者I/O操作)的線程需要設置較高優先級,

而偏重計算(需要較多CPU時間或者偏運算)的線程則設置較低的優先級,確保處理器不會被獨佔。

這段話應該怎麼理解呢,可參考 IOS 如何高效的使用多線程

其在線程優先級權衡中提到:通常來說,線程調度除了輪轉法以外,還有優先級調度的方案。

在線程調度時,高優先級的線程大概率會更早的執行。有兩個概念需要明確:

  • IO 密集型線程:頻繁等待的線程,等待的時候會讓出時間片。

  • CPU 密集型線程:很少等待的線程,意味着長時間佔用着 CPU。

在特殊場景下,當多個 CPU 密集型線程霸佔了所有 CPU 資源,

而它們的優先級都比較高,而此時優先級較低的 IO 密集型線程將持續等待,產生線程餓死的現象。

爲避免線程餓死,系統會逐步提高被“冷落”線程的優先級,IO 密集型線程通常下比 CPU 密集型線程更容易獲取到優先級提升。

雖然系統會自動做這些事情,但是這總歸會造成時間等待,可能會影響用戶體驗。所以開發者需要從兩個方面權衡優先級問題:

  • 讓 IO 密集型線程優先級高於 CPU 密集型線程。
  • 讓緊急的任務擁有更高的優先級。

2、線程間的狀態及轉換

 

Java線程在運行的生命週期中可能處於上圖所示的六種不同狀態,在給定時刻,線程只能處於其中的一個狀態。

 狀態名稱  說明
 NEW  初始狀態,線程被構建,但是還沒有調用start方法
 RUNNABLE  運行狀態,Java線程將操作系統中的就緒和運行狀態統稱爲運行中
 BLOCKED  阻塞狀態,表示線程阻塞於鎖
 WAITING  等待狀態,進入該狀態標識當前線程需要等待其他線程做出一些動作(通知或中斷)
 TIME_WAITING  超時等待狀態,與WAITING不同的是,在等待指定時間後會自行返回
 TERMINATED  終止狀態,表示當前線程已經執行完畢
3、同步塊和同步方法的實現方式

對於同步塊的實現使用了monitorenter和monitorexit指令,而同步方法則是依靠方法修飾符上的ACC_SYNCHRONIZE來完成的。

無論採用哪種方式,其本質是對一個對象的監視(monitor)進行獲取。

這個獲取的過程是排他的,也就是同一時刻只能有一個線程獲取到由synchronized所保護對象的監視器。

4、ThreadLocal的使用

ThreadLocal是一個線程變量,以ThreadLocal對象爲鍵,任意對象爲值的存儲結構。

這個結構被附帶在線程上,也就是說一個線程可以根據一個ThreadLocal對象查詢到綁定在這個線程上的值。

5、池化技術

池化技術指的是預先創建若干數量的線程,並且不能由用戶直接對線程的創建進行控制。

在這個前提下重複使用固定或較爲固定數目的線程來完成任務的執行。

這樣做的好處是:

  • 消除了頻繁創建和消亡線程的系統資源開銷
  • 面對過量任務的提交能夠平緩的劣化

第五章:Java中的鎖

1、Lock接口

Lock接口的實現基本都是通過聚合了一個同步器(AQS)的子類來完成線程訪問控制的。

子類推薦被定義爲自定義同步組件的靜態內部類,例如常用的ReentrantLock中的公平鎖和非公平鎖。

2、隊列同步器

同步器提供的模板方法基本分爲三類:

獨佔式獲取與釋放同步狀態、共享式獲取鎖與釋放同步狀態和查詢同步隊列中的等待線程情況。

同步器是實現鎖(也可以是任意同步組件)的關鍵,在鎖的實現中聚合同步器,利用同步器實現鎖的語義。

可以這樣理解二者之間的關係:

鎖是面向使用者的,它定義了使用者與鎖交互的接口,隱藏了實現細節;

同步器面向的是鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步狀態管理、線程的排隊、等待與喚醒等底層操作。

鎖和同步器很好的隔離了使用者和實現者所需關注的領域。


以上所涉及到的圖來源於以下博客:

內存屏障的原理

Java內存模型

淺談偏向鎖、輕量級鎖和重量級鎖

知乎程序員cxuan(內存屏障類型表)

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