01.16 JVM垃圾回收與一次線上內存洩露問題分析和解決過程

前言

內存洩漏(Memory Leak)是指程序中己動態分配的堆內存由於某種原因程序未釋放或無法釋放,造成系統內存的浪費,導致程序運行速度減慢甚至系統崩潰等嚴重後果。

Java是由C++發展來的,拋棄了C++中一些繁瑣容易出錯的東西,程序員忘記或者錯誤的內存回收會導致程序或系統的不穩定甚至崩潰,而Java的GC(Garbage Collection)是自動檢測不用的對象,達到自動回收,

既然是自動檢測回收不用對象,那Java有沒有可能出現內存洩露的情況呢?

一、JVM判斷垃圾對象方法

Java又是如何知道哪些對象不再使用,需要回收的呢?實際上JVM中對堆內存進行回收時有一套可達性分析算法,該算法的思路就是通過被稱為引用鏈(GC Roots)的對象作為起點,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的,最終不可用的對象會被回收。

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

GC Roots對象可歸納為如下幾種:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象;
  • 方法區中類靜態屬性引用的對象;
  • 方法區中常量引用的對象;
  • 本地方法棧中JNI(即一般說的Native方法)引用的對象;

瞭解了基本JVM如何判斷垃圾對象原則後有助於理解java如何發生內存洩露,由於篇幅有限這裡就不對jvm內存空間劃分和垃圾回收算法詳細的敘述了。

二、 根據現象分析並定位問題

先說說事情的現象吧,本來運行好好的活動項目某一天突然服務報警(當時沒有任何上線),客服陸續收到幾個用戶反饋投訴,查看日誌發現有一臺服務器各種報超時異常、cpu負載高,服務重啟後一切正常,再過一天又是超時異常、cpu負載高。

乍一看現象還有點摸不著頭腦,但有前面的內容聰明的你肯定猜到了什麼原因,如果沒有上述鋪墊,我們根據該現象定位問題呢?

我們一般發現問題,都是從現象到本質,逐步遞進的,如何從現象中提取有用信息加工並做判斷很重要。

異常特徵分析

特徵一、報錯範圍:看到的是大量業務日誌異常,大量操作超時和執行慢,redis超時、數據庫執行超時、調用http接口超時

分析:首先排除是某一個db的問題

網絡問題,ping服務器是通的,無丟包,查看wonder監控後臺網絡無丟包、網卡無故障 --排除網絡問題

服務器cpu問題,top命令發現java應用cpu異常高,查看wonder監控後臺也發現cpu負載高–這裡並不是根本原因,只是現象

特徵二、報錯普遍性:查看其它服務器是否有相同異常,相同的代碼,相同的jvm配置,只有一臺服務器有問題,其它服務器正常

分析:跟這一臺服務器代碼或者系統設置有關係

操作系統設置導致 --這臺服務器是虛擬機,跟其他虛擬機比較,參數配置一樣,排除操作系統設置(當時上來先入為主,就認為是虛擬機配置不一樣導致cpu過高,走了彎路)

負載均衡流量不均導致 --查看wonder監控後臺流量無明顯高,可以排除該原因

該機器運行與其它機器不一樣的業務 --當時認為代碼都一樣的,忽略了定時任務

特徵三、持續性:查看日誌,開始報錯後持續不斷報錯,cpu使用率持續高

分析:不是偶發狀況

查看數據指標

ps: 這裡簡單說一下我們的業務監控後臺,對診斷問題起很大的作用,可以看到cpu、線程、jvm內存等曲線圖

使用的是springboot actuator報點 + prometheus收集 + grafana圖形展示

springboot 只需要加上這兩個包,加上一個配置就行了,零侵入,prometheus定時每10秒一次請求http接口收集數據,也不會對業務產生影響

  1. 基於springboot的業務報點gradle配置:
<code>compile 'org.springframework.boot:spring-boot-starter-actuator'
compile 'io.micrometer:micrometer-registry-prometheus'
複製/<code>

