併發編程學習(3)線程安全性分析

初步認識 Volatile

    public /*volatile*/ static boolean stop=false;
    public static void main( String[] args ) throws InterruptedException {
        Thread t1=new Thread(()->{
            int i=0;
            while(!stop){ 
                i++;
            }
        });
        t1.start();
        Thread.sleep(1000);
        stop=true; //true
    }
  • 定義一個共享變量 stop
  • 在main線程中創建一個子線程 thread,子線程讀取到 stop的值做循環結束的條件
  • main線程中修改stop的值爲 true
  • 當 stop沒有增加volatile修飾時,子線程對於主線程的 stop=true的修改是不可見的,這樣將導致子線程出現死循環
  • 當 stop增加了volatile修飾時,子線程可以獲取到主線程對於 stop=true的值,子線程while循環條件不滿足退出循環

增加volatile關鍵字以後,main線程對於共享變量 stop值的更新,對於子線程 thread可見,這就是volatile的作用

volatile 關鍵字是如何保證可見性的?

我們可以使用【hsdis】這個工具,來查看前面演示的這段代碼的彙編指令,然後在輸出的結果中,查找下 lock 指令,會發現,在修改
帶有 volatile 修飾的成員變量時,會多一個lock指令。lock是一種控制指令,在多處理器環境下,lock 彙編指令可以基於總線鎖或者緩存鎖的機制來達到可見性的一個效果。

從硬件層面瞭解

在併發編程中,線程安全問題的本質其實就是 原子性、有序性、可見性;接下來主要圍繞這三個問題進行展開分析其本質,徹底瞭解可見性的特性

  • 原子性 和數據庫事務中的原子性一樣,滿足原子性特性的操作是不可中斷的,要麼全部執行成功要麼全部執行失敗
  • 有序性 編譯器和處理器爲了優化程序性能而對指令序列進行重排序,也就是你編寫的代碼順序和最終執行的指令順序是不一致的,重排序可能會導致多線程程序出現內存可見性問題
  • 可見性 多個線程訪問同一個共享變量時,其中一個線程對這個共享變量值的修改,其他線程能夠立刻獲得修改以後的值

一臺計算機中最核心的組件是 CPU、內存、以及 I/O 設備。在整個計算機的發展歷程中,除了 CPU、內存以及 I/O 設備不斷迭代升級來提升計算機處理性能之外,還有一個非常核心的矛盾點,就是這三者在處理速度的差異。CPU 的計算速度是非常快的,內存次之、最後是 IO 設備比如磁盤。而在絕大部分的程序中,一定會存在內存訪問,有些可能還會存在 I/O 設備的訪問爲了提升計算性能,CPU 從單核升級到了多核甚至用到了超線程技術最大化提高 CPU 的處理性能,但是僅僅提升CPU 性能還不夠,如果後面兩者的處理性能沒有跟上,意味着整體的計算效率取決於最慢的設備。爲了平衡三者的速度差異,最大化的利用 CPU 提升性能,從硬件、操作系統、編譯器等方面都做出了很多的優化

  • CPU 增加了高速緩存
  • 操作系統增加了進程、線程。通過 CPU 的時間片切換最大化的提升 CPU 的使用率 (在IntelPentium4開始,引入了超線程技術,也就是一個CPU核心模擬出2個線程的CPU,實現多線程並行)
  • 編譯器的指令優化,更合理的去利用好 CPU 的高速緩存然後每一種優化,都會帶來相應的問題,而這些問題也是導致線程安全性問題的根源。爲了瞭解前面提到的可見性問題的本質,我們有必要去了解這些優化的過程

原子性

多線程並行訪問同一個共享資源的時候的原子性問題,如果把問題放大到分佈式架構裏面,這個問題的解決方法就是鎖。所以在CPU層面,提供了兩種鎖的機制來保證原子性

總線鎖

處理器會提供一個LOCK#信號,當一個處理器在總線上輸出這個信號時,其他處理器的請求會被阻塞,那麼該處理器就可以獨佔共享內存

總線鎖有一個弊端,總線鎖相當於使得多個CPU由並行執行變成了串行,使得CPU的性能嚴重下降,所以在P6系列以後的處理器中,引入了緩存鎖。

緩存鎖

我們只需要保證 多個線程操作同一個被緩存的共享數據的原子性就行,所以只需要鎖定被緩存的共享對象即可。所謂緩存鎖是指被緩存在處理器中的共享數據,在Lock操作期間被鎖定,那麼當被修改的共享內存的數據回寫到內存時,處理器不在總線上聲明LOCK#信號,而是修改內部的內存地址,並通過 緩存一致性機制來保證操作的原子性。

所謂緩存一致性,就是多個CPU核心中緩存的同一共享數據的數據一致性,而(MESI)使用比較廣泛的緩存一致性協議。MESI協議實際上是表示緩存的四種狀態
M(Modify) 表示共享數據只緩存在當前CPU緩存中,並且是被修改狀態,也就是緩存的數據和主內存中的數據不一致
E(Exclusive) 表示緩存的獨佔狀態,數據只緩存在當前CPU緩存中,並且沒有被修改
S(Shared) 表示數據可能被多個CPU緩存,並且各個緩存中的數據和主內存數據一致
I(Invalid) 表示緩存已經失效
每個CPU核心不僅僅知道自己的讀寫操作,也會監聽其他Cache的讀寫操作 CPU的讀取會遵循幾個原則

  • 如果緩存的狀態是I,那麼就從內存中讀取,否則直接從緩存讀取
  • 如果緩存處於M或者E的CPU 嗅探到其他CPU有讀的操作,就把自己的緩存寫入到內存,並把自己的狀態設置爲S
  • 只有緩存狀態是M或E的時候,CPU纔可以修改緩存中的數據,修改後,緩存狀態變爲M

