【廖雪峯官方網站/Java教程】多線程(2)

1.使用wait和notify

1.1.多線程協調

在Java程序中,synchronized解決了多線程競爭的問題。例如,對於一個任務管理器,多個線程同時往隊列中添加任務,可以用synchronized加鎖:

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
    }
}

但是synchronized並沒有解決多線程協調的問題。
仍然以上面的TaskQueue爲例,我們再編寫一個getTask()方法取出隊列的第一個任務:

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
    }

    public synchronized String getTask() {
        while (queue.isEmpty()) {
        }
        return queue.remove();
    }
}

上述代碼看上去沒有問題:getTask()內部先判斷隊列是否爲空,如果爲空,就循環等待,直到另一個線程往隊列中放入了一個任務,while()循環退出,就可以返回隊列的元素了。
但實際上while()循環永遠不會退出。因爲線程在執行while()循環時,已經在getTask()入口獲取了this鎖,其他線程根本無法調用addTask(),因爲addTask()執行條件也是獲取this鎖。
因此,執行上述代碼,線程會在getTask()中因爲死循環而100%佔用CPU資源。
如果深入思考一下,我們想要的執行效果是:

  1. 線程1可以調用addTask()不斷往隊列中添加任務;
  2. 線程2可以調用getTask()從隊列中獲取任務。如果隊列爲空,則getTask()應該等待,直到隊列中至少有一個任務時再返回。

因此,多線程協調運行的原則就是:當條件不滿足時,線程進入等待狀態;當條件滿足時,線程被喚醒,繼續執行任務

1.2.wait()和notify()

對於上述TaskQueue,我們先改造getTask()方法,在條件不滿足時,線程進入等待狀態:

public synchronized String getTask() {
    while (queue.isEmpty()) {
        this.wait();
    }
    return queue.remove();
}

當一個線程執行到getTask()方法內部的while循環時,它必定已經獲取到了this鎖,此時,線程執行while條件判斷,如果條件成立(隊列爲空),線程將執行this.wait(),進入等待狀態。
這裏的關鍵是:wait()方法必須在當前獲取的鎖對象上調用,這裏獲取的是this鎖,因此調用this.wait()。
調用wait()方法後,線程進入等待狀態,wait()方法不會返回,直到將來某個時刻,線程從等待狀態被其他線程喚醒後,wait()方法纔會返回,然後,繼續執行下一條語句。
有些仔細的童鞋會指出:即使線程在getTask()內部等待,其他線程如果拿不到this鎖,照樣無法執行addTask(),腫麼辦?
這個問題的關鍵就在於wait()方法的執行機制非常複雜。首先,它不是一個普通的Java方法,而是定義在Object類的一個native方法,也就是由JVM的C代碼實現的。其次,必須在synchronized塊中才能調用wait()方法,因爲wait()方法調用時,會釋放線程獲得的鎖,wait()方法返回後,線程又會重新試圖獲得鎖。
因此,只能在鎖對象上調用wait()方法。因爲在getTask()中,我們獲得了this鎖,因此,只能在this對象上調用wait()方法:

public synchronized String getTask() {
    while (queue.isEmpty()) {
        // 釋放this鎖:
        this.wait();
        // 重新獲取this鎖
    }
    return queue.remove();
}

當一個線程在this.wait()等待時,它就會釋放this鎖,從而使得其他線程能夠在addTask()方法獲得this鎖。
現在我們面臨第二個問題:如何讓等待的線程被重新喚醒,然後從wait()方法返回?答案是在相同的鎖對象上調用notify()方法。我們修改addTask()如下:

public synchronized void addTask(String s) {
    this.queue.add(s);
    this.notify(); // 喚醒在this鎖等待的線程
}

注意到在往隊列中添加了任務後,線程立刻對this鎖對象調用notify()方法,這個方法會喚醒一個正在this鎖等待的線程(就是在getTask()中位於this.wait()的線程),從而使得等待線程從this.wait()方法返回。
使用notifyAll()將喚醒所有當前正在this鎖等待的線程,而notify()只會喚醒其中一個(具體哪個依賴操作系統,有一定的隨機性)。這是因爲可能有多個線程正在getTask()方法內部的wait()中等待,使用notifyAll()將一次性全部喚醒。通常來說,notifyAll()更安全。有些時候,如果我們的代碼邏輯考慮不周,用notify()會導致只喚醒了一個線程,而其他線程可能永遠等待下去醒不過來了。
所以,正確編寫多線程代碼是非常困難的,需要仔細考慮的條件非常多,任何一個地方考慮不周,都會導致多線程運行時不正常。

1.3.小結

wait和notify用於多線程協調運行:

  1. 在synchronized內部可以調用wait()使線程進入等待狀態;
  2. 必須在已獲得的鎖對象上調用wait()方法;
  3. 在synchronized內部可以調用notify()或notifyAll()喚醒其他等待線程;
  4. 必須在已獲得的鎖對象上調用notify()或notifyAll()方法;
  5. 已喚醒的線程還需要重新獲得鎖後才能繼續執行。

2.使用ReentrantLock

2.1.ReentrantLock介紹

從Java 5開始,引入了一個高級的處理併發的java.util.concurrent包,它提供了大量更高級的併發功能,能大大簡化多線程程序的編寫。
我們知道Java語言直接提供了synchronized關鍵字用於加鎖,但這種鎖一是很重,二是獲取時必須一直等待,沒有額外的嘗試機制。
java.util.concurrent.locks包提供的ReentrantLock用於替代synchronized加鎖,我們來看一下傳統的synchronized代碼:

public class Counter {
    private int count;

    public void add(int n) {
        synchronized(this) {
            count += n;
        }
    }
}

如果用ReentrantLock替代,可以把代碼改造爲:

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count;

    public void add(int n) {
        lock.lock();
        try {
            count += n;
        } finally {
            lock.unlock();
        }
    }
}

因爲synchronized是Java語言層面提供的語法,所以我們不需要考慮異常,而ReentrantLock是Java代碼實現的鎖,我們就必須先獲取鎖,然後在finally中正確釋放鎖。
顧名思義,ReentrantLock是可重入鎖,它和synchronized一樣,一個線程可以多次獲取同一個鎖。
和synchronized不同的是,ReentrantLock可以嘗試獲取鎖:

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        ...
    } finally {
        lock.unlock();
    }
}

上述代碼在嘗試獲取鎖的時候,最多等待1秒。如果1秒後仍未獲取到鎖,tryLock()返回false,程序就可以做一些額外處理,而不是無限等待下去。
所以,使用ReentrantLock比直接使用synchronized更安全,線程在tryLock()失敗的時候不會導致死鎖。

2.2.小結

  1. ReentrantLock可以替代synchronized進行同步;
  2. ReentrantLock獲取鎖更安全;
  3. 必須先獲取到鎖,再進入try {…}代碼塊,最後使用finally保證釋放鎖;
  4. 可以使用tryLock()嘗試獲取鎖。

3.使用Condition

3.1.Condition介紹

使用ReentrantLock比直接使用synchronized更安全,可以替代synchronized進行線程同步。
但是,synchronized可以配合wait和notify實現線程在條件不滿足時等待,條件滿足時喚醒,用ReentrantLock我們怎麼編寫wait和notify的功能呢?
答案是使用Condition對象來實現wait和notify的功能
我們仍然以TaskQueue爲例,把前面用synchronized實現的功能通過ReentrantLock和Condition來實現:

class TaskQueue {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}

可見,使用Condition時,引用的Condition對象必須從Lock實例的newCondition()返回,這樣才能獲得一個綁定了Lock實例的Condition實例。
Condition提供的await()、signal()、signalAll()原理和synchronized鎖對象的wait()、notify()、notifyAll()是一致的,並且其行爲也是一樣的:

  1. await()會釋放當前鎖,進入等待狀態;
  2. signal()會喚醒某個等待線程;
  3. signalAll()會喚醒所有等待線程;
  4. 喚醒線程從await()返回後需要重新獲得鎖。

此外,和tryLock()類似,await()可以在等待指定時間後,如果還沒有被其他線程通過signal()或signalAll()喚醒,可以自己醒來:

if (condition.await(1, TimeUnit.SECOND)) {
    // 被其他線程喚醒
} else {
    // 指定時間內沒有被其他線程喚醒
}

可見,使用Condition配合Lock,我們可以實現更靈活的線程同步。

3.2.小結

  1. Condition可以替代wait和notify;
  2. Condition對象必須從Lock對象獲取。

4.使用ReadWriteLock

4.1.ReadWriteLock介紹