yml配置文件加上:

<code>management:
endpoints:
web:
exposure:
include: health,prometheus

默認報點的url:
http://ip:port/actuator/prometheus/<code>
  1. 安裝prometheus並配置對應的ip和收集的url
  2. 安裝grafana,並去grafana官網 dashboards中下載一個叫“Spring Boot 2.1 Statistics”的模板,導入就能看到漂亮的統計界面了

grafana監控後臺

內存洩露-年輕代的eden區的特徵:

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

內存洩露-老年代的特徵:

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

內存洩露-年輕代的survivor區的特徵:

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

內存洩露-gc Stop The World 曲線圖:

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

有這個圖基本就可以斷定為內存洩露,

3.如何定位問題代碼

1、查詢pid

ps -ef|grep projectName 2、dump當前jvm堆內存(注意:要先切換到啟動java應用的用戶,並且切走流量,因為dump內存會卡住進程)

jmap -dump:live,format=b,file=dump.hprof


3、下載內存分析工具mat (Memory Analyzer Tool)( https://www.eclipse.org/mat/downloads.php),並分析,由於dump下來的內存比較大,建議選擇linux版本,直接在linux上分析

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

//解壓

<code>unzip -o MemoryAnalyzer-1.9.1.20190826-linux.gtk.x86_64.zip
cd mat/<code>

//執行分析命令

<code><code>./ParseHeapDump.sh <dump> org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components
/<dump>/<code>/<code>

最後會生成三個zip文件和一堆index索引文件,下載zip文件到本地,重點看這個文件xxx_Leak_Suspects.zip

打開後看到分析圖:

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

分析結果告訴你哪些類有問題:

JVM垃圾回收與一次線上內存洩露問題分析和解決過程

<code>class關係鏈
--ch.qos.logback.core.spi.AppenderAttachableImpl
----ch.qos.logback.classic.Logger
------org.slf4j.LoggerFactory
--------ch.qos.logback.classic.LoggerContext
----------org.slf4j.impl.StaticLoggerBinder/<code>

我們知道jvm垃圾回收是不會回收gc root對象,StaticLoggerBinder(源碼在底部)是單例對象(方法區中的類靜態屬性引用對象屬於gc root對象),與AppenderAttachableImpl有如上圖的關係鏈,而每一次請求都會new ListAppender(),放入到COWArrayList中,COWArrayList中的對象不斷增長,直到老年代滿,頻繁fullGc導致 Stop The World 執行卡頓,cpu負載居高不下。

最終找到有問題的代碼,是新加入沒多久的公共分佈式cron包(由於cron只會在一臺服務器運行,所以只有一臺服務器內存洩露):

<code>//com.huajiao.common.cron.service.CronServerService中的業務代碼

//構造logger
Logger logger = org.slf4j.LoggerFactory.getLogger(cronBean.getClass());

//這裡為了臨時儲存日誌信息
ListAppender<iloggingevent> listAppender = new ListAppender<>();
((ch.qos.logback.classic.Logger)logger).addAppender(listAppender);
listAppender.start();
複製/<iloggingevent>/<code>
<code>//ch.qos.logback.classic.Logger中的代碼
public final class Logger implements org.slf4j.Logger ,... {
...
transient private AppenderAttachableImpl<iloggingevent> aai;

public synchronized void addAppender(Appender<iloggingevent> newAppender) {
if (aai == null) {
aai = new AppenderAttachableImpl<iloggingevent>();
}
aai.addAppender(newAppender);
}
}/<iloggingevent>/<iloggingevent>/<iloggingevent>/<code>
<code>//ch.qos.logback.core.spi.AppenderAttachableImpl中的代碼

public class AppenderAttachableImpl implements AppenderAttachable {

@SuppressWarnings("unchecked")
final private COWArrayList<appender>> appenderList = new COWArrayList<appender>>(new Appender[0]);

/**
* Attach an appender. If the appender is already in the list in won't be
* added again.
*/
public void addAppender(Appender newAppender) {
if (newAppender == null) {
throw new IllegalArgumentException("Null argument disallowed");
}
appenderList.addIfAbsent(newAppender);
}

...

}
/<appender>/<appender>
/<code>
<code>//org.slf4j.impl.StaticLoggerBinder中的關鍵代碼
public class StaticLoggerBinder implements LoggerFactoryBinder {
//...省略部分代碼

private static StaticLoggerBinder SINGLETON = new StaticLoggerBinder();

static {
SINGLETON.init();
}

private boolean initialized = false;
private LoggerContext defaultLoggerContext = new LoggerContext();

public static StaticLoggerBinder getSingleton() {
return SINGLETON;
}

void init() {
try {
try {
new ContextInitializer(defaultLoggerContext).autoConfig();
} catch (JoranException je) {
Util.report("Failed to auto configure default logger context", je);
}
....省略部分代碼
initialized = true;
} catch (Exception t) { // see LOGBACK-1159
Util.report("Failed to instantiate [" + LoggerContext.class.getName() + "]", t);
}
}

public ILoggerFactory getLoggerFactory() {
if (!initialized) {
return defaultLoggerContext;
}

if (contextSelectorBinder.getContextSelector() == null) {
throw new IllegalStateException("contextSelector cannot be null. See also " + NULL_CS_URL);
}
return contextSelectorBinder.getContextSelector().getLoggerContext();
}
/<code>

修改解決:在finally中刪除ListAppender對象

<code>//構造logger
Logger logger = org.slf4j.LoggerFactory.getLogger(cronBean.getClass());

//這裡為了臨時儲存日誌信息
ListAppender<iloggingevent> listAppender = new ListAppender<>();
((ch.qos.logback.classic.Logger)logger).addAppender(listAppender);
listAppender.start();
try {
....
//此處省略部分業務代碼
} finally {
//刪除
((ch.qos.logback.classic.Logger) logger).detachAppender(listAppender);
MDC.remove("tid");
}/<iloggingevent>/<code>

三、總結

  1. 我們經常會聽到GC調優,實際不管什麼垃圾回收器和回收算法,首先得理解垃圾回收原理,然後保證寫出的代碼沒有問題,不然換垃圾回收器和算法都無法阻止內存溢出問題,加jvm大內存也只不過延遲出現問題時間;
  2. 擅於藉助工具的使用,如果沒有grafana監控後臺、hulk的監控wonder後臺、java自帶工具、mat分析工具很難快速解決問題,在這裡還推薦一個阿里的jvm監控工具Arthas,也是一個不錯的選擇
  3. 查看數據指標作為依據,不能憑空猜測和先入為主(由於當時先入為主,認為是服務器系統的問題而走了彎路,導致解決問題的時間延長),定位問題,還必須要知道java常見的問題和對應的數據指標現象,綜合分析便能迅速找到原因。

例如:

內存洩露:應用佔用cpu高,運行一段時間,頻繁fullGC,但不馬上拋內存溢出;

死鎖:應用佔用cpu高,gc無明顯異常,jstack 命令可以發現deadlock

OOM: 這個看日誌就能看出來,線程過多unable to create Thread,堆溢出:java heap space

某線程佔用cpu高: 通過top命令查找java線程佔用cpu最高的, jstack命令分析線程棧信息

後話

是否有開源的內存洩露靜態分析工具呢?但遺憾的是經調查幾個知名的靜態代碼分析工具findbugs 、SonarQube、Checkstyle等都不能實現內存洩露檢測,只能對編碼規範和部分潛在的bug提前報告,相信將來會有更好的檢測手段對內存洩露防範於未然。

最後

覺得此文不錯的大佬們可以多多關注哦,小玖每週會不定時更新幾篇精品乾貨文,還請大家多多支持!

JVM垃圾回收與一次線上內存洩露問題分析和解決過程


分享到:


相關文章: