【Java】線程的理解和使用

重要聲明:本文章僅僅代表了作者個人對此觀點的理解和表述。讀者請查閱時持自己的意見進行討論。

本文更新非常不及時,建議到原文地址瀏覽: 《【Java】線程的理解和使用》。

一、認識線程

任何一個程序至少有一個線程。這個線程是主線程,維持程序執行的線程。有時候我們在主線程中執行某個任務(方法)時,使主線程卡住或者執行緩慢。這時候,就非常有必要另外在創建一個新線程,將任務(方法)放在這個新的線程裏面去執行。這樣就可以減少主線程的負擔並將最終結果完成得更有效率。

二、創建並使用線程

1、創建線程

線程的創建離不開Thread類,因此首先必須要先學習Thread類,才能明白如何創建線程。Thread類提供了一個無參數的構造函數,意味着可以直接通過new Thread()來創建一個新的線程。

Thread nthd = new Thread();

上述代碼創建了一個空線程,這個線程什麼事情都不會做。現在,通過start()方法就可以讓這個線程運行起來。

nthd.start();

但是因爲這是空線程,沒有任何任務,因此,線程開啓後馬上就會退出。現在的任務就是如何讓這個新創建的線程運行一個任務。

要給新線程添加一個任務,其實非常簡單。新線程的創建,除了可以使用Thread的無參構造函數,還可以使用其提供的另一個構造函數Thread(Runnable target)。意味着可以傳入一個Runnable來創建線程。在加入任務創建新線程之前,先了解一下Runnable

2、Runnable

它是一個接口,裏面只有一個run方法,並且是無參數的。要實現這個接口就必須實現這個方法。你可以把你的任務放在這個run方法中去執行。通常實現這個接口的類都是有任務要放在新的線程裏去執行的。

比如說,打印1...100這些所有數字。可以這樣實現:

Runnable print100 = new Runnable() {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(i);
        }
    }
};

// 調用run方法,打印。
print100.run();

此程序輸出1...100沒有任何問題。但是注意,上述代碼並沒有新開任何線程。要讓這個任務在新線程裏面執行,必須要結合Thread纔可以。下面將進行介紹。

3、新線程執行任務

現在我們對Runnable有了認識,對Thread也有了認識,只需要將RunnableThread結合,就可以讓任務在新線程裏面執行。

Runnable print100 = new Runnable() {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(i);
        }
    }
};
Thread print100Thread = new Thread(print100);
// 啓動新開啓的線程。即可執行 print100 的任務
print100Thread.start();

上述代碼,就向你展示瞭如何使用RunnableThread結合來完成新線程執行指定的任務。

4、繼承Thread

上面我們通過了RunnableThread的結合來完成了新線程的創建並指定了具體任務。其實有另一種更加方便的方法來實現。

繼承Thread類,然後重寫run方法,直接將你的任務代碼寫在run方法裏面,就可以完成和上面一樣的效果。

public class Print100Thread extends Thread {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(i);
        }
    }
}

然後就可以直接通過new Print100Thread()來創建一個線程執行對應的任務:

Print100Thread print100Thread = new Print100Thread();
// 啓動新開啓的線程。即可執行任務
print100Thread.start();

5、注意事項

現在我們瞭解瞭如何創建線程,並且熟悉瞭如何將任務放在線程裏去執行。但是要精通這項技能,光了解這點知識點是遠遠不夠的。還有許多要注意的地方。

  1. 只有通過調用start()方法啓動的線程,run方法纔會在子線程裏面執行。調用其他任何方法使run方法得到了執行,都不是新開的線程。
  2. Thread類裏面,僅有run方法裏面的代碼,或在run方法裏面被調用的方法會在子線程裏面執行。也就是,Thread裏面子線程執行入口有且只有一個run方法。
  3. run方法執行完成,線程隨之退出。run方法一直執行,此線程會一直等待直到run方法執行完成。

三、線程的進階知識

上面講述了基本的線程知識,你明白瞭如何創建線程,和在線程內執行你希望的任務。但是在實際開發中,遇到的需求往往比案列中要複雜得多。爲了應對更爲複雜的需求場景,不得不祭出和線程操作有關的更多方法。

1、Thread.sleep()

這是一個靜態公共方法。由Thread類提供的。它可以使當前線程直接睡眠指定的時間(毫秒),相當於你給他傳遞多長時間,這行代碼就要卡住多長時間。比如:

for (int i = 0; i < 60; i++) {
    System.out.println(i);
    try {
        Thread.sleep(1000); // 每次循環輸出了數字後,睡眠1000毫秒(1秒)的時間。
    }catch (Exception ignore) {/* 這裏暫時忽略異常 */}
}

看起來非常簡單,就是讓當前線程卡住(睡眠)指定長度的時間。不過還是有些點是需要注意到。

  1. 這是一個靜態方法。建議你經通過Thread.sleep()的方式去調用。
  2. 起作用永遠都是作用於這行代碼所處的那個線程。例如下面這種極端場景也不例外:
// 創建一個新線程A
Thread threadA = new Thread();

// 創建一個新線程B
Thread threadB = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("線程B開始了。");

        try {
            // 嘗試通過A線程對象來調起 sleep 方法。
            threadA.sleep(1000);
        }catch (Exception ignore) {/*暫時忽略異常*/}
        System.out.println("線程B結束了。");
    }
});
threadB.start();

即便是這樣的代碼。最終的效果是:先輸出線程B開始了。。然後等待1秒鐘之後輸出線程B結束了。。可以證明sleep方法是一個靜態方法,它不在乎是誰引用調起了它,它的作用也永遠是:這行代碼在哪兒執行的,就把對應的線程給睡眠指定時間。

2、synchronized

synchronized 不是一個方法。em~~~。可以理解爲它是一種語法。用法示列如下:

public class Test {
    static Object obj = new Object(); // 標記1
    public static void main(String[] args) {
        int number = 0;

        // 使用 synchronized 代碼塊 包圍 裏面那段代碼。
        synchronized (obj) {       // 標記2
            number = 10 + number;  // 標記3
        }
    }
}

在我還沒介紹前不必擔心看不明白。首先需要對裏面進行標記的三處代碼進行介紹。

  • 標記1:創建了一個同步鎖對象。
  • 標記2:使用同步鎖對象和synchronized結合的語法形成同步代碼塊。
  • 標記3:要同步執行的代碼。

十分有必要再進一步解釋爲什麼需要這樣的代碼,你可能纔會完全領會到它的用途。爲了解釋清楚,先不用代碼直接說這件事。不妨比喻一個場景:

有一個公共衛生間,裏面只有一個坑,有時候客流量大的時候會有很多人同時衝進去蹲坑。相關部門就看不下去了,說這也不是一個辦法。必須解決這個問題。有人就提出一個解決方案,說每次只能進去一個人蹲坑,通過衛生間外面的一個“坑票循環機”,要蹲坑,先從這臺機器裏取票,而且總共只有一張票。蹲坑完成後將票塞回這臺機器的回收口,下一個人才能又從這臺機器的取票口拿到這張票。必須有票才能進去蹲坑。沒票說明上一個人還在蹲,就只能在外面等着,乖乖排隊。

相信這樣說你應該就明白了。所謂同步代碼塊,就相當於這個“坑票循環機”。obj就相當於“票”。整個代碼塊由 synchronized、“票”、和一個{}代碼塊構成。當有一個線程正在執行標記3的代碼時,相當於有個人進去蹲坑了,票(obj)也被拿了,這時候就不能有其它的線程再進來蹲坑了。

這就解決了一個問題啊,什麼問題?比如說,如果那個number的含義是共有多少個促銷商品,商品一旦開始促銷,就會瞬間有很多人搶購,反應到程序裏,就變成瞬間有多個線程同時要來操作這個number,廁所可以做到一個坑位即便來了2個人,另一個人也沒辦法蹲,而程序可不會,程序是來了就幹了。這樣可能出現同一個商品被賣出去多次啊,這問題可就大了去了。所以,爲了解決這個問題,同步代碼塊產生了。

同步鎖

通過上面的介紹,你要知道。同步代碼鎖其實就是一個 obj 對象,它允許的類型是Object的,也就是你可以使用任何類對象來作爲這把鎖。不管你用什麼類、什麼對象,你都要保證“坑票”只能有一個,在整塊同步代碼塊上,那個 obj 只能是唯一的。你不能每一個同步代碼塊都用一個新的obj對象,那就沒有意義了。那就等同於“坑票循環機”要產出多張票了。這是萬萬不應該的。

比如下面示列 錯誤 的用法(語法沒問題):

public class TestLock {
    public static void main(String[] args) {
        TestLock t = new TestLock();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();
    }

    int number = 0;

    void test() {
        // 創建同步鎖
        Object obj = new Object();
        synchronized (obj) { // 代碼塊
            number = 10 + number;
        }
        System.out.println(number);
    }
}

上述代碼中,test 方法裏有一個同步代碼塊,並且在main函數中使用2個線程分別調用這個方法。而這個方法裏面的同步鎖對象都是新創建的,意味着多個線程用的同步鎖不是同一個,這就導致同步代碼塊毫無意義。所以不能這樣使用。必須優化修改,只能使用一個鎖對象。如下:

public class TestLock {
    public static void main(String[] args) {
        TestLock t = new TestLock();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();

        new Thread(new Runnable() {
            public void run() {
                t.test();
        }}).start();

    }

    // 創建同步鎖. 同步鎖應該只用同一個,因此將其創建爲final不能修改,且作爲Test的全局變量。
    private final Object obj = new Object();

    int number = 0;
    void test() {
        synchronized (obj) { // 代碼塊
            number = 10 + number;
        }
        System.out.println(number);
    }
}

將鎖對象提取爲全局對象,讓test方法中使用的 obj 都保證是同一個鎖對象。不過你依然要注意,並不是永遠都是鎖對象保持唯一,此處的示列代碼,對於一個TestLock對象,裏面是同步有效的。假如你new了2個TestLock出來,當然這兩個裏面的obj分別是各種的obj對象,其對應同步代碼塊用的鎖也是各自的鎖。按某種理解上,這裏產生了2個鎖對象出來,不過這兩個也鎖對象也保證了只會用在各自的代碼塊裏。

所以,最終,你必須要弄清楚,同步代碼塊,你用在什麼地方,鎖對象的創建根據自己的需求創建唯一鎖。

3、Object#wait()

wait方法可以讓線程進入等待狀態。也就是一但你用了這個方法,這行代碼就會一直卡在這兒不會繼續向下執行了。如果你希望這行代碼的位置處繼續執行下去,你可以調用notifynotifyAll方法,這兩個方法將在下文講解。wait方法是Object類的成員方法,不是靜態方法。因此必須創建一個對象,在對象上使用此方法。要注意wait方法的使用必須在synchronized代碼塊裏面,在其它地方使用都會報錯的。

int number = 0;
void test() {
    synchronized (obj) {
        number = 10 + number;
        try {
            obj.wait(); // 標記1
        }catch (Exception ignore) {/*暫時忽略異常*/}
    }
    System.out.println(number);
}

上述代碼中,在標記1的位置處使用了wait方法,那麼這個時候,這個代碼就會永遠的卡在這裏,等待下去…。現在我們先不關心它後續會怎麼樣。先來剖析一下這個代碼。細心的你肯定會發現,我們是直接使用同步鎖對象來調用wait方法的。這也是爲什麼必須在synchronized裏面使用wait方法的原因。就拿上面的“蹲坑”事情來說吧,相當於這個進去蹲坑的人突然被那張票施了冰凍魔法(wait),將其凍住了,蹲坑的人就一直不動,必須等這張票再次施展火球魔法(notify)解凍,纔可以繼續執行下去。而此期間,進去的人一直在裏面,外面也拿不到票進不來。蹲坑線程凍住了沒關係,別的線程沒凍住嘛,別的線程拿到同步鎖,調用notify方法施展火球魔法,這張票施展火球魔法將其解凍,又繼續愉快的蹲坑了。所以說,通常情況下,wait是自己線程發現某情況下需要wait了,進行自行wait。而自己都卡住了就沒法運行了,也就不可能談什麼恢復了。因此通常是還有另一個線程,發現你的某情況又再一次符合標準了,它給你notify一下,你就又開始繼續執行了。

