Java併發學習筆記
一、程序 執行緒
程序:一個程序來對應一個程式,
每個程序對應一定的記憶體地址空間,並且只能使用它自己的記憶體空間,各個程序間互不干擾。
程序儲存了程式每個時刻的執行狀態,這樣就為程序切換提供了可能。當程序暫停時,它會儲存當前程序的狀態(比如程序標識、程序的使用的資源等),在下一次重新切換回來時,便根據之前儲存的狀態進行恢復,然後繼續執行。
對於單核計算機來講,在同一個時間點上,遊戲程序和音樂程序是同時在執行嗎?
不是。 因為計算機的 CPU 只能在某個時間點上做一件事。
由於計算機將在“遊戲程序”和“音樂程序”之間頻繁的切換執行,切換速度極高,人類感覺遊戲和音樂在同時進行。 多程序的作用不是提高執行速度,而是提高 CPU 的使用率。
執行緒:一個程序就包括了多個執行緒,每個執行緒負責一個獨立的子任務。這些執行緒是共同享有程序佔有的資源和地址空間的。
這樣在使用者點選按鈕的時候,就可以暫停獲取影象資料的執行緒,讓UI執行緒響應使用者的操作,響應完之後再切換回來,讓獲取影象的執行緒得到CPU資源。從而讓使用者感覺系統是同時在做多件事情的,滿足了使用者對實時性的要求。
程序讓作業系統的併發性成為可能,而執行緒讓程序的內部併發成為可能。
資源利用率更好
程式設計在某些情況下更簡單
程式響應更快
設計更復雜
上下文切換的開銷
增加資源消耗
上下文切換:
對於單核CPU來說(對於多核CPU,此處就理解為一個核),CPU在一個時刻只能執行一個執行緒,當在執行一個執行緒的過程中轉去執行另外一個執行緒,這個叫做執行緒上下文切換(對於程序也是類似)。
由於可能當前執行緒的任務並沒有執行完畢,所以在切換時需要儲存執行緒的執行狀態,以便下次重新切換回來時能夠繼續切換之前的狀態執行。
所以一般來說,執行緒上下文切換過程中會記錄程式計數器、CPU暫存器狀態等資料。
說簡單點的:對於執行緒的上下文切換實際上就是儲存和恢復CPU狀態的過程,它使得執行緒執行能夠從中斷點恢復執行。
雖然多執行緒可以使得任務執行的效率得到提升,但是由於線上程切換時同樣會帶來一定的開銷代價,並且多個執行緒會導致系統資源佔用的增加,所以在進行多執行緒程式設計時要注意這些因素。
二、建立、啟動執行緒
繼承Thread類
public class Test {public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); } } class MyThread extends Thread{ private static int num = 0; public MyThread(){ num++; } @Override public void run() { System.out.println("主動建立的第"+num+"個執行緒"); } }
實現Runnable介面
public class Test { public static void main(String[] args) { Thread thread = new Thread(new MyRunnable()); thread.start(); } } class MyRunnable implements Runnable{ public MyRunnable() { } @Override public void run() { System.out.println("子執行緒ID:"+Thread.currentThread().getId()); } }
注意:建立並執行一個執行緒呼叫start()方法 而不是run()
三、Thread類/執行緒狀態
1)getId 用來得到執行緒ID
2)getName和setName 用來得到或者設定執行緒名稱。
3)getPriority和setPriority 用來獲取和設定執行緒優先順序。
4)setDaemon和isDaemon
用來設定執行緒是否成為守護執行緒和判斷執行緒是否是守護執行緒。
守護執行緒和使用者執行緒的區別在於:守護執行緒依賴於建立它的執行緒,而使用者執行緒則不依賴。
舉個簡單的例子:如果在main執行緒中建立了一個守護執行緒,當main方法執行完畢之後,守護執行緒也會隨著消亡。而使用者執行緒則不會,使用者執行緒會一直執行直到其執行完畢。在JVM中,像垃圾收集器執行緒就是守護執行緒。
5)Thread類有一個比較常用的靜態方法currentThread()用來獲取當前執行緒。
6)start方法
7)run方法
8)sleep方法 sleep相當於讓執行緒睡眠,交出CPU,讓CPU去執行其他的任務。sleep方法不會釋放鎖。
9)yield方法
呼叫yield方法會讓當前執行緒交出CPU許可權,讓CPU去執行其他的執行緒。不會釋放鎖。
呼叫yield方法並不會讓執行緒進入阻塞狀態,而是讓執行緒重回就緒狀態。
10)join方法
假如在main執行緒中,呼叫myThread.join()方法,則main方法會等待thread執行緒執行完畢或者等待一定的時間。
如果呼叫的是無參join方法,則等待thread執行完畢,如果呼叫的是指定了時間引數的join方法,則等待一定的事件。
11)interrupt方法
單獨呼叫interrupt方法可以使得處於阻塞狀態的執行緒丟擲一個異常,它可以用來中斷一個正處於阻塞狀態的執行緒。
舉例:
控制檯輸出: 進入睡眠狀態 得到中斷異常 run方法執行完畢
public class Test { public static void main(String[] args) throws IOException { Test test = new Test(); MyThread thread = test.new MyThread(); thread.start(); try { Thread.currentThread().sleep(2000); } catch (InterruptedException e) { } thread.interrupt(); } class MyThread extends Thread{ @Override public void run() { try { System.out.println("進入睡眠狀態"); Thread.currentThread().sleep(10000); System.out.println("睡眠完畢"); } catch (InterruptedException e) { System.out.println("得到中斷異常"); } System.out.println("run方法執行完畢"); } } }
另:wait() notify() notifyAll()是Object類的方法
12)wait()
執行緒阻塞,JVM將該執行緒放置在目標物件的等待集合中。
釋放呼叫wait()物件的同步鎖,但是除此之外的其他鎖依然由該執行緒持有。
即使是在wait()物件多次巢狀同步鎖,所持有的可重入鎖也會完整的釋放。這樣,後面恢復的時候,當前的鎖狀態能夠完全地恢復。
object.wait() object.notify() object.notifyAll() 呼叫之前需要先拿到object鎖。
13)notify()
Java虛擬機器從目標物件的等待集合中隨意選擇一個執行緒(稱為T,前提是等待集合中還存在一個或多個執行緒)並從等待集合中移出T。當等待集合中存在多個執行緒時,並沒有機制保證哪個執行緒會被選擇到。
呼叫notify()的執行緒釋放鎖,執行緒T競爭鎖,如果競爭到鎖,執行緒T從之前wait的點開始繼續執行。
14)notifyAll()
notifyAll方法與notify方法的執行機制是一樣的。物件等待集合中的所有執行緒都移出,進入可執行狀態。
四、執行緒安全
競態條件:當多個執行緒同時訪問同一個資源,其中的一個或者多個執行緒對這個資源進行了寫操作,對資源的訪問順序敏感,就稱存在競態條件。多個執行緒同時讀同一個資源不會產生競態條件。
臨界區:導致競態條件發生的程式碼區稱作臨界區。在臨界區中使用適當的同步就可以避免競態條件。
基本上所有的併發模式在解決執行緒安全問題時,都採用“序列化訪問臨界資源”的方案,即在同一時刻,只能有一個執行緒訪問臨界資源,也稱作同步互斥訪問。
通常來說,是在訪問臨界資源的程式碼前面加上一個鎖,當訪問完臨界資源後釋放鎖,讓其他執行緒繼續訪問。
在Java中,提供了兩種方式來實現同步互斥訪問:synchronized和Lock。
五、Java同步塊(synchronized block)
Java中的同步塊用synchronized標記。
同步塊在Java中是同步在某個物件上(監視器物件)。
所有同步在一個物件上的同步塊在同時只能被一個執行緒進入並執行操作。
所有其他等待進入該同步塊的執行緒將被阻塞,直到執行該同步塊中的執行緒退出。
(注:不要使用全域性物件(常量等)做監視器。應使用唯一對應的物件)
public class MyClass { int count; // 1.例項方法 public synchronized void add(int value){ count += value; } // 2.例項方法中的同步塊 (等價於1) public void add(int value){ synchronized(this){ count += value; } } // 3.靜態方法 public static synchronized void add(int value){ count += value; } // 4.靜態方法中的同步塊 (等價於3) public static void add(int value){ synchronized(MyClass.class){ count += value; } } }
六、執行緒通訊
執行緒通訊的目標是使執行緒間能夠互相傳送訊號。另一方面,執行緒通訊使執行緒能夠等待其他執行緒的訊號。
通過共享物件通訊
// 必須是同一個MySignal例項 public class MySignal{ protected boolean hasDataToProcess = false; public synchronized boolean hasDataToProcess(){ return this.hasDataToProcess; } public synchronized void setHasDataToProcess(boolean hasData){ this.hasDataToProcess = hasData; } }
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(); } } }
問題一 訊號丟失:先呼叫了notify()後呼叫wait(),執行緒會一直等待下去
// 增加boolean wasSignalled, 記錄是否收到喚醒訊號。只有沒收到過喚醒訊號時才可以wait,避免訊號丟失。 public class MyWaitNotify2 { MonitorObject myMonitorObject = new MonitorObject(); boolean wasSignalled = false; public void doWait(){ synchronized(myMonitorObject){ if(!wasSignalled){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } wasSignalled = false;// wait+notify之後將訊號清除 } } public void doNotify() { synchronized (myMonitorObject) { wasSignalled = true; myMonitorObject.notify(); } } }
問題二 虛假訊號:
假設執行緒A1因為某種條件在條件佇列中等待,同時執行緒A2因為另外一種條件在同一個條件佇列中等待,也就是說執行緒A1/A2都被同一個Object.wait()掛起,但是等待的條件不同。
此時滿足A2的條件,允許執行緒B執行一個Object.notify()操作去喚醒A2,但是JVM從Object.wait()的多個執行緒(A1/A2)中隨機挑選一個喚醒,可能喚醒了A1。
A1執行緒即使沒有收到正確的訊號,也能夠執行後續的操作。A1收到的就是虛假訊號。
而此時A2仍然在傻傻的等待被喚醒的訊號。A2則訊號丟失。
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(); } } }
這樣的一個while迴圈叫做自旋鎖(注:這種做法要慎重,目前的JVM實現自旋會消耗CPU,如果長時間不呼叫doNotify方法,doWait方法會一直自旋,CPU會消耗太大)
問題三 多個執行緒等待相同訊號,notifyAll() (while好處)
如果有多個執行緒在等待,被notifyAll()喚醒,第一個獲得鎖的執行緒被正確執行。
用if():其它執行緒獲得鎖後,不管條件wasSignalled是否滿足都會直接wait往後執行;
用while():其它執行緒獲得鎖後先檢查條件wasSignalled,如果不滿足就繼續wait
(注:不要使用全域性物件(常量等)做監視器。應使用唯一對應的物件)
總之,用while()自旋鎖,執行緒被喚醒之後可以保證再次檢查條件是否滿足。
七、死鎖
多個執行緒同時但以不同的順序請求同一組鎖的時候可能死鎖。
如果執行緒1鎖住了A,然後嘗試對B進行加鎖,同時執行緒2已經鎖住了B,接著嘗試對A進行加鎖,這時死鎖就發生了。
如果執行緒1稍微領先執行緒2,然後成功地鎖住了A和B兩個物件,那麼執行緒2就會在嘗試對B加鎖的時候被阻塞,這樣死鎖就不會發生。因為執行緒排程通常是不可預測的,因此沒有一個辦法可以準確預測什麼時候死鎖會發生,僅僅是可能會發生。
/** * 如果執行緒1呼叫parent.addChild(child)方法的同時有另外一個執行緒2呼叫child.setParent(parent)方法, * 兩個執行緒中的parent表示的是同一個物件,child亦然,此時就可能發生死鎖。 */ public class TreeNode { TreeNode parent = null; List children = new ArrayList(); public synchronized void addChild(TreeNode child) {// parent物件鎖 if (!this.children.contains(child)) { this.children.add(child); child.setParentOnly(this);// child物件鎖 } } public synchronized void addChildOnly(TreeNode child){// parent物件鎖 if(!this.children.contains(child){ this.children.add(child); } } public synchronized void setParent(TreeNode parent) {// child物件鎖 this.parent = parent; parent.addChildOnly(this);// parent物件鎖 } public synchronized void setParentOnly(TreeNode parent) {// child物件鎖 this.parent = parent; } }
更復雜的死鎖
死鎖可能不止包含2個執行緒。四個執行緒死鎖的例子:
Thread 1 locks A, waits for BThread 2 locks B, waits for CThread 3 locks C, waits for DThread 4 locks D, waits for A
執行緒1等待執行緒2,執行緒2等待執行緒3,執行緒3等待執行緒4,執行緒4等待執行緒1。
避免死鎖
1、加鎖順序
多個執行緒請求的一組鎖按順序加鎖可以避免死鎖。
比如解決:如果執行緒1鎖住了A,然後嘗試對B進行加鎖,同時執行緒2已經鎖住了B,接著嘗試對A進行加鎖,這時死鎖就發生了。
執行緒1和執行緒2都先鎖A再鎖B,不會發生死鎖。
問題:這種方式需要你事先知道所有可能會用到的鎖,並對這些鎖做適當的排序。
2、加鎖時限(超時重試機制)
設定一個超時時間,在嘗試獲取鎖的過程中若超過了這個時限該執行緒則放棄對該鎖請求,回退並釋放所有已經獲得的鎖,然後等待一段隨機的時間再重試。
這段隨機的等待時間讓其它執行緒有機會嘗試獲取相同的這些鎖,並且讓該應用在沒有獲得鎖的時候可以繼續執行乾點其它事情。
問題:1)當執行緒很多時,等待的這一段隨機的時間會一樣長或者很接近, 因此就算出現競爭而導致超時後,由於超時時間一樣,它們又會同時開始重試,導致新一輪的競爭,帶來了新的問題。
2)不能對synchronized同步塊設定超時時間。需要建立一個自定義鎖,或使用java.util.concurrent包下的工具。
3、死鎖檢測
主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的情況。
每當一個執行緒獲得了鎖獲請求鎖,會線上程和鎖相關的資料結構中(比如map)將其記下。當一個執行緒請求鎖失敗時,這個執行緒可以遍歷鎖的關係圖看看是否有死鎖發生。
例如:執行緒A請求鎖7,但是鎖7這個時候被執行緒B持有,這時執行緒A就可以檢查一下執行緒B是否已經請求了執行緒A當前所持有的鎖。如果執行緒B確實有這樣的請求,那麼就是發生了死鎖(執行緒A擁有鎖1,請求鎖7;執行緒B擁有鎖7,請求鎖1)。
當檢測出死鎖時,可以有兩種做法:
1)釋放所有鎖,回退,並且等待一段隨機的時間後重試。(類似超時重試機制)
2)給這些執行緒設定優先順序,讓一個(或幾個)執行緒回退,剩下的執行緒就像沒發生死鎖一樣繼續保持著它們需要的鎖。
八、飢餓和公平
如果一個執行緒因為CPU時間全部被其他執行緒搶走而得不到CPU執行時間,這種狀態被稱之為飢餓。
解決飢餓的方案被稱之為公平性 – 即所有執行緒均能公平地獲得執行機會。
導致執行緒飢餓原因:
1)高優先順序執行緒吞噬所有的低優先順序執行緒的CPU時間。
2)執行緒始終競爭不到鎖。
3)執行緒呼叫object.wait()後沒有被喚醒。
Java中實現公平性方案
使用鎖方式替代同步塊(不能實現公平性)
/** * 第一個執行緒呼叫lock(),isLocked=true * 之後的執行緒呼叫lock(),都被wait() * 第一個執行緒執行完釋放鎖,呼叫unlock()。isLocked = false允許其它執行緒拿鎖,notify()喚醒一個執行緒 */ public class Lock { private boolean isLocked = false; private Thread lockingThread = null; public synchronized void lock() throws InterruptedException { while (isLocked) { wait(); } isLocked = true; lockingThread = Thread.currentThread(); } public synchronized void unlock() { if (this.lockingThread != Thread.currentThread()) { throw new IllegalMonitorStateException("Calling thread has not locked this lock"); } isLocked = false; lockingThread = null; notify(); } }
公平鎖:
/** * 第一個執行緒呼叫lock(),isLocked=true * 之後的執行緒呼叫lock()拿鎖,new queueObject()存入佇列並wait() * 第一個執行緒執行完畢呼叫unlock(),isLocked=false,取出佇列頭部的queueObject物件notify(),保證了先wait的執行緒按順序先notify(即公平性) * * 1.synchronized的同步塊走完就會釋放鎖,沒有巢狀,不存在死鎖和巢狀鎖死 */ public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException { QueueObject queueObject = new QueueObject(); boolean isLockedForThisThread = true; synchronized (this) { waitingThreads.add(queueObject); } while (isLockedForThisThread) { synchronized (this) { isLockedForThisThread = isLocked || waitingThreads.get(0) != queueObject; if (!isLockedForThisThread) { isLocked = true; waitingThreads.remove(queueObject); lockingThread = Thread.currentThread(); return; } } try { queueObject.doWait(); } catch (InterruptedException e) { synchronized (this) { waitingThreads.remove(queueObject); } throw e; } } } public synchronized void unlock() { if (this.lockingThread != Thread.currentThread()) { throw new IllegalMonitorStateException("Calling thread has not locked this lock"); } isLocked = false; lockingThread = null; if (waitingThreads.size() > 0) { waitingThreads.get(0).doNotify(); } } } public class QueueObject { private boolean isNotified = false; public synchronized void doWait() throws InterruptedException { while (!isNotified) { this.wait(); } this.isNotified = false; } public synchronized void doNotify() { this.isNotified = true; this.notify(); } public boolean equals(Object o) { return this == o; } }
九、巢狀管程鎖死
執行緒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(); } } } }
十、滑動條件(Slipped Conditions)
一個執行緒檢查某一條件到該執行緒操作此條件期間,這個條件已經被其它執行緒改變,導致第一個執行緒在該條件上執行了錯誤的操作。
/** * 兩個執行緒同時呼叫lock() * 第一個執行緒執行到兩個同步塊之間時,此時isLocked=false * 第二個執行緒開始執行lock(),會跳過while迴圈,走出第一個同步塊。(正確執行:第一個執行緒lock了,第二個執行緒要wait) */ public class Lock { private boolean isLocked = false; public void lock() { synchronized (this) { while (isLocked) { try { this.wait(); } catch (InterruptedException e) { // do nothing, keep waiting } } } synchronized (this) { isLocked = true; } } public synchronized void unlock() { isLocked = false; this.notify(); } }
為避免slipped conditions,條件的檢查與設定必須是原子的,也就是說,在條件檢查和設定期間,不會有其它執行緒操作這個條件。
修改:
public class Lock { private boolean isLocked = false; public void lock() { synchronized (this) { while (isLocked) { try { this.wait(); } catch (InterruptedException e) { // do nothing, keep waiting } } isLocked = true;// isLocked的檢查和修改要具有原子性 } } public synchronized void unlock() { isLocked = false; this.notify(); } }
十一、重入鎖死
如果一個執行緒持有某個物件上的鎖,那麼它就有權訪問所有在該物件上同步的塊。這就叫可重入。若執行緒已經持有鎖,那麼它就可以重複訪問所有使用該鎖的程式碼塊。
重入鎖死舉例:
/** * 如果一個執行緒兩次呼叫lock()間沒有呼叫unlock()方法,那麼第二次呼叫lock()就會被阻塞,這就出現了重入鎖死。 */ 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(); } }
十二、併發程式設計模型
並行工作者模型
在並行工作者模型中,委派者(Delegator)將傳入的作業分配給不同的工作者。每個工作者完成整個任務。工作者們並行運作在不同的執行緒上,甚至可能在不同的CPU上。
如果在某個汽車廠裡實現了並行工作者模型,每臺車都會由一個工人來生產。工人們將拿到汽車的生產規格,並且從頭到尾負責所有工作。
優點:容易實現,容易理解
缺點:
(1)共享狀態可能會很複雜。
執行緒需要以某種方式存取共享資料,以確保某個執行緒的修改能夠對其他執行緒可見(資料修改需要同步到主存中,不僅僅將資料儲存在執行這個執行緒的CPU的快取中)。
在等待訪問共享資料結構時,執行緒之間的互相等待將會丟失部分並行性。
非阻塞併發演算法和可持久化的資料結構 可以降低競爭並提升效能,但是實現比較困難。
(2)共享狀態能夠被系統中得其他執行緒修改。所以執行緒在每次需要的時候必須重讀狀態,會導致速度變慢,特別是狀態儲存在外部資料庫中的時候。
(3)任務順序是不確定的
流水線模式
每個工作者只負責作業中的部分工作。當完成了自己的這部分工作時工作者會將作業轉發給下一個工作者。每個工作者在自己的執行緒中執行,並且不會和其他工作者共享狀態。
Actors 和 channels 是兩種比較類似的流水線(或反應器/事件驅動)模型。
優點:
(1)工作者之間無需共享狀態,意味著實現的時候無需考慮所有因併發訪問共享物件而產生的併發性問題。
這使得在實現工作者的時候變得非常容易。在實現工作者的時候就好像是單個執行緒在處理工作-基本上是一個單執行緒的實現。
(2)當工作者知道了沒有其他執行緒可以修改它們的資料,工作者可以變成有狀態的。
它們可以在記憶體中儲存它們需要操作的資料,只需在最後將更改寫回到外部儲存系統。因此,有狀態的工作者通常比無狀態的工作者具有更高的效能。
(3)較好的硬體整合,更好的利用快取。
(4)實現一個有保障的作業順序。
缺點:作業的執行往往分佈到多個工作者上,並因此分佈到專案中的多個類上。編碼難度大。
函式式並行
函式都是通過拷貝來傳遞引數的,所以除了接收函式外沒有實體可以操作資料。這對於避免共享資料的競態來說是很有必要的。同樣也使得函式的執行類似於原子操作。每個函式呼叫的執行獨立於任何其他函式的呼叫。
一旦每個函式呼叫都可以獨立的執行,它們就可以分散在不同的CPU上執行了。這也就意味著能夠在多處理器上並行的執行使用函式式實現的演算法。