前面講到的ReentrantLock保證了只有一個線程可以執行臨界區代碼:

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        lock.lock();
        try {
            counts[index] += 1;
        } finally {
            lock.unlock();
        }
    }

    public int[] get() {
        lock.lock();
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            lock.unlock();
        }
    }
}

但是有些時候,這種保護有點過頭。因爲我們發現,任何時刻,只允許一個線程修改,也就是調用inc()方法是必須獲取鎖,但是,get()方法只讀取數據,不修改數據,它實際上允許多個線程同時調用。
實際上我們想要的是:允許多個線程同時讀,但只要有一個線程在寫,其他線程就必須等待
在這裏插入圖片描述
使用ReadWriteLock可以解決這個問題,它保證:

  • 只允許一個線程寫入(其他線程既不能寫入也不能讀取);
  • 沒有寫入時,多個線程允許同時讀(提高性能)。

用ReadWriteLock實現這個功能十分容易。我們需要創建一個ReadWriteLock實例,然後分別獲取讀鎖和寫鎖:

public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加寫鎖
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 釋放寫鎖
        }
    }

    public int[] get() {
        rlock.lock(); // 加讀鎖
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 釋放讀鎖
        }
    }
}

把讀寫操作分別用讀鎖和寫鎖來加鎖,在讀取時,多個線程可以同時獲得讀鎖,這樣就大大提高了併發讀的執行效率。

使用ReadWriteLock時,適用條件是同一個數據,有大量線程讀取,但僅有少數線程修改。

例如,一個論壇的帖子,回覆可以看做寫入操作,它是不頻繁的,但是,瀏覽可以看做讀取操作,是非常頻繁的,這種情況就可以使用ReadWriteLock。

4.2.小結

使用ReadWriteLock可以提高讀取效率:

  1. ReadWriteLock只允許一個線程寫入;
  2. ReadWriteLock允許多個線程在沒有寫入時同時讀取;
  3. ReadWriteLock適合讀多寫少的場景。

5.使用StampedLock

5.1.StampedLock介紹

前面介紹的ReadWriteLock可以解決多線程同時讀,但只有一個線程能寫的問題。
如果我們深入分析ReadWriteLock,會發現它有個潛在的問題:如果有線程正在讀,寫線程需要等待讀線程釋放鎖後才能獲取寫鎖,即讀的過程中不允許寫,這是一種悲觀的讀鎖
要進一步提升併發執行效率,Java 8引入了新的讀寫鎖:StampedLock。
StampedLock和ReadWriteLock相比,改進之處在於:讀的過程中也允許獲取寫鎖後寫入!這樣一來,我們讀的數據就可能不一致,所以,需要一點額外的代碼來判斷讀的過程中是否有寫入,這種讀鎖是一種樂觀鎖
樂觀鎖的意思就是樂觀地估計讀的過程中大概率不會有寫入,因此被稱爲樂觀鎖。反過來,悲觀鎖則是讀的過程中拒絕有寫入,也就是寫入必須等待。顯然樂觀鎖的併發效率更高,但一旦有小概率的寫入導致讀取的數據不一致,需要能檢測出來,再讀一遍就行。
看個例子:

public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 獲取寫鎖
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 釋放寫鎖
        }
    }

    public double distanceFromOrigin() {
        long stamp = stampedLock.tryOptimisticRead(); // 獲得一個樂觀讀鎖
        // 注意下面兩行代碼不是原子操作
        // 假設x,y = (100,200)
        double currentX = x;
        // 此處已讀取到x=100,但x,y可能被寫線程修改爲(300,400)
        double currentY = y;
        // 此處已讀取到y,如果沒有寫入,讀取是正確的(100,200)
        // 如果有寫入,讀取是錯誤的(100,400)
        if (!stampedLock.validate(stamp)) { // 檢查樂觀讀鎖後是否有其他寫鎖發生
            stamp = stampedLock.readLock(); // 獲取一個悲觀讀鎖
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 釋放悲觀讀鎖
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

和ReadWriteLock相比,寫入的加鎖是完全一樣的,不同的是讀取。注意到首先我們通過tryOptimisticRead()獲取一個樂觀讀鎖,並返回版本號。接着進行讀取,讀取完成後,我們通過validate()去驗證版本號,如果在讀取過程中沒有寫入,版本號不變,驗證成功,我們就可以放心地繼續後續操作。如果在讀取過程中有寫入,版本號會發生變化,驗證將失敗。在失敗的時候,我們再通過獲取悲觀讀鎖再次讀取。由於寫入的概率不高,程序在絕大部分情況下可以通過樂觀讀鎖獲取數據,極少數情況下使用悲觀讀鎖獲取數據。
可見,StampedLock把讀鎖細分爲樂觀讀和悲觀讀,能進一步提升併發效率。但這也是有代價的:一是代碼更加複雜,二是StampedLock是不可重入鎖,不能在一個線程中反覆獲取同一個鎖。
StampedLock還提供了更復雜的將悲觀讀鎖升級爲寫鎖的功能,它主要使用在if-then-update的場景:即先讀,如果讀的數據滿足條件,就返回,如果讀的數據不滿足條件,再嘗試寫。

5.2.小結

  1. StampedLock提供了樂觀讀鎖,可取代ReadWriteLock以進一步提升併發性能;
  2. StampedLock是不可重入鎖。

6.使用Concurrent集合

6.1.Concurrent中的併發集合

針對List、Map、Set、Deque等,java.util.concurrent包也提供了對應的併發集合類。我們歸納一下:
在這裏插入圖片描述
使用這些併發集合與使用非線程安全的集合類完全相同。我們以ConcurrentHashMap爲例:

Map<String, String> map = new ConcurrentHashMap<>();
// 在不同的線程讀寫:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");

因爲所有的同步和加鎖的邏輯都在集合內部實現,對外部調用者來說,只需要正常按接口引用,其他代碼和原來的非線程安全代碼完全一樣。即當我們需要多線程訪問時,把:

Map<String, String> map = new HashMap<>();

改爲:

Map<String, String> map = new ConcurrentHashMap<>();

就可以了。
java.util.Collections工具類還提供了一箇舊的線程安全集合轉換器,可以這麼用:

Map unsafeMap = new HashMap();
Map threadSafeMap = Collections.synchronizedMap(unsafeMap);

但是它實際上是用一個包裝類包裝了非線程安全的Map,然後對所有讀寫方法都用synchronized加鎖,這樣獲得的線程安全集合的性能比java.util.concurrent集合要低很多,所以不推薦使用。

6.2.小結

  1. 使用java.util.concurrent包提供的線程安全的併發集合可以大大簡化多線程編程:
  2. 多線程同時讀寫併發集合是安全的;
  3. 儘量使用Java標準庫提供的併發集合,避免自己編寫同步代碼。

7.使用Atomic

7.1.Atomic介紹

Java的java.util.concurrent包除了提供底層鎖、併發集合外,還提供了一組原子操作的封裝類,它們位於java.util.concurrent.atomic包。
我們以AtomicInteger爲例,它提供的主要操作有:

  1. 增加值並返回新值:int addAndGet(int delta)
  2. 加1後返回新值:int incrementAndGet()
  3. 獲取當前值:int get()
  4. 用CAS方式設置:int compareAndSet(int expect, int update)

Atomic類是通過無鎖(lock-free)的方式實現的線程安全(thread-safe)訪問。它的主要原理是利用了CAS:Compare and Set
如果我們自己通過CAS編寫incrementAndGet(),它大概長這樣:

public int incrementAndGet(AtomicInteger var) {
    int prev, next;
    do {
        prev = var.get();
        next = prev + 1;
    } while ( ! var.compareAndSet(prev, next));
    return prev;
}

CAS是指,在這個操作中,如果AtomicInteger的當前值是prev,那麼就更新爲next,返回true。如果AtomicInteger的當前值不是prev,就什麼也不幹,返回false。通過CAS操作並配合do … while循環,即使其他線程修改了AtomicInteger的值,最終的結果也是正確的。
我們利用AtomicLong可以編寫一個多線程安全的全局唯一ID生成器:

class IdGenerator {
    AtomicLong var = new AtomicLong(0);

    public long getNextId() {
        return var.incrementAndGet();
    }
}

通常情況下,我們並不需要直接用do … while循環調用compareAndSet實現複雜的併發操作,而是用incrementAndGet()這樣的封裝好的方法,因此,使用起來非常簡單。
在高度競爭的情況下,還可以使用Java 8提供的LongAdder和LongAccumulator。

7.2.小結

使用java.util.concurrent.atomic提供的原子操作可以簡化多線程編程:

  1. 原子操作實現了無鎖的線程安全;
  2. 適用於計數器,累加器等。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章