volatile面試題

一 什麼時候會用到 volatile ?

如果需要保證多線程共享變量的可見性和有序性(禁止指令重排序)

 

二 volatile的實現原理

通過內存屏障技術實現的。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障指令,內存屏障效果有:

  • 禁止volatile 修飾變量指令的重排序

  • 寫入數據強制刷新到主存

  • 讀取數據強制從主存讀取

2.1 內存屏障

  CPU 運行速度實在太快,主存(就是內存)的數據讀取速度和CPU 運算速度差了有幾個數量級,因此現代計算機系統通過在CPU 和主存之前加了一層讀寫速度儘可能接近CPU 運行速度的高速緩存來做數據緩衝,這樣緩存提前從主存獲取數據,CPU 不再從主存取數據,而是從緩存取數據。這樣就緩解由於主存速度太慢導致的CPU 飢餓的問題。同時CPU 內還有寄存器,一些計算的中間結果臨時放在寄存器內。

現在主流的多核CPU的硬件架構,如下圖所示。現代操作系統一般會有多級緩存(Cache Line),一般有L1、L2,甚至有L3

多核心CPU架構

CPU 從緩存讀取數據和從內存讀取數據除了讀取速度的差異?有什麼本質的區別嗎?不都是讀數據寫數據,而且加緩存會讓整個體系結構變得更加複雜。

緩存和主存不僅僅是讀取寫入數據速度上的差異,還有另外更大的區別:研究人員發現了程序80%的時間在運行20% 的代碼,所以緩存本質上只要把20%的常用數據和指令放進來就可以了(是不是和Redis 存放熱點數據很像),另外CPU 訪問主存數據時存在二個局部性現象:

1.時間局部性現象

如果一個主存數據正在被訪問,那麼在近期它被再次訪問的概率非常大。想想你程序大部分時間是不是在運行主流程。

2.空間局部性現象

CPU使用到某塊內存區域數據,這塊內存區域後面臨近的數據很大概率立即會被使用到。這個很好解釋,我們程序經常用的數組、集合(本質也是數組)經常會順序訪問(內存地址連續或鄰近)。

因爲這二個局部性現象的存在使得緩存的存在可以很大程度上緩解CPU 飢餓的問題。

 

程序運行時,數據是怎麼在主存、緩存、CPU寄存器之間流轉的

比如以 i = i + 2; 爲例, 當線程執行到這條語句時,會先從主存中讀取i 的值,然後複製一份到緩存中,CPU 讀取緩存數據(取數指令),進行 i + 2 操作(中間數據放寄存器),然後把結果寫入緩存,最後將緩存中i最新的值刷新到主存當中(寫回時間不確定)。

數據操作邏輯在單線程環境和多線程環境下有什麼區別?

比如i 如果是共享變量(例如對象的成員變量),單線程運行沒有任何問題(單核CPU 的多線程也會出現上面的線程不安全的問題,只是產生原因不是多核CPU緩存不一致的問題導致,而是CPU調度線程切換,多線程局部變量不同步引起的),但是多線程中運行就有可能出問題。例如:有A、B二個線程,在不同的CPU 上運行,因爲每個線程運行的CPU 都有自己的緩存,A 線程從內存讀取i 的值存入緩存,B 線程此時也讀取i 的值存入自己的緩存,A 線程對i 進行+1操作,i變成了1,B線程緩存中的變量 i 還是0,B線程也對i 進行+1操作,最後A、B線程先後將緩存數據寫入內存,內存預期正確的結果應該是2,但是實際是1。這個就是非常著名的緩存一致性問題。

     早期的一些CPU 設計中,是通過鎖總線(總線訪問加Lock# 鎖)的方式解決的。因爲CPU 都是通過總線來讀取主存中的數據,因此對總線加Lock# 鎖的話,其他CPU 訪問主存就被阻塞了,這樣防止了對共享變量的競爭。但是鎖總線對CPU的性能損耗非常大,把多核CPU 並行的優勢直接給乾沒了!

緩存一致性協議。協議的類型很多(MSI、MESI、MOSI、Synapse、Firefly),最常見的就是Intel 的MESI 協議。緩存一致性協議主要規範了CPU 讀寫主存、管理緩存數據的一系列規範,如下圖所示。

緩存一致性協議

 

MESI 協議的核心思想:

1.定義了緩存中的數據狀態只有四種,MESI 是四種狀態的首字母。
2.當CPU寫數據時,如果寫的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態;
2.當CPU讀取共享變量時,發現自己緩存的該變量的緩存行是無效的,那麼它就會從內存中重新讀取。

緩存中數據都是以緩存行(Cache Line)爲單位存儲;MESI 各個狀態描述如下表所示: 

image-20200512091902093

 

MESI 協議和volatile實現的內存可見性時什麼關係?

volatile 和MESI 中間差了好幾層抽象,中間會經歷java編譯器,java虛擬機和JIT,操作系統,CPU核心。

volatile 是Java 中標識變量可見性的關鍵字,說直接點:使用volatile 修飾的變量是有內存可見性的,這是Java 語法定的,Java 不關心你底層操作系統、硬件CPU 是如何實現內存可見的,我的語法規定就是volatile 修飾的變量必須是具有可見性的。
 

線程的工作內存在主存還是緩存中

JMM 中定義的每個線程私有的工作內存是抽象的規範,實際上工作內存和真實的CPU 內存架構如下所示,Java 內存模型和真實硬件內存架構是不同的:

JMM與真實內存架構

JMM 內存模型規範

1.初始變量首先存儲在主內存中;
2.線程操作變量需要從主內存拷貝到線程本地內存中;
3.線程的本地工作內存是一個抽象概念,包括了緩存、store buffer(後面會講到)、寄存器等。
JMM

 

JMM 模型中多線程如何通過共享變量通信呢?

線程間通信必須要經過主內存。

線程A與線程B之間要通信的話,必須要經歷下面2個步驟:

1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。

2)線程B到主內存中去讀取線程A之前已更新過的共享變量。

線程間通信

 

 

一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作(單一操作都是原子的)來完成: 

1.lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。
2.unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量解除鎖定,解除鎖定後的變量纔可以被其他線程鎖定。
3.read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用
4.load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
5.use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
6.assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
7.store(有的指令是save/存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。
8.write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。
 

Java內存模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

1.如果要把一個變量從主內存中複製到工作內存,需要順序執行read 和load 操作, 如果把變量從工作內存中同步回主內存中,就要按順序地執行store 和write 操作。但Java內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行,也就是操作不是原子的,一組操作可以中斷。
2.不允許read和load、store和write操作之一單獨出現,必須成對出現。
3.不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之後必須同步到主內存中。
4.不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步回主內存中。
5.一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。
6.一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。lock和unlock必須成對出現
7.如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值
8.如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。
9.對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)。
 

 2.2 Java 通過 Java 內存模型(JMM )實現 volatile 平臺無關

volatile 可以讓共享變量實現可見性,同時禁止共享變量的指令重排,保障可見性。

從JSR-333 規範 和 實現原理講:

             ①JSR-333 規範:JDK 5定義的內存模型規範,

                     在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。

                             1. 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

                               2. 兩個操作之間存在happens-before關係,並不意味着一定要按照happens-before原則制定的順序來執行。如果重排序之後的執行結果與按照happens-before關係來執行的結果一致,那麼這種重排序並不非法。

1.程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
2.鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;
3.volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作;
4.傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
5.線程啓動規則:Thread對象的start()方法先行發生於此線程的每個一個動作;
6.線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;
7.線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;
8.對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始;

                      ②實現原理

                  上面說的happens-before原則保障可見性,禁止指令重排保證有序性,如何實現的呢? 

Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,保證共享變量操作的有序性。

內存屏障指令:寫操作的會讓線程本地的共享內存變量寫完強制刷新到主存。讀操作讓本地線程變量無效,強制從主內存讀取,保證了共享內存變量的可見性。

JVM中提供了四類內存屏障指令:

image-20200512091721797

JSR-133 定義的相應的內存屏障,在第一步操作(列)和第二步操作(行)之間需要的內存屏障指令如下:

image-20200511174714486

Java volatile 例子:

image-20200511175002261

以下是區分各個CPU體系支持的內存屏障

(也叫內存柵欄),由JVM 實現平臺無關(volatile所有平臺表現一致)

image-20200511172853931

 

三 volatile使用總結

volatile 是Java 提供的一種輕量級同步機制,可以保證共享變量的可見性和有序性(禁止指令重排),常用於

狀態標誌、雙重檢查的單例等場景。使用原則:

對變量的寫操作不依賴於當前值。例如 i++ 這種就不適用。
該變量沒有包含在具有其他變量的不變式中
volatile的使用場景不是很多,使用時需要仔細考慮下是否適用volatile,注意滿足上面的二個原則。

單個的共享變量的讀/寫(比如a=1)具有原子性,但是像num++或者a=b+1;這種複合操作,volatile無法保證其原子性;
 

3.1 狀態標誌的使用

 

比如我們工程中經常用一個變量標識程序是否啓動、初始化完成、是否停止等,如下:

volatile修飾狀態標誌

 

如果不加volatile 修飾,會有什麼後果?

比如這是一個帶前端交互的系統,有A、 B二個線程,用戶點了停止應用按鈕,A 線程調用shutdown() 方法,讓變量shutdown 從false 變成 true,但是因爲沒有使用volatile 修飾, B 線程可能感知不到shutdown 的變化,而繼續執行 doWork 內的循環,這樣違背了程序的意願:當shutdown 變量爲true 時,代表應用該停下了,doWork函數應該跳出循環,不再執行。

 

3.2 懶漢式單例模式

我們常用的 double-check 的單例模式,如下所示:

懶漢式單例模式

爲什麼使用volatile 修飾了singleton 引用還用synchronized 鎖?

volatile 只保證了共享變量 singleton 的可見性,但是 singleton = new Singleton(); 這個操作不是原子的,可以分爲三步:

步驟1:在堆內存申請一塊內存空間;

步驟2:初始化申請好的內存空間;

步驟3:將內存空間的地址賦值給 singleton;

所以singleton = new Singleton(); 是一個由三步操作組成的複合操作,多線程環境下A 線程執行了第一步、第二步之後發生線程切換,B 線程開始執行第一步、第二步、第三步(因爲A 線程singleton 是還沒有賦值的),所以爲了保障這三個步驟不可中斷,可以使用synchronized 在這段代碼塊上加鎖。
 

第一次檢查singleton 爲空後爲什麼內部還需要進行第二次檢查?

A 線程進行判空檢查之後開始執行synchronized代碼塊時發生線程切換(線程切換可能發生在任何時候),B 線程也進行判空檢查,B線程檢查 singleton == null 結果爲true,也開始執行synchronized代碼塊,雖然synchronized 會讓二個線程串行執行,如果synchronized代碼塊內部不進行二次判空檢查,singleton 可能會初始化二次。
 

 

 

volatile 如何防止指令重排?

指令重排序 是編譯器和處理器爲了優化程序執行的性能而對指令序列進行重排的一種手段。現象就是CPU 執行指令的順序可能和程序代碼的順序不一致,例如 a = 1; b = 2; 可能 CPU 先執行b=2; 後執行a=1;

singleton = new Singleton(); 由三步操作組合而成,如果不使用volatile 修飾,可能發生指令重排序。步驟3 在步驟2 之前執行,singleton 引用的是還沒有被初始化的內存空間,別的線程調用單例的方法就會引發未被初始化的錯誤。

指令重排序也遵循一定的規則:

(1).重排序不會對存在依賴關係的操作進行重排

指令重排

(2).重排序目的是優化性能,不管怎樣重排,單線程下的程序執行結果不會變

as-if-serial

 

 

參考鏈接:

 https://angela.blog.csdn.net/article/details/106060526

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