多線程數據共享帶來的問題以及解決方案

小故事

  • 老王(操作系統)有一個功能強大的算盤(CPU),現在想把它租出去,賺一點外快
    在這裏插入圖片描述
  • 小男、小女(線程)來使用這個算盤來進行一些計算,並按照時間給老王支付費用
  • 但小男不能一天24小時使用算盤,他經常要小憩一會(sleep),又或是去喫飯上廁所(阻塞 io 操作),有
    時還需要一根菸,沒煙時思路全無(wait)這些情況統稱爲(阻塞)
    在這裏插入圖片描述
  • 在這些時候,算盤沒利用起來(不能收錢了),老王覺得有點不划算
  • 另外,小女也想用用算盤,如果總是小男佔着算盤,讓小女覺得不公平
  • 於是,老王靈機一動,想了個辦法 [ 讓他們每人用一會,輪流使用算盤 ]
  • 這樣,當小男阻塞的時候,算盤可以分給小女使用,不會浪費,反之亦然
  • 最近執行的計算比較複雜,需要存儲一些中間結果,而學生們的腦容量(工作內存)不夠,所以老王申請了
    一個筆記本(主存)把一些中間結果先記在本上
  • 計算流程是這樣的
    在這裏插入圖片描述
  • 但是由於分時系統,有一天還是發生了事故
  • 小男剛讀取了初始值 0 做了個 +1 運算,還沒來得及寫回結果
  • 老王說 [ 小男,你的時間到了,該別人了,記住結果走吧 ],於是小男唸叨着 [ 結果是1,結果是1…] 不甘心地到一邊待着去了(上下文切換)
  • 老王說 [ 小女,該你了 ],小女看到了筆記本上還寫着 0 做了一個 -1 運算,將結果 -1 寫入筆記本
  • 這時小女的時間也用完了,老王又叫醒了小男:[小男,把你上次的題目算完吧],小男將他腦海中的結果 1 寫
    入了筆記本
    在這裏插入圖片描述
  • 小男和小女都覺得自己沒做錯,但筆記本里的結果是 1 而不是 0

Java 的體現

兩個線程對初始值爲 0 的靜態變量一個做自增,一個做自減,各做 5000 次,結果是 0 嗎?

static int counter = 0;
public static void main(String[] args) throws InterruptedException {
 Thread t1 = new Thread(() -> {
 for (int i = 0; i < 5000; i++) {
 counter++;
 }
 }, "t1");
 Thread t2 = new Thread(() -> {
 for (int i = 0; i < 5000; i++) {
 counter--;
 }
 }, "t2");
 t1.start();
 t2.start();
 t1.join();
 t2.join();
 
}

問題分析

  • 以上的結果可能是正數、負數、零。爲什麼呢?因爲 Java 中對靜態變量的自增,自減並不是原子操作,要徹底理解,必須從字節碼來進行分析
    例如對於 i++ 而言(i 爲靜態變量),實際會產生如下的 JVM 字節碼指令:
getstatic i // 獲取靜態變量i的值
iconst_1 // 準備常量1
iadd // 自增
putstatic i // 將修改後的值存入靜態變量i

而對應i-- 也是類似:

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

而 Java 的內存模型如下,完成靜態變量的自增,自減需要在主存和工作內存中進行數據交換:
在這裏插入圖片描述
如果是單線程以上 8 行代碼是順序執行(不會交錯)沒有問題:
在這裏插入圖片描述
但多線程下這 8 行代碼可能交錯運行:

  • 出現負數的情況:
    在這裏插入圖片描述
  • 出現正數的情況
    在這裏插入圖片描述

臨界區 Critical Section

  • 一個程序運行多個線程本身是沒有問題的
  • 問題出在多個線程訪問共享資源
    • 多個線程讀共享資源其實也沒有問題
    • 在多個線程對共享資源讀寫操作時發生指令交錯,就會出現問題
  • 一段代碼塊內如果存在對共享資源的多線程讀寫操作,稱這段代碼塊爲臨界區

競態條件 Race Condition

多個線程在臨界區內執行,由於代碼的執行序列不同而導致結果無法預測,稱之爲發生了競態條件

synchronized 解決方案

爲了避免臨界區的競態條件發生,有多種手段可以達到目的。

  • 阻塞式的解決方案:synchronized,Lock
  • 非阻塞式的解決方案:原子變量

本次使用阻塞式的解決方案:synchronized,來解決上述問題,即俗稱的【對象鎖】,它採用互斥的方式讓同一

  • 時刻至多隻有一個線程能持有【對象鎖】,其它線程再想獲取這個【對象鎖】時就會阻塞住。這樣就能保證擁有鎖的線程可以安全的執行臨界區內的代碼,不用擔心線程上下文切換。

注意

  • 雖然 java 中互斥和同步都可以採用 synchronized 關鍵字來完成,但它們還是有區別的:
    互斥是保證臨界區的競態條件發生,同一時刻只能有一個線程執行臨界區代碼
    同步是由於線程執行的先後、順序不同、需要一個線程等待其它線程運行到某個點

解決方案

public class ThreadTest01 {
    static int counter = 0;
    static final Object room = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter++;
                    System.out.println(Thread.currentThread().getName()+"===> "+counter);
                }
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter--;
                    System.out.println(Thread.currentThread().getName()+"===> "+counter);

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

    }
}

在這裏插入圖片描述

代碼解釋

  • synchronized(對象) 中的對象,可以想象爲一個房間(room),有唯一入口(門)房間只能一次進入一人
    進行計算,線程 t1,t2 想象成兩個人
  • 當線程 t1 執行到 synchronized(room) 時就好比 t1 進入了這個房間,並鎖住了門拿走了鑰匙,在門內執行
    count++ 代碼
  • 這時候如果 t2 也運行到了 synchronized(room) 時,它發現門被鎖住了,只能在門外等待,發生了上下文切
    換,阻塞住了
  • 這中間即使 t1 的 cpu 時間片不幸用完,被踢出了門外(不要錯誤理解爲鎖住了對象就能一直執行下去哦),
    這時門還是鎖住的,t1 仍拿着鑰匙,t2 線程還在阻塞狀態進不來,只有下次輪到 t1 自己再次獲得時間片時才
    能開門進入
  • 當 t1 執行完 synchronized{} 塊內的代碼,這時候纔會從 obj 房間出來並解開門上的鎖,喚醒 t2 線程把鑰
    匙給他。t2 線程這時纔可以進入 obj 房間,鎖住了門拿上鑰匙,執行它的 count-- 代碼
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章