1. 程式人生 > 實用技巧 >Java 併發基礎知識

Java 併發基礎知識

一、什麼是執行緒和程序?

程序:

是程式的一次執行過程,是系統執行程式的基本單元(就比如開啟某個應用,就是開啟了一個程序),因此程序是動態的。系統執行一個程式即是一個程式從建立、執行到消亡的過程。

在 Java 中,當我們啟動 main 函式時其實就是啟動了 JVM 程序,而 main 函式所在的執行緒就是這個程序中的一個執行緒,也稱主執行緒。

執行緒:

執行緒與就程序相似,但執行緒是一個比程序更小的執行單位。一個程序在執行過程中可以產生多個執行緒。與程序不同的是同類的多個執行緒共享程序的堆和方法區資源,但每個執行緒有自己的程式計數器、虛擬機器棧和本地方法棧,所以系統在產生一個程序,或是在各個程序之間做切換工作時,負擔要比程序小得多,也正因為如此,執行緒也被稱為輕量級程序。

二、執行緒與程序的關係,區別及優缺點?

從 JVM 角度說程序和執行緒之間的關係

下圖是 Java 記憶體區域,通過下圖我們從 JVM 的角度說明執行緒與程序之間的關係。

可以看出,一個程序可以有多個執行緒,多個執行緒共享程序的堆和方法區(JDK 1.8 之後的元空間)資源。但是每個執行緒有自己的程式計數器、虛擬機器棧和本地方法棧。

綜上:執行緒是程序劃分成的更小的執行單位。執行緒與程序最大的不同在於基本上各程序是獨立的,而各執行緒則不一定,因為同一程序中的執行緒極有可能會相互影響。執行緒執行開銷小,但不利於資源的管理和保護;而程序則相反。

為什麼程式計數器、虛擬機器棧和本地方法棧是執行緒私有的呢?為什麼堆和方法區是執行緒共享的呢?

(1) 程式計數器為什麼是私有的?

首先明確程式計數器的作用:

  • 位元組碼直譯器通過改變程式計數器來一次讀取指令,從而實現程式碼的流程控制。如:順序執行、選擇、迴圈、異常處理。
  • 在多執行緒的情況下,程式計數器用於記錄當前執行緒執行的位置,從而當執行緒被切換回來的時候能夠知道該執行緒執行到哪了。

需要注意的是:如果執行的是 native 方法,那麼程式計數器記錄的是 undefined 地址,只有執行的是 Java 程式碼時程式計數器記錄的才是下一條指令的地址。

所以,程式計數器私有主要是為了執行緒切換後能夠恢復到正確的執行位置。

(2) 虛擬機器棧和本地方法棧為什麼是私有的?

  • 虛擬機器棧:
    每個Java 方法在執行的同時會建立一個幀棧用於儲存區域性變量表、運算元棧、常量池引用等資訊。從方法呼叫直至完成的過程,就對應一個幀棧在 Java 虛擬機器中入棧和出棧的過程。
  • 本地方法棧:和虛擬機器的作用非常相似。區別是:虛擬機器為虛擬機器執行 Java 方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 native 方法服務。在 HotSpot 虛擬機器中和 Java 虛擬機器棧合二為一。

所以,為了保證執行緒中的區域性變數不被別的執行緒訪問到,虛擬機器棧和本地方法棧是執行緒私有的。

(3) 堆和方法區

堆和方法區是所有執行緒共享的資源,其中堆是程序中最大的一塊記憶體,主要用來存放新建立的物件(所有的物件都在這裡分配記憶體);方法區主要用於存放已被載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼資料等。

參考:JavaGuide 公眾號及其相應的Github

三、併發和並行有什麼區別?

  • 併發:同一時間段,多個任務都在執行(單位時間內不一定同時執行);
  • 並行:單位時間內,多個任務同時執行。

併發的關鍵是你有處理多個任務的能力,不一定要同時。 而並行的關鍵是你有同時處理多個任務的能力。

四、為什麼要使用多執行緒?

先總體上:

  • 從計算機底層來說:執行緒可以比作是輕量級的程序,是程式執行的最小單元,執行緒間的切換和排程的成本遠遠小於程序。另外,多核 CPU 時代意味著多個執行緒可以同時執行,這減少了執行緒上下文切換的開銷。
  • 從當代網際網路發展趨勢來說:現在的系統動不動就要求百萬級甚至千萬級的併發量,而多執行緒併發程式設計正式開發高併發系統的基礎,利用好多執行緒機制可以大大提高系統的併發能力以及效能。

再深入到計算機底層:

  • 單核時代:在單核時代多執行緒主要是為了提高 CPU 和 IO 裝置的綜合利用率。
  • 多核時代:多核時代主要是為了提高 CPU 的利用率。

五、使用多執行緒可能會帶來什麼問題?

併發程式設計的目的就是為了能提高程式的執行效率提高程式執行速度,但是併發程式設計並不總是能提高程式執行速度的,而併發程式設計可能會遇到很多問題,比如:記憶體洩漏、上下文切換、死鎖等,還有受限於硬體和軟體和資源閒置問題。

六、說說執行緒的生命週期和狀態。

Java 執行緒在執行的生命週期中的指定時刻只可能指定處於下面幾種不同狀態的其中一個狀態:

  1. 新建狀態(NEW):新建立了一個執行緒物件;
  2. 就緒狀態(RUNNABLE):執行緒建立後,其他執行緒呼叫了該物件的 start() 方法。該方法狀態的執行緒位於可執行執行緒池中,變得可執行,等待獲取 CPU 的使用權;
  3. 執行狀態(RUNNING):就緒狀態的執行緒獲取了 CPU,執行程式程式碼;
  4. 阻塞狀態(BLOCKED):阻塞狀態是執行緒因為某種原因放棄 CPU 使用權,暫時停止執行。知道執行緒進入就緒狀態,才有機會轉到執行狀態。阻塞的情況分為三種:
    • 等待阻塞:執行的執行緒執行 wait() 方法,JVM 會把該執行緒放入執行緒池中。
    • 同步阻塞:執行的執行緒在獲取物件的同步鎖時,若該同步鎖被別的執行緒佔用,則 JVM 會把該執行緒放入鎖池中。
    • 其他阻塞:執行的執行緒執行 sleep() 或 join() 方法,或者發出了 I/O 請求時,JVM 會把該執行緒設定為阻塞狀態。當 sleep() 超時、join() 等待執行緒終止或者超時、或者 I/O 處理完畢時,執行緒重新轉入就緒狀態。
  5. 死亡狀態(DEAD):執行緒執行完了或者因異常退出了 run() 方法,該執行緒結束生命週期。

執行緒在生命週期中並不是固定處於一個狀態,而是隨著程式碼的執行在不同狀態之間切換。Java 執行緒狀態變遷如下圖(圖為《Java 併發程式設計的藝術》)

可以看出:執行緒建立之初處於NEW(新建) 狀態。呼叫start()方法後開始執行,執行緒這時候處於READY(可執行) 狀態。可執行狀態的執行緒獲得了 CPU 時間片 (timeslice) 後就處於RUNNING(執行)狀態。執行緒執行了wait()方法後,執行緒進入WAITING(超時等待) 狀態相當於等待狀態的基礎上增加了超時限制,比如sleep(long millis)方法或waiting(long millis)方法可以將 Java 執行緒置於 TIME WAITING 狀態。當超時時間達到後 Java 執行緒將會返回到 RUNNABLE 狀態。當執行緒呼叫同步方法時,在沒有獲取到鎖的情況下,執行緒將會進入到BLOCKED(阻塞)狀態。執行緒在執行 Runnable 的run()方法之後將進入到TERMINATED(終止) 狀態。

七、java 中如何建立執行緒?

Java 中建立執行緒有四種方式:① 繼承 Thread;② 實現 Runnable 介面;③ 執行緒池;④ 實現 Callable 介面。

關於 Thread 或者 Runnable 介面,首先 Runnable 是介面,實現了改介面的類還可以繼承其他類,更靈活;其次,Runnable 任務可以在 Executors 中或者 ExecutorService 提交執行。

Future 和 Callable:Callable 與 Runnable 一樣都是代表抽象的計算任務,其中的 call 方法做用與 run 一樣,但是會返回一個值。Future 表示一個任務的生命週期,並提供了相應的方法來判斷是否已經完成或者取消,以及獲取任務的結果。ExecutorService 中所有的 submit 方法都會返回一個 future。

Callable 和 Runnable 的區別:

  • Callable 定義的方法是 call,而 Runnable 定義的方法是 run;
  • Callable 的 call 方法可以有返回值,而 Runnable 的 run 方法不能有返回值;
  • Callable 的 call 方法可以丟擲異常,而 Runnable 的 run 方法不能丟擲異常。

八、什麼是上下文切換?

多執行緒程式設計中一般執行緒的個數都大於 CPU 核的個數,而一個 CPU 核在任意時刻只能被一個執行緒使用,為了讓這些縣城都能得到有效執行,CPU 採取的策略是為每個執行緒分配時間片並輪轉的形式。當一個執行緒是時間片用完的時候就會重新處於就緒狀態讓給其他執行緒使用,這個過程就屬於一次上下文切換。也就是:當任務執行完, CPU 時間片切換到另一個任務之前會先儲存自己的狀態,以便於再切換回這個任務時,可以載入這個任務的狀態。任務從保持到再載入的過程就是一個上下文切換。

上下文切換通常是計算密集型的。也就是說,它需要相當可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統來說意味著消耗大量的 CPU 時間,事實上,可能是作業系統中時間消耗最大的操作。

Linux 相比與其他作業系統(包括其他類 Unix 系統)有很多的優點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。

九、什麼是執行緒死鎖?怎麼避免?

死鎖:

兩個或者兩個以上的執行緒在執行的過程中,因爭奪資源產生的一種互相等待的現象。

為什麼會出現死鎖?

Java 執行多執行緒併發控制,當多個執行緒同時操作一個共享的資源變數時(如資料的增刪改查),將會導致資料出現不正確的結果,相互之間產生衝突,因此加入鎖保證了該變數的唯一性和準確性。

如下程式碼(程式碼源自《Java多執行緒程式設計核心技術》):

public class DeadThreadDemo implements Runnable{
    public String username;
    public Object lock1 = new Object();
    public Object lock2 = new Object();
    public void setFlag(String username) {
        this.username = username;
    }
    @Override
    public void run(){
        if(username.equals("a")) {
            synchronized (lock1) {
                try {
                    System.out.println("username = " + username);
                    Thread.sleep(3000);
                }catch(InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("按 lock1->lock2程式碼 順序執行了");
                }
            }
        }
        if(username.equals("b")) {
            synchronized (lock2) {
                try {
                    System.out.println("username = " + username);
                    Thread.sleep(3000);
                }catch(InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("按lock2->lock1程式碼順序執行了");
                }
            }
        }
    }
}

測試類:

public class DeadThreadTest {

    public static void main(String[] args) {
        try {
            DeadThreadDemo dtd1 = new DeadThreadDemo();
            dtd1.setFlag("a");
            Thread thread1 = new Thread(dtd1);
            thread1.start();
            Thread.sleep(100);
            dtd1.setFlag("b");
            Thread thread2 = new Thread(dtd1);
            thread2.start();
        }catch(InterruptedException e) {
            e.printStackTrace();
        }
    }
}

輸出:

username = a
username = b

執行緒 a 通過 synchronized (lock1) 獲得 lock1 的監視器鎖,然後通過thread.sleap(3000); 讓執行緒 a 休眠 3s 為的是讓執行緒 b 得到執行然後獲取到 lock2 的監視器鎖。執行緒 a 和執行緒 b 休眠結束了都開始企圖請求獲取對方的資源,然後這兩個執行緒就會陷入互相等待的狀態,這也就產生了死鎖。上面的例子符合產生死鎖的四個必要條件。

死鎖產生的四個條件:

  1. 互斥條件: 該資源任意一個時刻只由一個執行緒佔用;
  2. 請求與保持條件:一個執行緒因請求資源而阻塞,對已獲得的資源保持不放;
  3. 不剝奪條件:執行緒已經獲得的資源在未使用完之前不能被其他執行緒強行剝奪,只由自己使用完畢後才釋放資源;
  4. 迴圈等待條件:若干執行緒之間形成一種頭尾相接的迴圈等待資源關係。

怎麼避免執行緒死鎖?

只需要破壞產生死鎖的四個條件之一即可。

  • 破壞互斥條件:這個條件我們沒有辦法破壞,因為我們用鎖本身就是想讓他們互斥的(臨界資源需要互斥訪問)。
  • 破壞請求與保持條件:一次性申請所有的資源
  • 破壞不剝奪條件:佔用部分資源的執行緒進一步申請其他資源時,如果申請不到,可以主動釋放它佔有的資源。
  • 破壞迴圈等待條件:靠按順序申請資源來預防。按照某一順序申請資源,釋放資源則反序釋放。破壞迴圈等待條件。

十、sleep() 方法和 wait() 方法區別和共同點

  • 兩者最主要的區別在於:sleep() 方法沒有釋放鎖,而 wait() 方法釋放了鎖;
  • 兩者都可以暫停多執行緒;
  • wait() 通常被用於執行緒間互動/通訊,sleep() 通常被用於暫停執行;
  • wait() 方法被呼叫後,執行緒不會自動甦醒,需要別的執行緒呼叫同一個物件上的 notify() 或者 notifyAll() 方法。sleep 執行完後,會自動甦醒。

十一、為什麼我們呼叫 start() 方法時會執行 run() 方法,為什麼我們不能直接呼叫 run() 方法?

new 一個 Thread,執行緒進入了新建狀態;呼叫 start() 方法,會啟動一個執行緒並使執行緒進入就緒狀態,當分配到時間片後就可以開始運行了。start() 會執行執行緒的相應準備工作,然後自動執行 run() 方法的內容,這是真正的多執行緒工作。而直接執行 run() 方法,會把 run() 方法當成一個 main 執行緒下的普通方法去執行,並不會在某個執行緒中執行它,所以這不是多執行緒工作。

總之:呼叫 start() 方法可啟動執行緒並使執行緒進入就緒狀態,而 run() 方法只是 thread 的一個普通方法,還是在主執行緒裡執行的。