【JAVA併發第四篇】線程安全

一、線程安全

多個線程對同一個共享變量進行讀寫操做時可能產生不可預見的結果,這就是線程安全問題。java

線程安全的核心點就是共享變量,只有在共享變量的狀況下才會有線程安全問題。這裏說的共享變量,是指多個線程都能訪問的變量,通常包括成員變量和靜態變量,方法內定義的局部變量不屬於共享變量的範圍。程序員

線程安全問題示例:編程

import lombok.extern.slf4j.Slf4j;

/**
 * @Author FengJian
 * @Date 2021/1/27 10:59
 * @Version 1.0
 */
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafeTest {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                for (int i = 0;i < 5000;i++){
                    count++;
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                for (int i = 0;i < 5000;i++){
                    count--;
                }
            }
        };

        t1.start();
        t2.start();

        /**
         * join方法:使main線程與t一、t2線程同步執行,即t一、t2線程都執行完,main線程纔會繼續執行(但t一、t2之間依然是並行執行的)
         * 主要是爲了等待兩個線程執行完後,在main線程打印count的值
         */
        t1.join();
        t2.join();

        log.debug("count的值爲:{}",count);
    }
}

運行上述代碼三次的結果以下:數組

[main] DEBUG c.ThreadSafeTest - count的值爲:-904
[main] DEBUG c.ThreadSafeTest - count的值爲:-2206
[main] DEBUG c.ThreadSafeTest - count的值爲:73

在上述代碼中,線程t1中count進行5000次自增操做,而線程t2中count則進行5000次自減操做。在兩個線程都運行結束後,按照預期結果,count的值應爲0。但由打印結果可知,count的值並不爲0,且每次運行的結果都不同。這就是多線程對共享變量進行操做出現的不可預見的結果,即常說的線程安全問題。緩存

而線程安全,則指的是在多線程環境下,程序能夠始終執行正確的行爲,符合預期的邏輯。具體到上述代碼,就是不論執行多少次,在t一、t2線程執行完畢後,count的值都應該始終符合預期的結果0。上述代碼明顯是線程不安全的。安全

二、出現線程安全的緣由

線程安全是使用多線程一定會面臨的問題,致使線程不安全的主要緣由有如下三點:多線程

①原子性:一個或者多個操做在 CPU 執行的過程當中被中斷
②可見性:一個線程對共享變量的修改,另一個線程不能馬上看到
③有序性:序執行的順序沒有按照代碼的前後順序執行架構

2.一、原子性

2.1.1 什麼是原子性問題

原子性問題,其實說的是原子性操做。即一個或多個操做,應該是一個不可分的總體,這些操做要麼所有執行而且不被打斷,要麼就都不執行。併發

以上述代碼中的count的自增(count++)和自減(count--)爲例。app

count++count--看似只有一行代碼,但實際上這一行代碼在編譯後的字節碼指令以及在JVM執行的對應操做以下:

count++:

getstatic count  //獲取靜態變量count的值
iconst_1   //準備常量1
iadd   //自增
putstatic count  //將修改後的值存入靜態變量count

count--:

getstatic count  //獲取靜態變量count的值
iconst_1   //準備常量1
isub  //自減
putstatic count  //將修改後的值存入靜態變量count

由此可知,count自增或自減的操做,並非一個原子操做,即中間過程是有可能被打斷的。

count自增自減操做須要四個步驟(指令)才能完成,這意味着若是這執行這四個步驟的某一步時,線程發生了上下文切換,那麼自增自減操做將被打斷暫停。

若是使用單線程來執行自增自減操做,這實際上並沒有問題:
在這裏插入圖片描述

上圖爲單線程執行count自增自減的一次過程,能夠看出在沒有線程上下文切換的狀況下,即便自增自減不是原子操做,count的最後結果都會是0。

但在多線程環境下,就會出現問題了:

在這裏插入圖片描述
能夠看到因爲自增自減不是原子操做,所以在線程t1執行自增過程當中,若是進行上下文切換,則將致使線程t1還沒來得及把count = 1 寫入主存,count的值就被t2線程讀取,因此在最後,線程t2自減得出的值-1寫入主存後,會被線程t1覆蓋,變爲1。

這結果明顯是不符合咱們的預期的,實際上,上述圖片展現的只是一種可能的結果。還有多是t2寫入count的步驟是最後執行的,那麼最後count的值將爲-1。

這就是因爲非原子操做帶來的多線程訪問共享變量出現不符合預期的結果,即因爲原子性帶來的線程安全問題。

上面示例中兩個線程t一、t2分別執行count++和count--出現的問題,就是因爲原子性帶來的線程安全問題。

2.1.二、原子性問題解決辦法

解決辦法就是將count++和count--的操做變爲原子操做,Java中的實現方法是:

①上鎖:使用synchronized

只須要建立一個對象做爲鎖,並在訪問count時用synchronized進行加鎖便可。

static int count = 0;
 static Object lock = new Object(); //鎖對象
 
 synchronized(lock){ 
     count++;
 }

 synchronized(lock){ 
     count--;
 }

上鎖後,執行自增自減的示意圖以下:

在這裏插入圖片描述
因爲鎖的存在,則保證了不持有鎖的t2線程會被阻塞,直到t1線程執行自增完畢,並釋放鎖。在這一過程當中,雖然依舊存在線程的上下文切換,可是t2線程是沒法對共享變量count進行操做的,所以保證了t1線程中count++操做的原子性。

所以使用synchronized鎖能夠解決原子性帶來的線程安全問題。

②、循環CAS操做

其基本思路就是循環進行CAS操做(compare and swap,比較並交換)。即對共享變量進行計算前,線程會先將該共享變量保存一份舊值a,計算完畢後得出結果值b。在將b從線程的本地內存刷新回主內存前,會先比較主內存中的值是否和a一致。若是一致,則將b刷新回主內存。若不一致,則一直循環比較,直到主內存中的值與a一致,才把共享變量的值設爲b,操做才結束。

在Java中,使用CAS操做保證原子性的具體實現就是Lock和原子類(AtomicInteger)。它們都是經過使用unsafe的compareAndSwap方法實現CAS操做保證原子性的。

Lock的使用:

static int count = 0;
static Lock lock = new Lock (); //鎖對象

lock.lock(); //加鎖
count++;
lock.unlock(); //解鎖

lock.lock(); //加鎖
count--;
lock.unlock(); //解鎖

原子類的使用:

static AtomicInteger count = new AtomicInteger(0);

count.incrementAndGet(); //自增

count.decrementAndGet(); //自減

以上都是Java中能夠保證原子操做的具體方法,它們各有優缺點,要看具體的場景來選擇最佳的使用,以此來解決原子性帶來的線程安全問題。

2.二、可見性

2.2.一、什麼是可見性問題

可見性實際上指的是內存可見性問題。總的來講就是一個線程對共享變量的修改,另一個線程不能馬上看到,從而產生的線程安全問題。

在上一篇筆記【JAVA併發第三篇】線程間通訊 中的經過共享內存進行通訊實際上講的就是內存可見性問題。這裏再從線程安全的角度講述一遍。

咱們知道,CPU要從內存中讀取出數據來進行計算,但實際上CPU並不老是直接從內存中讀取數據。因爲CPU和內存間(常稱之爲主存)的速度不匹配(CPU的速度比主存快得多),爲了有效利用CPU,使用多級cache的機制,如圖
在這裏插入圖片描述
上圖所示是一個雙核心的CPU系統架構,每一個核心都有本身的控制器和運算器,也都有本身的一級緩存,還有可能有全部CPU核心共享的二級緩存,每一個核心均可以獨立運行線程。

所以,CPU讀取數據的順序是:寄存器-高速緩存-主存。主存中的部分數據,會先拷貝一份放到cache中,當CPU計算時,會直接從cache中讀取數據,計算完畢後再將計算結果放置到cache中,最後在主存中刷新計算結果。因此每一個CPU都會擁有一份拷貝。

