技術趣講 | Java「多線程王國」奇遇記

力扣leetcode 發佈 2021-08-03T08:04:24.785597+00:00

第一回 江湖救急NPR:「歡迎來到多線程的國度,勇士!」你:「你你你,你不是正則王國的 NPC 嗎?怎麼又跑到多線程王國來了?」NPR:「呃,你認錯人了,那是我的雙胞胎弟弟,我是他的哥哥 NPR。


第一回 江湖救急

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 簡化了哪一句代碼嗎?在留言區告訴我們吧!


推薦閱讀:

技術趣講 |「正則」王國奇遇記


本文作者:Alpinist Wang

聲明:本文歸 「力扣」 版權所有,如需轉載請聯繫。文章封面圖來源於網絡,如有侵權聯繫刪除。

關鍵字: