Java 多線程王國奇遇記

第一回 江湖救急

NPR:“歡迎來到多線程的國度,勇士!”

你:“你你你,你不是正則王國的 NPC 嗎?怎麼又跑到多線程王國來了?”

NPR:“呃,你認錯人了,那是我的雙胞胎弟弟,我是他的哥哥 NPR。”

你:“你可別唬我,我記得 NPC 是 Non-Player Character 的意思,非遊戲角色都叫 NPC,你這 NPR 是個啥?”

NPR:“I’m Non-Player Rule,也是非遊戲角色的一種哦。”

NPR 一臉天真無邪的微笑望着你,一時間你竟分不清這個所謂的 NPR 是真是假。

你:“行吧,先不管那麼多了,我到你們王國來,實在是江湖救急,有事相求。”

NPR:“說來聽聽。”每天來多線程王國求助的人絡繹不絕,NPR 早已見怪不怪。

你:“我寫了一段讀寫程序,但讀寫後的結果總是不對,可我怎麼看邏輯都是正確的,您能幫我看看嗎?”

說着,你亮出了自己寫的代碼:

public class Client {
    private int number = 0;

    private void read() {
        System.out.println("number = "+ number);
    }

    private void write(int change) {
        number += change;
    }

    @Test
    public void test() throws InterruptedException {
        // 開啓一個線程加 10000 次
        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                write(1);
            }
            System.out.println("增加 10000 次已完成");
        }).start();

        // 開啓一個線程減 10000 次
        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                write(-1);
            }
            System.out.println("減少 10000 次已完成");
        }).start();

        // 睡眠一秒保證線程執行完成
        Thread.sleep(1000);
        // 讀取結果
        read();
    }
}

你:“這段代碼簡單得不能再簡單了,這個類裏只有一個 number 變量,我開啓了一個新線程將它加了 10000 次,又開了一個線程讓它減 10000 次,按理說結果肯定是 0,但我執行時,每次結果都不一樣,就沒有一次是 0。”

增加 10000 次已完成
減少 10000 次已完成
number = -981
  
增加 10000 次已完成
減少 10000 次已完成
number = 92

第二回 宇宙射線?

“我已經反反覆覆看了好多遍了”,你重複道,“可我怎麼看都沒有錯,我想要麼是我電腦的硬件問題,要麼是 Java 基礎庫出了問題,或者是由於太陽黑子最近比較活躍,干擾了宇宙射線導致的。”

NPR 睜大了眼睛:“宇宙射線?哈,真是個毛頭小子,程序可不是物理世界。看來你還不知道 Java 編程第一法則。”

說着,NPR 身前亮出幾個鎏金大字:

Java 編程第一法則:程序出問題時,從自己的代碼找原因,永遠不要懷疑 Java 基礎庫。🐶

NPR:“在我們多線程王國的人看來,你的這段代碼錯得很明顯。當多個線程同時進行讀寫操作時,要保證結果正確,必須保證每一步操作都是原子操作。”

你:“原子操作?剛纔你還說程序不是物理世界,現在怎麼扯到原子了。”

NPR:“原子是元素能存在的最小單位,也就是說原子是不可分割的。這裏借鑑了原子的概念,原子操作是指不能被中斷的一個或一系列操作。”

你:“我還是不太明白,您可以先給我解釋一下我的程序爲什麼出錯嗎?”

NPR:“沒問題。你可知道,在你的 write 方法執行時,實際上會執行三條語句。”

ILOAD
IADD
ISTORE

NPR:“這三條語句的意思是:程序先從主內存中拷貝一個 number 的副本到本地線程中,增加後再回寫到主內存。所以兩個線程同時執行 write(1) 和 write(-1) 時可能出現這樣一種情況:

  • 第一個線程拷貝了主內存中的 number,假設此時主內存中 number 的值爲 0
  • 第一個線程被操作系統暫停
  • 操作系統調度了第二個線程,第二個線程拷貝了主內存中的 number,值爲 0
  • 第二個線程將本地副本中的 number 減少 1,第二個線程的本地副本中的 number 變爲 -1
  • 第二個線程中的值回寫到主內存,主內存中的 number 變成 -1
  • 第一個線程被系統繼續調度,本地副本中的 number 增加 1,第一個線程的本地副本中的 number 變爲 1
  • 第一個線程中的值回寫到主內存,number 變成 1

你看,執行了一次 +1,執行了一次 -1,因爲不是原子操作,第一個線程被系統中斷了一次,導致兩次運算的最終結果不是 0,而是 1。”

NPR:“同樣的,還存在另一種情況,如果第二個線程拷貝了副本後,第一個線程先回寫到主內存,number 變成 1 ,然後第二個線程中的 -1 回寫主內存,就會導致結果變成 -1,所以說你執行多次,有時候大於 0 ,有時候小於 0。就是因爲這個原因。”

你:“原來如此!”

第三回 獨一無二的鑰匙

你:“可是爲什麼操作系統不把我一個線程執行完後,再去執行另一個線程呢?這樣就不會有這個問題了。”

NPR:“那是因爲操作系統需要提高電腦運行效率。線程的調度完全是由操作系統決定的,程序不能自己決定什麼時候執行,以及執行多長時間。

你:“那就沒有什麼其他辦法了嗎?多個線程同時讀寫是一個很常見的需求啊,不能自己控制豈不是漏洞百出?”

NPR:“辦法當然有,試想一下,如果我們製造一把鑰匙,這把鑰匙獨一無二。在一個線程執行 write 前,先檢查這個線程有沒有拿到鑰匙,有鑰匙的話我們才讓它執行,執行完再把鑰匙交出來。沒有拿到鑰匙的線程就先等待鑰匙。這樣是不是就能保證一次只有一個線程能執行 write 了。”

你:“有點意思,你說的這個鑰匙像是一個通行證,因爲通行證只有一個,所以每次只有一個線程能拿到通行證,確實能保證一次只有一個線程執行。”

NPR:“你可以嘗試用僞代碼實現它嗎?”

你:“沒問題,代碼應該是類似這樣的。”

private final Object 鑰匙 = new 鑰匙;
private void write(int change) {
    if(拿到了鑰匙) {
        number += change;
        執行完畢,交出鑰匙;
    }
}

NPR:“沒錯,Java 裏的 sychronized 關鍵字就是用來實現這個功能的,實際代碼和這個差不多。”

private final Object key = new Object();
private void write(int change) {
    synchronized (key) {
        number += change;
    }
}

你:“爲什麼沒有看到交出鑰匙的代碼呢?”

NPR:“因爲交出鑰匙是每次都會執行的操作,所以被封裝到 synchronized 中了,當程序執行到 synchronized 的 } 時,就會交出鑰匙。另外,在我們多線程王國,一般不把它稱之爲鑰匙,而是按照它的功能,將其稱之爲。”

private final Object lock = new Object();
private void write(int change) {
    synchronized (lock) {
        number += change;
    }
}

你:“我把我的程序中的 write 方法換成這樣,果然每次執行都是 0 了。真是太感謝您了,NPR 先生!”

NPR:“別客氣,先別高興地太早,現在我們給 write 方法加上鎖後,寫入時沒有問題了,讀取時還是有問題的。”

你:“我想一想,現在讀取時不需要獲取鑰匙,所以讀取時可以直接將主內存中的 number 值拷貝到自己的工作內存。而此時有可能存在線程正在寫入值,這就會導致讀取線程無法讀到寫入後的最新值!”

NPR:“沒錯,就是這個問題,我們寫個測試類來驗證一下。”

public class Client {
    private int number = 0;
    private final Object lock = new Object();

    private void read() {
        System.out.println("number = " + number);
    }

    private void write(int change) {
        synchronized (lock) {
            number += change;
            System.out.println("寫入 " + number);
        }
    }

    @Test
    public void test() throws InterruptedException {
        // 開啓一個線程寫入 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                write(1);
            }
        }).start();

        // 開啓一個線程讀取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保證線程執行完成
        Thread.sleep(1000);
    }
}

運行結果如下:

