JVM

lucsa發表於2024-04-08

JVM

欲渡黃河冰塞川,將登太行雪滿山。 Docker中跑的JVM,總是有奇奇怪怪的問題,我們先說概念,後談GC優化以及工具的使用

為什麼會有JVM

write once run anywhere - 一次編譯到處執行說的是Java語言的跨平臺特性。

C語言就不是跨平臺的,你寫Linux的C和Windows的C,呼叫同樣功能的作業系統API(比如windows上的讀檔案和Linux上的讀檔案)可能存在較大差異。

Java語言是跨平臺的,你寫Linux的Java和Windows的Java,呼叫的是同一套JVM的API。至於JVM最終會在不同作業系統中如何呼叫底層API執行,那就不是我們關心的了。

Java虛擬機器(JVM)類似於一個作業系統,所有Java程式設計師無論在MAC還是Windows還是Linux上編碼時都面對JVM這個作業系統即可,呼叫的是JVM提供的API,JVM再根據不同的作業系統將自己本身的API呼叫轉換成為作業系統的API呼叫。

所以說,Java的跨平臺特性與Java虛擬機器密不可分。

我們從一個標準的HelloWorld.java檔案的編碼到執行說起,有三個步驟:

1.編碼

在HelloWorld.java中編寫程式碼

2.編譯

使用javac HelloWorld.java 得到HelloWolrd.class檔案

3.執行

java HelloWorld

4.除錯

你是否探究過每一步操作的背後有著怎樣具體的操作?怎麼就做到跨平臺了呢?我們從每一步展開來說

1.編碼:無論你是MAC還是Windows,編碼這一步的操作是一樣的,本質上是新建一個字尾為.java的文字檔案

2.編譯:使用javac命令的前提是你的電腦中已經安裝了JDK。好的,我們在安裝JDK時需要根據當前的環境來下載相應的版本, 比如MAC OS版本、Win 64bit版本、Win 32bit版本。編譯之後得到HelloWolrd.class檔案。.class檔案是可以執行在任意版本的Java虛擬機器(JVM)上的檔案。

3.執行:輸入java HelloWorld是在告訴系統使用Java虛擬機器(JVM)來執行我的HelloWolrd。JVM是通過安裝JRE得到的, 和JDK一樣,我們在安裝JRE時需要根據當前的環境來下載相應的版本。JVM將.class檔案解釋翻譯成當前機器(目標機器)可識別的目標機器碼,然後執行目標機器程式碼, 在這一步操作中,MAC OS版本的JVM會將.class翻譯成MAC OS可以識別的機器碼、Win 64bit版本的JVM會將.class翻譯成Win 64bit可以識別的機器碼...以此類推

在第3步,由於解釋執行的速度過慢,於是就有了JIT(即時編譯技術),可以將位元組碼直接轉換成高效能的本地機器碼來執行,而不是遇到每一行程式碼都先解釋再執行。

在Java8時代,解釋執行和即時編譯技術混合使用並駕齊驅,熱點程式碼會被直接JIT成目標機器碼,非熱點程式碼還是保持解釋執行的方式

在Java9時代,"AOT"提供了將所有程式碼直接編譯成機器碼的方式

位元組碼的命名由來

隨意開啟一個*.class檔案,可以看到開頭一定是這樣的

cafe babe 0000 0034 0052 0a00 1200 2b09

第一個位元組是Java之父定義的一個魔法數,標誌這個檔案是class檔案 第二個位元組代表了java的版本號,00034是52,代表了JDK-52-version

一個位元組8位,可以描述256種指令,每個指令意味著對JVM的一個操作碼。 在x86計算機中00001111的位元組碼很難被人類閱讀,於是有了彙編助記符,比如SADD\LOAD 當然,類似這種00001111的位元組碼,只適合機器閱讀,所以JVM也發明出了一套匯編助記符,比如ICONST\IPUSH\ILOAD|GETFIELD

物件例項化的過程

Object o = new Object();

新建一個物件,分為多個步驟,分別是:

  • 使用當前類載入器ClassLoader+包名+類名作為key找到.class檔案,如果沒找到,丟擲ClassNotFoundException
  • 如果有父類,初始化父類->初始化父類的static變數和方法塊->計算佔用記憶體->對父類成員變數設定預設值->設定物件值->呼叫建構函式
  • 初始化子類static變數和方法塊
  • 計算物件的佔用記憶體
  • 對成員變數設定預設值,不同的資料型別有不同的零值
  • 設定物件頭
  • 呼叫類的構造方法

JVM記憶體模型

JVM

JVM記憶體區域

堆和非堆。其中堆是程式設計師可用可控的記憶體,非堆記憶體則相反。

堆記憶體=Heap Space= Old(年老代) + New{Eden,From Survivor,To Survivor}(年輕代)

非堆記憶體=Persitence Space(持久代)

新建一個物件的記憶體分配順序

1.物件被new出來後,首先被放到Eden區。大物件直接進入老年代

 2.Eden足夠時,記憶體分配結束。Eden區不夠時,執行下一步

 3.JVM做YoungGC,將Eden空間中存活的物件放到Survivor區

 4.Survivor區用作Eden區和Old區的中間交換區域。當Old區空間足夠時,Survivor存活了一定次數的物件會被移到Old區。如果在YounGC後Survivor放不下,將超出的部分挪到老年代

 5.當Old區空間不夠時,JVM做FullGC

 6.若FullGC後,Survivor區及老年代仍然無法存放Eden區複製過來的物件,導致Out Of Memory
複製程式碼

JVM引數

—Xmx    最大堆記憶體(與—Xmx一致)

-Xms    初始化堆記憶體(與—Xms一致)

堆記憶體大於60%時,會增加到-Xmx;堆記憶體小於30%時,會減少到-Xms,為了減少頻繁調整的次數,兩者設定成一樣

-Xss    每個執行緒棧大小

-Xmn    年輕代大小,推薦為堆大小的3/8

-XX:MaxPermSize    持久代最大

-XX:PermSize    持久代初始

-XX:+PrintGCApplicationStoppedTime    列印詳細GC日誌

-XX:PretenureSizeThreadhold    大於該值的物件直接分配到Old區。避免在Young區之間的大量拷貝
複製程式碼

兩種GC

新建立的物件都在分配在Eden中。YoungGC的過程就是將Eden和使用中的Survivor移動到空閒的Survivor中,有一部分物件在經歷了幾次GC之後就會被移動到old區(可以通過引數設定)

JVM

JVM使用經驗

熱機批量啟動容易踩坑

由於JVM剛啟動時,所有的方法被執行次數都是0,熱點程式碼還沒有被JIT進行動態編譯,所有的方法都是解釋執行,這個時候效能是很低的

假如我們有100臺機器需要做更新,不應該一次性全部更新100臺機器,否則很容易產生因為效能底下導致的全部當機

合理的方式是:每間隔一段時間更新20臺機器,當上一批機器從冷機預備到熱機(進入JIT)後,再進行下一批的更新

解決類衝突

我們常常在啟動應用的時候,發現ClassNotFoundException,可是從邏輯來講,我們的類明明應該被載入;

這種情況,要麼是兩個jar包中有多個同名的類,兩者之間進行相互覆蓋,使Spring找到兩個類而且不知道使用哪個 或者是根本沒有載入該jar包

這是可以通過兩種方式:

  • JVM啟動引數加入 -XX:+TraceClassLoading引數,列印出JVM載入的所有類的全限定名
  • 在ClassLoader的loadClass(String name, boolean resolve)方法/Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home/src.zip!/java/lang/ClassLoader.java:401 打斷點,設定condition為圖:
    JVM

OOM

如果發生OOM後,JVM是直接掛掉了,我們連查GC日誌的機會都沒有。如果希望得到OOM時的堆資訊,我們需要開啟-XX:HeapDumpOnOutOfMemoryError, 讓JVM發生異常時能輸出堆內資訊,特別是對幾個月才出現一次OOM的應用來說非常有幫助

