JVM基礎(chǔ)和問題分析入門筆記

1.1 JDK、JRE、JVM的關(guān)系

JDK是java開發(fā)工具集合,JRE是java運(yùn)行環(huán)境,JVM是Java虛擬機(jī)

JDK > JRE > JVM

JDK = JRE + 開發(fā)工具

JRE = JVM + 類庫

三者在開發(fā)運(yùn)行Java程序時(shí)的交互關(guān)系:

通過JDK開發(fā)的程序,編譯以后,可以打包發(fā)給裝有JRE的機(jī)器上去運(yùn)行。而運(yùn)行的程序,則是通過Java命令啟動(dòng)的一個(gè)JVM實(shí)例,代碼邏輯的執(zhí)行都運(yùn)行在這個(gè)JVM實(shí)例上。

Java程序的開發(fā)運(yùn)行過程:

利用JDK開發(fā)Java程序,編譯成字節(jié)碼或者打包程序。然后在JRE里啟動(dòng)一個(gè)JVM實(shí)例,加載、驗(yàn)證、執(zhí)行Java字節(jié)碼和依賴庫,運(yùn)行Java程序。JVM將程序和依賴庫的Java字節(jié)碼解析并變成本地代碼執(zhí)行,產(chǎn)生結(jié)果。

常用性能指標(biāo)

  1. 延遲:平均響應(yīng)時(shí)間
  2. 吞吐量:每秒處理事務(wù)數(shù)TPS,每秒處理請(qǐng)求數(shù)QPS
  3. 系統(tǒng)容量:設(shè)計(jì)容量,硬件配置,成本約束

這三個(gè)維度互相關(guān)聯(lián),相互制約。

我們可采用的手段和方式包括:

  • 使用 JDWP 或開發(fā)工具做本地/遠(yuǎn)程調(diào)試
  • 系統(tǒng)和 JVM 的狀態(tài)監(jiān)控,收集分析指標(biāo)
  • 性能分析: CPU 使用情況/內(nèi)存分配分析
  • 內(nèi)存分析: Dump 分析/GC 日志分析
  • 調(diào)整 JVM 啟動(dòng)參數(shù),GC 策略等等

性能調(diào)優(yōu)總結(jié)

性能調(diào)優(yōu)的第一步是制定指標(biāo),收集數(shù)據(jù),第二步是找瓶頸,然后分析解決瓶頸問題。通過這些手段,找當(dāng)前的性能極限值。壓測(cè)調(diào)優(yōu)到不能再優(yōu)化了的 TPS 和 QPS,就是極限值。知道了極限值,我們就可以按業(yè)務(wù)發(fā)展測(cè)算流量和系統(tǒng)壓力,以此做容量規(guī)劃,準(zhǔn)備機(jī)器資源和預(yù)期的擴(kuò)容計(jì)劃。最后在系統(tǒng)的日常運(yùn)行過程中,持續(xù)觀察,逐步重做和調(diào)整以上步驟,長期改善改進(jìn)系統(tǒng)性能。

脫離場(chǎng)景談性能都是耍流氓”,實(shí)際的性能分析調(diào)優(yōu)過程中,我們需要根據(jù)具體的業(yè)務(wù)場(chǎng)景,綜合考慮成本和性能,使用最合適的辦法去處理。系統(tǒng)的性能優(yōu)化到 3000TPS 如果已經(jīng)可以在成本可以承受的范圍內(nèi)滿足業(yè)務(wù)發(fā)展的需求,那么再花幾個(gè)人月優(yōu)化到 3100TPS 就沒有什么意義,同樣地如果花一倍成本去優(yōu)化到 5000TPS 也沒有意義。

過早的優(yōu)化是萬惡之源”,我們需要考慮在恰當(dāng)?shù)臅r(shí)機(jī)去優(yōu)化系統(tǒng)。在業(yè)務(wù)發(fā)展的早期,量不大,性能沒那么重要。我們做一個(gè)新系統(tǒng),先考慮整體設(shè)計(jì)是不是 OK,功能實(shí)現(xiàn)是不是 OK,然后基本的功能都做得差不多的時(shí)候(當(dāng)然整體的框架是不是滿足性能基準(zhǔn),可能需要在做項(xiàng)目的準(zhǔn)備階段就通過 POC(概念證明)階段驗(yàn)證。),最后再考慮性能的優(yōu)化工作。因?yàn)槿绻婚_始就考慮優(yōu)化,就可能要想太多導(dǎo)致過度設(shè)計(jì)了。而且主體框架和功能完成之前,可能會(huì)有比較大的改動(dòng),一旦提前做了優(yōu)化,可能這些改動(dòng)導(dǎo)致原來的優(yōu)化都失效了,又要重新優(yōu)化,多做了很多無用功。

關(guān)于跨平臺(tái)

  • 編譯執(zhí)行:C,C++,Golang,Rust,C#,Java,Scala,Clojure,Kotlin,Swift 等等
  • 解釋執(zhí)行:NodeJS,Python,Perl,Ruby和JavaScript 的部分實(shí)現(xiàn)等等

一般來說解釋型語言都是跨平臺(tái)的,同一份腳本代碼,可以由不同平臺(tái)上的解釋器解釋執(zhí)行。

但是對(duì)于編譯型語言,存在兩種級(jí)別的跨平臺(tái): 源碼跨平臺(tái)和二進(jìn)制跨平臺(tái)。

1、典型的源碼跨平臺(tái)(C++):

<img src="https://tva1.sinaimg.cn/large/e6c9d24ely1h4gmwl1dy2j21160n4gna.jpg" alt="71212109.png" style="zoom:50%;" />

2、典型的二進(jìn)制跨平臺(tái)(Java 字節(jié)碼):

<img src="https://tva1.sinaimg.cn/large/e6c9d24ely1h4gmws1yf3j21460kwgnc.jpg" alt="71237637.png" style="zoom:50%;" />

C++可以一次編寫,到處編譯,但是在不同環(huán)境的依賴不一致或者不完全,需要到處調(diào)試,到處找依賴,該配置。

Java通過虛擬機(jī)技術(shù)解決了這個(gè)問題。源碼只需要編譯一次,然后把編譯后的 class 文件或 jar 包,部署到不同平臺(tái),就可以直接通過安裝在這些系統(tǒng)中的 JVM 上面執(zhí)行。 同時(shí)可以把依賴庫(jar 文件)一起復(fù)制到目標(biāo)機(jī)器,慢慢地又有了可以在各個(gè)平臺(tái)都直接使用的 Maven 中央庫(類似于 linux 里的 yum 或 apt-get 源,macos 里的 homebrew,現(xiàn)代的各種編程語言一般都有了這種包依賴管理機(jī)制:python 的 pip,dotnet 的 nuget,NodeJS 的 npm,golang 的 dep,rust 的 cargo 等等)。這樣就實(shí)現(xiàn)了讓同一個(gè)應(yīng)用程序在不同的平臺(tái)上直接運(yùn)行的能力。

JAVA字節(jié)碼

為什么要學(xué)

Java 中的字節(jié)碼,英文名為 bytecode, 是 Java 代碼編譯后的中間代碼格式。JVM 需要讀取并解析字節(jié)碼才能執(zhí)行相應(yīng)的任務(wù)。

了解字節(jié)碼對(duì)于編寫高性能代碼至關(guān)重要。通過修改字節(jié)碼來調(diào)整程序的行為是司空見慣的事情。想了解分析器(Profiler),Mock 框架,AOP 等工具和技術(shù)這一類工具,則必須完全了解 Java 字節(jié)碼。

簡(jiǎn)介

有一件有趣的事情,就如名稱所示, Java bytecode 由單字節(jié)(byte)的指令組成,理論上最多支持 256 個(gè)操作碼(opcode)。實(shí)際上 Java 只使用了 200 左右的操作碼, 還有一些操作碼則保留給調(diào)試操作。

操作碼, 下面稱為 指令, 主要由類型前綴操作名稱兩部分組成。

例如,'i' 前綴代表 ‘integer’,所以,'iadd' 很容易理解, 表示對(duì)整數(shù)執(zhí)行加法運(yùn)算。

根據(jù)指令的性質(zhì),主要分為四個(gè)大類:

  1. 棧操作指令,包括與局部變量交互的指令
  2. 程序流程控制指令
  3. 對(duì)象操作指令,包括方法調(diào)用指令
  4. 算術(shù)運(yùn)算以及類型轉(zhuǎn)換指令

此外還有一些執(zhí)行專門任務(wù)的指令,比如同步(synchronization)指令,以及拋出異常相關(guān)的指令等等。下文會(huì)對(duì)這些指令進(jìn)行詳細(xì)的講解。

獲取字節(jié)碼清單

可以用 **javap** 工具來獲取 class 文件中的指令清單。 **javap**是標(biāo)準(zhǔn) JDK 內(nèi)置的一款工具, 專門用于反編譯 class 文件。

GC

Serial GC 日志解讀

我們關(guān)注的主要是兩個(gè)數(shù)據(jù):GC 暫停時(shí)間,以及 GC 之后的內(nèi)存使用量/使用率。

FullGC,我們主要關(guān)注 GC 之后內(nèi)存使用量是否下降,其次關(guān)注暫停時(shí)間。簡(jiǎn)單估算,GC 后老年代使用量為 220MB 左右,耗時(shí) 50ms。如果內(nèi)存擴(kuò)大 10 倍,GC 后老年代內(nèi)存使用量也擴(kuò)大 10 倍,那耗時(shí)可能就是 500ms 甚至更高,就會(huì)系統(tǒng)有很明顯的影響了。這也是我們說串行 GC 性能弱的一個(gè)原因,服務(wù)端一般是不會(huì)采用串行 GC 的。

Tenured:用于清理老年代空間的垃圾收集器名稱。Tenured 表明使用的是單線程的 STW 垃圾收集器,使用的算法為“標(biāo)記—清除—整理(mark-sweep-compact)”。

[Times: user=0.05 sys=0.00,real=0.05 secs]:GC 事件的持續(xù)時(shí)間,分為 user、sys、real 三個(gè)部分。因?yàn)榇欣占髦皇褂脝蝹€(gè)線程,因此“real=user+system”。50 毫秒的暫停時(shí)間,比起前面年輕代的 GC 來說增加了一倍左右。這個(gè)時(shí)間跟什么有關(guān)系呢?答案是:GC 時(shí)間,與 GC 后存活對(duì)象的總數(shù)量關(guān)系最大。

Parallel GC 日志解讀

并行垃圾收集器對(duì)年輕代使用“標(biāo)記—復(fù)制(mark-copy)”算法,對(duì)老年代使用“標(biāo)記—清除—整理(mark-sweep-compact)”算法。

年輕代和老年代的垃圾回收時(shí)都會(huì)觸發(fā) STW 事件,暫停所有的應(yīng)用線程,再來執(zhí)行垃圾收集。在執(zhí)行“標(biāo)記”和“復(fù)制/整理”階段時(shí)都使用多個(gè)線程,因此得名“Parallel”。

通過多個(gè) GC 線程并行執(zhí)行的方式,能使 JVM 在多 CPU 平臺(tái)上的 GC 時(shí)間大幅減少。

通過命令行參數(shù) -XX:ParallelGCThreads=NNN 可以指定 GC 線程的數(shù)量,其默認(rèn)值為 CPU 內(nèi)核數(shù)量。

并行垃圾收集器適用于多核服務(wù)器,其主要目標(biāo)是增加系統(tǒng)吞吐量(也就是降低 GC 總體消耗的時(shí)間)。為了達(dá)成這個(gè)目標(biāo),會(huì)使用盡可能多的 CPU 資源:

  • 在 GC 事件執(zhí)行期間,所有 CPU 內(nèi)核都在并行地清理垃圾,所以暫停時(shí)間相對(duì)來說更短;
  • 在兩次 GC 事件中間的間隔期,不會(huì)啟動(dòng) GC 線程,所以這段時(shí)間內(nèi)不會(huì)消耗任何系統(tǒng)資源。

另一方面,因?yàn)椴⑿?GC 的所有階段都不能中斷,所以并行 GC 很可能會(huì)出現(xiàn)長時(shí)間的卡頓。

長時(shí)間卡頓的意思,就是并行 GC 啟動(dòng)后,一次性完成所有的 GC 操作,所以單次暫停的時(shí)間較長。

假如系統(tǒng)延遲是非常重要的性能指標(biāo),那么就應(yīng)該選擇其他垃圾收集器。

Minor GC 日志分析

前面的 GC 事件是發(fā)生在年輕代 Minor GC:

2019-12-18T00:37:47.463-0800: 0.690:
  [GC (Allocation Failure)
    [PSYoungGen: 104179K->14341K(116736K)]
    383933K->341556K(466432K),0.0229343 secs]
  [Times: user=0.04 sys=0.08,real=0.02 secs]

