多線程(二):synchronized與volatile

再說synchronized與volatile之前,先讓我們瞭解一下CAS。

CAS(compare and swap)

CAS全稱爲compare and swap,比較與交換。因爲經常與循環一起工作時又稱爲自旋、自旋鎖或無鎖,所以泛指一類操作。
在這裏插入圖片描述
CAS具體思路爲:設有兩個線程A、B。線程A先拿到變量值i,假如爲0,如果然後做操作將變量值i+1,然後再去讀取這個值,看是否爲0(是否有線程改寫該值),如果爲0說明沒有線程改寫,那麼就寫入1;反之則重新讀取改變後的值,再做+1操作然後繼續判斷,直到沒有其他線程改寫該值,這個過程就爲compare and swap。

但CAS會有ABA問題出現。所謂ABA問題就是,在上述思路中對變量i進行操作時,“如果爲0說明沒有線程改寫,那麼就寫入1”,但是實際上這個i已經被改寫過了,即如果變量值i初始值爲0被改寫後,再改回0,此時線程A是發現不到的,這就是所謂的ABA問題。
ABA問題的解決辦法爲:對i加版本號,如果期間更改過,版本號會改變,然後再加以處理。

在java中,大量的程序用到了CAS操作,例如原子類AtomicInteger。通過閱讀AtomicInteger類中方法的實現代碼,我們可以發現,在底層的具體實現過程中,大量的用到了CAS操作。對於多線程環境而言,多個線程對一個變量值進行更改時,如果不加以處理,就會出現數據不一致的情況,而AtomicInteger之所以能夠滿足在多線程環境下多個線程對變量值操作後的數據一致性,得益於代碼中比較底層的指令:lock cmpxchg。
lock cmpxchg->CAS底層實現代碼涉及,CAS底層使用該指令保證原子性
在我個人的理解中,
cmpxchg -> 彙編語言的指令,和CAS的思路相同,也就是說CAS底層實現中直接對應着彙編語言的cmpxchg指令
但cmpxchg指令本質上不保證原子性
所以CAS操作具有原子性的原因是lock指令
lock -> 彙編語言的指令,兩個指令結合起來看,意思爲當前cpu執行cmpxchg指令時,其他cpu不能對其中的值做修改

我們今天要討論的synchronized與volatile在底層也會涉及lock指令,也就是說,synchronized 與 volatile關鍵字的底層實現都與lock指令有關。

對象的內存佈局

要了解synchronized底層實現之前,我們首先要了解一下什麼是對象的內存佈局。

一個對象的普通內存佈局分爲以下幾個部分:

  1. markword 保留了對象鎖信息,GC信息等
  2. class pointer 指向了類元數據信息
  3. instance data 對象中的數據
  4. padding 對齊填充

在這裏插入圖片描述
其中,markword與class pointer稱爲對象頭header,haeder是分析synchronized的重點,接下來會解析。剩餘的instance data很好理解,就是存儲一個對象中的數據信息;padding的存在是因爲對象的內存佈局是沒有采用固定大小的存儲方式的(個人認爲是實例數據不同以及非固定模式比較節省空間),所以爲了方便管理內存中的一個個對象,對於不能對8整除的對象大小(單位是字節Byte)進行填充,填充至對8整除。

通過使用openJDK的包JOL來查看對象中的存儲佈局
對於一個普通對象的內存佈局而言,在64位JVM環境下:

  • markword 8字節
  • class pointer 4字節(原本是8字節,但是JVM默認開啓了指針壓縮後,壓縮至4字節)
  • instance data 根據實際數據爲準
  • padding 填充至被8整除

舉個例子:
new Object()
我們可以計算出,對象頭head所佔字節大小爲12字節(markword佔8字節,class pointer壓縮後佔4字節),實例數據0字節,但是由於12字節無法被8整除,故填充4字節至16字節,也就是說,new Object()這個操作不考慮其他因素,其在內存中佔16字節。
如果再加上對象引用o,指向這個對象,即:Object o = new Object() 那麼在內存中就是20B(o壓縮後佔4個字節)

再舉一個例子:
User u = new User();
假如User中有兩個變量,int id,String name。new User()需要多少個字節呢?
markword 8B
class pointer 4B
int 4B
String 4B(普通對象指針壓縮)
padding 4B
共24個字節

瞭解了對象的內存佈局,我們在看看header中markword到底有什麼。
在這裏插入圖片描述
根據上面我們可以得出,一個對象的對象頭header中markword佔8個字節,也就是64位。這64位決定了synchronized在對象加鎖、鎖優化過程中的記錄。

synchronized

現在,我們終於要了解synchronized的原理了,但在瞭解之前,我們還要回顧一下我們再程序代碼中是如何使用synchronized關鍵字的,具體可以看我的另外一篇博客:
多線程(一):Synchronized與ReentrantLock

  • 代碼階段
    在使用synchronized之前,我們首先要在程序代碼中添加關鍵字synchronized,這一點是毋庸置疑的。
  • 編譯期
    在程序編譯過程中,java編譯器會將我們的程序代碼轉換爲jvm能讀懂的字節碼,也就是.class文件。
    通過瀏覽字節碼中的源碼,我們可以發現在添加synchronized的代碼塊中(如果添加到方法上就是方法體內),出現了兩條指令monitorenter / monitorexit。
  • 運行期
    在JDK1.6之後,java開發人員對synchronized底層做了許多優化,也就是鎖升級過程,這個鎖升級過程就是在運行期進行的。
  • 彙編語言
    通過瀏覽程序比較底層的源碼,我們依然可以發現lock cmpxchg彙編指令

也就是說,synchronized關鍵字底層還是通過lock cmpxchg實現的同步(加鎖機制)
synchronized底層的實現:
1.java代碼:在源代碼加synchronized關鍵字
2class字節碼:monitorenter / monitorexit 監視器進入與退出,
3.jvm執行過程中進行鎖升級
4.lock cmpxchg彙編指令

synchronized鎖升級過程

鎖升級是JDK1.6對synchronized關鍵字的優化,在1.6之前,synchronized被認爲是重量級鎖(現在也是,只不過在“真正”synchronized之前,添加好多手段),性能不高。
synchronized鎖升級的過程。
new -> 偏向鎖 -> 輕量級鎖(無鎖、自旋鎖、自適應自旋)-> 重量級鎖
這一過程記錄在markword(包含鎖信息、GC信息),我們再來引用上面的圖
在這裏插入圖片描述
在這裏插入圖片描述

我們來整理一下思路:

  1. new階段
    當對象剛被new出來時,默認是沒有鎖的,此時對象鎖狀態就爲上圖中的無鎖態。
  2. 偏向鎖階段
    在對象被new出來後,一個線程來訪問嘗試對其加鎖(synchronized(this)),那麼對象的鎖就會升級爲偏向鎖狀態。因爲此時只有一個線程嘗試對該對象加鎖,所以JVM並不會對其真正的加鎖(因爲沒有其他線程來爭搶鎖),而是在其markword中記錄當前指針的線程ID,並同時將偏向鎖位置1表示該對象在偏向鎖狀態。
  3. 輕量級鎖/自旋鎖/無鎖階段
    這時又有一個線程嘗試對該對象加鎖,即目前有兩個線程競爭該鎖,此時的鎖便升級爲輕量級鎖狀態。這個時候的鎖會將偏向鎖位收回,並留出部分空間保存一個線程的地址信息。這個地址信息是每個線程在線程棧中生成的一個Lork Record對象地址。對於此時競爭的兩個線程而言,每個線程都生成了自己LR對象,並嘗試用CAS(文章最開始的時候介紹過)的方式去嘗試向該空間寫地址,最終寫成功的線程將擁有對該對象的控制權,而另外的線程則自旋等待或進入第4階段。
  4. 重量級鎖階段
    在多個鎖競爭該對象時,或最大自旋次數(默認10)超過還沒有得到該對象的鎖時,那麼就會進行“真正”的加鎖階段,也就是底層向OS申請將用戶態轉爲核心態操作操作系統底層的互斥量,進而真正的分配鎖。

JDK1.6以後,JVM引入自適應自旋,默認打開,也就是將輕量級階段的鎖自旋次數或時間交由JVM處理。
前三個階段是JDK1.6基於synchronized的優化,對於這三個階段來說,程序都還在用戶態上運行,並沒有轉換到核心態去申請資源,所以性能很高,到了第四階段,才真正申請了鎖。

對於JDK1.6其他的優化,請看下面兩個圖
在這裏插入圖片描述

在這裏插入圖片描述

volatile

volatile的底層實現設計到的知識更多,也更復雜,這裏只是附上個人的淺見。
volatile關鍵字最大的兩個作用是線程可見性禁止指令重排序,下面分別介紹這兩種作用的思路:

  1. 線程可見性
    JMM中分爲主內存和線程特有的工作內存,每個線程對主內存中的變量做操作時,首先要將主內存中的變量拿到本地的工作內存中(實際上是因爲線程運行在CPU不同的核中,這個變量拿過來放在了CPU的寄存器(做完運算在放到高速緩存Cache,然後再讀,這樣會提高cpu讀取速度)中),然後在進行操作,當操作完後,在把已經更改的變量值寫回到主內存。
    但是如果是多線程的情況下,線程A拿到變量值i到自己的工作內存做循環+1操作,在A沒有做寫回操作之前,B線程又來取主內存中i的值,它取的還是i原來沒有改變的值,A、B完成對變量的操作後,相繼做寫回主內存的操作,就會出現髒數據的情況。
    加了volatile後,線程在每次對變量做操作後(例如+1),都會將變量值寫回到主內存中,當該變量值被改變後,其他線程也會重新來主內存取變量值,這樣的操作就稱爲線程可見性,即每個操作對其他線程都是可見的。但是還是改變不了可能出現髒數據的情況。
    這是因爲,A先拿到了主內存中變量i 的值後,假如被阻塞了,在沒有寫回主內存之前,B線程讀了主內存變量的值(沒有加鎖,不會互斥,可以讀),儘管這時B將變量值刷新到主內存,會通知A在他下次對i的操作時要重新讀A,但對此時的A來說已經拿到了該值,也就是說讀取的這個原子操作已經結束了,它會做完操作後再繼續寫回i,這時i還是出現了髒數據的情況。因此volatile保證了可見性,不能保證原子性。
  2. 禁止指令重排序
    cpu會出現亂序執行指令的情況,重排序相當是一個優化的操作,增強了cpu處理的速度,在單線程模式下,不會有什麼問題,但在多線程環境下,就有可能出現程序執行邏輯的錯誤。
    volatile會禁止指令重排序,具體是通過內存屏障。volatile修飾的變量首先要連接內存屏障,內存屏障的作用是內存屏障兩側的指令不可以重排序,保證了指令的有序性。
    說到內存屏障,其實它是java的一種規範或規則,其實就是在指定指令之前加了一層屏障,屏障兩側的指令不能重排序,這樣就保證了目標指令無法重排序,也就是volatile達到禁止指令重排序的作用。
    在這裏插入圖片描述

說完了volatile具體的作用後,我們再來看看volatile的實現原理:

  1. 代碼層面
    代碼層面加了volatile
  2. 字節碼層面
    加了標識符ACC_COLATILE
  3. JVM層面
    jvm的內存屏障,JSR(關於內存屏障的規範)
  4. CPU層面
    CPU層面的底層實現,還是lock指令,在底層加鎖了
    在這裏插入圖片描述

volatile爲什麼不能保證原子性?
下面以volatile變量自增爲例:

public class VolatileDemo {
    public static volatile int race = 0;

    public static void increase(){
        race++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[20];
        for (int i=0 ;i<20;i++){
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i=0;i<1000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() >1){
            Thread.yield();
            System.out.println(race);
        }
    }
}

反編譯字節碼文件可以看到,自增操作會分爲三個步驟:1.讀取volatile變量的值到local;2.增加變量的值;3.把local的值寫回讓其他線程可見。可以看到,volatile不能保證原子性。

Code:
       0: iconst_0
       1: istore_1
       2: iload_1
       3: sipush        1000
       6: if_icmpge     18
       9: invokestatic  #2   

在這裏插入圖片描述

一道面試題:DCL(double check lock)單例模式是否要加volatile?
DCL就是實現單例模式時採用的兩層判斷機制來實現內存中是否只有一個對象實例。
如果開始時我們指定Instance i = null;時不加volatile,那麼就會造成指令重排序,那麼在多線程環境下,一個線程去初始化該對象時,造成了指令的重排序,導致了另外一個線程直接拿到了半初始化狀態的對象,那麼就會造成數據不一致的情況。
所以,DCL(double check lock)單例模式需要加volatile,不加可能會出現使用半初始化的對象的情況,因爲會出現指令亂序,沒有進行初始化指令的時候,就已經建立了引用與對象的連接。

在這裏插入圖片描述
在這裏插入圖片描述

synchronized與volatile的區別

  1. volatile本質是在告訴jvm當前變量在寄存器(工作內存)中的值是不確定的,需要從主存中讀取; synchronized則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。
  2. volatile僅能使用在變量級別;synchronized則可以使用在變量、方法、和類級別的
  3. volatile僅能實現變量的修改可見性,不能保證原子性;而synchronized則可以保證變量的修改可見性和原子性
  4. volatile不會造成線程的阻塞;synchronized可能會造成線程的阻塞。
  5. volatile標記的變量不會被編譯器優化;synchronized標記的變量可以被編譯器優化

volatile引申的更底層的東西

在此之前,我們要先了解一下CPU跑程序的過程。
在這裏插入圖片描述
一臺機器的核心由CPU與內存組成。簡單來講:
當我們寫好一個程序準備運行時,OS首先會將程序寫入內存中。
CPU從內存中讀取程序代碼(這麼說比較草率,應該執行是程序底層的指令):
CPU從指令寄存器PC(記錄下一條指令執行位置)中讀取指令的位置,讀進指令;
將指令中涉及的數據存到寄存器Registers(存數據)
並利用計算邏輯單元ALU計算。

但是這個過程太慢了,CPU計算的速度已經發展的非常快了,這就使得從內存中讀取數據是CPU計算能力的瓶頸之一,因此引入了Cache高速緩存。高速緩存Cache的作用就是緩解CPU與內存之間傳輸數據速度慢的問題,通過Cache,CPU可以快速的讀取緩存數據,大大提升了CPU的處理速度。
在這裏插入圖片描述

在這裏插入圖片描述
上圖中L1、L2、L3都是高速緩存,啓示L1、L2是CPU的每個核獨享的,L3是共享緩存。

超線程:所謂超線程就是一個ALU對應多個PC及Registers,也就是所謂的四核八線程

看完以上內容,我們瞭解一下Cache Line的概念。
在這裏插入圖片描述
當CPU想要讀取某個數據時,先要到最近的Cache L1中去找,找不到去L2去找,進而L3,如果都沒找到,那麼纔會去主存去找。那麼什麼是緩存行呢?
我們知道內存從硬盤中讀取數據時因爲局部性原理讀取的數據是按塊讀進來的,Cache從主存中也是這樣。
Cache從主存中讀取的塊成爲緩存行,這個緩存行大小爲64個字節。
所以當讀取數據時,如果兩個數據在內存中的存儲位置是相鄰的,那麼大概率會將兩個數據都按照一個緩存行讀到內存中來。

Cache提高了CPU讀取數據的速度,但在開發過程中,還可以利用這個緩存行這個特性進行性能的提升,這就是緩存行對齊
緩存行對齊:用多餘的字節,將兩個變量分別位於不同緩存行,如果兩個線程分別讀兩個變量時(一個線程讀一個),是不會通知其他變量的,就會提升性能。
個人理解:在使用volatile關鍵字時,如果使用了緩存行對齊,就會精準的將volatile修飾的變量加入緩存行中,從而使得程序更加有效率,不必反覆去主存中讀其他的值
在這裏插入圖片描述
在這裏插入圖片描述
MESI緩存一致性協議

MESI是CPU核之間的數據一致性協議。
針對的是Cache Line中的數據,有如下四種狀態:
在這裏插入圖片描述
緩存一致性協議
處理器上有一套完整的協議,來保證 Cache 的一致性。比較經典的是MESI 協議,它的方法是在 CPU 緩存中保存一個標記位,這個標記爲有四種狀態:
Ø M(Modified) 修改緩存
Ø I(Invalid) 失效緩存
Ø E(Exclusive) 獨佔緩存
Ø S(Shared) 共享緩存
每個 Core 的 Cache 控制器不僅知道自己的讀寫操作,也監聽其它 Cache 的讀寫操作,嗅探(snooping)協議。CPU 的讀取會遵循幾個原則:
1、如果緩存的狀態是 I,那麼就從內存中讀取,否則直接從緩存讀取
2、如果緩存處於 M 或者 E 的 CPU 嗅探到其他 CPU 有讀的操作,就把自己的緩存寫入到內存,並把自己的狀態設置爲

緩存一致性問題
基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是也爲計算機系統帶來更高的複雜度,因爲它引入了一個新的問題:緩存一致性(Cache Coherence)。
CPU-0 讀取主存的數據,緩存到 CPU-0 的高速緩存中,CPU-1 也做了同樣的事情,而 CPU-1 把 count 的值修改成了 2,並且同步到 CPU-1 的高速緩存,但是這個修改以後的值並沒有寫入到主存中,CPU-0 訪問該字節,由於緩存沒有更新,所以仍然是之前的值,就會導致數據不一致的問題。爲了解決這個問題,CPU 生產廠商提供了相應的解決方案:

總線鎖
當一個 CPU 對其緩存中的數據進行操作的時候,往總線中發送一個 Lock 信號。其他處理器的請求將會被阻塞,那麼該處理器可以獨佔共享內存。總線鎖相當於把 CPU 和內存之間的通信鎖住了,所以這種方式會導致 CPU 的性能下降,所以 P6 系列以後的處理器,出現了另外一種方式,就是緩存鎖。

緩存鎖
如果緩存在處理器緩存行中的內存區域在 LOCK 操作期間被鎖定,當它執行鎖操作回寫內存時,處理不在總線上聲明 LOCK 信號,而是修改內部的緩存地址,然後通過緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域的數據,當其他處理器回寫已經被鎖定的緩存行的數據時會導致該緩存行無效。所以緩存鎖會產生兩個作用:
1、將當前處理器緩存行的數據寫回到系統內存。
2、這個寫回內存的操作會使在其他CPU裏緩存了該內存地址的數據無效。

系統底層如何實現數據一致性(CPU)
1.MESI如果能解決,就使用MESI
2.如果不能,就鎖總線
3.但是這不是JVM採用的方法

系統底層如何保證有序性
1.內存屏障sfence mfence lfence等系統源語
2.鎖總線

參考:
https://www.jianshu.com/p/1ae887521cf3
https://www.jianshu.com/p/7d3425a78d72

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