以 DEBUG 方式深入理解執行緒的底層執行原理

飛天小牛肉發表於2021-04-27

說到執行緒的底層執行原理,想必各位也應該知道我們今天不可避免的要講到 JVM 了。其實大家明白了 Java 的執行時資料區域,也就明白了執行緒的底層原理,不過把這些東西明明白白寫在紙面上的,網路上的文章並不多,所以今天我總結了一下,帶著大家一步一步 DEBUG,來看看執行緒到底是怎麼執行的,順便把 IDEA 的 DEBUG 方法簡單講一下。

工具的使用應該是大部分同學都缺失的,我自己就深受其害,經常不由自主地習慣性用肉眼一行一行排 BUG(狗頭)。

Java 執行時資料區域

友情提示:這部分內容可能大部分同學都有一定的瞭解了,可以跳過直接進入下一小節哈。

Java 虛擬機器在執行 Java 程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域,這些區域都有各自的用途,以及建立和銷燬的時間。

全文我們都將以 JDK 7 的執行時資料區域為例:

先簡單解釋下執行緒共享和執行緒私有是啥意思。

所謂執行緒私有,通俗來說就是每個執行緒都會建立一個屬於自己的東西,每個執行緒之間的這塊私有區域互不影響,獨立儲存。比如程式計數器就是執行緒私有的,每個執行緒都會擁有一個屬於自己的程式計數器,互不干涉。

執行緒共享就沒啥好說的,簡單理解為公共場所,誰都能去,儲存的資料所有執行緒都能訪問。

OK,然後我們來逐個分析下每個區域都是用來儲存什麼的。當然了,這裡不會做太多詳細的說明,不然會使文章顯得非常臃腫,在理解本文的基礎上能夠讓大家對各個區域有基本的認知就好了。

首先來看一下執行緒共享的兩個區域:

1)Java 堆(Java Heap)是 Java 虛擬機器所管理的記憶體中最大的一塊,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。這一點在 Java 虛擬機器規範中的描述是:所有的物件例項以及陣列都要在堆上分配。

2)方法區(Method Area)與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

很多人習慣的把方法區稱為永久代(Permanent Generation),但實際上這兩者並不等價。通俗來說,方法區是一種規範,而永久代是 HotSpot 虛擬機器實現這個規範的一種手段,對於其他虛擬機器(比如 BEA JRockit、IBM J9 等)來說是不存在永久代的概念的。

另外,對於 HotSpot 虛擬機器來說,它在 JDK 8 中完全廢棄了永久代的概念,改用與 JRockit、J9 一樣在本地記憶體中實現的元空間(Meta-space)來代替,把 JDK 7 中永久代還剩餘的內容(主要是型別資訊)全部移到元空間中。

再來看看執行緒私有的三個區域:

1)虛擬機器棧(Java Virtual Machine Stacks)其實是由一個一個的棧幀(Stack Frame)組成的,一個棧幀描述的就是一個 Java 方法執行的記憶體模型。也就是說每個方法在執行的同時都會建立一個棧幀,用於儲存區域性變數表、運算元棧、動態連結、方法的返回地址等資訊。

每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程,當然,出棧的順序自然是遵守棧的後進先出原則的。

棧幀的概念在接下來的原理解析部分非常重要,各位務必搞懂哈。

2)本地方法棧(Native Method Stack)和上面我們所說的虛擬機器棧作用基本一樣,區別只不過是本地方法棧為虛擬機器使用到的 Native 方法服務,而虛擬機器棧為虛擬機器執行 Java 方法(也就是位元組碼)服務。

這裡解釋一下 Native 方法的概念,其實不僅 Java,很多語言中都有這個概念。

"A native method is a Java method whose implementation is provided by non-java code."

就是說一個 Native 方法其實就是一個介面,但是它的具體實現是在外部由非 Java 語言寫的。所以同一個 Native 方法,如果用不同的虛擬機器去呼叫它,那麼得到的結果和執行效率可能是不一樣的,因為不同的虛擬機器對於某個 Native 方法都有自己的實現,比如 Object 類的 hashCode 方法。

這使得 Java 程式能夠超越 Java 執行時的界限,有效地擴充了 JVM。

3)程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

由於 Java 虛擬機器的多執行緒是通過輪流分配 CPU 時間片的方式來實現的,因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器。

那麼程式計數器裡存的到底是什麼東西呢?

《深入理解 Java 虛擬機器:JVM 高階實踐與最佳實戰 - 第 2 版》給出了答案:如果執行緒正在執行的是一個 Java 方法,程式計數器中記錄的就是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是 Native 方法,這個計數器值則為空(Undefined)

用 DEBUG 的方式看執行緒執行原理

接下來,我們就通過 DEBUG 這段程式碼來看下執行緒的執行原理:

上述程式碼的邏輯非常簡單,main 方法呼叫了 method1 方法,而 method1 方法又呼叫了 method2 方法。

看下圖,我們打了一個斷點:

OK,以 DEBUG 的方式執行 Test.main(),雖然這裡我們沒有顯示的建立執行緒,但是 main 函式的呼叫本身就是一個執行緒,也被稱為主執行緒(main 執行緒),所以我們一啟動這個程式,就會給這個主執行緒分配一個虛擬機器棧記憶體。

上文我們也說了,虛擬機器棧記憶體其實就是個殼兒,裡面真正儲存資料的,其實是一個一個的棧幀,每個方法都對應著一個棧幀

所以當主執行緒呼叫 main 方法的時候,就會為 main 方法生成一個棧幀,其中儲存了區域性變數表、運算元棧、動態連結、方法的返回地址等資訊。

各位現在可以看看 DEBUG 視窗顯示的介面:

左邊的 Frames 就是棧幀的意思,可以看見現在主執行緒中只有一個 main 棧幀;

右邊的 Variables 就是該棧幀儲存的區域性變數表,可以看到現在 main 棧幀中只有一個區域性變數,也就是方法引數 args。

接下來 DEBUG 進入下一步,我們先來看看 DEBUG 介面上的每個按鈕都是啥意思,總共五個按鈕(已經瞭解的各位可以跳過這裡):

1)Step Over:F8

程式向下執行一行,如果當前行有方法呼叫,這個方法將被執行完畢並返回,然後到下一行

2)Step Into:F7

程式向下執行一行,如果該行有自定義方法,則執行進入自定義方法(不會進入官方類庫的方法)

3)Force Step Into:Alt + Shift + F7

程式向下執行一行,如果該行有自定義方法或者官方類庫方法,則執行進入該方法(也就是可以進入任何方法)

4)Step Out:Shift + F8

如果在除錯的時候你進入了一個方法,並覺得該方法沒有問題,你就可以使用 Step Out 直接執行完該方法並跳出,返回到該方法被呼叫處的下一行語句。

5)Drop frame

點選該按鈕後,你將返回到當前方法的呼叫處重新執行,並且所有上下文變數的值也回到那個時候。只要呼叫鏈中還有上級方法,可以跳到其中的任何一個方法。


OK,我們點選 Step Into 進入 method1 方法,可以看到,虛擬機器棧記憶體中又多出了一個 method1 棧幀:

再點選 Step Into 直到進入 method2 方法,於是虛擬機器棧記憶體中又多出了一個 method2 棧幀:

當我們 Step Into 走到 method2 方法中的 return n 語句後,n 指向的堆中的地址就會被返回給 method1 中的 m,並且,滿足棧後進先出的原則,method2 棧幀會從虛擬機器棧記憶體中被銷燬。

然後點選 Step Over 執行完輸出語句(Step Into 會進入 println 方法,Force Step Into 會進入 Object.toString 方法)

至此,method1 的使命全部完成,method1 棧幀會從虛擬機器棧記憶體中被銷燬。

最後再往下走一步,main 棧幀也會被銷燬,這裡就不再貼圖了。

執行緒執行原理詳細圖解

上面寫了這麼多,其實也就是教會了大家棧幀這個東西,接下來我們通過圖解的方式,來帶大家詳細看看執行緒執行時,Java 執行時資料區域的各種變化。

首先第一步,類載入。

《深入理解 Java 虛擬機器:JVM 高階實踐與最佳實戰 - 第 2 版》中是這樣解釋類載入的:虛擬機器把描述類的資料從 Class 檔案(位元組碼檔案)載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別,這就是虛擬機器的類載入機制。

載入進來的這些位元組碼資訊,就儲存在方法區中。看下圖,這裡為了各位理解方便,我就不寫位元組碼了,直接按照程式碼來,大家知道這裡存的其實是位元組碼就行

主執行緒呼叫 main 方法,於是為該方法生成一個 main 棧幀:

那麼這個引數 args 的值從哪裡來呢?沒錯,就是從堆中 new 出來的:

而 main 方法的返回地址就是程式的退出地址。

再來看程式計數器,如果執行緒正在執行的是一個 Java 方法,程式計數器中記錄的就是正在執行的虛擬機器位元組碼指令的地址,也就是說此時 method1(10) 對應的位元組碼指令的地址會被放入程式計數器圖片中我們仍然以具體的程式碼代替哈,大家知道就好

OK,CPU 根據程式計數器的指示,進入 method1 方法,自然,method1 棧幀就被建立出來了:

區域性變數表和方法返回地址安頓好後,就可以開始具體的方法呼叫了,首先 10 會被傳給 x,然後走到 y 被賦值成 x + 1 這步,也就是程式計數器會被修改成這步程式碼對應的位元組碼指令的地址:

走到 Object m = method2(); 這一步的時候,又會建立一個 method2 棧幀:

可以看到,method2 方法的第一行程式碼會在堆中建立一個 Object 物件:

隨後,走到 method2 方法中的 return n; 語句,n 指向的堆中的地址就會被返回給 method1 中的 m,並且,滿足棧後進先出的原則,method2 棧幀會從虛擬機器棧記憶體中被銷燬:

根據 method2 棧幀指向的方法返回地址,我們接著執行 System.out.println(m.toString()) 這條輸出語句,執行完後,method1 棧幀也被銷燬了:

再根據 method1 棧幀指向的方法返回地址,發現我們的程式已走到了生命的盡頭,main 棧幀於是也被銷燬了,就不再貼圖了。

用 DEBUG 的方式看多執行緒執行原理

上面說的是隻有一個執行緒的情況,其實多執行緒的原理也差不多,因為虛擬機器棧是每個執行緒私有的,大家互不干涉,這裡我就簡單的提一嘴。

分別在如下兩個位置打上 Thread 型別的斷點:

然後以 DEBUG 方式執行,你就會發現存在兩個互不干涉的虛擬機器棧空間:

當然,使用多執行緒就不可避免的會遇到一個問題,那就是執行緒的上下文切換(Thread Context Switch),就是說因為某些原因導致 CPU 不再執行當前的執行緒,轉而執行另一個執行緒。

導致執行緒上下文切換的原因大概有以下幾種:

1)執行緒的 CPU 時間片用完

2)發生了垃圾回收

3)有更高優先順序的執行緒需要執行

4)執行緒自己呼叫了 sleep、yield、wait、join、park、synchronized、lock 等方法

當執行緒的上下文切換髮生時,也就是從一個執行緒 A 轉而執行另一個執行緒 B 時,需要由作業系統儲存當前執行緒 A 的狀態(為了以後還能順利回來接著執行),並恢復另一個執行緒 B 的狀態。

這個狀態就包括每個執行緒私有的程式計數器和虛擬機器棧中每個棧幀的資訊等,顯然,每次作業系統都需要儲存這麼多的資訊,頻繁的執行緒上下文切換勢必會影響程式的效能

? 關注公眾號 | 飛天小牛肉,即時獲取更新

  • 博主東南大學碩士在讀,攜程 Java 後臺開發暑期實習生,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專注分享計算機基礎(資料結構 + 演算法 + 計算機網路 + 資料庫 + 作業系統 + Linux)、Java 技術棧等相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。關注公眾號第一時間獲取文章更新,成長的路上我們一起進步
  • 並推薦個人維護的開源教程類專案: CS-Wiki(Gitee 推薦專案,現已累計 1.6k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ ?
  • 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 700+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中。

相關文章