java - 深入篇 --Java的多線程實現

前面我們講了java的異常處理機制,介紹了在java開發中是如何定位錯誤,即顯見bug的,而今天我們要探究的是一個老司機開車的問題–多線程,關於這個問題,整個體系很是複雜,同時也是面試中必考的一個考點,最重要的是,如果沒有掌握到這個知識點,那麼你在接下來的學習中,會覺得非常的痛苦。所以,在這裏將額外花費一些事件進行介紹。(敲黑板,筆記做好咯)

多線程概念

在介紹什麼叫多進程前,請允許先介紹一下進程和線程的關係。所謂進程,就是我們調用程序的這麼一個過程,通俗來說,就是從我們打開應用,到最後關閉應用的這麼一個過程,在這個過程裏,計算機會經歷從代碼加載到內存,代碼執行和執行完畢,程序退出,內存空間被銷燬。這個整體的狀態,稱之爲程序的進程。而其中,代碼按照順序不斷執行的過程,就是一個線程。而何爲多進程呢?我們知道,程序運行的時候,是會按照代碼順序(循環,選擇,順序)一行一行去實現的是吧,那麼,在這裏就牽涉到了一個問題:“如果我們要實現從網上加載一張比較大的圖片到手機上並且顯示,會是怎麼樣的呢?”如果從單線程的角度來說,當我們要加載圖片的時候,肯定是要先等它加載完了纔可以執行下一步是吧。那麼如果這張圖要加載一個小時呢?在這個過程裏我們的用戶可不能再執行其他的操作,就只能眼巴巴地盼着時間快點過去了是吧,但這是不存在的,一般來說,如果我們的軟件要客戶登上一個小時什麼都不能動,客戶只會默默卸載而不是等待。所以,我們必須想一個辦法,既要讓用戶最終加載到這張圖,同時也不會說只能等待而不能執行其他的操作。怎麼辦呢?沒錯,這就要用到我們的多線程技術了。所謂多線程技術,你可以理解爲同時進程開掛,創造出了好幾個工人(線程),讓這些工人各自負責一些指定的內容,而不是隻有一個工人來幹完一件又一件。也就是說,所謂多線程,就是指可以同時運行多個程序塊,使得程序運行的速率得更高。

實現多線程

要實現多線程,從整體上而言,主要分爲兩種方式,分別是繼承Thread類或者實現Runnable接口,下面讓我們一起看看詳細的介紹

繼承Thread類

Thread類存放在java.lang包中,當一個子類繼承了Thread類時,必須實現其自帶的run()方法,在其中編寫需要實現的功能代碼,然後創建出該對象,調用start方法執行。格式如下:

class ClassName extends Thread{
    public void run(){
        //code
    }
}

ClassName test = new ClassName();
test.start();

我們來看一下具體實例:

package testabstractclass;

public class Test1 {
    public static void main(String[] args){
        ThreadTest1 test1 = new ThreadTest1();
        ThreadTest2 test2 = new ThreadTest2();
        test1.start();
        test2.start();
        }
}
/**
 *測試線程1
 */
class ThreadTest1 extends Thread{
    int i=0;
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println("測試線程1-->" + i);
        }

    }

}
/**
 *測試線程2
 */
class ThreadTest2 extends Thread{
    int i=0;
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println("測試線程2-->" + i);
        }
    }

}

運行,結果如圖:
這裏寫圖片描述
在這裏,你會發現,儘管test1和test2打印的代碼的順訊並不是連續的,但是也還是一個一個地打印出來,按道理來說,那也還是順序執行是吧?是的,在我們的多線程中,程序的執行也是有順序的,那麼什麼纔是決定順序的標誌呢?就是CPU資源,我們的CPU在程序運行的時候會產生空閒的CPU資源,一旦哪個線程搶先獲得了這個資源,就可以執行它的代碼。所以,在多線程中,搶資源是非常關鍵的,這也說明了爲什麼運行結果會有線程1和線程2交互執行。當然,如果我們再次執行的時候,就會發現,其實他的結果也是會變得。因爲每一次執行的時候,我們都不知道哪個線程會先取得CPU資源,所以每一次的運行結果都是未知的。但不可否認的是,這樣的話,真的實現了我們一邊加載圖片,一邊執行其他操作的願景。當然,問題也會產生,聰明的我們即將一步一步探究怎麼解決這些問題。慢慢來,不要急。
注意:一個線程對象只能調用一次的start方法,如果重複調用會出現IllegalThreadStateException異常,切記切記

實現Runnable接口

如果我們查看過Thread類的源碼(沒有查看的現在可以去查),你會發現,其實Thread是直接繼承自一個Runnable的接口來實現多線程的。也就是可以理解爲:Thread是在Runnable接口的基礎上進行封裝的一個類。我們可以使用Thread來實現多線程,自然也可以使用Runnale來實現多線程。怎麼實現呢?我們看一下基本格式:

class className implements Runnable{

    public void run(){
        //執行多線程的代碼
    }
}
具體的實例如下:
class ThreadTest1 implements Runnable{

    @Override
    public void run() {
        for (int i = 0;i<10;i++) {
            System.out.println("thread1->"+i);
        }
    }
}

看到這裏,大家可能就覺得和Thread沒什麼區別了,但是該怎麼啓動這個多線程呢?我們上面是用了Thread.start()方法來調用的是吧。那麼我們的Runnable又是怎麼調用的呢?熟悉我的套路的朋友們可能就想到說:我們來看一下源碼,是吧。但實際上,在這裏,是沒有源碼可以給你參考的,爲什麼呢?我們來看一下源碼(哈哈哈,繞回來了),但是如果你們真的去看了Runnable的源碼,你會發現,它就只有一個run方法,就這麼簡單粗暴了。連個啓動的方法都沒有,所以,我們要怎麼才能啓動Runnable呢?先看一段示例:
這裏寫圖片描述
眼尖的朋友們可能發現了其中的關鍵所在,這裏有兩句代碼:

new Thread(new ThreadTest1()).start();
new Thread(new ThreadTest2()).start();

是不是看起來特別無離頭?如果是的話,說明匿內部類還沒學到家,需要回去補補知識哦,在這裏,我們是通過匿名內部類來實現了一個Thread對象,並且調用了這個對象的start方法。但是這個對象呢,和我們在上面看到的Thread又有點區別,就是他不需要重寫run方法,而把這個run方法的實現交給了runnable。就好比Thread是一把槍,runnable是炮彈。即便我們是在Thread內部重寫的run方法,本質上也還是runnable,因爲Thread實現了Runnable接口。所以,姑且可以理解爲:Runnable是基礎,Thread是拓展。而由此便引發了這兩者的一個區別所在:
如果一個類繼承自Thread,則不適合多個線程共享資源,而如果一個類實現了Runnable接口,則可以在多個線程中去使用,從而實現了共享資源
就好比你有一支可以自動產生炮彈的槍,你可以隨時打出這一發子彈,但是你不能做到把這發子彈放在其他的槍上使用。而我用工廠製作出來的標準子彈,因爲沒有限制在你的槍上,所以可以隨便給其他的槍使用。所以,實現Runnable接口的好處就在於:

1.適合多個執行相同代碼的線程去處理統一資源
2.避免由於java的單繼承特性帶來的侷限
3.代碼與數據獨立分開,增強程序健壯性

如果想要親自看一下效果的同學,可以嘗試自己寫一個售票程序。模擬3臺機器同時出售50張票。分別用Thread和Runnable的方式來實現一次,或許你對這兩種方式的區別就會有一個更爲深刻的理解了。但是具體的操作步驟,這裏便不去說,最後的學習辦法除了跟着敲,還要想着敲。自己思考一下執行步驟應該是怎樣的,再去寫自己的代碼,更容易幫助你理解和記憶。

拓展:解密多線程背後的啓動方式

我們知道,我們前面是用了Thread.start()方法來實現多線程,但細心的我們應該可以發現,我們調用了這個start方法之後,執行的卻是run方法,爲什麼呢?這是因爲我們雖然是調用了start來啓動了一個多線程,但這時的多線程是並沒有執行,而是出於一種就緒的狀態,在這個狀態,一旦系統獲得了cpu資源,便開始執行run方法。注意,這裏的run方法也有一個坑,他執行的並不是Thread裏面的run方法,而是Runnable裏面的run方法,什麼意思呢?我們看看源碼:

 /* What will be run. */
    private Runnable target;

/**
     * If this thread was constructed using a separate
     * <code>Runnable</code> run object, then that
     * <code>Runnable</code> object's <code>run</code> method is called;
     * otherwise, this method does nothing and returns.
     * <p>
     * Subclasses of <code>Thread</code> should override this method.
     *
     * @see     #start()
     * @see     #stop()
     * @see     #Thread(ThreadGroup, Runnable, String)
     */
    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }

我們從源碼發現,儘管我們最終調用了Thread的run方法,但實際上,調用的卻是target中的run方法。也就是說,我們看起來是調用了Thread的start方法,但實際上最終執行的卻是Runnable中的run方法。是不是很暈?但暈也得記住,這也是一道面試題來的。

線程的狀態

我們前面說了,當我們調用start方法後,線程會進入一個就緒的狀態,那麼由此便牽涉到了下面的內容,線程有什麼狀態呢?我們通過一張圖來看看:
這裏寫圖片描述
雖然醜了點,但大致長這樣,接下來我們針對這張圖做一個說明:

創建狀態:當我們構造出一個線程對象的時候,此時這個對象便處於創建狀態,擁有着相應的內存空間以及資源,但是卻無法運行

就緒狀態:如上所說,當我們進調用了start方法時,便進入這個狀態,但此時同樣不能運行,因爲它缺少了cpu時間片

運行狀態:當現場對象獲得了cpu時間片時,便開始進入執行狀態(執行run方法)。一旦執行完畢,便終止當前線程。一旦還沒有執行完畢,便失去了cpu時間片(其他阻塞時間發生時),會進入阻塞狀態,或者返回就緒狀態,等待下一次獲得cpu時間片時,繼續運行

阻塞狀態:一個正在執行的線程在某種特殊的情況下,比如人爲的掛起(調用sleep()、wait()、suspend()等方法)或者需要執行耗時的操作時,線程會讓出cpu並暫時停止執行,進入阻塞狀態。在這個狀態下,線程不能進入排隊隊列,只有當引起阻塞的原因消除後,纔會重新轉入就緒狀態

結束狀態:線程調用stop方法或者run方法全部執行完畢後,便處於結束狀態,這個狀態下的線程不具有重新運行的能力,等待被回收。

線程安全的問題

談到多線程,必不可少的一個問題就是線程安全的問題。在開發過程中,如果我們通過多線程操作統一組數組,那麼因爲多個線程是同時執行的,所以在這個過程中便極爲出現數據丟失或者數據不準確的問題。什麼意思呢?比如我們通過兩個線程分別操作放東西和拿東西,一個線程負責把貨物放到車上,另一個線程便負責把貨物拉走。並且他們是同時工作的。假設剛開始的時候,放東西的線程還跟得上拿東西的線程的速度,但後來,體力不支,跟不上拿東西的線程的速度時,便存在了這麼一個問題,我們的貨物還沒放到車上,拿東西的線程就已經把車給拉走了。如此便造成了數據丟失。再比如售票的問題,我們假設當票大於0的時候,就繼續把票賣出,而小於或者等於0的時候,就不賣了是吧。但是有可能存在這麼一種情況,我們的線程一剛監測到票額還有1張,準備買下把票數減1,代表賣到了這一張票的時候,突然失去了cpu時間片,這時候,線程二也監測到了這張票的信息,因爲線程一還沒有減1,所以此時票數仍然顯示是1,所以這個時候線程二覺得,恩,還有票,然後準備執行見一操作時,坑爹的cpu時間片又沒了,此時線程三一路殺入,看到還有一張票,二話不說,買下了,此時票數顯示爲0了是吧。這一切看起來無非就是線程三運氣足夠好是吧,但是如果對線程狀態還有印象的同學可能會醒悟過來,這裏面有坑啊!設想,當線程一在此獲得時間片的時候,它會執行什麼樣的步驟呢?再監測有沒有票?太天真了,它說,我之前已經監測過了,肯定還有票的,不怕,於是刷刷刷地把票額減一,就走了,走了。。。看都不看票數還剩多少。再然後,線程二又醒了,同樣說我之前監測到了還有1張票,不怕,於是再把票數減一,又走了。但是坑爹的是,最後一張票明明已經被線程三拿走了啊!所以這裏線程一和線程二拿到的又是什麼鬼呢?所以這裏就是數據不準確的情況了。那麼,就買票而言,如果12306沒有處理好這種線程不安全的問題,一個春節課後,可能就要被買到票又做不了車的人給砸了吧,畢竟車位真的有限呀,那麼沒怎麼處理這個問題呢?這就又涉及到接下來要介紹的內容了。那就是——

同步與死鎖

先說同步,同步是一種用來處理線程不安全的技術,是指多個操作在同一個時間段內只能有一個線程進行,其他線程要等待這個線程結束後纔可以繼續執行。什麼意思呢?我們把數據空間當做一間房子,當線程一走進這個房子裏開始操作數據後,便把門給鎖住,不讓其他的線程進來。不管線程一在裏面睡了多久纔拿到cpu時間片,只要它不全部執行完畢,離開這個房子,其他的線程就不能進來。當然,也有一種情況是,線程一處理完這個數據後,第二次進來的也還是它,因爲它可以再次獲得cpu時間片,進而在此進行數據操作。但不管怎麼樣都好,我們都避免了上面說的被線程三捷足先登的問題了。因此也就保證了線程的安全。那麼,怎麼實現同步呢?有兩種方式:

使用同步代碼塊

使用同步代碼塊的時候需要了解一個關鍵字:synchronize,如果我們在常見的代碼塊上加上這個關鍵字,就表明它是一個同步代碼塊,格式如下:

synchronize (同步對象){
    需要同步的代碼
}

就以買票爲例,請看:

public class ThreadDemo1 {
    public static void main(String[] args) {
        new Thread(new ThreadTest1("線程一")).start();
        new Thread(new ThreadTest1("線程二")).start();
        new Thread(new ThreadTest1("線程三")).start();
    }
}

    class ThreadTest1 implements Runnable{
        private String name;
        private static int ticket = 5;
        public ThreadTest1(String name) {
            this.name = name;
        }


        @Override
        public void run() {
            for (int i = 0;i<10;i++) {
                synchronized (ThreadDemo1.class) {
                    if (ticket >0) {
                        try {
                            Thread.sleep(300);
                        } catch (InterruptedException e) {
                            // TODO Auto-generated catch block
                            e.printStackTrace();
                        }
                        System.out.println(name+"賣出一張票,當前票額:"+ticket--);
                    }
                }
            }
        }
    }

運行這段代碼,結果如下:
這裏寫圖片描述
在這裏,如果你多運行幾遍,你就會發現,前面的線程或許會變,但最後的票數肯定就是按照這樣的順序來執行,這就是同步帶來的好處,避免了線程不安全的問題發生。
那麼,在這裏,或許也有人奇怪,說在這裏:

synchronize (同步對象){
    需要同步的代碼
}

這裏的同步對象應該是什麼?關於這個問題,一般而言,我們是把當前能夠獲得該對象中需要同步的數據的對象。什麼意思呢?就如上面示例而言,我們鎖住的對象是:

synchronized (ThreadDemo1.class) {
                if (ticket >0) {
                    try {
                        Thread.sleep(300);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(name+"賣出一張票,當前票額:"+ticket--);
                }
            }

可以看到,我們鎖住的是ThreadDemo1而不是實現了Runnable的ThreadTest1對象呢,這是因爲,我們在ThreadDemo創建出了三個不同的線程,每個線程都繼承了Runnable接口,但是每個線程對象的地址
是否就是一致的呢?不是的。他們是分別獨立的三個對象,所以當我們鎖住的是ThreadTest1對象時,那就代表我們最終只是分別鎖住了三個不同的對象,這樣的話我們不是在同一個對象裏面進行操作。就好比我們在賣票的地方開了三個窗口,但是隻有一個窗口是隻能同步的,那麼剩下的窗口自然就是誰有空就誰去執行操作,這樣的話還能實現同步嗎?不能,所以我們要把範圍擴大,把這個賣票的地方鎖起來,每次只允許一個線程進入,不管他選擇哪個窗口都沒關係,只要保證是每次一個線程在操作就好。

同步方法

同步方法的使用比同步塊看起來簡單,它只需要在方法中添加synchronized聲明即可。這裏不多做介紹,直接上示例代碼:

public class ThreadDemo1 {
    public static void main(String[] args) {
        ThreadTest1 threadTest1 = new ThreadTest1("售票機");
        new Thread(threadTest1).start();
        new Thread(threadTest1).start();
        new Thread(threadTest1).start();
    }
}

class ThreadTest1 implements Runnable{
    private String name;
    private int ticket = 5;
    public ThreadTest1(String name) {
        this.name = name;
    }
    @Override
    public void run() {
        for (int i = 0;i<10;i++) {
            this.sale();
        }
    }


    private synchronized void sale() {
        if (ticket > 0) {
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name+"賣出一張票,當前票額:"+ticket--);

        }
    }
}

這個示例代碼看起來和上面那一個差不多,實際上卻存在着極大的差別。不僅體現在使用方法上,還體現在使用方式,數據處理,對象選擇等方面都有差別。因此希望朋友們多研究一下這兩個代碼有何不同之處,當你能找出這兩段代碼之間的差別時,就表明你對同步有一個比較深入的理解了。但是在這裏,並不會對此進行講解嗎,而希望是你們自己去領悟,如此才能提升你們的思考。當然,如果你思考出來的話,也可以在下方評論貼出,讓大傢伙參考參考,看看你的理解有沒有跑偏了,畢竟及時的學習反饋纔是最重要的嘛。
你們以爲學習就要結束了嗎?非也。我們只講了同步,還沒開始講死鎖呢,怎麼可以結束了呢?接下來再看我們的另一個知識點——“死鎖
什麼叫死鎖呢?所謂的死鎖就是指兩個線程都在等待對方先完成,造成了程序之間的停滯狀態,這是由於同步過多引起的。什麼意思呢?假設我們目前有兩個數據需要同步,線程一的名字叫張三,它需要接收線程二(李四)手中的畫之後,纔可以把自己的書傳送給李四。這看起來沒什麼問題,當李四把畫給了張三之後,張三就把書交給李四,很符合邏輯對不對?那我們接下來再加個條件,李四說:張三要先把書給李四,李四纔可以把畫交給張三。那麼,問題就發生了。雙方都需要對方的東西,雙方有需要對象先提交同樣的東西纔可以做出響應。而且張三和李四知識程序,他不會自主協調說,我先給你一半,你也先給我一半吧,所以問題就發生了,張三不斷請求李四給他畫,李四不斷請求張三給他書。就好像先有雞後有蛋,還有先有蛋,後有雞的問題一樣,不斷重複,因爲便造成了死鎖。那麼要怎麼解決這個問題呢?其一,就是要編碼前確定好邏輯順序,先給誰,再給誰。其二,就是儘量減少同步了。

多線程綜合案例:生產者與消費者的瓜葛

關於多線程,存在的憂慮無非就是數據丟失或者數據精度缺失的問題(至少我目前遇到的是這些,如果還有其他,也歡迎補充,不要讓我做井底之蛙呀,拜謝~),我們在前面就說了當多個線程操作同一數據時,線程安全問題就必須解決。就好比生產者與消費者的關係,生產者生產產品,消費者消費產品,兩者似乎沒什麼瓜葛是吧,但是需要注意的是,如果生產者沒有生產產品,消費者又何來的消費呢?因此,這裏便產生了一個模式“生產者-消費者模式”。它的思路大致如下:當生產者生產出產品後,通知消費者去拿。當生產者在生產產品時,消費者進入等待狀態,知道生產者叫它之後,再去消費產品。實例代碼如下:

public class ThreadDemo1 {
    private List<Integer> number = new ArrayList<Integer>(10);
    public static void main(String[] args) {
        ThreadDemo1 marKet = new ThreadDemo1();
        Consumer consumer = marKet.new Consumer();
        Producer producer = marKet.new Producer();
        new Thread(producer).start();
        new Thread(consumer).start();

    }
    /**
     * 生產者
     * @author dml
     *
     */
    class Producer implements Runnable{

        @Override
        public void run() {
            while(true){
                synchronized(number){
                    while(number.size() == 10){
                        try {
                            System.out.println("生產空間已滿,通知消費");
                            number.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            number.notify();
                        }
                    }
                    number.add(1);
                    System.out.println("生產一個商品,當前還能生產"+(10-number.size())+"個商品");
                }
            }
        }
    }

    /**
     * 消費者
     * @author dml
     *
     */
    class Consumer implements Runnable{

        @Override
        public void run() {
            while(true){
                synchronized (number) {
                    while(number.size() ==0){
                        try {
                            System.out.println("沒有商品可消費,等待中");
                            number.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            number.notify();
                        }
                    }
                    number.remove(0);
                    number.notify();
                    System.out.println("消費一個,剩餘"+number.size()+"個商品");
                }
            }


        }

    }
}

運行結果如下:
這裏寫圖片描述
好了,關於生產者,消費者的實現,不做講解,讓大家去思考。但是接下來要普及的幾個東西,是要記住(可能會被面試),也是輔助你理解的,不要錯過了哦

關於等待與喚醒那些事

在線程中使線程進入等待(阻塞狀態),有幾個方法,其中常用的是sleep()和wait();其中sleep方法是線程類的方法,作用是暫停該線程的執行時間,把執行機會讓給其他線程,到制定時間後便會自動回覆。調用sleep方法不會釋放對象鎖。因此不能用於同步
wait方法是Object類的方法,即所有對象原則上都能調用這個方法。當該對象調用wait方法時,會導致本線程放棄對象鎖,而進入等待對象池中,只有針對這個對象發出的notify或者notifyall方法後,本線程才進入對象鎖定池準備獲得對象鎖,進而進入運行狀態。

線程池那些事

關於線程,還有一個重要的概念,叫做線程池。爲什麼會有這個東西呢?我們來分析一下,當我們創建出一個線程對象時,是不是就已經爲其分配好了一定的內存空間,當線程執行結束後,便進入結束狀態呢?這點毋庸置疑,我們在前面已經說到,那麼,如果我們要創建多個線程,是不是要給每個線程都分配一定的內存空間呢?是的,這就有可能導致這樣一個問題,我們的內存空間不斷分配,而失去作用的線程對象又還沒被及時回收,如此便容易造成內存溢出(OOM)而導致程序崩潰。因此,我們再使用線程的時候,一定不能多開線程,要限制他的數量。可是我又可要這麼多線程,怎麼辦呢?線程池就出現了,它的作用是封裝幾個線程在裏面,當我們調用一個線程池時,會把裏面的線程取出,執行線程任務,執行完畢後,回到線程池中休眠,直到下一次的線程任務調用。如此,便解決了需要創建大量的線程對象的問題,用於多線程下載中是非常試用的。那麼,如何創建線程池呢?這個不用你擔心,一般而言,很多框架都會爲我們內置好線程池,我們不用手動去創建、但有時候遇到面試的時候,有的面試官會問你這個問題,如果你能寫得出的話,無疑又是一項加分項,關於這點,因爲篇幅有限(眼睛受不住了~~),所以這裏不作示例,貼出幾個乾貨連接,希望可以幫助到您~
傳送門出現!
深入理解java之線程池
海子的這篇文章,真的是非常詳細地介紹了關於線程池的知識。如果能看一遍下來,或許你就大概曉得怎麼實現線程池了。
最後,關於多線程的一些知識點就要結束了。距離我們正式進入Android方面的介紹也不遠了。但同樣的問題在於,本人的期末考試準備周也到來了。因此,總結起來就是6月上旬忙着“挑戰杯”,下旬忙着複習考試。所以博客這裏又要冷落一段時間了,估計下旬還會發一篇關於單例模式的面試知識點,其他的內容可就要等七月份啦。預估在七月份,我們就可以正式踏入Android開發的過程啦,加油吧,騷年們。
官方聲明:
如果對文章有表示疑問之處,請在下方評論指出,共同進步,謝謝~

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