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