原創(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è)定義:
- 堆內(nèi)存:指JVM Heap區(qū)域,可以通過(guò)-xms和-xmx為其設(shè)定大小
- 本機(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ì)把xmx和xmx設(shè)置為一致。
這臺(tái)機(jī)器設(shè)置的xmx和xmx都為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è)公式:
下面是對(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ì)算:
- 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
- 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)控malloc和free函數(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ò)展文檔
- MAT使用手冊(cè):https://wiki.eclipse.org/MemoryAnalyzer
- jcmd使用手冊(cè):https://docs.oracle.com/javase/8/docs/technotes/tools/windows/jcmd.html
- NMT使用手冊(cè):https://docs.oracle.com/javase/8/docs/technotes/guides/vm/nmt-8.html
- gdb排查內(nèi)存泄露:https://www.ibm.com/support/pages/linux-gdb-identify-memory-leaks
- 堆內(nèi)存與RSS不一致問(wèn)題:https://stackoverflow.com/questions/48982636/java-heap-xms-and-linux-free-memory-different
- Java故障排查手冊(cè):https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/index.html
- HotSpot VM選項(xiàng):https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html