天天看點

并發程式設計之:線程

大家好,我是小黑,一個在網際網路苟且偷生的農民工。前段時間公司面試招人,發現好多小夥伴雖然已經有兩三年的工作經驗,但是對于一些Java基礎的知識掌握的都不是很紮實,是以小黑決定開始跟大家分享一些Java基礎相關的内容。首先這一期我們從Java的多線程開始。

好了,接下來進入正題,先來看看什麼是程序和線程。

程序VS線程

程序是計算機作業系統中的一個線程集合,是系統資源排程的基本機關,正在運作的一個程式,比如QQ,微信,音樂播放器等,在一個程序中至少包含一個線程。

線程是計算機作業系統中能夠進行運算排程的最小機關。一條線程實際上就是一段單一順序運作的代碼。比如我們音樂播放器中的字幕展示,和聲音的播放,就是兩個獨立運作的線程。

并發程式設計之:線程

了解完程序和線程的差別,我們再來看一下并發和并行的概念。

并發VS并行

當有多個線程在操作時,如果系統隻有一個CPU,假設這個CPU隻有一個核心,則它根本不可能真正同時進行一個以上的線程,它隻能把CPU運作時間劃分成若幹個時間段,再将時間段配置設定給各個線程執行,在一個時間段的線程代碼運作時,其它線程處于挂起狀。這種方式我們稱之為并發(Concurrent)。
當系統有一個以上CPU或者一個CPU有多個核心時,則線程的操作有可能非并發。當一個CPU執行一個線程時,另一個CPU可以執行另一個線程,兩個線程互不搶占CPU資源,可以同時進行,這種方式我們稱之為并行(Parallel)。

讀完上面這段話,是不是感覺好像懂了,又好像沒懂?啥并發?啥并行?馬什麼梅?什麼冬梅?

别着急,小黑先給大家用個通俗的例子解釋一下并發和并行的差別,然後再看上面這段話,相信大家就都能夠了解了。

你吃飯吃到一半,電話來了,你一直把飯吃完之後再去接電話,這就說明你不支援并發也不支援并行;

你吃飯吃到一半,電話來了,你去電話,然後吃一口飯,接一句電話,吃一口飯,接一句電話,這就說明你支援并發;

你吃飯吃到一半,電話來了,你妹接電話,你在一直吃飯,你妹在接電話,這就叫并行。

總結一下,并發的關鍵,是看你有沒有處理多個任務的能力,不是同時處理;

并行的關鍵是看能不能同時處理多個任務,那要想處理多個任務,就要有“你妹”(另一個CPU或者核心)的存在(怎麼感覺好像在罵人)。

Java中的線程

在Java作為一門進階計算機語言,同樣也有程序和線程的概念。

我們用Main方法啟動一個Java程式,其實就是啟動了一個Java程序,在這個程序中至少包含2個線程,另一個是用來做垃圾回收的GC線程。

Java中通常通過Thread類來建立線程,接下來我們看看具體是如何來做的。

線程的建立方式

要想在Java代碼中要想自定義一個線程,可以通過繼承Thread類,然後建立自定義個類的對象,調用該對象的start()方法來啟動。

public class ThreadDemo {
    public static void main(String[] args) {
        new MyThread().start();
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("這是我自定義的線程");
    }
}
           

或者實作java.lang.Runnable接口,在建立Thread類的對象時,将自定義java.lang.Runnable接口的執行個體對象作為參數傳給Thread,然後調用start()方法啟動。

public class ThreadDemo {
    public static void main(String[] args) {
        new Thread(new MyRunnable()).s
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("這是我自定義的線程");
    }
}
           

那在實際開發過程中,是建立Thread的子類,還是實作Runnable接口呢?其實并沒有一個确定的答案,我個人更喜歡實作Runnable接口這種用法。在以後要學的線程池中也是對于Runnable接口的執行個體進行管理。當然我們也要根據實際場景靈活變通。

線程的啟動和停止

從上面的代碼中我們其實已經看到,建立線程之後通過調用start()方法就可以實作線程的啟動。

