內存泄漏(Memory Leak)是指程序中己動態分配的堆內存由於某種原因程序未釋放或無法釋放,造成系統內存的浪費,導致程序運行速度減慢甚至系統崩潰等嚴重后果。
Java是由C++發展來的,拋棄了C++中一些繁瑣容易出錯的東西,程序員忘記或者錯誤的內存回收會導致程序或系統的不穩定甚至崩潰,而Java的GC(Garbage Collection)是自動檢測不用的對象,達到自動回收,
既然是自動檢測回收不用對象,那Java有沒有可能出現內存泄露的情況呢?
一、JVM判斷垃圾對象方法
Java又是如何知道哪些對象不再使用,需要回收的呢?實際上JVM中對堆內存進行回收時有一套可達性分析算法,該算法的思路就是通過被稱為引用鏈(GC Roots)的對象作為起點,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的,最終不可用的對象會被回收。
GC Roots對象可歸納為如下幾種:
虛擬機棧(棧幀中的本地變量表)中引用的對象;
方法區中類靜態屬性引用的對象;
方法區中常量引用的對象;
本地方法棧中JNI(即一般說的Native方法)引用的對象;
了解了基本JVM如何判斷垃圾對象原則后有助於理解java如何發生內存泄露,由於篇幅有限這里就不對jvm內存空間划分和垃圾回收算法詳細的敘述了。
二、 根據現象分析並定位問題
先說說事情的現象吧,本來運行好好的活動項目某一天突然服務報警(當時沒有任何上線),客服陸續收到幾個用戶反饋投訴,查看日志發現有一台服務器各種報超時異常、cpu負載高,服務重啟后一切正常,再過一天又是超時異常、cpu負載高。
乍一看現象還有點摸不着頭腦,但有前面的內容聰明的你肯定猜到了什么原因,如果沒有上述鋪墊,我們根據該現象定位問題呢?
我們一般發現問題,都是從現象到本質,逐步遞進的,如何從現象中提取有用信息加工並做判斷很重要。
1.異常特征分析
特征一、報錯范圍:看到的是大量業務日志異常,大量操作超時和執行慢,redis超時、數據庫執行超時、調用http接口超時
分析:首先排除是某一個db的問題
1.網絡問題,ping服務器是通的,無丟包,查看wonder監控后台網絡無丟包、網卡無故障 --排除網絡問題
2.服務器cpu問題,top命令發現java應用cpu異常高,查看wonder監控后台也發現cpu負載高--這里並不是根本原因,只是現象
特征二、報錯普遍性:查看其它服務器是否有相同異常,相同的代碼,相同的jvm配置,只有一台服務器有問題,其它服務器正常
分析:跟這一台服務器代碼或者系統設置有關系
1.操作系統設置導致 --這台服務器是虛擬機,跟其他虛擬機比較,參數配置一樣,排除操作系統設置(當時上來先入為主,就認為是虛擬機配置不一樣導致cpu過高,走了彎路)
2.負載均衡流量不均導致 --查看wonder監控后台流量無明顯高,可以排除該原因
3.該機器運行與其它機器不一樣的業務 --當時認為代碼都一樣的,忽略了定時任務
特征三、持續性:查看日志,開始報錯后持續不斷報錯,cpu使用率持續高
分析:不是偶發狀況
2.查看數據指標
ps: 這里簡單說一下我們的業務監控后台,對診斷問題起很大的作用,可以看到cpu、線程、jvm內存等曲線圖
使用的是springboot actuator報點 + prometheus收集 + grafana圖形展示
springboot 只需要加上這兩個包,加上一個配置就行了,零侵入,prometheus定時每10秒一次請求http接口收集數據,也不會對業務產生影響
1. 基於springboot的業務報點gradle配置:
compile 'org.springframework.boot:spring-boot-starter-actuator'
compile 'io.micrometer:micrometer-registry-prometheus'
yml配置文件加上:
management:
endpoints:
web:
exposure:
include: health,prometheus
默認報點的url:
http://ip:port/actuator/prometheus
2. 安裝prometheus並配置對應的ip和收集的url
3. 安裝grafana,並去grafana官網 dashboards中下載一個叫“Spring Boot 2.1 Statistics”的模板,導入就能看到漂亮的統計界面了
grafana監控后台
內存泄露-年輕代的eden區的特征:
內存泄露-老年代的特征:
內存泄露-年輕代的survivor區的特征:
內存泄露-gc Stop The World 曲線圖:
有這個圖基本就可以斷定為內存泄露,
3.如何定位問題代碼
1、查詢pid
ps -ef|grep projectName
2、dump當前jvm堆內存(注意:要先切換到啟動java應用的用戶,並且切走流量,因為dump內存會卡住進程)
jmap -dump:live,format=b,file=dump.hprof <pid>
3、下載內存分析工具mat (Memory Analyzer Tool)(https://www.eclipse.org/mat/downloads.php),並分析,由於dump下來的內存比較大,建議選擇linux版本,直接在linux上分析
//解壓
unzip -o MemoryAnalyzer-1.9.1.20190826-linux.gtk.x86_64.zip
cd mat
//執行分析命令
./ParseHeapDump.sh <dump文件> org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components
最后會生成三個zip文件和一堆index索引文件,下載zip文件到本地,重點看這個文件xxx_Leak_Suspects.zip
打開后看到分析圖:
分析結果告訴你哪些類有問題:
class關系鏈
--ch.qos.logback.core.spi.AppenderAttachableImpl
----ch.qos.logback.classic.Logger
------org.slf4j.LoggerFactory
--------ch.qos.logback.classic.LoggerContext
----------org.slf4j.impl.StaticLoggerBinder
我們知道jvm垃圾回收是不會回收gc root對象,StaticLoggerBinder(源碼在底部)是單例對象(方法區中的類靜態屬性引用對象屬於gc root對象),與AppenderAttachableImpl有如上圖的關系鏈,而每一次請求都會new ListAppender(),放入到COWArrayList中,COWArrayList中的對象不斷增長,直到老年代滿,頻繁fullGc導致 Stop The World 執行卡頓,cpu負載居高不下。
最終找到有問題的代碼,是新加入沒多久的公共分布式cron包(由於cron只會在一台服務器運行,所以只有一台服務器內存泄露):
//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();
//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);
}
}
//ch.qos.logback.core.spi.AppenderAttachableImpl中的代碼
public class AppenderAttachableImpl<E> implements AppenderAttachable<E> {
final private COWArrayList<Appender<E>> appenderList = new COWArrayList<Appender<E>>(new Appender[0]);
/**
* Attach an appender. If the appender is already in the list in won't be
* added again.
*/
public void addAppender(Appender<E> newAppender) {
if (newAppender == null) {
throw new IllegalArgumentException("Null argument disallowed");
}
appenderList.addIfAbsent(newAppender);
}
...
}
//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();
}
修改解決:在finally中刪除ListAppender對象
//構造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");}
三、總結
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提前報告,相信將來會有更好的檢測手段對內存泄露防范於未然。
參考鏈接:
https://www.dynatrace.com/resources/ebooks/javabook/how-garbage-collection-works/