以上只是CPU訪問內存,進行計算的基本方式。實際上,不一樣的硬件,訪問過程會存在不一樣程度的差別。好比,不一樣的計算機,CPU和主存間可能會存在三級緩存、四級緩存、五級緩存等等的狀況。

爲了屏蔽掉各類硬件和操做系統的內存訪問差別,實現讓 Java 程序在各類平臺下都能達到一致的內存訪問效果,定義了Java的內存模型(Java Memory Model,JMM)。

JMM 的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到主存和從主存中取出變量這樣的底層細節。這裏的變量指的是可以被多個線程共享的變量,它包括了實例字段、靜態字段和構成數組對象的元素,方法內的局部變量和方法的參數爲線程私有,不受JMM的影響。

Java的內存模型以下:

在這裏插入圖片描述
Java內存模型中的本地內存,對應的就是CPU結構圖中的cache1或者cache2。它實際上並不真實存在,其包含了緩存、寫緩衝區、寄存器以及其餘的硬件和編譯器的優化。

JMM規定:將全部共享變量放到主內存中,當線程使用變量時,會把其中的變量複製到本身的本地內存,線程讀寫時操做的是本地內存中的變量副本。一個線程不能訪問其餘線程的本地內存。

這樣的狀況下,若是有一個變量i在線程A、B的本地內存中都有一份副本。此時,若線程A想修改i的值,在線程A將修改後的值放入到本地內存,但又未刷新回主內存時,若是線程B讀取變量i的值,則讀到的是未修改時的值,這就形成了讀寫共享變量出現不可預期的結果,產生線程安全問題。

有代碼以下:

/**
 * @Author FengJian
 * @Date 2021/2/21 23:47
 * @Version 1.0
 */
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafe02 {
    private static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread My_Thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (run) {

                }
            }
        }, "My_Thread");
        My_Thread.start();  //啓動My_Thread線程
        log.debug(Thread.currentThread().getName()+"正在休眠@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
        Thread.sleep(1000);  //主線程休眠1s
        run = false;  //改變My_Thread線程運行條件
        log.debug(Thread.currentThread().getName()+"正在運行@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
    }
}

從運行結果發現,即便在主線程中修改了共享變量run的值,My_Thread線程依然在循環並不會中止:
在這裏插入圖片描述
其緣由就是main線程對共享變量run的修改,另一個線程My_Thread並不能馬上看到:

在這裏插入圖片描述
這就是因爲內存可見性帶來的多線程訪問共享變量出現不符合預期的結果,即因爲可見性帶來的線程安全問題。

2.2.二、可見性問題解決辦法

解決辦法就是保證共享變量的可見性,具體實現就是任何對共享變量的訪問都要從共享內存(主內存)中獲取。在Java中的實現方法是:

①加鎖,synchronized和Lock均可以保證

線程在加鎖時,會清空本地內存中共享變量的值,共享變量的使用須要從主內存中從新獲取。而在釋放鎖資源時,則必須先把此共享變量同步回主內存中。

因爲鎖的存在,未持有鎖的線程並不能操做共享變量,而當阻塞的線程得到鎖時,主內存中共享變量的值已經刷新過了,所以線程修改共享變量對其餘線程是可見的。這保證了共享變量的可見性,能夠解決內存可見性產生的線程安全問題。

②使用volatile修飾共享變量

當一個變量被聲明爲volitale時,線程在寫入變量時,不會把值緩存本地內存,而是會當即把值刷新回主存,而當要讀取該共享變量時,線程則會先清空本地內存中的副本值,從主存中從新獲取。這些也都保證了內存的可見性。

優先使用volatile關鍵字來解決可見性問題,加鎖消耗的資源更多。

2.三、有序性

2.3.一、什麼是有序性問題

有序性,其實是指令的重排序問題。

咱們知道,CPU的執行速度是比內存要快出不少個數量級的。CPU爲了執行效率,會把CPU指令進行從新排序。即咱們編寫的Java代碼並不必定按照順序一行一行的往下執行,處理器會根據須要從新排序這些指令,稱爲指令並行重排序。

