java多執行緒:執行緒同步synchronized(不同步的問題、佇列與鎖),死鎖的產生和解決

Life_Goes_On發表於2020-08-18

0、不同步的問題

併發的執行緒不安全問題:

多個執行緒同時操作同一個物件,如果控制不好,就會產生問題,叫做執行緒不安全。

我們來看三個比較經典的案例來說明執行緒不安全的問題

0.1 訂票問題

例如前面說過的黃牛訂票問題,可能出現負數或相同。

執行緒建立方式&&黃牛訂票模擬

0.2 銀行取錢

再來看一個取錢的例子:

/*
    模擬一個賬戶
*/
class Account{
    int money;
    String name;
    public Account(int money, String name) {
        this.money = money;
        this.name = name;
    }
}
/*
    模擬取款機,方便設定名字,繼承Thread而不是實現Runnable
*/
class Drawing extends Thread{
    Account account;
    int outMoney;//取出去了多少錢
    int outTotal;//總共取到了多少錢

    public Drawing(Account account, int outMoney,String name) {
        super(name);
        this.account = account;
        this.outMoney = outMoney;
    }

    @Override
    public void run() {
        account.money -= outMoney;
        outTotal += outMoney;
        System.out.println(this.getName() + "---賬戶餘額為:" + account.money);
        System.out.println(this.getName() + "---總共取到了:" + outTotal);
    }
}

然後我們寫個客戶端呼叫一下,假設兩個人同時取錢,操作同一個賬戶

public class Checkout {
    public static void main(String[] args) {
        Account account = new Account(200000,"禮金");
        Drawing you = new Drawing(account,8000,"你");
        Drawing wife = new Drawing(account,300000,"你老婆");
        you.start();
        wife.start();
    }
}

執行起來,問題就會出現。

每次的結果都不一樣,而且,這樣肯定會把錢取成負數,顯然這是非法的(嘻嘻),首先邏輯上需要修改,當錢少於 0 了就應該退出,並且不能繼續取錢的動作了。按照這個思路,加上一個判斷呢?

if (account.money < outMoney){
    System.out.println("餘額不足");
    return;
}
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    e.printStackTrace();
}

可是即便是這樣,發現還是會出現結果為負的情況,無法保證執行緒安全

0.3 數字遞增

還有一個經典的例子,那就是對於直接計算迭代過慢,而轉為多執行緒。

一個數字 num ,開闢一萬個執行緒對他做 ++ 操作,看結果會是多少。

public class AddSum {
    private static int num = 0;
    public static void main(String[] args) {
        for (int i=0; i<=10000; i++){
            new Thread(()->{
                num++;
            }).start();
        }
        System.out.println(num);
    }
}

每次運算的結果都不一樣,一樣的是,結果永遠 < 10000 。

或者用給 list 裡新增數字來測試:

List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
    new Thread(()->{
        list.add(Thread.currentThread().getName());
    }).start();
}
System.out.println(list.size());

一樣的結果。

執行緒不安全的問題如何解決呢?

一、同步(synchronized)

1.1 問題出現的原因

從前面的介紹裡,我們總結出會出現同步問題的情況,也就是併發三要素:多個執行緒、同時操作、操作同一個物件。另外,操作的特點是:操作型別為修改這個時候會產生併發的問題,執行緒安全問題。

1.2 解決方案

  1. 確保執行緒安全,第一就是排隊。只要排隊,那麼不管多少執行緒,始終一個時間點只會有一個執行緒在執行,就保證了安全。
    不過排隊會有一個問題:怎麼直到輪到我了呢,也就是怎麼知道排在前面的執行緒執行完了呢?
  2. 現實生活中,可能會用類似房卡的形式,前一個人把卡交還了,才會有後面的人有機會入住。這就是

利用 佇列 + 鎖 的方式保證執行緒安全的方式叫執行緒同步,就是一種等待機制,多個同時訪問此物件的執行緒進入這個物件的等待池 形成佇列,前面的執行緒使用完畢後,下一個執行緒再使用。

