深入理解Java多線程(multiThread)

多線程的基本概念

一個java程序啟動后,默認只有一個主線程(Main Thread)。如果我們要使用主線程同時執行某一件事,那么該怎么操作呢?
例如,在一個窗口中,同時畫兩排圓,一排在10像素的高度,一排在50像素的高度。
如果只能在一個主線程里寫出同時執行的程序,那就只能先在高度10畫個圓,再快速在高度50畫一個圓,平移后再在高度10畫一個圓,....依此循環下去:

Paste_Image.png

假設,我們考慮,如果我們有兩個線程,一個在高度10的地方畫自己的圓,另一個在高度50的地方畫自己的圓,互不干擾。那么程序寫起來就很簡單,我們只要考慮一個線程怎么在自己的高度上畫圓就可以了。這就是java中多線程的簡單引入。

Paste_Image.png

要在java中新建一個線程,可以繼承實現Runnable接口,這個接口只有一個run方法需要實現,run方法就是一個可執行線程的進入點,run方法中的內容就是一個可執行的線程的執行內容。

public class CirclePainter implements Runnable {
    public CirclePainter(int x, int y, int r, int offset) {
        ....
    } 

    public void run() {
        while(...) {
           .. 在(x, y) 畫半徑 r 的 圓
           ... 平移 offset
        }
    } 
}

** JVM本身是個虛擬的系統,.class類就是JVM可執行的程序,一般假設,JVM只有一個CPU來執行可執行的.class程序,這個虛擬的CPU就是一個主線程,執行程序的程式入口就是main()方法。 **

** 如果你想在虛擬的系統中多使用幾個CPU,那就可以建立執行程序,給每個執行程序寫好執行代碼,然后啟動程序線程執行就行 **

Thread painterThread1 = new Thread(new CiclePainter(50, 10, 10));
Thread painterThread2 = new Thread(new CiclePainter(50, 50, 10));
painterThread1.start();
painterThread2.start();

新建的多線程Thread,執行的入口就是run方法,一旦執行完run方法之后,該線程就會被回收,如果執行完再反復調用就會發生錯誤。

Thread.start()方法會執行run方法中的代碼,這是定義在在Thread的run()方法中,實際上Thread也繼承實現了Runnable接口:

public class Thread implements Runnable {
    ...
    private Runnable target;
    ....
    public void run() {
        if (target != null) {
            target.run();
        }
    }
    ...
}

顯然,我們也可以繼承Thread類,實現它的run方法,通過這種方式來新建一個線程,但需要注意的是,一旦我們繼承Thread,那么這個就一定一個Thread,這樣就不能再繼承其他類了,顯然失去了靈活性,所以一般我們都是繼承runnable接口。

線程同步

在執行多線程的時候,如果有兩個或多個線程操作同樣的共享代碼或者數據時,就需要引起注意,這樣可能引發線程同步的問題,導致不可預知的程序結果。出現這種問題原因是因為“(Race condition)”資源競速產生的。

舉個簡單的例子來說,如果我們開發一個簡單的stack類:

public class Stack {
    private int[] data;
    private int index;
    public Stack(int capacity) {
        data = new int[capacity];
    }
    public void put(int d) {
        data[index] = d;
        index++;
    }
    public int pop() {
        index--;
        return data[index];
    }
}

這個程序在單線程執行的時候,沒有問題,但如果在多線程的執行的情況下,就可能出現race conditions的問題。假設在多線程的情況下,某個線程的run方法執行到put方法時,

public class Some implements Runnable {
    private Stack stack;
    ...
    public void run() {
        ....
        stack.put(d);
        ...
    }
}

假設index是2,執行完put的第一行代碼時,那么下面應該接下來執行index++的操作,但假設此時另一個線程正在執行pop():

public class Other implements Runnable {
    private Stack stack;
    ...
    public void run() {
        ....
        int p = stack.pop();
        ...
    }
}

那么index--就變成了1,假設,執行完這句話后,又切換回執行put方法,執行index++,那么最后index變成了2.但其實執行完put方法本來應該是3的,所以這時候就因為race conditions出現了程序的錯誤,造成了難以預知的運行結果。

實際上,我們可以想見,put方法和pop方法的index操作和取元素操作應該是不可切分的,需要一口氣執行完。但由于多線程的存在,就可能打破這個順序

那么自然想到,如果要解決這個問題,就需要將這幾步不能拆分的操作放在一個必須一次性執行完的代碼區域,這就是線程同步的概念,線程同步的區域內,所有代碼必須一次性執行完,當其在執行時,不會有其他線程插入進來。

使用synchronized關鍵字可以指定需要同步執行的代碼范圍,最基本的就是在方法前聲明為synchronized,這樣這個方法的代碼就處在同步區域中。

public class Stack {
    private int[] data;
    private int index;
    public Stack(int capacity) {
        data = new int[capacity];
    }
    public synchronized void put(int d) {
        data[index] = d;
        index++;
    }
    public synchronized int pop() {
        index--;
        return data[index];
    }
}

實際上,每個對象里都會有一個lock對象,也叫做鎖定,執行的線程要進入synchronized的區域中,必須取得這個對象的唯一的lock鎖定。假設有一個線程正在synchronized中的代碼塊,那么另一個線程想要進入這個執行區域時,由于lock已經被取走了,所以只能等待另一個線程執行完代碼,釋放代碼才行,所以這樣就實現了線程的同步。
對于上面這個例子,顯然執行put方法之前需要先取得stack的lock鎖定。

Paste_Image.png
Paste_Image.png

所以在這個例子中,如果在執行put方法,就無法執行pop方法,如果在執行pop方法,就無法執行put方法。就不會引發之前的錯誤。

進一步的,如果我們能清楚的知道,公用的存取范圍是哪些代碼塊,那我門就沒有必要將整個方法都聲明synchronized,因為那樣會降低效率,比如上個例子中,我們知道確切的共用代碼塊的范圍:

public void put(int d) {
        ...
        synchronized(this) { 
            data[index] = d;
            index++;
        }
        ...
    }
    public int pop() {
        ...
        synchronized(this) { 
            index--;
            return data[index];
        }
        ...
    }

進一步的,synchronized語句還可以進行更精細的控制,提供不同的對象的鎖定
如下面的例子:

public class Material {
    private int data1 = 0;
    private int data2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void doSome() {
        ...
        synchronized(lock1) {
            ...
            data1++;
            ...
        }
        ...
    }

    public void doOther() {
        ...
        synchronized(lock2) {
            ...
            data2--;
            ...
        }
        ...
    }
}
Paste_Image.png

在這個例子中,多個doSome方法無法同時執行,因為lock1鎖定,同理,doOther方法無法同時執行,因為lock2鎖定,但是doSome和doOther同時的執行是不干擾的,因為他們擁有不同的鎖定,互不影響。

wait,notify,notifyAll

wait和notify,notifyAll是由object所提供的方法,在定義自己的類的時候會被自動繼承下來,由于在object中,wait,notify,notifyAll都被定義為final,所以我們無法修改重新定義他們,這三個方法的作用是通知參與競爭對象的鎖定,或者是釋放對象的鎖定。

當執行程序進入synchronized區域時,會取得對象的鎖定,在執行synchronized代碼期間,如果使用對象的wait方法,就會釋放對象的鎖定,然后該執行程序就會被放入對象的等待集合中(wait set),這時候其他的線程就可以競爭鎖定的目標,進入synchronized區域執行代碼。

被放在wait set中的程序不會參加執行排版,而是一直等待notify方法或者interrupt方法調用才會參與排班,同時,wait方法可以指定wait的時間,那么就會在指定時間之后參與排班。

當調用被執行對象的notify方法時,會隨機從對象的wait set里面取出一個線程參與排版執行,也就是恢復runnable狀態,當你執行notifyAll方法時,就會從對象的wait set中取出所有的線程參與排班競爭。

舉個簡單的例子,這幾個方法就好比你讓一個做事,如果暫時不要他做事,就讓他等一下wait,等到輪到他做事了,就調用notify方法,通知他做事。

說明這幾個方法的最好例子就是生產者與消費者模式。生產者會生產商品交給店員,消費者會從店員處取走商品,店員只能持有一定數量的商品,超過商品限額,就會讓生產者wait一下,待會再生產,如果沒有商品了,就會讓消費者wait一下。

下面來具體看程序的代碼:
首先是生產者:

package Thread;

public class Producer implements Runnable {
    
    private Clerk clerk; 
    
    public Producer(Clerk clerk) { 
        this.clerk = clerk; 
    } 
    
    public void run() { 
        System.out.println(
                "生產者開始生產整數......"); 

        // 生產1到10的整數
        for(int product = 1; product <= 10; product++) { 
            try { 
                // 暫停隨機時間
                Thread.sleep((int) (Math.random() * 3000)); 
            } 
            catch(InterruptedException e) { 
                e.printStackTrace(); 
            } 
            // 將產品交給店員
            clerk.setProduct(product); 
        }       
    } 

    public static void main(String[] args) {
        Clerk clerk = new Clerk(); 

        Thread producerThread = new Thread(new Producer(clerk)); 
        Thread consumerThread = new Thread(new Consumer(clerk)); 
 
        producerThread.start(); 
        consumerThread.start();

    }

}

消費者

package Thread;

public class Consumer implements Runnable {
private Clerk clerk; 
    
    public Consumer(Clerk clerk) { 
        this.clerk = clerk; 
    } 
    
    public void run() { 
        System.out.println(
                "消費者開始消耗整數......"); 

        // 消耗10個整數
        for(int i = 1; i <= 10; i++) { 
            try { 
                // 等待隨機時間
                Thread.sleep((int) (Math.random() * 3000)); 
            } 
            catch(InterruptedException e) { 
                e.printStackTrace(); 
            } 

            // 從店員處取走整數
            clerk.getProduct(); 
        } 
    } 
}

店員

package Thread;

public class Clerk {
    // -1 表示目前沒有產品
    private int product = -1; 
 
    // 這個方法由生產者呼叫
    public synchronized void setProduct(int product) { 
        while(this.product != -1) { 
            try { 
                // 目前店員沒有空間收產品,請稍候!
                wait(); 
            } 
            catch(InterruptedException e) { 
                e.printStackTrace(); 
            } 
        } 
 
        this.product = product; 
        System.out.printf("生產者設定 (%d)%n", this.product); 

        // 通知等待區中的一個消費者可以繼續工作了
        notify(); 
    } 
    
    // 這個方法由消費者呼叫
    public synchronized int getProduct() { 
        while(this.product == -1) { 
            try { 
                // 缺貨了,請稍候!
                wait(); 
            } 
            catch(InterruptedException e) { 
                e.printStackTrace(); 
            } 
        } 
 
        int p = this.product; 
        System.out.printf(
                  "消費者取走 (%d)%n", this.product); 
        this.product = -1; 
 
        // 通知等待區中的一個生產者可以繼續工作了
        notify(); 
       
        return p; 
    } 

}

程序執行的結果:

Paste_Image.png

線程的生命周期

Paste_Image.png

當你實例化一個thread對象的時候,你必須使用start方法調用他,start只能執行一次,如果重復執行thread方法就會產生異常,執行start方法,程序并非立即執行,而是進入runnable狀態,進行執行排班的等待,等待分配cpu進行執行。

執行程序有優先級,你可以指定優先級setPriority進行優先級的設定。

執行程序一旦執行完就會進入dead狀態,可以使用isAlive方法來判斷程序是否仍存活,如果在程序死亡后,再次調用start方法就會拋出異常。

當執行程序由于IO等待或者因為執行thread.sleep方法之后,就會進入阻斷狀態,blocked,阻斷條件消失,就會進入runnable狀態,等待cpu排班執行

當執行程序進入synchronized區域時,必須先進入lock pool進行鎖定的競爭,才能進入可執行狀態進入cpu的排班。

執行中的對象取得了鎖定正在執行,但是,由于調用了wait方法,就會釋放鎖定,并且進入等到池中,等待notify,或者notifyAll方法,再進入鎖定池,進行鎖定的競爭,取得鎖定后,再進入可執行狀態,得到cpu的排班執行。

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

推薦閱讀更多精彩內容