什么是 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对象并且不持有对它的引用,我们也可以很容易地看到垃圾收集器无法清理内存。相反,它不断增长:
![](https://img.haomeiwen.com/i23850670/c2307ba843963f53.jpg)
如果看不到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块也会一直执行,从而将数据从内存中删除。
网友评论