併發編程的目標與挑戰

If I had only one hour to save the worlds,I would spend fifty-five minutes defining the problem,and only five minutes finding the solution.html

若是我只有1小時拯救世界,我將花55分鐘定義這個問題而只花分鐘去尋找解決方案 ——Albert Einsteinjava

本文講解的將是多線程的一些重要概念,爲接下來本身以及讀者更好的理解併發編程作個鋪墊。編程

以後會講解volatile關鍵字,CAS , AQS 等等,總之概念是實踐的基石
 緩存

1.1 競態

多線程編程中常常遇到一個問題就是對於一樣的輸入,程序的輸出有時候是正確的,而有時候倒是錯誤的。這種一個計算結果的正確性與時間有關的現象就被稱爲競態(Race Condition)。安全

java核心技術-多線程基礎 中 1.1 (2)多線程

public class Ticket implements Runnable{
    
    private int ticket = 100;

    @Override
    public void run() {
        while(ticket > 0){
            System.out.println(Thread.currentThread().getName() + "=" + --ticket);
        }
        
    }
}
public class TestThread2 {
    public static void main(String[] args) {
        
        Ticket ticket = new Ticket();
        
        //雖然是實現了Runnable接口 本質上只是實現了線程執行體 啓動工做仍是須要Thread類來進行
        Thread t1 = new Thread(ticket,"售票窗口一");
        t1.start();
        
        Thread t2 = new Thread(ticket,"售票窗口二");
        t2.start();
        
        Thread t3 = new Thread(ticket,"售票窗口三");
        t3.start();
    }
}

賣票的CASE,此案例中競態致使的結果是不一樣業務的線程可能拿到了重複的ticket(票),且可能出現ticket爲負數的狀況。併發

可見 while(ticket > 0) 以及 --ticket 這兩個操做 是禍端之源。app

進一步來講,致使競態的常見因素是多個線程 在沒有采起任何控制措施的狀況下,併發地更新、讀取同一個共享變量ide

有朋友可能會說:--ticket 操做 是一個操做啊 你怎麼能說是禍端之源性能

其實不是的,只是看起來像是一個操做而已,它實際上 至關於以下僞代碼所表示的三個指令

load(ticket,r1); //指令①:將變量ticket 的值從內存讀到寄存器r1
decrement(r1); //指令②:將寄存器r1的值減小1
store(ticket,r1);//指令③:將寄存器r1的內容寫入變量ticket所對應的內存空間

而 ①②③並不能保證是一個原子操做,兩個業務線程可能在同一時刻讀取到ticket的同一個值,一個業務線程對ticket所作的更新也可能"覆蓋"其餘線程對該變量作的更新,因此,問題不言而喻.....
 

1.2 競態的模式與競態產生的條件

從上述競態的典型實例中,咱們能夠提煉出競態的兩種模式:

① read-modify-write(讀改寫)

② check-then-act (檢測然後行動)

read-modify-write(讀改寫)操做能夠被細分爲這樣幾個步驟:讀取一個共享變量的值(read),而後根據該值作一些計算(modify),接着更新該共享變量的值。例如 --ticket

check-then-act (檢測然後行動) ,該操做能夠被細分爲這樣幾個步驟:讀取某個共享變量的值,根據該共享變量的值決定下一步的動做是什麼。while(ticket > 0) --ticket

可是對於局部變量(包括形式參數和方法體內定義的變量),因爲不一樣的線程各自訪問的各自訪問的是各自的那一份局部變量,所以局部變量的使用不會致使競態,以下例

public class NoRaceCondition {
    
    
    public int nextSequence(int sequence){
        if(sequence >= 999){
            sequence = 0;
        }else{
            sequence++;
        }
        return sequence;
    }
    
}

 

1.3 線程安全性

通常而言,若是一個類在單線程環境下可以正常運行,而且在多線程環境下,在其使用方沒必要爲其作任何改變的狀況下也能正常運行,那麼咱們就稱其是線程安全的,相應的咱們稱這個類具備線程安全性,反之亦然。而一個類若是是線程安全的,那麼它就不會致使競態。

線程安全問題歸納來講表現爲3個方面: 原子性、可見性、有序性

 

1.3.1 原子性

原子(Atomic) 的字面意思是不可分割的。其含義簡單的來講就是,訪問(讀、寫)某個共享變量的操做從執行線程之外的任何線程來看,該操做要麼已經執行結束,要麼還沒有發生,即其餘線程不會"看到"該操做線程執行了部分的中間效果

在生活中咱們能夠找到的一個原子操做的例子就是人們從 ATM 機提取現金; 儘管從ATM軟件的角度來講,一筆交易涉及扣減主帳戶餘額、吐鈔器吐出鈔票、新增交易記錄等一系列操做,可是從用戶的角度來看 ATM取款就是一個操做。 該操做要麼成功了,咱們拿到了現金。要麼失敗了,咱們沒有拿到現金。

理解原子操做要注意如下兩點:

  • 原子操做是針對訪問共享變量的操做而言的
  • 原子操做是從該操做的執行線程之外的線程來描述的

總的來講,Java 中有兩種方式來實現原子性。

一種是使用鎖(Lock)。鎖具備排他性,即它能保證一個共享變量在任意時刻只可以被一個線程訪問。這就排除了多個線程在同一時刻訪問通一個共享變量而致使干擾與衝突的可能,即消除了競態。

另外一種是利用處理器處理器專門提供的 CAS(Compare-and-Swap)指令 ,CAS 指令實現原子性的方式與鎖實現原子性的方式實質上相同的,差異在於鎖一般是在軟件這一層次實現的,而CAS 是直接在硬件(處理器和內存) 這一層次實現的,它能夠被看做"硬件鎖"

在Java 語言中,long型 和 double型 之外的任何基礎類型的變量的寫操做 都是原子操做。

對 long/double 型變量的寫操做 因爲 Java語言規範並不保障其具備原子性,所以多個線程併發訪問同 一 long/double型變量的狀況下,一個線程可能會讀取到其餘線程更新該變量的"中間結果"(64位的虛擬機應該不會出現這個問題);

注:使用32位虛擬機 用對個線程對long,double型數據進行操做 會有低32位 高32位的問題,儘管如此可使用volatile關鍵字進行解決,它能夠保證變量寫操做的原子性,即線程共享變量 刷新到主存這個動做是原子的

 

1.3.2 可見性

在多線程環境下,一個線程對某個共享變量進行更新後,後續訪問該變量的線程可能沒法馬上讀取到這個更新的結果,甚至永遠沒法讀取到這個更新的結果。這就是線程安全問題的另一個表現形式:可見性

下面咱們來一個Demo吧

public class ThreadVolatile{
    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo(); //01
        new Thread(td).start();//02
        
        while(true){
            if(td.isFlag()){//03
                System.out.println("-----------------");
                break;
            }
        }
    }
}

class ThreadDemo implements Runnable{
    
    private boolean flag = false;
    
    @Override
    public void run() {
        //此處的目的 是讓main線程 從主存那 先獲取flag等於false的值 
        try {
            Thread.sleep(200);
        } catch (Exception e) {
        }
        flag = true;//04
        System.out.println("flag=" + flag);
    }
    
    public boolean isFlag(){
        return flag;
    }
    
    public void setFlag(boolean flag){
        this.flag = flag;
    }
    
}

運行結果:

打印flag=true, 但循環沒法終止

在解釋緣由以前先說幾個概念:(很重要)

  • 棧:線程獨有,保存其運行狀態以及局部自動變量,操做系統在切換線程的時候會自動切換棧,也就是切換寄存器
  • 堆:保存對象的實體以及全局變量,能夠把堆內存 約當作 主內存

01-初始化完ThreadDemo 內存空間:

02.子線程ThreadDemo啓動 獲取到flag=false的值 開始睡覺

03.main線程得到了flag=false的值 在循環體中跑了若干次

04.因爲03步驟main線程得到了flag=flase,雖然主存變了,可是因爲while(true)執行效率過高,根本沒有時間讓主存中的數據同步到main線程中去,因此main線程一直在死循環

那麼,在Java平臺中 如何保證可見性呢?

對於上例Demo,咱們只需將其flag的聲明添加一個volatile關鍵字便可,即

private volatile boolean flag = false;

這裏,volatile關鍵字所起到的一個做用就是,提示JIT編譯器被修飾的變量可能被多個線程共享,以組織JIT編譯器作出可能致使運行不正常的優化 (重排序)。另一個做用就是 讀取一個volatile關鍵字所修飾的變量會使相應的處理器執行刷新處理器緩存的動做

 

1.3.3 有序性

有序性 指在什麼狀況下一個處理器上的運行的一個線程所執行的內存訪問操做在另一個處理器上運行的其餘線程看來是亂序的。(某書定義)

個人理解:程序運行順序要與代碼邏輯順序保持基本一致,避免多線程狀況因爲重排致使的錯誤

所謂亂序,是指內存訪問操做的順序看起來像是發生了變化。在進一步介紹有序性概念以前,咱們須要介紹重排序的概念

重排序:是指編譯器和處理器爲了優化程序性能而對指令序列進行從新排序的一種手段

  • 指令重排序:源代碼順序與程序順序不一致,或者程序順序與執行順序不一致的狀況下 (編譯器,處理器)
  • 存儲子系統重排:源代碼順序、程序順序和執行順序這三者保持一致,可是感知順序與執行順序不一致 (高速緩存,寫緩衝器)

注:這一塊建議瞭解編譯原理 以及彙編

as-if-serial語義:編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變程序執行結果。可是,若是操做之間不存在數據依賴關係,這些操做就能夠被編譯器和處理器重排序。

示例:

double pi = 3.14;  // A 
double r = 1.0;     //B
double area = pi * r * r; //C

分析:A與C之間存在數據依賴關係,因此C不能排到A的前面,同時B與C之間也存在數據依賴關係,因此,C也不能排到B的前面,可是A與B之間是不存在數據依賴關係的,因此A與B之間是能夠進行重排序的。

程序順序規則:

根據happens-before的程序規則,上面的計算圓的示例代碼存在3個happens-before關係:

A happens-before B ; B happens-before C; A happens-before C;

重排序對多線程的影響:

class RecorderExample{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1; // 1
        flag = true; // 2
}
    public void reader(){
        if(flag){          // 3
            int i = a * a;  // 4
             ......
    }
} 
}

flag是一個變量,用來表示變量a是否已被寫入。這裏假設有兩個線程A和B ,A線程首先執行writer方法,隨後線程B執行reader方法。線程B在執行操做4的時候,可否看到線程A在操做共享變量a的寫入呢?

答案是:在多線程的狀況下,不必定能看到;

因爲操做1和操做2沒有數據依賴的關係,編譯器和處理器能夠對這兩個操做進行重排序,操做3和操做4沒有數據依賴關係,編譯器和處理器也能夠對其進行重排序,下面咱們看一下可能的執行狀況的示意圖:

如上所示,操做1 和操做2 進行了重排序。程序執行時,線程A首先寫標記變量flag,隨後線程B讀這個變量。因爲判斷條件爲真,線程B將讀取變量a。此時,變量a尚未被線程A寫入,因此在這裏,多項層程序的語義就被重排序破壞了。

下面在看一下操做3和操做4重排序會發生什麼效果:

在程序中,操做3和操做4存在控制依賴關係。當代碼中存在控制依賴行時,會影響指令序列執行的並行度。爲此,編譯器和處理器會採用猜想執行來克服控制相關性對並行度的影響。以處理器的猜想執行爲例,執行線程B的處理器能夠提早讀取並計算a*a,而後把計算結果臨時保存到一個名爲重排序緩衝的硬件緩存中。當操做3的條件判斷爲真的時候,就把該結算結果寫入到變量i中。

從上圖咱們能夠看出,猜想執行實質上是對操做3和操做4進行了重排序,重排序在這裏破壞了多線程程序的語義。

在單線程程序中,對存在控制依賴的操做進行重排序,不會改變執行結果(這也是as-if-serial 語義容許對存在控制依賴的操做作重排序的緣由),可是在多線程的程序中,對存在控制依賴的操做重排序,可能會改變程序的執行結果。

相關文章
相關標籤/搜索