線程安全問題就在我身邊



  • 串行和並行

  • 線程安全常見錯誤

    • 運行結果錯誤

    • 發佈和初始化導致線程安全問題

    • 活躍性問題

  • 需要用到線程安全的場景

    • 訪問共享變量或資源

    • 依賴時序的操作

    • 不同數據之間存在綁定關係

    • 對方沒有聲明自己是線程安全的

  • 總結

  • 尾語


串行和並行

提到多線程這裏要說兩個概念,就是串行並行,搞清楚這個我們才能更好的理解多線程。

串行其實是相對於單條線程來執行多個任務來說的,我們就拿下載文件來舉個例子,我們下載多個文件,在串行中它是按照一定的順序去進行下載的,也就是說必須等下載完A之後,才能開始下載B,它們在時間上是不可能發生重疊的。

並行:下載多個文件,開啓多條線程,多個文件同時進行下載,這裏是嚴格意義上的在同一時刻發生的,並行在時間上是重疊的。

線程安全常見錯誤

線程安全是Java面試中的常客,而在Java中有一些類本身是線程安全的,這些類就是線程安全類,例如ConcurrentHashMap。但是有時候錯誤地使用線程安全類反而會出現線程不安全的情況。

對象線程安全的定義是:當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行問題,也不需要進行額外的同步,而調用這個對象的行爲都可以獲得正確的結果。

如果某個對象是線程安全的,那麼對於使用者而言,在使用時就不需要考慮方法間的協調問題,比如不需要考慮不能同時寫入或讀寫不能並行的問題,也不需要考慮任何額外的同步問題。

在擁有共享數據的多條線程並行執行的程序中,線程安全的代碼會通過同步機制如自己加 synchronized 鎖,保證各個線程都可以正常且正確的執行,不會出現數據污染等意外情況。

而我們在實際開發中經常會遇到線程不安全的情況,那麼一共有哪 3 種典型的線程安全問題呢?

  1. 運行結果錯誤;
  2. 發佈和初始化先後順序混亂導致線程安全問題;
  3. 活躍性問題,例如死鎖和活鎖。

運行結果錯誤

首先,來看多線程同時操作一個變量導致的運行結果錯誤。

public class WrongResult {
 
   volatile static int i;

   public static void main(String[] args) throws InterruptedException {
       Runnable r = new Runnable() {
           @Override
           public void run() {
               for (int j = 0; j < 10000; j++) {
                   i++;
               }
           }
       };
       Thread thread1 = new Thread(r);
       thread1.start();
       Thread thread2 = new Thread(r);
       thread2.start();
       thread1.join();
       thread2.join();
       System.out.println(i);
    }
}

理論上得到的結果應該是20000,但實際結果呢?

你可以去試驗10000次,應該沒有一次是20000。

結果可能是16326,也可能是12362,每次的結果都還不一樣,這是爲什麼呢?

是因爲在多線程下,CPU 的調度是以時間片爲單位進行分配的,這也意味着在短時間內很有可能有多個線程對某一個變量進行操作。

i++ 操作,表面上看只是一行代碼,但實際上它並不是一個原子操作,它的執行步驟主要分爲三步,而且在每步操作之間都有可能被打斷。

  1. 從內存中讀取變量;
  2. 增加變量;
  3. 將變量進行保存。

線程 1 首先拿到 i=1 的結果,然後進行 i+1 操作,但此時 i+1 的結果並沒有保存下來,線程 1 就被切換走了,於是 CPU 開始執行線程 2,它所做的事情和線程 1 是一樣的 i++ 操作。

實際上和線程 1 拿到的 i 的結果一樣都是 1,爲什麼呢?因爲線程 1 雖然對 i 進行了 +1 操作,但結果沒有保存,所以線程 2 看不到修改後的結果。

然後假設等線程 2 對 i 進行 +1 操作後,又切換到線程 1,讓線程 1 完成未完成的操作,即將 i+1 的結果 2 保存下來,然後又切換到線程 2 完成 i=2 的保存操作,雖然兩個線程都執行了對 i 進行 +1 的操作,但結果卻最終保存了 i=2 的結果,而不是我們期望的 i=3,這樣就發生了線程安全問題,導致了數據結果錯誤,這也是最典型的線程安全問題。

發佈和初始化導致線程安全問題

第二種是對象發佈和初始化時導致的線程安全問題。如果我們發佈和初始化的順序沒有同步,容易導致線程安全問題。

public class WrongInit {

 

    private Map<Integer, String> students;

 

    public WrongInit() {

        new Thread(new Runnable() {

            @Override

            public void run() {

                students = new HashMap<>();

                students.put(1"張三");

                students.put(2"李四");

                students.put(3"週五");

                students.put(4"趙六");

            }

        }).start();

     }

 

    public Map<Integer, String> getStudents() {

        return students;

    }

 

    public static void main(String[] args) throws InterruptedException {

        WrongInit multiThreadsError6 = new WrongInit();

        System.out.println(multiThreadsError6.getStudents().get(1));

 

    }

}

在類中,定義一個類型爲 Map 的成員變量 students,Integer 是學號,String 是姓名。然後在構造函數中啓動一個新線程,並在線程中爲 students 賦值。

  1. 學號:1,姓名:張三;
  2. 學號:2,姓名:李四;
  3. 學號:3,姓名:週五;
  4. 學號:4,姓名:趙六。

只有當線程運行完 run() 方法中的全部賦值操作後,4 名同學的全部信息纔算是初始化完畢,可是我們看在主函數 mian() 中,初始化 WrongInit 類之後並沒有進行任何休息就直接打印 1 號同學的信息。

試想這個時候程序會出現什麼情況?實際上會發生空指針異常。

Exception in thread "main" java.lang.NullPointerException

at lesson6.WrongInit.main(WrongInit.java:32)

這又是爲什麼呢?

因爲 students 這個成員變量是在構造函數中新建的線程中進行的初始化和賦值操作,而線程的啓動需要一定的時間,但是我們的 main 函數並沒有進行等待就直接獲取數據,導致 getStudents 獲取的結果爲 null,這就是在錯誤的時間或地點發布或初始化造成的線程安全問題。這說明初始化和發佈操作順序必須一致,才能避免此類問題。

活躍性問題

第三種線程安全問題統稱爲活躍性問題,最典型的有三種,分別爲死鎖、活鎖和飢餓

什麼是活躍性問題呢,活躍性問題就是程序始終得不到運行的最終結果,相比於前面兩種線程安全問題帶來的數據錯誤或報錯,活躍性問題帶來的後果可能更嚴重,比如發生死鎖會導致程序完全卡死,無法向下運行。

死鎖

最常見的活躍性問題是死鎖,死鎖是指兩個線程之間相互等待對方資源,但同時又互不相讓,都想自己先執行,如代碼所示。

 public class MayDeadLock {


    Object o1 = new Object();

    Object o2 = new Object();

 

    public void thread1() throws InterruptedException {

        synchronized (o1) {

            Thread.sleep(1000);

            synchronized (o2) {

                System.out.println("線程1成功拿到兩把鎖");

           }

        }

    }

 

    public void thread2() throws InterruptedException {

        synchronized (o2) {

            Thread.sleep(1000);

            synchronized (o1) {

                System.out.println("線程2成功拿到兩把鎖");

            }

        }

    }

 

    public static void main(String[] args) {

        MayDeadLock mayDeadLock = new MayDeadLock();

        new Thread(new Runnable() {

            @Override

            public void run() {

                try {

                    mayDeadLock.thread1();

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

            }

        }).start();

        new Thread(new Runnable() {

            @Override

            public void run() {

                try {

                    mayDeadLock.thread2();

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

            }

        }).start();

    }

}

首先,代碼中創建了兩個 Object 作爲 synchronized 鎖的對象,線程 1 先獲取 o1 鎖,sleep(1000) 之後,獲取 o2 鎖;線程 2 與線程 1 執行順序相反,先獲取 o2 鎖,sleep(1000) 之後,獲取 o1 鎖。

假設兩個線程幾乎同時進入休息,休息完後,線程 1 想獲取 o2 鎖,線程 2 想獲取 o1 鎖,這時便發生了死鎖,兩個程序既不會主動停止申請,也不會釋放自己本身持有的資源,處於循環等待的過程中。

在死鎖發生時,必然存在一個“進程-資源環形鏈”,即:{p0,p1,p2,…pn},進程p0(或線程)等待p1佔用的資源,p1等待p2佔用的資源,pn等待p0佔用的資源。(最直觀的理解是,p0等待p1佔用的資源,而p1而在等待p0佔用的資源,於是兩個進程就相互等待)

這種死鎖的出現解決方案是什麼?

申請鎖的時候執行順序要一致,每個線程的申請順序都固定爲先申請o1,再申請o2。這樣可以避免死鎖情況的發生。

活鎖

活鎖指的是任務或者執行者沒有被阻塞,由於某些條件沒有滿足,導致一直重複嘗試—失敗—嘗試—失敗的過程。

假設有一個消息隊列,隊列裏放着各種各樣需要被處理的消息,而某個消息由於自身被寫錯了導致不能被正確處理,執行時會報錯,可是隊列的重試機制會重新把它放在隊列頭進行優先重試處理,但這個消息本身無論被執行多少次,都無法被正確處理,每次報錯後又會被放到隊列頭進行重試,週而復始,最終導致線程一直處於忙碌狀態,便發生了活鎖問題。

飢餓

飢餓是指線程需要某些資源時始終得不到。在 Java 中有線程優先級的概念,Java 中優先級分爲 1 到 10,1 最低,10 最高。如果我們把某個線程的優先級設置爲 1,這是最低的優先級,在這種情況下,這個線程就有可能始終分配不到 CPU 資源,而導致長時間無法運行。或者是某個線程始終持有某個文件的鎖,而其他線程想要修改文件就必須先獲取鎖,這樣想要修改文件的線程就會陷入飢餓,長時間不能運行。

需要用到線程安全的場景

訪問共享變量或資源

第一種場景是訪問共享變量或共享資源的時候,典型的場景有訪問共享對象的屬性,訪問 static 靜態變量,訪問共享的緩存,等等。因爲這些信息不僅會被一個線程訪問到,還有可能被多個線程同時訪問,那麼就有可能在併發讀寫的情況下發生線程安全問題。比如多線程同時 i++ 的例子:

/**

 * 描述:     共享的變量或資源帶來的線程安全問題

 */


public class ThreadNotSafe1 {



    static int i;



    public static void main(String[] args) throws InterruptedException {

        Runnable r = new Runnable() {

            @Override

            public void run() {

                for (int j = 0; j < 10000; j++) {

                    i++;

                }

            }

        };

        Thread thread1 = new Thread(r);

        Thread thread2 = new Thread(r);

        thread1.start();

        thread2.start();

        thread1.join();

        thread2.join();

        System.out.println(i);

    }

}

如代碼所示,兩個線程同時對 i 進行 i++ 操作,最後的輸出可能是 15875 等小於20000的數,而不是我們期待的20000,這便是非常典型的共享變量帶來的線程安全問題。

依賴時序的操作

第二個需要我們注意的場景是依賴時序的操作,如果我們操作的正確性是依賴時序的,而在多線程的情況下又不能保障執行的順序和我們預想的一致,這個時候就會發生線程安全問題

if (map.containsKey(key)) {

    map.remove(obj)

}

代碼中首先檢查 map 中有沒有 key 對應的元素,如果有則繼續執行 remove 操作。

此時,這個組合操作就是危險的,因爲它是先檢查後操作,而執行過程中可能會被打斷。

如果此時有兩個線程同時進入 if() 語句,然後它們都檢查到存在 key 對應的元素,於是都希望執行下面的 remove 操作,隨後一個線程率先把 obj 給刪除了,而另外一個線程它剛已經檢查過存在 key 對應的元素,if 條件成立,所以它也會繼續執行刪除 obj 的操作,但實際上,集合中的 obj 已經被前面的線程刪除了,這種情況下就可能導致線程安全問題。

不同數據之間存在綁定關係

第三種需要我們注意的線程安全場景是不同數據之間存在相互綁定關係的情況。

有時候,我們的不同數據之間是成組出現的,存在着相互對應或綁定的關係,最典型的就是 IP 和端口號。有時候我們更換了 IP,往往需要同時更換端口號,如果沒有把這兩個操作綁定在一起,就有可能出現單獨更換了 IP 或端口號的情況,而此時信息如果已經對外發布,信息獲取方就有可能獲取一個錯誤的 IP 與端口綁定情況,這時就發生了線程安全問題。在這種情況下,我們也同樣需要保障操作的原子性。

對方沒有聲明自己是線程安全的

第四種值得注意的場景是在我們使用其他類時,如果對方沒有聲明自己是線程安全的,那麼這種情況下對其他類進行多線程的併發操作,就有可能會發生線程安全問題。舉個例子,比如說我們定義了 ArrayList它本身並不是線程安全的,如果此時多個線程同時對 ArrayList 進行併發讀/寫,那麼就有可能會產生線程安全問題,造成數據出錯,而這個責任並不在 ArrayList,因爲它本身並不是併發安全的,正如源碼註釋所寫的:

Note that this implementation is not synchronized. 
If multiple threads access an ArrayList instance concurrently, 
and at least one of the threads modifies the list structurally, it must be synchronized externally.

這段話的意思是說,如果我們把 ArrayList 用在了多線程的場景,需要在外部手動用 synchronized 等方式保證併發安全。

所以 ArrayList 默認不適合併發讀寫,是我們錯誤地使用了它,導致了線程安全問題。所以,我們在使用其他類時如果會涉及併發場景,那麼一定要首先確認清楚,對方是否支持併發操作

總結

線程安全問題是多線程併發的重中之重,這裏只是講了幾個有可能存在線程安全的例子,就此也打開了線程併發的學習。接下來將講述一些關於Java中的一些鎖機制和案例,敬請期待。


       
       
       
— 【 THE END 】—
本公衆號全部博文已整理成一個目錄,請在公衆號裏回覆「 m 」獲取!


3T技術資源大放送!包括但不限於:Java、C/C++,Linux,Python,大數據,人工智能等等。在公衆號內回覆「1024」,即可免費獲取!!





本文分享自微信公衆號 - 程序員書單(CoderBooklist)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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