首頁 > 軟體

Java多執行緒之執行緒安全問題詳情

2022-06-02 18:01:16

前言:

本篇文章介紹的內容為Java多執行緒中的執行緒安全問題,此處的安全問題並不是指的像駭客入侵造成的安全問題,執行緒安全問題是指因多執行緒搶佔式執行而導致程式出現bug的問題。

1.執行緒安全概述

1.1什麼是執行緒安全問題

首先我們需要明白作業系統中執行緒的排程是搶佔式執行的,或者說是隨機的,這就造成執行緒排程執行時執行緒的執行順序是不確定的,有一些程式碼執行順序不同不影響程式執行的結果,但也有一些程式碼執行順序發生改變了重寫的執行結果會受影響,這就造成程式會出現bug,對於多執行緒並行時會使程式出現bug的程式碼稱作執行緒不安全的程式碼,這就是執行緒安全問題。

下面,將介紹一種典型的執行緒安全問題範例,整數自增問題。

1.2一個存線上程安全問題的程式

有一天,老師佈置了這樣一個問題:使用兩個執行緒將變數count自增10萬次,每個執行緒承擔5萬次的自增任務,變數count的初始值為0。 這個問題很簡單,最終的結果我們也能夠口算出來,答案就是10萬。 小明同學做事非常迅速,很快就寫出了下面的一段程式碼:

class Counter {
    private int count;
    public void increase() {
        ++this.count;
    }
    public int getCount() {
        return this.count;
    }
}

public class Main11 {
    private static final int CNT = 50000;
    private static final Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < CNT; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int j = 0; j < CNT; j++) {
                counter.increase();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(counter.getCount());
    }
}

按理來說,結果應該是10萬,我們來看看執行結果:

執行的結果比10萬要小,你可以試著執行該程式你會發現每次執行的結果都不一樣,但絕大部分情況,結果都會比預期的值要小,下面我們就來分析分析為什麼會這樣。

2.執行緒加鎖與執行緒不安全的原因

2.1案例分析

上面我們使用多執行緒執行了一個程式,將一個變數值為0的變數自增10萬次,但是最終實際結果比我們預期結果要小,原因就是執行緒排程的順序是隨機的,造成執行緒間自增的指令集交叉,導致執行時出現兩次自增但值只自增一次的情況,所以得到的結果會偏小。

我們知道一次自增操作可以包含以下幾條指令:

  • 將記憶體中變數的值載入到暫存器,不妨將該操作記為load
  • 在暫存器中執行自增操作,不妨將該操作記為add
  • 將暫存器的值儲存至記憶體中,不妨將該操作記為save

我們來畫一條時間軸,來總結一下常見的幾種情況:

情況1: 執行緒間指令集,無交叉,執行結果與預期相同,圖中暫存器A表示執行緒1所用的暫存器,暫存器B表示執行緒2所用的暫存器,後續情況同理。

情況2: 執行緒間指令集存在交叉,執行結果低於預期結果。

情況3: 執行緒間指令集完全交叉,實際結果低於預期。

根據上面我們所列舉的情況,發現執行緒執行時沒有交叉指令的時候執行結果是正常的,但是一旦有了交叉會導致自增操作的結果會少1,綜上可以得到一個結論,那就是由於自增操作不是原子性的,多個執行緒並行執行時很可能會導致執行的指令交叉,導致執行緒安全問題。

那如何解決上述執行緒不安全的問題呢?當然有,那就是對物件加鎖。

2.2執行緒加鎖

2.2.1什麼是加鎖

為了解決由於“搶佔式執行”所導致的執行緒安全問題,我們可以對操作的物件進行加鎖,當一個執行緒拿到該物件的鎖後,會將該物件鎖起來,其他執行緒如果需要執行該物件的任務時,需要等待該執行緒執行完該物件的任務後才能執行。

舉個例子,假設要你去銀行的ATM機存錢或者取款,每臺ATM機一般都在一間單獨的小房子裡面,這個小房子有一扇門一把鎖,你進去使用ATM機時,門會自動的鎖上,這個時候如果有人要來取款,那它得等你使用完並出來它才能進去使用ATM,那麼這裡的“你”相當於執行緒,ATM相當於一個物件,小房子相當於一把鎖,其他的人相當於其他的執行緒。

在java中最常用的加鎖操作就是使用synchronized關鍵字進行加鎖。

2.2.2如何加鎖

synchronized 會起到互斥效果, 某個執行緒執行到某個物件的 synchronized 中時, 其他執行緒如果也執行到同一個物件 synchronized 就會阻塞等待。 執行緒進入 synchronized 修飾的程式碼塊, 相當於 加鎖,退出 synchronized 修飾的程式碼塊, 相當於 解鎖

java中的加鎖操作可以使用synchronized關鍵字來實現,它的常見使用方式如下:

方式1: 使用synchronized關鍵字修飾普通方法,這樣會使方法所在的物件加上一把鎖。 例如,就以上面自增的程式為例,嘗試使用synchronized關鍵字進行加鎖,如下我對increase方法進行了加鎖,實際上是對某個物件加鎖,此鎖的物件就是this,本質上加鎖操作就是修改this物件頭的標記位。

class Counter {
    private int count;
    synchronized public void increase() {
        ++this.count;
    }
    public int getCount() {
        return this.count;
    }
}

多執行緒自增的main方法如下,後面會以相同的栗子介紹synchronized的其他用法,後面就不在列出這段程式碼了。

public class Main11 {
    private static final int CNT = 50000;
    private static final Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < CNT; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int j = 0; j < CNT; j++) {
                counter.increase();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(counter.getCount());
    }
}

看看執行結果:

方式

2: 使用synchronized關鍵字對程式碼段進行加鎖,但是需要顯式指定加鎖的物件。 例如:

class Counter {
    private int count;
    public void increase() {
        synchronized (this){
            ++this.count;
        }
    }
    public int getCount() {
        return this.count;
    }
}

執行結果:

方式3: 使用synchronized關鍵字修飾靜態方法,相當於對當前類的類物件進行加鎖。

class Counter {
    private static int count;
    synchronized public static void increase() {
        ++count;
    }
    public int getCount() {
        return this.count;
    }
}

執行結果:

常見的用法差不多就是這些,對於執行緒加鎖(執行緒拿鎖),如果兩個執行緒同時拿一個物件的鎖,就會產生鎖競爭,兩個執行緒同時拿兩個不同物件的鎖不會產生鎖競爭。 對於synchronized這個關鍵字,它的英文意思是同步,但是同步在計算機中是存在多種意思的,比如在多執行緒中,這裡同步的意思是“互斥”;而在IO或網路程式設計中同步指的是“非同步”,與多執行緒沒有半點的關係。

synchronized 的工作過程:

  • 獲得互斥鎖lock
  • 從主記憶體拷貝變數的最新副本到工作的記憶體
  • 執行程式碼
  • 將更改後的共用變數的值重新整理到主記憶體
  • 釋放互斥鎖unlock

synchronized 同步塊對同一條執行緒來說是可重入的,不會出現自己把自己鎖死的問題,即死鎖問題,關於死鎖後續文章再做介紹。

綜上,synchronized關鍵字加鎖有如下性質:互斥性,重新整理記憶體性,可重入性。synchronized關鍵字也相當於一把監視器鎖monitor lock,如果不加鎖,直接使用wait方法(一種執行緒等待的方法,後面細說),會丟擲非法監視器異常,引發這個異常的原因就是沒有加鎖。

2.2.3再析案例

對自增那個程式碼上鎖後,我們再來分析一下為什麼加上了所就執行緒安全了,先列程式碼:

class Counter {
    private int count;
    synchronized public void increase() {
        ++this.count;
    }
    public int getCount() {
        return this.count;
    }
}

public class Main11 {
    private static final int CNT = 50000;
    private static final Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < CNT; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int j = 0; j < CNT; j++) {
                counter.increase();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(counter.getCount());
    }
}

多執行緒並行執行時,上一次就分析過沒有指令集交叉就不會出現問題,因此這裡我們只討論指令交叉後,加鎖操作是如何保證執行緒安全的,不妨記加鎖為lock,解鎖為unlock,兩個執行緒執行過程如下: 執行緒1首先拿到目標物件的鎖,對物件進行加鎖,處於lock狀態,當執行緒2來執行自增操作時會發生阻塞,直到執行緒1的自增操作完畢,處於unlock狀態,執行緒2才會就緒取執行執行緒2的自增操作。

加鎖後執行緒就是序列執行,與單執行緒其實沒有很大的區別,那多執行緒是不是沒有用了呢?但是對方法加鎖後,執行緒執行該方法才會加鎖,執行完該方法就會自動解鎖,況且大部分操作並行執行是不會造成執行緒安全的,只有少部分的修改操作才會有可能導致執行緒安全問題,因此整體上多執行緒執行效率還是比單執行緒高得多。

2.3執行緒不安全的原因

首先,執行緒不安全根源是執行緒間的排程充滿隨機性,導致原有的邏輯被改變,造成執行緒不安全,這個問題無法解決,無可奈何。多個執行緒針對同一資源進行寫(修改)操作,並且針對資源的修改操作不是原子性的,可能會導致執行緒不安全問題,類似於資料庫的事務。

由於編譯器的優化,記憶體可見性無法保證,就是當執行緒頻繁地對同一個變數進行讀操作時,會直接從暫存器上讀值,不會從記憶體上讀值,這樣記憶體的值修改時,執行緒就感知不到該變數已經修改,會導致執行緒安全問題(這是編譯器優化的結果,現代的編譯器都有類似的優化不止於Java),因為相比於暫存器,從內容中讀取資料的效率要小的多,所以編譯器會盡可能地在邏輯不變的情況下對程式碼進行優化,單執行緒情況下是不會翻車的,但是多執行緒就不一定了,比如下面一段程式碼:

import java.util.Scanner;

public class Main12 {
    private static int isQuit;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while (isQuit == 0) {

            }
            System.out.println("執行緒thread執行完畢!");
        });
        thread.start();

        Scanner sc = new Scanner(System.in);
        System.out.println("請輸入isQuit的值,不為0執行緒thread停止執行!");
        isQuit = sc.nextInt();
        System.out.println("main執行緒執行完畢!");
    }
}

執行結果:

我們從執行結果可以知道,輸入isQuit後,執行緒thread沒有停止,這就是編譯器優化導致執行緒感知不到記憶體可見性,從而導致執行緒不安全。 我們可以使用volatile關鍵字保證記憶體可見性。 我們可以使用volatile關鍵字修飾isQuit來保證記憶體可見性。

import java.util.Scanner;

public class Main12 {
    volatile private static int isQuit;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while (isQuit == 0) {

            }
            System.out.println("執行緒thread執行完畢!");
        });
        thread.start();

        Scanner sc = new Scanner(System.in);
        System.out.println("請輸入isQuit的值,不為0執行緒thread停止執行!");
        isQuit = sc.nextInt();
        System.out.println("main執行緒執行完畢!");
    }
}

執行結果:

⭐️synchronized與volatile關鍵字的區別: synchronized關鍵字能保證原子性,但是是否能夠保證記憶體可見性要看情況(上面這個栗子是不行的),而volatile關鍵字只能保證記憶體可見性不能保證原子性。 保證記憶體可見性就是禁止編譯器做出如上的優化而已。

import java.util.Scanner;

public class Main12 {
    private static int isQuit;
    //鎖物件
    private static final Object lock = new Object();
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
                synchronized (lock) {
                    while (isQuit == 0) {

                    }
                    System.out.println("執行緒thread執行完畢!");
                }
        });
        thread.start();

        Scanner sc = new Scanner(System.in);
        System.out.println("請輸入isQuit的值,不為0執行緒thread停止執行!");
        isQuit = sc.nextInt();
        System.out.println("main執行緒執行完畢!");
    }
}

執行結果:

編譯器優化除了導致記憶體可見性感知不到的問題,還有指令重排序也會導致執行緒安全問題,指令重排序也是編譯器優化之一,就是編譯器會智慧地(保證原有邏輯不變的情況下)調整程式碼執行順序,從而提高程式執行的效率,單執行緒沒問題,但是多執行緒可能會翻車,這個原因瞭解即可。

3.執行緒安全的標準類

Java 標準庫中很多都是執行緒不安全的。這些類可能會涉及到多執行緒修改共用資料, 又沒有任何加鎖措施。例如,ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder。 但是還有一些是執行緒安全的,使用了一些鎖機制來控制,例如,Vector (不推薦使用),HashTable (不推薦使用),ConcurrentHashMap (推薦),StringBuffer。 還有的雖然沒有加鎖, 但是不涉及 "修改", 仍然是執行緒安全的,例如String。

線上程安全問題中可能你還會遇到JMM模型,在這裡補充一下,JMM其實就是把作業系統中的暫存器,快取和記憶體重新封裝了一下,其中在JMM中暫存器和快取稱為工作記憶體,記憶體稱為主記憶體。 其中快取分為一級快取L1,二級快取L2和三級快取L3,從L1到L3空間越來越大,最大也比記憶體空間小,最小也比暫存器空間大,存取速度越來越慢,最慢也比記憶體的存取速度快,最快也沒有暫存器存取快。

4.Object類提供的執行緒等待方法

除了Thread類中的能夠實現執行緒等待的方法,如join,sleep,在Object類中也提供了相關執行緒等待的方法。

序號方法說明
1public final void wait() throws InterruptedException釋放鎖並使執行緒進入WAITING狀態
2public final native void wait(long timeout) throws InterruptedException;相比於方法1,多了一個最長等待時間
3public final void wait(long timeout, int nanos) throws InterruptedException相比於方法2,等待的最長時間精度更大
4public final native void notify();喚醒一個WAITING狀態的執行緒,並加鎖,搭配wait方法使用
5public final native void notifyAll();喚醒所有處於WAITING狀態的執行緒,並加鎖(很可能產生鎖競爭),搭配wait方法使用

上面介紹synchronized關鍵字的時候,如果不對執行緒加鎖會產生非法監視異常,我們來驗證一下:

public class TestDemo12 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("執行完畢!");
        });

        thread.start();
        System.out.println("wait前");
        thread.wait();
        System.out.println("wait後");
    }
}

看看執行結果:

果然丟擲了一個IllegalMonitorStateException,因為wait方法的執行步驟為:先釋放鎖,再使執行緒等待,你現在都沒有加鎖,那如何釋放鎖呢?所以會丟擲這個異常,但是執行notify是無害的。

wait方法常常搭配notify方法搭配一起使用,前者能夠釋放鎖,使執行緒等待,後者能獲取鎖,使執行緒繼續執行,這套組合拳的流程圖如下:

現在有兩個任務由兩個執行緒執行,假設執行緒2比執行緒1先執行,請寫出一個多執行緒程式使任務1在任務2前面完成,其中執行緒1執行任務1,執行緒2執行任務2。 這個需求可以使用wait/notify來實現。

class Task{
    public void task(int i) {
        System.out.println("任務" + i + "完成!");
    }
}
public class WiteNotify {
    //鎖物件
    private static final Object lock = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            synchronized (lock) {
                Task task1 = new Task();
                task1.task(1);
                //通知執行緒2執行緒1的任務完成
                System.out.println("notify前");
                lock.notify();
                System.out.println("notify後");
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock) {
                Task task2 = new Task();
                //等待執行緒1的任務1執行完畢
                System.out.println("wait前");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                task2.task(2);
                System.out.println("wait後");
            }
        });
        thread2.start();
        Thread.sleep(10);
        thread1.start();
    }
}

執行結果:

到此這篇關於Java多執行緒之執行緒安全問題詳情的文章就介紹到這了,更多相關Java執行緒安全內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com