8、多線程(2)

四、線程同步

4.1 基本概念

  • 1、由于同一進程的多個線程共享同一片存儲空間,在帶來方便的同時,也帶來了訪問沖突這個嚴重的問題。java語言提供了專門的機制來解決這種沖突,有效避免了同一個數據對象被多個線程同時訪問。
  • 2、由于我們可以通過private關鍵字來保證數據對象只能被方法訪問,所以我們只需針對方法提出一套機制,這套機制就是synchronized關鍵字,它包括方法:synchronized方法和synchronized塊。

4.2 相關例子

不使用同步時可能會出現沖突

package cn.itcast.day178.thread02;
public class SynDemo01 {
    public static void main(String[] args) {
        Web12306 web = new Web12306();// 真實角色
        // 代理對象
        Thread t1 = new Thread(web, "黃牛1");// 第二個參數是當前線程的名字
        Thread t2 = new Thread(web, "黃牛2");
        Thread t3 = new Thread(web, "黃牛3");
        // 啟動線程
        t1.start();
        t2.start();
        t3.start();
    }
}

class Web12306 implements Runnable {
    private int num = 10;
    private boolean flag = true;

    public void run() {
        while (flag) {
            test1();
        }
    }

    // 線程不安全
    public void test1() {
        if (num <= 0) {
            this.flag = false;
            return;
        }
        try {
            Thread.sleep(500);// 500ms的延時
            // 加入延時之后可能會造成資源沖突的問題,這就是并發問題
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "搶到了第" + num--
                + "張票");
    }
}

說明:這個例子是模擬搶票的情況,如果不加入同步,則可能一張票同時被多個人搶到,顯示這是有問題的,下面我們看使用同步方法來解決這個問題:

    // 線程安全,同步方法
    public synchronized void test2() {
        if (num <= 0) {
            this.flag = false;
            return;
        }
        try {
            Thread.sleep(500);// 500ms的延時
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "搶到了第" + num--
                + "張票");
    }

說明:在方法中加上synchronized關鍵字就可以將此方法變成一個同步方法,當一個運行一個線程的此方法時,如果此方法沒有運行完,則其他線程的此方法是不能執行的。當然我們還可以使用同步塊來達到這個目的:

    // 線程安全,同步塊
    public void test3() {
        synchronized (this) {// 鎖定this,即鎖定當前線程
            if (num <= 0) {
                this.flag = false;
                return;
            }
            try {
                Thread.sleep(500);// 500ms的延時
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "搶到了第"
                    + num-- + "張票");
        }
    }

說明:這里我們將方法中所要執行的代碼全部放在同步塊中,這樣在同步塊中的代碼沒有執行完的時候資源是被此線程鎖定的,同時要注意:這里同步塊需要給定鎖定的線程對象,這里我們給出的是當前線程。當時有時候我們將要執行的代碼全部放在同步塊中會造成效率的下降,一般我們將可能出現并發錯誤的代碼放在同步塊中,達到最佳的效果,下面我們看一個錯誤的例子:

    // 線程不安全,同步塊,鎖定一部分,鎖定范圍不正確
    public void test4() {
        synchronized (this) {// 鎖定this,即鎖定當前線程
            if (num <= 0) {
                this.flag = false;
                return;
            }
        }
        try {
            Thread.sleep(500);// 500ms的延時
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "搶到了第" + num--
                + "張票");
    }

說明:這里可能發生并發錯誤的位置是票數量減少的代碼,這里顯然同步塊位置是有問題的,所以并不能解決并發問題。放在同步塊中的代碼不僅要正確,我們鎖定的資源對象也要正確,下面看鎖定資源對象錯誤的一個例子:

    // 線程不安全,同步塊,鎖定資源不正確
    public void test5() {
        synchronized ((Integer) num) {// 對于基本類型需要包裝
            if (num <= 0) {
                this.flag = false;
                return;
            }
            try {
                Thread.sleep(500);// 500ms的延時
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "搶到了第"
                    + num-- + "張票");
        }
    }

說明:資源對象一般是某個線程對象(基本類型數據需要包裝),但是這里卻不是,所以也不能解決并發問題。還有一種同步塊范圍不對的情況:

    // 線程不安全,同步塊,鎖定資源不正確
    public void test6() {
        if (num <= 0) {
            this.flag = false;
            return;
        }
        //a b c
        synchronized (this) {
            try {
                Thread.sleep(500);// 500ms的延時
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "搶到了第"
                    + num-- + "張票");
        }
    }

說明:這里我們可以看到,多個線程可能同時出現在同步塊之前進行等待,那哪個線程進入同步塊中執行呢?這顯然是不確定的,這樣就會造成沖突。上面我們講解了同步塊的兩種形式:

synchronized(引用類型)
synchronized(this)

其實同步塊還有一種形式synchronized(類.class)。先看一種設計模式:單例設計模式

package cn.itcast.day178.thread02;
//單例設計模式:確保一個類只有一個對象
public class SynDemo02 {
    public static void main(String[] args) {
        test2();
    }

    public static void test2() {
        // 此時我們看到單例就沒有達到效果,我們在getnInstance方法中加入同步關鍵字
        JvmThread thread1 = new JvmThread(100);
        JvmThread thread2 = new JvmThread(500);
        thread1.start();
        thread2.start();
    }
    public static void test1() {
        Jvm jvm1 = Jvm.getInstance();
        Jvm jvm2 = Jvm.getInstance();
        // 單線程中下面兩個對象是一樣的,達到了單例的效果,但是在多線程中就不一定了
        System.out.println(jvm1);
        System.out.println(jvm2);
    }
}

class JvmThread extends Thread {
    private long time;

    public JvmThread() {
    }

    public JvmThread(long time) {
        this.time = time;
    }

    public void run() {
        System.out.println(Thread.currentThread().getName() + "-->"
                + Jvm.getInstance(time));
    }
}

// 確保一個類只有一個對象:
// 懶漢式
class Jvm {

    // 1、構造器私有化,避免外部直接創建對象
    private Jvm() {}
    // 2、聲明一個私有靜態變量
    private static Jvm instance = null;
    // 3、創建一個靜態的公共方法訪問該變量,如果變量沒有對象,創建該對象
    public static Jvm getInstance() {
        if (instance == null) {
            instance = new Jvm();
        }
        return instance;
    }
}

說明:單例設計模式就是為了確保在程序運行過程中一個類只有一個實例對象。Jvm類我們使用了基本的單例設計模式,在單線程中可以確保只有一個對象實例,但是在多線程就不一定了(test1方法)。從這個類中我們可以知道單例設計模式的基本步驟。其中加入延時是為了放大出錯的概率。從測試結果中可以看到并沒有達到單例的效果(run方法打印出來的結果不一致)。當然解決這個問題最簡單的方式就是在getInstance方法中加入synchronized關鍵字,但是這里我們主要看使用同步塊如何解決,下面我們改進Jvm類:

class Jvm1 {
    // 1、構造器私有化,避免外部直接創建對象
    private Jvm1() {
    }

    // 2、聲明一個私有靜態變量
    private static Jvm1 instance = null;

