Java執行緒安全--加鎖和防止死鎖
注:本文轉自:https://mp.weixin.qq.com/s/Xah_9zUQoECbQBmLTFsnSw
前言
關於執行緒安全問題是一塊非常基礎的知識,但基礎不代表簡單,一個人的基本功能往往能決定他是否可以寫出高質量、高效能的程式碼。關於什麼是synchronized、Lock、volatile,相信大家都能道出一二,但概念都懂一用就懵,一不小心還能寫出一個死鎖出來。
本文將基於生產者消費者模式加一個個具體案例,循序漸進的講解執行緒安全問題的誕生背景以及解決方案,一文幫你抓住synchronized的應用場景,以及與Lock的區別。
1. 執行緒安全問題的誕生背景以及解決方式
1.1 為什麼執行緒間需要通訊?
執行緒是CPU執行的基本單位,為了提高CPU的使用率以及模擬多個應用程式同時執行的場景,便衍生出了多執行緒的概念。
在JVM架構下堆記憶體、方法區是可以被執行緒共享的,那為什麼要這樣設計呢?
舉個例子簡要描述下:
現要做一個網路請求,請求響應後渲染到手機介面。Android為了提升使用者體驗將main執行緒當作UI執行緒,只做介面渲染,耗時操作應交由到工作執行緒。如若在UI執行緒執行耗時操作可能會出現阻塞現象,最直觀的感受就是介面卡死。網路請求屬於IO操作會出現阻塞想象,前面提到UI執行緒不允許出現阻塞現象,所以網路請求必須扔到工作執行緒,但拿到資料包後怎麼傳遞給UI執行緒呢?最常規的做法就是回撥介面,將HTTP資料包解析成本地模型,再通過介面將本地模型對應的堆記憶體地址值傳遞到UI執行緒。
工作執行緒將堆記憶體物件地址值交給UI執行緒這一過程,就是執行緒間通訊,也是JVM將堆記憶體設定為執行緒共享的原因,關於執行緒間通訊用一句通俗易懂的話描述就是:"多個執行緒操作同一資源",這一資源位於堆記憶體或方法區
1.2 單生產單消費引發的安全問題
"多個執行緒操作同一資源",聽起來如此的簡單,殊不知一不小心便可能引發致命問題。喲,此話怎講呢?,不急,容我娓娓道來...
案例
現有一個車輛公司,主要經營四輪小汽車和兩輪自行車,工人負責生產,銷售員負責售賣。
以上案例如何通過應用程式來實現?思路如下:
定義一個車輛資源類,可以設定為小汽車和自行車
public class Resource {//一輛車對應一個id private int id; //車名 private String name; //車的輪子數 private int wheelNumber; //標記(後面會用到) private boolean flag = false; ... 忽略setter、getter ... @Override public String toString() { return "id=" + id + "--- name=" + name + "--- wheelNumber=" + wheelNumber; } }
定義一個工人執行緒任務,專門用來生產四輪小汽車和倆輪自行車,為生產者
public class Input implements Runnable{ private Resource r; public Input(Resource r){ this.r = r; } public void run() { //無限生產車輛 for(int i =0;;i++){ if(i%2==0){ r.setId(i);//設定車的id r.setName("小汽車");//設定車型別 r.setWheelNumber(4);//設定車的輪子數 }else{ r.setId(i);//設定車的id r.setName("電動車");//設定車型別 r.setWheelNumber(2);//設定車的輪子數 } } } }
定義一個銷售員執行緒任務,專門用來銷售車輛,為消費者
public class Output implements Runnable{ private Resource r; public Output(Resource r){ this.r = r; } public void run() { //無限消費車輛 for(;;){ //消費車輛 System.out.println(r.toString()); } } }
開始生產、消費
//資源物件,對應車輛 Resource r = new Resource(); //生產者runnable,對應工人 Input in = new Input(r); //消費者runnable,對應銷售員 Output out = new Output(r); Thread t1 = new Thread(in); Thread t2 = new Thread(out); //開啟生產者執行緒 t1.start(); //開啟消費者執行緒 t2.start();
列印結果:
... id=51--- name=電動車--- wheelNumber=2 id=52--- name=小汽車--- wheelNumber=2 ...
一切有條不紊的進行,老闆數著鈔票那叫一個開心。吃水不忘挖井人,正當老闆準備給員工發獎金時,出現了一個嚴重問題 編號為52的小汽車少裝了倆輪子!!!得,獎金不僅沒了,還得連夜排查問題
導致原因:
tips:流程對應上面列印結果。下同
- 生產者執行緒得到CPU執行權,將name和
wheelNumber
分別設定為電動車和2,隨後CPU切換到了消費者執行緒。 - 消費者執行緒得到CPU執行權,此時name和
wheelNumber
別為電動車和2,隨後列印name=電動車--- wheelNumber=2
,CPU切換到了生產者執行緒。 - 生產者執行緒再次得到CPU執行權,將name設定為小汽車(未對wheelNumber進行設定),此時name和
wheelNumber
分別為小汽車和2,CPU切換到了消費者執行緒。 - 消費者執行緒得到CPU執行權,此時name和
wheelNumber
別為小汽車和2,隨後列印name=小汽車--- wheelNumber=2
工人:"生產到一半你銷售員就拿去賣了,這鍋我不背"
解決方案:
導致原因其實就是生產者對Resource的一次操作還未結束,消費者強行介入了。此時可以引入synchronized關鍵字,使得生產者一次工作結束前消費者不得介入
更改後的程式碼如下:
#Input public void run() { //無限生產車輛 for(int i =0;;i++){ synchronized(r){ if(i%2==0){ r.setId(i);//設定車的id r.setName("小汽車");//設定車型別 r.setWheelNumber(4);//設定車的輪子數 }else{ r.setId(i);//設定車的id r.setName("電動車");//設定車型別 r.setWheelNumber(2);//設定車的輪子數 } } } } #Output public void run() { for(;;){ synchronized(r){ //消費車輛 System.out.println(r.toString()); } } }
生產者和消費者for迴圈中都加了一個synchronized,對應的鎖是r,修改後重新執行
... id=79--- name=電動車--- wheelNumber=2 id=80--- name=小汽車--- wheelNumber=4 id=80--- name=小汽車--- wheelNumber=4 ...
一切又恢復了正常。但又暴露出一個更嚴重的問題,編號為80的小汽車被消費(銷售)了兩次
也既銷售員把一輛車賣給了兩個客戶,真乃商業奇才啊!!!
導致原因:
- 生產者執行緒得到CPU執行權,將name和
wheelNumber
分別設定為小汽車和4,隨後CPU執行權切換到了消費者執行緒。 - 消費者執行緒得到CPU執行權,此時name和
wheelNumber
別為小汽車和4,隨後列印name=小汽車--- wheelNumber=4
,但消費後 CPU執行權並未切換到生產者執行緒,而是由消費者執行緒繼續執行,於是就出現了編號為80的小汽車被列印(消費)了兩次
解決方案:
產生問題的原因就是消費者把資源消費後未處於等待狀態,而是繼續消費。此時可以引入wait、notify機制,使得銷售員售賣完一輛車後處於等待狀態,當工人重新生產一輛新車後再通知銷售員,銷售員接收到工人訊息後再進行售賣。
更改後的程式碼如下:
#Input public void run() { //無限生產車輛 for(int i =0;;i++){ synchronized(r){ //flag為true的時候代表已經生產過,此時將當前執行緒wait,等待消費者消費 if(r.isFlag()){ try { r.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if(i%2==0){ r.setId(i);//設定車的id r.setName("小汽車");//設定車的型號 r.setWheel(4);//設定車的輪子數 }else{ r.setId(i);//設定車的id r.setName("電動車");//設定車的型號 r.setWheel(2);//設定車的輪子數 } r.setFlag(true); //將執行緒池中的執行緒喚醒 r.notify(); } } } #Output public void run() { //無限消費車輛 for(;;){ synchronized(r){ //flag為false,代表當前生產的車已經被消費掉, //進入wait狀態等待生產者生產 if(!r.isFlag()){ try { r.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } //消費車輛 System.out.println(r.toString()); r.setFlag(false); //將執行緒池中的執行緒喚醒 r.notify(); } } }
列印結果:
... id=129--- name=電動車--- wheelNumber=2 id=130--- name=小汽車--- wheelNumber=4 id=131--- name=電動車--- wheelNumber=2 ...
這次真的沒問題了,工人和銷售員都如願以償的拿到了老闆發的獎金
注意點1:
synchronized括號內傳入的是一把鎖,可以是任意型別的物件,生產者消費者必須使用同一把鎖才能實現同步操作。這樣設計的目的是為了更靈活使用同步程式碼塊,否則整個程序那麼多synchronized,鎖誰不鎖誰根本不明確
注意點2:
wait、notify其實是object的方法,它們只能在synchronized程式碼塊內由鎖進行呼叫,否則就會拋異常。每一把鎖對應執行緒池的一塊區域,被wait的執行緒會被放入到鎖對應的執行緒池區域,並且釋放鎖。notify會隨機喚醒鎖對應執行緒池區域的任意一個執行緒,執行緒被喚醒後會重新上鎖,注意是隨機喚醒任意一個執行緒
2. 由死鎖問題看顯示鎖 Lock 的應用場景
2.1 何為死鎖?
關於死鎖,顧名思義應該是鎖死了,它可以使執行緒處於假死狀態但又沒真死,卡在半道又無法被回收。
舉個例子:
class Deadlock1 implements Runnable{ private Object lock1; private Object lock2; public Deadlock1(Object obj1,Object obj2){ this.lock1 = obj1; this.lock2 = obj2; } public void run() { while(true){ synchronized(lock1){ System.out.println("Deadlock1----lock1"); synchronized(lock2){ System.out.println("Deadlock1----lock2"); } } } } } class Deadlock2 implements Runnable{ private Object lock1; private Object lock2; public Deadlock2(Object obj1,Object obj2){ this.lock1 = obj1; this.lock2 = obj2; } public void run() { while(true){ synchronized(lock2){ System.out.println("Deadlock2----lock2"); synchronized(lock1){ System.out.println("Deadlock2----lock1"); } } } } } #執行 private static final Object lock1 = new Object(); private static final Object lock2 = new Object(); public static void main(String[] args) { Deadlock1 d1 = new Deadlock1(lock1,lock2); Deadlock2 d2 = new Deadlock2(lock1,lock2); Thread t1 = new Thread(d1); Thread t2 = new Thread(d2); t1.start(); t2.start(); }
執行後列印結果:
Deadlock1----lock1
Deadlock2----lock2
run()
方法中寫的是無限迴圈,按理來說應該是無限列印。但程式執行後,在我沒有終止控制檯的情況下只打印了這兩行資料。實際上這一過程引發了死鎖,具體緣由如下:
- 執行緒t1執行,判斷了第一個同步程式碼塊,此時鎖lock1可用,於是持著鎖lock1進入了第一個同步程式碼塊,列印了:
Deadlock1----lock1
,隨後執行緒切換到了執行緒t2 - 執行緒t2執行,判斷第一個同步程式碼塊,此時鎖lock2可用,於是持著鎖lock2進入了第一個同步程式碼塊,列印了:
Deadlock2----lock2
,接著向下執行,判斷鎖lock1不可用(因為鎖lock1已經被執行緒t1所佔用),於是執行緒t1進行等待.隨後再次切換到執行緒t1 - 執行緒t1執行,判斷第二個同步程式碼塊,此時鎖lock2不可用(因為所lock2已經被執行緒t2所佔用),執行緒t1也進入了等待狀態
通過以上描述可知:執行緒t1持有執行緒t2需要的鎖進行等待,執行緒t2持有執行緒t1所需要的鎖進行等待,兩個執行緒各自拿著對方需要的鎖處於一種僵持現象,導致執行緒假死即死鎖
以上案例只是死鎖的一種,死鎖的標準就是判斷執行緒是否處於假死狀態
2.2 多生產多消費場景的死鎖如何避免?
第一小節主要是在講單生產單消費,為了進一步提升執行效率可以適當引入多生產多消費,既多個生產者多個消費者。繼續引用第一小節案例,稍作改動:
//生產者任務 class Input implements Runnable{ private Resource r; //將i寫為成員變數而不是寫在for迴圈中是為了方便講解下面多生產多消費的內容,沒必要糾結這點 private int i = 0; public Input(Resource r){ this.r = r; } public void run() { //無限生產車輛 for(;;){ synchronized(r){ //flag為true的時候代表已經生產過,此時將當前執行緒wait,等待消費者消費 if(r.isFlag()){ try { r.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if(i%2==0){ r.setId(i);//設定車的id r.setName("小汽車");//設定車的型號 r.setWhell(4);//設定車的輪子數 }else{ r.setId(i);//設定車的id r.setName("電動車");//設定車的型號 r.setWhell(2);//設定車的輪子數 } i++; r.setFlag(true); //將執行緒池中的執行緒喚醒 r.notify(); } } } } public static void main(String[] args) { Resource r = new Resource(); Input in = new Input(r); Output out = new Output(r); Thread in1= new Thread(in); Thread in2 = new Thread(in); Thread out1 = new Thread(out); Thread out2 = new Thread(out); in1.start();//開啟生產者1執行緒 in2 .start();//開啟生產者2執行緒 out1 .start();//開啟消費者1執行緒 out2 .start();//開啟消費者2執行緒 }
執行結果:
id=211--- name=自行車--- wheelNumber=2 id=220--- name=小汽車--- wheelNumber=4 id=220--- name=小汽車--- wheelNumber=4 id=220--- name=小汽車--- wheelNumber=4 ...
安全問題又產生了,編號為211-220的車輛未被列印,也即生產了未被消費。同時編號為220的車輛被列印了三次。先彆著急,我接著給大家分析:
- 生產者執行緒in1得到執行權,生產了id為211的車輛,將flag置為true,迴圈回來再判斷標記為true,此時執
wait()
方法進入等待狀態 - 生產者執行緒in2得到執行權,判斷標記為true,執行
wait()
方法進入等待狀態。 - 消費者執行緒out1得到執行權,判斷標記為true,不進行等待而是選擇了消費id為211的車輛,消費完畢後將標記置為false並執行
notify()
將執行緒池中的任意一個執行緒給喚醒,假設喚醒的是in1 - 生產者執行緒in1再次得到執行權,此時生產者執行緒in1被喚醒後不會判斷標記而是選擇生產一輛id為1的車輛,隨後將標記置為true並執行
notify()
將執行緒池中任意一個執行緒給喚醒,假設喚醒的是in2 - 生產者執行緒in2再次得到執行權,此時生產者執行緒in2被喚醒後不會判斷標記而是直接生產了一輛id為212的車輛,隨後喚醒in1生產id為213的車輛,再喚醒in2.....
以上即為編號211-220的車輛未被列印的原因,編號為220車輛重複列印同理。
如何解決?其實很簡單,將生產者和消費者判斷flag地方的if更改成while,被喚醒後重新再判斷標記即可。程式碼就不重複貼了,執行結果如下:
id=0--- name=小汽車--- wheelNumber=4 id=1--- name=電動車--- wheelNumber=2 id=2--- name=小汽車--- wheelNumber=4 id=3--- name=電動車--- wheelNumber=2 id=4--- name=小汽車--- wheelNumber=4
看起來很正常,但在我沒有關控制檯的情況下列印到編號為4的車輛時停了,沒錯,死鎖出現了,具體原因如下:
- 執行緒in1開始執行,生產了一輛車將flag置為true,迴圈回來判斷flag進入
wait()
狀態,此時執行緒池中進行等待的執行緒有:in1 - 執行緒in2開始執行,判斷flag為true進入
wait()
狀態,此時執行緒池中進行等待的執行緒有:in1,in2 - 執行緒out1開始執行,判斷flag為true,消費了一輛汽車將flag置為false並喚醒一個執行緒,我們假定喚醒的為in1(這裡需要注意,被喚醒並不意味著會立刻執行,只是當前具備著執行資格但並不具備執行權),執行緒out1迴圈回來判讀flag進入wait狀態,此時執行緒池中的執行緒有in2,out1,隨後out2得到執行權
- 執行緒out2開始執行,判斷標記為false,進入等待狀態,此時執行緒池中的執行緒有in2,out1,out2
- 執行緒in1開始執行,判斷標記為false,生產了一輛汽車必將flag置為true並喚醒執行緒池中的一個執行緒,我們假定喚醒的是in2,隨後in1迴圈判斷flag進入
wait()
狀態,此時執行緒池中的執行緒有in1,out1,out2 - 執行緒int2得到執行權,判斷標記為false,進入
wait()
狀態,此時執行緒池中的執行緒有in1,in2,out1,out2
所有生產者消費者執行緒都被wait掉了,導致了死鎖現象的產生。根本原因在於生產者wait後理應喚醒消費者,而不是喚醒生產者,object還有一個方法notifyAll()
,它可以喚醒鎖對應執行緒池區域的所有執行緒,所以將notify替換成notifyAll即可解決以上死鎖問題
2.3 通過 Lock 優雅的解決死鎖問題
2.2提到的notifyAll
是可以解決死鎖問題,但不夠優雅,因為notifyAll()
會喚醒對應執行緒池所有執行緒,單其實只需要喚醒一個即可,多了就會造成執行緒反覆被wait,進而會造成效能問題。所以後來Java在1.5版本引入了顯示鎖Lock的概念,它可以靈活的指定wait、notify的作用域,專門用來解決此類問題。
通過顯示鎖Lock對2.2死鎖問題改進後代碼如下:
#生產者 class Input implements Runnable{ private Resource r; private int i = 0; private Lock lock; private Condition in_con;//生產者監視器 private Condition out_con;//消費者監視器 public Input(Resource r,Lock lock,Condition in_con,Condition out_con){ this.r = r; this.lock = lock; this.in_con = in_con; this.out_con = out_con; } public void run() { //無限生產車輛 for(;;){ lock.lock();//獲取鎖 //flag為true的時候代表已經生產過,此時將當前執行緒wait,等待消費者消費 while(r.isFlag()){ try { in_con.await();//跟wait作用相同 } catch (InterruptedException e) { e.printStackTrace(); } } if(i%2==0){ r.setId(i);//設定車的id r.setName("小汽車");//設定車的型號 r.setWhell(4);//設定車的輪子數 }else{ r.setId(i);//設定車的id r.setName("電動車");//設定車的型號 r.setWhell(2);//設定車的輪子數 } i++; r.setFlag(true); //將執行緒池中的消費者執行緒喚醒 out_con.signal(); lock.unlock();//釋放鎖 } } } //消費者 class Output implements Runnable{ private Resource r; private Lock lock; private Condition in_con;//生產者監視器 private Condition out_con;//消費者監視器 public Output(Resource r,Lock lock,Condition in_con,Condition out_con){ this.r = r; this.lock = lock; this.in_con = in_con; this.out_con = out_con; } public void run() { //無限消費車輛 for(;;){ lock.lock();//獲取鎖 while(!r.isFlag()){ try { out_con.await();//將消費者執行緒wait } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(r.toString()); r.setFlag(false); in_con.signal();//喚醒生產者執行緒 lock.unlock();//釋放鎖 } } } public static void main(String[] args) { Resource r = new Resource(); Lock lock = new ReentrantLock(); //生產者監視器 Condition in_con = lock.newCondition(); //消費者監視器 Condition out_con = lock.newCondition(); Input in = new Input(r,lock,in_con,out_con); Output out = new Output(r,lock,in_con,out_con); Thread t1 = new Thread(in); Thread t2 = new Thread(in); Thread t3 = new Thread(out); Thread t4 = new Thread(out); t1.start();//開啟生產者執行緒 t2.start();//開啟生產者執行緒 t3.start();//開啟消費者執行緒 t4.start();//開啟消費者執行緒 }
這次就真的沒問題了。其中Lock對應synchronized,Condition為Lock下的監視器,每一個監視器對應一個wait、notify作用域,註釋寫的很清楚就不再贅述
綜上所述
- 多執行緒是用來提升CUP使用率的
- 多個執行緒訪問同一資源可能會引發安全問題
- synchronized配合wait、notify可以解決執行緒安全問題
- Lock可以解決synchronized下wait、notify的侷限性