什么是 Java 中的內存泄漏
內存泄漏是一個對象或多個對象不再被使用,但同時又不能被持續工作的垃圾收集器移除的情況。
我們可以將內存中的對象分為兩大類:
- 引用的對象是可以從我們的應用程序代碼中訪問并且正在或將要使用的對象。
- 未引用的對象是應用程序代碼無法訪問的對象。
垃圾收集器最終會從堆中刪除未引用的對象,為新的對象騰出空間,但不會刪除引用的對象,因為它們被認為很重要。這樣的對象會讓Java堆內存越來越大,推動垃圾收集做更多的工作。這將通過拋出 OutOfMemory
異常導致應用程序變慢甚至最終崩潰。
內存泄漏的癥狀
有一些癥狀可以讓懷疑 Java 應用程序正在遭受內存泄漏,最常見的:
- 應用程序運行時出現
Java OutOfMemory
錯誤。 - 當應用程序運行較長時間并且在應用程序啟動后不存在時性能下降。
- 應用程序運行的時間越長,垃圾收集時間越長。
- 連接用盡。
內存泄漏的類型
靜態字段保持對象參考
Java 內存泄漏的最簡單示例之一是通過未清除的靜態字段引用的對象。例如,一個靜態字段包含永遠不會清除或丟棄的對象集合。
可以使用以下代碼演示此類行為的一個簡單示例:
public class StaticReferenceLeak {
public static List<Integer> NUMBERS = new ArrayList<>();
public void addBatch() {
for (int i = 0; i < 100000; i++) {
NUMBERS.add(i);
}
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1000000; i++) {
(new StaticReferenceLeak()).addBatch();
System.gc();
Thread.sleep(10000);
}
}
}
addBatch方法將100000 個整數添加到名為NUMBERS的集合中。這種情況下,我們永遠不會刪除它。即使我們在 main 方法中創建了StaticReferenceLeak對象并且不持有對它的引用,我們也可以很容易地看到垃圾收集器無法清理內存。相反,它不斷增長:
如果看不到StaticReferenceLeak類的實現細節,會想著對象使用的內存被釋放,但并非如此,因為NUMBERS集合是靜態的。如果它不是靜態的就沒有問題,所以在使用靜態變量時要格外小心。
如何避免:為了避免和潛在地防止這種類型的 Java 內存泄漏,應該盡量減少靜態變量的使用。如果必須使用,在不再需要時從靜態集合中刪除數據。
未封閉的資源
訪問位于遠程服務器上的資源、打開文件并處理它們等的情況并不少見。此類代碼需要在我們的代碼中打開流、連接或文件。但必須記住,我們不僅要負責打開資源,還要負責關閉它。否則,我們的代碼可能會泄漏內存,最終導致 OutOfMemory 錯誤。
為了說明這個問題,讓我們看看下面的例子:
public class UnclosedResources {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1000000; i++) {
URL url = new URL("http://www.google.com");
URLConnection conn = url.openConnection();
InputStream is = conn.getInputStream();
}
}
}
上述循環的每次運行都會導致URLConnection實例被打開和引用,從而導致資源(內存)緩慢耗盡。
如何避免它:要么記住使用 try-finally
塊,要么更新 Java 版本使用try-with-resources
代碼塊。
使用不正確的 equals() 和 hashCode() 實現的對象
Java 內存泄漏的另一個常見示例是使用具有未正確實現(或根本不存在)的自定義equals() 和hashCode() 方法的對象,以及使用散列檢查重復項的集合。這種集合的一個例子是HashSet。
為了說明這個問題,讓我們看一下下面的例子:
public class HashAndEqualsNotImplemented {
public static void main(String[] args) {
Set<Entry> set = new HashSet<>();
for (int i = 0; i < 1000; i++) {
set.add(new Entry("test"));
}
System.out.println(set.size());
}
}
class Entry {
public String entry;
public Entry(String entry) {
this.entry = entry;
}
}
在深入解釋之前,先問自己一個簡單的問題:調用System.out.println(set.size()) 時代碼將打印的數字是多少?如果你的答案是1000,那么你是對的。那是因為我們沒有正確實現equals方法。 這意味著添加到HashSet的 Entry對象的每個實例都會被添加,無論從我們的角度來看是否是重復的。這可能會導致 OutOfMemory 異常。
如果我們用正確的實現來改變我們的代碼,代碼將導致打印1作為我們的HashSet的大小。舉個例子,下面是JetBrains IntelliJ 實現的equals() 和hashCode()方法的代碼:
public class HashAndEqualsNotImplemented {
public static void main(String[] args) {
Set<Entry> set = new HashSet<>();
for (int i = 0; i < 1000; i++) {
set.add(new Entry("test"));
}
System.out.println(set.size());
}
}
class Entry {
public String entry;
public Entry(String entry) {
this.entry = entry;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Entry entry1 = (Entry) o;
return Objects.equals(entry, entry1.entry);
}
@Override
public int hashCode() {
return Objects.hash(entry);
}
}
如何避免:根據經驗,在創建類時正確實現equals() 和hashCode() 方法。
引用外部類的內部類
內部私有類保持對其父類的引用。考慮以下場景:
public class OuterClass {
private InnerClass inner;
public void create() {
inner = new InnerClass();
}
class InnerClass {
}
}
假設OuterClass包含對大量占用內存對象的引用,即使不再使用它也不會被垃圾回收。因為InnerClass對象將隱式引用OuterClass,這使得它沒有資格進行垃圾收集。
如何避免:將內部類轉換為靜態將解決該問題。還可以考慮是否確實需要內部私有類,也許可以使用不同的架構模式。
ThreadLocals
應用程序服務器或 servlet 容器使用線程池來控制可以并發運行的線程數,從而一遍又一遍地重用相同的線程。在這種情況下,線程被重用并且不會被垃圾回收,因為對線程的引用一直保存在池本身中。
如何避免:ThreadLocal 提供了remove() 方法,該方法為該變量刪除當前線程的值,從而有效地清除數據。甚至可以在finally塊中清除 ThreadLocal 中的數據,這樣即使代碼執行過程中發生異常,finally塊也會一直執行,從而將數據從內存中刪除。