Java生產環境JVM設定成固定堆大小深層原理

月光冷鋒 發表於 2021-11-28
Java JVM
  可能很多人都知道Java程式上生產後,運維人員都會設定好JVM的堆大小,而且還是把最大最小設定成一樣的值。那究竟是為什麼呢?一般而言,Java程式如果你不顯示設定該值得話,會自動進行初始化設定。
  -Xmx 的預設值為你當前機器最大記憶體的 1/4
  -Xms 的預設值為你當前機器最大記憶體的 1/64 
  顯然這樣配置的意義是希望JVM可以根據當前執行的環境,動態伸縮堆記憶體大小。之所以生產上設定成固定大小,網上也是說法不一,很多時候都是使用“防止記憶體抖動”這樣的模糊詞語給出解釋。但是我相信各位讀者也很懵,不知道這個詞具體表達什麼含義。
  所以接下來我打算用這篇文章來著重解釋一下這其中的門道。帶大家徹底弄懂設定固定大小堆的底層原理和好處。為了能順利看懂本文,我假設你們已經具備了一定的作業系統基礎知識。
最大堆或最小堆,從字面上理解就是JVM在執行Java程式時,為其分配堆記憶體空間的上限和下限值。我們把最大和最小堆設定成相同值那意思就是分配了固定大小的記憶體唄。這樣不就省去了動態調整記憶體(申請和釋放)以及頻繁的使用者態和核心態的切換帶來的開銷嗎?。如下圖所示。
Java生產環境JVM設定成固定堆大小深層原理
Java生產環境JVM設定成固定堆大小深層原理
  看上去就是這麼回事,簡單明瞭。然而當我們嘗試去做個模擬實驗,事實卻並非如此。比如,隨便寫個Java程式,使用如下命令啟動之。並設定好固定大小堆為1G。
  java -Xmx1024m -Xms1024m -jar demo.jar
  然後我們通過檢視程式的記憶體佔用時,發現程式並沒有佔用1G的空間,而是很小的佔用。這個實驗結果和我們預期的完全不一致。究竟是什麼原因呢?
  問題其實出在我們對記憶體模型的理解上有問題。很多人可能都是像上面圖中那樣理解程式分配記憶體的。實際上是不對的,且也更復雜。首先我們要理解一個重要概念,那就是“程式的虛擬地址空間”,我們使用者程式通過malloc這個系統呼叫申請記憶體,實際上就是申請了一個虛擬的記憶體,並不是真正的實體記憶體。大家要注意,這個虛擬的記憶體就是指“程式的虛擬地址空間”,而不是我們通常理解的Windows下的虛擬記憶體或Linux下的swap(分割槽交換)。如下圖所示。
Java生產環境JVM設定成固定堆大小深層原理
Java生產環境JVM設定成固定堆大小深層原理
  使用者程式申請的虛擬記憶體(虛擬地址空間),也就是通過malloc系統呼叫,本質就是在程式的虛擬地址空間裡分配了一塊地址範圍而已。32位系統理論上最大4G,每個程式都有自己的虛擬地址空間,都能申請到最大4G記憶體。但是申請了的記憶體,如果沒有實際使用(寫入資料),則作業系統不會給這塊虛擬空間分配實際的實體記憶體。其實原因很簡單,實體記憶體一直屬於緊缺資源,所以現代作業系統都設計為由核心程式統一管理,使用者程式無權直接干涉。不是說你申請多少就真的給你多少,而是你實際使用多少才會給你多少。
  回到上面那個小實驗,你發現啟動後程式記憶體佔用很小就是這個原因。儘管JVM已經在你啟動時向系統申請了1G的固定堆大小空間。但是由於你這個程式只是一個簡單的測試,裡面並沒有實際的程式碼操作業務。所以你實際上只用到了很小的實體記憶體空間。但是如果你的程式真有業務邏輯,隨著系統的執行,實際佔用實體記憶體就會越來越多,直到達到申請的上限值1G。執行期間,你的程式同時也會釋放一些物件(通過GC),並在適當的時機歸還一些實體記憶體給作業系統。所以佔用的實體記憶體大小,也會動態有所調整。這樣作業系統就可以給其他程式使用,提高了記憶體利用效率。這樣的設計也沒什麼不好的。
  如上圖所示,作業系統對記憶體管理是以頁為基本單位的,一個頁代表了一個固定大小的地址範圍。使用者程式給某個變數比如byte[]賦值時,此時該變數對應的程式虛擬地址空間所在的頁在實體記憶體上找不到對應的頁對映時,就會觸發了一個缺頁中斷異常,作業系統就會重新將虛擬地址的頁對映到實體記憶體中的頁,此時才是真正實現了記憶體分配,會佔用實際的實體記憶體空間。假如Java程式的GC把這個byte[]變數收回了,也就是不需要佔用記憶體空間了,使用者程式的堆管理器會適當的歸還一些實體記憶體給作業系統,以便下次可以給其他任何程式使用。需要注意的是使用者程式呼叫的malloc和free兩個系統呼叫,都是針對使用者程式的虛擬地址空間而言的,並不是實際操作實體記憶體。只有作業系統才擁有對實際實體記憶體的管理許可權。作業系統可以使用有效的各種演算法,來獨立高效的管理實體記憶體。這裡面的細節,我這裡不詳細說了,有興趣的可以去看些作業系統的資料深入瞭解下。
  然而我們實際的Java程式,配置成固定堆大小後,你會發現,記憶體佔用一旦上去了就下不來了。即使當前程式處於比較空閒的狀態下。這又是為什麼呢?難道Java的GC沒有回收記憶體?
  其實並不是GC沒有回收記憶體,而是我們這裡存在理解問題。GC回收記憶體並不是指實體記憶體,而是指當前程式的虛擬記憶體(虛擬地址空間)。一般而言,回收的虛擬記憶體並不會立即歸還給作業系統,從而作業系統也就無法回收它了。至於何時歸還實體記憶體,這取決於一個叫glibc的堆管理器。它根據一定的策略和演算法適當的釋放真實的實體記憶體。否則即便Java程式GC了物件,該物件佔用的實體記憶體也不會立即釋放出來。由於這裡我們是設定了固定大小的堆空間,實際上GC回收的虛擬記憶體,也不會被釋放歸還給作業系統。故Java程式記憶體佔用一旦增長,記憶體佔用幾乎都不會再下降了,這樣也是出於物件再分配的效率考慮的。這樣顯然可以避免作業系統反覆把程式的虛擬地址頁復對映實體記憶體頁(缺頁中斷異常)操作,導致頻繁的使用者態和核心態切換造成的效能問題。