(JVM | 第1部分:自動記憶體管理與性能調優) 前言 參考資料: 《深入理解 Java 虛擬機 - JVM 高級特性與最佳實踐》 第1部分主題為自動記憶體管理,以此延伸出 Java 記憶體區域與記憶體溢出、垃圾收集器與記憶體分配策略、參數配置與性能調優等相關內容; 第2部分主題為虛擬機執行子系統,以此延伸 ...
目錄
前言
參考資料:
《深入理解 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 堆大小是電腦實際記憶體的四分之一左右;