    // 3、創建一個靜態的公共方法訪問該變量,如果變量沒有對象,創建該對象
    public static Jvm1 getInstance1(long time) {
        if (instance == null) {
            try {
                Thread.sleep(time);// 加入延時
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            instance = new Jvm1();
        }
        return instance;
    }

    // 加入同步,我們可以直接在方法上加上synchronized關鍵字,這里我們使用同步塊,但是效率不高
    // 在下面我們進行改進
    public static Jvm1 getInstance2(long time) {
        synchronized (Jvm.class) {// 這里我們不能使用this了,因為this還沒有創建出來,于是使用字節碼
            if (instance == null) {
                try {
                    Thread.sleep(time);// 加入延時
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                instance = new Jvm1();
            }
            return instance;
        }
    }

    // 改進,這里比如有a,b,c三個線程,一開始對象為空,進入第一個if,然后a進入同步塊,其他線程等待
    // 當a進去之后則對象就被創建了,于是當其他線程進入同步塊的時候就不需要像上面那樣等待了,直接返回已有
    // 對象
    public static Jvm1 getInstance3(long time) {
        if (instance == null) {
            synchronized (Jvm.class) {
                if (instance == null) {
                    try {
                        Thread.sleep(time);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    instance = new Jvm1();
                }
            }
        }
        return instance;
    }
}

說明:首先我們是對方法getInstance改進成了getInstance2,進入了同步關鍵字,但是參數不能再是this了,因為此時對象還沒有創建出來。此時我們進行測試可以發現達到了同步的效果。但是這種實現的方式可能效率和之前的同步方法的效率一樣,不太高,因為此時不管對象存在不存在都需要在同步塊前面等待,我們改進為getInstance3方法,這樣如果對象存在則不需要進入同步塊中,直接拿到對象即可使用。而單例創建的方式有上面提到的懶漢式,還有其他方式:

package cn.itcast.day178.thread02;

/*單例創建的幾種方式:
 * 1、懶漢式
 *  a、構造器私有化
 *  b、聲明私有的靜態屬性
 *  c、對外提供訪問屬性的靜態方法,確保該對象存在
 * */
public class MyJvm03 {
    private static MyJvm03 instance;
    private MyJvm03(){
        
    }
    public static MyJvm03 getInstance(){
        if(instance == null){//為了效率
            synchronized (MyJvm03.class) {
                if(instance == null){//為了安全
                    instance = new MyJvm03();
                }
            }
        }
        
        return instance;
    }
}

/*2、惡漢式
 *  a、構造器私有化
 *  b、聲明私有的靜態屬性,同時創建該對象
 *  c、對外提供訪問屬性的靜態方法,確保該對象存在
 * */
class MyJvm04{
    private static MyJvm04 instance = new MyJvm04();
    private MyJvm04(){
        
    }
    public static MyJvm04 getInstance(){
        return instance;
    }
    
}
//惡漢式提高效率的改進:類在使用的時候才讓其加載,這樣只要不調用
//getInstance方法,那么就不會加載類,這樣延緩了類加載時機
class MyJvm05{
    private static class JVMholder{
        private static MyJvm05 instance = new MyJvm05();
    }
    
    private MyJvm05(){
        
    }
    public static MyJvm05 getInstance(){
        return JVMholder.instance;
    }
}

說明:相對來說,惡漢式的效率較高一點。

五、死鎖

過多的同步容易造成死鎖,就是一份資源同時被多個線程同時調用。

package cn.itcast.day178.thread02;
//兩個線程使用的是同一份資源,可能就會造成死鎖,但是這并不絕對
//過多的同步容易造成死鎖
public class SynDemo03 {
    public static void main(String[] args) {
        Object goods = new Object();
        Object money = new Object();
        Test t1 = new Test(goods, money);
        Test1 t2 = new Test1(goods, money);
        Thread proxy1 = new Thread(t1);
        Thread proxy2 = new Thread(t2);
        proxy1.start();
        proxy2.start();
    }
}

class Test implements Runnable{
    Object goods;
    Object money;
    
    public Test(Object goods, Object money) {
        this.goods = goods;
        this.money = money;
    }

    public void run() {
        while(true){
            test();
        }
    }
    public void test(){
        synchronized (goods) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (money) {
            }
        }
        System.out.println("一手給錢");
    }
}


class Test1 implements Runnable{
    Object goods;
    Object money;
    
    public Test1(Object goods, Object money) {
        super();
        this.goods = goods;
        this.money = money;
    }

    public void run() {
        while(true){
            test();
        }
    }
    public void test(){
        synchronized (money) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (goods) {
            }
        }
        System.out.println("一手交貨");
    }
}

說明:此時我們發現不會的打印出任何內容,因為造成了死鎖。解決死鎖的思路就是使用生產者消費者設計模式。

生產者消費者模式

  • 1)生產者消費者模式也稱有限資源緩沖問題,是一個多線程同步問題的經典案例。該問題描述了兩個共享固定大小的緩沖區的線程-即所謂的生產者合格消費者-在實際運行時會發生的問題。生產者的主要作用是生成一定量的數據放到緩沖區中,然后重復此過程。與此同時,消費者也在緩沖區消耗這些數據。該問題的關鍵就是要保證生產者不會在緩沖區滿時加入數據,消費者也不會在緩沖區空時消耗數據。

  • 2)要解決該問題,就必須讓生產者在緩沖區滿時休眠(要么干脆就放棄數據),等到下次消費者消耗緩沖區中的數據的時候,生產者才能被喚醒,開始往緩沖區中添加數據。同樣,也可以讓消費者在緩沖區空的時候進入休眠,等到生產者往緩沖區中添加數據之后,再喚醒消費者。通常常用的方法有信號燈法,管程等。如果解決方法不不夠完善,則容易出現死鎖的情況,出現死鎖時,兩個線程都會陷入休眠,等待對方喚醒自己。

這里我們介紹信號燈法,首先給出資源:

package cn.itcast.day178.thread02;
/*一個場景,一份共同的資源
 * 生產者消費者模式,采用信號燈法
 * wait會釋放鎖,而sleep則不釋放鎖
 * notify和notifyAll表示喚醒
 * 注意:上面說的方法必須和同步在一起使用,不然就使用不了
 * */
public class Movie {
    private String pic;
    //信號燈,當為true時表示生產者生產,消費者等待,生產完成之后通知消費者消費
    //當為false的時候,生產者等待,消費者消費,當消費完成之后通知生產者生產
    private boolean flag = true;
    public synchronized void play(String pic){
        if(!flag){//生產者等待
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //開始生產
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("生產了: " + pic);
        //生產完畢
        this.pic = pic;
        //通知消費
        this.notify();
        //生產者停止
        this.flag = false;
    }
    
    public synchronized void watch(){
        if(flag){
            //消費者等待
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //開始消費
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("消費了: " + pic);
        //消費完畢,通知生產
        this.notify();
        
        //消費停止
        this.flag = true;
    }
}

說明:資源是一個電影,那么生產者就是演員:

package cn.itcast.day178.thread02;
/*表演者,這里就相當于生產者 */
public class Player implements Runnable{
    private Movie movie;
    
    public Player(Movie movie) {
        super();
        this.movie = movie;
    }

    public void run() {
        for(int i = 0; i < 20; i++){
            if(i % 2 == 0){
                movie.play("左青龍");
            }else{
                movie.play("右白虎");
            }
        }
    }
}

說明:再給出消費者:

package cn.itcast.day178.thread02;
public class Watcher implements Runnable{
    private Movie movie;

    public Watcher(Movie movie) {
        super();
        this.movie = movie;
    }

    public void run() {
        for(int i = 0; i < 20; i++){
            movie.watch();
        }
    }
}

說明:下面我們使用:

package cn.itcast.day178.thread02;
public class App {
    public static void main(String[] args) {
        //共同的資源
        Movie m = new Movie();
        
        //多線程
        Player p = new Player(m);
        Watcher w = new Watcher(m);
        
        new Thread(p).start(); 
        new Thread(w).start(); 
    }
}

說明:我們在使用的時候同時開啟了生產者和消費者線程,在運行過程中如果資源沒有生產出來則消費者線程等待,資源生產出來之后消費者線程執行。

六、任務調度

  • 1)Timer定時器類
  • 2)TimerTask任務類
  • 3)通過timertimertask:(spring的任務調度就是通過它們來實現的)
  • 4)在這種實現方式中,Timer類實現的是類似鬧鐘的功能,也就是定時或者每個一定時間觸發一次線程。其實,Timer類本身實現的就是一個線程,只是這個線程是用來實現調用其他線程的。而TimerTask類是一個抽象類,該類實現了Runnable接口,所以按照前面的介紹,該類具備多線程的能力。
  • 5)在這種實現方式中,通過繼承TimerTask使該類獲得多線程的能力,將需要多線程執行的代碼書寫在run方法內部,然后通過Timer類啟動線程的執行。
  • 6)在實際使用時,一個Timer可以啟動任意多個TimerTask實現的線程,但是多個線程之間會存在阻塞。所以如果多個線程之間如果需要完全獨立運行的話,最好還是一個Timer啟動一個TimerTask實現。

下面看一個例子:

package cn.itcast.day178.thread02;
import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;
/*
 * 使用方法schedule指定任務
 * */
public class TimerDemo01 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        //這里第一個參數表示指定一個任務,第二個參數表示什么時候開始執行,
        //第三個參數表示每隔多少秒執行一次,如果沒有第三個參數則只運行一次
        timer.schedule(new TimerTask() {
            //線程體
            public void run() {
                System.out.println("線程體....");
            }
        }, new Date(System.currentTimeMillis() + 1000), 200);
    }
}

最后我們看一下notifynotifyAll的區別:
這里notify()notifyAll()都是Object對象用于通知處在等待該對象的線程的方法。

  • void notify():喚醒一個正在等待該對象的線程。
  • void notifyAll():喚醒所有正在等待該對象的線程。

兩者的最大區別在于:
notifyAll使所有原來在該對象上等待被notify的線程統統退出wait的狀態,變成等待該對象上的鎖,一旦該對象被解鎖,他們就會去競爭。notify他只是選擇一個wait狀態線程進行通知,并使它獲得該對象上的鎖,但不驚動其他同樣在等待被該對象notify的線程們,當第一個線程運行完畢以后釋放對象上的鎖,此時如果該對象沒有再次使用notify語句,即便該對象已經空閑,其他wait狀態等待的線程由于沒有得到該對象的通知,繼續處在wait狀態,直到這個對象發出一個notifynotifyAll,它們等待的是被notifynotifyAll,而不是鎖。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容