可能原因

  • Old區溢位 可能是Xmx過小或者記憶體洩漏導致,例如迴圈上萬次的序列化,建立大量物件。 還有的時候系統一直頻繁FullGC,根本無法響應使用者的請求

  • 持久代溢位 動態載入大量Java類導致,只能通過調大 —XX:MaxPermSize

常用的JVM分析工具

jps

檢視當前機器執行的JVM執行緒

[root@96e6a9290fca /]# jps
1 jar
222 Jps
複製程式碼

jstack

檢視某個Java程式內的執行緒堆疊資訊 這個命令可以幫助我們定位到執行緒堆疊,再找到對應的程式碼,比如,我們想找出程式ID為1的耗時最大的執行緒

找到JVM中最耗時的執行緒

top -Hp 1

  PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND
  120 root      20   0 9981.8m 857652  13880 S  7.0  2.6   0:00.76 java
  124 root      20   0 9981.8m 857652  13880 S  4.0  2.6   0:02.56 java
   16 root      20   0 9981.8m 857652  13880 S  0.3  2.6   0:00.98 java
複製程式碼

找到TIME最多的執行緒號124,計算出16進位制數

printf "%x\n" 124

7c
複製程式碼

找到程式1中的執行緒ID為7c的堆疊資訊

jstack 1 | grep 7c

"XNIO-1 task-41" #108 prio=5 os_prio=0 tid=0x00007f941038b800 nid=0x7c waiting on condition [0x00007f94c8ffb000]
"XNIO-1 task-17" #80 prio=5 os_prio=0 tid=0x00007f941037c800 nid=0x60 waiting on condition [0x00007f94d0d38000]
        - locked <0x00000006c7427c48> (a java.util.Collections$UnmodifiableSet)
        - locked <0x00000006c72c67c0> (a io.netty.channel.nio.SelectedSelectionKeySet)
複製程式碼

jmap

統計當前堆中各個物件的大小,到底是誰佔用了記憶體!

jmap -histo:live 2297 | more

num     #instances         #bytes  class name
----------------------------------------------
   1:           392       16932248  [B
   2:         11198        1229280  [C
   3:          6647         445424  [Ljava.lang.Object;
   4:          3439         383824  java.lang.Class
   5:         11148         267552  java.lang.String
複製程式碼

jstat

實時監測JVM資訊

jstat -gc 1 1000

S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
7168.0 7168.0 4200.2  0.0   190464.0 55153.9  3891200.0   149948.4  91648.0 86891.5 11008.0 10242.2    282    3.063   3      0.423    3.486
7168.0 7168.0 4200.2  0.0   190464.0 55227.0  3891200.0   149948.4  91648.0 86891.5 11008.0 10242.2    282    3.063   3      0.423    3.486
7168.0 7168.0 4200.2  0.0   190464.0 57357.0  3891200.0   149948.4  91648.0 86891.5 11008.0 10242.2    282    3.063   3      0.423    3.486
7168.0 7168.0 4200.2  0.0   190464.0 59528.5  3891200.0   149948.4  91648.0 86891.5 11008.0 10242.2    282    3.063   3      0.423    3.486
7168.0 7168.0 4200.2  0.0   190464.0 64430.1  3891200.0   149948.4  91648.0 86891.5 11008.0 10242.2    282    3.063   3      0.423    3.486
複製程式碼

各列含義:

  • S0C、S1C、S0U、S1U:Survivor 0/1區容量(Capacity)和使用量(Used)
  • EC、EU:Eden區容量和使用量
  • OC、OU:年老代容量和使用量
  • PC、PU:永久代容量和使用量
  • YGC、YGT:年輕代GC次數和GC耗時
  • FGC、FGCT:Full GC次數和Full GC耗時
  • GCT:GC總耗時

JVM效能調優實戰

JVM速度慢的很大一部分原因是因為無法及時釋放所不需要的記憶體。在編寫java程式時,我們不需要自己釋放記憶體,而是交由GC回收。如此一來,對堆記憶體和GC演算法的掌握決定了是否可以發揮JVM的整體效能。

調優原則

1.Young GC儘可能多地回收新生代物件
2.堆記憶體越大越好
3.一切以減少Full GC為目的:
    1.如果大物件過多,新生代沒有足夠的記憶體分配,造成新生代的物件往老生代上遷移,老生代逐漸變多,觸發Full Gc。
    2.如果大物件直接放到老生代,也會產生老生的Full GC頻繁的問題,所以,一定要儘量減少使用大物件,如果一定要使用,那就保持其最短的生命週期,最好作為臨時變數
4.雖然加大記憶體有利於減少GC收集的次數,但是大記憶體的一次Full GC時間也會更長。所以,對於大記憶體的Java應用(現在似乎都是這樣),一定要儘量減少Full GC的次數
手段主要有兩個:
    1.避免物件生命週期過長,在不需要的時候及時釋放,讓物件被Young GC回收,避免物件被移動到老年代
    2.提高大物件進入老年代的門檻:設定-XX:PretrnureSizeThreshold為一個比較大的值,小於該值的物件都先進入新生代,然後被Young GC回收,只有小概率事件會進入老生代
複製程式碼

JVM崩潰的幾大原因:

1.非同步處理請求:對接受到的請求開闢一個執行緒去保持TCP連線,如果非同步處理請求慢,則TCP連線過多,造成執行緒數過多,JVM崩潰

2.使用了netty等NIO框架,JVM在JVM記憶體之外分配直接記憶體,導致直接記憶體過大,發生記憶體洩露

高併發例子

JVM使用例子-承受海量訪問的動態Web應用

伺服器配置:8 CPU, 8G MEM, JDK 1.6.X

引數方案:

-server -Xmx3550m -Xms3550m -Xmn1256m -Xss128k -XX:SurvivorRatio=6 -XX:MaxPermSize=256m -XX:ParallelGCThreads=8 -XX:MaxTenuringThreshold=0 -XX:+UseConcMarkSweepGC

調優說明:

  • -Xmx 與 -Xms 相同以避免JVM反覆重新申請記憶體
  • -Xmn1256m 設定年輕代大小為1256MB。官方推薦配置年輕代大小為整個堆的3/8。
  • -Xss128k 設定較小的執行緒棧以支援建立更多的執行緒,支援海量訪問,並提升系統效能。
  • -XX:SurvivorRatio=6 設定年輕代中Eden區與Survivor區的比值。系統預設是8,根據經驗設定為6,則2個Survivor區與1個Eden區的比值為2:6,一個Survivor區佔整個年輕代的1/8。
  • -XX:ParallelGCThreads=8 配置並行收集器的執行緒數,即同時8個執行緒一起進行垃圾回收。此值一般配置為與CPU數目相等。
  • -XX:+UseConcMarkSweepGC 設定年老代為併發收集。CMS(ConcMarkSweepGC)收集的目標是儘量減少應用的暫停時間,減少Full GC發生的機率,利用和應用程式執行緒併發的垃圾回收執行緒來標記清除年老代記憶體,適用於應用中存在比較多的長生命週期物件的情況。

幾點原則

1.Server端設定-Xms和-Xmx為相同值。為了優化GC,最好讓-Xmn值約等於-Xmx的1/3或2/3。
2.堆大小並不決定程式的記憶體使用量。程式的記憶體使用量要大於-Xmx定義的值,因為Java為其他任務分配記憶體,例如每個執行緒的JVM函式棧等。 
3.JVM函式棧的設定每個執行緒都有他自己的JVM函式棧。-Xss為每個執行緒的JVM函式棧大小
JVM函式棧的大小限制著執行緒的數量。如果JVM函式棧過大就會導致記憶體溢漏。-Xss引數決定JVM函式棧大小,例如-Xss1024K。如果JVM函式棧太小,也會導致JVM函式棧溢漏。
複製程式碼

調優步驟

Young Gc頻率高->新生代太小,總是不夠->增大新生代

Young Gc時間長->新生代太大,很久才做一次Gc->減少新生代

相關文章