1.什麽是Java中的內存泄漏?
內存泄漏的標準定義是在以下情況下發生的情況:應用程序不再使用對象,但是垃圾收集器無法將它們從工作內存中刪除-因為仍在引用它們。結果,應用程序消耗了越來越多的資源-最終導致致命的OutOfMemoryError問題(OOM)。
為了更好地理解該概念,以下是一個簡單的視覺表示:
如我們所見,有兩種類型的對象-引用對象和未引用對象。垃圾收集器可以刪除未引用的對象。引用的對象將不會被收集,即使應用程序實際上不再使用它們也是如此。
檢測內存泄漏可能很困難。許多工具會執行靜態分析來確定潛在的泄漏,但是這些技術並不是完美的,因為最重要的因素是正在運行的係統的實際運行時(runtime)行為。
因此,讓我們通過分析一些常見情況,重點研究一下防止內存泄漏。
2. Java堆泄漏
在本初始部分中,我們將重點介紹經典的內存泄漏情況-在這種情況下,將連續創建Java對象而不釋放它們。
理解這些情況的一種有利技術是通過以下方式使重現內存泄漏更容易: 設置較小的堆大小。因此,在啟動應用程序時,我們可以調整JVM以適應我們的內存需求:
-Xms<size>
-Xmx<size>
這些參數指定初始Java堆大小以及最大堆大小。
2.1 保持對象引用的靜態字段
可能導致Java內存泄漏的第一種情況是使用靜態字段引用大對象。
讓我們看一個簡單的例子:
private Random random = new Random();
public static final ArrayList list = new ArrayList(1000000);
@Test
public void givenStaticField_whenLotsOfOperations_thenMemoryLeak() throws InterruptedException {
for (int i = 0; i < 1000000; i++) {
list.add(random.nextDouble());
}
System.gc();
Thread.sleep(10000); // to allow GC do its job
}
我們將ArrayList作為一個靜態字段創建-即使在完成使用該字段的計算之後,在JVM進程的生存期內它也永遠不會被JVM內存收集器收集。我們還調用了Thread.sleep(10000)以使GC可以執行完整收集並嘗試回收所有可以回收的內容。
讓我們運行測試並使用分析器分析JVM:
注意,從一開始,當然所有內存都是空閑的。
然後,隻需2秒鍾,迭代過程便會運行並完成-將所有內容加載到列表中(自然地,這取決於運行測試的計算機)。
此後,將觸發一個完整的垃圾回收周期,並繼續執行測試,以允許該周期時間運行並完成。如您所見,該列表不會被回收,內存消耗也不會減少。
現在,我們來看完全相同的示例,隻是這次,ArrayList不會被靜態變量引用。相反,它是一個先創建、使用然後丟棄的局部變量:
@Test
public void givenNormalField_whenLotsOfOperations_thenGCWorksFine() throws InterruptedException {
addElementsToTheList();
System.gc();
Thread.sleep(10000); // to allow GC do its job
}
private void addElementsToTheList(){
ArrayList list = new ArrayList(1000000);
for (int i = 0; i < 1000000; i++) {
list.add(random.nextDouble());
}
}
該方法完成工作後,我們將在下圖大約50秒處觀到主要的GC收集:
請注意,GC現在能夠回收JVM使用的某些內存。
怎麽預防呢?
既然您已經了解了內存泄漏的現象,那麽當然有防止這種情況發生的方法。
首先,我們需要密切注意我們的static用法;將任何集合或大對象聲明為靜態的,會將其生命周期與JVM本身的生命周期聯係起來,並使整個對象圖無法收集。
2.2。在長字符串上調用String.intern()
第二組經常導致內存泄漏的場景涉及字符串操作-特別是String.intern()API。
讓我們看一個簡單的例子:
@Test
public void givenLengthString_whenIntern_thenOutOfMemory()
throws IOException, InterruptedException {
Thread.sleep(15000);
String str
= new Scanner(new File("src/test/resources/large.txt"), "UTF-8")
.useDelimiter("\\A").next();
str.intern();
System.gc();
Thread.sleep(15000);
}
在這裏,我們隻是簡單地嘗試將大文本文件加載到運行的內存中,然後使用.intern()來返回規範形式。
.intern() API將str字符串放置JVM內存池中(無法收集該字符串),將導致GC無法釋放足夠的內存:
我們可以清楚地看到,在最初的15秒內JVM穩定了,然後我們加載文件並由JVM執行垃圾回收(第20秒)。
最後,str.intern()調用將導致內存泄漏-穩定線表示堆內存使用率很高,它將永遠不會釋放。
怎麽預防呢?
請記住,內部字符串對象存儲在PermGen空間—如果我們的應用程序打算執行很多操作大字符串,我們可能需要增加永久代的大小:
-XX:MaxPermSize=<size>
第二種解決方案是使用Java 8-PermGen間由元空間替代-這樣在字符串上使用intern不會導致任何OutOfMemoryError:
2.3 未關閉的流
忘記關閉流是一種非常普遍的情況,當然,大多數開發人員都可能涉及到這種情況。當Java 7中自動關閉所有類型的流的功能引入時,該問題已部分解決。見:try-with-resource子句。
為什麽隻是部分解決?因為的try-with-resources語法是可選的:
@Test(expected = OutOfMemoryError.class)
public void givenURL_whenUnclosedStream_thenOutOfMemory()
throws IOException, URISyntaxException {
String str = "";
URLConnection conn
= new URL("http://norvig.com/big.txt").openConnection();
BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
while (br.readLine() != null) {
str += br.readLine();
}
//
}
讓我們看看從URL加載大文件時應用程序的內存表現:
如我們所見,堆使用率隨著時間的推移逐漸增加-這是由於不關閉流而導致的內存泄漏的直接影響。
讓我們對這種情況進行更深入的研究,因為它不像其他情況那樣一目了然。從技術上講,未關閉的流將導致兩種類型的泄漏-底層資源泄漏和內存泄漏。
底層l資源泄漏僅僅是操作係統級資源的泄漏-例如文件描述符,打開的連接等。這些資源也是可能泄漏的,就像內存一樣。
當然,因為JVM也使用內存來跟蹤這些基礎資源,這就是為什麽資源泄漏也會導致內存泄漏。
怎麽預防呢?
我們始終需要記住手動關閉流,或者利用Java 8中引入的自動關閉功能:
try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
// further implementation
} catch (IOException e) {
e.printStackTrace();
}
在這種情況下,緩衝讀取器會在結束時自動關閉try語句,無需顯式關閉最後塊。
2.4 未關閉的連接
這種情況與前一種情況非常相似,主要區別是處理未關閉的連接(例如,數據庫連接,FTP服務器連接等)。同樣,不正確的實現可能造成很多危害,從而導致內存問題。
讓我們看一個簡單的例子:
@Test(expected = OutOfMemoryError.class)
public void givenConnection_whenUnclosed_thenOutOfMemory()
throws IOException, URISyntaxException {
URL url = new URL("ftp://speedtest.tele2.net");
URLConnection urlc = url.openConnection();
InputStream is = urlc.getInputStream();
String str = "";
//
}
URLConnection保持打開狀態,結果可以預見的是,內存泄漏:
請注意,垃圾收集器無法執行任何操作來釋放未使用但引用的內存。第1分鍾後情況立即就清楚了-GC操作數迅速減少,導致堆內存使用增加,從而導致OutOfMemoryError問題。
怎麽預防呢?
答案很簡單-我們需要始終以規定的方式關閉連接。
2.5 將沒有 hashCode()和 equals()的對象添加到哈希集中
一個可能導致內存泄漏的簡單但非常常見的示例是使用缺少hashCode()或equals()實現的哈希集。
具體來說,當我們開始將重複的對象添加到Set-這隻會不斷增長,而不會像預期的那樣忽略重複項。添加後,我們也將無法刪除這些對象。
讓我們創建一個不帶任何equals或hashCode的簡單類:
public class Key {
public String key;
public Key(String key) {
Key.key = key;
}
}
現在,讓我們看一下場景:
@Test(expected = OutOfMemoryError.class)
public void givenMap_whenNoEqualsNoHashCodeMethods_thenOutOfMemory()
throws IOException, URISyntaxException {
Map<Object, Object> map = System.getProperties();
while (true) {
map.put(new Key("key"), "value");
}
}
這個簡單的實現將在運行時導致以下情況:
注意上圖中顯示的內存泄漏情況。
怎麽預防呢?
在這種情況下,解決方案很簡單-提供hashCode()和equals()實現。
這裏值得一提的工具是Project Lombok—這通過注釋提供了許多默認實現,例如@EqualsAndHashCode。
3.如何在您的應用程序中查找內存泄漏源
診斷內存泄漏是一個漫長的過程,需要大量的實踐經驗、調試技能和詳細的應用程序知識。
讓我們看看除了標準配置文件之外哪些技術可以為您提供幫助。
3.1 詳細跟蹤垃圾回收狀況
識別內存泄漏的最快方法之一是啟用Verbose垃圾回收。
通過添加-verbose:gc參數作為應用程序JVM配置的參數,我們啟用了非常詳細的GC跟蹤。摘要報告顯示在默認錯誤輸出文件中,該文件應有助於您了解如何管理內存。
3.2 做分析
第二種技術是我們在整篇文章中一直使用的技術-這就是分析。最受歡迎的分析器是Visual VM-這是一個開始擺脫命令行 JDK工具並進入輕量級配置文件的好地方。
在本文中,我們使用了另一個分析器-YourKit——與Visual VM相比,它具有一些其他的更高級的功能。
3.3 檢查代碼
最後,與處理內存泄漏的特定技術相比,這是一種更通用的良好實踐。
簡而言之-徹底檢查代碼,定期進行代碼檢查,並充分利用靜態分析工具來幫助您理解代碼和係統。
結論
在本教程中,我們實際研究了JVM上的內存泄漏如何發生。了解這些情況如何發生是處理它們的第一步。
然後,使用技巧和工具來真正了解運行時發生的內存泄漏也很關鍵。靜態分析和認真的代碼檢查也解決很多問題,而運行時將向您顯示無法在代碼中立即識別的更複雜的內存泄漏。
最後,眾所周知,泄漏很難發現和重現,因為許多泄漏僅在高負荷下發生,而高負荷通常在生產中發生。在這裏,您不僅需要進行代碼級別的分析,還需要研究兩個主要方麵-再現和早期檢測。
最好、最可靠的重現內存泄漏方法是借助好的性能測試套件盡可能接近地模擬生產環境的使用模式。
可以在GitHub上找到本教程的完整實現。這是一個基於Maven的項目,因此可以直接將其導入並按原樣運行。