一篇文章帶你全面了解內(nèi)存泄漏
背景
今天這篇文章跟大家聊聊應(yīng)用程序內(nèi)存泄漏相關(guān)的概念、原因以及排查和解決方案。
過完春節(jié)來公司,發(fā)現(xiàn)有幾個(gè)項(xiàng)目出現(xiàn)了很明顯的內(nèi)存泄漏問題。在此之前,一直在趕新功能的開發(fā),項(xiàng)目幾乎每天都在上線發(fā)布新的功能,內(nèi)存泄漏的問題并沒有暴露出來。春節(jié)期間,項(xiàng)目停止了發(fā)布,這一問題便顯現(xiàn)出來了。
項(xiàng)目是基于k8s部署的,有兩個(gè)項(xiàng)目的Pod進(jìn)行了自動(dòng)擴(kuò)容,查看Pod的內(nèi)存使用情況,呈直線上升的趨勢(shì)。
內(nèi)存泄露場景圖
于是,節(jié)后的第一件事便是進(jìn)行內(nèi)存泄漏問題的排查。項(xiàng)目中內(nèi)存泄漏的問題最終找到并解決了,在此期間也調(diào)研和排查了各類內(nèi)存泄漏的問題。本篇文章會(huì)對(duì)解決內(nèi)存泄漏問題中涉及到的理論知識(shí)進(jìn)行梳理和講解,以便大家在遇到類似問題時(shí)可參考解決。
內(nèi)存泄漏與內(nèi)存溢出
在聊內(nèi)存泄漏的時(shí)候,肯定要提一下內(nèi)存溢出,這兩者很容易混淆,但區(qū)分缺失非常明顯的。
內(nèi)存溢出(Out of Memory,簡稱OOM),通俗地來講,就是當(dāng)程序申請(qǐng)內(nèi)存時(shí),沒有足夠的內(nèi)存可以使用了,也就是說程序申請(qǐng)的內(nèi)存大于系統(tǒng)能夠提供的內(nèi)存,此時(shí)就會(huì)出現(xiàn)Out Of Memory的錯(cuò)誤。
內(nèi)存泄漏(Memory Leak),是指程序在申請(qǐng)內(nèi)存后,使用完畢之后,無法釋放對(duì)應(yīng)的內(nèi)存空間。比如,在程序運(yùn)行時(shí),申請(qǐng)分配一部分內(nèi)存給臨時(shí)變量使用,但使用完之后這部分內(nèi)存沒有被手動(dòng)釋放或無法被GC(Java中的垃圾回收)回收,就會(huì)導(dǎo)致此部分內(nèi)存始終被占用,從而導(dǎo)致內(nèi)存泄漏。
一次內(nèi)存泄漏危害可以忽略,但內(nèi)存泄漏堆積后果很嚴(yán)重,因?yàn)闊o論多少內(nèi)存,遲早會(huì)被耗光。最終導(dǎo)致OOM(內(nèi)存溢出)。
在Linux內(nèi)核的操作系統(tǒng)中,當(dāng)系統(tǒng)內(nèi)存嚴(yán)重不足時(shí),還會(huì)觸發(fā)OOM Killer(Out of Memory Killer)機(jī)制,強(qiáng)行釋放進(jìn)程內(nèi)存。這也是某些應(yīng)用程序莫名其妙被Kill的原因之一。
內(nèi)存泄漏分類
了解了內(nèi)存泄漏的基本定義,再來看看內(nèi)存泄漏的場景和分類。
按泄漏頻次分類
如果按照泄漏的頻次特性來劃分,內(nèi)存泄漏可分為4類:
常發(fā)性內(nèi)存泄漏:發(fā)生內(nèi)存泄漏的代碼經(jīng)常會(huì)被執(zhí)行,而每次執(zhí)行都會(huì)導(dǎo)致一定程度的內(nèi)存泄漏。
偶發(fā)性內(nèi)存泄漏:在某些特定環(huán)境或特定分支邏輯中才會(huì)發(fā)生內(nèi)存泄漏。常發(fā)性和偶發(fā)性是相對(duì)的。對(duì)于特定的環(huán)境,偶發(fā)性的也許就變成了常發(fā)性的。因此,測(cè)試環(huán)境和測(cè)試方法對(duì)檢測(cè)內(nèi)存泄漏至關(guān)重要。
一次性內(nèi)存泄漏:發(fā)生內(nèi)存泄漏的代碼只會(huì)被執(zhí)行一次,或者由于算法上的缺陷,導(dǎo)致總會(huì)有且僅有一塊內(nèi)存發(fā)生泄漏。比如,在類的構(gòu)造函數(shù)中分配內(nèi)存,在析構(gòu)函數(shù)中卻沒有釋放該內(nèi)存,此時(shí)內(nèi)存泄漏只會(huì)發(fā)生一次。
隱式內(nèi)存泄漏:程序在運(yùn)行過程中不停地分配內(nèi)存,直到結(jié)束時(shí)才釋放。嚴(yán)格說這里并沒有發(fā)生內(nèi)存泄漏,因?yàn)閮?nèi)存最終被釋放了。但是對(duì)于服務(wù)器程序來說,往往會(huì)運(yùn)行幾天,幾周甚至幾個(gè)月,不及時(shí)釋放內(nèi)存也可能會(huì)導(dǎo)致耗盡系統(tǒng)的內(nèi)存。稱這類內(nèi)存泄漏為隱式內(nèi)存泄漏。
站在用戶的角度來看,內(nèi)存泄漏的影響有限(可能會(huì)產(chǎn)生響應(yīng)慢等情況),但當(dāng)內(nèi)存泄漏堆積到一定程度,耗盡系統(tǒng)內(nèi)存時(shí),往往會(huì)導(dǎo)致服務(wù)器資源的浪費(fèi)(比如,開篇提到的自動(dòng)擴(kuò)容)、響應(yīng)緩慢,甚至OOM和OOM Killer。此時(shí),危害性就比較大了。特別是應(yīng)用系統(tǒng)沒有做自動(dòng)擴(kuò)容恢復(fù)等運(yùn)維措施時(shí)。
對(duì)于上述4類內(nèi)存泄漏,常發(fā)性內(nèi)存泄漏最容易發(fā)現(xiàn)和解決,偶發(fā)性內(nèi)存泄漏次之,最難發(fā)現(xiàn)和排查的當(dāng)屬隱式內(nèi)存泄漏,而且它的危害性非常大。對(duì)于一次性內(nèi)存泄漏,不會(huì)進(jìn)行堆積,相對(duì)而言,影響有限。
按泄漏位置分類
根據(jù)內(nèi)存泄漏在內(nèi)存中的位置分為以下兩類:
- 堆內(nèi)存泄漏:我們經(jīng)常說的內(nèi)存泄漏就是堆內(nèi)存泄漏,在堆上申請(qǐng)了資源,在使用完畢時(shí),沒有將內(nèi)存釋放歸還給OS,從而導(dǎo)致該塊內(nèi)存無法被再次使用。
- 資源泄漏:通常指的是系統(tǒng)資源,比如socket,文件描述符等,這些資源在系統(tǒng)中都是有限制的,如果創(chuàng)建了而不歸還,久而久之,就會(huì)耗盡資源,導(dǎo)致其他程序不可用。
內(nèi)存泄漏的場景
以下以Java語言中的場景來進(jìn)行說明。
1、被長生命周期對(duì)象持有
場景一:在Java中像HashMap、LinkedList等集合類,如果在使用時(shí)將其生命為靜態(tài)變量,那么它們的生命周期將伴隨整個(gè)JVM的生命周期。在這種場景下,如果持續(xù)將對(duì)象放入該類容器,而未進(jìn)行相應(yīng)的移除操作,便會(huì)形成一個(gè)長生命周期(與JVM一樣)的對(duì)象,持有了(大量)短生命周期的對(duì)象,從而導(dǎo)致短生命周期的對(duì)象所占有的內(nèi)存資源無法釋放,從而造成內(nèi)存泄漏問題。
示例如下:
public class TestStaticSet {
static List<Object> list = new ArrayList<>();
public void memoryLeakCase1() {
Object object = new Object();
list.add(object);
}
}
場景二:與上述場景類似的,在使用單例模式時(shí),單例的靜態(tài)對(duì)象也具有與JVM相同的生命周期,如果該靜態(tài)類持有了外部對(duì)象的引用,也會(huì)導(dǎo)致外部對(duì)象無法被釋放,從而造成內(nèi)存泄漏。
場景三:同樣是一個(gè)對(duì)象被長期持有,與上面兩種情況不同的是,該對(duì)象是某個(gè)其他對(duì)象的內(nèi)部類。這樣,不僅被長期持有的內(nèi)部類對(duì)象無法被釋放,就連內(nèi)部類所在的外部類對(duì)象,即便已經(jīng)不再使用,也同樣無法被釋放。
場景四:變量的作用域不同導(dǎo)致的生命周期不同。比如,原本一個(gè)變量的作用域在方法內(nèi)部,但如果將該變量設(shè)置為類級(jí)別的成員變量,此時(shí),原本在方法內(nèi)部使用完即可釋放的內(nèi)存,變?yōu)榕c類對(duì)象生命周期一樣長??赡軙?huì)造成一定程度的內(nèi)存泄漏。
場景五:緩存泄漏。這個(gè)場景屬于場景一的拓展場景。比如將對(duì)象放入緩存(靜態(tài)集合也可以看做是緩存的容器)中,而忽略了緩存不同場景下的大小以及釋放機(jī)制,從而導(dǎo)致一定程度的內(nèi)存泄漏。
以上情況,都可以歸類為由于長生命周期的對(duì)象持有了短生命周期的對(duì)象,而沒有做好釋放操作而導(dǎo)致內(nèi)存泄漏情況的發(fā)生。
2、系統(tǒng)資源型內(nèi)存泄漏
在項(xiàng)目實(shí)踐中會(huì)涉及到各類連接性資源,比如數(shù)據(jù)庫連接、網(wǎng)絡(luò)連接、流和IO連接等。無論什么時(shí)候當(dāng)我們創(chuàng)建一個(gè)連接或打開一個(gè)流,JVM都會(huì)分配內(nèi)存給這些資源。比如,數(shù)據(jù)庫鏈接、輸入流和session對(duì)象。
忘記關(guān)閉這些資源,會(huì)阻塞內(nèi)存,從而導(dǎo)致GC無法進(jìn)行清理。特別是當(dāng)程序發(fā)生異常時(shí),沒有在finally中進(jìn)行資源關(guān)閉的情況。
以數(shù)據(jù)庫操作為例,在對(duì)數(shù)據(jù)庫進(jìn)行操作時(shí),創(chuàng)建的數(shù)據(jù)庫連接使用完畢之后,未調(diào)用對(duì)應(yīng)的close方法進(jìn)行釋放,便會(huì)造成兩個(gè)維度的內(nèi)存泄漏問題。
以數(shù)據(jù)庫連接為例:
第一個(gè)維度,JVM中大量對(duì)象無法釋放。在針對(duì)于數(shù)據(jù)庫的操作中,像Connection
、Statement
、ResultSet
這些對(duì)象都需要顯式地關(guān)閉,如果不關(guān)閉它們,這些對(duì)象不會(huì)被垃圾回收器回收,繼而造成JVM內(nèi)部內(nèi)存的占用不斷增加。這會(huì)導(dǎo)致Java應(yīng)用程序內(nèi)存的不斷消耗,最終可能會(huì)導(dǎo)致內(nèi)存溢出(OutOfMemoryError)。
第二個(gè)維度,數(shù)據(jù)庫連接資源無法釋放。數(shù)據(jù)庫連接是一種寶貴的資源。建立和關(guān)閉數(shù)據(jù)庫連接的開銷很高,通常使用連接池來重復(fù)利用這些連接。如果數(shù)據(jù)庫連接沒有被顯式關(guān)閉,就會(huì)被占用在連接池外部。這會(huì)導(dǎo)致連接池中的可用連接數(shù)量減少,最終可能用盡連接池,導(dǎo)致后續(xù)請(qǐng)求無法獲取到可用的數(shù)據(jù)庫連接,系統(tǒng)的數(shù)據(jù)庫操作因此陷入僵局。
類似這種場景的資源型泄漏還有HTTP連接、操作本地磁盤文件等場景下的資源釋放。特別是針對(duì)異常情況下的資源釋放,否則會(huì)引發(fā)偶發(fā)性或隱式內(nèi)存泄漏。
3、監(jiān)聽器和回調(diào)
內(nèi)存泄漏的常見來源還有監(jiān)聽器和其他回調(diào),如果客戶端在對(duì)應(yīng)的API中注冊(cè)了回調(diào),卻沒有顯示的取消,那么就會(huì)造成積聚,從而引發(fā)內(nèi)存泄漏。這種內(nèi)存泄漏屬于被動(dòng)型的,類似的要處理好服務(wù)端的連接超時(shí)、資源超時(shí)釋放等場景。
針對(duì)上述回調(diào)場景,需要確?;卣{(diào)立即被當(dāng)作垃圾回收的最佳方法是只保存它的弱引用,例如將它們保存成為WeakHashMap中的鍵。
4、不當(dāng)?shù)膃quals方法和hashCode方法實(shí)現(xiàn)
當(dāng)我們定義個(gè)新的類時(shí),往往需要重寫equals方法和hashCode方法。在HashSet和HashMap中的很多操作都用到了這兩個(gè)方法。如果重寫不得當(dāng),會(huì)造成內(nèi)存泄漏的問題。
下面來看一個(gè)具體的實(shí)例:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
}
現(xiàn)在將重復(fù)的Person對(duì)象插入到Map當(dāng)中。我們知道Map的key是不能重復(fù)的。
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
Map<Person, Integer> map = new HashMap<>();
for(int i=0; i<100; i++) {
map.put(new Person("jon"), 1);
}
Assert.assertFalse(map.size() == 1);
}
上述代碼中將Person對(duì)象作為key,存入Map當(dāng)中。理論上當(dāng)重復(fù)的key存入Map時(shí),會(huì)進(jìn)行對(duì)象的覆蓋,不會(huì)導(dǎo)致內(nèi)存的增長。
但由于上述代碼的Person類并沒有重寫equals方法,因此在執(zhí)行put操作時(shí),Map會(huì)認(rèn)為每次創(chuàng)建的對(duì)象都是新的對(duì)象,從而導(dǎo)致內(nèi)存不斷的增長。
VisualVM中顯示信息如下圖:
img
內(nèi)存走勢(shì)圖
當(dāng)重寫equals方法和hashCode方法之后,Map當(dāng)中便只會(huì)存儲(chǔ)一個(gè)對(duì)象了,內(nèi)存泄漏問題也便解決了。
5、使用ThreadLocal場景
ThreadLocal提供了線程本地變量,它可以保證訪問到的變量屬于當(dāng)前線程,每個(gè)線程都保存有一個(gè)變量副本,每個(gè)線程的變量都不同。ThreadLocal相當(dāng)于提供了一種線程隔離,將變量與線程相綁定,從而實(shí)現(xiàn)線程安全的特性。
堆棧結(jié)構(gòu)
ThreadLocal的實(shí)現(xiàn)中,每個(gè)Thread維護(hù)一個(gè)ThreadLocalMap映射表,key是ThreadLocal實(shí)例本身,value是真正需要存儲(chǔ)的Object。
ThreadLocalMap使用ThreadLocal的弱引用作為key,如果一個(gè)ThreadLocal沒有外部強(qiáng)引用來引用它,那么系統(tǒng)GC時(shí),這個(gè)ThreadLocal勢(shì)必會(huì)被回收,這樣一來,ThreadLocalMap中就會(huì)出現(xiàn)key為null的Entry,就沒有辦法訪問這些key為null的Entry的value。
如果當(dāng)前線程遲遲不結(jié)束的話,這些key為null的Entry的value就會(huì)一直存在一條強(qiáng)引用鏈:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永遠(yuǎn)無法回收,造成內(nèi)存泄漏。
如何解決此問題?
第一,使用ThreadLocal提供的remove方法,可對(duì)當(dāng)前線程中的value值進(jìn)行移除;
第二,不要使用ThreadLocal.set(null) 的方式清除value,它實(shí)際上并沒有清除值,而是查找與當(dāng)前線程關(guān)聯(lián)的Map并將鍵值對(duì)分別設(shè)置為當(dāng)前線程和null。
第三,最好將ThreadLocal視為需要在finally塊中關(guān)閉的資源,以確保即使在發(fā)生異常的情況下也始終關(guān)閉該資源。
try {
threadLocal.set(System.nanoTime());
//... further processing
} finally {
threadLocal.remove();
}
內(nèi)存泄漏的檢測(cè)與定位
檢測(cè)和定位內(nèi)存泄漏的方法和場景很多,針對(duì)Java語言中的JVM內(nèi)存泄露的排查,介紹幾種常用的方法:
分析堆轉(zhuǎn)儲(chǔ)(Heap Dump Analysis):通過分析堆轉(zhuǎn)儲(chǔ)文件,可以查看當(dāng)前JVM堆中所有對(duì)象的內(nèi)存占用情況。常用的工具包括VisualVM等。
JConsole和Java Mission Control:這兩個(gè)工具是Java自帶的性能分析工具,可以實(shí)時(shí)監(jiān)控JVM的性能指標(biāo),包括堆使用情況、垃圾收集情況等。通過這兩個(gè)工具,可以快速定位內(nèi)存泄露的問題。
GC日志分析:垃圾收集器的日志文件中記錄了每次垃圾收集的信息,通過分析這些日志文件,可以找出哪些對(duì)象占用了大量內(nèi)存并且無法被回收。
代碼審查:通過仔細(xì)審查代碼,特別是關(guān)注那些可能導(dǎo)致對(duì)象長時(shí)間被引用的代碼,可以發(fā)現(xiàn)潛在的內(nèi)存泄露問題。
小結(jié)
根據(jù)上述案例及場景的分析,我們可以看到,導(dǎo)致內(nèi)存泄漏的場景非常多,但最終歸結(jié)成一句話就是內(nèi)存泄漏本身的定義:程序在申請(qǐng)內(nèi)存后,使用完畢之后,無法釋放對(duì)應(yīng)的內(nèi)存空間。
因此,在具體實(shí)踐的過程中,針對(duì)本文所述場景以及其他涉及資源、內(nèi)存使用的場景要特別留意一下,做好正常、異常邏輯下各類資源的釋放操作。
當(dāng)然,如果內(nèi)存泄漏已經(jīng)發(fā)生,在尋找內(nèi)存泄漏的問題點(diǎn)時(shí),除了全面定點(diǎn)排查項(xiàng)目中涉及到資源使用的情況之外,還可以結(jié)合具體的編程語言(比如,Java的VisualVM等)的內(nèi)存分析工具進(jìn)行來定位導(dǎo)致內(nèi)存泄漏的地方。關(guān)于各類工具的使用及內(nèi)存分析,本篇文章就不再展開。