...省略
寫入 43
寫入 44
寫入 45
number = 36
寫入 46
寫入 47
寫入 48
寫入 49
寫入 50
寫入 51
寫入 52
寫入 53
number = 46
寫入 54
number = 54
寫入 55
寫入 56
...省略

你:“果然如此,所以我要給 read 中的代碼也加上判斷,它也要拿到鑰匙後才能讀取,這樣就能保證讀取時不會有寫操作,寫的時候也沒有讀取操作了。”

private void read() {
    synchronized (lock) {
        System.out.println("number = " + number);
    }
}

運行結果如下:

寫入 1
寫入 2
寫入 3
...
寫入 13
number = 13
number = 13
number = 13
number = 13
寫入 14
寫入 15
...
寫入 80
number = 80
number = 80
...
number = 80
寫入 81
寫入 82
...
寫入 99
寫入 100
number = 100
number = 100
...
number = 100

NPR:“很好,運行結果沒有錯,說明我們確實解決了多線程競爭的問題。但這樣的流程往往並不是我們想要的。更常見的需求是寫入全部完成後,再去讀取值。”

第四回 死局

你:“沒錯,不過這難不倒我,我可以增加一個標誌位來實現這個功能。”

public class Client {
    private int number = 0;
    private final Object lock = new Object();
    // 標誌是否寫入完成
    private boolean writeComplete = false;

    private void read() {
        synchronized (lock) {
            // 如果還沒有寫入完成,循環等待直到寫入完成
            while (!writeComplete) {
                System.out.println("等待寫入完成...");
            }
            System.out.println("number = " + number);
        }
    }

    private void write(int change) {
        synchronized (lock) {
            number += change;
            System.out.println("寫入 " + number);
        }
    }

    @Test
    public void test() throws InterruptedException {
        // 開啓一個線程寫入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
        }).start();

        // 開啓一個線程讀取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保證線程執行完成
        Thread.sleep(1000);
    }
}

你:”我增加了一個標誌位 writeComplete,在寫入完成後,將它置爲 true,讀取時循環等待,直到它變成 true 時纔讀取結果。“

NPR 不置可否,說道:“你運行一下試試。”

運行結果:

寫入 1
寫入 2
寫入 3
寫入 4
寫入 5
寫入 6
寫入 7
寫入 8
寫入 9
寫入 10
寫入 11
寫入 12
等待寫入完成...
等待寫入完成...
等待寫入完成...
等待寫入完成...
... 省略 20 多萬次 "等待寫入完成..."

你:“???爲什麼寫入一會之後,就一直卡在等待寫入完成?”

NPR 擡頭看了看天,若有所思地說:“據說太陽黑子每 11 年活躍一次,上一次活躍還是 2009 年,最近也差不多該發出新一輪的宇宙射線了。”

你翻了個白眼,感嘆道:“你和你的雙胞胎弟弟還真是如出一轍,都喜歡陰陽怪氣地取笑人。快給我講講我的程序是哪裏出了問題吧!”

NPR:“哈哈,看來你終於領悟了 Java 編程第一法則。這次的問題在於現在讀寫方法都需要獲取鎖,一旦進入 read 函數,write 函數就必須等待,直到 read 函數釋放鎖。這會導致什麼問題?”

你:“ write 函數在等待 read 函數,write 函數中的循環無法執行完,那麼 writeComplete 就無法被置爲 true,所以 read 函數就會無限循環。啊,這樣就陷入死局了!這可怎麼辦?”

第五回 破局

NPR 微微一笑,身前再次亮起幾個鎏金大字:

Java 編程第二法則:當你無法解決問題的時候,往往說明了現有知識量儲備不足。🐶

NPR:“單用 synchronized 是無法實現這個功能的,但在解決問題之前,我們最好先搞清楚我們想要的是什麼。”

你:“我想要的效果是:寫入操作不受限制;如果寫入還沒有完成,read 方法先進入等待狀態。write 方法寫入完成後,通知 read 開始讀取。”

NPR:“很好,像上次一樣,先嚐試寫一下僞代碼吧!”

