1. 程式人生 > 其它 >Java執行緒安全--加鎖和防止死鎖

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的侷限性