Java 8 併發:同步和鎖

Simeone_xu發表於2018-02-02

原文地址: Java 8 Concurrency Tutorial: Synchronization and Locks

為了簡單起見,本教程的示例程式碼使用了在這裡定義的兩個輔助方法,sleep(seconds)stop(executor)

Synchronized

當我們編寫多執行緒程式碼訪問可共享的變數時需要特別注意,下面是一個多執行緒去改變一個整數的例子。

定義一個變數 count,定義一個方法 increment() 使 count 增加 1.

int count = 0;

void increment() {
    count = count + 1;
}
複製程式碼

當多個執行緒同時呼叫 increment() 時就會出現問題:

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::increment));

stop(executor);

System.out.println(count);  // 9965
複製程式碼

上面的程式碼執行結果並不是10000,原因是我們在不同的執行緒上共享一個變數,而沒有給這個變數的訪問設定競爭條件。

為了增加數字,必須執行三個步驟:(i) 讀取當前值;(ii) 將該值增加1;(iii) 將新值寫入變數;如果兩個執行緒並行執行這些步驟,則兩個執行緒可能同時執行步驟1,從而讀取相同的當前值。 這導致寫入丟失,所以實際結果較低。 在上面的示例中,35個增量由於併發非同步訪問計數而丟失,但是當你自己執行程式碼時可能會看到不同的結果。

幸運的是,Java 早期通過 synchronized 關鍵字支援執行緒同步。增加計數時,我們可以利用同步來解決上述競爭條件:

synchronized void incrementSync() {
    count = count + 1;
}
複製程式碼

當我們使用 incrementSync() 方法時,我們得到了希望的結果,而且每次執行的結果都是這樣的。

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::incrementSync));

stop(executor);

System.out.println(count);  // 10000
複製程式碼

synchronized 關鍵值也可以用在一個語句塊中

void incrementSync() {
    synchronized (this) {
        count = count + 1;
    }
}
複製程式碼

JVM 的內部使用了一個監視器,也可以稱為監視器鎖和內部鎖來管理同步。這個監視器被繫結到一個物件上,當使用同步方法時,每個方法共享相應物件的監視器。

所有隱式監視器都實現了可重入特性。 可重入意味著鎖被繫結到當前執行緒,執行緒可以安全地多次獲取相同的鎖,而不會發生死鎖(例如同步方法在同一物件上呼叫另一個同步方法)。

Locks

除了使用關鍵字 synchronized 支援的隱式鎖(物件的內建鎖)外,Concurrency API 支援由 Lock 介面指定的各種顯示鎖。顯示鎖能控制更細的粒度,因此也有更好的效能,在邏輯上也比較清晰。

標準 JDK中提供了多種顯示鎖的實現,將在下面的章節中進行介紹。

ReentrantLock

ReentrantLock 類是一個互斥鎖,它和 synchronized 關鍵字訪問的隱式鎖具有相同的功能,但它具有擴充套件功能。它也實現了可重入的功能。

下面來看看如何使用 ReentrantLock

ReentrantLock lock = new ReentrantLock();
int count = 0;

void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}
複製程式碼

鎖通過 lock() 獲取,通過 unlock() 釋放,將程式碼封裝到 try/finally 塊中是非常重要的,以確保在出現異常的時候也能釋放鎖。這個方法和使用關鍵字 synchronized 修飾的方法是一樣是執行緒安全的。如果一個執行緒已經獲得了鎖,後續執行緒呼叫 lock() 會暫停執行緒,直到鎖被釋放,永遠只有一個執行緒能獲取鎖。

lock 支援更細粒度的去控制一個方法的同步,如下面的程式碼:

ExecutorService executor = Executors.newFixedThreadPool(2);
ReentrantLock lock = new ReentrantLock();

executor.submit(() -> {
    lock.lock();
    try {
        sleep(1000);
    } finally {
        lock.unlock();
    }
});

executor.submit(() -> {
    System.out.println("Locked: " + lock.isLocked());
    System.out.println("Held by me: " + lock.isHeldByCurrentThread());
    boolean locked = lock.tryLock();
    System.out.println("Lock acquired: " + locked);
});

stop(executor);
複製程式碼

當第一個任務獲取鎖時,第二個任務獲取鎖的狀態資訊:

Locked: true
Held by me: false
Lock acquired: false
複製程式碼

作為 lock() 方法的替代方法 tryLock() 嘗試去獲取鎖而不暫停當前執行緒,必須使用 bool 結果去判斷是否真的獲取到了鎖。

ReadWriteLock

ReadWriteLock 指定了另一種型別的鎖,即讀寫鎖。讀寫鎖實現的邏輯是,當沒有執行緒在寫這個變數時,其他的執行緒可以讀取這個變數,所以就是當沒有執行緒持有寫鎖時,讀鎖就可以被所有的執行緒持有。如果讀取比寫更頻繁,這將增加系統的效能和吞吐量。

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
ReadWriteLock lock = new ReentrantReadWriteLock();

executor.submit(() -> {
    lock.writeLock().lock();
    try {
        sleep(1000);
        map.put("foo", "bar");
    } finally {
        lock.writeLock().unlock();
    }
});
複製程式碼

上面的例子首先獲取一個寫入鎖,在 sleep 1秒後在 map 中寫入值,在這個任務完成之前,還有兩個任務正在提交,試圖從 map 讀取值:

Runnable readTask = () -> {
    lock.readLock().lock();
    try {
        System.out.println(map.get("foo"));
        sleep(1000);
    } finally {
        lock.readLock().unlock();
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);
複製程式碼

當執行上面的程式碼時,你會注意到兩人讀取的任務必須等待直到寫入完成(當在讀取的時候,寫是不能獲取鎖的)。寫入鎖釋放後,兩個任務並行執行,它們不必等待對方是否完成,因為只要沒有執行緒持有寫入鎖,它們就可以同時持有讀取鎖。

StampedLock

Java 8 提供了一種新型別的鎖 StampedLock,像上面的例子一樣它也支援讀寫鎖,與 ReadWriteLock 不同的是,StampedLock 的鎖定方法返回一個 long 值,可以利用這個值檢查是否釋放鎖和鎖仍然有效。另外 StampedLock 支援另外一種稱為樂觀鎖的模式。

下面使用 StampedLock 來替換 ReadWriteLock

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        sleep(1000);
        map.put("foo", "bar");
    } finally {
        lock.unlockWrite(stamp);
    }
});

Runnable readTask = () -> {
    long stamp = lock.readLock();
    try {
        System.out.println(map.get("foo"));
        sleep(1000);
    } finally {
        lock.unlockRead(stamp);
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);
複製程式碼

通過 readLock()writeLock() 方法來獲取讀寫鎖會返回一個稍後用於在 finally 塊中釋放鎖的值。注意,這裡的鎖不是可重入的。每次鎖定都會返回一個新的值,並在沒有鎖的情況下阻塞,在使用的時候要注意不要死鎖。

就像前面 ReadWriteLock 中的示例一樣,兩個讀取任務必須等待寫入任務釋放鎖。然後同時並行執行列印結果到控制檯。

下面的例子演示了樂觀鎖

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.tryOptimisticRead();
    try {
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(1000);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(2000);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
    } finally {
        lock.unlock(stamp);
    }
});

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        System.out.println("Write Lock acquired");
        sleep(2000);
    } finally {
        lock.unlock(stamp);
        System.out.println("Write done");
    }
});

stop(executor);
複製程式碼

通過呼叫 tryOptimisticRead() 來獲取樂觀讀寫鎖tryOptimisticRead()總是返回一個值,而不會阻塞當前執行緒,也不關鎖是否可用。如果有一個寫鎖啟用則返回0。可以通過 lock.validate(stamp) 來檢查返回的標記(long 值)是否有效。

執行上面的程式碼輸出:

Optimistic Lock Valid: true
Write Lock acquired
Optimistic Lock Valid: false
Write done
Optimistic Lock Valid: false
複製程式碼

樂觀鎖在獲得鎖後立即生效。與普通讀鎖相反,樂觀鎖不會阻止其他執行緒立即獲得寫鎖。在第一個執行緒休眠一秒之後,第二個執行緒獲得一個寫鎖,而不用等待樂觀讀鎖解除。樂觀的讀鎖不再有效,即使寫入鎖定被釋放,樂觀的讀取鎖仍然無效。

因此,在使用樂觀鎖時,必須在每次訪問任何共享的變數後驗證鎖,以確保讀取仍然有效。

有時將讀鎖轉換為寫鎖並不需要再次解鎖和鎖定是有用的。StampedLock 為此提供了tryConvertToWriteLock() 方法,如下面的示例所示:

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.readLock();
    try {
        if (count == 0) {
            stamp = lock.tryConvertToWriteLock(stamp);
            if (stamp == 0L) {
                System.out.println("Could not convert to write lock");
                stamp = lock.writeLock();
            }
            count = 23;
        }
        System.out.println(count);
    } finally {
        lock.unlock(stamp);
    }
});

stop(executor);
複製程式碼

該任務首先獲得一個讀鎖,並將當前的變數計數值列印到控制檯。 但是,如果當前值為 0,我們要分配一個新的值23。我們首先必須將讀鎖轉換為寫鎖,以不打破其他執行緒的潛在併發訪問。 呼叫 tryConvertToWriteLock() 不會阻塞,但可能會返回 0,指示當前沒有寫鎖定可用。 在這種情況下,我們呼叫writeLock()來阻塞當前執行緒,直到寫鎖可用。

Semaphores

除了鎖之外,併發API還支援計數訊號量。 鎖通常授予對變數或資源的獨佔訪問權,而訊號量則能夠維護整套許可證。 在不同的情況下,必須限制對應用程式某些部分的併發訪問量。

下面是一個如何限制對長時間任務的訪問的例子:

ExecutorService executor = Executors.newFixedThreadPool(10);

Semaphore semaphore = new Semaphore(5);

Runnable longRunningTask = () -> {
    boolean permit = false;
    try {
        permit = semaphore.tryAcquire(1, TimeUnit.SECONDS);
        if (permit) {
            System.out.println("Semaphore acquired");
            sleep(5000);
        } else {
            System.out.println("Could not acquire semaphore");
        }
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    } finally {
        if (permit) {
            semaphore.release();
        }
    }
};

IntStream.range(0, 10)
    .forEach(i -> executor.submit(longRunningTask));

stop(executor);
複製程式碼

執行程式可以同時執行10個任務,但是我們使用5訊號量,因此限制併發訪問為5個。使用try/finally塊,即使在異常的情況下正確釋放訊號量也是非常重要的。

執行上面的程式碼輸出:

Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
複製程式碼

當有 5 個任務獲取型號量後,隨後的任務便不能獲取訊號量了。但是如果前面 5 的任務執行完成,finally 塊釋放了型號量,隨後的執行緒就可以獲取星號量了,總數不會超過5個。這裡呼叫 tryAcquire() 獲取型號量設定了超時時間1秒,意味著當執行緒獲取訊號量失敗後可以阻塞等待1秒再獲取。

相關文章