併發程式設計之:執行緒

黑子的學習筆記發表於2021-08-27

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

好了,接下來進入正題,先來看看什麼是程式和執行緒。

程式VS執行緒

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

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

image

瞭解完程式和執行緒的區別,我們再來看一下併發和並行的概念。

併發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,我們通過下面的圖來展示執行緒見狀態發生變化的情況。

image

執行緒狀態詳細說明

初始化狀態(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()方法的呼叫必須是在同步程式碼中,並且會釋放鎖。


好了,今天的內容就到這裡。我是小黑,我們下期見。
如果喜歡小黑也可以關注我的微信公眾號黑子的學習筆記,全網同名。
image

相關文章