日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 人文社科 > 生活经验 >内容正文

生活经验

从底层吃透java内存模型(JMM)、volatile、CAS

發布時間:2023/11/28 生活经验 38 豆豆
生活随笔 收集整理的這篇文章主要介紹了 从底层吃透java内存模型(JMM)、volatile、CAS 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

前言

隨著計算機的飛速發展,cpu從單核到四核,八核。在2020年中國網民數預計將達到11億人。這些數據都意味著,作為一名java程序員,必須要掌握多線程開發,談及多線程,繞不開的是對JMM(Java 內存模型)。那么什么是JMM?什么是可見性、原子性、有序性?如何解決?本文將從CPU的緩存開始談起,深度解剖JMM底層原理。

CPU高速緩存(cache)

學過操作系統的同學都應該知道CPU緩存。那么為什么要弄這么一個CPU緩存呢?這是因為緩存的出現主要是為了解決CPU運算速度與內存讀寫速度不匹配的矛盾,因為CPU運算速度要比內存讀寫速度快很多,這樣會使CPU花費很長時間等待數據到來或把數據寫入內存。因此如果任何時候對數據的操作都要通過和內存的交互來進行,會大大降低指令執行的速度。因此在CPU里面就有了高速緩存。也就是,當程序在運行過程中,會將運算需要的數據從主存復制一份到CPU的高速緩存當中,那么CPU進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束之后,再將高速緩存中的數據刷新到主存當中


如圖,CPU緩存分為三層(L1,L2,L3 Cache),L1和L2 Cache都是每個CPU core獨立擁有一個,而L3 Cache是幾個Cores共享的,可以認為是一個更小但是更快的內存。CPU在做運算時需要先把內存(RAM)中的數據讀取到緩存當中,經過運算后再將數據寫回內存(RAM)中。這樣的操作在單核CPU中當然是沒有問題的,但是在多核CPU中會出現Cache一致性問題

什么是Cache一致性問題?

比如兩個CPU(a和b)同時將內存中的同一個變量i=0加載到了CPU緩存(L1或L2)中,aCPU對變量i進行了++操作后回寫到了內存中,此時內存中的i變量值變成了1,但是bCPU不知道,這是 bCPU在緩存中(L1或L2)的i變量還是0,這時bCPU對i變量進行i++運算后回寫到內存中,這是內存中的i變量被覆蓋,值還是1。這就是Cache一致性問題。

如何解決Cache一致性問題?

為了正確性,一旦一個CPU更新了內存中的內容,硬件就必須要保證其他的核心能夠讀到更新后的數據。目前大多數硬件采用的策略或協議是MESI或基于MESI的變種:
M代表更改(modified),表示緩存中的數據已經更改,在未來的某個時刻將會寫入內存;
E代表排除(exclusive),表示緩存的數據只被當前的CPU所緩存;
S代表共享(shared),表示緩存的數據還被其他CPU緩存;
I代表無效(invalid),表示緩存中的數據已經失效,即其他CPU更改了數據。
單個CPU對緩存中數據進行了改動,需要通知給其它CPU,也就是意味著,CPU處理要控制自己的讀寫操作,還要監聽其他CPU發出的通知,從而保證最終一致。

CPU運行時的指令重排

CPU在對性能的優化除了緩存之外還有運行時指令重排,當CPU寫緩存時發現緩存區正被其他CPU占用(例如:三級緩存L3),為了提高CPU處理性能,可能將后面的讀緩存命令優先執行。列如:

 x = 6;y = z;

這一段程序的正常執行順序應該是:

  1. 將6寫入X
  2. 讀取z的值
  3. 將z值寫入y

但是經過CPU指令重排后的執行順序可能是這樣:

  1. 讀取z的值
  2. 將z值寫入y
  3. 將6寫入x
    當然,指令重排并非隨便重排,是需要遵守 as-if-serial 語義的,as-if-serial 語義的意思是指不管怎么重排序(編譯器和處理器為了提高并行度),單線程程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守 as-if-serial 語義,也就是說編譯器和處理器不會對存在數據依賴關系的操作做重排序。但是,雖然遵守了 as-if-serial語義,僅在單CPU自己執行的情況下能保證結果正確。多核多線程中,指令邏輯無法分辨因果關聯,可能出現亂序執行,導致程序運行結果錯誤。為了解決這個問題,就需要引入內存屏障了。

內存屏障

處理器提供了兩個內存屏障(Memory Barrier)指令用于解決上述兩個問題:

  • 寫內存屏障(Store Memory Barrier):在指令后插入 Store Barrier,能讓寫入緩存中的最新數據更新寫入主內存,讓其他線程可見。強制寫入主內存,這種顯示調用,CPU 就不會因為性能考慮而去對指令重排。

  • 讀內存屏障(Load Memory Barrier):在指令前插入 Load Barrier,可以讓高速緩存中的數據失效,強制從新的主內存加載數據。強制讀取主內存內容,讓 CPU 緩存與主內存保持一致,避免了緩存導致的一致性問題。

好了,到這里總算是將CPU的緩存機制粗略的講完了,接下來到了文章的重點部分:JMM,其實JMM的實現原理基本上就是照搬的CPU高速緩存的Cache一致性問題和CPU運行時的指令重排問題的解決策略。

JMM是什么?

JMM(Java內存模型Java Memory Model)本身是一種抽象的概念 并不真實存在,它是Java虛擬機規范中試圖定義的一種模型或規范來屏蔽各個硬件平臺和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。,通過規范定制了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式.
JMM關于同步規定:

  1. 可見性:要求主存中的變量數據對每個線程是可見的,即每個線程要得到主內存中實時的最新數據
  2. 原子性:變量的修改是一個不可分割的步驟
  3. 有序性:程序執行的順序按照代碼的先后順序執行

由于JVM運行程序的實體是線程,而每個線程創建時JVM都會為其創建一個工作內存(對應JVM內存區域的虛擬機棧),工作內存是每個線程的私有數據區域,而Java內存模型中規定所有變量(這里指的變量為類的成員變量,方法中創建的臨時變量不在其中,下同)都存儲在主內存(對應JVM內存區域的堆),主內存是共享內存區域,所有線程都可訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存拷貝到自己的工作空間,然后對變量進行操作,操作完成再將變量寫回主內存,不能直接操作主內存中的變量,各個線程中的工作內存儲存著主內存中的變量副本拷貝,因此不同的線程無法訪問對方的工作內存,線程之間的通訊(傳值) 必須通過主內存來完成,其簡要訪問過程如下圖:

可見性

在之前的CPU高速緩存中,我們講解了Cache一致性問題,JMM規范中的可見性Cache一致性問題是一樣一樣的。即:當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
下面一段代碼將描述變量的不可見性:

public class NoVisibility {private static int NUM = 0;public void numEqTen(){NUM = 10;}public static void main(String[] args) {final NoVisibility noVisibility = new NoVisibility();// 第一個線程new Thread(() -> {try {// 睡眠1秒鐘,保證主線程得到執行Thread.sleep(1000L);noVisibility.numEqTen();System.out.println(Thread.currentThread().getName() + "\t 執行完畢");} catch (InterruptedException e) {e.printStackTrace();}},"thread1").start();while (noVisibility.NUM == 0) {//如果myData的num一直為零,main線程一直在這里循環}System.out.println(Thread.currentThread().getName() + "\t 主線程執行完畢, num 值是 " + noVisibility.NUM);}
}

該程序的運行結果是:輸出thread1執行完畢,后一直停在了主線程的while循環中不能結束。下面解釋一下這段代碼為什么一直停留在while而無法執行完畢:
在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那么主線程在運行的時候,會將num變量的值拷貝一份放在自己的工作內存當中。那么當線程1更改了num變量的值之后,主線程由于不知道線程1對num變量的更改,因此還會一直循環下去。

原子性

即一個操作或者多個操作 要么全部執行并且執行的過程不會被任何因素打斷,要么就都不執行。對變量的操作,如:i++,該操作是分為三個指令執行的:

  1. 先得到i的初始值
  2. 對i進行加1操作
  3. 把i的累加結果寫回給i
    在多線程的環境下,線程在運行i++操作時,可能會在第一或第二個指令結束后由于線程的調度而被掛起去執行其它線程導致所得的結果可能會不是預期的結果。所以JMM規范變量的操作必須為原子操作。下面給出程序演示非原子性操作:
public class NoAtomicity {private int num;public void numPlusPlus(){num++;}public static void main(String[] args) {NoAtomicity noAtomicity = new NoAtomicity();for (int i = 0; i < 10; i++) {new Thread(() -> {try {for (int j = 0; j <200 ; j++) {noAtomicity.numPlusPlus();}} catch (Exception e) {e.printStackTrace();}},"thread" + String.valueOf(i)).start();}// 等待上面的線程運行完畢try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "\t num的最終值是:" + noAtomicity.num);}
}

我們都知道在理想情況下值應該是2000,然而因為num++不是原子性的,所以我執行出來的結果是:main num的最終值是:1600 當然,每次運行的結果可能都不一樣。但基本上都是小于2000的。

有序性

在前面我們講解了CPU運行時的指令重排,這里的有序性也是同樣的問題。計算機在執行程序時,為了提高性能(原因在CPU運行時的指令重排有說),編譯器和處理器常常會做指令重排,一把分為以下3中:

線程環境里面確保程序最終執行結果和代碼順序執行的結果一致,處理器在進行重新排序是必須要考慮指令之間的數據依賴性。多線程環境中線程交替執行,由于編譯器優化重排的存在,兩個線程使用的變量能否保持一致性是無法確定的,所以所得的結果無法預測。
重排代碼實例:

聲明變量:int a,b,x,y=0

線程1線程2
x = a;y = b;
b = 1;a = 2;
結 果x = 0 y=0

如果編譯器對這段程序代碼執行重排優化后,可能出現如下情況:

線程1線程2
b = 1;a = 2;
x= a;y = b;
結 果x = 2 y=1

這個結果說明在多線程環境下,由于編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的。
另外,Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 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()方法的開始
      這8條原則摘自《深入理解Java虛擬機》。

這8條規則中,前4條規則是比較重要的,后4條規則都是顯而易見的。

下面我們來解釋一下前4條規則:

  1. 對于程序次序規則來說,我的理解就是一段程序代碼的執行在單個線程中看起來是有序的。注意,雖然這條規則中提到“書寫在前面的操作先行發生于書寫在后面的操作”,這個應該是程序看起來執行的順序是按照代碼順序執行的,因為虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此,在單個線程中,程序執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。
  2. 第二條規則也比較容易理解,也就是說無論在單線程中還是多線程中,同一個鎖如果出于被鎖定的狀態,那么必須先對鎖進行了釋放操作,后面才能繼續進行lock操作。
  3. 第三條規則是一條比較重要的規則,直觀地解釋就是,如果一個線程先去寫一個變量,然后一個線程去進行讀取,那么寫入操作肯定會先行發生于讀操作。
  4. 第四條規則實際上就是體現happens-before原則具備傳遞性。

如何實現JMM規范?

在了解了JMM規范后,那么如何保證變量的可見性、原子性和有序性呢?可愛的java為我們提供了一些關鍵字如:synchronized、volatile。還有一個誠意滿滿的類庫:JUC,是不是很感動?哈哈~ 接下來我們來介紹幾種實現。

synchronized

談及synchronized,這家伙在在JavaSE 1.6之前可是一個重量級鎖,在JavaSE 1.6之后進行了主要包括為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖以及其它各種優化之后變得在某些情況下并不是那么重了。synchronized的底層實現主要依靠 Lock-Free 的隊列,基本思路是 自旋后阻塞,競爭切換后繼續競爭鎖,稍微犧牲了公平性,但獲得了高吞吐量。synchronized有三種使用方式:

  1. 修飾一個代碼塊,被修飾的代碼塊稱為同步語句塊,其作用的范圍是大括號{}括起來的代碼,作用的對象是調用這個代碼塊的對象
  2. 修飾一個方法,被修飾的方法稱為同步方法,其作用的范圍是整個方法,作用的對象是調用這個方法的對象
  3. 修改一個靜態的方法,其作用的范圍是整個靜態方法,作用的對象是這個類的所有對象
  4. 修改一個類,其作用的范圍是synchronized后面括號括起來的部分,作用主的對象是這個類的所有對象

當某部分被sychronized關鍵字修飾后,該部分在任意時刻只能有一個線程執行(得到鎖的線程),既然只能有一個線程執行,那么JMM中的可見性,原子性它都能夠保證了。那么有序性呢?sychronized還是不能阻止指令重排,在雙重檢驗+鎖實現單例模式時還是會出現空指針異常,這個我們后面會講到。

volatile

volatile是Java虛擬機提供的輕量級的同步機制,作用在變量上(類成員變量、類的靜態成員變量),它能對作用的變量保證可見性和禁止指令重排,但是并不能保證原子性。

可見性
我們回到前面講可見性時舉的例子:

public class NoAtomicity {private volatile int num;public void numPlusPlus(){num++;}public static void main(String[] args) {NoAtomicity noAtomicity = new NoAtomicity();for (int i = 0; i < 10; i++) {new Thread(() -> {try {for (int j = 0; j <200 ; j++) {noAtomicity.numPlusPlus();}} catch (Exception e) {e.printStackTrace();}},"thread" + String.valueOf(i)).start();}// 等待上面的線程運行完畢try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "\t num的最終值是:" + noAtomicity.num);}
}

通過之前的分析,我們知道主線程會在while循環中一直循環下去出不來,那么,如果在num變量前面加上關鍵字volatile修飾,情況就不一樣了:

  1. 使用volatile關鍵字會強制將修改的值立即寫入主存
  2. 使用volatile關鍵字的話,當線程1進行修改時,會導致主線程的工作內存中緩存變量num的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效)
  3. 由于主線程的工作內存中緩存變量num的緩存行無效,所以主線程再次讀取變量num的值時會去主存讀取

那么在線程1修改num值時(當然這里包括2個操作,修改線程1工作內存中的值,然后將修改后的值寫入內存),會使得主線程的工作內存中緩存變量num的緩存行無效,然后主線程讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之后,然后去對應的主存讀取最新的值。那么主線程讀取到的就是最新的正確的值。

有序性
在前面提到volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。
volatile關鍵字禁止指令重排序有兩層意思:

  1. 當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對后面的操作可見;在其后面的操作肯定還沒有進行;
  2. 在進行指令優化時,不能將在對volatile變量訪問的語句放在其后面執行,也不能把volatile變量后面的語句放到其前面執行。

我們前面講了CPU運行時的指令重排底層原理其實是內存屏障,volatile關鍵字禁止指令重排其實就是利用了內存屏障的原理:
“觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的匯編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令”
lock前綴指令實際上相當于一個內存屏障(也叫內存柵欄),內存屏障會提供3個功能:

  1. 它確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
  2. 它會強制將對緩存的修改操作立即寫入主存;
  3. 如果是寫操作,它會導致其他CPU中對應的緩存行無效。

原子性
在前面我們講原子性的時候已經講過,比舉了一個例子,現在我們再對剛才那個例子進行講解:

public class NoAtomicity {private volatile int num;public void numPlusPlus(){num++;}public static void main(String[] args) {NoAtomicity noAtomicity = new NoAtomicity();for (int i = 0; i < 10; i++) {new Thread(() -> {try {for (int j = 0; j <200 ; j++) {noAtomicity.numPlusPlus();}} catch (Exception e) {e.printStackTrace();}},"thread" + String.valueOf(i)).start();}// 等待上面的線程運行完畢try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "\t num的最終值是:" + noAtomicity.num);}
}

之前我們講過,在變量num沒有加上volatile關鍵字修飾時,最后num的結果會是小于2000,那么加上之后呢?我們來分析分析:假設此時num的值為10

  1. 線程1對變量num進行自增操作,即線程1讀取num的原始值,并且對num進行了加1操作,此時等式右邊的值已經變成了11(將 num++ 看成 num = num + 1 ),但還沒有將11賦值給num,然后線程1被阻塞了。
  2. 然后線程2對變量num進行自增操作,線程2也去讀取原始值,但這時由于線程1并沒有將11賦值給num,即沒有對變量進行修改操作,所以不會導致線程2的工作內存中緩存變量num的緩存行無效,所以這時num讀取到的值還是10,然后進行加1操作,并把11賦值給num寫入工作內存,最后寫入主存。
  3. 然后線程1接著進行賦值操作,將11賦值給變量num,然后將11寫入工作內存,最后寫入主存。

此時可以發現,兩次自增操作下來,由于num++不是原子操作,從而導致變量num只增加了1。
那么如何保證原子性?有三種解決辦法:

  1. 在numPlusPlus方法前面加上sychronized關鍵字修飾
  2. 使用Lock鎖
  3. 使用JUC.Atomic包下的AtomicInteger(后面細講)

理解了volatile和sychronized關鍵字后,我們來舉個常用的懶漢式雙重判斷+鎖的單例模式的實現:

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

這里將變量instance使用volatile修飾的原因是為了防止指令重排,導致空指針異常,具體原因:

在 instance = new Singleton();這個操作不是原子操作,可能存在著指令重排,正常順序是:

  1. 為Singleton()分配內存
  2. 初始化Singleton()
  3. 將instance變量指向Singleton()對象在堆內存中的地址

然而出現指令重排后,可能的順序會變成132,這樣就會導致線程1在執行到第3步時線程1被阻塞,這時雖然第2步還沒有執行,但是instance已經不為null了
然后線程2獲得執行,在if判斷時,因為instance不為null了,此時將會直接返回instance。這時線程2在通過instance訪問其成員變量時(如:instance.getName())就會報空指針異常。

這里使用的雙重if判斷的原因:

  1. 第一個if判斷主要是為了提高速率,因為絕大部分的線程都會在第一個if判斷后就直接返回instance從而跳過了synchronize這個略重的線程鎖。
  2. 第二個判斷是為了防止有兩個或以上線程同時通過了第一個if判讀進而掙搶鎖,線程1第一個獲取到了鎖創建了實例釋放鎖后,線程2競爭到了鎖,如果這時沒有加if判斷,那么線程2也會創建實例。

好了,我們回到剛剛說的使用JUC.Atomic包下的AtomicInteger解決volatile關鍵字不能實現原子性而導致上面程序的結果不為2000的解決辦法。那么何為AtomicInteger?

AtomicInteger

AtomicInteger類是java.util.concurrent.atomic下的類。java在atomic包下提供了基本變量和引用變量的原子類,支持單個變量上的無鎖線程安全編程。使用AtmoicInteger + volatile關鍵字實現上面所提到的程序結果不為2000的程序:

public class Atomicity {private volatile AtomicInteger num = new AtomicInteger(0);public void numIncrement(){num.getAndIncrement();}public int getNum(){return num.get();}public static void main(String[] args) {Atomicity atomicity = new Atomicity();for (int i = 0; i < 10; i++) {new Thread(() -> {try {for (int j = 0; j <200 ; j++) {atomicity.numIncrement();}} catch (Exception e) {e.printStackTrace();}},"thread" + String.valueOf(i)).start();}try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "\t num的最終值是:" + atomicity.getNum());}
}

運行此代碼的結果是2000,那么為什么使用了AtomicInteger后就能保證原子性了呢?
我們翻看AtomicInteger的源碼:

 /*** Atomically increments by one the current value.** @return the previous value*/public final int getAndIncrement() {return unsafe.getAndAddInt(this, valueOffset, 1);}

發現調用的是unsafe的方法,那么usafe又是什么呢?
UnSafe是CAS的核心類 由于Java 方法無法直接訪問底層 ,需要通過本地(native)方法來訪問,UnSafe相當于一個后面,基于該類可以直接操作特額定的內存數據.UnSafe類在于sun.misc包中,其內部方法操作可以向C的指針一樣直接操作內存,因此Java中CAS操作依賴于UNSafe類的方法.
注意UnSafe類中所有的方法都是native修飾的,也就是說UnSafe類中的方法都是直接調用操作底層資源執行響應的任務。
好了,現在了解了UnSafe是CAS的核心類,那么CAS又是什么?

CAS

CAS的全稱為Compare-And-Swap ,它是一條CPU并發原語.
它的功能是判斷內存某個位置的值是否為預期值,如果是則更新為新的值,這個過程是原子的.
CAS并發原語提現在Java語言中就是sun.miscUnSafe類中的各個方法.調用UnSafe類中的CAS方法,JVM會幫我實現CAS匯編指令.這是一種完全依賴于硬件 功能,通過它實現了原子操作,再次強調,由于CAS是一種系統原語,原語屬于操作系統用于范疇,是由若干條指令組成,用于完成某個功能的一個過程,并且原語的執行必須是連續的,在執行過程中不允許中斷,也即是說CAS是一條原子指令,不會造成所謂的數據不一致的問題。
了解了CAS后,現在我們繼續跟進unsafe.getAndAddInt(this, valueOffset, 1)方法:

public final int getAndAddInt(Object var1, long var2, int var4) {int var5;do {var5 = this.getIntVolatile(var1, var2);} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));return var5;}

那么這個方法又是如何實現原子操作的呢?
先對方法的參數進行解讀:

  1. 其中var1為AtomicInteger的對象,在上面的程序中,就是num對象
  2. valueOffset為地址偏移量,即為num對象在主內存中的地址
  3. var4為1,也就是每次要增加的值

對這個方法的解讀:
假設線程A和線程B兩個線程同時執行getAndAddInt操作(分別在不同的CPU上):

  1. AtomicInteger里面的value原始值為3,即主內存中AtomicInteger的value為3,根據JMM模型,線程A和線程B各自持有一份值為3的value的副本分別到各自的工作內存。
  2. 線程A通過getIntVolatile(var1,var2) 拿到value值3,這是線程A被掛起。
  3. 線程B也通過getIntVolatile(var1,var2) 拿到value值3,此時剛好線程B沒有被掛起并執行compareAndSwapInt方法比較內存中的值也是3 成功修改內存的值為4 線程B運行完畢。
  4. 這時線程A恢復,執行compareAndSwapInt方法比較,發現自己手里的數值和內存中的數字4不一致,說明該值已經被其他線程搶先一步修改了,那A線程修改失敗,只能重新來一遍了。
  5. 線程A重新獲取value值,因為變量value是volatile修飾,所以其他線程對他的修改,線程A總是能夠看到,線程A繼續執行compareAndSwapInt方法進行比較替換,直到成功。

好了,到這里就解釋清楚了AtomicInteger是如何保證原子性的,但是它的缺點也很明顯:

  1. 循環時間長,開銷很大。我們可以看到有個do while循環,若CAS一直失敗,會一直重試。
  2. 只能保證一個共享變量的原子性。一個變量可用使用CAS來保證原子性,若是涉及多個變量那就得使用鎖來保證原子性了。
  3. 會導致ABA問題

ABA問題

什么是ABA問題?簡單點的回答就是:貍貓換太子!
因為CAS在取出主存中的數據,然后再進行比較,在這兩個步驟中會有一個時間差,即這兩個步驟不是原子性的。那么就有可能線程2在線程1取完數據A后,也將數據A取出并將它改為B然后又將它改回A寫回內存。這是線程1在進行CAS操作時發現內存中的數據還是A,然后線程1就執行成功了。這就是ABA問題。
ABA問題程序實現:

public class ABA {private static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);public static void main(String[] args) {new Thread(() ->{atomicReference.compareAndSet(100,101);atomicReference.compareAndSet(101,100);},"thread1").start();new Thread(() ->{try {// 睡眠1秒,保證完成ABAThread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}atomicReference.compareAndSet(100,2020);System.out.println(atomicReference.get());},"thread1").start();}
}

執行的最終結果為2020,沒有解決ABA問題

那么如何解決ABA問題?
我們想想每次完成CAS操作后都給它加上一個版本號不就可以知道它有沒有被改過了嘛?那既然我們都能想到,可愛的Java早就想到了并且為我們提供了一個叫AtomicStampedReference的類,它也是在JUC.atomic包下。

public class ABAResolve {private static AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(100,1);public static void main(String[] args) {new Thread(()->{int stamp = stampedReference.getStamp();System.out.println(Thread.currentThread().getName()+"\t 第1次版本號"+stamp+"\t值是"+stampedReference.getReference());// 睡眠1s讓線程2獲取值和版本號try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}stampedReference.compareAndSet(100,101,stampedReference.getStamp(),stampedReference.getStamp()+1);System.out.println(Thread.currentThread().getName()+"\t 第2次版本號"+stampedReference.getStamp()+"\t值是"+stampedReference.getReference());stampedReference.compareAndSet(101,100,stampedReference.getStamp(),stampedReference.getStamp()+1);System.out.println(Thread.currentThread().getName()+"\t 第3次版本號"+stampedReference.getStamp()+"\t值是"+stampedReference.getReference());},"thread1").start();new Thread(()->{int stamp = stampedReference.getStamp();System.out.println(Thread.currentThread().getName()+"\t 第1次版本號"+stamp+"\t值是"+stampedReference.getReference());//保證線程1完成1次ABAtry {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}boolean result = stampedReference.compareAndSet(100, 2019, stamp, stamp + 1);System.out.println(Thread.currentThread().getName()+"\t 修改成功否"+result+"\t最新版本號"+stampedReference.getStamp());System.out.println("最新的值\t"+stampedReference.getReference());},"thread2").start();}
}

運行結果為:

thread1	 第1次版本號1	值是100
thread2	 第1次版本號1	值是100
thread1	 第2次版本號2	值是101
thread1	 第3次版本號3	值是100
thread2	 修改成功否false	最新版本號3
最新的值	100

至此ABA問題解決。

代碼

本文所涉及的所有代碼都在我的GitHub上:https://github.com/dave0824/jmm

推薦閱讀

  1. 程序員應該吃透的集合List
  2. Java集合之并發容器
  3. Java集合詳解之Map

參考

  1. 《深入理解Java虛擬機》
  2. volatile關鍵字之全面深度剖析
  3. 原來 CPU 為程序性能優化做了這么多

總結

以上是生活随笔為你收集整理的从底层吃透java内存模型(JMM)、volatile、CAS的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。