性能優化之 JVM 高級特性
線程共享內存
可以被所有線程共享的區域,包括堆區、方法區、運行時常量池。
1.1 堆(Heap)
大多數時候,Java 堆是 Java 虛擬機管理的內存裏最大的一塊,所有的對象實例和數組都要在堆上分配內存空間,Java 對象可以分為兩類,一類是快速創建快速消亡的,另一類是長期使用的。所以針對這種情況大多收集器都是基於分代收集算法進行回收。
Java 的堆可以分為新生代(Young Generation)和老年代(Old Generation),而新生代(Young Generation)又可以分為 Eden Space 空間 (伊甸園區)、From Survivor 空間(From 生存區)、To Survivor 空間(To 生存區)。
Java 堆是一塊共享的區域,會出現線程安全的問題,而操作共享區域就需要鎖和同步,通過- Xms設置堆的最小值,堆內存越小越容易發生內存不夠用的情況而觸犯 Full GC(對新生代、老年代、永久代進行垃圾回收)。官方推薦新生代大小占整個堆大小的 3/8,通過- Xmx設置堆的最大值,堆內存超過此值會發拋出 OutOfMemoryError 異常:
1.2 方法區(Method Area)
方法區(Method Area)在 HotSpot 虛擬機上可以看作是永久代(Permanent Generation),對於其他虛擬機(JRockit 、J9 等)來說是不存在永久代的。方法區也是所有線程共享的區域,主要存儲被虛擬機加載的類信息、常量、靜態變量,堆存儲對象數據,方法區存儲靜態信息。
方法區不像 Java 堆區那樣經常發生垃圾回收,但不表示不會發生。永久代的大小跟新生代、老年代比都是很小的,通過設置- XX:MaxPermSize來指定最大內存,方法區需要的內存超過此值會拋出 OutOfMemoryError 異常。
1.3 運行時常量池(Runtime Constant Pool)
Java 通過類加載機制可以把字節碼文件中的常量池表加載進運行時常量池,而我們也可使用 String 類的 intern() 方法在運行期將新的常量放入池中,運行時常量池是方法區的一部分,在 JDK1.7 的 HotSpot 中,已經把原本放在方法區的常量池移出來了。
線程私有內存
只允許被所屬的線程私自訪問的內存區,包括 PC 寄存器、Java 棧和本地方法棧。
1.4 棧(Java Stack)
Java Stack 描述的是 Java 方法執行時的內存模型,每個方法執行時都會創建一個棧幀(Stack Frame),棧幀包含局部變量表(存放編譯期間的各種基本數據類型,對象引用等信息)、操作數棧、動態鏈接、方法出口等數據。
一個線程運行時會分配棧空間,每個線程的棧空間數據是相互隔離的,所以棧是私有的,堆是共享的,一個線程執行多個方法,會入棧出棧多個棧幀(多個方法),棧是先進後出的數據結構,最先入棧的棧幀,最後出棧,可以通過-Xss設置每個線程棧的大小,越小,能創建的線程數就越多,但並不是可以無限的,在一個進程裏(JVM 進程)能生成的線程數最多不超過五千
1.5 本地方法棧(Native Stack)
虛擬機棧(Java Stack)為執行 Java 方法(就是字節碼)服務,而本地方法棧(Native Stack)則為 Native 方法(比如用 C/C++ 編寫的代碼)服務,其他方面都很類似。
1.6 PC 寄存器(程序計數器)
JVM 字節碼解析器通過改變 PC 寄存器的值來明確下一條需要執行的字節碼指令,每個線程都會分配一個獨立的 PC 寄存器。
2、JVM 垃圾回收算法
JVM 垃圾收集算法不同虛擬機的具體實現會不一樣,這裏先講解幾種經典的垃圾收集算法的思想,後面再以使用得最廣泛的 HotSpot 虛擬機為例講解具體的垃圾收集器算法。
2.1 引用計數法
給每個對象維護一個引用計數器,每當被引用一次就加 1,每當引用失效一次就減 1,引用計數器為 0,表明當前對象沒有被任何對象引用,則可以當作垃圾回收。但是當 A 對象和 B 對象相互引用對方的時候,大家的計數器值都不為 0,而如果對象 A 和對象 B 都已經不被外部引用,就是說兩個無用的對象因為相互引用而無法進行垃圾回收。這就是循環引用的缺陷,故現在 JVM 虛擬機大多不采用這種方式做垃圾回收。
2.2 根搜索算法(Tracing)
復制 (Coping)
標記-清除 (Mark-Sweep)
標記-壓縮(Mark-Compact)
分代收集算法(Generational Collection)
根搜索算法從那些被稱為 GC Roots 的對象(虛擬機棧中引用的對象、方法區中靜態屬性引用的對象、方法區中常量引用的對象、本地方法棧 JNI 引用的對象)作為起始節點,從這些節點向下搜索,搜索所形成的路徑叫引用鏈。當一個對象到 GC Roots 的所有對象都沒有一條引用鏈,則說明該對象不可用,所以根搜索算法又叫可達性算法,GC Roots 到該對象不可達則表明該對象不可用,表明該對象可回收。
根搜索算法有四種,其中復制算法應用在新生代。
2.2.1 復制算法
復制算法將內存劃分相等的兩塊,當一塊內存用完了,將還存活的對象移動到另一塊內存,將之前使用的內存一次清理,新生代的內存空間通常都是所有代裏最大的,適用復制算法,實際上垃圾回收時是把 Eden Space 和 From Survivor 上還存活的對象復制到 To Survivor,而不需要按照 1:1 的比例來劃分。
通常 Eden Space:From Survivor:To Survivor = 8:1:1,如果出現狀況 To Survivor 空間不足以容納復制過來的還存活的對象,那通過分配擔保機制,這些對象可直接進入老年代,然後下一次垃圾回收發生時 From Survivor 和 To Survivor 交換身份,內存直接從 To Survivor 分配,回收到 From Survivor。
優點:沒有標記和清除的過程,效率高,沒有內存碎片,可以利用 Bump-the-pointer(指針碰撞)技術實現快速內存分配,因為已用和未用的內存各自一邊,內存分布規整有序,當新對象分配時就可以通過修改指針偏移量將其分配在第一個空閑的內存位置上,從而快速分配內存,否則只能使用空閑列表(Free List)方式分配內存,如下面要講的標記-清除(Mark-Sweep)垃圾回收算法。
缺點:開辟專門的空間存放存活對象,占用更多的內存。
2.2.2 標記-清除(Mark-Sweep)
上面的內存圖示是為了理解,其實內存是線性的。
從根集合開始掃描,對存活動對象進行標記,然後重新掃描整個內存空間清除未被標記的對象,優點是不需要額外的空間,缺點是重復掃描,性能低,而且產生內存碎片。
2.2.3 標記-壓縮(Mark-Compact)
一樣是從從根集合開始掃描,對存活動對象進行標記,然後重新掃描整個內存空間,並往一個方向移動存活對象,雖然移動對象的消耗時間,但不產生內存碎片,可以通過 Bump-the-pointer(指針碰撞)快速分配內存。
2.2.4 標記-清除-壓縮
是“標記-清除”和“標記-壓縮”算法的結合,唯一的不同是要等“標記-清除”多次以後,也就是多次垃圾回收進行以後才進行移動對象(壓縮),以避免每次 GC(垃圾回收)後都壓縮一次,降低了移動對象的耗時。
JVM 幾種經典的垃圾回收算法已經講完,下面直接進入 HotSpot 虛擬機的講解。
2.3 HotSpot 內存分配
2.3.1 內存分配實例
public class Earth
{ String name; // 單位:億年 int age; String size;
public Earth(String name, int age, String size)
{ super();
this.name = name;
this.age = age;
this.size = size;
} Earth e = new Earth("地球", 46, "1.0832×10^12立方千米");
}
當我們去 new 一個對象時,首先會在棧空間為對象的引用 e 分配內存,這是聲明 Earth e,但由於 Earth 還是一個空對象,無法使用,不指向任何實體,接著 new Earth 會在堆開辟內存給成員變量 name、age、size,並初始化為各個數據類型的默認值,然後才是初始化為自定義的賦值,接著調用構造函數通過參數列表 (“地球”, 46, “1.0832×10^12 立方千米”) 為成員變量再賦值,最後返回對象的引用(首地址)給變量 e。
Earth e1 = new Earth("地球", 46, "1.0832×10^12立方千米");
Earth e2 = new Earth("地球", 46, "1.0832×10^12立方千米");
Earth e3 = new Earth("地球", 46, "1.0832×10^12立方千米");
就算創建多個對象,在堆中還是一個實例,在棧中 e1、e2、e3 共同指向一個實例,而由於對象創建都是頻繁而且短生命周期,故一般對象被分配在堆的新生代的 Eden 區。
而堆空間是非線程安全的,是線程共享的,為對象分配內存時就要保證原子性,防止產生臟數據,這是會消耗性能的。為此 JVM 做了優化,優先為加載完成對類在 TLAB(Thread Local Allocation,本地線程分配緩沖區)中為對象實例分配內存空間。
TLAB 是在堆中 Eden 區裏開辟的空間,但卻是一塊線程私有區域,並不是所有對象都能在這成功分配,但在 TLAB 分配是個優選項,為了優化內存分配,還可以使用 JVM 的堆外內存存放對象實例,堆空間不是唯一 一個可以存放對象實例的地方,當一個對象作用域在方法體內,但隨時間推移,一旦其引用被方法體外的成員變量引用時,就發生了逃逸;
反過來,如果作用域還是局限於方法體內,JVM 就可以為對象在棧幀裏分配空間,對象分配在方法的棧幀上,隨著方法創建而創建,隨著方法退出而消亡,無需垃圾回收。
2.3.2 運行時常量池
我們再來看一下跟運行時常量池相關的內存分配的例子(面試常客):
public class StringDemo
{
public static void stingDemo()
{ // 池化的思想,把共享的數據放入字符串池中,
以減少頻繁創建銷毀對象的開銷
// 引用s1被放入棧中,字符串字面量"123"如果
存在於字符串池中,返回其引用給s1,否則,在池中新建字符串
String s1 = "123"; // 池中已經存在"123",
不會再創建,直接拿到引用(地址值)
String s2 = "123"; // 就是new對象的創建過程,不會去池查找,
直接在堆開辟空間存放實例對象,返回對象地址給s3
String s3 = new String("123");// String類本身是被final
修飾的,final修飾會保證s4的值不變,也就是s4=123,
而這個字符串字面量直接可以從池中拿到,不需要創建對象
String s4 = "1" + "23";
String str = "1";
// 由於被final修飾,str的值"1"不能改變,s5的值也不能變,其值是str+"23"創建對象所返回的地址(不是指對象內容
// 123不許變),所以這裏會新建一個對象
String s5 = str + "23"; // 兩個基本類型用==比較,比較的是兩個基本類型的值是否相等
// 一個包裝類型和一個基本類型用==比較,比較包裝類型的值和基本類型的值是否相等
// 兩個包裝類型用==比較,比較兩個對象返回的地址是否相等 // 兩個包裝類型用equals比較,比較兩個對象是否相等,
// 包括兩個對象類的類型是否相同,對象裏的值是否完全相同,對象的hashcode是否相同,不比較地址,所以hashcode相同,對象不一定相等,對象相等,hashcode一定相同
// s1==s2:true
System.out.println("s1==s2:" + (s1 == s2)); // s1==s3:false
System.out.println("s1==s3:" + (s1 == s3)); // s1.equals(s3):true
// Sting類已經幫我們覆寫了Object的equals方法,使得equals的比較正常,
// 如果沒覆寫,底層還是用==做的比較,我們自定義對象要用equals比較的前提是記得覆寫equals方法
System.out.println("s1.equals(s3):" + (s1.equals(s3)));
// s1=s4:true
System.out.println("s1=s4:" + (s1 == s4));
}
public static void main(String[] args)
{
StringDemo.stingDemo();
}
}
public class TestInteger
{
public static void main(String[] args)
{
int i1 = 129;
// java在編譯的時候,會變成 Integer i2 = Integer.valueOf(129)
Integer i2 = 129;
// int和integer(無論是否new出來的)比,都為true,因為會把Integer自動拆箱為int再去比較
System.out.println("int i1 = 129 == Integer i2= 129 :" + (i1 == i2));
Integer i3 = new Integer(129);
// Integer與new Integer不會相等。不會經歷拆箱過程,i3與i2指向是兩個不同的地址,為false
System.out.println("Integer i2= 129 == Integer i3 = new Integer(129) :" + (i2 == i3));
Integer i4 = new Integer(129);
// 兩個都是new出來的,開辟不同的內存空間,都為false
System.out.println("Integer i3 = new Integer(129) == Integer i4 =new Integer(129) :" + (i3 == i4));
Integer i5 = 129;
/*
* Integer i2 = 129 會被編譯成Integer.valueOf(129) 而valueOf的源碼如下 public static
* Integer valueOf(int i) { * assert IntegerCache.high >= 127;
* 如果值在-128到127之間,直接從緩存取值,不需要重新創建
* if (i >= IntegerCache.low && i <=IntegerCache.high)
* return IntegerCache.cache[i + (-IntegerCache.low)];
* return new Integer(i); * }
*/
// 兩個都是非new出來的Integer,如果數在-128到127之間,則是true,否則為false,超過範圍不會在常量池裏取,會重新創建兩個Integer,==比較Integer的值,即是地址,肯定false
System.out.println("Integer i2= 129 == Integer i5 = 129 :" + (i2 == i5)); i2 = 127; i5 = 127; // 在-128到127之間,從常量池取同一個引用給Integer,肯定是true
System.out.println("Integer i2= 127 == Integer i5 = 127 :" + (i2 == i5));
}
}
HotSpot 內存管理裏,新生代 80% 的對象生命周期較短,GC 頻率高,適合采用效率較高的復制算法,經歷了多次 GC 仍然存活的對象或者一些超過設定值大小的對象會分配到老年代,老年代 GC 頻率較低,適合使用“標記 - 清除 - 壓縮”這種綜合的算法。
回收算法還有回收方式的不同,串行回收(Serial),是指就算有多個 CPU 核,某一時刻也只能有一個 CPU 核可以執行垃圾回收線程,此時用戶線程會被掛起處於暫停狀態,只有等回收線程執行完畢,用戶線程才能繼續執行,也就是會產生所謂的 Stop-the-world,JVM 短時間內卡頓不會工作。
並行回收是指某一時刻可以由多個 CPU 核同時執行各自的垃圾回收線程,不過一樣會出現 Stop-the-world,而並發回收是指用戶線程和垃圾回收線程交替執行,大大縮短 Stop-the-world 的停頓時間。現在大型項目動輒使用上百 G 的內存,內存越大,回收時間越久,而 Stop-the-world 的卡頓時間也會越久,目前還沒有算法可以做到零停頓的。
算法的思想講完了,下面就講垃圾收集算法的具體實現垃圾收集器。
上面紅色橫線的地方就是安全點,用戶線程執行時,要到達了安全點,才能暫停,讓回收線程執行;當觸發回收線程執行時,不會直接中斷用戶線程,而是設置一個標誌位,讓用戶線程輪詢。發現為中斷標誌時就運行到最近的安全點再將自己掛起讓 CPU 執行回收線程,但如果此時用戶線程處於 Waiting 或者 Blocked 狀態,無法輪詢標誌位,就會造成回收線程長時間無法運行的情況。
為此引入了安全區,安全區就是引用關系不會發生變化的代碼,在這段代碼的任何地方發起 GC 都是安全的。所以當用戶線程進入到安全區,恰好這時回收線程要執行就會直接中斷用戶線程,用戶線程離開安全區時,只需檢查回收線程是否已經完成,如果完成則可以離開,否則等待直到 GC 完畢。
3、HotSpot 垃圾回收器
3.1 新生代可用的垃圾回收器
Serial Coping(串行復制),Parallel Scavenge(並行復制),ParNew(並發復制)這三種回收器都是基於復制算法,復制 young eden 和 young from 中還存活的對象到 young to,或者根據設定對象的大小和 GC 次數直接晉升到 old,清空 young eden 和 young from 中的垃圾,下一次 GC 發生交換 young from 和 young to,只可使用於新生代,是在 young eden 內存空間不足以分配給對象時觸發 Minor GC(新生代垃圾回收)。
3.2 老年代可用垃圾回收器
-
Serial Old (串行標記-清理-壓縮)
-
Parallel Old(並行標記-壓縮)
- CMS Concurrent Mark-Sweep(並發標記清除)
3.3 垃圾收集器的組合
- Serial Coping(串行復制)
適合客戶端工作,不適合在服務器運行,針對單 CPU,小新生代,不太在乎暫停時間的應用,可通過- XX:+UseSerialGC手動指定新生代使用 Serial Coping(串行復制)收集器,老年代使用 Serial Old (串行標記 - 清理 - 壓縮)收集器執行內存回收。
- ParNew(並發復制)
是 Serial Coping(串行復制)的多線程版本,在多 CPU 核情況下可以提高收集能力,但如果是單 CPU 條件下,還要來回切換任務,不一定比 Serial Coping(串行復制)收集能力強,通過- XX:+UseParNewGC手動指定新生代使用 ParNew(並發復制)收集器,老年代使用 Serial Old (串行標記 - 清理 - 壓縮)收集器執行內存回收。
- Parallel Scavenge(並行復制)
跟 ParNew(並發復制)相比更註重於吞吐量而不是低延遲,如果吞吐量優先,必然會降低 GC 的頻次,也就造成 GC 回收垃圾量更多、時間更長。如果低延遲優先,為了降低每次的暫停時間,就得高頻的回收,這頻繁的回收又會導致吞吐量的下降,所以吐吞量和低延遲是對矛盾體,適合多 CPU、高 IO 密集操作、高計算消耗的應用,通過XX:+UseParallelGC手動指定新生代使用 Parallel Scavenge(並行復制)收集器,老年代使用 Serial Old (串行標記 - 清理 - 壓縮)收集器執行內存回收。
- Serial Old (串行標記 - 清理 - 壓縮)
單線程串行回收,停頓時間長,可以使用
- XX:+PrintGCApplicationStoppedTime
查看暫停時間,適合客戶端使用,不會產生內存碎片
- Parallel Old(並行標記 - 壓縮)
根據 GC 線程數劃分若幹區域(Region),並行做標記,重新掃描,定位到需要壓縮的 Region,統計 Region 裏所有存活對象的下次要移動的目的地地址,然後並行的往一端壓縮,不產生內存碎片,整理後的空閑區域是連續的,通過- XX:+UseParallelOldGC手動指定新生代使用 Parallel Scavenge(並行復制)收集器,老年代使用 Parallel Old(並行標記 - 壓縮)收集器執行內存回收。
- CMS Concurrent Mark-Sweep(並發標記清除)
第一階段是初始標記,需要 Stop-the-world,這階段標記出那些與根對象集合所連接的不可達的對象,標記完就會被暫停的應用線程;
第二階段是並發標記,這階段是應用線程和回收線程交替執行,把第一步標記為不可達的對象標記為垃圾對象,由於是交替進行,一開始被標記為垃圾的對象,後面應用線程可能更改對象的引用關系導致標記錯誤;
所以第三階段重新標記,需要 Stop-the-world,修正上個階段由於對象引用或者新對象創建導致的標記錯誤,這階段只有回收線程執行,確保修正的正確性。
經過三個階段的標記,第四個階段會並發的清除無有的對象釋放內存,這階段是應用線程和回收線程交替執行,如果用戶應用線程產生了新的垃圾(浮動垃圾),只能留到下次 GC 進行回收,極端情況如果產生的新的垃圾,而老年代的預留空間又不夠,就會產生 Concurrent Mode Failure,這個時候只能通過後備的 Serial Old (串行標記 - 清理 - 壓縮)來進行垃圾回收。
又因為 CMS 並沒有用到壓縮算法,回收後會產生內存碎片,為新對象分配內存無法使用 Bump-the-pointer(指針碰撞)技術實現快速內存分配,只能使用空閑列表(Free List :JVM 會維護一張可用內存地址的列表,當需要分配空間,就從列表搜索一段和對象大小一樣的連續內存塊用於存放要生成的對象實例)方式分配內存。
但也可以通過- XX:CMSFullGCsBeforeCompaction,用於指定經過多少次 Full GC 後對內存碎片整理壓縮,由於內存碎片不是並發執行,會帶來更長的停頓時間,通過- XX:+UseConcMarkSweepGC設定新生代使用 ParNew(並發復制)收集器,老年代使用 CMS Concurrent Mark-Sweep(並發標記清除)收集器執行內存回收,當出現浮動垃圾導致 Concurrent Mode Failure 或者新對象分配內存失敗時,通過備用組合新生代使用 ParNew(並發復制)收集器,老年代使用 Serial Old (串行標記 - 清理 - 壓縮)收集器執行內存回收,適用於要求暫停時間短,追求快速響應的應用,如互聯網應用。
JVM回收需要註意的點:
-
在執行 Minor GC 的時候,JVM 會檢查老年代中最大連續可用空間是否大於了當前新生代所有對象的總大小,如果大於,則直接執行 Minor GC;
如果小於了,JVM 會檢查是否開啟了空間分配擔保機制;如果開啟了,則 JVM 會檢查老年代中最大連續可用空間是否大於了歷次晉升到老年代中的平均大小;
如果大於則會執行 Minor GC,如果小於則執行改為執行 Full GC,如果沒有開啟則直接改為執行 Full GC。 - 當老年代(Major GC)和永久代發生 GC 時,除了 CMS 外都會觸發 Full GC,Full GC 就是先按新生代 GC 方式進行 Minor GC,再按照老年代的配置進行 Major GC,包含對老年代和永久代進行 GC,若 JVM 估計 Minor GC 會產生晉升失敗,則會采用 Major GC 的配置進行 Full GC。
- 如果 Minor GC 執行失敗則會執行 Full GC。
- 吞吐量:應用運行時間/總時間,暫停時間:每次 GC 造成的暫停
- 分區分代增量式式收集器:G1(Garbage-First)收集器
傳統的分代收集也提供了並發收集,但最致命的是分代收集把整個堆空間劃分成固定間隔的內存塊,每次收集都很容易觸發 Full GC 從而掃描整個堆空間,這會拖慢應用,而且要對整個堆空間都做內存碎片整理會很麻煩。
而增量式的收集方式是一種新的收集思想,增量收集把堆空間劃分成一系列的小內存塊(內存塊大小可配置),使用時只使用部分內存塊,等這部分內存塊空間不足時,再把存活對象移動到未被使用過的內存塊,避免整個堆用完了再 Full GC,可以一邊使用內存一邊收集垃圾。
G1 收集器將整個 Java 堆區分成約 2048 大小相同的 Region 塊(分新生 Region 塊、幸存 Region 塊、老年 Region 塊),Region 塊大小在 1MB 到 32MB 之間,每個對象會被分配到 Region 塊裏,既可以被塊內對象引用也可以被塊外對象引用,在判斷對象是否存活時,為了避免全堆掃描或者遺漏,是通過 Remembered Set 來檢查 Reference 引用的對象是否存在不同的 Region 塊中的。G1 在收集垃圾時,會對各個 Region 塊的回收價值和成本做排序,根據用戶配置的期望停頓時間來進行回收。
G1 收集器與 CMS 收集器執行過程類似。初始標記階段,Stop-the-World,標記 GC Roots 可直接訪問到的對象,為下一個階段並發標記時,和應用線程交替執行時,有正確可有的 Region 來分配新建對象,並發標記階段識別上個階段標記對象的下層對象的活躍狀態,找出存活的對象,也就是標記 GC Roots 可達對象;
最終標記階段,Stop-the-World,修正上次線程交替執行產生的變動;
清除復制階段,Stop-the-World,這階段並不是最終標記執行完了就一定執行,畢竟是要 Stop-the-World,為了達到準實時(可配置在 M 毫秒內最多只占用 N 毫秒的時間進行垃圾回收)會根據用戶配置的 GC 時間去決定是否做清除。
還有,因為清除復制階段使用的是復制算法,每次清理都必須保證”to space” 空間是足夠的(將存活的對象復制到未使用的 Region 塊),所以只有已用空間達到了(1-h)*堆大小(h 是 G1 定義的一個堆空間的百分比閾值,是個固定值)才執行清除,把存活的對象往一個方向移動到”to space” 並整理內存,不會產生內存碎片。
接著把”Eden space” “from space” 的垃圾對象清理,根據維護的優先列表,優先回收價值最大的 Region,通過五個階段完成垃圾收集,可以通過設定 - XX:UseG1GC 在整個 Java 堆使用 G1 進行垃圾回收,G1 適合高吞吐、低延時、大堆空間的應用。
【文章福利】
小編推薦一個Java交流群:937053620,群內提供設計模式、spring/mybatis源碼分析、高並發與分布式、微服務、性能優化,面試題整理合集等免費資料!
性能優化之 JVM 高級特性