初步認識 Volatile 一段代碼引發(fā)的思考 下面這段代碼,演示了一個使用 volatile 以及沒使用volatile這個關鍵字,對于變量更新的影響 public class VolatileDemo {public /*volatile*/ static boolean stop=false;public static void main(String[] args) throwsInterruptedException {Thread thread=new Thread(()->{int i=0;while(!stop){i++;}});thread.start();System.out.println('begin start thread');Thread.sleep(1000);}} volatile 的作用 volatile 可以使得在多處理器環(huán)境下保證了共享變量的可見性,那么到底什么是可見性呢?不知道大家有沒有思考過這個問題 在單線程的環(huán)境下,如果向一個變量先寫入一個值,然后在沒有寫干涉的情況下讀取這個變量的值,那這個時候讀取到的這個變量的值應該是之前寫入的那個值。這本來是一個很正常的事情。但是在多線程環(huán)境下,讀和寫發(fā)生在不同的線程中的時候,可能會出現(xiàn):讀線程不能及時的讀取到其他線程寫入的最新的值。這就是所謂的可見性 為了實現(xiàn)跨線程寫入的內存可見性,必須使用到一些機制來實現(xiàn)。而 volatile 就是這樣一種機制 volatile 關鍵字是如何保證可見性的? 我們可以使用【hsdis】這個工具,來查看前面演示的這段代碼的匯編指令,具體的使用請查看使用說明文檔 在運行的代碼中,設置 jvm參數(shù)如下 【-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*App.*(替換成實際運行的代碼)】 然后在輸出的結果中,查找下 lock指令,會發(fā)現(xiàn),在修改帶有 volatile修飾的成員變量時,會多一個 lock指令。lock是一種控制指令,在多處理器環(huán)境下,lock 匯編指令可以基于總線鎖或者緩存鎖的機制來達到可見性的一個效果。 為了讓大家更好的理解可見性的本質,我們需要從硬件層面進行梳理 從硬件層面了解可見性的本質 一臺計算機中最核心的組件是 CPU、內存、以及 I/O設備。在整個計算機的發(fā)展歷程中,除了 CPU、內存以及 I/O設備不斷迭代升級來提升計算機處理性能之外,還有一個非常核心的矛盾點,就是這三者在處理速度的差異。CPU的計算速度是非??斓模瑑却娲沃?、最后是 IO設備比如磁盤。而在絕大部分的程序中,一定會存在內存訪問,有些可能還會存在 I/O設備的訪問 為了提升計算性能,CPU 從單核升級到了多核甚至用到了超線程技術最大化提高 CPU 的處理性能,但是僅僅提升CPU性能還不夠,如果后面兩者的處理性能沒有跟上,意味著整體的計算效率取決于最慢的設備。為了平衡三者的速度差異,最大化的利用 CPU提升性能,從硬件、操作系統(tǒng)、編譯器等方面都做出了很多的優(yōu)化 1. CPU增加了高速緩存 2. 操作系統(tǒng)增加了進程、線程。通過 CPU的時間片切換最大化的提升 CPU 的使用率 3.編譯器的指令優(yōu)化,更合理的去利用好 CPU的高速緩存然后每一種優(yōu)化,都會帶來相應的問題,而這些問題也是導致線程安全性問題的根源。為了了解前面提到的可見性問題的本質,我們有必要去了解這些優(yōu)化的過程 CPU 高速緩存 線程是 CPU調度的最小單元,線程設計的目的最終仍然是更充分的利用計算機處理的效能,但是絕大部分的運算任務不能只依靠處理器“計算”就能完成,處理器還需要與內存交互,比如讀取運算數(shù)據(jù)、存儲運算結果,這個 I/O 操作是很難消除的。而由于計算機的存儲設備與處理器的運算速度差距非常大,所以現(xiàn)代計算機系統(tǒng)都會增加一層讀寫速度盡可能接近處理器運算速度的高速緩存來作為內存和處理器之間的緩沖:將運算需要使用的數(shù)據(jù)復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步到內存之中。 通過高速緩存的存儲交互很好的解決了處理器與內存的速度矛盾,但是也為計算機系統(tǒng)帶來了更高的復雜度,因為它引入了一個新的問題,緩存一致性。 什么叫緩存一致性呢? 首先,有了高速緩存的存在以后,每個 CPU 的處理過程是,先將計算需要用到的數(shù)據(jù)緩存在 CPU高速緩存中,在 CPU進行計算時,直接從高速緩存中讀取數(shù)據(jù)并且在計算完成之后寫入到緩存中。在整個運算過程完成后,再把緩存中的數(shù)據(jù)同步到主內存。 由于在多CPU種,每個線程可能會運行在不同的CPU內,并且每個線程擁有自己的高速緩存。同一份數(shù)據(jù)可能會被緩存到多個 CPU 中,如果在不同 CPU 中運行的不同線程看到同一份內存的緩存值不一樣就會存在緩存不一致的問題 為了解決緩存不一致的問題,在 CPU 層面做了很多事情,主要提供了兩種解決辦法 1. 總線鎖 2. 緩存鎖 總線鎖和緩存鎖 總線鎖,簡單來說就是,在多 cpu 下,當其中一個處理器要對共享內存進行操作的時候,在總線上發(fā)出一個 LOCK#信號,這個信號使得其他處理器無法通過總線來訪問到共享內存中的數(shù)據(jù),總線鎖定把 CPU 和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數(shù)據(jù),所以總線鎖定的開銷比較大,這種機制顯然是不合適的如何優(yōu)化呢?最好的方法就是控制鎖的保護粒度,我們只需要保證對于被多個 CPU 緩存的同一份數(shù)據(jù)是一致的就行。所以引入了緩存鎖,它核心機制是基于緩存一致性協(xié)議來實現(xiàn)的。 緩存一致性協(xié)議 為了達到數(shù)據(jù)訪問的一致,需要各個處理器在訪問緩存時遵循一些協(xié)議,在讀寫時根據(jù)協(xié)議來操作,常見的協(xié)議有MSI,MESI,MOSI等。最常見的就是MESI協(xié)議。接下來給大家簡單講解一下MESI MESI表示緩存行的四種狀態(tài),分別是 1. M(Modify) 表示共享數(shù)據(jù)只緩存在當前 CPU 緩存中,并且是被修改狀態(tài),也就是緩存的數(shù)據(jù)和主內存中的數(shù)據(jù)不一致 2. E(Exclusive) 表示緩存的獨占狀態(tài),數(shù)據(jù)只緩存在當前CPU緩存中,并且沒有被修改 3. S(Shared) 表示數(shù)據(jù)可能被多個 CPU緩存,并且各個緩存中的數(shù)據(jù)和主內存數(shù)據(jù)一致 4. I(Invalid) 表示緩存已經失效 在 MESI 協(xié)議中,每個緩存的緩存控制器不僅知道自己的讀寫操作,而且也監(jiān)聽(snoop)其它 Cache的讀寫操作 對于 MESI 協(xié)議,從 CPU 讀寫角度來說會遵循以下原則:CPU讀請求:緩存處于M、E、S狀態(tài)都可以被讀取,I狀態(tài)CPU只能從主存中讀取數(shù)據(jù) CPU寫請求:緩存處于M、E 狀態(tài)才可以被寫。對于 S狀態(tài)的寫,需要將其他 CPU中緩存行置為無效才可寫 使用總線鎖和緩存鎖機制之后,CPU對于內存的操作大概可以抽象成下面這樣的結構。從而達到緩存一致性效果 總結可見性的本質 由于 CPU 高速緩存的出現(xiàn)使得 如果多個 cpu 同時緩存了相同的共享數(shù)據(jù)時,可能存在可見性問題。也就是 CPU0修改了自己本地緩存的值對于 CPU1 不可見。不可見導致的后果是 CPU1 后續(xù)在對該數(shù)據(jù)進行寫入操作時,是使用的臟數(shù)據(jù)。使得數(shù)據(jù)最終的結果不可預測。 很多同學肯定希望想在代碼里面去模擬一下可見性的問題,實際上,這種情況很難模擬。因為我們無法讓某個線程指定某個特定 CPU,這是系統(tǒng)底層的算法, JVM 應該也是沒法控制的。還有最重要的一點,就是你無法預測 CPU緩存什么時候會把值傳給主存,可能這個時間間隔非常短,短到你無法觀察到。最后就是線程的執(zhí)行的順序問題,因為多線程你無法控制哪個線程的某句代碼會在另一個線程的某句代碼后面馬上執(zhí)行。 所以我們只能基于它的原理去了解這樣一個存在的客觀事實了解到這里,大家應該會有一個疑問,剛剛不是說基于緩存一致性協(xié)議或者總線鎖能夠達到緩存一致性的要求嗎?為什么還需要加volatile 關鍵字?或者說為什么還會存在可見性問題呢? MESI 優(yōu)化帶來的可見性問題 MESI協(xié)議雖然可以實現(xiàn)緩存的一致性,但是也會存在一些問題。 就是各個 CPU緩存行的狀態(tài)是通過消息傳遞來進行的。如果CPU0 要對一個在緩存中共享的變量進行寫入,首先需要發(fā)送一個失效的消息給到其他緩存了該數(shù)據(jù)的 CPU。并且要等到他們的確認回執(zhí)。CPU0 在這段時間內都會處于阻塞狀態(tài)。為了避免阻塞帶來的資源浪費。在 cpu 中引入了Store Bufferes。 CPU0 只需要在寫入共享數(shù)據(jù)時,直接把數(shù)據(jù)寫入到 store bufferes中,同時發(fā)送 invalidate消息,然后繼續(xù)去處理其他指令。 當收到其他所有CPU發(fā)送了 invalidate acknowledge消息時,再將 store bufferes 中的數(shù)據(jù)數(shù)據(jù)存儲至 cache line中。最后再從緩存行同步到主內存。 但是這種優(yōu)化存在兩個問題 1. 數(shù)據(jù)什么時候提交是不確定的,因為需要等待其他 cpu給回復才會進行數(shù)據(jù)同步。這里其實是一個異步操作2. 引入了 storebufferes后,處理器會先嘗試從 storebuffer中讀取值,如果 storebuffer 中有數(shù)據(jù),則直接從storebuffer中讀取,否則就再從緩存行中讀取 我們來看一個例子 exeToCPU0和exeToCPU1分別在兩個獨立的CPU上執(zhí)行。假如 CPU0 的緩存行中緩存了 isFinish 這個共享變量,并且狀態(tài)為(E)、而 Value可能是(S)狀態(tài)。 那么這個時候,CPU0 在執(zhí)行的時候,會先把 value=10的指令寫入到storebuffer中。并且通知給其他緩存了該value變量的 CPU。在等待其他 CPU通知結果的時候,CPU0會繼續(xù)執(zhí)行 isFinish=true這個指令。 而因為當前 CPU0緩存了 isFinish并且是 Exclusive狀態(tài),所以可以直接修改 isFinish=true。這個時候 CPU1 發(fā)起 read操作去讀取 isFinish 的值可能為 true,但是 value的值不等于10。 這種情況我們可以認為是 CPU的亂序執(zhí)行,也可以認為是一種重排序,而這種重排序會帶來可見性的問題volatile 關鍵字?或者說為什么還會存在可見性問題呢? 這下硬件工程師也抓狂了,我們也能理解,從硬件層面很難去知道軟件層面上的這種前后依賴關系,所以沒有辦法通過某種手段自動去解決。 所以硬件工程師就說: 既然怎么優(yōu)化都不符合你的要求,要不你來寫吧。 所以在 CPU 層面提供了 memory barrier(內存屏障)的指令,從硬件層面來看這個 memroy barrier 就是 CPU flush store bufferes中的指令。軟件層面可以決定在適當?shù)牡胤絹聿迦雰却嫫琳稀?/p> CPU 層面的內存屏障 什么是內存屏障?從前面的內容基本能有一個初步的猜想,內存屏障就是將 store bufferes 中的指令寫入到內存,從而使得其他訪問同一共享內存的線程的可見性。 X86的memory barrier指令包括 lfence(讀屏障) sfence(寫屏障) mfence(全屏障)Store Memory Barrier(寫屏障) 告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(store bufferes)中的數(shù)據(jù)同步到主內存,簡單來說就是使得寫屏障之前的指令的結果對屏障之后的讀或者寫是可見的 Load Memory Barrier(讀屏障) 處理器在讀屏障之后的讀操作,都在讀屏障之后執(zhí)行。配合寫屏障,使得寫屏障之前的內存更新對于讀屏障之后的讀操作是可見的 Full Memory Barrier(全屏障) 確保屏障前的內存讀寫操作的結果提交到內存之后,再執(zhí)行屏障后的讀寫操作有了內存屏障以后,對于上面這個例子,我們可以這么來改,從而避免出現(xiàn)可見性問題 總的來說,內存屏障的作用可以通過防止 CPU對內存的亂序訪問來保證共享數(shù)據(jù)在多線程并行執(zhí)行下的可見性但是這個屏障怎么來加呢?回到最開始我們講 volatile 關鍵字的代碼,這個關鍵字會生成一個 Lock的匯編指令,這個指令其實就相當于實現(xiàn)了一種內存屏障這個時候問題又來了,內存屏障、重排序這些東西好像是和平臺以及硬件架構有關系的。作為 Java 語言的特性,一次編寫多處運行。我們不應該考慮平臺相關的問題,并且這些所謂的內存屏障也不應該讓程序員來關心。 ![]() |
|
來自: 菌心說 > 《編程+、計算機、信息技術》