鎖機制最開始在 java 裡就是一個關鍵字 synchronized(同步),屬於排他鎖,當一個執行緒獲得物件的排他鎖,獨佔資源,其他執行緒必須等待,使用後釋放鎖即可。

按照這種思路,可以想象到這種保證安全方式的弊端,也就是早期的 synchronized 存在的問題:

  1. 一個執行緒持有鎖會導致其他所有需要這個鎖的執行緒掛起;
  2. 多執行緒競爭下,加鎖、釋放鎖導致耗時嚴重,效能問題
  3. 一個優先順序高的執行緒等待一個優先順序低的執行緒的鎖釋放,會使得本應該的優先順序倒置,引起效能問題。

另外,Synchronized 是基於底層作業系統的 Mutex Lock 實現的,每次獲取和釋放鎖操作都會帶來使用者態和核心態的切換,從而增加系統效能開銷。因此,在鎖競爭激烈的情況下,Synchronized 同步鎖在效能上就表現得非常糟糕,它也常被大家稱為重量級鎖。

但是 jdk 6 之後有了很強的改進,這個內容待更新,留個坑。

二、同步關鍵字的用法

2.1 同步方法

synchronized 方法控制對 成員變數或者類變數 物件的訪問,每個物件對應一把鎖。寫法如下:

public synchronized void test(){
    //。。。
}
  1. 如果修飾的是具體物件:鎖的是物件
  2. 如果修飾的是成員方法:那鎖的就是 this
  3. 如果修飾的是靜態方法:鎖的就是這個物件.class

每個 synchronized 方法都必須獲得呼叫該方法的物件的鎖才能執行,否則所屬的這個執行緒阻塞,方法一旦執行,就獨佔該鎖,直到從該方法返回時,鎖釋放。

同步方法的寫法程式碼,以上面的取錢案例歷的 取錢類為例,如果直接在提款機的操作,把 run 方法或者裡面的內容提出來變成 test ,加上 synchronized 修飾:

@Override
public void run() {
    test();
}
public synchronized void test(){
    //內容都不變
}

會發現,仍然出現了負數。鎖定失敗。

分析

我們認為在 test 方法裡進行的物件修改,所以把他鎖上就好了,但是對於這個類,這個提款機類來說,test 方法是成員方法,因此鎖的物件實際上是 this ,也就是提款機。

但我們的初衷,要執行緒鎖的資源應該是 Account 物件,而不是提款機物件。

2.2 同步塊

除了方法,synchronized 還可以修飾塊,叫做同步塊

synchronized 修飾同步塊的方式是:

synchronized (obj){
    //...
}    

其中的 obj 可以是任何物件,但是用到它,肯定是設定為那個共享資源,這個 obj 被稱為同步監視器同步監視器的作用就是,判斷這個監視器是否被鎖定(是否能訪問),從而決定是否能執行其中的程式碼。

java的花括號中內容有以下幾種:

  1. 方法裡面的塊:區域性塊。解決變數作用域的問題,快速釋放記憶體(比如方法裡面再有個for迴圈,裡面的變數);
  2. 類層的塊:構造塊。初始化資訊,和構造方法是一樣的;
  3. 類層的靜態塊:靜態構造快。最早載入,不是物件的資訊,而是類的資訊;
  4. 方法裡面的同步塊:監視物件。

第四種就是我們這裡學習的同步塊。

注意,如果是同步方法裡,沒必要指定同步監視器,因為同步方法的監視器已經是 this 或者 .class。

用同步塊的方式對提款機問題進行修改:

public void test(){
    synchronized(account){
            //內容不變
    }
}

也就是加上對 account 的監視器,鎖住這個物件。這樣執行結果就正確了 。

這種做法效率不高,因為雖然對 account 上了鎖,但是每一次都要把整個流程走一遍,方法體的內容是很多的,另外,每次加鎖與否,都是效能的消耗,進入之後再出來,哪怕什麼也不做,也是消耗。

其實,我們可以在加鎖的前面再加一重判斷,那麼之後就沒必要再進行上鎖的過程了。

public void test(){
    if (account.money ==0 ){
        return;
    }
    synchronized(account){
    }
}

就是這樣的一個程式碼,在併發量很高的時候,往往可以大大提高效率

對於上面的 10000 個執行緒的加法那個問題,我們也可以通過 synchronized 加鎖,來保證結果的正確性。

(但是 synchronized 修飾的要是引用型別,所以直接對 int num 加鎖不行,一般直接使用專門提供的原子類)

list 的里加數字的測試:

List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
    new Thread(()->{
        synchronized (list){
            list.add(Thread.currentThread().getName());
        }
    }).start();
}
Thread.sleep(2000);
System.out.println(list.size());

main方法,下面的print語句,這些都是執行緒,所以可能上面還沒有操作的時候,就已經輸出了,為了方便觀察,我們在最後輸出之前先讓main執行緒休眠一會,再看裡面add的結果是否正確。

java多執行緒:執行緒同步synchronized(不同步的問題、佇列與鎖),死鎖的產生和解決

tips:對於容器的操作,Java的util.concurrent包裡也直接提供了對應的安全容器CopyOnWriteArrayList。

CopyOnWriteArrayList<String > list1 = new CopyOnWriteArrayList<>();
for (int i=0; i<10000; i++){
    new Thread(()->{
        list1.add(Thread.currentThread().getName());
    }).start();
}
Thread.sleep(2000);
System.out.println(list1.size());

2.3 問題

synchronized 塊太小,可能鎖不住,安全性又不行了,鎖的方法太大,又效率會降低,所以要很注意控制範圍

而且,還有類似於 單例模式 裡 Double-Check 寫法針對的問題,有時候一重鎖性質不夠,兩重鎖仍然不夠保證安全。

三、執行緒同步問題應用示例

3.1 快樂影院

電影院買票。

/**
* 快樂影院
*/
public class HappyCinema {
    public static void main(String[] args) {
        Cinema cinema = new Cinema(20, "萬達");
        new Thread(new Customer(cinema,2)).start();
        new Thread(new Customer(cinema,1)).start();
    }
}
/**
* 電影院,提供訂票方法
*/
class Cinema{
    int available;
    String name;

    public Cinema(int available, String name) {
        this.available = available;
        this.name = name;
    }
    //提供購票方法
    public boolean bookTickets(int seats){
        System.out.println("可用位置為:"+available);
        if (seats > available){
            return false;
        }
        available -= seats;
        return true;
    }
}
/**
* 顧客,有多個顧客,模仿多執行緒
*/
class Customer implements Runnable{
    Cinema cinema;
    int seats;
    //顧客建立的時候帶上要預定的作為+訂哪個影院
    public Customer(Cinema cinema, int seats) {
        this.cinema = cinema;
        this.seats = seats;
    }

    @Override
    public void run() {
        boolean flag = cinema.bookTickets(seats);
        if (flag){
            System.out.println("出票成功,"+Thread.currentThread().getName()+"買了 "+seats+" 張票");
        }else{
            System.out.println("出票失敗,"+Thread.currentThread().getName()+"買票,但位置不足 ");
        }
    }
}

對於一個電影院的票:available 資源來說,多個執行緒訪問,是需要同步的,否則就會出現不安全的問題。

解決:

@Override
public void run() {
    synchronized (cinema){
                //。。。
        }
    }
}

3.2 快樂影院進階

影院票的時候不是簡單計數,是可以選座位的,我們修改程式碼,具體到某一個座位號的預定。

將 int 座位數目改成 List,那麼購票方法改動如下:

    public boolean bookTickets(List<Integer> seats){
        System.out.println("可用位置為:" + available);
        List<Integer> copy = new ArrayList<>(available);
        //相減
        copy.removeAll(seats);
        //判斷改變後
        if (available.size() != copy.size() + seats.size() ){
            return false;
        }
        available = copy;
        return true;
    }

其他地方只需要做簡單的修改,在呼叫的時候傳入一個構造好的 list 即可,這個時候再來看:

java多執行緒:執行緒同步synchronized(不同步的問題、佇列與鎖),死鎖的產生和解決

如果兩個顧客同時訂票的位置衝突

java多執行緒:執行緒同步synchronized(不同步的問題、佇列與鎖),死鎖的產生和解決

可以看到完成了同步。

3.3 火車票

還是類似於訂票,因為上面電影院的部分我們都使用 同步塊 的方式鎖定某個物件,這裡使用同步方法來加深上鎖的理解。

模仿第一種電影院訂票的初始不加鎖寫法。

public class Happy12306 {
    public static void main(String[] args) {
        Railway railway = new Railway(20, "京西G12138");
        new Thread(new Passenger(railway,2)).start();
        new Thread(new Passenger(railway,1)).start();
    }
}
/**
* 鐵路系統,提供訂票方法
*/
class Railway{
    int available;
    String name;

    public Railway(int available, String name) {
        this.available = available;
        this.name = name;
    }
    //提供購票方法
    public boolean bookTickets(int seats){
        System.out.println("可用位置為:"+available);
        if (seats > available){
            return false;
        }
        available -= seats;
        return true;
    }
}
/**
* 顧客,有多個顧客,模仿多執行緒
*/
class Passenger implements Runnable{
    Railway railway;
    int seats;
    public Passenger(Railway railway, int seats) {
        this.railway = railway;
        this.seats = seats;
    }

    @Override
    public void run() {
        boolean flag = railway.bookTickets(seats);
        if (flag){
            System.out.println("出票成功,"+Thread.currentThread().getName()+"買了 "+seats+" 張票");
        }else{
            System.out.println("出票失敗,"+Thread.currentThread().getName()+"買票,但位置不足 ");
        }
    }
}

現在開始給方法加鎖,考慮這個問題:

  1. 本來的 run 方法寫了 同步塊 對一個資源加鎖,這個資源是 票所在的 鐵路系統(上一個例子的電影院);
  2. 所以如果鎖 run 方法,我們前面說過的,鎖成員方法相當於鎖的 this,也就是鎖了 乘客 類,是沒有用的,因為被修改的資源不在這裡
  3. 應該將這個方法放到 鐵路系統 類裡,然後對這個方法上鎖。

這樣會帶來新的問題,模擬多個執行緒的執行緒體應該來源於 乘客 ,不能是鐵路系統,所以乘客類也要繼續修改,繼承 Thread 類,本身作為一個代理,去找到目標介面的實現類:鐵路系統 ,然後start。

public class Happy12306 {
    public static void main(String[] args) {
        Railway railway = new Railway(5, "京西G12138");
        new Passenger(5,railway,"乘客B").start();
        new Passenger(2,railway,"乘客A").start();
    }
}
/**
* 鐵路系統,提供訂票方法,本身就是一個執行緒,
*/
class Railway implements Runnable{
    int available;
    String name;

    public Railway(int available, String name) {
        this.available = available;
        this.name = name;
    }
    //提供購票方法,加入同步
    public synchronized boolean bookTickets(int seats){
        System.out.println("可用位置為:"+available);
        if (seats > available){
            return false;
        }
        available -= seats;
        return true;
    }
    //run方法從 顧客類裡 挪過來,
    @Override
    public void run() {
        //執行時需要知道哪個執行緒在操作自己,也就是seats的來源
        Passenger p = (Passenger) Thread.currentThread();
        boolean flag = this.bookTickets(p.seats);
        if (flag){
            System.out.println("出票成功,"+Thread.currentThread().getName()+"買了 "+p.seats+" 張票");
        }else{
            System.out.println("出票失敗,"+Thread.currentThread().getName()+"買票,但位置不足 ");
        }
    }
}
/**
* 顧客,作為代理,是 Thread 的子代理
*/
class Passenger extends Thread{
    int seats;
    public Passenger(int seats, Runnable target, String name) {
        super(target,name);//用父類方法找到目標,也就是鐵路系統
        this.seats = seats;
    }
}

總結:

  1. synchronized 修飾成員方法鎖定的是 this,所以要加入鐵路系統類,
  2. 鐵路系統通過 Thread.currentThread() 方法 確定當前的執行緒,同時獲取到訂票資訊
  3. 乘客變成了 代理,是 Thread 的子類,在這個基礎上加入訂票資訊
  4. 最後呼叫的時候,本應該使用 Thread 作為代理去執行,改為用乘客類,起到了一個系統用多個不同執行緒的作用

