溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

Java并發(fā)問題有哪些

發(fā)布時間:2021-12-21 11:50:31 來源:億速云 閱讀:129 作者:iii 欄目:編程語言

這篇文章主要介紹“Java并發(fā)問題有哪些”,在日常操作中,相信很多人在Java并發(fā)問題有哪些問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”Java并發(fā)問題有哪些”的疑惑有所幫助!接下來,請跟著小編一起來學(xué)習(xí)吧!

序言

先來看如下這個簡單的Java類,該類中并沒有使用任何的同步。

final class SetCheck {
    private int a = 0;
    private long b = 0;
    void set() {
        a = 1;
        b = -1;
    }
    boolean check() {
        return ((b == 0) || (b == -1 && a == 1));
    }
}

如果是在一個串行執(zhí)行的語言中,執(zhí)行SetCheck類中的check方法永遠不會返回false,即使編譯器,運行時和計算機硬件并沒有按照你所期望的邏輯來處理這段程序,該方法依然不會返回false。在程序執(zhí)行過程中,下面這些你所不能預(yù)料的行為都是可能發(fā)生的:

  • 編譯器可能會進行指令重排序,所以b變量的賦值操作可能先于a變量。如果是一個內(nèi)聯(lián)方法,編譯器可能更甚一步將該方法的指令與其他語句進行重排序。

  • 處理器可能會對語句所對應(yīng)的機器指令進行重排序之后再執(zhí)行,甚至并發(fā)地去執(zhí)行。

  • 內(nèi)存系統(tǒng)(由高速緩存控制單元組成)可能會對變量所對應(yīng)的內(nèi)存單元的寫操作指令進行重排序。重排之后的寫操作可能會對其他的計算/內(nèi)存操作造成覆蓋。

  • 編譯器,處理器以及內(nèi)存系統(tǒng)可能會讓兩條語句的機器指令交錯。比如在32位機器上,b變量的高位字節(jié)先被寫入,然后是a變量,緊接著才會是b變量的低位字節(jié)。

  • 編譯器,處理器以及內(nèi)存系統(tǒng)可能會導(dǎo)致代表兩個變量的內(nèi)存單元在(如果有的話)連續(xù)的check調(diào)用(如果有的話)之后的某個時刻才更新,而以這種方式保存相應(yīng)的值(如在CPU寄存器中)仍會得到預(yù)期的結(jié)果(check永遠不會返回false)。

在串行執(zhí)行的語言中,只要程序執(zhí)行遵循類似串行的語義,如上幾種行為就不會有任何的影響。在一段簡單的代碼塊中,串行執(zhí)行程序不會依賴于代碼的內(nèi)部執(zhí)行細節(jié),因此如上的幾種行為可以隨意控制代碼。

這樣就為編譯器和計算機硬件提供了基本的靈活性。基于此,在過去的數(shù)十年內(nèi)很多技術(shù)(CPU的流水線操作,多級緩存,讀寫平衡,寄存器分配等等)應(yīng)運而生,為計算機處理速度的大幅提升奠定了基礎(chǔ)。這些操作的類似串行執(zhí)行的特性可以讓開發(fā)人員無須知道其內(nèi)部發(fā)生了什么。對于開發(fā)人員來說,如果不創(chuàng)建自己的線程,那么這些行為也不會對其產(chǎn)生任何的影響。

然而這些情況在并發(fā)編程中就完全不一樣了,上面的代碼在并發(fā)過程中,當(dāng)一個線程調(diào)用check方法的時候完全有可能另一個線程正在執(zhí)行set方法,這種情況下check方法就會將上面提到的優(yōu)化操作過程暴露出來。

如果上述任意一個操作發(fā)生,那么check方法就有可能返回false。例如,check方法讀取long類型的變量b的時候可能得到的既不是0也不是-1.而是一個被寫入一半的值。另一種情況,set方法中的語句的亂序執(zhí)行有可能導(dǎo)致check方法讀取變量b的值的時候是-1,然而讀取變量a時卻依然是0。

換句話說,不僅是并發(fā)執(zhí)行會導(dǎo)致問題,而且在一些優(yōu)化操作(比如指令重排序)進行之后也會導(dǎo)致代碼執(zhí)行結(jié)果和源代碼中的邏輯有所出入。由于編譯器和運行時技術(shù)的日趨成熟以及多處理器的逐漸普及,這種現(xiàn)象就變得越來越普遍。

對于那些一直從事串行編程背景的開發(fā)人員(其實,基本上所有的程序員)來說,這可能會導(dǎo)致令人詫異的結(jié)果,而這些結(jié)果可能從沒在串行編程中出現(xiàn)過。這可能就是那些微妙難解的并發(fā)編程錯誤的根本源頭吧。

在絕大部分的情況下,有一個很簡單易行的方法來避免那些在復(fù)雜的并發(fā)程序中因代碼執(zhí)行優(yōu)化導(dǎo)致的問題:使用同步。例如,如果SetCheck類中所有的方法都被聲明為synchronized,那么你就可以確保那么內(nèi)部處理細節(jié)都不會影響代碼預(yù)期的結(jié)果了。

但是在有些情況下你卻不能或者不想去使用同步,抑或著你需要推斷別人未使用同步的代碼。在這些情況下你只能依賴Java內(nèi)存模型所闡述的結(jié)果語義所提供的最小保證。Java內(nèi)存模型允許上面提到的所有操作,但是限制了它們在執(zhí)行語義上潛在的結(jié)果,此外還提出了一些技術(shù)讓程序員可以用來控制這些語義的某些方面。

Java內(nèi)存模型是Java語言規(guī)范的一部分,主要在JLS的第17章節(jié)介紹。這里,我們只是討論一些基本的動機,屬性以及模型的程序一致性。這里對JLS第一版中所缺少的部分進行了澄清。

我們假設(shè)Java內(nèi)存模型可以被看作在1.2.4中描述的那種標準的SMP機器的理想化模型。

Java并發(fā)問題有哪些

(1.2.4)

在這個模型中,每一個線程都可以被看作為運行在不同的CPU上,然而即使是在多處理器上,這種情況也是很罕見的。但是實際上,通過模型所具備的某些特性,這種CPU和線程單一映射能夠通過一些合理的方法去實現(xiàn)。例如,因為CPU的寄存器不能被另一個CPU直接訪問,這種模型必須考慮到某個線程無法得知被另一個線程操作變量的值的情況。這種情況不僅僅存在于多處理器環(huán)境上,在單核CPU環(huán)境里,因為編譯器和處理器的不可預(yù)測的行為也可能導(dǎo)致同樣的情況。

Java內(nèi)存模型沒有具體講述前面討論的執(zhí)行策略是由編譯器,CPU,緩存控制器還是其它機制促成的。甚至沒有用開發(fā)人員所熟悉的類,對象及方法來討論。取而代之,Java內(nèi)存模型中僅僅定義了線程和內(nèi)存之間那種抽象的關(guān)系。眾所周知,每個線程都擁有自己的工作存儲單元(緩存和寄存器的抽象)來存儲線程當(dāng)前使用的變量的值。Java內(nèi)存模型僅僅保證了代碼指令與變量操作的有序性,大多數(shù)規(guī)則都只是指出什么時候變量值應(yīng)該在內(nèi)存和線程工作內(nèi)存之間傳輸。這些規(guī)則主要是為了解決如下三個相互牽連的問題:

  1. 原子性:哪些指令必須是不可分割的。在Java內(nèi)存模型中,這些規(guī)則需聲明僅適用于-—實例變量和靜態(tài)變量,也包括數(shù)組元素,但不包括方法中的局部變量-—的內(nèi)存單元的簡單讀寫操作。

  2. 可見性:在哪些情況下,一個線程執(zhí)行的結(jié)果對另一個線程是可見的。這里需要關(guān)心的結(jié)果有,寫入的字段以及讀取這個字段所看到的值。

  3. 有序性:在什么情況下,某個線程的操作結(jié)果對其它線程來看是無序的。最主要的亂序執(zhí)行問題主要表現(xiàn)在讀寫操作和賦值語句的相互執(zhí)行順序上。

原子性

當(dāng)正確的使用了同步,上面屬性都會具有一個簡單的特性:一個同步方法或者代碼塊中所做的修改對于使用了同一個鎖的同步方法或代碼塊都具有原子性和可見性。同步方法或代碼塊之間的執(zhí)行過程都會和代碼指定的執(zhí)行順序保持一致。即使代碼塊內(nèi)部指令也許是亂序執(zhí)行的,也不會對使用了同步的其它線程造成任何影響。

當(dāng)沒有使用同步或者使用的不一致的時候,情況就會變得復(fù)雜。Java內(nèi)存模型所提供的保障要比大多數(shù)開發(fā)人員所期望的弱,也遠不及目前業(yè)界所實現(xiàn)的任意一款Java虛擬機。這樣,開發(fā)人員就必須負起額外的義務(wù)去保證對象的一致性關(guān)系:對象間若有能被多個線程看到的某種恒定關(guān)系,所有依賴這種關(guān)系的線程就必須一直維持這種關(guān)系,而不僅僅由執(zhí)行狀態(tài)修改的線程來維持。

除了long型字段和double型字段外,java內(nèi)存模型確保訪問任意類型字段所對應(yīng)的內(nèi)存單元都是原子的。這包括引用其它對象的引用類型的字段。此外,volatile long 和volatile double也具有原子性 。(雖然java內(nèi)存模型不保證non-volatile long 和 non-volatile double的原子性,當(dāng)然它們在某些場合也具有原子性。)(譯注:non-volatile long在64位JVM,OS,CPU下具有原子性)

當(dāng)在一個表達式中使用一個non-long或者non-double型字段時,原子性可以確保你將獲得這個字段的初始值或者某個線程對這個字段寫入之后的值;但不會是兩個或更多線程在同一時間對這個字段寫入之后產(chǎn)生混亂的結(jié)果值(即原子性可以確保,獲取到的結(jié)果值所對應(yīng)的所有bit位,全部都是由單個線程寫入的)。但是,如下面(譯注:指可見性章節(jié))將要看到的,原子性不能確保你獲得的是任意線程寫入之后的最新值。 因此,原子性保證通常對并發(fā)程序設(shè)計的影響很小。

可見性

只有在下列情況時,一個線程對字段的修改才能確保對另一個線程可見:

一個寫線程釋放一個鎖之后,另一個讀線程隨后獲取了同一個鎖。本質(zhì)上,線程釋放鎖時會將強制刷新工作內(nèi)存中的臟數(shù)據(jù)到主內(nèi)存中,獲取一個鎖將強制線程裝載(或重新裝載)字段的值。鎖提供對一個同步方法或塊的互斥性執(zhí)行,線程執(zhí)行獲取鎖和釋放鎖時,所有對字段的訪問的內(nèi)存效果都是已定義的。

注意同步的雙重含義:鎖提供高級同步協(xié)議,同時在線程執(zhí)行同步方法或塊時,內(nèi)存系統(tǒng)(有時通過內(nèi)存屏障指令)保證值的一致性。這說明,與順序程序設(shè)計相比較,并發(fā)程序設(shè)計與分布式程序設(shè)計更加類似。同步的第二個特性可以視為一種機制:一個線程在運行已同步方法時,它將發(fā)送和/或接收其他線程在同步方法中對變量所做的修改。從這一點來說,使用鎖和發(fā)送消息僅僅是語法不同而已。

如果把一個字段聲明為volatile型,線程對這個字段寫入后,在執(zhí)行后續(xù)的內(nèi)存訪問之前,線程必須刷新這個字段且讓這個字段對其他線程可見(即該字段立即刷新)。每次對volatile字段的讀訪問,都要重新裝載字段的值。

一個線程首次訪問一個對象的字段,它將讀到這個字段的初始值或被某個線程寫入后的值。
此外,把還未構(gòu)造完成的對象的引用暴露給某個線程,這是一個錯誤的做法,在構(gòu)造函數(shù)內(nèi)部開始一個新線程也是危險的,特別是這個類可能被子類化時。Thread.start有如下的內(nèi)存效果:調(diào)用start方法的線程釋放了鎖,隨后開始執(zhí)行的新線程獲取了這個鎖。

如果在子類構(gòu)造函數(shù)執(zhí)行之前,可運行的超類調(diào)用了new Thread(this).start(),當(dāng)run方法執(zhí)行時,對象很可能還沒有完全初始化。同樣,如果你創(chuàng)建且開始一個新線程T,這個線程使用了在執(zhí)行start之后才創(chuàng)建的一個對象X。你不能確信X的字段值將能對線程T可見。除非你把所有用到X的引用的方法都同步。如果可行的話,你可以在開始T線程之前創(chuàng)建X。

線程終止時,所有寫過的變量值都要刷新到主內(nèi)存中。比如,一個線程使用Thread.join來終止另一個線程,那么第一個線程肯定能看到第二個線程對變量值得修改。

注意,在同一個線程的不同方法之間傳遞對象的引用,永遠也不會出現(xiàn)內(nèi)存可見性問題。

內(nèi)存模型確保上述操作最終會發(fā)生,一個線程對一個特定字段的特定更新,最終將會對其他線程可見,但這個“最終”可能是很長一段時間。線程之間沒有同步時,很難保證對字段的值能在多線程之間保持一致(指寫線程對字段的寫入立即能對讀線程可見)。

特別是,如果字段不是volatile或沒有通過同步來訪問這個字段,在一個循環(huán)中等待其他線程對這個字段的寫入,這種情況總是錯誤的。

在缺乏同步的情況下,模型還允許不一致的可見性。比如,得到一個對象的一個字段的最新值,同時得到這個對象的其他字段的過期的值。同樣,可能讀到一個引用變量的最新值,但讀取到這個引用變量引用的對象的字段的過期值。
不管怎樣,線程之間的可見性并不總是失效(指線程即使沒有使用同步,仍然有可能讀取到字段的最新值),內(nèi)存模型僅僅是允許這種失效發(fā)生而已。因此,即使多個線程之間沒有使用同步,也不保證一定會發(fā)生內(nèi)存可見性問題(指線程讀取到過期的值),java內(nèi)存模型僅僅是允許內(nèi)存可見性問題發(fā)生而已。

在很多當(dāng)前的JVM實現(xiàn)和java執(zhí)行平臺中,甚至是在那些使用多處理器的JVM和平臺中,也很少出現(xiàn)內(nèi)存可見性問題。共享同一個CPU的多個線程使用公共的緩存,缺少強大的編譯器優(yōu)化,以及存在強緩存一致性的硬件,這些都會使線程更新后的值能夠立即在多線程之間傳遞。

這使得測試基于內(nèi)存可見性的錯誤是不切實際的,因為這樣的錯誤極難發(fā)生。或者這種錯誤僅僅在某個你沒有使用過的平臺上發(fā)生,或僅在未來的某個平臺上發(fā)生。這些類似的解釋對于多線程之間的內(nèi)存可見性問題來說非常普遍。沒有同步的并發(fā)程序會出現(xiàn)很多問題,包括內(nèi)存一致性問題。

有序性

有序性規(guī)則表現(xiàn)在以下兩種場景: 線程內(nèi)和線程間

  • 從某個線程的角度看方法的執(zhí)行,指令會按照一種叫“串行”(as-if-serial)的方式執(zhí)行,此種方式已經(jīng)應(yīng)用于順序編程語言。

  • 這個線程“觀察”到其他線程并發(fā)地執(zhí)行非同步的代碼時,任何代碼都有可能交叉執(zhí)行。唯一起作用的約束是:對于同步方法,同步塊以及volatile字段的操作仍維持相對有序。

再次提醒,這些僅是最小特性的規(guī)則。具體到任何一個程序或平臺上,可能存在更嚴格的有序性規(guī)則。所以你不能依賴它們,因為即使你的代碼遵循了這些更嚴格的規(guī)則,仍可能在不同特性的JVM上運行失敗,而且測試非常困難。

需要注意的是,線程內(nèi)部的觀察視角被JLS [1] 中其他的語義的討論所采用。例如,算術(shù)表達式的計算在線程內(nèi)看來是從左到右地執(zhí)行操作(JLS 15.6章節(jié)),而這種執(zhí)行效果是沒有必要被其他線程觀察到的。

僅當(dāng)某一時刻只有一個線程操作變量時,線程內(nèi)的執(zhí)行表現(xiàn)為串行。出現(xiàn)上述情景,可能是因為使用了同步,互斥體[2] 或者純屬巧合。當(dāng)多線程同時運行在非同步的代碼里進行公用字段的讀寫時,會形成一種執(zhí)行模式。在這種模式下,代碼會任意交叉執(zhí)行,原子性和可見性會失效,以及產(chǎn)生競態(tài)條件。這時線程執(zhí)行不再表現(xiàn)為串行。

盡管JLS列出了一些特定的合法和非法的重排序,如果碰到所列范圍之外的問題,會降低以下這條實踐保證 :運行結(jié)果反映了幾乎所有的重排序產(chǎn)生的代碼交叉執(zhí)行的情況。所以,沒必要去探究這些代碼的有序性。

volatile關(guān)鍵字詳解:在JMM中volatile的內(nèi)存語義是鎖

volatile的特性

當(dāng)我們聲明共享變量為volatile后,對這個變量的讀/寫將會很特別。理解volatile特性的一個好方法是:把對volatile變量的單個讀/寫,看成是使用同一個監(jiān)視器鎖對這些單個讀/寫操作做了同步。下面我們通過具體的示例來說明,請看下面的示例代碼:

class VolatileFeaturesExample {
    volatile long vl = 0L; // 使用volatile聲明64位的long型變量
    public void set(long l) {
        vl = l; // 單個volatile變量的寫
    }
    public void getAndIncrement() {
        vl++; // 復(fù)合(多個)volatile變量的讀/寫
    }
    public long get() {
        return vl; // 單個volatile變量的讀
    }
}

假設(shè)有多個線程分別調(diào)用上面程序的三個方法,這個程序在語意上和下面程序等價:
   class VolatileFeaturesExample {
       long vl = 0L;               // 64位的long型普通變量

    public synchronized void set(long l) {     //對單個的普通 變量的寫用同一個監(jiān)視器同步
        vl = l;
    }
    public void getAndIncrement () { //普通方法調(diào)用
        long temp = get();           //調(diào)用已同步的讀方法
        temp += 1L;                  //普通寫操作
        set(temp);                   //調(diào)用已同步的寫方法
    }
    public synchronized long get() { 
    //對單個的普通變量的讀用同一個監(jiān)視器同步
        return vl;
    }
}

如上面示例程序所示,對一個volatile變量的單個讀/寫操作,與對一個普通變量的讀/寫操作使用同一個監(jiān)視器鎖來同步,它們之間的執(zhí)行效果相同。

監(jiān)視器鎖的happens-before規(guī)則保證釋放監(jiān)視器和獲取監(jiān)視器的兩個線程之間的內(nèi)存可見性,這意味著對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入。

簡而言之,volatile變量自身具有下列特性:監(jiān)視器鎖的語義決定了臨界區(qū)代碼的執(zhí)行具有原子性。這意味著即使是64位的long型和double型變量,只要它是volatile變量,對該變量的讀寫就將具有原子性。如果是多個volatile操作或類似于volatile++這種復(fù)合操作,這些操作整體上不具有原子性。

  • 可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入。

  • 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似于volatile++這種復(fù)合操作不具有原子性。

volatile寫-讀建立的happens before關(guān)系

上面講的是volatile變量自身的特性,對程序員來說,volatile對線程的內(nèi)存可見性的影響比volatile自身的特性更為重要,也更需要我們?nèi)リP(guān)注。

從JSR-133開始,volatile變量的寫-讀可以實現(xiàn)線程之間的通信。

從內(nèi)存語義的角度來說,volatile與監(jiān)視器鎖有相同的效果:volatile寫和監(jiān)視器的釋放有相同的內(nèi)存語義;volatile讀與監(jiān)視器的獲取有相同的內(nèi)存語義。

請看下面使用volatile變量的示例代碼:
class VolatileExample {
   int a = 0;
   volatile boolean flag = false;

public void writer() {
    a = 1; // 1
    flag = true; // 2
}
public void reader() {
    if (flag) {                //3
        int i =  a;           //4
        ……
    }
}

}

假設(shè)線程A執(zhí)行writer()方法之后,線程B執(zhí)行reader()方法。根據(jù)happens before規(guī)則,這個過程建立的happens before 關(guān)系可以分為兩類:

  1. 根據(jù)程序次序規(guī)則,1 happens before 2; 3 happens before 4。

  2. 根據(jù)volatile規(guī)則,2 happens before 3。

  3. 根據(jù)happens before 的傳遞性規(guī)則,1 happens before 4。

上述happens before 關(guān)系的圖形化表現(xiàn)形式如下:

Java并發(fā)問題有哪些

在上圖中,每一個箭頭鏈接的兩個節(jié)點,代表了一個happens before 關(guān)系。黑色箭頭表示程序順序規(guī)則;橙色箭頭表示volatile規(guī)則;藍色箭頭表示組合這些規(guī)則后提供的happens before保證。

這里A線程寫一個volatile變量后,B線程讀同一個volatile變量。A線程在寫volatile變量之前所有可見的共享變量,在B線程讀同一個volatile變量后,將立即變得對B線程可見。

volatile寫-讀的內(nèi)存語義

volatile寫的內(nèi)存語義如下:

  • 當(dāng)寫一個volatile變量時,JMM會把該線程對應(yīng)的本地內(nèi)存中的共享變量刷新到主內(nèi)存。

以上面示例程序VolatileExample為例,假設(shè)線程A首先執(zhí)行writer()方法,隨后線程B執(zhí)行reader()方法,初始時兩個線程的本地內(nèi)存中的flag和a都是初始狀態(tài)。下圖是線程A執(zhí)行volatile寫后,共享變量的狀態(tài)示意圖:

Java并發(fā)問題有哪些

如上圖所示,線程A在寫flag變量后,本地內(nèi)存A中被線程A更新過的兩個共享變量的值被刷新到主內(nèi)存中。此時,本地內(nèi)存A和主內(nèi)存中的共享變量的值是一致的。

volatile讀的內(nèi)存語義如下:

  • 當(dāng)讀一個volatile變量時,JMM會把該線程對應(yīng)的本地內(nèi)存置為無效。線程接下來將從主內(nèi)存中讀取共享變量。

下面是線程B讀同一個volatile變量后,共享變量的狀態(tài)示意圖:

Java并發(fā)問題有哪些

如上圖所示,在讀flag變量后,本地內(nèi)存B已經(jīng)被置為無效。此時,線程B必須從主內(nèi)存中讀取共享變量。線程B的讀取操作將導(dǎo)致本地內(nèi)存B與主內(nèi)存中的共享變量的值也變成一致的了。

如果我們把volatile寫和volatile讀這兩個步驟綜合起來看的話,在讀線程B讀一個volatile變量后,寫線程A在寫這個volatile變量之前所有可見的共享變量的值都將立即變得對讀線程B可見。

下面對volatile寫和volatile讀的內(nèi)存語義做個總結(jié):

  • 線程A寫一個volatile變量,實質(zhì)上是線程A向接下來將要讀這個volatile變量的某個線程發(fā)出了(其對共享變量所在修改的)消息。

  • 線程B讀一個volatile變量,實質(zhì)上是線程B接收了之前某個線程發(fā)出的(在寫這個volatile變量之前對共享變量所做修改的)消息。

  • 線程A寫一個volatile變量,隨后線程B讀這個volatile變量,這個過程實質(zhì)上是線程A通過主內(nèi)存向線程B發(fā)送消息。

volatile內(nèi)存語義的實現(xiàn)

下面,讓我們來看看JMM如何實現(xiàn)volatile寫/讀的內(nèi)存語義。

前文我們提到過重排序分為編譯器重排序和處理器重排序。為了實現(xiàn)volatile內(nèi)存語義,JMM會分別限制這兩種類型的重排序類型。下面是JMM針對編譯器制定的volatile重排序規(guī)則表:

是否能重排序第二個操作

第一個操作普通讀/寫volatile讀volatile寫
普通讀/寫

NO
volatile讀NONONO
volatile寫
NONO

舉例來說,第三行最后一個單元格的意思是:在程序順序中,當(dāng)?shù)谝粋€操作為普通變量的讀或?qū)憰r,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。

從上表我們可以看出:

  • 當(dāng)?shù)诙€操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規(guī)則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。

  • 當(dāng)?shù)谝粋€操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規(guī)則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。

  • 當(dāng)?shù)谝粋€操作是volatile寫,第二個操作是volatile讀時,不能重排序。

為了實現(xiàn)volatile的內(nèi)存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能,為此,JMM采取保守策略。下面是基于保守策略的JMM內(nèi)存屏障插入策略:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。

  • 在每個volatile寫操作的后面插入一個StoreLoad屏障。

  • 在每個volatile讀操作的后面插入一個LoadLoad屏障。

  • 在每個volatile讀操作的后面插入一個LoadStore屏障。

