目錄
- 前言
- 1. 自動記憶體管理
- 1.1 JVM運行時資料區
- 1.2 Java 記憶體結構
- 1.3 HotSpot 虛擬機創建物件
- 1.4 HotSpot 虛擬機的物件記憶體布局
- 1.5 訪問物件
- 2. 垃圾回收與記憶體分配
- 2.1 判斷物件是否存活
- 2.2 分代與記憶體分配、回收策略
- 2.3 垃圾回收演算法(GC 的演算法)
- 2.4 HotSpot 的演算法實作
- 2.5 垃圾收集器
- 3. JVM 引數配置
- 3.1 JVM 記憶體引數簡述
- 3.2 JVM 的 GC 收集器設定
- 4. JVM 性能調優案例分析
- 4.1 大記憶體硬體上的應用程式部署策略
- 4.2 集群間同步導致的記憶體溢位
- 4.3 堆外記憶體導致的溢位錯誤
- 4.4 外部命令導致系統緩慢
- 4.5 服務器虛擬機行程崩潰
- 4.6 不恰當資料結構導致記憶體占用過大
- 4.7 由 Windows 虛擬記憶體導致的長時間停頓
- 4.8 由安全點導致長時間停頓
- 4.9 調優總結
- 最后
前言
參考資料:
《深入理解 Java 虛擬機 - JVM 高級特性與最佳實踐》
第1部分主題為自動記憶體管理,以此延伸出 Java 記憶體區域與記憶體溢位、垃圾收集器與記憶體分配策略、引數配置與性能調優等相關內容;
第2部分主題為虛擬機執行子系統,以此延伸出 class 類檔案結構、虛擬機類加載機制、虛擬機位元組碼執行引擎等相關內容;
第3部分主題為程式編譯與代碼優化,以此延伸出程式前后端編譯優化、前端易用性優化、后端性能優化等相關內容;
第4部分主題為高效并發,以此延伸出 Java 記憶體模型、執行緒與協程、執行緒安全與鎖優化等相關內容;
本系列學習筆記可看做《深入理解 Java 虛擬機 - JVM 高級特性與最佳實踐》書籍的縮減版與總結版,想要了解細節請見紙質版書籍;
1. 自動記憶體管理
1.1 JVM運行時資料區

- 執行緒共享資料區:
- 方法區(Non-Heap 非堆):存盤已被 Java 虛擬機加載的
類資訊、常量、靜態變數,即時編譯器編譯后的代碼等資料,當方法區無法滿足記憶體分配需求時,拋出 OutOfMemoryError 例外;- 運行時常量池:存放編譯期生成的各種字面量和符號參考;
- Java 堆(Java Heap):記憶體中最大的一塊,存放
物件實體和陣列,是垃圾收集器管理的主要區域,可能劃分出多個執行緒私有的分配緩沖區,目的是為了更好的回收記憶體,或者更快的分配記憶體,可以處于物理上不連續的記憶體空間中,沒有記憶體可以完成實體分配,并且堆也無法再擴展時,將會拋出 OutOfMemoryError 例外;
- 方法區(Non-Heap 非堆):存盤已被 Java 虛擬機加載的
- 執行緒獨立資料區:
- 程式計數器(Program Counter Register 執行緒計數器):執行緒正在執行 Java 方法時,保存
虛擬機位元組碼指令的地址,否則 Undefined,Java 虛擬機的多執行緒是通過執行緒輪流切換并分配處理器執行時間的方式來實作的,每條執行緒都有一個獨立的程式計數器,各個執行緒之間計數器互不影響,獨立存盤,是虛擬機中唯一沒有規定 OutOfMemoryError 情況的區域; - 本地方法堆疊(Native Method Stack):為虛擬機使用到的 Native 方法服務,原始碼是 C 和 C++;
- Java 虛擬機堆疊(Java Virtual Machine Stacks):生命周期和執行緒一致,存盤 Java 方法執行的記憶體模型:每個方法在執行的同時都會創建一個
堆疊幀(Stack Frame)用于存盤區域變數表、運算元堆疊、動態鏈接、方法出口等資訊;- 區域變數表:存放
方法引數和方法內定義的區域變數,存放編譯期可知的各種基本型別(boolean、byte、char、short、int、float、long、double)、物件參考(reference 型別,能找到物件在 Java 堆中的資料存放的起始地址索引,與物件所屬資料型別在方法區中存盤的型別資訊)、returnAddress型別(指向了一條位元組碼指令的地址),執行緒安全,通過索引定位方式使用區域變數表,容量以變數槽(slot)為最小單位;- slot 可以復用,但可能會導致 GC 問題:大物件復用時會作為 GC Roots的一部分,當它的其中一個區域變數超過作用域時,理應回收大物件,但由于 slot 復用保持著大物件的參考,導致 GC 無法回收;
- 運算元堆疊:運算元堆疊是用來操作的,堆疊中的資料元素必須與位元組碼指令的序列嚴格匹配,在概念模型中,兩個堆疊幀是相互獨立的;在實際實作中,上下兩個堆疊幀可能會出現一部分重疊,以實作資料共享;
- 動態鏈接:鏈接到別的方法中去,用來存盤鏈接的地方,動態體現在:在每一次運行期間轉化為直接參考,而不是第一次類加載階段(靜態決議);
- 方法出口:有兩種出口:
- 正常 return:方法呼叫者的程式計數器的值可以作為回傳地址;
- 不正常拋出例外:需要通過例外處理表來確定出口;
- 附加資訊:虛擬機規范允許具體的虛擬機實作增加一些規范里沒有描述的資訊到堆疊幀中,例如與除錯相關的資訊,由虛擬機自行實作;
- 區域變數表:存放
- 程式計數器(Program Counter Register 執行緒計數器):執行緒正在執行 Java 方法時,保存

1.2 Java 記憶體結構

- 直接記憶體(Direct Memory):非虛擬機運行時資料區的部分,不是 Java 虛擬機規范中定義的記憶體區域;
- 應用:JDK1.4 中新加入了NIO(New Input/Output)類,引入了一種基于通道(Channel)與緩沖區(Buffer)的 I/O 方式,可以使用 Native 函式庫直接分配堆外記憶體,然后使用一個存盤在 Java 堆中的 DirectByteBuffer 物件作為這塊記憶體的參考進行操作,避免了在 Java 堆和 Native(本地)堆中來回復制資料;
- 記憶體區域總和大于物理記憶體限制從而導致動態擴展時出現 OutOfMemoryError 例外;
- 直接記憶體與堆記憶體的區別:
- 直接記憶體:ByteBuffer.allocateDirect();
- 非直接記憶體:ByteBuffer.allocate();
- 直接記憶體申請空間
耗費高性能,堆記憶體申請空間耗費比較低; - 直接記憶體的
IO 讀寫的性能優于堆記憶體,在多次讀寫操作的情況相差非常明顯;
- JVM 位元組碼執行引擎:核心組件,負責執行虛擬機的位元組碼;
- 垃圾收集系統:垃圾收集系統是 Java 的核心,垃圾指沒有參考指向的記憶體物件;
1.3 HotSpot 虛擬機創建物件
- 1. 判斷是否加載:遇到 new 指令時,首先檢查這個指令的引數是否能在常量池中定位到一個類的符號參考,并且檢查這個符號參考代表的類是否已經被加載、決議和初始化過,如果沒有,執行相應的類加載;
- 2. 分配記憶體:類加載檢查通過之后,為新物件分配記憶體(在堆里,記憶體大小在類加載完成后便可確認),在堆的空閑記憶體中劃分一塊區域(有兩種:‘指標碰撞’——serial、ParNew 演算法;‘空閑串列’——CMS 演算法);
- 這里可能會有并發執行緒安全問題,多個個執行緒同時分配同一塊記憶體,兩種解決方法:對分配記憶體的動作進行同步處理(采用 CAS 配上失敗重試保證原子操作),或者采用:根據執行緒不同劃分不同的記憶體緩沖區執行記憶體分配操作;
- 3. 初始化值:記憶體空間分配完成后會初始化為 0(不包括物件頭),然后填充物件頭(哪個類的實體、何找到類的元資料資訊、哈希碼、GC 分代年齡等);
- 4. 執行 init 方法:賦實際值,程式員可控;
1.4 HotSpot 虛擬機的物件記憶體布局
- 物件頭(Header):包含兩部分:
- 用于存盤物件自身的運行時資料:哈希碼、GC 分代年齡、鎖狀態標志、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等;
- 型別指標:物件指向它的類的元資料指標,確定是哪個類的實體;
- 陣列在物件頭中還必須有一塊用于記錄陣列長度的資料(普通物件可以通過元資料確定大小);
- 實體資料(Instance Data):程式代碼中所定義的各種型別的欄位內容(包含父類繼承下來的和子類中定義的)
- 對齊填充(Padding):不是必然需要,主要是占位,保證物件大小是某個位元組的整數倍;
1.5 訪問物件
- 通過堆疊上的 reference 資料(在 Java 堆中)來操作堆上的具體物件:
- reference 存盤的是句柄地址:好處是在物件移動(GC)時只改變實體資料指標地址;
- reference 中直接存盤物件地址:好處是速度快(只需一次指標尋址);


2. 垃圾回收與記憶體分配
垃圾回識訓制的缺點:是否執行,什么時候執行卻是不可知的;
2.1 判斷物件是否存活
- 參考計數法:
- 如果一個物件沒有被任何參考指向,則可視之為垃圾;
- 主流的Java虛擬機里面都沒有選用參考計數演算法來管理記憶體;
- 缺點:不能解決回圈參考問題;
- 可達性分析法:(主流)
- 從 GC Roots 開始向下搜索,搜索所走過的路徑為參考鏈,當一個物件到 GC Roots 沒用任何參考鏈時,則證明此物件是不可用的,表示可以回收,實際上一個物件的真正死亡至少要經歷兩次標記程序;
- GC Roots 的物件:
- 虛擬機堆疊(堆疊幀中的本地變數表)中參考的物件;
- 方法區中
類靜態屬于參考的物件; - 方法區中
常量參考的物件; - 本地方法堆疊中 JNI(即一般說的 Native方法)參考的物件;
- 目前主流的虛擬機都是采用的演算法;
- 物件的四種參考:(JDK 1.2 之后,參考概念進行了擴充)
- 強參考:類似 new 關鍵字創建的參考,只要強參考在就不回收;
- 軟參考:SoftReference 類實作,發生記憶體溢位例外之前,會把這些物件列進回收范圍;
- 弱參考:WeakReference 類實作,在垃圾收集器作業時,無論記憶體是否足夠都會回收;
- 虛參考:PhantomReference 類實作,無法訪問實體,唯一目的是在這個物件被收集器回收時收到一個系統通知;
2.2 分代與記憶體分配、回收策略
- 相關代碼:
- 手動回收垃圾:
System.gc() - 執行 GC 操作呼叫:
Object.finalize()
- 手動回收垃圾:
- 分代:
- 方法區:永久代,不容易回收,主要回收
廢棄的常量(沒有該常量的參考)和無用的類(所有實體已回收、該類的 ClassLoader 已回收、無法通過反射訪問); - Java 堆:新生代 + 老年代,默認新生代與老年代的比例的值為 1:2;
- 老年代(2/3):物件存活率高、沒有額外空間對它進行分配擔保,
“標記-清理”演算法或者“標記-整理”演算法,大物件、長期存活物件分配在老年代; - 新生代(1/3):Eden + From Survivor + To Survivor,默認的 Edem : From Survivor : To Survivor = 8 : 1 : 1,JVM 每次只會使用 Eden 和其中的一塊 Survivor 區域來為物件服務,剩余的存放回收后存活的物件(與
復制演算法有關);- Eden(4/15):資料會首先分配到 Eden 區,Eden沒有足夠空間的時候就會觸發 JVM 發起一次 Minor GC,存活則進入 Survivor;
- From Survivor(1/30) 和 To Survivor(1/30):物件每熬過一次 Minor GC 還存活則年齡加1,當年齡達到(默認為15)時晉升到老年代;
- 老年代(2/3):物件存活率高、沒有額外空間對它進行分配擔保,
- 方法區:永久代,不容易回收,主要回收
- 幾種分代 GC:
- Minor GC:新生代 GC,執行頻繁,回收速度快;
- 觸發條件:Eden 區滿,
- Major GC:老年代 GC,通常會連著 Minor GC 一起執行,速度慢;
- 觸發條件:晉升老年代物件大小 > 老年代剩余空間,Minor GC 后存活物件大小 > 老年代剩余空間,永久代空間不足,執行 System.gc(),CMS GC例外,堆記憶體分配很大物件,
- Full GC:清理整個堆空間,包括新生代和老年代,Full GC 相對于Minor GC來說,停止用戶執行緒的 STW(stop the world)時間過長,應盡量避免;
- 觸發條件:System.gc() 方法呼叫,晉升老年代物件大小 > 老年代剩余空間,Metaspace區記憶體達到閾值(JDK8 引入,使用本地記憶體),堆中產生大物件超過閾值,老年代連續空間不足,
- Minor GC:新生代 GC,執行頻繁,回收速度快;
- 動態物件年齡判定:在 Survivor 空間中相同年齡 x 的物件總大小 > Survivor 空間的一半時,年齡大于等于 x 的物件將直接進入老年代;(HotSpot 虛擬機)
- 空間分配擔保:
- JDK 6 Update 24 之前:
老年代可用連續空間大小 < 新生代物件總大小時,查看相關引數判斷是否允許擔保失敗,允許則判斷是否:年代可用連續空間大小 > 歷次晉升老年代物件平均大小,成立則進行 Major GC(有風險);不成立說明老年代可用連續空間很少,進行 Full GC,或者不允許擔保失敗也會進行 Full GC; - JDK 6 Update 24 之后:
老年代可用連續空間大小 > 新生代物件總大小或老年代可用連續空間大小 > 次晉升老年代物件平均大小時,進行 Major GC,反之進行 Full GC;
- JDK 6 Update 24 之前:
2.3 垃圾回收演算法(GC 的演算法)
- 參考計數演算法(Reference counting):
- 每個物件在創建的時候,就給這個物件系結一個計數器,每當有一個參考指向該物件時,計數器加一,每當有一個指向它的參考被洗掉時,計數器減一,計數器為0就代表該物件死亡,這時就應該對這個物件進行垃圾回收操作;
- 主流的 Java 虛擬機里面都沒有選用參考計數演算法來回收垃圾;
- 標記–清除演算法(Mark-Sweep):
- 分為兩個階段,一個是標記階段,這個階段內,為每個物件更新標記位,檢查物件是否死亡,第二個階段是清除階段,該階段對死亡的物件進行清除,執行 GC 操作;
- 優點:必要時才回收,解決回圈參考的問題;
- 缺點:回收時,應用需要掛起,效率不高,會造成記憶體碎片;
- 應用:老年代(生命周期比較長);
- 標記–整理演算法:
- 在第二個清除階段,該演算法并沒有直接對死亡的物件進行清理,而是將所有存活的物件整理一下,放到另一處空間,然后把剩下的所有物件全部清除;
- 優點:解決記憶體碎片問題;
- 缺點:由于移動了可用物件,需要去更新參考;
- 應用:老年代(生命周期比較長);
- 復制演算法:
- 把空間分成兩塊,每次只對其中一塊進行 GC,當這塊記憶體使用完時,就將還存活的物件復制到另一塊上面,回圈下去,實際分為一塊 Eden 和兩塊 Survivor;
- 優點:存活物件不多時性能高,解決記憶體碎片和參考更新問題;
- 缺點:記憶體浪費,存活物件數量大時性能差;
- 應用:新生代(當回收時,將 Eden 和 Survivor 中還存活的物件一次性復制到另一塊 Survivor 上,最后清理 Eden 和 Survivor 空間);
- 分代演算法:(次要)
- 針對不同代使用不同的 GC 演算法;
2.4 HotSpot 的演算法實作
2.5 垃圾收集器
垃圾回收演算法是記憶體回收的理論,垃圾回收器是記憶體回收的實踐;

- 上圖說明:如果兩個收集器之間存在連線說明他們之間可以搭配使用;
- 垃圾收集器:
- 是垃圾回收演算法的具體實作,不同版本的 JVM 所提供的垃圾收集器可能會有很在差別;
- JDK8 的垃圾收集器:
- Serial:Client 模式下默認,一個單執行緒收集器,只會使用一個 CPU 或者執行緒去完成垃圾收集作業,而且在它進行垃圾收集時,必須暫停其他所有的作業執行緒,直到它收集結束,單執行緒收集高效;
- 作業區域:新生帶;
- 回收演算法:復制演算法;
- 作業執行緒:單執行緒;
- 執行緒并行:不支持;
- ParNew:可看做 Serial 的多執行緒版本,Server 模式下首選, 可搭配 CMS 的新生代收集器;
- 作業區域:新生帶;
- 回收演算法:復制演算法;
- 作業執行緒:多執行緒;
- 執行緒并行:不支持;
- Parallel Scavenge:目標是達到可控制的吞吐量(即:減少垃圾收集時間),吞吐量 Throughput = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間);
- 作業區域:新生帶;
- 回收演算法:復制演算法;
- 作業執行緒:多執行緒;
- 執行緒并行:不支持;
- Serial Old:Serial 老年代版本,Client 模式下的虛擬機使用;
- 作業區域:老年帶;
- 回收演算法:標記-整理演算法;
- 作業執行緒:單執行緒;
- 執行緒并行:不支持;
- Parallnel old:Parallel Scavenge 老年代版本,吞吐量優先;
- 作業區域:老年帶;
- 回收演算法:標記-整理演算法;
- 作業執行緒:多執行緒;
- 執行緒并行:不支持;
- CMS:一種以獲取
最短回收停頓時間為目標的收集器,適用于互聯網站或者 B/S 系統的服務端上,并發收集、低停頓,與用戶執行緒可以同時作業;- 作業區域:老年帶;
- 回收演算法:標記-清除演算法(記憶體碎片);
- 作業執行緒:多執行緒;
- 執行緒并行:支持;
- 缺點:對 CPU 資源敏感,無法收集浮動垃圾(Concurrent Mode Failure),記憶體碎片;
- 運作步驟:初始標記(標記 GC Roots 能直接關聯到的物件)、并發標記(進行 GC Roots Tracing)、重新標記;
- G1:最前沿成果之一,面向服務端應用的垃圾收集器,可看做 CM的終極改進版,JDK1.9 默認垃圾收集器,能充分利用多CPU、多核環境下的硬體優勢,可以并行來縮短(Stop The World)停頓時間,能獨立管理整個 GC 堆,采用不同方式處理不同時期的物件,
- 作業區域:新生帶 + 老年帶;
- 回收演算法:標記-整理 + 復制演算法;
- 作業執行緒:多執行緒;
- 執行緒并行:支持;
- 運作步驟:初始標記(標記 GC Roots 能直接關聯到的物件)、并發標記(進行 GC Roots Tracing)、重新標記;
- Serial:Client 模式下默認,一個單執行緒收集器,只會使用一個 CPU 或者執行緒去完成垃圾收集作業,而且在它進行垃圾收集時,必須暫停其他所有的作業執行緒,直到它收集結束,單執行緒收集高效;
3. JVM 引數配置
3.1 JVM 記憶體引數簡述
- 常用:
- -Xms:初始堆大小,JVM 啟動的時候,給定堆空間大小;
- -Xmx:最大堆大小,JVM 運行程序中,如果初始堆空間不足的時候,最大可以擴展到多少;
- -Xmn:設定堆中年輕代大小,整個堆大小=年輕代大小+年老代大小+持久代大小;
- -XX:NewSize=n 設定年輕代初始化大小大小;
- -XX:MaxNewSize=n 設定年輕代最大值;
- -XX:NewRatio=n 設定年輕代和年老代的比值,如: -XX:NewRatio=3,表示年輕代與年老代比值為 1:3,年輕代占整個年輕代+年老代和的 1/4 ;
- -XX:SurvivorRatio=n 年輕代中 Eden 區與兩個 Survivor 區的比值,注意 Survivor 區有兩個,8表示兩個Survivor :eden=2:8 ,即一個Survivor占年輕代的1/10,默認就為8;
- -Xss:設定每個執行緒的堆疊大小,JDK5后每個執行緒 Java 堆疊大小為 1M,以前每個執行緒堆疊大小為 256K;
- -XX:ThreadStackSize=n 執行緒堆疊大小;
- -XX:PermSize=n 設定持久代初始值;
- -XX:MaxPermSize=n 設定持久代大小;
- -XX:MaxTenuringThreshold=n 設定年輕帶垃圾物件最大年齡,如果設定為 0 的話,則年輕代物件不經過 Survivor 區,直接進入年老代;
- 不常用:
- -XX:LargePageSizeInBytes=n 設定堆記憶體的記憶體頁大小;
- -XX:+UseFastAccessorMethods 優化原始型別的 getter 方法性能;
- -XX:+DisableExplicitGC 禁止在運行期顯式地呼叫 System.gc(),默認啟用;
- -XX:+AggressiveOpts 是否啟用JVM開發團隊最新的調優成果,例如編譯優化,偏向鎖,并行年老代收集等,jdk6 之后默認啟動;
- -XX:+UseBiasedLocking 是否啟用偏向鎖,JDK6 默認啟用;
- -Xnoclassgc 是否禁用垃圾回收;
- -XX:+UseThreadPriorities 使用本地執行緒的優先級,默認啟用;
3.2 JVM 的 GC 收集器設定
- -XX:+UseSerialGC:設定串行收集器,年輕帶收集器;
- -XX:+UseParNewGC:設定年輕代為并行收集,可與 CMS 收集同時使用,JDK5.0 以上,JVM 會根據系統配置自行設定,所以無需再設定此值;
- -XX:+UseParallelGC:設定并行收集器,目標是目標是達到可控制的吞吐量;
- -XX:+UseParallelOldGC:設定并行年老代收集器,JDK6.0 支持對年老代并行收集;
- -XX:+UseConcMarkSweepGC:設定年老代并發收集器;
- -XX:+UseG1GC:設定 G1 收集器,JDK1.9 默認垃圾收集器;
4. JVM 性能調優案例分析
調優目的:GC 的時間足夠的小、GC 的次數足夠的少、發生 Full GC 的周期足夠的長;
問題原因:Full GC 的停止用戶執行緒的 STW 時間過長,應盡量避免;
Full GC 觸發條件:主要是兩個:老年代記憶體過小、老年代連續記憶體過小;
控制 Full GC 頻率的關鍵:保障老年代空間的穩定,大多數物件的生存時間不應當太長,尤其是不能有成批量的、長生存時間的大物件產生;
4.1 大記憶體硬體上的應用程式部署策略
- 場景簡述:原來有 16GB 物理記憶體(堆記憶體有 4GB),升級硬體配置后控制堆記憶體為 12GB,結構出現不定期長時間失去回應的問題;
- 場景特點:用戶互動性強、對停頓時間敏感、記憶體較大、Java堆較大;
- 問題原因:記憶體出現很多由檔案序列化產生的大物件,大物件大多在分配時就直接進入了老年代,Minor GC 清理不掉,最終導致導致老年代記憶體過小,經常發生 Full GC;
- 解決思路:通過減少單個行程的記憶體,減低老年代記憶體,使檔案序列化物件不易進入老年代,在 Minor GC 時就被清理;
- 實際方案:目前單體應用在較大記憶體的硬體上主要的部署方式有兩種:
- 方案一:通過一個單獨的 Java 虛擬機實體來管理大量的 Java 堆記憶體,具體來說:
- 1. 使用 Shenandoah、ZGC 這些明確以控制延遲為目標的垃圾收集器;
- 2. 在把 Full GC 頻率控制得足夠低的情況下(老年代的相對穩定),使用 Parallel Scavenge/Old 收集器,并且給 Java 虛擬機分配較大的堆記憶體;
- 方案二:使用多個 Java 虛擬機,建立邏輯集群來利用硬體資源,具體來說:
- 1. 在一臺物理機器上啟動多個應用服務器行程,為每個服務器行程分配不同埠,然后在前端搭建一個負載均衡器,以反向代理的方式來分配訪問請求;
- 2. 使用無 Session 復制的親合式集群,即:均衡器按一定的規則演算法(譬如根據 Session ID 分配)將一個固定的用戶請求永遠分配到一個固定的集群節點進行處理;(一致 hash 演算法的思想);
- 方案一:通過一個單獨的 Java 虛擬機實體來管理大量的 Java 堆記憶體,具體來說:
- 調優程序:
- 1. 發現問題:監控服務器運行狀況 -> 發現網站失去回應是由垃圾收集停頓所導致的;
- 2. 分析解決;
- 經驗之談:
- 1. 計劃使用單個 Java 虛擬機實體來管理大記憶體,可能遇到的問題:
- 回收大塊堆記憶體而導致的長時間停頓(G1 收集器緩解問題,ZGC 和 Shenandoah 收集器徹底解決);
- 大記憶體必須有 64 位 Java 虛擬機的支持,但由于壓縮指標、處理器快取行容量(Cache Line)等因素,64 位虛擬機的性能測驗結果普遍略低于相同版本的 32 位虛擬機;
- 必須保證應用程式足夠穩定,因為這種大型單體應用要是發生了堆記憶體溢位,幾乎無法產生堆轉儲快照(要產生十幾GB乃至更大的快照檔案),出了問題可能必須應用 JMC 這種能夠在生產環境中進行的運維工具;
- 相同的程式在 64 位虛擬機中消耗的記憶體一般比 32 位虛擬機要大,這是由于指標膨脹,以及資料型別對齊補白等因素導致的,可以開啟(默認即開啟)壓縮指標功能來緩解;
- 2. 使用邏輯集群的方式來部署程式,可能遇到的問題:
- 節點競爭全域的資源,最典型的就是磁盤競爭;
- 很難最高效率地利用某些資源池,譬如連接池,一般都是在各個節點建立自己獨立的連接池,這樣有可能導致一些節點的連接池已經滿了,而另外一些節點仍有較多空余,盡管可以使用集中式的 JNDI 來解決,但這個方案有一定復雜性并且可能帶來額外的性能代價;
- 如果使用 32 位 Java 虛擬機作為集群節點的話,各個節點仍然不可避免地受到 32 位的記憶體限制,在 32 位 Windows 平臺中每個行程只能使用 2GB 的記憶體,考慮到堆以外的記憶體開銷,堆最多一般只能開到 1.5GB,在某些 Linux 或 UNIX 系統(如 Solaris)中,可以提升到 3GB 乃至接近 4GB 的記憶體,但 32 位中仍然受最高 4GB(2 的 32 次冪)記憶體的限制;
- 大量使用本地快取(如大量使用 HashMap 作為 K/V 快取)的應用,在邏輯集群中會造成較大的記憶體浪費,因為每個邏輯節點上都有一份快取,這時候可以考慮把本地快取改為集中式快取(如 4.6);
- 1. 計劃使用單個 Java 虛擬機實體來管理大記憶體,可能遇到的問題:
4.2 集群間同步導致的記憶體溢位
- 場景簡述:采用親合式集群的 MIS 系統,為了實作部分資料在各個節點中共享,使用 JBossCache 構建了一個全域快取,結果不定期出現多次的記憶體溢位問題;
- 場景特點:親合式集群、JBossCache 全域快取;
- 問題原因:JBossCache 基于 JGroups 進行集群間的資料通信,JGroups 在收發資料包時會在記憶體構建 NAKACK 堆疊保證順序與重發,當網路不好時重發資料在記憶體中不斷堆積;
- 解決思路:改進 JBossCache 的缺陷,改進 MIS 系統;
- 實際方案:可以允許讀操作頻繁,不允許寫操作頻繁,避免大的網路同步開銷;
- 調優程序:
- 1. 發現問題:添加
-XX:+HeapDumpOnOutOfMemoryError引數 -> 運行一段時間發現存在大量 t.NAKACK 物件; - 2. 分析解決;
- 1. 發現問題:添加
4.3 堆外記憶體導致的溢位錯誤
- 場景簡述:使用 CometD 1.1.1 作為服務端推送框架,服務器為 4GB 記憶體,運行 32 位
Windows 作業系統,堆記憶體設定為 1.6GB,結果不定時拋出記憶體溢位例外; - 場景特點:32 位系統、小記憶體、大量的 NIO 操作
- 問題原因:32 位 Windows 平臺中每個行程只能使用 2GB 的記憶體,其中 1.6GB 分配給了堆記憶體,0.4 GB 分配給了直接記憶體,CometD 1.1.1 框架,有大量的 NIO 操作,NIO 會使用 Native 函式庫直接分配堆外記憶體,最終導致直接記憶體溢位;
- 解決思路:注意占用較多記憶體的區域:調整直接記憶體、執行緒堆疊、Socket 緩沖區大小,注意 JNI 代碼,選擇合適的虛擬機與垃圾收集器;
- 1. 直接記憶體:通過
-XX:MaxDirectMemorySize調整直接記憶體大小; - 2. 執行緒堆疊:通過
-Xss調整執行緒堆大小; - 3. Socket快取:每個 Socket 連接都 Receive 和 Send 兩個快取區,控制 Socket 連接數;
- 4. JNI代碼:JNI呼叫本地庫會使用 Native 函式庫直接分配堆外記憶體;
- 5. 虛擬機和垃圾收集器:虛擬機、垃圾收集器的作業也是要消耗一定數量的記憶體的;
- 1. 直接記憶體:通過
- 調優程序:
- 1. 發現問題:首先查看日志 -> 在記憶體溢位后的系統日志中找到例外堆疊(OutOfMemoryError);
- 2. 分析解決;
4.4 外部命令導致系統緩慢
- 場景簡述:在一臺四路處理器的 Solaris 10 作業系統上,處理每次用戶請求時都會執行一個外部 Shell 腳本獲取系統資訊,最后發現請求回應時間比較慢,并且系統中占用絕大多數處理器資源的程式并不是該應用本身;
- 場景特點:Shell 腳本、創建行程耗費大量資源、“fork”系統;
- 問題原因:執行 Shell 腳本是通過 Java 的 Runtime.getRuntime().exec() 方法來呼叫的,它首先復制一個和當前虛擬機擁有一樣環境變數的行程,再用這個新的行程去執行外部命令,最后再退出這個行程;
- 解決思路:盡量減少創建行程的開銷;
- 實際方案:去掉這個 Shell 腳本執行的陳述句,改為使用 Java 的 API 去獲取資訊;
- 調優程序:
- 1. 發現問題:通過 Solaris 10 的 dtrace 腳本 -> 查看當前情況下哪些系統呼叫花費了最多的處理器資源;
- 2. 定位問題:發現最消耗處理器資源的竟然是“fork”系統呼叫(用來創建行程);
- 3. 分析問題:Shell腳本是通過 Java 的
Runtime.getRuntime().exec()方法創建大量行程; - 4. 分析解決;
4.5 服務器虛擬機行程崩潰
- 場景簡述:MIS 系統在與一個 OA 門戶做了集成后,服務器運行期間頻繁出現集群節點的虛擬機行程自動關閉的現象;
- 場景特點:遠程斷開連接例外、OA 門戶集成、異步呼叫;
- 問題原因:MIS 系統作業流待辦事項變化時,使用異步呼叫 Web 服務,通知 OA 門戶,兩邊服務速度不對等,時間越長越多 Web 服務沒有呼叫,等待執行緒和 Socket 連接越多;
- 解決思路:問題根源是異步呼叫導致執行緒過多,處理時間超過了設定的超時等待時間;可以從服務通信和超時等待兩方面優化;
- 實際方案:將異步呼叫改為生產者/消費者模式的訊息佇列;
- 調優程序:
- 1. 發現問題:首先查看日志 -> 發現報大量相同的 Socket 重連例外(java.net.SocketException: Connection reset);
- 2. 分析解決;
4.6 不恰當資料結構導致記憶體占用過大
- 場景簡述:一個后臺 RPC 服務器,需要每 10 min 加載一個約 800MB 的 HashMap<Long,Long>Entry 型別的資料結構,在這段時間內執行 Minor GC 停頓較長時間;
- 場景特點:Map資料結構、長停頓 Minor GC;
- 問題原因:有兩方面,一來 800MB 的資料很快把 Eden 填滿引發垃圾收集,垃圾收集時這 800MB 資料重復復制到 Survivor 導致 Minor GC 時間長,二來
HashMap<Long,Long>型別 key 和 value 共占 2*8=16 位元組,封裝成 Map.Entry 后多了 16 位元組物件頭、8 位元組 next 欄位和 4 位元組 int 型別的 hash 欄位,為了對其追加 4 位元組空白物件頭,還有 8 位元組對這個 Map.Entry 的參考,最后實際耗費的記憶體為(Long(24byte)×2)+Entry(32byte)+HashMap Ref(8byte) = 88byte,空間效率為:16 位元組 / 88 位元組 = 18% 太低; - 解決思路:有兩方面的思路,一來可以將大物件盡早劃入老年代,二來可以優化資料結構;
- 實際方案:將新生代空間減少或使用親合式集群將大記憶體劃進老年代(類似 4.1),除此之外還可以將 Survivor 空間去掉,讓新生代中存活的物件在第一次 Minor GC 后立即進入老年代,等到 Major GC 的時候再去清理它們,最根本的方法是優化資料結構;
- 方案一:去掉 Survivor 空間,具體來說:
- 1. 加入
引數-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0; - 2. 或者
-XX:+Always-Tenure;
- 1. 加入
- 方案二:優化資料結構,需要具體的業務背景;
- 方案一:去掉 Survivor 空間,具體來說:
- 調優程序:
- 1. 發現問題:首先查看日志 -> 發現在每 10min 里,Minor GC 會造成 500ms 停頓;
- 2. 分析解決;
4.7 由 Windows 虛擬記憶體導致的長時間停頓
- 場景簡述:GUI 程式使用記憶體較小,在最小化時,偶爾會出現長時間完全無日志輸出,程式處于停頓狀態,查看記憶體發現在最小化時占用記憶體大幅減小,但虛擬了留下來沒有變化;
- 場景特點:GUI 程式、虛擬記憶體、應用最小化;
- 問題原因:GUI 程式在應用最小化時,會將作業記憶體交換到磁盤頁面檔案中(修剪),在進行垃圾回收前需要恢復作業頁面檔案導致停頓,進而導致從準備開始垃圾收集,到真正開始之間所消耗的時間較長;
- 解決思路:由于 GUI 程式使用記憶體較小,不對其修剪,修剪的好處是記憶體可用于其他應用程式,缺點是在恢復作業集記憶體時會有延遲;
- 實際方案:在應用程式最小化后阻止 JVM 對其進行修剪,具體來說:
- 1.
-Dsun.awt.keepWorkingSetOnMinimize=true;
- 1.
- 調優程序:
- 1. 定位停頓問題:加入引數
-XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDate-Stamps-Xloggc:gclog.log-> 確認了停頓確實是由垃圾收集導致; - 2. 定位停頓日志:添加
-XX:+PrintReferenceGC引數,找到長時間停頓的具體日志資訊 -> 發現從準備開始收集,到真正開始收集之間所消耗的時間卻占了絕大部分; - 3. 分析解決;
- 1. 定位停頓問題:加入引數
4.8 由安全點導致長時間停頓
- 場景簡述:一個使用 G1 收集器的離線 HBase 集群,有大量的 MapReduce 或 Spark 離線分析任務對其進行訪問,集群讀寫壓力較大,結果發現垃圾收集的停頓時間較長;
- 場景特點:MapReduce 與 Spark 任務、垃圾收集時間短但空轉等待時間長、可數回圈;
- 問題原因:HotSpot 虛擬機在認為回圈次數較少時,使用 int 型別或范圍更小
的資料型別作為索引值,不進入安全點(具有讓程式長時間執行的特征),在 HBase 連接中有很多個Mapper / Reducer / Executer 執行緒,清理這些執行緒靠一個連接超時清理的回圈函式, HotSpot 判斷這個回圈函式為可數回圈,等待回圈全部跑完才能進入安全點,此時其他執行緒也必須一起等著,宏觀來看就是長時間停頓; - 解決思路:連接超時清理的回圈函式使用 int 索引因此被判斷為可數回圈,修改索引將其變為不可數回圈即可;
- 實際方案:把回圈索引的資料型別從int改為long即可;
- 調優程序:
- 1. 發現問題:首先查看日志 -> 發現垃圾收集停頓時間長,但實際垃圾回收時間短;
- 2. 查看安全點日志:加入引數
-XX:+PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1查看安全點日志 -> 發現虛擬機在等待所有用戶執行緒進入安全點時有執行緒很慢; - 3. 找到超時執行緒:添加
-XX: +SafepointTimeout和-XX:SafepointTimeoutDelay=2000兩個引數,使虛擬機在等到執行緒進入安全點的時間超過 2000 毫秒時就認定為超時 -> 輸出導致問題的執行緒名稱; - 4. 分析解決;
4.9 調優總結
- 在實際作業中,我們可以直接將初始的堆大小與最大堆大小相等,這樣的好處是可以減少程式運行時垃圾回收次數,從而提高效率;
- 初始堆值和最大堆記憶體記憶體越大,吞吐量就越高,但是也要根據自己電腦(服務器)的實際記憶體來比較;
- 最好使用并行收集器,因為并行收集器速度比串行吞吐量高,速度快,當然,服務器一定要是多執行緒的;
- 設定堆記憶體新生代的比例和老年代的比例最好為 1:2 或者 1:3 ,默認的就是 1:2;
- 減少 GC 對老年代的回收(老年代 GC 慢),設定新生代垃圾物件最大年齡,盡量不要有大量連續記憶體空間的 Java 物件,因為會直接到老年代,記憶體不夠就會執行 GC;
- 默認的 JVM 堆大小是電腦實際記憶體的四分之一左右;
最后

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/449045.html
標籤:Java