同時,JIT編譯器也會在代碼編譯的時候對代碼進行從新整理,最大限度的去優化代碼的執行效率,稱爲編譯器的重排序。

而又因爲處理器與主存之間會使用緩存和讀/寫緩衝機制,所以從主存加載和存儲操做也有多是通過指令重排序的,稱爲內存系統重排序。

綜上所述,在執行程序時,爲了提升性能,編譯器和處理器經常會對指令進行重排序,再加上主內存和處理器的緩存,Java源碼通過層層的重排序,最後才得出最終結果。

在這裏插入圖片描述

由圖可知,從Java源碼到最後的執行指令,會經歷3種重排序的優化。如有ava代碼以下:

int a = 2; //A
int b = 3; //B
int c = a*b; //C

通過上述3種重排序後,語句A和語句B的執行順序是可能互換的,而且這種互換並不影響代碼的正確性。可是咱們發現語句C則不能和A、B互換,不然得出的結果將不正確,由於他們之間存在着數據依賴關係,即語句C的數據依賴A和B得出。

由此,咱們能夠發現,以上3種指令的重排序並不能隨意排序,他們須要遵照必定的規則,以保證程序的正確性。

①as-if-serial語義

as-if-serial語義是指:無論怎麼樣重排序,單線程程序的執行結果都不能被改變。即不會對存在數據依賴關係的操做進行重排序。

編譯器、處理器進行指令重排序優化時都必須遵照as-if-serial語義。即在單線程的狀況下,指令重排序只能對不影響處理結果的部分進行重排序。

以上述語句A、B、C爲例,存在數據依賴關係的語句C和A或B不能被重排序:

在這裏插入圖片描述
as-if-serial語義把單線程程序保護起來了,遵照該語義的編譯器、處理器等使咱們編寫單線程有一個錯覺:單線程程序是按照源代碼的順序來執行的。實際上在因爲as-if-serial語義的存在,咱們編寫單線程時,徹底能夠認爲源代碼是按照順序執行的,由於即便代碼被進行了重排序,其結果也不會改變,同時單線程中也無需擔憂內存可見性問題。

as-if-serial語義的核心思想是:不會對存在數據依賴關係的操做進行重排序。

那麼數據依賴類型有哪些呢?以下表所示:

類型 示例 說明
寫後讀 a = 1; b = a 寫一個變量後再讀該變量
寫後寫 a = 1; a = 2 寫一個變量後再寫該變量
讀後寫 a = b; b = 2 讀一個變量後再寫該變量

以上三種依賴關係,一旦重排序兩個操做的執行順序,其結果就會改變,因此依照as-if-serial語義,Java在單線程的狀況下不會對這三種依賴關係進行重排序(多線程狀況不符合此狀況)。

as-if-serial語義是基於數據依賴關係的,但它沒法保證多線程環境下,重排序以後程序執行結果的正確性。

有代碼以下:

/**
 * @Author FengJian
 * @Date 2021/2/24 16:44
 * @Version 1.0
 */
@Slf4j(topic = "c.HappensBeforeTest")
public class HappensBeforeTest {
    static int a = 0;
    static boolean finish = false;

    public static void main(String[] args) {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                if(finish){
                    log.debug("a*a:"+a*a);
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                a = 2;
                finish = true;
            }
        };

        t2.start();
        t1.start();
    }
}

關於上述代碼,咱們先忽略內存可見性的問題(即線程t2修改了a和finish,但t1可能看不到的緩存問題)。在此前提下若是成功打印a*a的值,那麼結果應該爲4。

但實際上a*a打印的結果還可能爲0,這是因爲指令重排序的存在致使的

在線程t2中,因爲a = 2;finish = true;沒有數據依賴關係,依照as-if-serial語義,能夠對這兩條語句進行重排序,所以會出現finish = true;的指令比a = 2;先執行的狀況。

若是在先執行finish = true;,而a = 2;沒有執行時發生線程上下文切換,輪到線程t1執行,那麼t1線程中的if語句條件爲真,而a的值依然爲初始值0,則a*a的結果爲0。

在這裏插入圖片描述

能夠看出,即便在假設沒有內存可見性問題的前提下,上述代碼的結果也是不可預期的,所以上述代碼也是線程不安全的,其緣由就是重排序破壞了多線程程序的語義。

②happens-before規則

既然是重排序出現問題,那麼解決思路就是禁止重排序。可是也要注意不能所有禁用重排序,重排序的目的是爲了提高執行效率,若是所有禁用那麼Java程序的性能將會不好。因此,應該作到的是部分禁用,Java的內存模型提供了一個可用於多線程環境,也適用於單線程環境的規則:happens-before規則。

happens-before規則的定義以下:A happens-before B,那麼操做A的執行結果對操做B是可見的,且操做A的執行順序排在操做B以前。這裏的操做A和操做B能夠在同一個線程中,也能夠在不一樣線程中。

注意:執行順序只是happens-before向開發人員作的保證,實際上在處理器和編譯器上執行時並不必定按照操做A排在操做B以前執行。
若是重排序以後,依然能夠保證與先A後B的執行結果同樣,那麼進行重排序也是能夠的。也就是說,符合happens-before的操做,只要不改變執行結果,處理器和編譯器怎麼優化(重排序)都行。
只是咱們開發人員能夠直接認爲操做A的執行順序排在操做B以前。

happens-before保證操做A的執行結果對B可見,依靠這個原則,能夠解決多線程環境下內存可見性和有序性問題。

回到代碼:

/**線程t1**/
if(finish){ 
   a*a;
}

/**線程t2**/
a = 2;
finish = true;

一共有四個操做a = 2;finish = true;if(finish)a*a;,想要上述代碼達到線程安全(即打印都正確輸出4),只須要:

在這裏插入圖片描述
即在t2線程計算a*a;if(finish);以前,須要知道t1線程中a = 2;finish = true;(t2線程對t1線程的結果可見)。

要達到這一目的,就須要上圖中,①和②所示的happens-before關係。

那要如何達到呢?這就須要瞭解happens-before的六大具體規則了(兩個操做,只須要符合其中任何一條就能夠認爲是happens-before關係):

  • ①程序順序規則:一個線程中的每一個操做,按照程序順序,前面的操做 happens-before 於該線程中的任意後續操做。
以上述代碼爲例:
/**線程t2**/
a = 2; //操做1
finish = true; //操做2

/**線程t1**/
if(finish ); //操做3
a*a; //操做4

操做1 happens-before 操做2
操做3 happens-before 操做4
  • ②監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
synchronized (lock) { //加鎖
	// x是共享變量,初始值=10
	if (x < 12) {
		x = 12; 
  	}  
} //解鎖

如有兩個線程A、B,前後執行這段代碼。則線程A執行完畢後X = 12並釋放鎖。而線程B得到鎖後,進入代碼塊,在if中取X值判斷是否小於12。

此時 線程A中X=12的操做 happens-before 線程B中取X值判斷的操做(即線程B能看到線程A中執行的X=12的結果)
  • ③volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
volatile int x = 10;

/**線程t1**/
x = 11; //操做1

/**線程t2**/
int y = x; //操做2

操做1 happens-before 操做2
  • ④傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。

  • ⑤start()規則:若是線程A執行操做ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操做happens-before於線程B中的任意操做。

  • ⑥join()規則:若是線程A執行操做ThreadB.join()併成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做成功返回。

以上就是happens-before的六大經常使用規則(所有有八種,但後面兩種應該不多用到)

2.3.二、有序性問題解決辦法

解決有序性問題,實際上就是要運用以上提到的兩種規則,as-if-serial語義解決了單線程程序的有序性問題,而happens-before關係則能解決多線程程序的有序性問題。

再回顧一下原始代碼,這是一段存在有序性問題線程不安全的代碼,咱們要利用happens-before關係解決有序性問題:

public class HappensBeforeTest {
    static int a = 0;
    static boolean finish = false;

    public static void main(String[] args) {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                if(finish){
                    log.debug("a*a:"+a*a);
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                a = 2;
                finish = true;
            }
        };

        t2.start();
        t1.start();
    }
}

提取一下關鍵的操做,以下嗷:

/**線程t1**/
if(finish){ 
   a*a;
}

/**線程t2**/
a = 2;
finish = true;

咱們的目標是運用happens-before的六大經常使用規則達到以下圖的happens-before關係,以實現上訴代碼的線程安全

在這裏插入圖片描述
解決辦法以下:
①、方法一:運用volatile修飾變量

使用到happens-before規則中的程序順序規則、volatile變量規則和傳遞性。

首先,按照程序順序規則,能夠知道以下的happens-before關係:

線程t1 線程t2
if(finish) happens-before a*a; a = 2; happens-before finish = true;

這由線程中的代碼很容易就能得出。接下來運用volatile變量規則,須要用volatile修飾一個變量,咱們選變量finish。即初始化時代碼改成爲volatile static boolean finish = false;

那麼根據volatile變量規則,可知對finish的寫要happens-before於對finish的讀。

所以給finish加上volatile關鍵字後,就能夠達到以下效果:

在這裏插入圖片描述
volatile關鍵字不只能夠保證內存可見性問題,同時依照happens-before的volatile變量規則,對於volatile修飾的變量,要保證對該變量寫的結果要對讀的操做可見,所以volatile禁止對有讀寫操做的volatile修飾的變量進行重排序。

也就是說,volatile關鍵字不只能夠解決可見性問題,還能夠解決有序性問題。

最後,經過傳遞性。可知:

在這裏插入圖片描述

可知,圖示的三和五,就是咱們的目標。到此,咱們利用happens-before關係保證了代碼的可見性和有序性問題。

雖然分析的過程比較長,可是在原代碼中,咱們實際上只改動了一行代碼。即將static boolean finish = false;改成volatile static boolean finish = false;而已,就可使咱們的代碼改變線程安全的。

這就是運用volatile修飾變量來解決線程安全的辦法。volatile直接經過禁止相關的重排序來達到有序性的目的。

②、方法二:加鎖,synchronized

這個應該比較容易理解,對相關代碼加鎖後,同一時刻就只有一個線程在執行,也就至關於對相關變量的操做,是保證有序的。

不過synchronized並不像volatile同樣禁止指令重排序,實際上synchronized塊內部的代碼指令依然是能夠進行重排序優化的。

三、小結

  1. 多個線程對同一個共享變量進行讀寫操做時就可能產生不可預見的結果,就是線程安全問題。其重點是多線程對共享變量進行讀和寫,若是隻有讀,並不會有線程安全問題。
  2. 線程安全的緣由有:①線程切換帶來的原子性問題②緩存帶來的可見性問題③指令重排序帶來的原子性問題。
  3. 線程安全的解決辦法:①對於原子性問題,使用鎖synchronized和Lock、或者使用原子類(AtomicInteger等)②對於可見性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字③對於有序性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字

點個贊吧彥祖,(◕ᴗ◕✿)

因爲能力有限,可能存在錯誤,感謝並懇請老鐵們指出。以上內容爲本人在學習過程當中所作的筆記。參考的書籍、文章或博客以下:
[1]方騰飛,魏鵬,程曉明. Java併發編程的藝術[M].機械工業出版社.
[2]霍陸續,薛賓田. Java併發編程之美[M].電子工業出版社.
[3]mg驛站. 多線程篇-線程安全-原子性、可見性、有序性解析.知乎.https://zhuanlan.zhihu.com/p/142929863
[4]JAVA bx.Java併發的原子性、可見性、有序性.知乎.https://zhuanlan.zhihu.com/p/205335197
[5]程序員七哥.happens-before是什麼?JMM最最核心的概念,看完你就懂了.知乎.https://zhuanlan.zhihu.com/p/126275344

相關文章
相關標籤/搜索