這樣就就形成了一種很協調的機制,與現實世界的事件發生順序可以完全吻合了。比方說:你找包工頭要工資,包工頭髮現上頭還沒撥款下來,包工頭也沒錢。你就賴着(wait)不走了,就一直等啊等,突然包工頭收到了錢了,包工頭給你一notify,你就美滋滋拿錢走人了。又比如說:你是工廠,你在生產手機。有個超市要從你這兒進購一批手機,你發現手機不夠啊。這時候,人家就wait在哪兒了,你就不停的生產啊生產。唉!突然手機生產夠了,你就又給人家來個notify。人家美滋滋拿着貨走人。

4、Object#notify()\notifyAll()

notifynotifyAll方法作用相似,只不過前者只會喚醒一個在wait的線程。而後者會喚醒所有在wait的線程。我們知道wait方法的使用必須要在同步鎖對象上使用。因此這裏所說的喚醒一個或者喚醒所有是指的喚醒使用對應同步鎖進行wait的一個或者所有線程。示列如下:

// 假設現已有同步鎖 obj
final Object obj = new Object();
// 開啓線程
new Thread(new Runnable() {
    public void run() {
        synchronized(obj) {
            System.out.println("開始...");

            try {
                // 進入等待狀態。
                obj.wait();
            }catch(Exception ignore) {
                /*暫時忽略異常*/
            }

            System.out.println("完成...");
        }
    }
}).start();
// 同時也開啓另一個線程
new Thread(new Runnable() {
    public void run() {
        // 先 sleep 2秒鐘,
        // 這樣2秒過後左邊都等很久了。
        // 再調notify就能把左邊喚醒。
        try {
            Thread.sleep(2 * 1000);
        }catch(Exception ignore) {
            /*暫時忽略異常*/
        }

        synchronized (lock) {
            // 用obj同步鎖調用喚醒,
            // 讓左邊的線程繼續執行。
            obj.notify();
        }
    }
}).start();

爲了直觀感受,我將排版改成橫向的了。運行上面的代碼,左右兩邊的線程同時開啓,左邊一開始就進入wait狀態,後面一開始就先睡眠2秒鐘,等2秒鐘過後,右邊的線程喚醒了左邊的wait狀態,左邊進行輸出。集合wait那一小節的知識,香型。很快就能理解。

四、生產者與消費者

學會了上面的知識點之後,不如進行一次簡單的實戰。生產者與消費者的關係基本上是學習線程的首個demo案列。就如同你要學習某個語言之後第一個程序是“hello world.”一樣。

生產者作爲一個線程。 消費者作爲一個線程。這就形成了最簡單的供求關係了。而且可能有多個消費者線程、也有多個生產者線程。

1、程序設計

這個案例涉及到的東西相對較多,我們必須有一個合適的程序結構,才能更容易理解,更符合程序規範,更加容易維護我們寫出來的程序。首先我們要考慮到的就是“生產者”可以有多個人的,“消費者”也可以有多個人。那麼,生產者生產的“產品”,是要設計在生產者裏面,還是要設計在一個單獨的地方?

這個問題很好回答,如果我們只支持一個生產者,當然可以把“產品”就放在生產者裏面。但我們要設計支持多個生產者同時生產的,如果還把產品放在各自生產者裏自己維護,那麼每個生產者要去關心自己有沒有足夠的產品提供。相當於生產者除了要做生產這件事、它還要管理產品夠不夠給的事情。這無疑有點讓生產者的設計變得臃腫且沒有必要。

所以,將產品放在一個單獨的管理區,相當於有一個倉庫。生產者生產的產品都放在這個倉庫裏面。這樣生產者只需要生產,而不需在乎產品具體有多少。即便有多個生產者,產出的產品也直接往倉庫裏面放就好了。而對於消費者,直接從倉庫取。這樣一來,無論是生產者還是消費者,都可以支持多個生產者或消費者。

2、開發產品倉庫

不論是消費者還是生產者,都是要和倉庫打交道的。首先開發出倉庫,可以同時爲生產者和消費者提供更多便利之處。以ProductRoom類作爲倉庫:

public class ProductRoom {
    // 同步鎖對象。
    private final Object lock = new Object();
    // 所有產品都放置在這個集合裏。
    private List<Object> products;

    public ProductRoom () {
        products = new ArrayList<>();
    }

    // 從倉庫中取一個產品出來。
    public Object takeAProduct() throws Exception{
        // 使用同步代碼塊進行同步,保證每個產品不會被多次獲取。
        synchronized (lock) {
            // 只要倉庫裏沒有產品,就在這兒等着。
            while (products.isEmpty()) {
                lock.wait();
            }
            // 能運行到這裏,說明必然是有產品的。
            return products.remove(0);
        }
    }

    // 放一個產品到倉庫裏面。
    public void putAProduct(Object obj) {
        synchronized (lock) {
            products.add(obj);
            // 放了一個產品進去後,有可能有些消費者還在等着獲取產品呢。
            // 所以,這裏就調一次喚醒,讓等待的消費者能繼續獲取產品。
            lock.notifyAll();
        }
    }
}

3、開發生產者

一個生產者就是一個新線程,所以,我們的生產者肯定是要繼承Thread來實現的。並且我們定義生產者每5秒鐘生產一個產品。我們以ProductMaker作爲生產者類:

/**
 * 生產者。
 */
public class ProductMaker extends Thread{

    // 爲了識別生產者,我們給生產者一個名字。
    private String mName;

    // 倉庫。
    private ProductRoom productRoom;

    // 構造時,指定將生產的產品放到對應的倉庫裏。
    public ProductMaker (String name, ProductRoom productRoom) {
        this.mName = name;
        this.productRoom = productRoom;
    }

    @Override
    public void run() {
        super.run();

        // 生產者只要已啓動,就不停的每5秒生產一個產品。
        while (true) {
            try {
                // 每五秒生產一個,只需要sleep5秒鐘,來模擬耗時5秒鐘才生產完成。
                Thread.sleep(5 * 1000);
            }catch (Exception ignore) {/* 暫時忽略異常 */}

            Object o = new Object();
            // 生產了一個。就放到倉庫裏
            productRoom.putAProduct(o);
            // 順便打印一個提示。
            System.out.println("[" + new Date().toLocaleString() + "] " + this.mName + "生產了一個產品:@" + o.hashCode());
        }

    }

    public String getmName() {
        return mName;
    }
}

4、開發消費者

一個消費者也是一個線程,所以也還繼承Thread來實現,我們定義消費者每3秒就消費一個產品。定義消費者ProductUser類如下:

/**
 * 消費者類
 */
public class ProductUser extends Thread {

    // 爲了記錄是哪一個消費者,我們定一個姓名。
    private String mName;

    // 定義消費者要消費哪一個倉庫裏的產品。
    private ProductRoom productRoom;

    public ProductUser(String mName, ProductRoom productRoom) {
        this.mName = mName;
        this.productRoom = productRoom;
    }

    @Override
    public void run() {
        super.run();

        while (true) {

            try {
                // 從倉庫取出消耗一個產品。
                System.out.println("[" + new Date().toLocaleString() + "] " + mName + "獲取產品..");
                Object o = productRoom.takeAProduct();
                System.out.println("[" + new Date().toLocaleString() + "] " + "成功並消費了:@" + o.hashCode());

                // 每3秒就消耗一個產品。
                Thread.sleep(3 * 1000);
            } catch (Exception ignore) {/*暫時忽略異常*/}
        }
    }
}

5、測試

現在,一切就緒,可以進行測試了:

public static void main(String[] args) {
    // 創建一個倉庫
    ProductRoom room = new ProductRoom();

    // 創建2個生產者
    ProductMaker maker = new ProductMaker("生產者A", room);
    ProductMaker maker2 = new ProductMaker("生產者B", room);

    // 創建一個消費者
    ProductUser user = new ProductUser("消費者Z", room);

    // 開啓生產和消費
    maker.start();
    maker2.start();
    user.start();
}

來看一下運行結果:

{"class":"useDialog"}

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