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