new MyThread().start();
           

注意,我們看到從上一節的代碼中看到我們自定義的Thread類是重寫了父類的run()方法,那我們直接調用run()方法可不可以啟動一個線程呢?答案是不可以。直接調用run()方法和普通的方法調用沒有差別,不會開啟一個新線程執行,這裡一定要注意。

那要怎麼來停止一個線程呢?我們看Thread類的方法,是有一個stop()方法的。

@Deprecated // 已經棄用了。
public final void stop() {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        checkAccess();
        if (this != Thread.currentThread()) {
            security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
        }
    }
    if (threadStatus != 0) {
        resume(); 
    }
    stop0(new ThreadDeath());
}
           

但是我們從這個方法上可以看到是加了@Deprecated注解的,也就是這個方法被JDK棄用了。被棄用的原因是因為通過stop()方法會強制讓這個線程停止,這對于線程中正在運作的程式是不安全的,就好比你正在拉屎,别人強制不讓你拉了,這個時候你是夾斷還是不夾斷(這個例子有點惡心,但是很形象哈哈)。是以在需要停止形成的是不不能使用stop方法。

那我們應該怎樣合理地讓一個線程停止呢,主要有以下2種方法:

第一種:使用标志位終止線程

class MyRunnable implements Runnable {
    private volatile boolean exit = false; // volatile關鍵字,保證主線程修改後目前線程能夠看到被改後的值(可見性)
    @Override
    public void run() {
        while (!exit) { // 循環判斷辨別位,是否需要退出
            System.out.println("這是我自定義的線程");
        }
    }
    public void setExit(boolean exit) {
        this.exit = exit;
    }
}
public class ThreadDemo {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        new Thread(runnable).start();
        runnable.setExit(true); //修改标志位,退出線程
    }
}
           

線上程中定義一個标志位,通過判斷标志位的值決定是否繼續執行,在主線程中通過修改标志位的值達到讓線程停止的目的。

第二種:使用interrupt()中斷線程

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable runnable = new MyRunnable();
        Thread t = new Thread(runnable);
        t.start();
        Thread.sleep(10);
        t.interrupt(); // 企圖讓線程中斷
    }
}
class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            System.out.println("線程正在執行~" + i);
        }
    }
}
           

這裡需要注意的點,就是interrupt()方法并不會像使用标志位或者stop()方法一樣,讓線程馬上停止,如果你運作上面這段代碼會發現,線程t并不會被中斷。那麼如何才能讓線程t停止呢?這個時候就要關注Thread類的另外兩個方法。

public static boolean interrupted(); // 判斷是否被中斷,并清除目前中斷狀态
private native boolean isInterrupted(boolean ClearInterrupted); // 判斷是否被中斷,通過ClearInterrupted決定是否清楚中斷狀态
           

那麼我們再來修改一下上面的代碼。

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable runnable = new MyRunnable();
        Thread t = new Thread(runnable);
        t.start();
        Thread.sleep(10);
        t.interrupt();
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            //if (Thread.currentThread().isInterrupted()) {
            if (Thread.interrupted()) {
                break;
            }
            System.out.println("線程正在執行~" + i);
        }
    }
}
           

這個時候線程t就會被中斷執行。

到這裡大家其實會有個疑惑,這種方式和上面的通過标志位的方式好像沒有什麼差別呀,都是判斷一個狀态,然後決定要不要結束執行,它們倆到底有啥差別呢?這裡其實就涉及到另一個東西叫做線程狀态,如果當線程t在sleep()或者wait()的時候,如果用辨別位的方式,其實并不能立馬讓線程中斷,隻能等sleep()結束或者wait()被喚醒之後才能中斷。但是用第二種方式,線上程休眠時,如果調用interrupt()方法,那麼就會抛出一個異常InterruptedException,然後線程繼續執行。

線程的狀态

