上篇對線程的一些基礎知識做了總結,本篇來對多線程編程中最重要,也是最麻煩的一個部分——同步,來做個總結。
創建線程並不難,難的是如何讓多個線程能夠良好的協作運行,大部分需要多線程處理的事情都不是完全獨立的,大都涉及到數據的共享,本篇是對線程同步的一個總結,如有紕漏的地方,歡迎在評論中指出。
為什么要有同步
我們來看一個簡單的例子,有兩個數 num1,num2,現在用 10 個線程來做這樣一件事--每次從 num1 中減去一個隨機的數 a,加到 num2 上。
public class Demo1 {
public static void main(String[] args) {
Bank bank = new Bank();
//創建10個線程,不停的將一個賬號資金轉移到另一個賬號上
for (int i = 0; i < 100; i++) {
new Thread(() -> {
while (true) {
int account1 = ((Double) Math.floor(Math.random() * 10)).intValue();
int account2 = ((Double) Math.floor(Math.random() * 10)).intValue();
int num = ((Long) Math.round(Math.random() * 100)).intValue();
bank.transfer(account1, account2, num);
try {
Thread.sleep(((Double) (Math.random() * 10)).intValue());
} catch (Exception e) {
}
}
}).start();
}
}
}
class Bank {
/**
* 10個資金賬戶
*/
public int[] accounts = new int[10];
public Bank() {
Arrays.fill(accounts, 1000);
}
public void transfer(int from, int to, int num) {
accounts[from] -= num;
accounts[to] += num;
//計算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
}
}
正常情況下,無論什么時候資金賬號的和應該都是 10000.然而真的會這樣嗎?運行程序一段時間后會發現和不等於 10000 了,可能變大也可能變小了。
競爭
上面的代碼中有多個程序同時更新賬戶信息,因此出現了競爭關系。假設兩個線程同時執行下面的一句代碼:
accounts[account1] -= num;
該代碼不是原子性的,可能會被處理成如下三條指令:
- 將 accounts[account1]加載到寄存器
- 值減少 num
- 結果寫回到 accounts[account1]
這里僅說明單核心情況下的問題(多核一樣會有問題),單核心是不能同時運行兩個線程的,如果一個線程 A 執行到第三步時,被剝奪了運行權,線程 B 開始執行完成了整個過程,然后線程 A 繼續運行第三步,這就產生了錯誤,線程 A 的結果覆蓋了線程 B 的結果,總金額不再正確。如下圖所示:

如何同步
鎖對象
為了防止並發導致數據錯亂,Java 語言提供了 synchronized 關鍵字,並且在 Java SE 5 的時候加入了 ReentrantLock 類。synchronized 關鍵字自動提供了一個鎖以及相關的條件,這個后面再說。ReentrantLock 的基本使用如下:
myLock.lock()//myLock是一個ReetrantLock對象示例
try{
//要保護的代碼塊
}finally{
//一定要在finally中釋放鎖
myLock.unlock();
}
上述結構保證任意時刻只有一個線程進入臨界區,一旦一個線程調用 lock 方法獲取了鎖,其他所有線程都會阻塞在 lock 方法處,直到有鎖線程調用 unlock 方法。
將 ban 類中的 transfer 方法加鎖,代碼如下:
class Bank {
/**
* 10個資金賬戶
*/
public int[] accounts = new int[10];
private ReentrantLock lock = new ReentrantLock();
public Bank() {
Arrays.fill(accounts, 1000);
}
public void transfer(int from, int to, int num) {
try {
lock.lock();
accounts[from] -= num;
accounts[to] += num;
//計算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
} finally {
lock.unlock();
}
}
}
經過加鎖,無論多少線程同時運行,都不會導致數據錯亂。
鎖是可以重入的,已經持有鎖的線程可以重復獲取已經持有的鎖。鎖有一個持有計數(hold count)來跟蹤 lock 方法的嵌套調用。每 lock 一次計數+1,unlock 一次計數-1,當 lock 為 0 時鎖釋放掉。
可以通過帶 boolean 參數構造一個帶有公平策略的鎖--new ReentrantLock(true)。公平鎖偏愛等待時間最長的線程。但是會導致性能大幅降低,而且即使使用公平鎖,也不能確保線程調度器是公平的。
條件對象
通常我們會遇到這樣的問題,當一個線程獲取鎖后,發現需要滿足某個條件才能繼續往后執行,這就需要一個條件對象來管理已經獲取鎖但是卻不能做有用工作的線程。
現在來考慮給轉賬加一個限制,只有資金充足的賬戶才能作為轉出賬戶,也就是不能出現負值。注意下面的代碼是不可行的:
if(bank.accounts[from]>=num){
bank.transfer(from,to,num);
}
因為多線程下極有可能 if 判斷成功后,剛好數據被其他線程修改了。
可以通過條件對象來這樣實現判斷:
class Bank {
/**
* 10個資金賬戶
*/
public int[] accounts = new int[10];
private ReentrantLock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
public Bank() {
Arrays.fill(accounts, 1000);
}
public void transfer(int from, int to, int num) {
try {
lock.lock();
while (accounts[from] < num) {
//進入阻塞狀態
condition.await();
}
accounts[from] -= num;
accounts[to] += num;
//計算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
//通知解除阻塞
condition.signalAll();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
在 while 循環中判斷是否滿足,如果不滿足條件,調用await方法進入阻塞狀態,同時放棄鎖。這樣讓其他線程有機會給轉出賬戶轉入資金也滿足判斷條件。
當某一個線程完成轉賬工作后,應該調用signalAll方法讓所有阻塞線程接觸阻塞狀態,因為此時可能會滿足判斷條件,可以繼續轉賬操作。
注意:調用signalAll不會立即激活一個等待線程,僅僅只是接觸阻塞狀態,以便這些線程可以通過競爭獲取鎖,繼續進行 while 判斷。
還有一個方法signal隨機解除一個線程的阻塞狀態。這里可能會導致死鎖的產生。
synchronized 關鍵詞
上一節中的 Lock 和 Condition 為開發人員提供了強大的同步控制。但是大多數情況並不需要那么復雜的控制。從 java 1.0 版本開始,Java 中的每個對象都有一個內部鎖。如果一個方法用synchronized聲明,那么對象的鎖將保護整個方法,也就是調用方法時自動獲取內部鎖,方法結束時自動解除內部鎖。
同 ReentrantLock 鎖一樣,內部鎖也有 wait/notifyAll/notify 方法,對應關系如下:
- wait 對應 await
- notifyAll 對應 signalAll
- notify 對應 signal
之所以方法名不同是因為 wait 這幾個方法是 Object 類的 final 方法,為了不發生沖突,ReentrantLock類中方法需要重命名。
用 synchronized 實現的 ban 類如下:
class Bank {
/**
* 10個資金賬戶
*/
public int[] accounts = new int[10];
private ReentrantLock lock = new ReentrantLock();
// private Condition condition = lock.newCondition();
public Bank() {
Arrays.fill(accounts, 1000);
}
synchronized public void transfer(int from, int to, int num) {
try {
// lock.lock();
while (accounts[from] < num) {
//進入阻塞狀態
// condition.await();
this.wait();
}
accounts[from] -= num;
accounts[to] += num;
//計算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
//通知解除阻塞
// condition.signalAll();
this.notifyAll();
} catch (Exception e) {
e.printStackTrace();
}
// finally {
// lock.unlock();
// }
}
}
靜態方法也可以聲明為 synchronized,調用這中方法,獲取到的是對應類的類對象的內部鎖。
代碼中怎么用
-
最好既不使用 Lock/Condition 也不使用 synchronized 關鍵字,大多是情況下都可以用 java.util.concurrent 包中的類來完成數據同步,該包中的類都是線程安全的。會在下一篇中講到。
-
如果能用 synchronized 的,盡量用它,這樣既可以減少代碼數量,減少出錯的幾率。
-
如果上面都不能解決問題,那就只能使用 Lock/Condition 了。
本篇所用全部代碼:github
