記一次內(nèi)存異常排查

原創(chuàng) ning0h2o date 2020-11-30

1. 內(nèi)存異常問(wèn)題

背景

  • jdos機(jī)器:4核8G
  • JVM設(shè)置:
export JAVA_OPTS="-Djava.library.path=/usr/local/lib -server -Xms4096m -Xmx4096m 
-XX:NativeMemoryTracking=detail -XX:NewRatio=2 -XX:MaxMetaspaceSize=256m -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+CMSParallelRemarkEnabled -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/Logs -Djava.awt.headless=true -Dsun.net.client.defaultConnectTimeout=60000 -Dsun.net.client.defaultReadTimeout=60000 -Djmagick.systemclassloader=no -Dnetworkaddress.cache.ttl=300 -Dsun.net.inetaddr.ttl=300"

異?,F(xiàn)象:應(yīng)用程序啟動(dòng)之后,內(nèi)存使用量緩慢持續(xù)上升,然后會(huì)在某個(gè)時(shí)間點(diǎn)突然陡升,陡升之后會(huì)繼續(xù)緩慢上升,然后又會(huì)經(jīng)歷陡升階段。這種行為大概會(huì)經(jīng)歷兩到三輪,最后內(nèi)存使用量平穩(wěn)在70%左右(有時(shí)甚至超過(guò)80%)。

機(jī)器上占用最多內(nèi)存的進(jìn)程就是Java(超過(guò)80%占比),所以上面的內(nèi)存使用率上升曲線也可以看做為Java內(nèi)存使用率上升曲線。

查看內(nèi)存陡升同時(shí)段JVM監(jiān)控發(fā)現(xiàn)JVM堆內(nèi)存正常。

2. JVM內(nèi)存區(qū)域

知己知彼,百戰(zhàn)不殆。Java內(nèi)存異常,那必須得從JVM內(nèi)存區(qū)域開始。

Java8 JVM內(nèi)存區(qū)域主要可以劃分為:堆區(qū)(Heap Space)、棧區(qū)(Stack Space)非堆區(qū)(Non-Heap Space)。除棧區(qū)為其他區(qū)域都是線程共享區(qū)域。

  • Stack Space

    棧區(qū)由程序計(jì)數(shù)器(PC)、虛擬機(jī)棧(Stack)和本地方法棧(Native Stack),棧區(qū)和線程關(guān)聯(lián)較大,每個(gè)線程都有獨(dú)立的程序計(jì)數(shù)器、方法棧和本地方法棧。

    • 程序計(jì)數(shù)器可以看作是線程執(zhí)行字節(jié)碼的行號(hào)指示器,程序中的分支、循環(huán)、跳轉(zhuǎn)、異常處理和線程恢復(fù)等基礎(chǔ)功能依賴程序計(jì)數(shù)器完成。

    • 虛擬機(jī)棧是Java方法執(zhí)行的內(nèi)存模型,JVM中有一個(gè)稱為棧幀的數(shù)據(jù)結(jié)構(gòu)(可以視為方法對(duì)象)保存方法有關(guān)信息,如局部變量表、操作數(shù)棧、動(dòng)態(tài)鏈接、方法出口等信息。每當(dāng)線程調(diào)用一個(gè)方法時(shí)會(huì)創(chuàng)建一個(gè)棧幀壓入線程所有的方法棧中,等方法執(zhí)行完畢后棧幀出棧。在Java代碼處理異常打印堆棧信息e.printStackTrace()的時(shí)候可以很直觀的看到這種行為。

      線程請(qǐng)求的深度如果大于虛擬機(jī)棧所允許的深度(可以使用-Xss設(shè)定)將會(huì)拋出StackOverflowError異常。

    • 本地方法棧所發(fā)揮的作用和方法棧相似,不過(guò)本地方法棧中的方法指的是本地方法(Native Method)。

      同虛擬機(jī)棧,線程請(qǐng)求的深度如果大于虛擬機(jī)棧所允許的深度也會(huì)拋出StackOverflowError異常。

  • Heap Space

    JVM內(nèi)存中最大的一塊區(qū)域,主要作用是存放對(duì)象實(shí)例,所有的對(duì)象實(shí)例都在這里分配內(nèi)存(不過(guò)新出現(xiàn)的JVM技術(shù)也可以在其他區(qū)域分配了)。這也是垃圾收集器(Garbage Collector)主要工作區(qū)域,因此也被成為GC堆。

    堆根據(jù)所使用垃圾收集算法不同,有更為細(xì)節(jié)內(nèi)存區(qū)域劃分。當(dāng)使用分代收集算法時(shí),堆整體被劃分為新生代(Young Generation)和老年代(Old Generation),其中新生代可以進(jìn)一步劃分為Eden、From Survivor、To Survivor。

    當(dāng)堆沒(méi)有足夠的內(nèi)存分配對(duì)象時(shí),將會(huì)拋出OutOfMemoryError異常。

    在Java8中字符串常量池被移入堆區(qū),可以通過(guò)jmap -heap輸出的信息驗(yàn)證這一點(diǎn)。

  • Non-Heap Space

    堆之外的內(nèi)存稱為非堆內(nèi)存,這塊區(qū)域主要是JVM用于其自身內(nèi)部操作(如GC)的內(nèi)存。

    • 元數(shù)據(jù)區(qū)(Metaspace):非堆內(nèi)存中有一塊用戶存儲(chǔ)虛擬機(jī)加載后的class文件信息(元數(shù)據(jù)),包括運(yùn)行時(shí)常量池、字段和方法數(shù)據(jù),以及方法和構(gòu)造函數(shù)的代碼等,這塊區(qū)域一般稱之為方法區(qū)(Method Area),在Java 7時(shí)也被稱為永久代(Perm Gen),從Java 8開始由元空間(Metaspace)替換。

      此區(qū)域無(wú)法滿足新的內(nèi)存分配需求時(shí),將會(huì)拋出OutOfMemoryError異常。

    • 本地內(nèi)存區(qū)域(Native Memory)主要是通過(guò)JNI(Java Native Interface)和NIO一些方法(DirectByteBuffer)調(diào)用產(chǎn)生的一些字節(jié)緩沖區(qū)(通常通過(guò)調(diào)用C語(yǔ)言malloc函數(shù)分配)。

    • 為了在不同平臺(tái)上運(yùn)行JVM字節(jié)碼,需要將其轉(zhuǎn)換為機(jī)器指令。JIT編譯器負(fù)責(zé)進(jìn)行此項(xiàng)工作。JVM將字節(jié)碼編譯為匯編指令時(shí),會(huì)將這些指令(代碼)存儲(chǔ)在稱為代碼緩存(Code Cache)的非堆數(shù)據(jù)區(qū)域中。

    注:更詳細(xì)的JVM內(nèi)存區(qū)域信息可以通過(guò)NMT(Native Memory Tracking)工具查看。

  • 堆外內(nèi)存

    首先明確兩個(gè)定義:

    1. 堆內(nèi)存:指JVM Heap區(qū)域,可以通過(guò)-xms和-xmx為其設(shè)定大小
    2. 本機(jī)內(nèi)存:指物理內(nèi)存,有時(shí)也稱為本地內(nèi)存、原生內(nèi)存、C Heap、Native堆等,其大小為機(jī)器內(nèi)存(條)大小

    堆外內(nèi)存是指本機(jī)內(nèi)存除Java堆內(nèi)存之外那一部分本機(jī)內(nèi)存。在Java中,堆外內(nèi)存使用是通過(guò)(魔法類Unsafe)JNI調(diào)用本地方法分配,這部分內(nèi)存不受GC管理(其實(shí)是間接管理的),而且也不受最大堆內(nèi)存限制,同時(shí)在網(wǎng)絡(luò)IO和某些場(chǎng)景下的文件IO中會(huì)減少數(shù)據(jù)拷貝次數(shù)。因此一些高性能中間件(如Netty)會(huì)使用堆外內(nèi)存。

    在Java程序中,使用堆外內(nèi)存也一般是指使用java.nio.DirectByteBuffer,準(zhǔn)確的來(lái)說(shuō),應(yīng)該是java.nio.DirectByteBuffer所”引用”的堆外內(nèi)存。DirectByteBuffer類在JVM使用堆外內(nèi)存過(guò)程中充當(dāng)一個(gè)handle的角色,JVM通過(guò)對(duì)DirectByteBuffer類(里面保存了使用堆外空間的大小和堆外空間首地址)的管理來(lái)間接管理堆外內(nèi)存,DirectByteBuffer對(duì)象創(chuàng)建則進(jìn)行堆外內(nèi)存分配,DirectByteBuffer對(duì)象GC回收則進(jìn)行堆外內(nèi)存釋放。

    Java使用堆外內(nèi)存有個(gè)臭名昭著的陷阱——冰山現(xiàn)象,幾十個(gè)占用不大的DirectByteBuff對(duì)象后面可能存在遠(yuǎn)超這些對(duì)象幾個(gè)數(shù)量級(jí)的內(nèi)存占用空間,這種現(xiàn)象一般表現(xiàn)為JVM內(nèi)存正常,機(jī)器內(nèi)存快被消耗殆盡。

3. 堆外內(nèi)存排查篇

內(nèi)存陡增時(shí),JVM內(nèi)存監(jiān)控正常,內(nèi)存異?,F(xiàn)象表現(xiàn)也與冰山現(xiàn)象幾乎一致,那大概是堆外內(nèi)存異常了。

3.1 堆外內(nèi)存監(jiān)控

為了判斷是否是堆外內(nèi)存使用導(dǎo)致內(nèi)存異常,決定對(duì)堆外內(nèi)存進(jìn)行監(jiān)控。首先,前面說(shuō)到一些中間件會(huì)使用到堆外內(nèi)存,在我們的項(xiàng)目使用堆外內(nèi)存的中間件只有Netty。所以需要對(duì)Netty堆外內(nèi)存和Java代碼中使用的堆外內(nèi)存監(jiān)控。

  • Netty堆外內(nèi)存監(jiān)控,主要參考Netty堆外內(nèi)存泄露排查盛宴這篇博客,其中提到:Netty底層有一個(gè)字段PlatformDependent.DIRECT_MEMORY_COUNTER有統(tǒng)計(jì)堆外內(nèi)存的使用情況,只需要通過(guò)反射獲取就可以監(jiān)控Netty堆外內(nèi)存使用情況。

  • Java堆外內(nèi)存監(jiān)控,則使用了JMX(Java Management Extensions)提供的MBean。

@Component
public class DirectMemoryReport {
    private static final Logger LOGGER = LoggerFactory.getLogger("DirectMemoryReportLogger");
    private final int _1K = 1024;
    private AtomicLong directMemory;

    @PostConstruct
    public void init() {
        // 反射獲取Netty堆外內(nèi)存統(tǒng)計(jì)字段
        Field field = ReflectionUtils.findField(PlatformDependent.class, "DIRECT_MEMORY_COUNTER");
        field.setAccessible(true);

        try {
            directMemory = (AtomicLong) field.get(PlatformDependent.class);
            startReport();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    /**
     * 10秒打印一次堆外內(nèi)存使用情況
     */
    private void startReport() {
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(this::doReport, 0, 10, TimeUnit.SECONDS);
    }

    private void doReport() {
        long memoryInKb = directMemory.get()/_1K;
        LOGGER.info("netty_direct_memory: {}k", memoryInKb);
        // DirectBuffer
        BufferPoolMXBean directBufferPoolMXBean = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class).get(0);
        LOGGER.info("DirectBuffer count: {}, MemoryUsed: {}k", directBufferPoolMXBean.getCount(),
                directBufferPoolMXBean.getMemoryUsed()/_1K);
    }

}

在本地測(cè)試驗(yàn)證之后放到了問(wèn)題機(jī)器上去跑,最后結(jié)果讓人大失所望,Netty使用的堆外內(nèi)存始終恒定在13107k=120M這一個(gè)數(shù)值(Netty使用池化堆外內(nèi)存,這個(gè)大概是堆外內(nèi)存池大小),DirectByteBuff使用的堆外內(nèi)存使用量雖然時(shí)有變化,但是不到1M的使用量幾乎可以忽略不計(jì)。

3.2 Btrace監(jiān)控堆外內(nèi)存申請(qǐng)

Btrace是一款動(dòng)態(tài)的Java跟蹤工具。Btrace通過(guò)動(dòng)態(tài)(字節(jié)碼)檢測(cè)正在運(yùn)行的Java程序的類來(lái)工作。Btrace將跟蹤操作插入正在運(yùn)行的Java程序的類中,并熱交換所跟蹤的程序類(GitHub主頁(yè)介紹)。通過(guò)Btrace可以獲取程序執(zhí)行過(guò)程中的一切信息(如方法參數(shù)、返回值、堆棧信息等等)。Btrace使用方式其實(shí)和AOP切面很相似。

監(jiān)控堆外內(nèi)存申請(qǐng)腳本如下:

// 注意:此腳本只適用Oracle JDK環(huán)境,如果是Open JDK環(huán)境請(qǐng)?zhí)鎿Q包名
// 替換規(guī)則為:com.sun.btrace → org.openjdk.btrace.core
import com.sun.btrace.annotations.*;
import static com.sun.btrace.BTraceUtils.*;
 
@BTrace
public class DirectMemoryMonitor {
    @OnMethod(clazz="java.nio.Bits", method="reserveMemory")
    public static void printThreadStack() {
        println("==============thread dump where reserveMemory invoked!");
            // 打印堆棧信息
        jstack();
    }
}

這段腳本的功能是,在java.nio.Bits類執(zhí)行reserveMemory方法時(shí)執(zhí)行printThreadStack方法(是不是很類似AOP切面),printThreadStack方法會(huì)打印出調(diào)用reserveMemory方法的堆棧信息,這樣就能知道是誰(shuí)在使用堆外內(nèi)存了。

至于為什么監(jiān)控堆外內(nèi)存申請(qǐng)實(shí)際監(jiān)控的是java.nio.Bits#reserveMemory方法,限于篇幅不再繼續(xù)深入了。詳細(xì)原因可以參考DirectByteBuffer構(gòu)造函數(shù)。

運(yùn)行腳本命令是:btrace <pid> DirectMemoryMonitor.java,這樣就可以運(yùn)行BTrace腳本了,不過(guò)在實(shí)際使用中我們更希望腳本能在后臺(tái)運(yùn)行并把腳本運(yùn)行結(jié)果輸出到指定目錄。在這里可以使用nohup(no hang up)命令,命令修改之后如下:

nohup btrace <pid> DirectMemoryMonitor.java > /export/Logs/smart-launch/BTraceReport.log 2>&1 &

腳本輸出日志如下:

從輸出日志可以看出主要是Tomcat在申請(qǐng)堆外內(nèi)存,通過(guò)比較同時(shí)段堆外內(nèi)存監(jiān)控日志,可以發(fā)現(xiàn)Tomcat申請(qǐng)的量不多(上面記錄的874k)。因此,到此可以基本確定與堆外內(nèi)存沒(méi)有什么關(guān)系。

馬后炮:JVM可選參數(shù)中-xx:MaxDirectMemorySize可以限制堆外內(nèi)存。

4. 走投無(wú)路排查篇

堆外內(nèi)存這條線索斷了之后,有點(diǎn)找不著北了,索性來(lái)個(gè)老生常談的堆dump分析。

4.1 堆dump分析

堆dump分析可以說(shuō)是眾多Java內(nèi)存排查手段中最常用的一種,對(duì)某一時(shí)刻對(duì)堆進(jìn)行快照轉(zhuǎn)儲(chǔ)為dump文件(文件后綴名為hprof),然后通過(guò)使用dump文件分析工具就可以知曉當(dāng)時(shí)堆中各個(gè)對(duì)象的使用情況。

4.1.1 獲取堆dump文件

線上機(jī)器生成堆轉(zhuǎn)儲(chǔ)文件可以通過(guò)JDK中jcmd或jmap工具生成,我使用的是jmap,命令如下:

        jmap -dump:[live,]format=b,file=<file-path> <pid>

命令中參數(shù)說(shuō)明:

  • 可選參數(shù)live:如果指定了live參數(shù),則會(huì)在轉(zhuǎn)儲(chǔ)堆前進(jìn)行一次Full GC,會(huì)顯著減少堆轉(zhuǎn)儲(chǔ)文件的大小。
  • format=b:以二進(jìn)制形式轉(zhuǎn)儲(chǔ)堆。
  • file-path:轉(zhuǎn)儲(chǔ)文件路徑,文件后綴名應(yīng)為hprof
  • pid:Java進(jìn)程ID,可以使用jps獲取。

不過(guò),jmap是作為實(shí)驗(yàn)性工具被引入JDK中,官方更推薦使用功能更多性能更強(qiáng)的jcmd替代jmap,這里也介紹下jcmd轉(zhuǎn)儲(chǔ)堆的方式:

        jcmd <pid> GC.heap_dump [-all] <file-path>

加上-all選項(xiàng)與jmap不加live選項(xiàng)轉(zhuǎn)儲(chǔ)行為一致,會(huì)轉(zhuǎn)儲(chǔ)堆中所有對(duì)象,否則會(huì)在堆轉(zhuǎn)儲(chǔ)之前進(jìn)行一次Full GC。

注意:轉(zhuǎn)儲(chǔ)堆會(huì)hold住Java進(jìn)程,進(jìn)行轉(zhuǎn)儲(chǔ)之前請(qǐng)仔細(xì)評(píng)估對(duì)應(yīng)用程序的影響。

4.1.2 分析dump文件

分析dump文件可以使用JDK中jvisualvm和jhat工具,不過(guò),一般來(lái)說(shuō)第三方處理堆轉(zhuǎn)儲(chǔ)的工具都領(lǐng)先于JDK。這里我采用的是MAT(Eclipse Memory Analyzer Tool)進(jìn)行分析。

上圖是MAT對(duì)內(nèi)存異常機(jī)器上堆轉(zhuǎn)儲(chǔ)文件進(jìn)行的一個(gè)分析,從上可以得出:堆Full GC之后的大小,class數(shù)、對(duì)象數(shù)以及加載器數(shù)量。

MAT相比其他堆轉(zhuǎn)儲(chǔ)分析工具最有特點(diǎn)的一點(diǎn)是可以自動(dòng)智能生成各種報(bào)告:疑是內(nèi)存泄露報(bào)告(Leak Suspects)、Top Components報(bào)告等。

查看支配樹(Dominator Tree)一般是分析堆轉(zhuǎn)儲(chǔ)文件的第一步。保留了大量堆空間的對(duì)象一般稱作堆的支配者(Dominator),如果順利發(fā)現(xiàn)有些對(duì)象支配著堆大部分空間,那說(shuō)明就離答案不遠(yuǎn)了。

從支配樹報(bào)告可以看出支配著堆空間前列(按保留內(nèi)存排序)的對(duì)象是:web容器(Tomcat)和Spring框架類相關(guān)對(duì)象。這對(duì)于使用了web容器和Spring框架的項(xiàng)目來(lái)說(shuō)很正常。

對(duì)象的淺內(nèi)存(Shallow Heap)、保留內(nèi)存(Retained Heap)和深內(nèi)存(Deep Heap)

一個(gè)對(duì)象的保留內(nèi)存是指回收該對(duì)象之后可以釋放的內(nèi)存空間量。

一個(gè)對(duì)象的淺內(nèi)存是指該對(duì)象本身大小。如果該對(duì)象包含一個(gè)指定另一個(gè)對(duì)象的引用,則引用大小會(huì)計(jì)入,目標(biāo)對(duì)象大小不會(huì)計(jì)入。

一個(gè)對(duì)象的深內(nèi)存則會(huì)包含那些對(duì)象的大小。

淺內(nèi)存 <= 保留內(nèi)存 <= 深內(nèi)存

第二步一般會(huì)進(jìn)行堆直方圖(Histogram)的分析。

直方圖會(huì)將同一類型的對(duì)象聚合在一起。直方圖前列(按保留內(nèi)存排序)一般是一些基礎(chǔ)對(duì)象,Char、byte、String等。如果在直方圖前列中發(fā)現(xiàn)一些異常多的對(duì)象,尤其是業(yè)務(wù)對(duì)象時(shí),大概就找到內(nèi)存異常點(diǎn)了。

經(jīng)過(guò)上面兩步驟排查,基本確定堆內(nèi)存是沒(méi)有問(wèn)題的,又一次找錯(cuò)了方向。。。

MAT功能不限于上面介紹的這些,更多內(nèi)容可參考https://wiki.eclipse.org/MemoryAnalyzer

4.2 Zip工具包內(nèi)存泄露排查

Zip工具包指的是java.util.zip工具包,此工具包提供文件數(shù)據(jù)解壓縮功能,在網(wǎng)絡(luò)IO和RPC調(diào)用中常有使用。在使用過(guò)程中如果忘記進(jìn)行工具的關(guān)閉操作就會(huì)導(dǎo)致內(nèi)存泄露問(wèn)題。

排查Zip工具包泄露使用的是Btrace腳本,主要監(jiān)控的是Deflater、Inflater、Zip流的初始和關(guān)閉方法。

5. 內(nèi)存跟蹤排查篇

堆內(nèi)存始終正常,堆外內(nèi)存也正常,哪么突增的內(nèi)存去哪了?

5.1 JVM本機(jī)內(nèi)存跟蹤

Native Memory Tracking (NMT) 是Hotspot VM用來(lái)分析VM內(nèi)部?jī)?nèi)存使用情況的一個(gè)功能。我們可以利用jcmd這個(gè)工具來(lái)訪問(wèn)NMT的數(shù)據(jù)。

5.1.1 打開NMT

需要在JVM啟動(dòng)參數(shù)中添加-XX:NativeMemoryTracking參數(shù),可選的值有:

  • summary:只統(tǒng)計(jì)各個(gè)分類的內(nèi)存使用情況
  • detail:統(tǒng)計(jì)各個(gè)分類的內(nèi)存使用情況同時(shí),還會(huì)記錄各個(gè)區(qū)域內(nèi)存分配情況

注意:打開NMT會(huì)帶來(lái)5%-10%的性能損耗。

5.1.2 查看NMT報(bào)告

通過(guò)jcmd工具獲取NMT報(bào)告命令如下:

jcmd <pid> VM.native_memory <option> scale=<KB | MB | GB>

option可選選項(xiàng)有:

  • summary:分類內(nèi)存使用情況
  • detail:詳細(xì)內(nèi)存使用情況
  • baseline:創(chuàng)建內(nèi)存使用快照,方便與后面diff
  • summary.diff:和baseline的summary做diff
  • detail.diff:和baseline的detail做diff

NMT生成的報(bào)告如下:

? NMT分類描述來(lái)源:NMT Memory Categories

5.2 本機(jī)內(nèi)存跟蹤

top命令可以實(shí)時(shí)反映機(jī)器中各個(gè)進(jìn)程的資源占用情況,這里我們主要關(guān)注RES(Resident size)部分。

pmap -x <pid>命令可以得到所監(jiān)控進(jìn)程的地址空間和內(nèi)存分配狀態(tài)。

5.3 Crontab定時(shí)跟蹤內(nèi)存

準(zhǔn)備好上述命令后,可以通過(guò)Crontab定時(shí)執(zhí)行這些命令來(lái)跟蹤內(nèi)存的使用情況。

pid=<pid>
logPath=/export/Logs/smart-launch/logs/
pmap -x ${pid} > ${logPath}/pmap_`date '+%Y%m%d_%H%M%S'`.txt
jcmd ${pid} VM.native_memory detail > ${logPath}/nmt_`date '+%Y%m%d_%H%M%S'`.txt
top -b -n 1 > ${logPath}/top_`date '+%Y%m%d_%H%M%S'`.txt

5.4 報(bào)告diff分析

通過(guò)diff不同時(shí)間段的NMT報(bào)告和pmap報(bào)告可以分析出內(nèi)存增長(zhǎng)分配在哪了。

上面是機(jī)器剛啟動(dòng)時(shí)和機(jī)器內(nèi)存率70%左右時(shí)Java進(jìn)程的pmap diff報(bào)告。

可以看到RSS占用從機(jī)器啟動(dòng)時(shí)的2.1G(2236544KB)后面增長(zhǎng)到了4.8G(5009304KB),大概增加了2.7G。0x6f080000-0x7f080000這塊內(nèi)存空間RSS增長(zhǎng)最多,從1.5G(1592668KB)增長(zhǎng)到了4.0G(4162684KB),大概增加了2.5G,Java進(jìn)程增加的內(nèi)存幾乎都被這個(gè)地址空間“吃掉”了。

那么,這個(gè)地址空間是啥呢?總分配的虛擬內(nèi)存空間為4G,有點(diǎn)耐人尋味,這個(gè)地址空間是不是就是堆?

好了,就不賣關(guān)子了。這個(gè)地址空間就是堆,對(duì)比NMT報(bào)告就可以發(fā)現(xiàn)了,NMT detail部分很清楚的標(biāo)出了0x6f080000-0x7f080000 reserved 4194304KB for Java Heap。(為了驗(yàn)證這段地址是堆,其實(shí)走了不少?gòu)澛罚?/p>

5.5 堆內(nèi)存何時(shí)分配?

xms用于設(shè)置堆初始容量大小,堆會(huì)在內(nèi)存不足時(shí)逐漸擴(kuò)張至xmx指定大小(這個(gè)過(guò)程是可逆的),通常為了避免堆伸縮導(dǎo)致性能問(wèn)題會(huì)把xmxxmx設(shè)置為一致。

這臺(tái)機(jī)器設(shè)置的xmxxmx都為4G,按理來(lái)說(shuō)堆會(huì)恒定為4G內(nèi)存。但是結(jié)合pmap和NMT報(bào)告來(lái)看,堆有一個(gè)內(nèi)存擴(kuò)張的過(guò)程(有點(diǎn)反常識(shí))。

實(shí)際情況是。。。這是一個(gè)正常情況。= . =

原因是:操作系統(tǒng)惰性內(nèi)存分配機(jī)制導(dǎo)致的。JVM指定xmx的初始化堆空間容量操作系統(tǒng)并不會(huì)立即在物理內(nèi)存上分配,而是會(huì)在堆內(nèi)存實(shí)際使用(發(fā)生缺頁(yè)錯(cuò)誤)才進(jìn)行實(shí)際的內(nèi)存分配(這個(gè)過(guò)程省略了很多細(xì)節(jié))。雖然沒(méi)有實(shí)際分配內(nèi)存,但是操作系統(tǒng)向JVM做了一個(gè)承諾(commit):這4G內(nèi)存你什么時(shí)候想用我都可以分配給你。因此這部分內(nèi)存稱為committed內(nèi)存,NMT報(bào)告中就有堆committed=4G。

Reserved Memory、Committed Memory、RSS(Resident Set Size)

Reserved Memory(保留內(nèi)存):指把系統(tǒng)中的一部分內(nèi)存保留起來(lái),內(nèi)核不會(huì)為它建立頁(yè)表,其他應(yīng)用程序無(wú)法訪問(wèn)到這段內(nèi)存。

Committed Memory(已提交內(nèi)存):將保留(Reserve)的內(nèi)存頁(yè)面正式提交(Commit)使用。

RSS(常駐內(nèi)存集):表示進(jìn)程用了具體的多少頁(yè)的內(nèi)存。由于linux系統(tǒng)采用的是虛擬內(nèi)存,進(jìn)程的代碼,庫(kù),堆和棧使用的內(nèi)存都會(huì)消耗內(nèi)存,但是申請(qǐng)出來(lái)的內(nèi)存,只要沒(méi)真正touch過(guò)是不算做RSS,因?yàn)闆](méi)有真正為之分配物理頁(yè)面

5.6 內(nèi)存緩慢增長(zhǎng)和內(nèi)存陡升現(xiàn)象的解釋

內(nèi)存緩慢增長(zhǎng)是因?yàn)閷?duì)象新建是在堆上進(jìn)行的,但是這個(gè)時(shí)候堆未進(jìn)行實(shí)際的內(nèi)存分配。就可能出現(xiàn)這樣一種現(xiàn)象,每次新建一個(gè)對(duì)象,觸發(fā)一次實(shí)際的內(nèi)存分配操作,從內(nèi)存使用率監(jiān)控來(lái)看就是內(nèi)存在緩慢增長(zhǎng)。

內(nèi)存陡升是因?yàn)閼?yīng)用程序經(jīng)過(guò)一段時(shí)間運(yùn)行之后,會(huì)有對(duì)象晉升到老年代,同理也會(huì)觸發(fā)實(shí)際的內(nèi)存分配操作,不過(guò)這次的量不同于每次新建一個(gè)對(duì)象,而是一大批對(duì)象,從內(nèi)存使用率監(jiān)控來(lái)看就是內(nèi)存突然陡升。

從GC日志可以比較好的驗(yàn)證上面的觀點(diǎn):

內(nèi)存陡升時(shí)刻,JVM剛好執(zhí)行了一次Young GC,可以看出這次Young GC停頓時(shí)間非常久,足足停頓了1秒(這對(duì)于一些實(shí)時(shí)服務(wù)會(huì)是災(zāi)難性的影響)。究其原因是這次GC大約有1G的對(duì)象進(jìn)入了老年代,老年代進(jìn)行了一個(gè)非常耗時(shí)的內(nèi)存分配過(guò)程。

5.7 -XX:+AlwaysPreTouch

-XX:+AlwaysPreTouch選項(xiàng)可以在Java應(yīng)用程序啟動(dòng)時(shí)進(jìn)行堆內(nèi)存touch,確保堆內(nèi)存全部駐留在物理內(nèi)存中(over-commit)。

使用此選項(xiàng)會(huì)導(dǎo)致應(yīng)用啟動(dòng)時(shí)間變長(zhǎng),還有內(nèi)存換頁(yè)率上升。不過(guò)對(duì)于主要進(jìn)程是單個(gè)Java進(jìn)程的機(jī)器來(lái)說(shuō),此選項(xiàng)帶來(lái)的性能提升大于此選項(xiàng)帶來(lái)負(fù)面影響(上面提到的GC時(shí)間變長(zhǎng))。

下圖為使用-XX:+AlwaysPreTouch選項(xiàng)啟動(dòng)后的內(nèi)存使用率監(jiān)控。

6 內(nèi)存排查總結(jié)篇

排查一個(gè)多月之后的結(jié)果竟然是一切正常,著實(shí)讓人哭笑不得。不過(guò),前事不忘后事之師。

6.1 正常Java進(jìn)程占用多少內(nèi)存(RSS)?

這里有一個(gè)公式:
Java RSS = Heap + Thread + Metaspace + GC + Code Cache + DirectMemory + Native Libraries + etc.
下面是對(duì)公式中的各個(gè)參數(shù)描述:

  • Heap:指堆已使用的(Used)內(nèi)存,注意和committed內(nèi)存的區(qū)別。(used <= committed)
  • Thread:指線程占用空間,計(jì)算公式為ThreadNum * ThreadSize
  • Metaspace:指元空間大小
  • GC:指定GC結(jié)構(gòu)使用的空間
  • CodeCache:代碼緩存占用空間
  • DirectMemory:堆外內(nèi)存空間
  • NativeLibraries:JVM運(yùn)行中的本地庫(kù)(通常是C庫(kù),如gclib)

這個(gè)公式占大頭的主要是堆,如果一個(gè)Java進(jìn)程線程數(shù)很少且使用少量的堆外內(nèi)存的話,Java進(jìn)程RSS會(huì)比較接近堆內(nèi)存大小。

下面是關(guān)于問(wèn)題機(jī)器某時(shí)刻的內(nèi)存計(jì)算:

  1. Java RSS為4.9G,堆內(nèi)存最后分配完畢為4G,線程占用內(nèi)存為線程數(shù)(500+)乘以1M,因此線程占用大約為0.5G。其他空間占用合計(jì)約0.5G左右。 JavaRSS=4.9G≈4G+0.5G+0.5G
  2. 70%內(nèi)存使用率:使用內(nèi)存為5.6G(0.7*8G),除去Java占用的約5G內(nèi)存,剩下的0.6G被Nginx,其他應(yīng)用程序和操作系統(tǒng)占有。

由上可得70%左右內(nèi)存占用空間是JVM堆全部完成分配之后的一個(gè)正常的內(nèi)存占用率。

6.2 內(nèi)存排查流程

6.2.1 監(jiān)控&保留現(xiàn)場(chǎng)

現(xiàn)有的JVM監(jiān)控和機(jī)器監(jiān)控比較完善了,可以視情況加上相應(yīng)資源監(jiān)控,如這次的堆外內(nèi)存監(jiān)控。

XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath選項(xiàng)必須開啟,在異常狀態(tài)不可復(fù)制的情況下,這可能是唯一可以分析的現(xiàn)場(chǎng)信息了。

-XX:+PrintGCDetails -Xloggc:/export/Logs/gc.log -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC選項(xiàng)開啟GC日志,GC日志在內(nèi)存異常分析中很有用。

6.2.2 使用JDK工具&堆轉(zhuǎn)儲(chǔ)分析工具

工欲善其事必先利其器。

JDK工具中jcmd有“一統(tǒng)天下之勢(shì)”,可以打印Java進(jìn)程所涉及的基本類、線程和VM信息。(Oracle文檔中多次推薦使用此工具)

其他的工具這里也列舉一下,可以根據(jù)情況不同選用:

  • jconsole:提供JVM活動(dòng)信息的GUI工具,線上機(jī)器一般用不上這個(gè)
  • jhat:分析堆轉(zhuǎn)儲(chǔ)文件
  • jmap:提供堆轉(zhuǎn)儲(chǔ)和JVM內(nèi)存使用信息
  • jinfo:查看JVM系統(tǒng)屬性,可以動(dòng)態(tài)設(shè)置一些系統(tǒng)屬性
  • jstack:轉(zhuǎn)儲(chǔ)Java進(jìn)程的棧信息
  • jstat:提供GC和類轉(zhuǎn)載信息
  • jvisulavm:監(jiān)視JVM的GUI工具,可以抓取&分析堆轉(zhuǎn)儲(chǔ)文件

堆轉(zhuǎn)儲(chǔ)分析工具推薦使用第三方工具M(jìn)AT(Eclipse Memory Analyzer Tool)。

6.2.3 Linux命令行&其他性能跟蹤工具

Linux命令行有一些可以提供進(jìn)程的運(yùn)行狀況,比如上面使用到的pmap(查看內(nèi)存映射空間)和top命令,其他的還有ps、free等等。

Btrace是Java問(wèn)題排查過(guò)程中一大利器,不要忘了上面對(duì)它的介紹,幾乎可以獲取Java程序執(zhí)行過(guò)程中的一切信息。

gdb是Linux操作系統(tǒng)下程序調(diào)試工具(主要用來(lái)調(diào)試C/C++代碼)。某些情況下用來(lái)調(diào)試Java應(yīng)用程序也有奇效??梢杂脕?lái)dump指定地址空間內(nèi)存(配合pmap使用),監(jiān)控mallocfree函數(shù)調(diào)用情況等等。

6.3 排查過(guò)程中發(fā)現(xiàn)的一些問(wèn)題

  • 可能的內(nèi)存泄露點(diǎn)

這是內(nèi)存異常機(jī)器上某個(gè)時(shí)間段的支配樹信息,不同于以前支配樹信息前列是web容器和Spring框架類相關(guān)對(duì)象,這里位居第二的是Druid數(shù)據(jù)源對(duì)象,數(shù)據(jù)源對(duì)象中占用內(nèi)存最多的是被稱為stat的對(duì)象,搜索了一下,是Druid監(jiān)控功能實(shí)現(xiàn)的關(guān)鍵。這個(gè)對(duì)象會(huì)記錄SQL語(yǔ)句,并保存在LinkedHashMap結(jié)構(gòu)中,看樣子保存在map里面的數(shù)據(jù)是不會(huì)釋放的,如果記錄時(shí)間長(zhǎng)了,勢(shì)必會(huì)出現(xiàn)內(nèi)存泄露的情況。

在Google上,以“Druid stat 內(nèi)存泄露”為關(guān)鍵字進(jìn)行搜索,果不其然出現(xiàn)一大堆搜索結(jié)果。同時(shí)在Druid GitHub項(xiàng)目中也有一大堆關(guān)于此種內(nèi)存泄露情況的Issue。

然后上面這條記錄的SQL也很有疑點(diǎn),一條SQL語(yǔ)句竟然占用525848B=0.5MB的內(nèi)存,copy到本地編輯器打開:

SELECT  xxx, xxx, xxx, xxx  FROM xx_info  WHERE xx = 1 and  (       (  xx = ?
              and yn = ?
              and xx_id in
              (
                ?
              ,
                ?
              ,
                ?
              ,
                ?
              此處省略 1萬(wàn)+個(gè)?

小朋友,你是否有很多問(wèn)號(hào)???(黑人問(wèn)號(hào))

  • 未命名的線程

請(qǐng)更換為帶有業(yè)務(wù)名稱的線程名,否則排查的時(shí)候就是噩夢(mèng)。

  • 線程池使用的一些問(wèn)題

    @Bean(name = "skuExecutorService")
    public ExecutorService skuExecutorService() {
        return Executors.newFixedThreadPool(50);
    }

這是項(xiàng)目中一段創(chuàng)建固定線程數(shù)量線程池的代碼,看起來(lái)是沒(méi)啥問(wèn)題。但是點(diǎn)開newFixedThreadPool這個(gè)方法:

會(huì)發(fā)現(xiàn)FixedThreadPool使用的是無(wú)界隊(duì)列,有潛在的內(nèi)存溢出問(wèn)題存在。

  • 不合理堆區(qū)分配

jmap -histo輸出某時(shí)刻堆直方圖(Histogram)

jmap -heap輸出同一時(shí)刻堆內(nèi)存占用信息:

項(xiàng)目中會(huì)通過(guò)調(diào)用RPC查詢廣告主所有的sku信息,一般SKU量會(huì)比較大,會(huì)產(chǎn)生大量SkuSeckillInfo對(duì)象。

現(xiàn)在堆區(qū)新生代與老年代的比例是1:2,新生代設(shè)置的比較小,大量SkuSeckillInfo對(duì)象會(huì)充斥整個(gè)新生代,導(dǎo)致更頻繁的Young GC,更頻繁的Young GC勢(shì)必導(dǎo)致對(duì)象晉升老年代速度變快,對(duì)象晉升老年代速度變快勢(shì)必導(dǎo)致Full GC會(huì)比較多。(頻繁的GC意味著累計(jì)更長(zhǎng)的停頓時(shí)間)

擴(kuò)展文檔

最后編輯于
?著作權(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)容