通過上面對于線程停止方法的對比,我們了解到線程除了運作和停止這兩種狀态意外,還有wait(),sleep()這樣的方法,可以讓線程進入到等待或者休眠的狀态,那麼線程具體都哪些狀态呢?其實通過代碼我們能夠找到一些答案。在Thread類中有一個叫State的枚舉類,這個枚舉類中定義了線程的6中狀态。

public enum State {
    /**
     * 尚未啟動的線程的線程狀态
     */
    NEW,
    /**
     * 可運作狀态
     */
    RUNNABLE,
    /**
     * 阻塞狀态
     */
    BLOCKED,
    /**
     * 等待狀态
     */
    WAITING,
    /**
     * 逾時等待狀态
     */
    TIMED_WAITING,
    /**
     * 終止狀态
     */
    TERMINATED;
}
           

那麼線程中的這六種狀态到底是怎麼變化的呢?什麼時候時RUNNABLE,什麼時候BLOCKED,我們通過下面的圖來展示線程見狀态發生變化的情況。

并發程式設計之:線程

線程狀态詳細說明

初始化狀态(NEW)

在一個Thread執行個體被new出來時,這個線程對象的狀态就是初始化(NEW)狀态。

可運作狀态(RUNNABLE)

  1. 在調用start()方法後,這個線程就到達可運作狀态,注意,可運作狀态并不代表一定在運作,因為作業系統的CPU資源要輪換執行(也就是最開始說的并發),要等作業系統排程,隻有被排程到才會開始執行,是以這裡隻是到達就緒(READY)狀态,說明有資格被系統排程;
  2. 當系統排程本線程之後,本線程會到達運作中(RUNNING)狀态,在這個狀态如果本線程擷取到的CPU時間片用完以後,或者調用yield()方法,會重新進入到就緒狀态,等待下一次被排程;
  3. 當某個休眠線程被notify(),會進入到就緒狀态;
  4. 被park(Thread)的線程又被unpark(Thread),會進入到就緒狀态;
  5. 逾時等待的線程時間到時,會進入到就緒狀态;
  6. 同步代碼塊或同步方法擷取到鎖資源時,會進入到就緒狀态;

逾時等待(TIMED_WAITING)

當線程調用sleep(long),join(long)等方法,或者同步代碼中鎖對象調用wait(long),以及LockSupport.arkNanos(long),LockSupport.parkUntil(long)這些方法都會讓線程進入逾時等待狀态。

等待(WAITING)

等待狀态和逾時等待狀态的差別主要是沒有指定等待多長的時間,像Thread.join(),鎖對象調用wait(),LockSupport.park()等這些方法會讓線程進入等待狀态。

阻塞(BLOCKED)

阻塞狀态主要發生在擷取某些資源時,在擷取成功之前,會進入阻塞狀态,知道擷取成功以後,才會進入可運作狀态中的就緒狀态。

終止(TERMINATED)

終止狀态很好了解,就是目前線程執行結束,這個時候就進入終止狀态。這個時候這個線程對象也許是存活的,但是沒有辦法讓它再去執行。所謂“線程”死不能複生。

線程重要的方法

從上一節我們看到線程狀态之間變化會有很多方法的調用,像Join(),yield(),wait(),notify(),notifyAll(),這麼多方法,具體都是什麼作用,我們來看一下。

上面我們講到過的start()、run()、interrupt()、isInterrupted()、interrupted()這些方法想必都已經了解了,這裡不做過多的贅述。

/**
 * sleep()方法是讓目前線程休眠若幹時間,它會抛出一個InterruptedException中斷異常。
 * 這個異常不是運作時異常,必須捕獲且處理,當線程在sleep()休眠時,如果被中斷,這個異常就會産生。
 * 一旦被中斷後,抛出異常,會清除标記位,如果不加處理,下一次循環開始時,就無法捕獲這個中斷,故一般在異常處理時再設定标記位。
 * sleep()方法不會釋放任何對象的鎖資源。
 */
public static native void sleep(long millis) throws InterruptedException;

/**
 * yield()方法是個靜态方法,一旦執行,他會使目前線程讓出CPU。讓出CPU不代表目前線程不執行了,還會進行CPU資源的争奪。
 * 如果一個線程不重要或優先級比較低,可以用這個方法,把資源給重要的線程去做。
 */
public static native void yield();
/**
 * join()方法表示無限的等待,他會一直阻塞目前線程,隻到目标線程執行完畢。
 */
public final void join() throws InterruptedException ;
/**
 * join(long millis) 給出了一個最大等待時間,如果超過給定的時間目标線程還在執行,目前線程就不等了,繼續往下執行。
 */
public final synchronized void join(long millis) throws InterruptedException ;

           

以上這些方法是Thread類中的方法,從方法簽名可以看出,sleep()和yield()方法是靜态方法,而join()方法是成員方法。

而wait(),notify(),notifyAll()這三個方式是Object類中的方法,這三個方法主要用于在同步方法或同步代碼塊中,用于對共享資源有競争的線程之間的通信。

/**
 * 使目前線程等待,直到另一個線程調用該對象的 notify()方法或 notifyAll()方法。
 */
public final void wait() throws InterruptedException
/**
 * 喚醒正在等待對象螢幕的單個線程。
 */
public final native void notify();
/**
 * 喚醒正在等待對象螢幕的所有線程。
 */
public final native void notifyAll();
           

針對wait(),notify/notifyAll() 有一個典型的案例:生産者消費者,通過這個案例能加深大家對于這三個方法的印象。

場景如下:

假設現在有一個KFC(KFC給你多少錢,我金拱門出雙倍),裡面有漢堡在銷售,為了漢堡的新鮮呢,店員在制作時最多不會制作超過10個,然後會有顧客來購買漢堡。當漢堡數量到10個時,店員要停止制作,而當數量等于0也就是賣完了的時候,顧客得等新漢堡制作處理。

我們現在通過兩個線程一個來制作,一個來購買,來模拟這個場景。代碼如下:

class KFC {
    // 漢堡數量
    int hamburgerNum = 0; 
    
    public void product() {
        synchronized (this) {
            while (hamburgerNum == 10) {
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("生産一個漢堡" + (++hamburgerNum));
            this.notifyAll();
        }
    }
    
    public void consumer() {
        synchronized (this) {
            while (hamburgerNum == 0) {
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("賣出一個漢堡" + (hamburgerNum--));
            this.notifyAll();
        }
    }
}
public class ProdConsDemo {
    public static void main(String[] args) {
        KFC kfc = new KFC();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.product();
            }
        }, "店員").start();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.consumer();
            }
        }, "顧客").start();

    }
}
           

從上面的代碼可以看出,這三個方法是要配合使用的。

wait()、notify/notifyAll() 方法是Object的本地final方法,無法被重寫。

wait()使目前線程阻塞,前提是必須先獲得鎖,一般配合synchronized關鍵字使用。

當線程執行wait()方法時,會釋放目前的鎖,然後讓出CPU,進入等待狀态。

由于 wait()、notify/notifyAll() 在synchronized 代碼塊執行,說明目前線程一定是擷取了鎖的。隻有當notify/notifyAll()被執行時,才會喚醒一個或多個正處于等待狀态的線程,然後繼續往下執行,直到執行完synchronized代碼塊的代碼或是中途遇到wait() ,再次釋放鎖。

要注意,notify/notifyAll()喚醒沉睡的線程後,線程會接着上次的執行繼續往下執行。是以在進行條件判斷時候,不能使用if來判斷,假設存在多個顧客來購買,當被喚醒之後如果不做判斷直接去買,有可能已經被另一個顧客買完了,是以一定要用while判斷,在被喚醒之後重新進行一次判斷。

最後再強調一下wait()和我們上面講到的sleep()的差別,sleep()可以随時随地執行,不一定在同步代碼塊中,是以在同步代碼塊中調用也不會釋放鎖,而wait()方法的調用必須是在同步代碼中,并且會釋放鎖。

好了,今天的内容就到這裡。我是小黑,我們下期見。

如果喜歡小黑也可以關注我的微信公衆号【小黑說Java】。

并發程式設計之:線程

繼續閱讀