Java内存泄漏解决之道


Java的核心优势之一是在内置垃圾收集器(简称GC)的帮助下实现自动内存管理。GC隐式地负责分配和释放内存,因此能够处理大多数内存泄漏问题。
虽然GC有效地处理了大部分内存,但它并不能保证内存泄漏的万无一失的解决方案。GC很聪明,但并不完美。即使在尽职尽责的开发人员的应用程序中,内存泄漏仍然可能会泄漏。
仍然可能存在应用程序生成大量多余对象的情况,从而耗尽关键内存资源,有时会导致整个应用程序失败。
内存泄漏是Java中的一个真正问题。在本教程中,我们将了解内存泄漏的潜在原因是什么,如何在运行时识别它们,以及如何在我们的应用程序中处理它们。

什么是内存泄漏
内存泄漏是堆中存在不再使用的对象但垃圾收集器无法从内存中删除它们的情况,因此它们会被不必要地维护。
内存泄漏很糟糕,因为它会阻止内存资源并降低系统性能。如果不处理,应用程序最终将耗尽其资源,最终以致命的java.lang.OutOfMemoryError终止。
堆内存中有两种不同类型的对象 - 引用和未引用。引用的对象是在应用程序中仍具有活动引用的对象,而未引用的对象没有任何活动引用。
垃圾收集器会定期删除未引用的对象,但它永远不会收集仍在引用的对象。
内存泄漏的症状

  • 应用程序长时间连续运行时性能严重下降
  • 应用程序中的OutOfMemoryError堆错误
  • 自发和奇怪的应用程序崩溃
  • 应用程序偶尔会耗尽数据库连接池对象

让我们仔细看看其中一些场景以及如何处理它们。

Java中的内存泄漏类型
在任何应用程序中,由于多种原因都可能发生内存泄漏:
1. 静态字段
可能导致潜在内存泄漏的第一种情况是大量使用静态变量。
在Java中,静态字段的生命周期通常与正在运行的应用程序的整个生命周期相匹配(除非ClassLoader符合垃圾回收的条件)。
让我们创建一个填充静态 List的简单Java程序  :

public class StaticTest {
    public static List<Double> list = new ArrayList<>();
 
    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }
 
    public static void main(String[] args) {
        Log.info(
"Debug Point 1");
        new StaticTest().populateList();
        Log.info(
"Debug Point 3");
    }
}

现在,如果我们在程序执行期间分析堆内存,那么我们将看到调试点1和2之间,正如预期的那样,堆内存增加了。
但是当我们离开populateList()所在的调试点3时,堆内存还没有被垃圾收集。
在上面的程序中,在第2行中,如果我们只删除关键字  static,这次我们离开  populateList()  方法之后,列表的所有内存都被垃圾收集,因为我们没有任何对它的引用。
如何预防呢?

  • 最大限度地减少静态变量的使用
  • 使用单例时,依赖于延迟加载对象而不是急切加载的实现

2. 未关闭的连接池资源
每当我们建立新连接或打开流时,JVM都会为这些资源分配内存。一些示例包括数据库连接,输入流和会话对象。
忘记关闭这些资源可以阻止内存,从而使它们远离GC的范围。如果异常阻止程序执行到达处理代码以关闭这些资源的语句,则甚至可能发生这种情况。
在任何一种情况下,资源留下的开放连接都会消耗内存,如果我们不处理它们,它们可能会降低性能,甚至可能导致OutOfMemoryError。
如何预防呢?

  • 始终使用finally块来关闭资源
  • 关闭资源的代码(甚至在  finally块中)本身不应该有任何异常
  • 使用Java 7+时,我们可以使用try -with-resources块

3. 不正确的equals()和hashCode()实现
在定义新类时,一个非常常见的疏忽是不为equals()和hashCode()方法编写适当的重写方法。
HashSet  和  HashMap  在许多操作中使用这些方法,如果它们没有被正确覆盖,那么它们可能成为潜在的内存泄漏问题的来源。
让我们以一个简单的Person  类为例,  并将其用作HashMap中的键  :

public class Person {
    public String name;
     
    public Person(String name) {
        this.name = name;
    }
}

现在我们将重复的Person对象插入到使用此对象作为键的Map中。
请记住,Map不能包含重复的键:

@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不允许重复键,因此我们作为键插入的众多重复Person对象不应增加内存。
但是由于我们没有定义正确的equals()方法,重复的对象会堆积并增加内存,这就是我们在内存中看到多个对象的原因。
如果我们正确地重写了  equals()  和hashCode()方法,那么在这个Map中只会存在一个Person对象。让我们一起来看看正确实现的equals()和hashCode()方法:

public class Person {
    public String name;
     
    public Person(String name) {
        this.name = name;
    }
     
    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Person)) {
            return false;
        }
        Person person = (Person) o;
        return person.name.equals(name);
    }
     
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}

在这种情况下,以下断言将成立:
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<2; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertTrue(map.size() == 1);
}

另一个例子是使用像Hibernate这样的ORM工具,它使用equals()  和hashCode()方法来分析对象并将它们保存在缓存中。
如果不覆盖这些方法,则内存泄漏的可能性非常高,因为Hibernate将无法比较对象并将使用重复对象填充其缓存。
如何预防呢?

  • 根据经验,在定义新实体时,始终覆盖equals()和hashCode()方法
  • 它不仅仅足以覆盖,但这些方法也必须以最佳方式被覆盖

4.引用外类的内部类
这种情况发生在非静态内部类(匿名类)的情况下。对于初始化,这些内部类总是需要封闭类的实例。
默认情况下,每个非静态内部类都包含对其包含类的隐式引用。如果我们在应用程序中使用这个内部类'对象,那么即使在我们的包含类'对象超出范围之后,它也不会被垃圾收集。
因为内部类对象隐式地保存对外部类对象的引用,从而使其成为垃圾收集的无效候选者。在匿名类的情况下也是如此。
如何预防呢?

  • 如果内部类不需要访问当前包含这个内部类的父类的成员时,请考虑将其转换为静态类

5. finalize()方法
是潜在的内存泄漏问题的另一个来源。每当重写类的  finalize()方法时,该类的对象不会立即被垃圾收集。相反,GC将它们排队等待最终确定,在稍后的时间点才会发送GC。
如果用finalize()方法编写的代码不是最佳的,并且finalize队列无法跟上Java垃圾收集器,那么迟早,我们的应用程序注定要遇到  OutOfMemoryError。
如何预防呢?

  • 我们应该总是避免使用finalize方法

6. 内部字符串
Java 7的重大变化:Java String池在从PermGen转移到HeapSpace了。但是对于在版本6及更低版本上运行的应用程序,在使用大型字符串时我们应该更加专心。
如果我们读取一个庞大的大量String对象,并在该对象上调用intern(),那么它将转到字符串池,它位于PermGen(永久内存)中,并且只要我们的应用程序运行就会保留在那里。这会阻止内存收集并在我们的应用程序中造成重大内存泄漏。
如何预防呢?

  • 解决此问题的最简单方法是升级到最新的Java版本,因为String池从Java版本7开始转移到HeapSpace
  • 如果处理大型字符串,请增加PermGen空间的大小以避免任何潜在的OutOfMemoryErrors:
    -XX:MaxPermSize=512m

7. 使用ThreadLocal
ThreadLocal使我们能够将状态隔离到特定线程,从而允许我们实现线程安全。
使用此构造时,  每个线程将保留对其ThreadLocal变量副本的隐式引用,并且将保留其自己的副本,而不是跨多个线程共享资源,只要该线程处于活动状态即可。
尽管有其优点,ThreadLocal  变量的使用仍存在争议,因为如果使用不当,它们会因引入内存泄漏而臭名昭着。Joshua Bloch  曾评论线程本地用法

“如果在许多地方已经注意到,使用线程池的粗糙使用与ThreadLocal的粗略使用会导致意外的对象保留。但把责任归咎于ThreadLocal是没有根据的。“

内存泄漏与ThreadLocals
一旦保持线程不再存在,ThreadLocals应该被垃圾收集。但是当ThreadLocals与现代应用程序服务器一起使用时,问题就出现了。
现代应用程序服务器使用线程池来处理请求而不是创建新请求(例如  在Apache Tomcat的情况下为Executor)。此外,他们还使用单独的类加载器。
由于 应用程序服务器中的线程池在线程重用的概念上工作,因此它们永远不会被垃圾收集 - 相反,它们会被重用来处理另一个请求。
现在,如果任何类创建  ThreadLocal 变量但未显式删除它,则即使在Web应用程序停止后,该对象的副本仍将保留在工作线程中,从而防止对象被垃圾回收。
如何预防呢?

  • 在不再使用ThreadLocals时清理ThreadLocals是一个很好的做法-  ThreadLocals提供了  remove()方法,该方法删除了此变量的当前线程值
  • 不要使用  ThreadLocal.set(null) 来清除该值  - 它实际上不会清除该值,而是查找与当前线程关联的Map并将键值对设置为当前线程并分别为null
  • 最好将  ThreadLocal 视为需要在finally块中关闭的资源,以  确保它始终关闭,即使在异常的情况下:
  • try {
        threadLocal.set(System.nanoTime());
        //... further processing
    }
    finally {
        threadLocal.remove();
    }

处理内存泄漏的其他策略
虽然在处理内存泄漏时没有一个通用的解决方案,但有一些方法可以最大限度地减少这些泄漏。
1. 启用分析
Java分析器如Java VisualVM是通过应用程序监视和诊断内存泄漏的工具。他们分析我们的应用程序内部发生了什么 - 例如,如何分配内存。
使用分析器,我们可以比较不同的方法,并找到我们可以最佳地使用我们的资源的领域。

2. 增强垃圾收集
通过启用详细垃圾收集,我们将跟踪GC的详细跟踪。要启用此功能,我们需要将以下内容添加到JVM配置中:
-verbose:gc
通过添加此参数,我们可以看到GC内部发生的详细信息。

3. 使用引用对象避免内存泄漏
还可以使用java中的引用对象来构建java.lang.ref包来处理内存泄漏。使用java.lang.ref包,我们使用对象的特殊引用,而不是直接引用对象,这些对象可以很容易地进行垃圾回收。

4. Eclipse内存泄漏警告
对于JDK 1.5及更高版本的项目,Eclipse会在遇到明显的内存泄漏情况时显示警告和错误。因此,在Eclipse中开发时,我们可以定期访问“问题”选项卡,并对内存泄漏警告(如果有)更加警惕

5. 基准测试
我们可以通过执行基准来测量和分析Java代码的性能。这样,我们可以比较替代方法的性能来完成相同的任务。这可以帮助我们选择更好的方法,并可以帮助我们节约记忆。

6. 代码评审
最后,我们总是采用经典的老式方式进行简单的代码演练。
在某些情况下,即使是这种微不足道的方法也可以帮助消除一些常见的内存泄漏问题。