1. 程式人生 > >簡單說說可見性和volatile

簡單說說可見性和volatile

() 安全 設計 性能 atom 總結 感悟 舉例 內部

以下是重新翻閱寫在書上的筆記整理出來的,前一篇文章就不再更新了(懶)

以可見性的討論開始

可見性和硬件的關聯

計算機為了高速訪問資源,對內存進行了一定的緩存,但緩存不一定能在各線程(處理器)之間相互通信,因此在多線程上需要額外註意硬件帶來的可見性問題(可能會讀到臟數據),註意這裏只討論共享變量下的情況

可能導致的問題

處理器不直接與主內存執行讀寫操作(慢),而是通過寄存器/寫緩沖器/高速緩存/無效化隊列等部件執行,解決一個問題的同時會產生更多的問題,因此多線程下會導致以下問題

1.不可訪問:線程所共享的變量分配到寄存器中

2.不可同步:縣城所共享變量只更新到寫緩沖器中,未到達高速緩沖

3.可同步,但需通過緩存一致性協議:總算寫入到高速緩存中,但其他處理器把該更新通知的內容存入無效化隊列(x86並沒有該部件)

緩存一致性協議

先說療效:對於某個處理器,通過該協議,該協議可讀取其他處理器的高速緩存

我們稱一個處理器從自身緩存以外的其它存儲部件讀取的數據並更新到該處理器的高速緩存成為緩存同步

因此緩存同步能使一個線程(處理器)讀取到其它線程(處理器)的共享變量,也就保障了可見性

緩存一致性協議就是一種緩存同步的方法

緩存一致性協議做到的事情:

1.沖刷緩存:處理器的更新最終寫入到(該處理器)的高速緩存或主內存

2.刷新緩存:處理器讀取時必須從其他高速緩存/主內存對應的變量進行緩存同步

更為具體的內部實現Chapter.11有提到

Java上的體現

volatile的作用之一便是寫進行沖刷緩存,讀進行沖刷緩存,以達到保證線程可見性的目的

(另外的作用是提示JIT不要亂優化,這是有序性上的問題,一般指令重排序由JIT引起)

輪到volatile

可見性的程度

前面提到volatile是調用緩存一致性在Java中的體現,保障了可見性,但可見到什麽程度?我們需要註意這個問題

給出答案:我們能保障的可見性僅是讀取到共享變量的相對新值,而並非最新值

相對新值:線程更新值後,其他線程能讀取到更新後的值

最新值:讀取變量的線程在讀取時其他線程無法對該值更新

舉例

我對書上P53的例子做出一定修改來說明上面的問題

假設a為volatile int型共享變量,初值為0,先開啟兩個線程

處理器0 處理器1
時刻0 null(a此時為0) null(a此時為0)
時刻1 a=1 無關操作
時刻2 無關操作 b=a+1

從時刻2來看,處理器1能看到此時a必然為1,因為時刻1之後其他處理器均能看到更新後的值,因此b=2

處理器0 處理器1
時刻0 null(a此時為0) null(a此時為0)
時刻1 a=1 無關操作
時刻2 a=2 b=a+1

但如果是時刻2中處理器1在讀取a的同時,處理器0也在更新,那麽此時a便無法由可見性確認是1還是2,因此b無法確定

題外話:Java還規定了子線程對創建前的父線程更新的可見性,因此時刻1的讀操作前無論是否有volatile都可得知a=0

題外話2:如果a僅為int型,我們只能確保其中的原子性,在表1的時刻2的處理器1看來a可能是0或者1

題外話3:如果a為long型,我們甚至無法確保原子性,在大數值時可能會產生一個不存在的數(區分高低32位)

解決重排序

volatile解決重排序除了軟件頂層提醒JIT的優化以外,還會對讀寫操作設置不同的內存屏障禁止存儲子系統重排序,有待更新

volatile的性能

從性能層面來說,volatile暴打內部鎖是沒問題的,原因如下

1.沒有上下文切換的開銷

2.沒有鎖的申請

但和普通變量相比,它依然有所不足:

1.讀寫會沖刷/刷新緩存

2.變量肯定不會暫存於寄存器,最多也就在L1