解讀如下:

  1. 2019-12-18T00:37:47.463-0800: 0.690:GC 事件開始的時(shí)間。
  2. GC:用來區(qū)分 Minor GC 還是 Full GC 的標(biāo)志。這里是一次“小型 GC(Minor GC)”。
  3. PSYoungGen:垃圾收集器的名稱。這個(gè)名字表示的是在年輕代中使用并行的“標(biāo)記—復(fù)制(mark-copy)”,全線暫停(STW)垃圾收集器。104179K->14341K(116736K) 表示 GC 前后的年輕代使用量,以及年輕代的總大小,簡(jiǎn)單計(jì)算 GC 后的年輕代使用率 14341K/116736K=12%。
  4. 383933K->341556K(466432K) 則是 GC 前后整個(gè)堆內(nèi)存的使用量,以及此時(shí)可用堆的總大小,GC 后堆內(nèi)存使用率為 341556K/466432K=73%,這個(gè)比例不低,事實(shí)上前面已經(jīng)發(fā)生過 FullGC 了,只是這里沒有列出來。
  5. [Times: user=0.04 sys=0.08,real=0.02 secs]:GC 事件的持續(xù)時(shí)間,通過三個(gè)部分來衡量。user 表示 GC 線程所消耗的總 CPU 時(shí)間,sys 表示操作系統(tǒng)調(diào)用和系統(tǒng)等待事件所消耗的時(shí)間; real 則表示應(yīng)用程序?qū)嶋H暫停的時(shí)間。因?yàn)椴⒉皇撬械牟僮鬟^程都能全部并行,所以在 Parallel GC 中,real 約等于 user+system/GC 線程數(shù)。筆者的機(jī)器是 8 個(gè)物理線程,所以默認(rèn)是 8 個(gè) GC 線程。分析這個(gè)時(shí)間,可以發(fā)現(xiàn),如果使用串行 GC,可能得暫停 120 毫秒,但并行 GC 只暫停了 20 毫秒,實(shí)際上性能是大幅度提升了。

通過這部分日志可以簡(jiǎn)單算出:在 GC 之前,堆內(nèi)存總使用量為 383933K,其中年輕代為 104179K,那么可以算出老年代使用量為 279754K。

在此次 GC 完成后,年輕代使用量減少了 104179K-14341K=89838K,總的堆內(nèi)存使用量減少了 383933K-341556K=42377K。

那么我們可以計(jì)算出有“89838K-42377K=47461K”的對(duì)象從年輕代提升到老年代。老年代的使用量為:341556K-14341K=327215K。

老年代的大小為 466432K-116736K=349696K,使用率為 327215K/349696K=93%,基本上快滿了。

總結(jié):

年輕代 GC,我們可以關(guān)注暫停時(shí)間,以及 GC 后的內(nèi)存使用率是否正常,但不用特別關(guān)注 GC 前的使用量,而且只要業(yè)務(wù)在運(yùn)行,年輕代的對(duì)象分配就少不了,回收量也就不會(huì)少。

此次 GC 的內(nèi)存變化示意圖為:

Full GC 日志分析

前面介紹了并行 GC 清理年輕代的 GC 日志,下面來看看清理整個(gè)堆內(nèi)存的 GC 日志:

2019-12-18T00:37:47.486-0800: 0.713:
  [Full GC (Ergonomics)
    [PSYoungGen: 14341K->0K(116736K)]
    [ParOldGen: 327214K->242340K(349696K)]
    341556K->242340K(466432K),
    [Metaspace: 3322K->3322K(1056768K)],
  0.0656553 secs]
  [Times: user=0.30 sys=0.02,real=0.07 secs]

解讀一下:

  1. 2019-12-18T00:37:47.486-0800:GC 事件開始的時(shí)間。
  2. Full GC:完全 GC 的標(biāo)志。Full GC 表明本次 GC 清理年輕代和老年代,Ergonomics 是觸發(fā) GC 的原因,表示 JVM 內(nèi)部環(huán)境認(rèn)為此時(shí)可以進(jìn)行一次垃圾收集。
  3. [PSYoungGen: 14341K->0K(116736K)]:和上面的示例一樣,清理年輕代的垃圾收集器是名為“PSYoungGen”的 STW 收集器,采用“標(biāo)記—復(fù)制(mark-copy)”算法。年輕代使用量從 14341K 變?yōu)?0,一般 Full GC 中年輕代的結(jié)果都是這樣。
  4. ParOldGen:用于清理老年代空間的垃圾收集器類型。在這里使用的是名為 ParOldGen 的垃圾收集器,這是一款并行 STW 垃圾收集器,算法為“標(biāo)記—清除—整理(mark-sweep-compact)”。327214K->242340K(349696K)]:在 GC 前后老年代內(nèi)存的使用情況以及老年代空間大小。簡(jiǎn)單計(jì)算一下,GC 之前,老年代使用率為 327214K/349696K=93%,GC 后老年代使用率 242340K/349696K=69%,確實(shí)回收了不少。那么有多少內(nèi)存提升到老年代呢?其實(shí)在 Full GC 里面不好算,而在 Minor GC 之中比較好算,原因大家自己想一想。
  5. 341556K->242340K(466432K):在垃圾收集之前和之后堆內(nèi)存的使用情況,以及可用堆內(nèi)存的總?cè)萘?。?jiǎn)單分析可知,GC 之前堆內(nèi)存使用率為 341556K/466432K=73%,GC 之后堆內(nèi)存的使用率為:242340K/466432K=52%。
  6. [Metaspace: 3322K->3322K(1056768K)]:前面我們也看到了關(guān)于 Metaspace 空間的類似信息。可以看出,在 GC 事件中 Metaspace 里面沒有回收任何對(duì)象。
  7. 0.0656553secs:GC 事件持續(xù)的時(shí)間,以秒為單位。
  8. [Times: user=0.30 sys=0.02,real=0.07 secs]:GC 事件的持續(xù)時(shí)間,含義參見前面。

Full GC 和 Minor GC 的區(qū)別是很明顯的,此次 GC 事件除了處理年輕代,還清理了老年代和 Metaspace。

總結(jié):

Full GC 時(shí)我們更關(guān)注老年代的使用量有沒有下降,以及下降了多少。如果 FullGC 之后內(nèi)存不怎么下降,使用率還很高,那就說明系統(tǒng)有問題了。

此次 GC 的內(nèi)存變化示意圖為:

細(xì)心的同學(xué)可能會(huì)發(fā)現(xiàn),此次 FullGC 事件和前一次 MinorGC 事件是緊挨著的:0.690+0.02secs~0.713。因?yàn)?Minor GC 之后老年代使用量達(dá)到了 93%,所以接著就觸發(fā)了 Full GC。

內(nèi)存計(jì)算

操作系統(tǒng)中的最大可用內(nèi)存除去操作系統(tǒng)本身使用的部分,剩下的都可以為某一個(gè)進(jìn)程服務(wù),在JVM進(jìn)程中,內(nèi)存又被分為堆、本地內(nèi)存和棧等三大塊,Java堆是JVM自動(dòng)管理的內(nèi)存,應(yīng)用的對(duì)象的創(chuàng)建和銷毀、類的裝載等都發(fā)生在這里,本地內(nèi)存是Java應(yīng)用使用的一種特殊內(nèi)存,JVM并不直接管理其生命周期,每個(gè)線程也會(huì)有一個(gè)棧,是用來存儲(chǔ)線程工作過程中產(chǎn)生的方法局部變量、方法參數(shù)和返回值的,每個(gè)線程對(duì)應(yīng)的棧的默認(rèn)大小為1M。

從內(nèi)存角度來看創(chuàng)建線程需要內(nèi)存空間,如果JVM進(jìn)程正當(dāng)一個(gè)應(yīng)用創(chuàng)建線程,而操作系統(tǒng)沒有剩余的內(nèi)存分配給此JVM進(jìn)程,則會(huì)拋出問題中的OOM異常:unable to create new native thread。

如下公式可以用來從內(nèi)存角度計(jì)算允許創(chuàng)建的最大線程數(shù):

最大線程數(shù) = (操作系統(tǒng)最大可用內(nèi)存 - JVM內(nèi)存 - 操作系統(tǒng)預(yù)留內(nèi)存)/ 線程棧大小

根據(jù)這個(gè)公式,我們可以通過剩余內(nèi)存計(jì)算可以創(chuàng)建線程的數(shù)量。

使用free -m查看剩余內(nèi)存

使用ulimit -a來顯示當(dāng)前的各種系統(tǒng)對(duì)用戶使用資源的限制:

max user processes        (-u) 1024

機(jī)器設(shè)置的允許使用的最大用戶進(jìn)程數(shù)為1024。

使用jstack命令查看Java棧

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容