可見性

首先cpu執行代碼時,執行順序會根據他自己的優化重排序代碼執行順序,這也就導致了亂序訪問問題。這也就是加入內存屏障的原因。

內存屏障就是將 store bufferes中的指令寫入到內存,從而使得其他訪問同一共享內存的線程的可見性。 X86的memory barrier指令包括lfence(讀屏障) sfence(寫屏障) mfence(全屏障)

  • Store Memory Barrier(寫屏障) 告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(store bufferes)中的數據同步到主內存,簡單來說就是使得寫屏障之前的指令的結果對屏障之後的讀或者寫是可見的
  • Load Memory Barrier(讀屏障) 處理器在讀屏障之後的讀操作,都在讀屏障之後執行。配合寫屏障,使得寫屏障之前的內存更新對於讀屏障之後的讀操作是可見的
  • Full Memory Barrier(全屏障) 確保屏障前的內存讀寫操作的結果提交到內存之後,再執行屏障後的讀寫操作
    總的來說,內存屏障的作用可以通過防止CPU對內存的亂序訪問來保證共享數據在多線程並行執行下的可見性

有序性

  1. 編譯器優化重排序,在不改變單線程程序語義的前提下,改變代碼的執行順序
  • 指令集並行的重排序,對於不存在數據依賴的指令,處理器可以改變語句對應指令的執行順序來充分利用CPU資源
  • 內存系統的重排序,也就是前面說的CPU的內存亂序訪問問題

也就是說,我們編寫的源代碼到最終執行的指令,會經過三種重排序

有序性會帶來可見性問題,所以可以通過內存屏障指令來進制特定類型的處理器重排序

所以 volatile這個關鍵字會有一個lock指令這也就是相當於內存屏障的作用從而實現可見性 因爲lock會實現cpu底層的緩存鎖。

JMM層面

硬件層面的原子性、有序性、可見性在不同的CPU架構和操作系統中的實現可能都不一樣,而Java語言的特性是 write once,run anywhere,意味着JVM層面需要屏蔽底層的差異,因此在JVM規範中定義了JMM(內存模型)
可見性根本原因是就是高速緩存與重排序

JMM屬於語言級別的抽象內存模型,可以簡單理解爲對硬件模型的抽象,它定義了共享內存中多線程程序讀寫操作的行爲規範,也就是在虛擬機中將共享變量存儲到內存以及從內存中取出共享變量的底層細節。 通過這些規則來規範對內存的讀寫操作從而保證指令的正確性,它解決了CPU多級緩存、處理器優化、指令重排序導致的內存訪問問題,保證了併發場景下的可見性。 需要注意的是,JMM並沒有限制執行引擎使用處理器的寄存器或者高速緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序,也就是說在JMM中,也會存在緩存一致性問題和指令重排序問題。只是JMM把底層的問題抽象到JVM層面,再基於CPU層面提供的內存屏障指令,以及限制編譯器的重排序來解決併發問題

JMM 層面的內存屏障

爲了保證內存可見性,Java 編譯器在生成指令序列的適當位置會插入內存屏障來禁止特定類型的處理器的重排序,

HappenBefore

它的意思表示的是前一個操作的結果對於後續操作是可見的,所以它是一種表達多個線程之間對於內存的可見性。所以我們可以認爲在 JMM 中,如果一個操作執行的結果需要對另一個操作課件,那麼這兩個操作必須要存在happens-before 關係。這兩個操作可以是同一個線程,也可以是不同的線程

JMM 中有建立 happen-before 的規則
public class Demo {
    int a=0;
    volatile  boolean flag=false;

    public void writer(){ //線程A
        a=1;             //1
        flag=true;       //2
    }
    public void reader(){
        if(flag){  //3
            int x=a; //4
        }
    }
}
  • 一個線程中的每個操作,happens-before 於該線程中的任意後續操作; 可以簡單認爲是 as-if-serial(as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不會改變)。單個線程中的代碼順序不管怎麼變,對於結果來說是不變的順序規則表示 1 happenns-before 2; 3 happensbefore 4
  • volatile 變量規則,對於 volatile 修飾的變量的寫的操作,一定 happen-before 後續對於 volatile 變量的讀操作;根據 volatile 規則,2 happens before 3
  • 傳遞性規則,如果 1 happens-before 2; 3happensbefore 4; 那麼傳遞性規則表示: 1 happens-before 4;
  • start 規則 線程執行之前主線程改變的值一定對線程可見
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
 // 主線程調用 t1.start() 之前
 // 所有對共享變量的修改,此處皆可見
 // 此例中,x==10
});
// 此處對共享變量 x 修改
x = 10;
// 主線程啓動子線程
t1.start();
}
  • join規則 線程t1改變的值一定對主線程可見
int x=0;
Thread t1=new Thread(()->{
 x=100;
 });
 t1.start();
 t1.join();
 System.out.println(x);
  • 監視器鎖的規則,對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖(其實就是加鎖後改變的值後續進來的線程是可以拿到的)
synchronized (this) { // 此處自動加鎖
// x 是共享變量, 初始值 =10
if (this.x < 12) {
 this.x = 12;
 }
} // 此處自動解鎖

JMM層面解決原子性、有序性、可見性

原子性:Java中提供了兩個高級指令 monitorenter和 monitorexit,也就是對應的synchronized同步鎖來保證原子性
可見性:volatile、synchronized、final(修飾的東西初始化時就已存在並且不能更改happens-before於隨後的操作)都可以解決可見性問題
有序性:synchronized和volatile可以保證多線程之間操作的有序性,volatile會禁止指令重排序

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