深入理解以DEBUG方式線程的底層運行原理

一、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 的狀態。

這個狀態就包括每個線程私有的程序計數器和虛擬機棧中每個棧幀的信息等,顯然,每次操作系統都需要存儲這麼多的信息,頻繁的線程上下文切換勢必會影響程序的性能。

以上就是深入理解以DEBUG方式線程的底層運行原理的詳細內容,更多關於DEBUG方式線程運行原理的資料請關註WalkonNet其它相關文章!

推薦閱讀: