個人博客請訪問 http://www.x0100.top
1. 併發編程介紹
1.1 併發的出現
單CPU時代,單任務在一個時間點只能執行單一程序。
多任務階段,計算機能在同一時間點並行執行多進程。多個任務或進程共享一個CPU,並交由操作系統來完成多任務間對CPU的運行切換,以使得每個任務都有機會獲得一定的時間片運行。
現代的計算機多核CPU,在一個程序內部能擁有多個線程並行執行,多個CPU同時執行該程序。一個進程就包括了多個線程,每個線程負責一個獨立的子任務。
進程讓操作系統的併發性成爲可能,而線程讓進程的內部併發成爲可能。
一個進程雖然包括多個線程,但是這些線程是共同享有進程佔有的資源和地址空間的。
進程是操作系統進行資源分配的基本單位,而線程是操作系統進行調度的基本單位。
1.2 併發編程優點
1)資源利用率更好
舉例:
一個程序讀取文件(5s)和處理文件(2s),處理2個文件。
5秒讀取文件A
2秒處理文件A
5秒讀取文件B
2秒處理文件B
總共需要14秒。讀取文件的時候,CPU空閒等待讀取數據,浪費CPU資源。
併發處理:
5秒讀取文件A
5秒讀取文件B + 2秒處理文件A
2秒處理文件B
總共需要12秒。當第二文件在被讀取的時候,利用CPU的空閒去處理第一個文件。
2)程序設計在某些情況下更簡單
如上述讀取處理文件舉例中,如果使用單線程實現,需要每個文件讀取和處理的狀態;而使用多線程,每個線程處理一個文件的讀取和處理,不需要記錄文件讀取和處理狀態,實現更簡單。
3)程序響應更快
併發編程缺點
1)設計更復雜
由於多個線程是共同佔有所屬進程的資源和地址空間的,那麼就會存在多個線程同時訪問同一個資源的問題,可能導致線程安全問題。避免多線程編程中線程安全設計較複雜。
2)上下文切換的開銷
CPU從執行一個線程切換到執行另外一個線程的時候,需要先存儲當前線程的本地的數據,程序指針等,然後載入另一個線程的本地數據,程序指針等,最後纔開始執行。這種切換稱爲上下文切換。
對於線程的上下文切換實際上就是存儲和恢復CPU狀態的過程,它使得線程執行能夠從中斷點恢復執行。
雖然多線程可以使得任務執行的效率得到提升,但是由於在線程切換時同樣會帶來一定的開銷代價,並且多個線程會導致系統資源佔用的增加,所以在進行多線程編程時要注意這些因素。
3)增加資源消耗
線程在運行的時候需要從計算機裏面得到一些資源。除了CPU,線程還需要一些內存來維持它本地的堆棧。它也需要佔用操作系統中一些資源來管理線程。
2. 線程安全問題
競態條件:當多個線程同時訪問同一個資源,其中的一個或者多個線程對這個資源進行了寫操作,對資源的訪問順序敏感,就稱存在競態條件。多個線程同時讀同一個資源不會產生競態條件。
臨界區:導致競態條件發生的代碼區稱作臨界區。在臨界區中使用適當的同步就可以避免競態條件。
public class Counter {
protected long count = 0;
public void add(long value){
this.count = this.count + value;
}
}
多線程同時執行上面的代碼可能會出錯:多線程同時執行臨界區代碼this.count = this.count + value時,同時對同一資源this.count進行寫操作,產生了競態條件。
基本上所有的併發模式在解決線程安全問題時,都採用“序列化訪問臨界資源”的方案,即在同一時刻,只能有一個線程訪問臨界資源,也稱作同步互斥訪問。通常來說,是在訪問臨界資源的代碼前面加上一個鎖,當訪問完臨界資源後釋放鎖,讓其他線程繼續訪問。
在Java中,提供了兩種方式來實現同步互斥訪問:synchronized和Lock。
3. 線程通信
線程通信的目標是使線程間能夠互相發送信號。另一方面,線程通信使線程能夠等待其他線程的信號。
通過共享對象通信
// 必須是同一個MySignal實例,通過共享變量hasDataToProcess通信
public class MySignal {
protected boolean hasDataToProcess = false;
public synchronized boolean hasDataToProcess() {
return this.hasDataToProcess;
}
public synchronized void setHasDataToProcess(boolean hasData) {
this.hasDataToProcess = hasData;
}
}
單線程A完成某一操作M之後,調用setHasDataToProcess(true),將hasDataToProcess置爲true,表示操作M完成。
線程B調用hasDataToProcess()獲取hasDataToProcess爲true,就知道操作M已經完成。
wait() - notify()/notifyAll()
//A線程調用doWait()等待, B線程調用doNotify()喚醒A線程
public class MyWaitNotify {
MonitorObject myMonitorObject = new MonitorObject();
public void doWait(){
synchronized(myMonitorObject){
try{
myMonitorObject.wait();
} catch(InterruptedException e){...}
}
}
public void doNotify() {
synchronized (myMonitorObject) {
myMonitorObject.notify();
}
}
}
優化:
-
增加boolean wasSignalled,記錄是否收到喚醒信號。只有沒收到過喚醒信號時纔可以wait,避免信號丟失導致永久wait。
-
while()自旋鎖,線程被喚醒之後可以保證再次檢查條件是否滿足,避免虛假信號。
public class MyWaitNotify3 {
MonitorObject myMonitorObject = new MonitorObject();
boolean wasSignalled = false;
public void doWait() {
synchronized (myMonitorObject) {
while (!wasSignalled) {
try {
myMonitorObject.wait();// 如果被虛假喚醒,再回while循環檢查條件wasSignalled
} catch (InterruptedException e) {
}
}
wasSignalled = false;
}
}
public void doNotify() {
synchronized (myMonitorObject) {
wasSignalled = true;
myMonitorObject.notify();
}
}
}
4. 死鎖
死鎖:多個線程同時但以不同的順序請求同一組鎖的時候,線程之間互相循環等待鎖導致線程一直阻塞。
如果線程1鎖住了A,然後嘗試對B進行加鎖,同時線程2已經鎖住了B,接着嘗試對A進行加鎖,這樣線程1持有鎖A等待鎖B,線程2持有鎖B等待鎖A,就會發生死鎖。
死鎖可能不止包含2個線程,可以包含多個線程。如線程1等待線程2,線程2等待線程3,線程3等待線程4,線程4等待線程1。
舉例:
public class Test {
static Object lockObject1 = new Object();
static Object lockObject2 = new Object();
public static void main(String[] args) {
new Thread() {
@Override
public void run() {
synchronized (lockObject1) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockObject2) {
System.out.println(1);
}
}
}
}.start();
new Thread() {
@Override
public void run() {
synchronized (lockObject2) {
synchronized (lockObject1) {
System.out.println(1);
}
}
}
}.start();
}
}
如何避免死鎖?
1)按順序加鎖
多個線程請求的一組鎖按順序加鎖可以避免死鎖。
死鎖:如果線程1鎖住了A,然後嘗試對B進行加鎖,同時線程2已經鎖住了B,接着嘗試對A進行加鎖,發生死鎖。
解決:規定鎖A和鎖B的順序,某個線程需要同時獲取鎖A和鎖B時,必須先拿鎖A再拿鎖B。線程1和線程2都先鎖A再鎖B,不會發生死鎖。
問題:需要事先知道所有可能會用到的鎖,並對這些鎖做適當的排序。
2)加鎖時限(超時重試機制)
設置一個超時時間,在嘗試獲取鎖的過程中若超過了這個時限該線程則放棄對該鎖請求,回退並釋放所有已經獲得的鎖,然後等待一段隨機的時間再重試。
這段隨機的等待時間讓其它線程有機會嘗試獲取相同的這些鎖,並且讓該應用在沒有獲得鎖的時候可以繼續運行乾點其它事情。
問題:
-
當線程很多時,等待的這一段隨機的時間會一樣長或者很接近,因此就算出現競爭而導致超時後,由於超時時間一樣,它們又會同時開始重試,導致新一輪的競爭,帶來了新的問題。
-
不能對synchronized同步塊設置超時時間。需要創建一個自定義鎖,或使用java.util.concurrent包下的工具。
3)死鎖檢測
主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的情況。
每當一個線程獲得了鎖,會在線程和鎖相關的數據結構中(比如map)將其記下。當一個線程請求鎖失敗時,這個線程可以遍歷鎖的關係圖看看是否有死鎖發生。
例如:線程1請求鎖A,但是鎖A這個時候被線程2持有,這時線程1就可以檢查一下線程2是否已經請求了線程1當前所持有的鎖。
如果線程2確實有這樣的請求,那麼就是發生了死鎖(線程1擁有鎖B,請求鎖A;線程B擁有鎖A,請求鎖B)。
當檢測出死鎖時,可以有兩種做法:
-
釋放所有鎖,回退,並且等待一段隨機的時間後重試。(類似超時重試機制)
-
給這些線程設置優先級,讓一個(或幾個)線程回退,剩下的線程就像沒發生死鎖一樣繼續保持着它們需要的鎖。
5. 嵌套管程鎖死
線程1獲得A對象的鎖。
線程1獲得對象B的鎖(A對象鎖還未釋放)。
線程1調用B.wait(),從而釋放了B對象上的鎖,但仍然持有對象A的鎖。
線程2需要同時持有對象A和對象B的鎖,才能向線程1發信號B.notify()。
線程2無法獲得對象A上的鎖,因爲對象A上的鎖當前正被線程1持有。
線程2一直被阻塞,等待線程1釋放對象A上的鎖。
線程1一直阻塞,等待線程2的信號,因此不會釋放對象A上的鎖。
舉例:
public class Lock {
protected MonitorObject monitorObject = new MonitorObject();
protected boolean isLocked = false;
public void lock() throws InterruptedException {
synchronized (this) {
while (isLocked) {
synchronized (this.monitorObject) {
this.monitorObject.wait();
}
}
isLocked = true;
}
}
public void unlock() {
synchronized (this) {
this.isLocked = false;
synchronized (this.monitorObject) {
this.monitorObject.notify();
}
}
}
}
線程1調用lock()方法,Lock對象鎖和monitorObject鎖,調用monitorObject.wait()阻塞,但仍然持有Lock對象鎖。
線程2調用unlock()方法解鎖時,無法獲取Lock對象鎖,因爲線程1一直持有Lock鎖,造成嵌套管程鎖死。
6. 重入鎖死
如果一個線程持有某個對象上的鎖,那麼它就有權訪問所有在該對象上同步的塊,這就叫可重入。synchronized、ReentrantLock都是可重入鎖。
如果一個線程持有鎖A,鎖A是不可重入的,該線程再次請求鎖A時被阻塞,就是重入鎖死。
重入鎖死舉例:
public class Lock {
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait();
}
isLocked = true;
}
public synchronized void unlock() {
isLocked = false;
notify();
}
}
如果一個線程兩次調用lock()間沒有調用unlock()方法,那麼第二次調用lock()就會被阻塞,這就出現了重入鎖死。
7. 飢餓和公平
如果一個線程因爲CPU時間全部被其他線程搶走而得不到CPU運行時間,這種狀態被稱之爲飢餓。
導致線程飢餓原因:
-
高優先級線程吞噬所有的低優先級線程的CPU時間。
-
線程始終競爭不到鎖。
-
線程調用object.wait()後沒有被喚醒。
解決飢餓的方案被稱之爲公平性,即所有線程均能公平地獲得運行機會。關於公平鎖會在之後ReentrantLock中詳細介紹。
總結
併發編程可以更好的利用CPU資源,更高效快速的響應程序,但是設計較複雜,並且上下文切換會造成一定的消耗。
併發編程中,由於多個線程同時訪問同一個資源,可能造成線程安全問題,Java中可以通過synchronized和Lock的方式實現同步解決線程安全問題。
更好的發揮多線程的優勢需要線程之間通信,常用的線程通信方式是通過共享對象的狀態通信和wait()/notify()。
多個線程同時但以不同的順序請求同一組鎖的時候,線程之間互相循環等待鎖導致線程一直阻塞,造成死鎖。最常用的解決死鎖的方式是按順序加鎖。
線程持有不可重入鎖之後再次請求不可重入鎖時被阻塞,就是重入鎖死。
如果一個線程因爲CPU時間全部被其他線程搶走而得不到CPU運行時間,這種狀態被稱之爲飢餓。