上述內(nèi)存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內(nèi)存語義。

下面是保守策略下,volatile寫插入內(nèi)存屏障后生成的指令序列示意圖:

Java并發(fā)問題有哪些

上圖中的StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經(jīng)對任意處理器可見了。這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內(nèi)存。

這里比較有意思的是volatile寫后面的StoreLoad屏障。這個屏障的作用是避免volatile寫與后面可能有的volatile讀/寫操作重排序。因為編譯器常常無法準確判斷在一個volatile寫的后面,是否需要插入一個StoreLoad屏障(比如,一個volatile寫之后方法立即return)。為了保證能正確實現(xiàn)volatile的內(nèi)存語義,JMM在這里采取了保守策略:在每個volatile寫的后面或在每個volatile讀的前面插入一個StoreLoad屏障。從整體執(zhí)行效率的角度考慮,JMM選擇了在每個volatile寫的后面插入一個StoreLoad屏障。因為volatile寫-讀內(nèi)存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當(dāng)讀線程的數(shù)量大大超過寫線程時,選擇在volatile寫之后插入StoreLoad屏障將帶來可觀的執(zhí)行效率的提升。從這里我們可以看到JMM在實現(xiàn)上的一個特點:首先確保正確性,然后再去追求執(zhí)行效率。

下面是在保守策略下,volatile讀插入內(nèi)存屏障后生成的指令序列示意圖:

Java并發(fā)問題有哪些

上圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

上述volatile寫和volatile讀的內(nèi)存屏障插入策略非常保守。在實際執(zhí)行時,只要不改變volatile寫-讀的內(nèi)存語義,編譯器可以根據(jù)具體情況省略不必要的屏障。下面我們通過具體的示例代碼來說明:
   class VolatileBarrierExample {
       int a;
       volatile int v1 = 1;
       volatile int v2 = 2;

    void readAndWrite() {
        int i = v1; // 第一個volatile讀
        int j = v2; // 第二個volatile讀
        a = i + j; // 普通寫
        v1 = i + 1; // 第一個volatile寫
        v2 = j * 2; // 第二個 volatile寫
    }
}

針對readAndWrite()方法,編譯器在生成字節(jié)碼時可以做如下的優(yōu)化:

Java并發(fā)問題有哪些

注意,最后的StoreLoad屏障不能省略。因為第二個volatile寫之后,方法立即return。此時編譯器可能無法準確斷定后面是否會有volatile讀或?qū)?,為了安全起見,編譯器常常會在這里插入一個StoreLoad屏障。

上面的優(yōu)化是針對任意處理器平臺,由于不同的處理器有不同“松緊度”的處理器內(nèi)存模型,內(nèi)存屏障的插入還可以根據(jù)具體的處理器內(nèi)存模型繼續(xù)優(yōu)化。以x86處理器為例,上圖中除最后的StoreLoad屏障外,其它的屏障都會被省略。

前面保守策略下的volatile讀和寫,在 x86處理器平臺可以優(yōu)化成:

Java并發(fā)問題有哪些

前文提到過,x86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀,讀-寫和寫-寫操作做重排序,因此在x86處理器中會省略掉這三種操作類型對應(yīng)的內(nèi)存屏障。在x86中,JMM僅需在volatile寫后面插入一個StoreLoad屏障即可正確實現(xiàn)volatile寫-讀的內(nèi)存語義。這意味著在x86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因為執(zhí)行StoreLoad屏障開銷會比較大)。

JSR-133為什么要增強volatile的內(nèi)存語義

在JSR-133之前的舊Java內(nèi)存模型中,雖然不允許volatile變量之間重排序,但舊的Java內(nèi)存模型允許volatile變量與普通變量之間重排序。在舊的內(nèi)存模型中,VolatileExample示例程序可能被重排序成下列時序來執(zhí)行:

Java并發(fā)問題有哪些

在舊的內(nèi)存模型中,當(dāng)1和2之間沒有數(shù)據(jù)依賴關(guān)系時,1和2之間就可能被重排序(3和4類似)。其結(jié)果就是:讀線程B執(zhí)行4時,不一定能看到寫線程A在執(zhí)行1時對共享變量的修改。

因此在舊的內(nèi)存模型中 ,volatile的寫-讀沒有監(jiān)視器的釋放-獲所具有的內(nèi)存語義。為了提供一種比監(jiān)視器鎖更輕量級的線程之間通信的機制,JSR-133專家組決定增強volatile的內(nèi)存語義:嚴格限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile的寫-讀和監(jiān)視器的釋放-獲取一樣,具有相同的內(nèi)存語義。從編譯器重排序規(guī)則和處理器內(nèi)存屏障插入策略來看,只要volatile變量與普通變量之間的重排序可能會破壞volatile的內(nèi)存語意,這種重排序就會被編譯器重排序規(guī)則和處理器內(nèi)存屏障插入策略禁止。