因此對於極為頻繁的讀操作,還是要打折扣的

(至於量化測試,待我學有所成再說吧)

作用總結

1.可見性,我已經(盡我所能)說明了

2.有序性,由於Java中其它單獨控制有序性的工具沒有別的(final我會後續補充)

3.極為有限的原子性,volatile在規定上保證longdouble的讀寫原子性,以及任意操作只與自身相關的原子性

volatile的讀和寫

讀:作為讀的使用,我們是可以放心的,因為它註定只涉及自身相關,前面提到了,原子性也是可以保證的

寫:寫僅當不涉及共享變量時才確保原子性。具體以volatile a為例:

1.首先多個線程寫入不共享也會保證原子性,比如a=3,因為最後一步必成功(必保證單一寫的原子性,而3可認為是immutable)

2.即使只涉及自身的運算也不一定線程安全,因為a自身便是共享變量,volatile並不保證賦值(涉及到讀共享變量和寫共享變量)一定具有原子性,比如a++便是線程不安全的

針對寫的不足,可以采用如下方法

1.部分加鎖,可利用對讀直接返回,對寫加鎖進行處理,比如讀寫鎖的實現、單例模式的實現

2.CAS解決a++問題,而這便是Atomic類的實現思路

volatile的使用場合

1.作為某個通知變量,只讀並輸出

2.部分代替鎖,對於創建新的對象,如volatile Map map = new HashMap(),該操作會分為3個步驟,分配HashMap.class所需的空間、初始化引用對象、將對象引用寫入變量map。註意到前面兩個步驟依然是只涉及局部變量,而最後的寫操作也必然保證原子性,因此該賦值是原子性的。倘若設計一個類封裝許多變量,讀是並發的,但寫仿照該方式來賦值,那也無需任何加鎖

3.作為鎖的部分優化,比如前面提到的讀寫鎖,對讀並發,對寫獨占,雖然不足是有的,但還是比鎖厲害

final的多線程用法

在語義上,volatilefinal是不可共存的,因此final在設計上也需要線程安全的某種保障,令人驚異的是它具有有序性卻沒有可見性和原子性,這種設計和安全發布有一定關聯

安全發布

之前曾經遇到private的逸出操作,深感自己菜到不行,因為這是常見的getter暴露對象的做法,但本篇重點不在這裏,相關的內容放到以後總結

這裏要提的是初始化安全問題,new的操作涉及三個步驟,1分配空間2初始化3引用寫入,但重排序會導致2和3的步驟不一致,可能發布後對象內某個變量依然沒有初始化完畢(或者不可見)

final就保證了引用寫入前變量必然初始化完畢,這裏需要註意的是,final不保證可見性但必須保證有序性,個人認為原因如下:

1.對於單一線程來說,有序性是無需討論的,而多線程意義上,判斷一個對象是否初始化想必是使用obj == null來判斷,即使它不可見,但也不影響當其它線程可見時該final對象必然初始化完畢這個結論,更何況final的意義就在於發布,因此是否可見已經無所謂了

2.如果不保證有序性,其它線程無從判斷是否完全初始化完畢,比如上面的例子,雖然只是部分初始化,但它確實不是null,線程安全無法保證

3.那能不能只保證可見但不保證有序,我認為不能,起碼Java沒這操作

題外話,static能保證讀線程必然讀到初始值,也算是一種有限的可見性,該初始化可能導致上下文切換

CAS

CAS可認為是硬件鎖,能實現C和S的原子性(由硬件大哥保證),一般搭配volatile使用

個人感悟是其特點是不阻塞,通常用於單一可變變量的判斷,比如如何在多線程下僅多開啟一個線程?你可以用AtomicBooleantruefalse的CAS來輕松實現(原子性保證只有一個線程成功,其余線程均告失敗但絕不阻塞),註意這個操作僅靠bool是無法完成的,因為原子(判)+原子(寫)≠原子

喜聞樂見的ABA可用類似時間戳的方法解決(其中ABA在數據結構上引發的問題挺有意思的,隨便搜搜就有),MySQL也有類似操作(樂觀鎖),不寫了bye

簡單說說可見性和volatile