乘客本身作為代理子類可能比較難理解。

但是我們回頭看看,對於上一種方式:

        new Thread(new Passenger(railway,2)).start();
        new Thread(new Passenger(railway,1)).start();

雖然這麼寫的,但是其實傳入的一個 Runnable的實現類,在 Thread 原始碼裡面呼叫了構造方法:

java多執行緒:執行緒同步synchronized(不同步的問題、佇列與鎖),死鎖的產生和解決

可以看到,傳入一個 Runnable ,這個構造器加上了額外的資訊,所以其實我們這種做法:

public Passenger(int seats, Runnable target, String name) {
    super(target,name);//用父類方法找到目標,也就是鐵路系統
    this.seats = seats;
}

是模擬了原始碼的寫法而已。

四、多執行緒死鎖的產生與解決

4.1 問題

死鎖:當多個執行緒各自佔有一些共享資源,並且互相等待其他執行緒佔有的資源才能進行,從而導致兩個或者多個執行緒都在等待對方釋放資源,都停止執行的情況。

最簡單的,某一個同步塊同時擁有“兩個以上的物件的鎖”的時候,就可能會發生死鎖問題。

  • 如果兩個執行緒,那就是塗口紅、照鏡子的問題,每個人都想先拿了一個再拿另一個;
  • 如果是多個執行緒,對應哲學家就餐問題,每個人都想左手拿刀、右手拿叉。

口紅鏡子問題示例:

/**
* 死鎖的產生
*/
public class DeadLock {
    public static void main(String[] args) {
        Makup makup = new Makup(1,"女孩1");
        Makup makup1 = new Makup(0,"女孩2");
        makup.start();
        makup1.start();
    }
}
/**
* 口紅
*/
class Lipstick{ }
/**
* 鏡子
*/
class Mirror{ }
/**
* 化妝
*/
class Makup extends Thread{
    static Lipstick lipstick = new Lipstick();
    static Mirror mirror = new Mirror();
    int choice;//選擇
    String girl;
    public Makup(int choice, String girl){
        this.choice = choice;
        this.girl = girl;
    }

    @Override
    public void run() {
        makeup();
    }
    //相互持有對方的物件鎖
    private void makeup(){
        if (choice == 0){
            synchronized (lipstick){
                System.out.println(this.girl + "獲得口紅");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (mirror){
                    System.out.println(this.girl + "然後獲得鏡子");
                }
            }
        }else{
            synchronized (mirror){
                System.out.println(this.girl + "獲得鏡子");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lipstick){
                    System.out.println(this.girl + "然後獲得口紅");
                }
            }
        }
    }
}
java多執行緒:執行緒同步synchronized(不同步的問題、佇列與鎖),死鎖的產生和解決

可以發現程式停不下來了,死鎖已經產生。

其中的過程就是:

  1. 女孩 1 先拿到了鏡子,對其上鎖;
  2. 女孩 1 休息的時候,女孩 2 先拿到了口紅,對其上鎖;
  3. 女孩 2 休息的時候,女孩 1 休息結束,想要獲取口紅,但此時口紅上鎖,因此等待;
  4. 女孩 2 休息結束,想要獲取鏡子,但此時鏡子上鎖,因此等待。

4.2 解決

解決這個問題的方法:

不要出現 鎖的 巢狀 ,將等待後獲取另一個鎖的程式碼放到第一個加鎖的後面就可以解決這個問題了:

    private void makeup(){
        if (choice == 0){
            synchronized (lipstick){
                System.out.println(this.girl + "獲得口紅");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (mirror){
               System.out.println(this.girl + "然後獲得鏡子");
            }
        }else{
            synchronized (mirror){
                System.out.println(this.girl + "獲得鏡子");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (lipstick){
                System.out.println(this.girl + "然後獲得口紅");
            }
        }
    }

總結:儘量不要讓 一個同步程式碼塊 同時擁有“兩個以上的物件的鎖”。

相關文章