由于volatile僅僅保證對單個volatile變量的讀/寫具有原子性,而監(jiān)視器鎖的互斥執(zhí)行的特性可以確保對整個臨界區(qū)代碼的執(zhí)行具有原子性。在功能上,監(jiān)視器鎖比volatile更強大;在可伸縮性和執(zhí)行性能上,volatile更有優(yōu)勢。如果讀者想在程序中用volatile代替監(jiān)視器鎖,請一定謹慎。

CAS操作詳解

本文屬于作者原創(chuàng),原文發(fā)表于InfoQ: http://www.infoq.com/cn/articles/atomic-operation

引言

原子(atom)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為”不可被中斷的一個或一系列操作” 。在多處理器上實現(xiàn)原子操作就變得有點復(fù)雜。本文讓我們一起來聊一聊在Inter處理器和Java里是如何實現(xiàn)原子操作的。

術(shù)語定義

術(shù)語名稱英文解釋
緩存行Cache line緩存的最小操作單位
比較并交換Compare and SwapCAS操作需要輸入兩個數(shù)值,一個舊值(期望操作前的值)和一個新值,在操作期間先比較下在舊值有沒有發(fā)生變化,如果沒有發(fā)生變化,才交換成新值,發(fā)生了變化則不交換。
CPU流水線CPU pipelineCPU流水線的工作方式就象工業(yè)生產(chǎn)上的裝配流水線,在CPU中由5~6個不同功能的電路單元組成一條指令處理流水線,然后將一條X86指令分成5~6步后再由這些電路單元分別執(zhí)行,這樣就能實現(xiàn)在一個CPU時鐘周期完成一條指令,因此提高CPU的運算速度。
內(nèi)存順序沖突Memory order violation內(nèi)存順序沖突一般是由假共享引起,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當(dāng)出現(xiàn)這個內(nèi)存順序沖突時,CPU必須清空流水線。

3    處理器如何實現(xiàn)原子操作

32位IA-32處理器使用基于對緩存加鎖或總線加鎖的方式來實現(xiàn)多處理器之間的原子操作。

3.1   處理器自動保證基本內(nèi)存操作的原子性

首先處理器會自動保證基本的內(nèi)存操作的原子性。處理器保證從系統(tǒng)內(nèi)存當(dāng)中讀取或者寫入一個字節(jié)是原子的,意思是當(dāng)一個處理器讀取一個字節(jié)時,其他處理器不能訪問這個字節(jié)的內(nèi)存地址。奔騰6和最新的處理器能自動保證單處理器對同一個緩存行里進行16/32/64位的操作是原子的,但是復(fù)雜的內(nèi)存操作處理器不能自動保證其原子性,比如跨總線寬度,跨多個緩存行,跨頁表的訪問。但是處理器提供總線鎖定和緩存鎖定兩個機制來保證復(fù)雜內(nèi)存操作的原子性。

3.2   使用總線鎖保證原子性

第一個機制是通過總線鎖保證原子性。如果多個處理器同時對共享變量進行讀改寫(i++就是經(jīng)典的讀改寫操作)操作,那么共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之后共享變量的值會和期望的不一致,舉個例子:如果i=1,我們進行兩次i++操作,我們期望的結(jié)果是3,但是有可能結(jié)果是2。如下圖
Java并發(fā)問題有哪些

(例1)

原因是有可能多個處理器同時從各自的緩存中讀取變量i,分別進行加一操作,然后分別寫入系統(tǒng)內(nèi)存當(dāng)中。那么想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量內(nèi)存地址的緩存。

處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信號,當(dāng)一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那么該處理器可以獨占使用共享內(nèi)存。

3.3 使用緩存鎖保證原子性

第二個機制是通過緩存鎖定保證原子性。在同一時刻我們只需保證對某個內(nèi)存地址的操作是原子性即可,但總線鎖定把CPU和內(nèi)存之間通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內(nèi)存地址的數(shù)據(jù),所以總線鎖定的開銷比較大,最近的處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優(yōu)化。

頻繁使用的內(nèi)存會緩存在處理器的L1,L2和L3高速緩存里,那么原子操作就可以直接在處理器內(nèi)部緩存中進行,并不需要聲明總線鎖,在奔騰6和最近的處理器中可以使用“緩存鎖定”的方式來實現(xiàn)復(fù)雜的原子性。所謂“緩存鎖定”就是如果緩存在處理器緩存行中內(nèi)存區(qū)域在LOCK操作期間被鎖定,當(dāng)它執(zhí)行鎖操作回寫內(nèi)存時,處理器不在總線上聲言LOCK#信號,而是修改內(nèi)部的內(nèi)存地址,并允許它的緩存一致性機制來保證操作的原子性,因為緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內(nèi)存區(qū)域數(shù)據(jù),當(dāng)其他處理器回寫已被鎖定的緩存行的數(shù)據(jù)時會起緩存行無效,在例1中,當(dāng)CPU1修改緩存行中的i時使用緩存鎖定,那么CPU2就不能同時緩存了i的緩存行。