你:“好的,我想 read 方法中應該有一個等待方法,並且這個等待方法不能阻塞寫入過程。”

private void read() {
    synchronized (lock) {
        // 如果還沒有寫入完成,循環等待直到寫入完成
        while (!writeComplete) {
            等待,並且不要阻塞寫入
        }
        System.out.println("number = " + number);
    }
}

你:“在寫線程寫完後,喚醒讀取線程繼續讀取。”

 // 開啓一個線程寫入 100 次 number
 new Thread(() -> {
     writeComplete = false;
     for (int i = 0; i < 100; i++) {
         write(1);
     }
     writeComplete = true;
     寫入完成,喚醒讀取線程
 }).start();

NPR:“沒錯,我的勇士,你在多線程編程上真是有天賦!你寫出的正是等待/喚醒機制設計者的設計思路。這個等待方法叫做 wait(),喚醒方法叫做 notify()。唯一需要注意的一點是,等待與喚醒操作必須在鎖的範圍內執行,也就是說,調用 wait() 或 notify() 時,都必須用 synchronized 鎖住被 wait/notify 的對象。”

public class Client {
    private int number = 0;
    private final Object lock = new Object();
    // 標誌是否寫入完成
    private boolean writeComplete = false;

    private void read() {
        synchronized (lock) {
            // 如果還沒有寫入完成,循環等待直到寫入完成
            while (!writeComplete) {
                // 等待,並且不要阻塞寫入
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("number = " + number);
        }
    }

    private void write(int change) {
        synchronized (lock) {
            number += change;
            System.out.println("寫入 " + number);
        }
    }

    @Test
    public void test() throws InterruptedException {
        // 開啓一個線程寫入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
            // 寫入完成,喚醒讀取線程,wait/notify 操作必須在 synchronized 中執行。
            synchronized (lock) {
                lock.notify();
            }
        }).start();

        // 開啓一個線程讀取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保證線程執行完成
        Thread.sleep(1000);
    }
}

你:“現在運行果然沒有問題了,達到了我預期的效果。”

寫入 1
寫入 2
寫入 3
...
寫入 100
number = 100
number = 100
...
number = 100

NPR:“不錯,由於本例中只有一個線程在等待,所以我們只需要使用 notify() 函數,如果有多個線程需要喚醒,我們應該用 notifyAll() 函數。實際上,工作中往往都是使用 notifyAll() 函數。”

你:“是爲了防止某些線程由於沒有被喚醒一直等待嗎?”

NPR:“沒錯。很好,你已經掌握了我們多線程王國的 synchronized 關鍵字和 wait/notify 機制,但我想告訴你,synchronized 並不是一個很好的加鎖方案。”

你:“啊?我覺得 synchronized 已經很好用了啊,簡直是一個相當偉大的發明,它還有什麼缺點嗎?”

NPR:“年輕人啊,真是健忘。你剛纔已經遇到了一次 synchronized 的缺點,由於使用了 synchronized,你剛纔的程序陷入了死循環中。”

你:“的確,但那是我代碼邏輯沒有考慮清楚導致的,不能讓 synchronized 背鍋吧!”

“‘如果不曾見過太陽,我本可以忍受黑暗’”。NPR 突然吟誦起狄金森的詩句,“這樣的死循環完全是可以避免的,如果你使用過更加優秀的加鎖工具,可能就不會再覺得 synchronized 有多好了。”

第六回 併發大師 Doug Lea

NPR 凝望着天空,思緒彷彿回到了多年以前,你驚異的發現 NPR 眼中竟閃爍出崇拜的光芒。

只聽 NPR 娓娓道來:“很早以前,我們王國只有 synchronized 關鍵字可以使用,每個 Java 程序員必須小心翼翼,生怕線上的程序陷入無盡等待。而 synchronized 又是一個很重的操作,爲了優化 synchronized 的效率,一代又一代的程序員們做了非常多的努力,但併發始終是一個艱難又讓人頭疼的問題。直到後來,併發大師 Doug Lea 的出現,這個鼻樑掛着眼鏡,留着德王威廉二世的鬍子,臉上永遠掛着謙遜靦腆笑容的老大爺,親自操刀設計了 Java 併發工具包 java.util.concurrent。這套工具在 Java 5 中被引入,從此以後,Java 併發變得相當容易。”

作爲一名年輕的 Java 工程師,你實在很難代入 NPR 的情緒中,只是簡單地說道:“哦,那他很棒棒哦!他的這個工具包要怎麼用呢?”

NPR:“有了它,我們可以用 ReentrantLock 類替代 synchronized 關鍵字。”

使用 synchronized 的代碼:

public class Client {
    private int number = 0;
    private final Object lock = new Object();

    private void write(int change) {
        synchronized (lock) {
            number += change;
        }
    }
}

使用 ReentrantLock 的代碼:

public class Client {
    private int number = 0;
    private final ReentrantLock lock = new ReentrantLock();

    private void write(int change) {
        lock.lock();
        number += change;
        lock.unlock();
    }
}

剛聽 NPR 吹了半天,以爲這個工具包會有多牛的你,盯着這段代碼端詳了半天,卻根本沒看出有多大區別,忍不住吐槽道:“恕我直言,看起來完全沒有那麼神奇,只是把 synchronized 關鍵字換成了 ReentrantLock 類而已。”

NPR:“當然,這只是替換,ReentrantLock 的優勢在於,它可以設置嘗試獲取鎖的等待時間,超過等待時間便不再嘗試獲取鎖,這在實際開發中非常有用。”

public class Client {
    private int number = 0;
    private final ReentrantLock lock = new ReentrantLock();

    private void write(int change) throws InterruptedException {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            number += change;
            lock.unlock();
        } else {
            System.out.println("1 秒內沒有獲取到鎖,不再等待。");
        }
    }
}

你:“原來如此,看來 ReentrantLock 比 synchronized 更安全,可以完全避免無限等待。小 Doug 還是有一點實力的。”

NPR:“再來看看 ReentrantLock 是怎麼實現 wait/notify 功能的,感受一下它的第二個優勢。”

第七回 睡覺記得定鬧鐘

NPR:“在使用 ReentrantLock 時,我們通過一個叫做 Condition 的類實現 wait/notify,與之對應的方法爲 await/signal,我仍然會從最簡單的開始,先將之前使用 wait/notify 的代碼替換爲用 Condition 實現。”

public class Client {
    private int number = 0;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    // 標誌是否寫入完成
    private boolean writeComplete = false;

    private void read() {
        lock.lock();
        // 如果還沒有寫入完成,循環等待直到寫入完成
        while (!writeComplete) {
            // 等待,並且不要阻塞寫入
            try {
                condition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("number = " + number);
        lock.unlock();
    }

    private void write(int change) {
        lock.lock();
        number += change;
        System.out.println("寫入 " + number);
        lock.unlock();
    }

    @Test
    public void test() throws InterruptedException {
        // 開啓一個線程寫入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
            // 寫入完成,喚醒讀取線程,await/signal 操作必須在 lock 時執行。
            lock.lock();
            condition.signal();
            lock.unlock();
        }).start();

        // 開啓一個線程讀取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保證線程執行完成
        Thread.sleep(1000);
    }
}

你:“看起來並不難,通過 ReentrantLock 的 newCondition 方法創建出 Condition 類,wait 方法替換成了 Condition 的 await 方法,notify 方法替換成了 Condition 的 signal 方法。我試着運行了一下,運行結果和之前一模一樣。”

寫入 1
寫入 2
寫入 3
...
寫入 100
number = 100
number = 100
...
number = 100

NPR:“另外,Condition 中對應 notifyAll 的方法是 signalAll 。”

你:“這麼看來,ReentrantLock 和 Condition 結合,確實可以完全替代 synchronized 和 wait/notify。並且 ReentrantLock 相比 sychronized 還有一個獨到的優點,那就是可以設置嘗試獲取鎖的等待時間。”

NPR:“獨到的優點~我喜歡這個詞!很好地描述出 ReentrantLock 拓展了 synchronized 沒有的功能。其實,Condition 也有兩個獨到的優點~你不妨猜猜看是什麼。”

你:“讓我想想,嗯…ReentrantLock 可以設置等待時間…莫非 Condition 可以設置醒來的時間?”

NPR:“你真令我驕傲,勇士!完全正確,Condition 的 await(long l, TimeUnit timeUnit) 方法就是用來實現這個功能的。”

if (condition.await(1, TimeUnit.SECOND)) {
    // 1 秒內被 signal 喚醒
} else {
    // 1 秒內沒有被喚醒,自己醒來
}

你:“哈哈,這就像線程睡覺前,先給自己定了一個鬧鐘,如果沒人喚醒自己,就自己醒過來,真是太有趣了!”

NPR:“一個定時喚醒自己的鬧鐘,非常棒的理解!”

你:“您剛纔說 Condition 有兩個獨到的優點,那另一個是什麼呢?”

NPR:“Condition,譯爲情境、狀況。當我們在不同的情境下,通過使用多個不同的 Condition,再調用不同 Condition 的 signal 方法,就可以喚醒自己想要喚醒的某個或某一部分線程。”

你:“妙啊,這樣的同步操作真是太靈活了!我逐漸感受到 Doug Lea 的大師魅力了!”

這時,NPR 眼中再次泛起崇拜的光芒:“偉大的 Doug Lea,我們多線程王國的一顆巨星,渾身散發着睿智的光芒,億萬 Java 程序員心中的夢。”

你聽得起了一身的雞皮疙瘩,忽而眉頭一皺,發現事情並不是這麼簡單。思索片刻後,你對 NPR 說道:“我隱隱覺得現在的流程還不夠完美,ReentrantLock 好像還得再優化一下。”

第八回 更進一步

NPR 饒有興趣的看着你,問道:“何出此言?”

你:“打個比方,將讀操作比作瀏覽一個網頁,寫操作比作修改這個網頁的內容。當多個用戶瀏覽一個網頁時,由於讀操作被加了鎖,大家必須排隊依次瀏覽,這會嚴重影響效率。”

NPR 再次豎起大拇指:“很不錯,我的勇士!這正是可以優化的地方。我們回到之前討論過的問題:讀操作真的必須鎖嗎?”

你:“必須鎖啊,我們剛纔做了實驗,如果讀操作不鎖的話,會導致無法及時讀取到最新值。”

NPR:“梳理一下我們的需求,其實我們想要的是這樣的效果。”

  • 當有寫操作時,其他線程不能讀取也不能寫入。
  • 當沒有寫操作時,允許多個線程同時讀取,以提高併發效率。

你:“對對對,這就是我想要的優化。可是要怎麼做呢?我沒有想到一個好的實現思路。”

NPR:“Doug Lea 早已考慮到這一點,並且爲我們提供了一個使用非常方便的工具類,名字叫 ReadWriteLock。”

public class Client {
    private int number = 0;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();
    private final Condition condition = writeLock.newCondition();
    // 標誌是否寫入完成
    private boolean writeComplete = false;

