深入理解 Java 虛擬機(jī) - 自動內(nèi)存管理機(jī)制

本系列文章的知識來源于周志明的《深入理解 Java 虛擬機(jī)》一書,內(nèi)容經(jīng)過了自己的加工整理和刪減。

1. 走近 Java


JDK(Java Development Kit)包含三部分:Java、JVM、Java API 類庫

JRE(Java Runtime Enviroment)包含兩部分:Java API 類庫中的 Java SE API 子集、JVM

最常見的 Java 虛擬機(jī)有:

  • HotSpot VM:最常用的 JVM,有熱點代碼探測和 OSR(棧上替換)功能,可以在運行時觸發(fā) JIT 編譯器進(jìn)行優(yōu)化和產(chǎn)生本地機(jī)器代碼。Oracle 公司在收購了 JRockit 之后,還移植了一些 JRockit 的特性到 HotSpot VM 中。
  • JRockit VM:JRockit 專門用于服務(wù)器端,不包含解析器,全靠 JIT 編譯器。
  • J9 VM:IBM 的產(chǎn)品,與 HotSpot 的定位類似。

除此之外,還有特定硬件平臺專有的「高性能」JVM:Azul VM、Liquid VM。

還有個特殊的 VM 是 Dalvik VM,用于 Android 平臺。它不是一個 JVM,沒有遵守 JVM 的規(guī)范,不能直接運行 Java 的 Class 文件,使用的是寄存器架構(gòu)而不是 JVM 中常見的棧架構(gòu)。

Java 技術(shù)未來的幾個方向:

  • 模塊化:主要的技術(shù)有 OSGi 和 Java 9 中的 Jigsaw(拼圖)
  • 混合語言:JVM 上可以運行多種語言,譬如:Clojure、Scala、Groovy,因為它們都會被編譯成統(tǒng)一的字節(jié)碼
  • 多核并行:JDK 1.5 中引入了 java.util.concurrent,JDK 1.7 中加入了 java.util.concurrent.forkjoin,Java 8 中的 Lambda
  • 進(jìn)一步豐富語法:譬如自動裝箱、泛型、動態(tài)注解、可變長參數(shù)、遍歷循環(huán),還有 Coin 項目
  • 64 位虛擬機(jī):現(xiàn)在的 64 位 JVM 比 32 位的內(nèi)存消耗更多,性能差大約 15%,還有改進(jìn)空間

2. Java 內(nèi)存區(qū)域與內(nèi)存溢出異常


2.2 運行時數(shù)據(jù)區(qū)域

JVM 管理的內(nèi)存會包括以下運行時數(shù)據(jù)區(qū)域:

程序計數(shù)器(Program Counter Register)

這是一塊較小的內(nèi)存空間,可以看作是當(dāng)前線程所執(zhí)行的字節(jié)碼的行號指示器。每條線程有獨立的程序計數(shù)器,因此是「線程私有」的內(nèi)存。這塊區(qū)域是 JVM 規(guī)范中唯一沒有規(guī)定 OutOfMemoryError 的內(nèi)存區(qū)域。

Java 虛擬機(jī)棧(Java Virtual Machine Stacks)

這也是「線程私有」的,生命周期與線程相同。JVM Stack 描述的是 Java 方法執(zhí)行的內(nèi)存模型:每個方法在執(zhí)行時都會創(chuàng)建一個 Stack Frame(棧幀),存儲了局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口等。每個方法從調(diào)用到執(zhí)行完成的過程,就對應(yīng)一個 Stack Frame 入棧和出棧。

多數(shù)人把 Java 內(nèi)存區(qū)分為 Heap 和 Stack,這個 Stack 就是這里的 JVM Stack。

局部變量表存了 primitive types、reference type和 returnAddress type。

  • reference type 是對象的引用,這個不等同于引用的對象本身
  • returnAddress type 指向一條字節(jié)碼指令的地址

這個區(qū)域有兩種異常:StackOverflowError 和 OutOfMemoryError,導(dǎo)致的原因可能是單個棧幀太大,也可能是棧幀太多。方法遞歸的深度太深、建立的線程太多,都有可能產(chǎn)生大量的棧幀(方法調(diào)用),然后就有可能導(dǎo)致 StackOverflowError。

本地方法棧(Native Method Stack)

Native Method Stack 和 JVM Stack 類似,線程私有,能拋出的異常相同。HotSpot VM 不區(qū)分虛擬機(jī)棧和本地方法棧。

兩者區(qū)別是 JVM Stack 為字節(jié)碼服務(wù)、而 Native Method Stack 為 JVM 自身使用的 Native Method 服務(wù)。

Java 堆(Java Heap)

Java Heap 是 JVM 管理的內(nèi)存中最大的一塊,線程共享,唯一的目的就是存放對象實例。(JIT 編譯器可能使用逃逸分析技術(shù),將對象實例存放在 JVM Stack 中,隨線程終結(jié)而銷毀)

Java Heap 是 GC 管理的主要區(qū)域,因此也被稱為 GC Heap。

現(xiàn)在的 GC 采用的是分代收集算法,因此 Java Heap 可以分為:

  • 新生代:還能細(xì)分為 Eden 空間、From Survivor 空間、To Survivor 空間
  • 老年代

Heap 大小可以通過參數(shù)控制(-Xmx 和 -Xms)

如果堆內(nèi)存太小無法分配實例,并且堆也無法繼續(xù)擴(kuò)展,則拋出 OutOfMemoryError。如果創(chuàng)建了太多的實例對象,就有可能導(dǎo)致該異常。

方法區(qū)(Method Area)

這個區(qū)域也被稱為 Non-Heap,同樣是線程共享的,存儲的是 JVM 加載的類信息、常量、靜態(tài)變量、JIT 編譯后的代碼等。

HotSpot JVM 中也把這個區(qū)域稱為 Permanent Generation(永久代),因為 HotSpot 把 GC 的分代收集擴(kuò)展到了方法區(qū),JDK 1.7 中已經(jīng)逐步放棄永久代了。而 JRockit 和 J9 是沒有永久代的概念的,JVM 規(guī)范也沒有要求對方法區(qū)進(jìn)行 GC。

方法區(qū)還有一部分稱為 Runtime Constant Pool(運行時常量池),其中加載的是 Class 文件中的常量池(Constant Pool Table),存放的是編譯期生產(chǎn)的各種字面量和符號引用。如果常量過多,就有可能使常量池溢出,拋出 OutOfMemoryError。

方法區(qū)無法滿足內(nèi)存分配需求時,會拋出 OutOfMemoryError。Spring、Hibernate 這種框架在對類進(jìn)行增強(qiáng)的時候,會使用 CGLib、ASM 等字節(jié)碼技術(shù),生產(chǎn)大量的動態(tài)類;還有大量的 JSP 文件,基于 OSGi 的應(yīng)用等等,都有可能導(dǎo)致 OutOfMemoryError。

直接內(nèi)存(Direct Memory)

Direct Memory 不屬于 JVM 管理的內(nèi)存,NIO 類可以使用 Native 函數(shù)庫直接分配堆外內(nèi)存,這樣可以避免在 Java Heap 和 Native Heap 來回復(fù)制數(shù)據(jù),提高性能。

2.3 Java 堆中實例對象的創(chuàng)建、內(nèi)存布局和訪問

對象的創(chuàng)建

  1. 類加載:當(dāng) JVM 遇到 new 指令時,先檢查能否在常量池中定位到類的符號引用,然后檢查這個符號引用代表的類是否被加載、解析和初始化過,如果沒有,則先進(jìn)行類加載過程。

  2. 分配內(nèi)存:為新生對象分配內(nèi)存的過程等同于從 Java 堆中劃分一塊大小確定的內(nèi)存。如果 Java 堆內(nèi)存是絕對規(guī)整的,則使用「指針碰撞(Bump the Pointer)」方式,即用一個指針劃分空閑和占用的分界點,Serial、ParNew 這種帶 Compact 過程的收集器使用這種方式。如果 Java 堆不是規(guī)整的,則使用「空閑列表(Free List)」方式,這個列表標(biāo)記了哪些內(nèi)存塊是可用的,CMS 這種基于 Mark-Sweep 算法的收集器使用這種方式。為了處理并發(fā)的問題,有兩種方案:同步處理(CAS方式)、TLAB(本地線程分配緩沖,每個線程會預(yù)先分配一塊獨立的小內(nèi)存)

  3. 初始化零值:分配到的內(nèi)存空間都會初始化為零值(除了對象頭),設(shè)置默認(rèn)值等等不在這個階段完成,因為這些都是在字節(jié)碼中實現(xiàn)的。

  4. 對實例對象進(jìn)行必要的設(shè)置:主要是設(shè)置對象頭(Object Header)中的數(shù)據(jù)

從 JVM 的角度來看,這個時候新的對象已經(jīng)產(chǎn)生了。但從 Java 程序的角度來看,還需要執(zhí)行 init 指令,對這個「零值」的對象按照 Java 代碼中的方式進(jìn)行初始化。

對象的內(nèi)存布局

HotSpot VM 的對象頭(Object Header)包含兩部分信息:

  1. 對象自身的運行時數(shù)據(jù)(Mark Word):HashCode、GC 分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時間戳等等。

  2. 類型指針:類元數(shù)據(jù)的指針,也就是說通過這個指針來確定這個對象是哪個類的實例。

如果對象是個 Java 數(shù)組,還要在 Object Header 中記錄數(shù)組長度。

Object Header 之后的數(shù)據(jù)是實例對象真正存儲的有效信息,也就是各個字段內(nèi)容。這些字段的存儲順序會受到分配策略和 Java 源碼中定義的順序的影響。

對象的訪問定位

一個實例對象包括兩部分:

  1. 對象實例的數(shù)據(jù),存儲在 Java Heap
  2. 對象類型的數(shù)據(jù),存儲在 Method Area(Non-Heap)

而調(diào)用對象是通過 Stack 中的 reference 類型的數(shù)據(jù),reference 定位的方式有兩種:

  1. 句柄訪問:reference 存儲的是一個句柄的地址,這個句柄同時包含了這個對象實例數(shù)據(jù)和類型數(shù)據(jù)各自的指針。
  2. 直接指針訪問:reference 存儲的就是對象實例的地址,然后可以通過 Object Header 中的類型指針去 Method Area 訪問對象類型數(shù)據(jù)。HotSpot 采用的是這種形式。

使用句柄的好處是移動對象的時候只需要改變句柄中的實例數(shù)據(jù)指針,不需要改變 reference 本身。而直接指針訪問的好處就是訪問速度快。

3. 垃圾收集器與內(nèi)存分配策略


3.1 概述

Lisp 是第一門真正使用內(nèi)存動態(tài)分配和垃圾收集技術(shù)的語言。

我們?yōu)槭裁匆獙ψ詣拥?GC 和 內(nèi)存分配進(jìn)行監(jiān)控和調(diào)節(jié):

  1. 排查各種內(nèi)存溢出、內(nèi)存泄露問題
  2. 當(dāng)垃圾收集成為系統(tǒng)達(dá)到更高并發(fā)量的瓶頸

在 Java 內(nèi)存運行時區(qū)域的各個部分中,程序計算器、虛擬機(jī)棧、本地方法棧這三個區(qū)域的生命周期是和線程一樣的,內(nèi)存分配和回收都具備確定性,因此不需要考慮回收的問題,方法結(jié)束或線程結(jié)束時,內(nèi)存就跟隨著回收了。

而 Java 堆和方法區(qū)只有在程序運行時才知道會創(chuàng)建哪些對象,這些內(nèi)存的分配和回收都是動態(tài)的,并且是線程共享的。 GC 關(guān)注的就是這部分內(nèi)存。

3.2 對象存活判定算法

一個方法是「引用計數(shù)法(Reference Counting)」,這個方法很難解決對象之間相互循環(huán)引用的問題。少數(shù)語言會使用過這個方法。

主流語言(Java、C#、Lisp)使用的都是「可達(dá)性分析算法(Reachability Analysis)」,它從「GC Roots」對象作為起始點,從這些節(jié)點開始往下搜索,走過的路徑稱為「引用鏈(Reference Chain)」,如果從 GC Roots 到某個對象「不可達(dá)」時,這個對象就是不可用的。

Java 中可作為 GC Roots 的對象有:

  • JVM Stack(Stack Frame 的本地變量表)中引用的對象
  • Native Method Stack 中 JNI(Native 方法)引用的對象
  • Method Area 中類靜態(tài)屬性引用的對象
  • Method Area 中常量引用的對象

Java 中 Reference 有四種:

  • 強(qiáng)引用(Strong Reference):普遍存在的引用,類似「Object obj = new Object()」,只要強(qiáng)引用存在,GC 就不會回收被引用的對象
  • 軟引用(Soft Reference):在將要發(fā)生內(nèi)存溢出之前,就會回收軟引用的對象
  • 弱引用(Weak Reference):生存周期是當(dāng)前到下一次 GC 時刻,也就是說在下次 GC 就會回收掉弱引用的對象
  • Phantom Reference(虛引用或幽靈引用)

回收一個對象要經(jīng)過兩次標(biāo)記過程,第一次標(biāo)記的時候 JVM 會檢查對象是否有必要執(zhí)行 finalize() 方法(沒有覆蓋該方法或者已經(jīng)被調(diào)用過都被視為沒必要執(zhí)行),如果對象在執(zhí)行 finalize() 方法的過程中恢復(fù)了引用,拯救了自己,在第二次標(biāo)記的過程中就不會被回收,否則就回收。但是,finalize() 方法非常不建議用。

Method Area(或者說 HotSpot VM 中的永久代)都是可以有 GC 的,該區(qū)域主要回收兩部分內(nèi)容:

  • 廢棄常量:譬如 String 對象
  • 無用的類:判定無用的類條件比較苛刻,在大量使用反射、動態(tài)代理、CGLib 等字節(jié)碼生產(chǎn)技術(shù)、OSGi 等場景下就需要回收無用的類,以保證永久代不會溢出

3.3 垃圾收集算法

垃圾收集就是對被判定「死亡」的對象進(jìn)行內(nèi)存回收,算法大概有以下幾種:

Mark-Sweep(標(biāo)記 - 清除)算法

算法分為「標(biāo)記」和「清除」兩個階段,先標(biāo)記需要回收的對象,再統(tǒng)一清除所有被標(biāo)記的對象。這個算法的問題是:(1)效率不高(2)收集之后會產(chǎn)生大量不連續(xù)的內(nèi)存碎片

Copying(復(fù)制)算法

算法將內(nèi)存分為 A B 兩個大小相等的區(qū)域,先使用 A 區(qū),GC 的時候?qū)?A 區(qū)存活的對象 Copy 到 B 區(qū),然后清空 A 區(qū),使用 B 區(qū),再循環(huán),這樣不會產(chǎn)生內(nèi)存碎片,但每次可用的空間只有 50%。

這個算法被普遍用來回收新生代,因為據(jù)研究表明,新生代 98% 的對象是「朝生暮死」的,所以存活的對象很少,復(fù)制的代價較小。A B 兩個區(qū)實際也不用對等分,HotSpot 將內(nèi)存分為一塊 Eden 空間和兩塊 Survivor 空間,三者默認(rèn)比例是 8:1:1,每次只使用 Eden 和一塊 Survivor 空間,因此內(nèi)存使用率是 90%,GC 時存活的對象被 Copy 到另外一塊 Survivor 區(qū)域,如果存活的對象超過 10%,Survivor 空間不夠用,可以用老年代進(jìn)行分配擔(dān)保,這些「溢出」的對象將直接進(jìn)入老年代。

過程示例(E 表示 Eden,S0 和 S1 分別代表兩塊 Survivor,O 代表老年代):

  1. 使用 E 和 S0
  2. 對 E 和 S0 進(jìn)行 GC,存活的對象 Copy 到 S1 中(如果 S1 無法容納,則存活的對象直接進(jìn)入 O)
  3. 清空 E 和 S0
  4. 使用 E 和 S1
  5. 繼續(xù)循環(huán) ...

Copying 算法一般不能用于老年代,一是因為老年代對象的存活率高,復(fù)制的代價大;二是因為如果不想浪費 50% 的空間,就得有額外空間進(jìn)行分配擔(dān)保,但沒有額外空間給老年代進(jìn)行擔(dān)保。

Mark-Compact(標(biāo)記 - 整理)算法

這個算法是根據(jù)老年代的特定設(shè)計的,標(biāo)記過程和 Mark-Sweep 一樣,只是沒有清除過程,它將存活的對象向內(nèi)存區(qū)域的一端移動,然后清理掉存活對象區(qū)域邊界以外的內(nèi)存。

Generational Collection(分代收集算法)

目前的商用 JVM 都使用這種算法,實際就是把 Java Heap 分為新生代和老年代,根據(jù)這兩個區(qū)域不同的特定,選用前面幾種算法中最合適的。因此,新生代使用 Copying 算法,老年代使用 Mark-Sweep 或 Mark-Compact 算法。

3.4 HotSpot 的算法實現(xiàn)

前面提到的對象存活判定算法和垃圾收集算法,在實現(xiàn)的時候必須要考量執(zhí)行的效率,才能保證 VM 高效運行。

枚舉 GC Roots

在可達(dá)性分析過程中,必須要停頓所有 Java 執(zhí)行線程(Stop The World),才能保證「一致性」,繼而進(jìn)行枚舉 GC Roots,但 STW 會造成系統(tǒng)停頓。HotSpot 中用一個 OopMap 數(shù)據(jù)結(jié)構(gòu)來存哪些地方存放著對象引用,因此 VM 可以快速完成 GC Roots 枚舉。

Safepoint 和 Safe Region(安全點和安全區(qū)域)

(這一部分內(nèi)容沒仔細(xì)看,大致意思是程序只有在一些安全點或區(qū)域才能進(jìn)行 GC,或生產(chǎn) OopMap)

3.5 垃圾收集器

前面的垃圾收集算法是內(nèi)存回收的方法論,這里的垃圾收集器就是具體實現(xiàn),然而 JVM 規(guī)范沒有限定垃圾收集器的具體實現(xiàn),下面討論的收集器是基于 HotSpot VM 的,包含了以下七種收集器:

Serial 收集器

Serial 是個單線程的收集器,它只使用一條線程去完成垃圾收集工作,并且在收集時,必須「Stop The World」,可能帶來長時間的停頓,但它仍然是 VM 在 Client 模式下默認(rèn)的新生代收集器。

ParNew 收集器

ParNew 是 Serial 的多線程版本,它是 Server 模式下默認(rèn)的新生代收集器。

但是,ParNew 在單 CPU 的環(huán)境中不會比 Serial 有優(yōu)勢,因為線程交互是有開銷的,只有在多 CPU 多線程的環(huán)境下有優(yōu)勢。

選擇 Serial 或 ParNew 的其中一個重要原因是,目前只有這兩個收集器才能與老年代的 CMS 收集器配合工作。

Parallel Scavenge 收集器

這個收集器的目的是讓吞吐量(Throughput)可控,吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 + 垃圾收集時間)。這個收集器有兩個參數(shù)精確控制吞吐量:

  • 最大垃圾收集停頓時間:縮短停頓時間是以犧牲吞吐量和新生代空間來換取的
  • 吞吐量大小

用戶需要在停頓時間和吞吐量之間進(jìn)行權(quán)衡,這個時候也可以使用 UseAdaptiveSizePolicy 進(jìn)行自適應(yīng)調(diào)節(jié),將兩者都控制在一個合適的數(shù)值。

Parallel Scavenge 與 CMS 不兼容。

Serial Old 收集器

這個是 Serial 的老年代版本,使用「Mark - Compact」算法。主要是在 Client 模式下使用,在 Server 模式下有兩個用途:

  1. JDK 1.6 之前的版本與 Parallel Scavenge 配合使用
  2. 作為 CMS 收集器的后備預(yù)案,在并發(fā)收集發(fā)生 Concurrent Model Failure 時使用

Parallel Old 收集器

這個是 Parallel Scavenge 的老年代版本,JDK 1.6 開始出現(xiàn)。出現(xiàn)的主要原因是,如果新生代選擇了 Parallel Scavenge,老年代除了 Serial Old,沒有優(yōu)秀的收集器(Parallel Scavenge 與 CMS 不兼容)

因此,Parallel Scavenge 和 Parallel Old 組合可以運用在注重吞吐量以及 CPU 資源敏感的場合(Parallel Scavenge 是吞吐量優(yōu)先的,CMS 比較費 CPU 資源)。

CMS 收集器(Concurrent Mark Sweep)

CMS 的目標(biāo)是獲取最短的回收停頓時間,適合在服務(wù)端用?;凇窶ark - Sweep」算法實現(xiàn)。整個過程有四個步驟:

  1. initial mark(初始標(biāo)記):需要 STW,標(biāo)記 GC Roots 直接引用的對象,速度很快
  2. concurrent mark(并發(fā)標(biāo)記):對 GC Roots 進(jìn)行 Tracing,爬出所有的存活對象,耗時較長
  3. remark(重新標(biāo)記):需要 STW,修正并發(fā)標(biāo)記期間因為用戶線程繼續(xù)運作產(chǎn)生的變動,這個階段比初始標(biāo)記稍長,但遠(yuǎn)比并發(fā)標(biāo)記短
  4. concurrent sweep(并發(fā)清除):這個階段和并發(fā)標(biāo)記階段是整個過程耗時最長的,但這兩個過程都不需要 STW,可以和用戶線程一起工作。

CMS 的優(yōu)點是并發(fā)收集、低停頓。缺點有三個:

  1. 對 CPU 資源敏感。在并發(fā)階段,它會因為占用一部分線程(CPU 資源)而導(dǎo)致應(yīng)用程序變慢,總吞吐量降低。

  2. 無法處理浮動垃圾(Floating Garbage),可能出現(xiàn) Concurrent Model Failure,一旦這個異常出現(xiàn),就需要用 Serial Old 進(jìn)行一次 Full GC,停頓時間會很長。這個異常出現(xiàn)的原因是內(nèi)存回收過程和用戶線程是并發(fā)進(jìn)行的,因此在 CMS 標(biāo)記之后,會有新的垃圾產(chǎn)生,當(dāng)次的 GC 不會回收這行新垃圾,也就成了「浮動垃圾」。因此 CMS 不能像其他收集器一樣等到老年代幾乎填滿了再收集,需要多留些空間給程序運行時產(chǎn)生的浮動垃圾,如果留的空間不夠,就會產(chǎn)生 Concurrent Model Failure。如果大量產(chǎn)生該異常,性能反而會降低。

  3. CMS 是基于「Mark - Sweep」算法的,收集之后會產(chǎn)生大量內(nèi)存碎片,如果大對象無法找到連續(xù)的內(nèi)存空間,就需要提前進(jìn)行一次 Full GC。為了解決這個問題,CMS 還有關(guān)于 Compact 的設(shè)置。

G1 收集器(Garbage - First)

G1 是一款面向服務(wù)端的垃圾收集器,跟 CMS 一樣,立足于低停頓時間,但如果是追求吞吐量,G1 并沒有更好的表現(xiàn)。G1 的特點如下:

  1. 并行與并發(fā):可以利用多核來縮短 STW 時間,部分 GC 過程可以與用戶線程并發(fā)執(zhí)行
  2. 分代收集:G1 可以同時管理新生代和老年代
  3. 空間整合:G1 從整體上來看是基于「Mark - Compact」算法的,不會產(chǎn)生內(nèi)存碎片
  4. 可預(yù)測的停頓:G1 可建立可預(yù)測的停頓時間模型,可以明確 M 毫秒的時間內(nèi),GC 的時間不能超過 N 毫秒

總結(jié)

對于高性能服務(wù)器(多核多 CPU),適合的組合有:

  1. 追求低停頓時間:ParNew + CMS(Serial Old 作為后備預(yù)案)
  2. 追求吞吐量或者 CPU 資源敏感:Parallel Scavenge + Parallel Old
  3. 未來的趨勢:G1

Client 模式下,適合的組合是:

  1. Serial + Serial Old

3.6 內(nèi)存分配與回收策略

自動內(nèi)存管理可以歸結(jié)為兩個問題:

  1. 自動給對象分配內(nèi)存
  2. 自動回收分配給對象的內(nèi)存

前面講的都是關(guān)于如何自動回收內(nèi)存,接下類就講下如何給對象分配內(nèi)存。

大體上來講,對象的內(nèi)存分配有以下原則:

從整個 JVM 管理的內(nèi)存區(qū)域來看:

  1. 正常是在 Heap 上分配
  2. 如果 JIT 優(yōu)化編譯之后,有可能在 Stack 上分配。

從 Heap 區(qū)域來看:

  1. 對象優(yōu)先在新生代的 Eden 區(qū)分配,如果 Eden 區(qū)沒有足夠的空間進(jìn)行分配,就會觸發(fā) Minor GC
  2. 如果啟用了 TLAB,則按線程優(yōu)先在 TLAB 上分配
  3. 大對象直接分配在老年代中,例如很長的字符串以及數(shù)組
  4. 長期存活的對象將進(jìn)入老年代:如果對象在 Eden 出生,經(jīng)過 Minor GC,并且能被 Survivor 容納,被移動到 Survivor 空間,年齡就增長一歲,當(dāng)歲數(shù)達(dá)到閾值,就進(jìn)入老年代。這個年齡判斷也可以是動態(tài)的,譬如按一定比例來判定
  5. 空間分配擔(dān)保:Copying 收集算法中,如果 Survivor 空間無法容納存活的對象,就會將存活的對象移動到老年代中,但這個時候也要保證老年代有足夠的連續(xù)可用空間。因此,JVM 在 Minor GC 發(fā)生之前,會檢查老年代最大可用的連續(xù)空間是否大于新生代所有對象的總空間,如果成立,則 Minor GC 確保是安全的,如果不成立,則 Minor GC 是有風(fēng)險的,這個時候如果設(shè)置不允許冒險,則要進(jìn)行 Full GC 來讓老年代騰出更多空間;如果允許冒險,會根據(jù)以往數(shù)據(jù)再次檢查,如果這次檢查通過,則進(jìn)行冒險的 Minor GC,如果沒通過,則還是進(jìn)行 Full GC。

Minor GC 和 Full GC 的區(qū)別:

  • Minor GC:發(fā)生在新生代的 GC,Minor GC 非常頻繁,回收速度也快
  • Full GC / Major GC:發(fā)生在老年代的 GC,速度一般比 Minor GC 慢 10 倍以上。

4. 虛擬機(jī)性能監(jiān)控與故障處理工具


JDK 的命令行工具都在 bin 目錄中,常用的有:

  1. jps
  2. jstat
  3. jinfo
  4. jmap
  5. jstack
  6. HSDIS

可視化工具有:

  1. JConsole:Java 監(jiān)視與管理控制臺
  2. VisualVM:多合一故障處理工具

5. 調(diào)優(yōu)案例分析


高性能硬件上的程序部署策略

目標(biāo)環(huán)境:4 個 CPU、16 GB 內(nèi)存、64 位操作系統(tǒng)

在高性能硬件上部署程序,主要有兩種方式:

  1. 64 位 JDK 來使用大內(nèi)存
  2. 用若干 32 位 JVM ,搭配負(fù)載均衡器,建立邏輯集群來利用硬件資源

64 位 JDK 部署需要有把握把應(yīng)用程序的 Full GC 頻率控制得足夠低,最好是一天一次,在深夜執(zhí)行定時任務(wù)來觸發(fā) Full GC 或是自動重啟應(yīng)用服務(wù)器。控制 Full GC 頻率的關(guān)鍵是控制老年代空間的增長,絕大多數(shù)對象要符合「朝生暮死」的特定,不能有成批、長生存時間的大對象產(chǎn)生,這樣才能保障老年代空間的穩(wěn)定。

使用 64 位 JDK 有可能面臨以下問題:

  1. Full GC 導(dǎo)致的長時間停頓
  2. 64 位 JDK 性能普遍低于 32 位的
  3. 相同程序在 64 位 JDK 中消耗的內(nèi)存比 32 位的大

對于第二種方式,使用邏輯集群,要保證負(fù)載均衡器將一個固定的用戶請求永遠(yuǎn)分配到固定的一個集群節(jié)點進(jìn)行處理,適合使用無 Session 復(fù)制的親合式集群。

使用邏輯集群也可能面臨一些問題:

  1. 節(jié)點競爭全區(qū)的資源,譬如磁盤競爭,容易導(dǎo)致 IO 異常
  2. 很難高效利用某些資源池:譬如連接池,一般各個節(jié)點有自己獨立的連接池
  3. 受內(nèi)存限制:32 位 Windows 平臺每個進(jìn)程最高只能 2GB 內(nèi)存,堆一般最多到 1.5 GB
  4. 大量使用本地緩存(譬如用 HashMap 作為 KV 緩存)的應(yīng)用,會造成較大的內(nèi)存浪費

最后的部署方案是:5 個 32 位 JDK 的邏輯集群,每個進(jìn)程按 2GB 內(nèi)存技術(shù)(其中堆固定為 1.5 GB),另外考慮到用戶對響應(yīng)速度比較關(guān)心,CPU 資源敏感度較低,用了 CMS 收集器進(jìn)行垃圾回收。

堆外內(nèi)存導(dǎo)致的溢出錯誤

給 JVM 分配內(nèi)存的時候,也要考慮留給系統(tǒng)和 Direct Memory 的空間。Direct Memory 不能像新生代、老年代那樣,發(fā)現(xiàn)空間不足就通知 GC 進(jìn)行回收,只能等老年代滿了后進(jìn)行 Full GC,「順便」幫它清理下內(nèi)存。

外部命令導(dǎo)致系統(tǒng)緩慢

JVM 中調(diào)用外部 Shell 腳本是非常消耗資源的操作,要改為調(diào)用 Java 相關(guān)的 API 來完成相同的功能。

服務(wù)器 JVM 進(jìn)程崩潰

兩臺服務(wù)器間使用異步的方式遠(yuǎn)程調(diào)用對方的服務(wù),如果兩者服務(wù)速度不對等,譬如生產(chǎn)的速度遠(yuǎn)大于消費的速度,則可能隨著時間變長就累積越來越多的等待線程和 Socket 連接。這種情況將異步調(diào)用改為生產(chǎn)者/消費者模式的消息隊列即可。

不恰當(dāng)數(shù)據(jù)結(jié)構(gòu)導(dǎo)致內(nèi)存占用過大

目標(biāo)環(huán)境:后臺 RPC 服務(wù)器,64 位 JVM,-Xms4g -Xmx8g -Xmn1g,ParNew + CMS 收集器組合

問題:正常情況下 Minor GC 在 30 毫秒以內(nèi)可以接受。但是,業(yè)務(wù)上每 10 分鐘加載一個 80 MB 的數(shù)據(jù)文件,然后在內(nèi)存中會產(chǎn)生超過 100 萬個 HashMap<Long, Long> Entry,這個時候 Minor GC 會產(chǎn)生超過 500 毫秒的停頓。

原因:Minor GC 后,新生代大多數(shù)都是存活的,不符合「朝生暮死」的特點,導(dǎo)致 ParNew 這種基于 Copying 的算法復(fù)制的開銷很大

治標(biāo)不治本的辦法:因為 Minor GC 對象的存活率很高,Survivor 空間也無法容納存活的對象,因此可以去掉 Survivor 空間,讓新生代存活的對象在第一次 Minor GC 后就進(jìn)入老年代,等到老年代滿的時候再 Full GC 清理掉。


注:

  1. JVM 是 Java Virtual Machine(Java 虛擬機(jī))的縮寫
  2. Primitive types 指各種基本數(shù)據(jù)類型(boolean、byte、char、short、int、float、long、double)
  3. GC 是 Garbage Collect 或 Garbage Collection 的縮寫
  4. STW 是 HotSpot 中「Stop The World」的縮寫
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容