java多線程(三)多線程通信 —— synchronized + wait + notifyAll等待喚醒機制


多線程間通信
多線程通信其實包含兩方面,一個是線程間通知,一個線程告訴另一個線程是否結束。另一個是線程間傳值。

1. java線程間直接傳值

而java多線程之間的通信他不像Erlang語言那種沒有共享變量,每個線程都操作單獨變量然後傳遞值給其他線程。java這種操作共享變量機制就要加同步鎖來解決一系列安全問題。
所以說Erlang在操作多線程更安全是很有道理的。

java也提供不同線程間傳值

PipedInputStream類 與 PipedOutputStream類 用於在應用程序中創建管道通信。一個PipedInputStream實例對象必須和一個PipedOutputStream實例對象進行連接而產生一個通信管道。
在 Java 的 JDK 中,提供了四個類用於線程間通信傳值:

  • 字節流:PipedInputStream 和 PipedOutputStream;
  • 字符流:PipedReader 和 PipedWriter;

PipedOutputStream可以向管道中寫入數據,PipedIntputStream可以讀取PipedOutputStream向管道中寫入的數據,這兩個類主要用來完成線程之間的通信。一個線程的PipedInputStream對象能夠從另外一個線程的PipedOutputStream對象中讀取數據,如下圖所示:
在這裏插入圖片描述

但感覺實際上用到的情況比較多的是線程操作數據而不是儲存數據,所以數據都存儲在主線程內存中,其他線程來操作就可以了。

2. 等待、喚醒機制

等待喚醒機制主要是一個線程通知其他線程是否來進入操作。
JDK5之前主要實現方式是使用synchronized 和notify 、wait方法。但是存在一些缺陷,JDK5是一個改動很大的版本,之後使用Lock鎖來保證同步 和await、notify方法來實現代替之前版本。
我們通過java多線程範例生產者消費者模式來介紹這兩種方式。

synchronized + wait + notify

之前我們的例子比如說銀行存錢,每個線程都是執行相同的代碼,都是向銀行中存錢。
而通常情況我們是要不同的線程執行不同的代碼做不同的事,比如說一個線程往銀行存錢,一個線程從銀行取錢。我們現在用一個經典的生產者消費者例子來介紹多線程。

如何使用共享對象:

首先我們介紹一下如何使用共享對象,這裏我們使用共享對象的時候可以這樣做:這裏相當於把主線程的變量傳遞給子線程,子線程就可以修改該變量。

//自定義共享對象
Res r = new Res();

//定義兩個線程對象把共享對象傳入構造方法
Input in = new Input(r);
Output out = new Output(r);

//開啓線程
Thread t1 = new Thread(in);
Thread t2 = new Thread(out);
t1.start();
t2.start();

生產者消費者模型:例子說明

我們生產麪包,生產1消費1,生產2消費2,也就是說我們的生產和消費要一一對應。並且生產和消費在不同線程執行。
而且每生產一個就要消費一個這樣不造成資源的浪費。也就是說生產和消費一一對應。

生產消費者模型:安全問題

//共享資源類
class Resource {
    private int breadCount = 0;       // 資源編號

    // 生產資源
    public void produce(String name) {
        synchronized (this) {
            breadCount++;    // 資源編號遞增,用來模擬資源遞增
            System.out.println(Thread.currentThread().getName() + "...生產者生產bread.." + breadCount);
        }
    }

    // 消費資源
    public void consume() {
        System.out.println(Thread.currentThread().getName() + "...消費者消費bread......." + breadCount);
    }
}

// 生產者類線程
class Producer implements Runnable {
    private Resource res;

    //構造函數中生產者初始化分配資源
    public Producer(Resource res) {
        this.res = res;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            res.produce("bread");     // 循環生產10次
        }
    }

}

// 消費者類線程
class Comsumer implements Runnable {
    private Resource res;

    //構造函數中消費者一初始化也要分配資源
    public Comsumer(Resource res) {
        this.res = res;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            res.consume();  // 循環消費10次
        }
    }
}

public class SynchronizedSample {
    public static void main(String[] args) {
        Resource resource = new Resource();  // 實例化資源

        Producer producer = new Producer(resource);  // 實例化生產者和消費者類,它們取得同一個資源
        Comsumer comsumer = new Comsumer(resource);

        Thread threadProducer = new Thread(producer); // 創建1個生產者線程
        Thread threadComsumer = new Thread(comsumer); // 創建1個消費者線程

        // 分別開啓線程
        threadProducer.start();
        threadComsumer.start();
    }
}
/*
Thread-0...生產者生產..bread1
Thread-0...生產者生產..bread2
Thread-0...生產者生產..bread3
Thread-1...消費者消費.......bread1
Thread-1...消費者消費.......bread4
Thread-1...消費者消費.......bread4
...*/

我們可以發現由於我們沒有加任何同步機制由於使用共享資源,多線程所以必然會出現安全問題,這裏同時消費多個bread4

生產消費者模型:加同步鎖

由於上述問題,我們需要解決,因爲我們在生產資源的時候,比如生產bread2,這時只有生產線程在操作,消費線程是不能操作的。也就是說保證生產一個麪包的時候只有一個線程在操作該面包。
我們查找哪塊共享資源是多線程要操作,然後修改:

// 資源類
class Resource {
    private int breadCount = 0;       // 資源編號

    // 生產資源
    public void produce(String name) {
        synchronized (this) {
            breadCount++;    // 資源編號遞增,用來模擬資源遞增
            System.out.println(Thread.currentThread().getName() + "...生產者生產bread.." + breadCount);
        }
    }

    // 消費資源
    public void consume() {
        synchronized (this) {
            System.out.println(Thread.currentThread().getName() + "...消費者消費bread......." + breadCount);
        }
    }
}
/*
Thread-0...生產者生產bread..1
Thread-0...生產者生產bread..2
Thread-0...生產者生產bread..3
Thread-0...生產者生產bread..4
Thread-0...生產者生產bread..5
Thread-0...生產者生產bread..6
Thread-0...生產者生產bread..7
Thread-0...生產者生產bread..8
Thread-0...生產者生產bread..9
Thread-0...生產者生產bread..10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10
Thread-1...消費者消費bread.......10*/

我們在共享資源的地方添加了同步塊,也就是說,當我們每生產一個麪包的時候,不能消費該面包。但是又出現個問題就是我們的生產者線程:

// 生產者類線程
class Producer implements Runnable {
    private Resource res;

    //構造函數中生產者初始化分配資源
    public Producer(Resource res) {
        this.res = res;
    }
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            res.produce("bread");     // 循環生產10次
        }
    }

}

在生產者線程中循環10次調用produce同步鎖,而一直沒退出該線程,只有當退出該線程時纔會釋放同步鎖,而我們一直沒有退出該線程就沒釋放該鎖,鎖只有退出該線程時纔會釋放。所以生產者線程一直拿着這個鎖生產了10個bread而消費者不能消費,等到生產完10個才進行消費,這不是我們目標,我們要求生產一個就消費一個,不然之前生產的麪包可能會過期。

生產消費者模型:等待喚醒機制

爲解決上述問題,我們可以爲資源設置一個標誌flag,該標誌用來標明資源是否存在,所有的線程執行操作前都要判斷資源是否存在。系統初始化後,資源是空的。如果是消費者線程獲得執行權,先判斷資源,此時爲空,就會進入凍結狀態,交出執行權,並喚醒其他線程。當生產者線程獲得執行權,先判斷資源,若爲空,立馬進行生產,生產完成進入凍結交出執行權並喚醒其他線程。

線程間通知:通過等待喚醒實現:
wait()讓線程進入凍結狀態,交出執行權(釋放鎖)
notify() 喚醒一個凍結狀態的線程(持有相同鎖的線程)
notifyAll() 喚醒所有凍結狀態線程(持有相同鎖的)
這樣的方法都用在同步裏,因爲需要鎖,用鎖的對象來調用這些方法,比如這個鎖是r,那麼就使用r.wait().表示持有r這個鎖的線程。所以只有同一個鎖上的被等待線程可以被同一個鎖的r.notify()喚醒。不能喚醒持有其他所的線程。

但是如果有多個線程,比如多個生產線程和多個消費線程,notifyAll()會全部喚醒他們。遺憾的是,並不能直接喚醒對方線程比如只喚醒消費者。這點也就是他的缺陷,也就是後面一章我們講的JDK5的版本升級,對synchronized和wait模式的替換。

// 資源類
class Resource {
    private int breadCount = 0;       // 資源編號
    private boolean flag = false;  // 資源類增加一個資源標誌位,判斷是否有資源

    // 生產資源
    public void produce() {
        synchronized (this) {
            // 添加循環判斷,如果flag爲true,也就是有資源了,生產者線程就暫停生產,進入凍結狀態,等待喚醒。
            while (flag == true) {
                try {
                    this.wait();  // wait函數拋出的異常只能被截獲
                    // 因爲wait會讓該線程等在這裏,如果這裏使用if判斷,則當線程被喚醒後會直接往下執行,
                    // 不再進行flag判斷了,則由於錯誤標記執行,這就可能造成多線程死鎖。
                    //所以使用while循環判斷,讓線程再次判斷是否標記正確
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            //當可以生產的時候flag=false時,執行以下

            breadCount++;    // 資源編號遞增,用來模擬資源遞增
            System.out.println(Thread.currentThread().getName() + "...生產者生產bread.." + breadCount);
            //生產完成修改flag
            flag = true;
            //並喚醒其他所有線程,如果只喚醒一個,則可能喚醒一個同類線程,而我們要喚醒的是對方線程
            this.notifyAll();
        }
    }

    // 消費資源
    public void consume() {
        synchronized (this) {
            //判斷如果沒有資源則消費者等待
            while (flag == false) {
                try {
                    this.wait();  // wait函數拋出的異常只能被截獲
                    // 因爲wait會讓該線程等在這裏,如果這裏使用if判斷,則當線程被喚醒後會直接往下執行,
                    // 不再進行flag判斷了,則由於錯誤標記執行,這就可能造成多線程死鎖。
                    //所以使用while循環判斷,讓線程再次判斷是否標記正確
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + "...消費者消費bread......." + breadCount);
            //生產完成修改flag
            flag = false;
            //並喚醒其他所有線程,如果只喚醒一個,則可能喚醒一個同類線程,而我們要喚醒的是對方線程
            this.notifyAll();
        }
    }
}

// 生產者類線程
class Producer implements Runnable {
    private Resource res;

    //構造函數中生產者初始化分配資源
    public Producer(Resource res) {
        this.res = res;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            res.produce();     // 每個線程生產5個
        }
    }

}

// 消費者類線程
class Comsumer implements Runnable {
    private Resource res;

    //構造函數中消費者一初始化也要分配資源
    public Comsumer(Resource res) {
        this.res = res;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            res.consume();  // 每個線程消費5個
        }
    }
}

public class SynchronizedSample {
    public static void main(String[] args) {
        Resource resource = new Resource();  // 實例化資源

        Thread threadProducer = new Thread(new Producer(resource)); // 創建2個生產者線程
        Thread threadProducer2 = new Thread(new Producer(resource)); // 創建2個生產者線程
        Thread threadComsumer = new Thread(new Comsumer(resource)); // 創建2個消費者線程
        Thread threadComsumer2 = new Thread(new Comsumer(resource)); // 創建2個消費者線程

        // 分別開啓線程
        threadProducer.start();
        threadProducer2.start();
        threadComsumer.start();
        threadComsumer2.start();
    }
}
/*
Thread-0...生產者生產bread..1
Thread-2...消費者消費bread.......1
Thread-1...生產者生產bread..2
Thread-3...消費者消費bread.......2
Thread-0...生產者生產bread..3
Thread-2...消費者消費bread.......3
Thread-1...生產者生產bread..4
Thread-3...消費者消費bread.......4
Thread-0...生產者生產bread..5
Thread-2...消費者消費bread.......5
Thread-1...生產者生產bread..6
Thread-3...消費者消費bread.......6
Thread-0...生產者生產bread..7
Thread-2...消費者消費bread.......7
Thread-1...生產者生產bread..8
Thread-2...消費者消費bread.......8
Thread-0...生產者生產bread..9
Thread-3...消費者消費bread.......9
Thread-1...生產者生產bread..10
Thread-3...消費者消費bread.......10
 */

根據結果可以看出:不同線程負責生產和消費,當生產一個就消費一個。運行正確。

進入 wait()方法後,當前線程釋放鎖。然後當我們notifyAll()喚醒其他線程的時候,誰競爭到這個對象鎖誰就獲得執行權,進行執行,執行完後再進入wait()等待並釋放鎖,讓其他線程執行。

上述代碼中的問題有2點需要注意,

  • 第一點是用if還是while來判斷flag,這點在代碼註釋中已經說明,
  • 第二點是用notify還是notifyAll函數。也在代碼註釋中說明

所以,多線程一般都要用while和notifyAll()的組合。

小結

多線程編程往往是多個線程執行不同的任務,不同的任務不僅需要“同步”,還需要“等待喚醒機制”。兩者結合就可以實現多線程編程,其中的生產者消費者模式就是經典範例。

然而,使用synchronized修飾同步函數和使用Object類中的wait,notify方法實現等待喚醒是有弊端的。就是效率問題,notifyAll方法喚醒所有被wait的線程,包括本類型的線程,如果本類型的線程被喚醒,還要再次判斷並進入wait,這就產生了很大的效率問題,也在代碼中給出了說明。理想狀態下,生產者線程要喚醒消費者線程,而消費者線程要喚醒生產者線程。爲此,JDK5提供了Lock和Condition接口及實現類來替代sychronized和wait機制,將在下一章介紹。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章