    private void read() {
        readLock.lock();
        // 如果還沒有寫入完成,循環等待直到寫入完成
        while (!writeComplete) {
            // 等待,並且不要阻塞寫入
            try {
                condition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("number = " + number);
        readLock.unlock();
    }

    private void write(int change) {
        writeLock.lock();
        number += change;
        System.out.println("寫入 " + number);
        writeLock.unlock();
    }

    @Test
    public void test() throws InterruptedException {
        // 開啓一個線程寫入 100 次 number
        new Thread(() -> {
            writeComplete = false;
            for (int i = 0; i < 100; i++) {
                write(1);
            }
            writeComplete = true;
            // 寫入完成,喚醒讀取線程,await/signal 操作必須在 lock 時執行。
            writeLock.lock();
            condition.signal();
            writeLock.unlock();
        }).start();

        // 開啓一個線程讀取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保證線程執行完成
        Thread.sleep(1000);
    }
}

NPR:“只需定義一個 ReentrantReadWriteLock 變量,讀取時使用 readLock,寫入時使用 writeLock,這個工具就會幫我們完成剩下的所有工作,達到的就是我們之前討論的效果:單獨寫、一起讀。”

你:“666,ReadWriteLock,啊!這就是大名鼎鼎的讀寫鎖!”

NPR:“沒錯,ReadWriteLock 爲我們優化了讀與讀之間互斥的問題。

這時,你露出了羞赧的神色,撓着腦袋說道:“其實我以前也聽過讀寫鎖,可總覺得它是一個很難的東西,所以自己一直沒有掌握。”

NPR:“嗯,我見過很多來我這裏的多線程新手,他們畏難情緒太重,總是被我們王國的各種專業名詞嚇到,實際上這些可愛的工具類們都不難。畢竟工具是用來服務大衆的,API 設計之初就要考慮到使用時稱不稱手。”

第九回 做人最重要的就是開心

你:“ReadWriteLock 就是最完美的鎖了嗎?”

NPR:“不是的,我們還可以更進一步優化。”

你:“竟然還可以優化?我實在是想不到哪裏還能優化了。”

NPR:“同樣以你剛纔的例子打比方,使用 ReadWriteLock 會出現一個問題,當多個用戶一起瀏覽網頁時,如果有網頁修改操作,必須等待所有人瀏覽完成後,才能修改。”

你:“使用 ReadWriteLock 會導致寫線程必須等待讀線程完成後才能寫?這是個坑啊。”

NPR:“沒錯,讀的過程中不允許寫,我們稱這樣的鎖爲悲觀鎖。”

你:“程序又沒有感情,怎麼還悲觀起來了。”

NPR:“悲觀鎖是和樂觀鎖相對的,如果不是樂觀鎖的出現,人們也不會發覺現在的鎖是悲觀的。”

你:“樂觀鎖又是什麼?”

NPR:“樂觀鎖的特點是,讀的過程中也允許寫。”

你:“啊?這不是會出問題嗎?就像我們剛纔測試的那樣,萬一讀的過程中寫線程拿到寫鎖後將值修改了,讀的數據就錯了啊。”

NPR:“你說得沒錯,但只要我們樂觀地估計讀的過程中不會有寫入,就不會出問題了。幾乎所有的數據庫,讀操作比寫操作都要多得多,所以樂觀鎖可以進一步提高效率。”

你:“編程哪能靠樂觀地估計,萬一出問題了,造成多大的損失啊。果然人不能太樂觀,古人都說生於憂患,死於安樂。”

NPR 嬉皮笑臉地說:“害,那都是幾千年前的思想了,現在提倡做人最重要的就是開心。”

你:“可別得意忘了形,我想是個正常的公司都會使用悲觀鎖吧。性能和穩定二選一的話,只能捨棄性能選擇穩定。”

NPR:“呵,小孩子才做選擇。”

你:“你是說我們可以全都要?”

NPR:“沒錯,只要我們樂觀地讀取數據後做一個檢查,判斷讀的過程中是否有寫入發生。如果沒有寫入,說明我們樂觀地獲取到的數據是正確的,樂觀爲我們提高了效率。如果檢查發現讀的過程中有寫入,說明讀到的數據有誤,這時我們再使用悲觀鎖將正確的數據讀出來。這樣就可以做到性能、穩定兼顧了。”

你:“聽起來還不錯,不過要怎麼判斷讀的過程中是否有寫入發生呢?”

NPR:“比如我們可以在讀取前,給數據設置一個版本號,寫入後修改此版本號,讀取完成後通過判斷這個版本號是否被修改,就可以做到這一點了。”

你:“這個版本號也不好實現啊,Doug Lea 大師有給我們提供什麼工具類嗎?”

NPR:“當然有,這個類叫做 StampedLock,Stamp 譯爲戳,戳就是我給你提到的版本號。”

public class Client {
    private int number = 0;
    private final StampedLock lock = new StampedLock();

    private void read() {
        // 嘗試樂觀讀取
        long stamp = lock.tryOptimisticRead();
        int readNumber = number;
        System.out.println("樂觀讀取到的 number = " + readNumber);
        // 檢查樂觀讀取到的數據是否有誤
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            System.out.println("樂觀讀取到的 number " + readNumber + " 有誤,換用悲觀鎖重新讀取:number = " + number);
            lock.unlockRead(stamp);
        }
    }

    private void write(int change) {
        long stamp = lock.writeLock();
        number += change;
        System.out.println("寫入 " + number);
        lock.unlockWrite(stamp);
    }

    @Test
    public void test() throws InterruptedException {
        // 開啓一個線程寫入 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                write(1);
            }
        }).start();

        // 開啓一個線程讀取 100 次 number
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                read();
            }
        }).start();

        // 睡眠一秒保證線程執行完成
        Thread.sleep(1000);
    }
}

運行程序,輸出如下:

寫入 1
寫入 2
寫入 3
寫入 4
寫入 5
寫入 6
...
樂觀讀取到的 number = 86
寫入 87
樂觀讀取到的 number 86 有誤,換用悲觀鎖重新讀取:number = 87
樂觀讀取到的 number = 87
寫入 88
樂觀讀取到的 number 87 有誤,換用悲觀鎖重新讀取:number = 88
樂觀讀取到的 number = 88
寫入 89
寫入 90
寫入 91
寫入 92
樂觀讀取到的 number 88 有誤,換用悲觀鎖重新讀取:number = 92
寫入 93
寫入 94
寫入 95
寫入 96
寫入 97
寫入 98
寫入 99
寫入 100
樂觀讀取到的 number = 92
樂觀讀取到的 number 92 有誤,換用悲觀鎖重新讀取:number = 100
樂觀讀取到的 number = 100
樂觀讀取到的 number = 100
樂觀讀取到的 number = 100
樂觀讀取到的 number = 100
...

NPR:“就是這麼簡單,先用 tryOptimisticRead 嘗試樂觀讀取,再使用 lock.validate(stamp) 驗證版本號是否被修改。如果被修改了,說明讀取有誤,則換用悲觀鎖重新讀取即可。”

你:“之前我看到 lock 和 unlock 都是成對出現的,這段代碼裏如果 lock.validate(stamp) 驗證結果爲 true,樂觀鎖就執行不到 unlock 方法了啊!會不會導致沒有解鎖?”

NPR:“你多慮了,tryOptimisticRead 只是返回一個版本號,不是鎖,根本沒有鎖,所以不需要解鎖。這也是樂觀鎖提升效率的祕訣所在。”

你:“原來如此,這麼說來,當需要頻繁地讀取時,使用樂觀鎖可以大大的提升效率。”

NPR:“沒錯,如果讀取頻繁,寫入較少時,使用樂觀鎖可以減少加鎖、解鎖的次數;但如果寫入頻繁,使用樂觀鎖會增加重試次數,反而降低了程序的吞吐量。所以總的來說,讀取頻繁使用樂觀鎖,寫入頻繁使用悲觀鎖。”

你:“大師果然是大師,針對各種場景的優化都替我們考慮到了,我已經路轉粉了!偉大的 Doug Lea!”

NPR 不禁感嘆道:“是啊,偉大的 Doug Lea!世界上 2% 的頂級程序員寫出了 98% 的優秀程序,我們平常不過是使用他們造好的輪子而已。”

第十回 最終考驗

你:“要用好輪子也需要懂得輪子從創造到發展的過程。謝謝你教我這麼多,NPR 先生。”

NPR:“哈哈,先別謝我,其實我給你展示的代碼是有一點問題的,爲了給你講解,我簡化了一句代碼,你能看出是什麼嗎?”

你:“啊?你個坑貨!讓我想想,嗯…”

NPR:“提示你一下,問題在於異常處理。”

你:“我知道了,Java 代碼需要考慮異常,使用 java.util.concurrent 時,獲取鎖之後的代碼都需要放在 try 代碼塊中,並且需要將 unlock() 函數寫在 finally 語句中,才能保證一定能夠解鎖。”

NPR:“就是這樣!再會,我的勇士!”

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