但是有兩種情況下處理器不會使用緩存鎖定。第一種情況是:當(dāng)操作的數(shù)據(jù)不能被緩存在處理器內(nèi)部,或操作的數(shù)據(jù)跨多個緩存行(cache line),則處理器會調(diào)用總線鎖定。第二種情況是:有些處理器不支持緩存鎖定。對于Inter486和奔騰處理器,就算鎖定的內(nèi)存區(qū)域在處理器的緩存行中也會調(diào)用總線鎖定。

以上兩個機制我們可以通過Inter處理器提供了很多LOCK前綴的指令來實現(xiàn)。比如位測試和修改指令BTS,BTR,BTC,交換指令XADD,CMPXCHG和其他一些操作數(shù)和邏輯指令,比如ADD(加),OR(或)等,被這些指令操作的內(nèi)存區(qū)域就會加鎖,導(dǎo)致其他處理器不能同時訪問它。

4    JAVA如何實現(xiàn)原子操作

在java中可以通過鎖和循環(huán)CAS的方式來實現(xiàn)原子操作。

4.1 使用循環(huán)CAS實現(xiàn)原子操作

JVM中的CAS操作正是利用了上一節(jié)中提到的處理器提供的CMPXCHG指令實現(xiàn)的。自旋CAS實現(xiàn)的基本思路就是循環(huán)進行CAS操作直到成功為止,以下代碼實現(xiàn)了一個基于CAS線程安全的計數(shù)器方法safeCount和一個非線程安全的計數(shù)器count。

package Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
    private AtomicInteger atomicI = new AtomicInteger();
    private int i = 0;
    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> ts = new ArrayList<Thread>();
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        cas.count();
                        cas.safeCount();
                    }
                }
            });
            ts.add(t);
        }
        for (Thread t : ts) {
            t.start();
        }
        // 等待所有線程執(zhí)行完成
        for (Thread t : ts) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis() - start);
    }
    /**
     * 
     * 使用CAS實現(xiàn)線程安全計數(shù)器
     * 
     */
    private void safeCount() {
        for (;;) {
            int i = atomicI.get();
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }
    /**
     * 
     * 非線程安全計數(shù)器
     * 
     */
    private void count() {
        i++;
    }
}
結(jié)果
992362
1000000
75

從Java1.5開始JDK的并發(fā)包里提供了一些類來支持原子操作,如 AtomicBoolean(用原子方式更新的 boolean 值), AtomicInteger(用原子方式更新的 int 值), AtomicLong(用原子方式更新的 long 值),這些原子包裝類還提供了有用的工具方法,比如以原子的方式將當(dāng)前值自增1和自減1。

在Java并發(fā)包中有一些并發(fā)框架也使用了自旋CAS的方式來實現(xiàn)原子操作,比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。ABA問題,循環(huán)時間長開銷大和只能保證一個共享變量的原子操作。

  1. ABA問題。因為CAS需要在操作值的時候檢查下值有沒有發(fā)生變化,如果沒有發(fā)生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那么使用CAS進行檢查時會發(fā)現(xiàn)它的值沒有發(fā)生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那么A-B-A 就會變成1A-2B-3A。

從Java1.5開始JDK的atomic包里提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當(dāng)前引用是否等于預(yù)期引用,并且當(dāng)前標志是否等于預(yù)期標志,如果全部相等,則以原子方式將該引用和該標志的值設(shè)置為給定的更新值。

public boolean compareAndSet(
   V      expectedReference,//預(yù)期引用
   V      newReference,//更新后的引用
  int    expectedStamp, //預(yù)期標志
  int    newStamp //更新后的標志
)
  1. 循環(huán)時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執(zhí)行開銷。如果JVM能支持處理器提供的pause指令那么效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執(zhí)行指令(de-pipeline),使CPU不會消耗過多的執(zhí)行資源,延遲的時間取決于具體實現(xiàn)的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環(huán)的時候因內(nèi)存順序沖突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執(zhí)行效率。

  1. 只能保證一個共享變量的原子操作。當(dāng)對一個共享變量執(zhí)行操作時,我們可以使用循環(huán)CAS的方式來保證原子操作,但是對多個共享變量操作時,循環(huán)CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合并一下ij=2a,然后用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象里來進行CAS操作。

4.2 使用鎖機制實現(xiàn)原子操作

鎖機制保證了只有獲得鎖的線程能夠操作鎖定的內(nèi)存區(qū)域。JVM內(nèi)部實現(xiàn)了很多種鎖機制,有偏向鎖,輕量級鎖和互斥鎖,有意思的是除了偏向鎖,JVM實現(xiàn)鎖的方式都用到的循環(huán)CAS,當(dāng)一個線程想進入同步塊的時候使用循環(huán)CAS的方式來獲取鎖,當(dāng)它退出同步塊的時候使用循環(huán)CAS釋放鎖。詳細說明可以參見文章 Java SE1.6中的Synchronized。

到此,關(guān)于“Java并發(fā)問題有哪些”的學(xué)習(xí)就結(jié)束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學(xué)習(xí),快去試試吧!若想繼續(xù)學(xué)習(xí)更多相關(guān)知識,請繼續(xù)關(guān)注億速云網(wǎng)站,小編會繼續(xù)努力為大家?guī)砀鄬嵱玫奈恼拢?/p>

向AI問一下細節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI