軟體效能測試分析與調優實踐之路-Java應用程式的效能分析與調優-手稿節選

張永清發表於2022-03-01

Java程式語言自從誕生起,就成為了一門非常流行的程式語言,覆蓋了網際網路、安卓應用、後端應用、大資料等很多技術領域,因此Java應用程式的效能分析和調優也是一門非常重要的課題。Java應用程式的效能直接關係到了很多大型電商網站的訪問承載能力、大資料的資料處理量等,它的效能分析和調優往往還可以節省很多的硬體成本。

5.1  JVM基礎知識

5.1.1  JVM簡介

JVM是Java Virtual Machine(Java虛擬機器)的英文簡寫,通過在實際的計算機上模擬模擬各種計算機功能來實現的。Java程式語言在引入了Java虛擬機器後,使得Java應用程式可以在不同作業系統平臺上執行,而不需要再次重新編譯。Java程式語言通過使用Java虛擬機器遮蔽了與具體作業系統平臺相關的資訊,保證了編譯後的應用程式的平臺相容性,使得Java應用程式只需編譯生成在Java虛擬機器上執行的目的碼(位元組碼),就可以在不同的作業系統上部署和執行。Java虛擬機器本質上可以認為是執行在作業系統上的一個程式、一個程式。Java虛擬機器在啟動後就開始執行儲存在位元組碼檔案中的指令,其內部組成結構如圖5-1-1所示。

 

圖5-1-1

在JDK1.8(Java 8)及以後的版本中,JVM的內部組成結構發生了一些小的變化,如圖5-1-2所示。

 

圖5-1-2

5.1.2  類載入器

類載入器(Class Loader)負責將編譯好的.class位元組碼檔案裝載到記憶體中,使得JVM可以例項化或以其他方式使用載入後的類。類載入器支援在執行時的動態載入,動態載入可以節省記憶體空間,靈活地從本地或者網路上載入類,可以通過名稱空間的分隔來實現類的隔離,增強了整個系統的安全性等。類載入器分為如下幾種:

l啟動類載入器(BootStrap Class Loader): 啟動類載入器是最底層的載入器由C/C++語言實現,非Java語言實現,負責載入JDK中的rt.jar檔案中所有的Java位元組碼檔案。如圖5-1-3所示,rt.jar檔案一般位於JDK的jre目錄下,裡面存放中Java語言自身的核心位元組碼檔案。Java自身的核心位元組碼檔案一般都是由啟動類載入器進行載入。

 

圖5-1-3

l擴充套件類載入器(Extension Class Loader):負責載入一些擴充套件功能的jar包到記憶體中。一般負責載入<Java_Runtime_Home >/lib/ext目錄或者由系統變數-Djava.ext.dir指定位置中的位元組碼檔案。

l系統類載入器(System Class Loader):負責將系統類路徑java -classpath或-Djava.class.path引數所指定的目錄下的位元組碼類庫載入到記憶體中。通常程式設計師自己編寫的Java程式也是由該類載入器進行載入。

類載入器載入類的過程如圖5-1-4所示,該圖同時也描述了一個class位元組碼檔案的整個生命週期。

 

圖5-1-4

本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園

類載入器載入過程詳細描述如表5-1所示。

表5-1 類載入器載入過程詳細描述

步驟

說明

載入

將指定的.calss位元組碼檔案載入到JVM中

連線

已經載入到JVM中的二進位制位元組流的類資料資訊,合併到JVM的執行時狀態中,載入過程包括驗證、準備、解析三個步驟

驗證

校驗.class位元組碼檔案的正確性,確保該檔案是符合規範定義的,並且適合當前JVM版本的使用。一般包含如下4個子步驟:

(1)檔案格式校驗:校驗位元組碼檔案的格式是否符合規範、版本號是否正確並且對應的版本是否是當前JVM可以支援的、常量池中的常量是否有不被支援的型別等。

(2)後設資料校驗:對位元組碼描述的資訊進行語義分析,以確保其描述的資訊符合Java語言的規範。

(3)位元組碼校驗:通過對位元組碼檔案的資料流和控制流進行分析,驗證程式碼的語義是合法的、符合Java語言程式設計規範的。

(4)符號引用校驗:符號引用是指以一組符號來描述所引用的目標,校驗符號引用轉化成為真正的記憶體地址是否正確

準備

為載入到JVM中的類分配記憶體,同時初始化類中的靜態變數的初始值

解析

將符號引用轉換為直接引用,一般主要是把類的常量池中的符號引用解析為直接引用

初始化

初始化類中的靜態變數,並執行類中的static程式碼、建構函式等。如果沒有建構函式,系統新增預設的無參建構函式。如果類的建構函式中沒有顯示的呼叫父類的建構函式,編譯器會自動生成一個父類的無參建構函式

被呼叫

指在執行時被使用

解除安裝

指將類從JVM中移除

5.1.3  Java虛擬機器棧和本地方法棧

Java虛擬機器棧是Java方法執行的記憶體模型,是執行緒私有的,和執行緒直接相關。每建立一個新的執行緒,JVM就會為該執行緒分配一個對應的Java棧。各個執行緒的Java棧的記憶體區域是不能互相直接被訪問的,以保證在併發執行時執行緒的安全性。每呼叫一個方法,Java虛擬機器棧就會為每個方法生成一個棧幀(Stack Frame),呼叫方法時壓入棧幀(通常叫入棧),方法返回時彈出棧幀並拋棄(通常叫出棧)。棧幀中儲存區域性變數、運算元棧、動態連結、中間運算結果、方法返回值等資訊。每個方法被呼叫和完成的過程,都對應一個棧幀從虛擬機器棧上入棧和出棧的過程。虛擬機器棧的生命週期和執行緒是一樣的,棧幀中儲存的區域性變數隨著執行緒執行的結束而結束。

本地方法棧類似於Java虛擬機器棧,主要儲存了本地方法native method,指用native關鍵字修飾的方法)呼叫的狀態和資訊,是為了方便JVM去呼叫本地方法(native method)和介面的棧區。

和棧相關的常見異常如下:

lStackOverflowError:俗稱棧溢位。一般當棧深度超過JVM虛擬機器分配給執行緒的棧大小時,就會出現這個錯誤。在迴圈呼叫方法而無法退出的情況下,容易出現棧溢位錯誤。

lOutOfMemoryError:詳細錯誤資訊一般為“Exception in thread "main" java.lang.OutOfMemoryError:unable to create new native thread”。Java 虛擬機器棧的記憶體大小允許動態擴充套件,且當執行緒請求棧時記憶體用完了,無法再動態擴充套件了,此時丟擲 OutOfMemoryError 錯誤。

5.1.4  方法區與後設資料區

本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園

方法區也就是我們常說的永久代區域,裡面儲存著Java 類資訊、常量池、靜態變數等資料,方法區佔用的記憶體區域在JVM中是執行緒共享的。在JDK1.8及以後的版本中,方法區已經被移除,取而代之的是後設資料區和本地記憶體,類的後設資料資訊直接存放到JVM管理的本地記憶體中。需要注意的是,本地記憶體不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。常量池、靜態變數等資料則存放到了Java堆(Heap)中。這樣做的目的主要是為了減少載入的類過多時容易造成Full GC問題。

5.1.5  堆區

Java是一門物件導向的開發語言,而JVM堆區是真正儲存Java物件例項的記憶體區域,並且是所有執行緒共享的。所以Java程式在進行例項化物件等操作時,需要解決同步和執行緒安全問題。Java堆區可以細分為新生代區域和老年代區域。新生代還可以再細分為Eden空間區域、From Survivor空間區域、To Survivor空間區域,如圖5-1-5所示。堆區是發生GC垃圾回收最頻繁的記憶體區域,因此也是JVM效能調優的關鍵區域。

 

圖5-1-5

Java堆區內部結構說明如表5-2所示。

表5-2 Java堆區內部結構說明

區域

說明

新生代區

又稱年輕代區域,由Eden空間區域和Survivor空間區域共同組成。在新生代區域中JVM預設記憶體分配比例為Eden : From Survivo : To Survivor = 8 : 1 : 1

Eden空間區域

新生物件存放的記憶體區域,存放著首次建立的物件例項

Survivor空間區域

由From Survivor空間區域和To Survivor空間區域共同組成,並且這兩個區域中總是有一個是空的

From Survivor空間區域

儲存Eden空間區域發生GC垃圾回收後倖存的物件例項。From Survivor空間區域和To Survivor空間區域的作用是等價的,並且預設情況下這兩個區域的大小是一樣大的

To Survivor空間區域

儲存Eden空間區域發生GC垃圾回收後倖存的物件例項。當一個Survivor(倖存者)空間飽和,依舊存活的物件會被移動到另一個Survivor(倖存者)空間,然後會清空已經飽和的那個Survivor(倖存者)空間

老年代區域

JVM的垃圾回收器分代進行垃圾回收。在回收到一定次數(可以通過JVM引數設定)後,依然存活的新生代物件例項將會進入老年代區域

上圖5-1-5中的箭頭指示的方向就代表JVM堆區分代進行垃圾回收時資料的移動過程。物件在剛剛被建立之後是儲存在Eden空間區域的,那些長期存活的物件會經由Survivor(倖存者)空間轉存到老年代空間區域(Old generation)。當然對於一些比較大的物件(需要分配一塊比較大的連續記憶體空間),則直接進入到老年代區域,這種情況一般在Survivor 空間區域記憶體不足的時候下會發生。

在JDK1.7以及之前的版本中,JVM的共享記憶體區域組成如圖5-1-6所示。

本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園

 

圖5-1-6

在JDK1.8以及之後的版本中,JVM的共享記憶體區域組成如圖5-1-7所示。

 

圖5-1-7

5.1.6  程式計數器

程式計數器是一個記錄著執行緒所執行的位元組碼指令位置的指示器,裝載入JVM記憶體中的.class位元組碼檔案通過位元組碼直譯器進行解釋執行,按照順序讀取位元組碼指令。每讀取一個指令後,將該指令轉換成對應的操作,並根據這些操作進行分支、迴圈、條件判斷等流程處理。由於程式一般是多執行緒來協同執行的,並且JVM的多執行緒是通過CPU時間片輪轉(即執行緒輪流切換並分配公平爭搶CPU執行時間)演算法來實現的,這樣就存在著某個執行緒在執行過程中可能會因為時間片耗盡而被掛起,而另一個執行緒獲取到時間片開始執行。當被掛起的執行緒重新獲取到CPU時間片的時候,它要想從被掛起的地方繼續執行,就必須知道它上次執行到哪個位置即程式碼中的具體行號了,在JVM中就是通過程式計數器來記錄某個執行緒的位元組碼指令的執行位置。因此,程式計數器是執行緒私有的、是執行緒隔離的,每個執行緒在執行時都有屬於自己的程式計數器。另外,如果是執行native方法,程式計數器的值為空,因為native方法是Java通過JNI(Java Native Interface)直接呼叫Java本地C/C++語言庫執行的,而C/C++語言實現的方法自然無法產生相應的.class位元組碼(C/C++語言是按照C/C++語言的方式來執行的),因此Java的程式計數器此時是無值的。

5.1.7  垃圾回收

Java語言和別的程式語言不一樣,程式執行時的記憶體回收不需要開發者自己在程式碼中進行手動回收和釋放,而是JVM自動進行記憶體回收。記憶體回收時會將已經不再使用的物件例項等從記憶體中移除掉,以釋放出更多的記憶體空間,這個過程就是常說的JVM垃圾回收機制。

垃圾回收一般也叫GC,新生代的垃圾回收一般稱作Minor GC,老年代的垃圾回收一般稱作Major GC或者Full GC。垃圾回收之所以如此重要,是因為發生垃圾回收時一般會伴隨著應用程式的暫停執行。一般發生垃圾回收時除GC所需的執行緒外,所有的其他執行緒都進入等待狀態,直到GC執行完成。GC調優最主要目標就是減少應用程式的暫停執行時間。

JVM垃圾回收的常見演算法有根搜尋演算法、標記-清除演算法、複製演算法標記-整理演算法增量回收演算法

1. 根搜尋演算法

根搜尋演算法把垃圾回收執行緒把應用程式的所有引用關係看作一張圖,從一個節點GC ROOT(英文解釋為A garbage collection root is an object that is accessible from outside the heap,即一個可以從堆外訪問的物件) 開始,尋找對應的引用節點,找到這個節點後,繼續尋找這個節點的引用節點。當所有的引用節點尋找完畢後,剩餘的節點則被認為是沒有被引用到的節點,即無用的節點,然後對這些節點執行垃圾回收。

如圖5-1-8所示,顏色較深的節點(例項物件6、例項物件7、例項物件8)就是可以被垃圾回收的節點,因為這些節點已經被引用了。

 

圖5-1-8

本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園

IBM網站頁面https://www.ibm.com/support/knowledgecenter/en/SS3KLZ/com.ibm.java. diagnostics.memory.analyzer.doc/gcroots.html介紹中認為JVM中可以作為GC ROOT節點的物件包括:

System[z8] class[u9]

A class that was loaded by the bootstrap loader, or the system class loader. For example, this category includes all classes in the rt.jar file (part of the Java™ runtime environment), such as those in the java.util.* package.

JNI local

A local variable in native code, for example user-defined JNI code or JVM internal code.

JNI global

A global variable in native code, for example user-defined JNI code or JVM internal code.

Thread block

An object that was referenced from an active thread block.

Thread

A running thread.

Busy monitor

Everything that called the wait() or notify() methods, or that is synchronized, for example by calling the synchronized(Object) method or by entering a synchronized method. If the method was static, the root is a class, otherwise it is an object.

Java local

A local variable. For example, input parameters, or locally created objects of methods that are still in the stack of a thread.

Native stack

Input or output parameters in native code, for example user-defined JNI code or JVM internal code. Many methods have native parts, and the objects that are handled as method parameters become garbage collection roots. For example, parameters used for file, network, I/O, or reflection operations.

Finalizer

An object that is in a queue, waiting for a finalizer to run.

Unfinalized

An object that has a finalize method, but was not finalized, and is not yet on the finalizer queue.

Unreachable

An object that is unreachable from any other root, but was marked as a root by Memory Analyzer so that the object can be included in an analysis.

Unreachable objects are often the result of optimizations in the garbage collection algorithm. For example, an object might be a candidate for garbage collection, but be so small that the garbage collection process would be too expensive. In this case, the object might not be garbage collected, and might remain as an unreachable object.

By default, unreachable objects are excluded when Memory Analyzer parses the heap dump. These objects are therefore not shown in the histogram, dominator tree, or query results. You can change this behavior by clicking File > Preferences... > IBM Diagnostic Tools for Java - Memory Analyzer, then selecting the Keep unreachable objects check box.

而https://www.dynatrace.com/resources/ebooks/javabook/how-garbage-collection-works/網站中給出的解釋如圖5-1-9所示。

 

圖5-1-9

最終我們總結歸納如下:

(1)JVM虛擬機器棧中引用的例項物件。

(2)方法區中靜態屬性引用的物件(僅針對JDK1.8之前的JVM,JDK1.8及之後由於不存在方法區,靜態屬性直接存於Heap中)。

(3)方法區中靜態常量引用的物件(僅針對JDK1.8之前的JVM,JDK1.8及之後由於不存在方法區,靜態常量直接存於Heap中)。

(4)本地方法(native method,多用在JNI介面呼叫中)棧中引用的物件。

(5)JVM自身持有的物件,比如啟動類載入器、系統類載入器等。

下面講的其他GC演算法基本都會引用根搜尋演算法這種概念。

本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園

2. 標記-清除演算法

如圖5-1-10所示,標記-清除演算法採用從GC ROOT進行掃描,對存活的物件節點進行標記,標記完成後再掃描整個記憶體區域中未被標記的物件進行直接回收。由於標記-清除演算法標記完畢後不會對存活的物件進行移動和整理,因此很容易導致記憶體碎片空閒的連續記憶體空間比要申請的空間小,導致大量空閒的小記憶體塊不能被利用。但是由於僅對不存活的物件進行處理,在存活的物件較多、不存活的物件較少的情況下,標記清除-演算法的效能極高。

 

圖5-1-10

3. 複製演算法

複製演算法同樣採用從GC ROOT根集合掃描,將存活的物件複製到空閒區間,當掃描完活動區間後,會將活動區間記憶體一次性全部回收,此時原來的活動區間就變成了空閒區域,如圖5-1-11所示。複製演算法會將記憶體分為兩個區間,所有動態分配的例項物件都只能分配在其中一個區間(此時該區間就變成了活動區間),而另外一個區間則是空閒的,每次GC時都重複這樣的操作,每次總是會有一個區域是空閒的。

 

圖5-1-11

4. 標記-整理演算法

採用標記-清除演算法一樣的方式進行物件的標記、清除,但在回收不存活的物件佔用的記憶體空間後,會將所有存活的物件往左端空閒空間移動,並更新對應的記憶體節點指標,如圖5-1-12所示。標記-整理演算法是在標記-清除演算法之上,又進行了物件的移動排序整理,雖然效能成本更高了,但卻解決了記憶體碎片的問題。如果不解決記憶體碎片的問題,一旦出現需要建立一個大的物件例項時,JVM可能無法給這個大的例項物件分配連續的大記憶體,從而導致發生Full GC。在垃圾回收中,Full GC 應該是需要儘量去避免的,因為一旦出現Full GC,一般會導致應用程式暫停很久以等待Full GC完成。

本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園

 

圖5-1-12

JVM為了優化垃圾回收的效能,使用了分代回收的方式。它對於新生代記憶體的回收(Minor GC)主要採用複製演算法,而對於老年代的回收(Major GC/Full GC),大多采用標記-整理演算法。在做垃圾回收優化時,最重要的一點就是減少老年代垃圾回收的次數,因為老年代垃圾回收耗時長,效能成本非常高,對應用程式的執行影響非常大。

4. 增量回收演算法

增量回收演算法把JVM記憶體空間劃分為多個區域,每次僅對其中某一個區域進行垃圾回收,這樣做的好處就是減小應用程式的的中斷時間,使得使用者一般不能覺察到垃圾回收器正在工作

5.1.8  並行與併發

本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園

在併發程式開發中經常會提到並行與併發。在垃圾回收中並行和併發的區別如下:

l並行:JVM啟動多個垃圾回收執行緒並行工作,但此時使用者執行緒(應用程式的工作執行緒)需要一直處於等待狀態。

l併發:指使用者執行緒(應用程式的工作執行緒)與垃圾回收執行緒同時執行(但並不一定是並行的,可能會交替執行),使用者執行緒此時可以繼續執行,而垃圾回收執行緒執行於另一個CPU核上,彼此可以互不干擾。

未完待續,本文作者:張永清,轉載請註明:https://www.cnblogs.com/laoqing/p/15950682.html 來源於部落格園。 本文摘選自《軟體效能測試分析與調優實踐之路》

相關文章