JVM | 第1部分:自動記憶體管理與效能調優《深入理解 Java 虛擬機器》

多氯環己烷發表於2022-03-24


前言

參考資料
《深入理解 Java 虛擬機器 - JVM 高階特性與最佳實踐》

第1部分主題為自動記憶體管理,以此延伸出 Java 記憶體區域與記憶體溢位、垃圾收集器與記憶體分配策略、引數配置與效能調優等相關內容;

第2部分主題為虛擬機器執行子系統,以此延伸出 class 類檔案結構、虛擬機器類載入機制、虛擬機器位元組碼執行引擎等相關內容;

第3部分主題為程式編譯與程式碼優化,以此延伸出程式前後端編譯優化、前端易用性優化、後端效能優化等相關內容;

第4部分主題為高效併發,以此延伸出 Java 記憶體模型、執行緒與協程、執行緒安全與鎖優化等相關內容;

本系列學習筆記可看做《深入理解 Java 虛擬機器 - JVM 高階特性與最佳實踐》書籍的縮減版與總結版,想要了解細節請見紙質版書籍;


1. 自動記憶體管理

1.1 JVM執行時資料區

JVM執行時資料區

  • 執行緒共享資料區
    • 方法區(Non-Heap 非堆):儲存已被 Java 虛擬機器載入的類資訊常量靜態變數即時編譯器編譯後的程式碼等資料。當方法區無法滿足記憶體分配需求時,丟擲 OutOfMemoryError 異常;
      • 執行時常量池:存放編譯期生成的各種字面量和符號引用;
    • Java 堆(Java Heap):記憶體中最大的一塊。存放物件例項和陣列。是垃圾收集器管理的主要區域。可能劃分出多個執行緒私有的分配緩衝區。目的是為了更好的回收記憶體,或者更快的分配記憶體。可以處於物理上不連續的記憶體空間中。沒有記憶體可以完成例項分配,並且堆也無法再擴充套件時,將會丟擲 OutOfMemoryError 異常;
  • 執行緒獨立資料區
    • 程式計數器(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:方法呼叫者的程式計數器的值可以作為返回地址;
        • 不正常丟擲異常:需要通過異常處理表來確定出口;
      • 附加資訊:虛擬機器規範允許具體的虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀中,例如與除錯相關的資訊,由虛擬機器自行實現;

幀棧

1.2 Java 記憶體結構

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)時晉升到老年代
  • 幾種分代 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 引入,使用本地記憶體)。堆中產生大物件超過閾值。老年代連續空間不足。
  • 動態物件年齡判定:在 Survivor 空間中相同年齡 x 的物件總大小 > Survivor 空間的一半時,年齡大於等於 x 的物件將直接進入老年代;(HotSpot 虛擬機器)
  • 空間分配擔保
    • JDK 6 Update 24 之前:老年代可用連續空間大小 < 新生代物件總大小 時,檢視相關引數判斷是否允許擔保失敗。允許則判斷是否:年代可用連續空間大小 > 歷次晉升老年代物件平均大小,成立則進行 Major GC(有風險);不成立說明老年代可用連續空間很少,進行 Full GC。或者不允許擔保失敗也會進行 Full GC;
    • JDK 6 Update 24 之後:老年代可用連續空間大小 > 新生代物件總大小老年代可用連續空間大小 > 次晉升老年代物件平均大小 時,進行 Major GC。反之進行 Full GC;

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)、重新標記;

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 演算法的思想);
  • 調優過程
    • 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);

4.2 叢集間同步導致的記憶體溢位

  • 場景簡述:採用親合式叢集的 MIS 系統,為了實現部分資料在各個節點中共享,使用 JBossCache 構建了一個全域性快取。結果不定期出現多次的記憶體溢位問題;
  • 場景特點:親合式叢集、JBossCache 全域性快取;
  • 問題原因:JBossCache 基於 JGroups 進行叢集間的資料通訊,JGroups 在收發資料包時會在記憶體構建 NAKACK 棧保證順序與重發。當網路不好時重發資料在記憶體中不斷堆積;
  • 解決思路:改進 JBossCache 的缺陷,改進 MIS 系統;
  • 實際方案:可以允許讀操作頻繁,不允許寫操作頻繁,避免大的網路同步開銷;
  • 調優過程
    • 1. 發現問題:新增 -XX:+HeapDumpOnOutOfMemoryError 引數 -> 執行一段時間發現存在大量 t.NAKACK 物件;
    • 2. 分析解決

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. 發現問題:首先檢視日誌 -> 在記憶體溢位後的系統日誌中找到異常堆疊(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. 發現問題:首先檢視日誌 -> 發現在每 10min 裡,Minor GC 會造成 500ms 停頓;
    • 2. 分析解決

4.7 由 Windows 虛擬記憶體導致的長時間停頓

  • 場景簡述:GUI 程式使用記憶體較小。在最小化時,偶爾會出現長時間完全無日誌輸出,程式處於停頓狀態。檢視記憶體發現在最小化時佔用記憶體大幅減小,但虛擬了留下來沒有變化;
  • 場景特點:GUI 程式、虛擬記憶體、應用最小化;
  • 問題原因:GUI 程式在應用最小化時,會將工作記憶體交換到磁碟頁面檔案中(修剪),在進行垃圾回收前需要恢復工作頁面檔案導致停頓,進而導致從準備開始垃圾收集,到真正開始之間所消耗的時間較長;
  • 解決思路:由於 GUI 程式使用記憶體較小,不對其修剪。修剪的好處是記憶體可用於其他應用程式,缺點是在恢復工作集記憶體時會有延遲;
  • 實際方案:在應用程式最小化後阻止 JVM 對其進行修剪。具體來說:
    • 1. -Dsun.awt.keepWorkingSetOnMinimize=true
  • 調優過程
    • 1. 定位停頓問題:加入引數 -XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDate-Stamps-Xloggc:gclog.log -> 確認了停頓確實是由垃圾收集導致;
    • 2. 定位停頓日誌:新增 -XX:+PrintReferenceGC 引數,找到長時間停頓的具體日誌資訊 -> 發現從準備開始收集,到真正開始收集之間所消耗的時間卻佔了絕大部分;
    • 3. 分析解決

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 堆大小是電腦實際記憶體的四分之一左右;

最後

新人制作,如有錯誤,歡迎指出,感激不盡!
歡迎關注公眾號,會分享一些更日常的東西!
如需轉載,請標註出處!
JVM | 第1部分:自動記憶體管理與效能調優《深入理解 Java 虛擬機器》

相關文章