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可以調用addTask()不斷往隊列中添加任務;
- 線程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用於多線程協調運行:
- 在synchronized內部可以調用wait()使線程進入等待狀態;
- 必須在已獲得的鎖對象上調用wait()方法;
- 在synchronized內部可以調用notify()或notifyAll()喚醒其他等待線程;
- 必須在已獲得的鎖對象上調用notify()或notifyAll()方法;
- 已喚醒的線程還需要重新獲得鎖後才能繼續執行。
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.小結
- ReentrantLock可以替代synchronized進行同步;
- ReentrantLock獲取鎖更安全;
- 必須先獲取到鎖,再進入try {…}代碼塊,最後使用finally保證釋放鎖;
- 可以使用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()是一致的,並且其行爲也是一樣的:
- await()會釋放當前鎖,進入等待狀態;
- signal()會喚醒某個等待線程;
- signalAll()會喚醒所有等待線程;
- 喚醒線程從await()返回後需要重新獲得鎖。
此外,和tryLock()類似,await()可以在等待指定時間後,如果還沒有被其他線程通過signal()或signalAll()喚醒,可以自己醒來:
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他線程喚醒
} else {
// 指定時間內沒有被其他線程喚醒
}
可見,使用Condition配合Lock,我們可以實現更靈活的線程同步。
3.2.小結
- Condition可以替代wait和notify;
- 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可以提高讀取效率:
- ReadWriteLock只允許一個線程寫入;
- ReadWriteLock允許多個線程在沒有寫入時同時讀取;
- 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.小結
- StampedLock提供了樂觀讀鎖,可取代ReadWriteLock以進一步提升併發性能;
- 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.小結
- 使用java.util.concurrent包提供的線程安全的併發集合可以大大簡化多線程編程:
- 多線程同時讀寫併發集合是安全的;
- 儘量使用Java標準庫提供的併發集合,避免自己編寫同步代碼。
7.使用Atomic
7.1.Atomic介紹
Java的java.util.concurrent包除了提供底層鎖、併發集合外,還提供了一組原子操作的封裝類,它們位於java.util.concurrent.atomic包。
我們以AtomicInteger爲例,它提供的主要操作有:
- 增加值並返回新值:int addAndGet(int delta)
- 加1後返回新值:int incrementAndGet()
- 獲取當前值:int get()
- 用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提供的原子操作可以簡化多線程編程:
- 原子操作實現了無鎖的線程安全;
- 適用於計數器,累加器等。