Java 開發, volatile 你必須了解一下

2021-08-20 04:18:16 字數 3436 閱讀 1896

首先說我們如果要使用 volatile 了,那肯定是在多執行緒併發的環境下。我們常說的併發場景下有三個重要特性:原子性、可見性、有序性。只有在滿足了這三個特性,才能保證併發程式正確執行,否則就會出現各種各樣的問題。

原子性,上篇文章說到的 cas 和 atomic* 類,可以保證簡單操作的原子性,對於一些負責的操作,可以使用synchronized 或各種鎖來實現。

可見性,指當多個執行緒訪問同乙個變數時,乙個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

有序性,程式執行的順序按照**的先後順序執行,禁止進行指令重排序。看似理所當然的事情,其實並不是這樣,指令重排序是jvm為了優化指令,提高程式執行效率,在不影響單執行緒程式執行結果的前提下,盡可能地提高並行度。但是在多執行緒環境下,有些**的順序改變,有可能引發邏輯上的不正確。

而 volatile 做實現了兩個特性,可見性和有序性。所以說在多執行緒環境中,需要保證這兩個特性的功能,可以使用 volatile 關鍵字。

說到可見性,就要了解一下計算機的處理器和主存了。因為多執行緒,不管有多少個執行緒,最後還是要在計算機處理器中進行的,現在的計算機基本都是多核的,甚至有的機器是多處理器的。我們看一下多處理器的結構圖:

這是兩個處理器,四核的 cpu。乙個處理器對應乙個物理插槽,多處理器間通過qpi匯流排相連。乙個處理器包含多個核,乙個處理器間的多核共享l3 cache。乙個核包含暫存器、l1 cache、l2 cache。

在程式執行的過程中,一定要涉及到資料的讀和寫。而我們都知道,雖然記憶體的訪問速度已經很快了,但是比起cpu執行指令的速度來,還是差的很遠的,因此,在核心中,增加了l1、l2、l3 **快取,這樣一來,當程式執行的時候,先將所需要的資料從主存複製乙份到所在核的快取中,運算完成後,再寫入主存中。下圖是 cpu 訪問資料的示意圖,由暫存器到快取記憶體再到主存甚至硬碟的速度是越來越慢的。

了解了 cpu 結構之後,我們來看一下程式執行的具體過程,拿乙個簡單的自增操作舉例。

i=i+1

;

執行這條語句的時候,在某個核上執行的某執行緒將 i 的值拷貝乙個副本到此核所在的快取中,當運算執行完成後,再回寫到主存中去。如果是多執行緒環境下,每乙個執行緒都會在所執行的核上的快取記憶體區有乙個對應的工作記憶體,也就是每乙個執行緒都有自己的私有工作快取區,用來存放運算需要的副本資料。那麼,我們再來看這個 i+1 的問題,假設 i 的初始值為0,有兩個執行緒同時執行這條語句,每個執行緒執行都需要三個步驟:

1、從主存讀取 i 值到執行緒工作記憶體,也就是對應的核心快取記憶體區;

2、計算 i+1 的值;

3、將結果值寫回主存中;

建設兩個執行緒各執行 10,000 次後,我們預期的值應該是 20,000 才對,可惜很遺憾,i 的值總是小於 20,000 的 。導致這個問題的其中乙個原因就是快取一致性問題,對於這個例子來說,一旦某個執行緒的快取副本做了修改,其他執行緒的快取副本應該立即失效才對。

而使用了 volatile 關鍵字後,會有如下效果:

1、每次對變數的修改,都會引起處理器快取(工作記憶體)寫回到主存;

2、乙個工作記憶體回寫到主存會導致其他執行緒的處理器快取(工作記憶體)無效。

因為 volatile 保證記憶體可見性,其實是用到了 cpu 保證快取一致性的 mesi 協議。mesi 協議內容較多,這裡就不做說明,請各位同學自己去查詢一下吧。總之用了 volatile 關鍵字,當某執行緒對 volatile 變數的修改會立即回寫到主存中,並且導致其他執行緒的快取行失效,強制其他執行緒再使用變數時,需要從主存中讀取。

那麼我們把上面的 i 變數用 volatile 修飾後,再次執行,每個執行緒執行 10,000 次。很遺憾,還是小於 20,000 的。這是為什麼呢?

volatile 利用 cpu 的 mesi 協議確實保證了可見性。但是,注意了,volatile 並沒***操作的原子性,因為這個自增操作是分三步的,假設執行緒 1 從主存中讀取了 i 值,假設是 10 ,並且此時發生了阻塞,但是還沒有對i進行修改,此時執行緒 2 也從主存中讀取了 i 值,這時這兩個執行緒讀取的 i 值是一樣的,都是 10 ,然後執行緒 2 對 i 進行了加 1 操作,並立即寫回主存中。此時,根據 mesi 協議,執行緒 1 的工作記憶體對應的快取行會被置為無效狀態,沒錯。但是,請注意,執行緒 1 早已經將 i 值從主存中拷貝過了,現在只要執行加 1 操作和寫回主存的操作了。而這兩個執行緒都是在 10 的基礎上加 1 ,然後又寫回主存中,所以最後主存的值只是 11 ,而不是預期的 12 。

所以說,使用 volatile 可以保證記憶體可見性,但無法保證原子性,如果還需要原子性,可以參考,之前的這篇文章。

這裡主要說一下 volatile 關鍵字的規則,舉乙個著名的單例模式中的雙重檢查的例子:

class

singleton

public

static

singleton

getinstance

()

} return

instance;

} }

如果 instance 不用 volatile 修飾,可能產生什麼結果呢,假設有兩個執行緒在呼叫 getinstance() 方法,執行緒 1 執行步驟 step1 ,發現 instance 為 null ,然後同步鎖住 singleton 類,接著再次判斷 instance 是否為 null ,發現仍然是 null,然後執行 step 3 ,開始例項化 singleton 。而在例項化的過程中,執行緒 2 走到 step 1,有可能發現 instance 不為空,但是此時 instance 有可能還沒有完全初始化。

什麼意思呢,物件在初始化的時候分三個步驟,用下面的偽**表示:

memory = allocate();  //1. 分配物件的記憶體空間 

ctorinstance(memory); //2. 初始化物件

instance = memory; //3. 設定 instance 指向物件的記憶體空間

因為步驟 2 和步驟 3 需要依賴步驟 1,而步驟 2 和 步驟 3 並沒有依賴關係,所以這兩條語句有可能會發生指令重排,也就是或有可能步驟 3 在步驟 2 的之前執行。在這種情況下,步驟 3 執行了,但是步驟 2 還沒有執行,也就是說 instance 例項還沒有初始化完畢,正好,在此刻,執行緒 2 判斷 instance 不為 null,所以就直接返回了 instance 例項,但是,這個時候 instance 其實是乙個不完全的物件,所以,在使用的時候就會出現問題。

jvm 底層是通過乙個叫做「記憶體屏障」的東西來完成。記憶體屏障,也叫做記憶體柵欄,是一組處理器指令,用於實現對記憶體操作的順序限制。

通過 volatile 關鍵字,我們了解了一下併發程式設計中的可見性和有序性,當然只是簡單的了解。更深入的了解,還得靠各位同學自己去鑽研。

專案開發 volatile

vay0721 博主總結的挺好的 傳送門做一些摘要 volatile關鍵字是一種型別修飾符,用它宣告的型別變數表示可以被某些編譯器未知的因素更改,比如 作業系統 硬體或者其它執行緒等。遇到這個關鍵字宣告的變數,編譯器對訪問該變數的 就不再進行優化,從而可以提供對特殊位址的穩定訪問。直接訪問原始記憶體...

Java基礎 volatile作用

解決的問題 有時為了提高程式的執行效率,編譯器會進行優化,優化的方法就是講訪問的變數快取起來,程式讀取這個變數時直接到快取 例如暫存器 中來讀取,而不是去記憶體中讀取。這樣做的好處是提高了執行效率,但是遇到多執行緒時,有可能變數的值因為其他執行緒改變了,快取中的值不會改變,這樣會導致程式讀取的值和實...

Java(多執行緒) volatile

現在有乙個靜態變數 x static int x 0 執行緒a執行 x 2 使用 volatile 修飾的變數對所有執行緒具有可見性,這就解決了我們上邊遇到的問題 當乙個執行緒改變了變數的值,會立刻同步到主記憶體中,其它執行緒讀取時,也會從主記憶體中得到最新的值。volatile 的可見性是基於先行...