1. 引言

Java 的核心优势之一是借助 JVM 内置的垃圾收集器(或简称 GC)的自动内存管理。GC 隐式地负责分配和释放内存,因此能够处理大多数内存泄漏问题。

尽管 GC 有效地处理了很大一部分内存,但它不能保证针对内存泄漏的万无一失的解决方案。 GC 非常聪明,但并非完美无缺。即使在有责任心的开发人员的应用程序中,内存泄漏仍然可能存在。

在某些情况下,应用程序会生成大量多余的对象,从而耗尽关键的内存资源,有时会导致整个应用程序的故障。

内存泄漏是 Java 中的真正问题。在本教程中,我们将看到导致内存泄漏的潜在原因是什么,如何在运行时识别它们,以及如何在应用程序中对其进行处理。

Mark & sweep GC

2. 什么是内存泄漏

内存泄漏指当堆中存在不再使用的对象,但是垃圾回收器无法将其从内存中删除的情况,因此不必要地对其进行了维护。

内存泄漏很严重,因为它会阻塞内存资源并随着时间的推移降低系统性能。如果不加以处理,该应用程序最终将耗尽其资源,最终以致命的 java.lang.OutOfMemoryError 终止。

堆内存中有两种不同类型的对象-已引用和未引用。引用对象是那些在应用程序中仍具有活动引用的对象,而未引用的对象则没有任何活动引用。

垃圾收集器会定期删除未引用的对象,但是它永远不会收集仍在引用的对象。这是可能发生内存泄漏的地方:

Memory Leak

Symptoms of a Memory Leak

  • 当应用程序长时间连续运行时,性能严重下降
  • 应用程序中的 OutOfMemoryError 堆错误
  • 自发和奇怪的应用程序崩溃
  • 该应用程序偶尔会用完连接对象

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

3. Java 中的内存泄漏类型

在任何应用程序中,由于多种原因都会发生内存泄漏。在本节中,我们将讨论最常见的那些。

3.1 静态字段的内存泄漏

可能导致潜在内存泄漏的第一种情况是大量使用静态变量。

在 Java 中,静态字段的寿命通常与正在运行的应用程序的整个寿命匹配(除非 ClassLoader 符合垃圾收集的条件)。

让我们创建一个填充静态列表的简单 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 之间,按预期,堆内存增加了。

但是,当我们在调试点 3 保留 populateList() 方法时,堆内存尚未被垃圾回收,正如我们在此 VisualVM 响应中看到的那样:

Memory with static

但是,在上面的程序的第 2 行中,如果我们仅删除关键字 static,那么它将对内存使用量带来巨大的变化,此 Visual VM 响应显示:

Memory without static

调试点之前的第一部分几乎与我们在静态情况下获得的结果相同。但是这一次我们离开 populateList() 方法之后,列表的所有内存都被垃圾回收了,因为我们没有对其的任何引用。

因此,我们需要非常注意静态变量的使用。如果将集合或大型对象声明为静态,那么它们会在应用程序的整个生命周期中保留在内存中,从而阻塞了本来可以在其他地方使用的重要内存。

如何预防?

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

3.2 未关闭资源

每当我们建立新连接或打开流时,JVM 就会为这些资源分配内存。一些示例包括数据库连接,输入流和会话对象。

忘记关闭这些资源可能会阻塞内存,从而使它们无法进入 GC。甚至在发生异常的情况下也会发生这种情况,该异常会阻止程序执行到达处理关闭这些资源的代码语句。

在任何一种情况下,资源留下的开放连接都会消耗内存,如果我们不处理它们,它们可能会降低性能,甚至可能导致 OutOfMemoryError

@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 加载大文件时应用程序的内存情况:

Java unclosed streams memory leak

如我们所见,堆使用率随着时间逐渐增加 – 这是由于不关闭流而导致的内存泄漏的直接影响。

如何预防?

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

3.3 equalshashcode() 实现不当

在定义新类时,一个非常普遍的疏忽是没有为 equals()hashCode() 方法编写适当的重写方法。

HashSetHashMap 在许多操作中使用这些方法,如果未正确覆盖它们,则它们可能成为潜在内存泄漏问题的根源。

让我们以一个简单的 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 作为键。由于 Map 不允许使用重复的键,因此,作为键插入的大量重复的 Person 对象不应增加内存。

但是,由于我们尚未定义适当的 equals() 方法,因此重复的对象堆积并增加了内存,这就是为什么我们在内存中看到多个对象的原因。为此,VisualVM 中的堆内存如下所示:

Before implementing equals and hashcode

但是,如果我们正确地重写了equals()hashCode() 方法,则此 Map 中将仅存在一个 Person 对象。

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;
    }
}	

正确覆盖 equals()hashCode() 之后,同一程序的堆内存如下所示:

After implementing equals and hashcode

如果不重写这些方法,则发生内存泄漏的可能性非常高,因为 Hibernate 无法比较对象,并会使用重复的对象填充其缓存。

如何预防?

  • 根据经验,定义新实体时,请始终覆盖 equals()hashCode() 方法
  • 不仅要覆盖,而且还必须以最佳方式覆盖这些方法

3.4 使用非静态内部类

这是在非静态内部类(匿名类)的情况下发生的。为了进行初始化,这些内部类始终需要封闭类的实例。

默认情况下,每个非静态内部类都有对其包含类的隐式引用。如果我们在应用程序中使用此内部类的对象,则即使在包含类的对象超出范围后,也不会对其进行垃圾回收。

考虑一个类其中包含对大量笨重对象的引用,并且具有一个非静态内部类。现在,当我们创建内部类的对象时,内存模型如下所示:

Inner Classes That Reference Outer Classes

但是,如果仅将内部类声明为静态,则相同的内存模型如下所示:

Static Classes That Reference Outer Classes

发生这种情况是因为内部类对象隐式持有对外部类对象的引用,从而使其成为垃圾回收的无效候选对象。对于匿名类,也会发生同样的情况。

如何预防?

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

3.5 通过 finalize() 方法

使用终结器是潜在的内存泄漏问题的另一个来源。每当覆盖类的 finalize() 方法时,该类的对象不会立即被垃圾收集。取而代之的是, GC 将它们排入队列以进行最终确定,这将在以后的某个时间点进行。

另外,如果用 finalize() 方法编写的代码不是很优化,并且终结器队列无法跟上 Java 垃圾收集器的速度,那么迟早,我们的应用程序注定会遇到 OutOfMemoryError

为了说明这一点,让我们考虑一下我们有一个覆盖了 finalize() 方法的类,并且该方法需要一点时间来执行。

finalize method overridden

但是,如果只删除覆盖的 finalize() 方法,则同一程序将给出以下响应:

finalize method not overridden

如何预防?

  • 我们应该始终避免使用终结器

3.6 字符串常量池

Java 7 之前,JVMJava 字符串池放置在永久代 PermGen 空间中,该空间的大小是固定的 - 无法在运行时扩展,并且不适合进行垃圾回收。

默认的字面量创建方法和 String 的 intern 方法会在字符串常量池保留字符串。

PermGen(而不是堆 Heap)中保留 String 的风险是,如果我们保留过多的 String,我们可能会从 JVM 中获得 OutOfMemory 错误。

Java 7 开始,Java 字符串池**存储在 Heap 空间中,该空间由 JVM 进行垃圾回收。**这种方法的优点是减少了 OutOfMemory 错误的风险,因为未引用的字符串将从池中删除,从而释放内存。

如何预防?

  • 解决此问题的最简单方法是升级到最新的 Java 版本

  • 或者要在 6 之前使用大量字符串或长字符串,请增加 PermGen 空间的大小,以避免任何潜在的 OutOfMemoryErrors

    -XX:MaxPermSize=512m

3.7 使用 ThreadLocal

ThreadLocal 是一种使我们能够将状态隔离到特定线程,从而使我们能够实现线程安全的结构。

使用此构造时,每个线程将保留对其 ThreadLocal 变量副本的隐式引用,并将维护自己的副本,只要线程是活动的。

ThreadLocal

实线代表强引用,虚线代表弱引用(WeakReference

JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在 java 中,用 java.lang.ref.WeakReference 类来表示。可以在缓存中使用弱引用。

尽管有其优点,但使用 ThreadLocal 变量还是有争议的,因为如果使用不当,它们会导致内存泄漏。

ThreadLocal 导致内存泄漏

一旦持有线程不再存活,就应该对 ThreadLocals 进行垃圾回收。但是,将 ThreadLocals 与现代应用程序服务器一起使用时会出现问题。

现代应用程序服务器使用线程池来处理请求,而不是创建新请求(例如,对于 Apache TomcatExecutor)。此外,他们还使用单独的类加载器。

由于应用程序服务器中的线程池使用线程重用的概念,因此它们永远不会被垃圾回收 - 而是被重用以满足另一个请求。

现在,如果任何类创建了一个 ThreadLocal 变量,但未明确删除它,那么即使在 Web 应用程序停止后,该对象的副本也将与工作线程一起保留,从而阻止垃圾回收该对象。

从上图中可以看出,ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 不存在外部强引用时,Key(ThreadLocal) 势必会被 GC 回收,这样就会导致 ThreadLocalMapkeynull, 而 value 还存在着强引用,只有 thead 线程退出以后,value 的强引用链条才会断掉。

但如果当前线程再迟迟不结束的话,这些 keynullEntryvalue 就会一直存在一条强引用链:

Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value

永远无法回收,造成内存泄漏。

如何预防?

  • 在不再使用 ThreadLocal 时清理是一个好习惯 - ThreadLocal 提供了remove() 方法,该方法将删除此变量的当前线程值

  • 不要使用 ThreadLocal.set(null) 来清除值 - 实际上并不会清除值,而是查找与当前线程关联的 Map 并将键值对分别设置为当前线程和 null

  • 最好将 ThreadLocal 视为需要在 finally 块中关闭的资源,以确保即使在发生异常的情况下也始终关闭该资源:

    try {
        threadLocal.set(System.nanoTime());
        //... further processing
    }
    finally {
        threadLocal.remove();
    }
    

3.8 过期引用

虽然绝大多数情况下我们在编写代码时不必考虑内存的回收,但是一旦我们自身管理了这些内存,垃圾回收可能就无法正常工作。让我们先来看一下一个简单的 Stack 类的实现,看看能不能找出内存泄漏的问题:

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITAL_CAPACITY = 16;
    
    public Stack() {
        elements = new Object[DEFAULT_INITAL_CAPACITY];
    }
    
    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }
    
    public Object pop() {
        if(size == 0) {
            throw new EmptyStackException();
        }
        return elements[--size];
    }
    
    private void ensureCapacity() {
        if(elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }
}

那么它的问题在哪里?如果堆栈增大然后缩小,弹出的对象被外部程序引用,那么即使使用该堆栈的程序没有引用该对象,从堆栈弹出的对象也不会被垃圾回收。 这是因为虽然弹出了对象的引用,但是堆栈自身还保留对这些对象的过时引用 (obsolete reference)所以垃圾回收器永远不会回收这部分对象。

垃圾收集语言中的内存泄漏(更适当地称为无意的对象保留 unintentional object retentions)是隐蔽的。 如果无意中保留了对象引用,那么不仅这个对象排除在垃圾回收之外,而且该对象引用的任何对象也是如此。 即使只有少数对象引用被无意地保留下来,也可以阻止垃圾回收机制对许多对象的回收,这对性能产生很大的影响。

此类问题的解决方法很简单:一旦引用过时,则将其清空。 就我们的 Stack 类而言,对某个项目的引用在从堆栈中弹出后便会过时。 正确的 pop 方法版本如下所示:

public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // 消除过期对象引用
    return result;
}

清空好处:如果它们以后又被错误地引用,程序立即抛出 NullPointerException 异常,而不是悄悄地做继续做错误的事情。尽可能快地发现程序中的错误是有好处的。 清空对象引用应该是例外而不是规范,不是所有的对象结束引用都需要手动清空,GC 自身能很好的做到,除非我们自身管理了内存时,才需要警惕。

JDK 中,已经有现成的 Stack 类供我们使用,来看看它是怎么实现 pop 的:

public synchronized E pop() {
        E       obj;
        int     len = size();

        obj = peek(); //这个方法将栈顶的元素取出来,但并不会把栈顶元素弹出
        removeElementAt(len - 1); //这是 Stack 父类的方法,将栈顶元素弹出

        return obj;
}

public synchronized void removeElementAt(int index) {
        modCount++;
        if (index >= elementCount) {
            throw new ArrayIndexOutOfBoundsException(index + " >= " +
                                                     elementCount);
        }
        else if (index < 0) {
            throw new ArrayIndexOutOfBoundsException(index);
        }
        int j = elementCount - index - 1;
        if (j > 0) {
            System.arraycopy(elementData, index + 1, elementData, index, j);
        }
        elementCount--;
        // 可以看到,jdk 的实现就是显式地把引用清空,以此告诉 GC 将过期引用回收
        elementData[elementCount] = null; /* to let gc do its work */
}

如何预防?

  • 如果我们自身管理了对象的内存,首先应该尽可能的将变量定义在狭窄的范围内
  • 消除过时引用的最佳方法是让包含引用的变量超出范围
  • 如果我们清楚知道该对象已经不再使用,可以手动清空对象使 GC 清楚可以被回收

3.9 未正确使用缓存

一旦将对象引用放入缓存中,很容易忘记它的存在,并且在它变得无关紧要之后,仍然保留在缓存中。对于这个问题有几种解决方案。如果你正好想实现一个缓存:只要在缓存之外存在对某个项(entry)的键(key)引用,那么这项就是明确有关联的,就可以用 WeakHashMap 来表示缓存;这些项在过期之后自动删除。记住,只有当缓存中某个项的生命周期是由外部引用到键(key)而不是值(value)决定时,WeakHashMap 才有用。这点和 ThreadLocal 很像。

更常见的情况是,缓存项有用的生命周期不太明确,随着时间的推移一些项变得越来越没有价值。在这种情况下,缓存应该偶尔清理掉已经废弃的项。这可以通过一个后台线程(也许是 ScheduledThreadPoolExecutor)或将新的项添加到缓存时顺便清理。LinkedHashMap 类使用它的 removeEldestEntry 方法实现了后一种方案。对于更复杂的缓存,可能直接需要使用 java.lang.ref

如何预防?

  • 使用成熟的缓存实现类
  • 如果简单实用,可以使用 WeakHashMap 或者定期清理缓存或设置过期时间等策略

3.10 监听器和其它回调

如果你实现了一个API,其客户端注册回调,但是没有显式地撤销注册回调,除非采取一些操作,否则它们将会累积。确保回调是垃圾收集的一种方法是只存储弱引用(weak references),例如,仅将它们保存在 WeakHashMap的键(key)中。

比如 JDK 中虚拟机状态监听器:

class VMState {
    private final VirtualMachineImpl vm;

    // Listeners
    private final List<WeakReference<VMListener>> listeners = new ArrayList<WeakReference<VMListener>>();
		......
}

如何预防?

  • 自身管理对象内存时,要正确实现对象的管理操作
  • 使用弱引用以便不使用时让 GC 回收

4. 处理内存泄漏的其他策略

尽管在处理内存泄漏时没有一种万能的解决方案,但是有一些方法可以使这些泄漏最小化。

4.1 启用分析

Java Profiler 是监视和诊断通过应用程序的内存泄漏的工具。他们分析了我们应用程序内部发生的事情 - 例如,内存分配方式。

使用分析器,我们可以比较不同的方法并找到可以最佳利用资源的领域。

在之前的例子中,我们一直使用 Java VisualVM。还有其它不同类型的 Profiler,例如 Mission ControlJProfilerYourKitNetbeans Profiler 等。

4.2 详细垃圾回收

通过启用详细的垃圾收集,我们可以跟踪 GC 的详细信息。为此,我们需要将以下内容添加到我们的 JVM 配置中:

-verbose:gc

通过添加此参数,我们可以看到 GC 内部发生的情况的详细信息:

verbose garbage collection

4.3 使用引用对象避免内存泄漏

我们还可以诉诸于 java.lang.ref 包内置的 Java 引用对象来处理内存泄漏。使用 java.lang.ref 包,而不是直接引用对象,通过使用对对象的特殊引用,使它们易于垃圾回收。

在引入引用类之前,只有强引用可用。例如,下面的代码行展示了一个强引用 obj

Object obj = new Object();

obj 引用存储在堆中的对象。只要 obj 引用存在,垃圾收集器就永远不会释放用于保存该对象的存储。一个对象可以被多种引用类型引用。

类型对应类特征使用
强引用强引用的对象被引用时不会被 GC 回收
软引用SoftReference如果物理内存充足则不会被 GC 回收,如果物理内存不充足则会被 GC 回收。实现内存敏感型数据缓存
弱引用WeakReference一旦被 GC 扫描到则会被回收用于实现规范化的映射。如果映射仅包含特定值的一个实例,则称为规范化。比如 WeakHashMap解决 Lapsed Listener 问题,上面所述监听器回调问题
虚引用PhantomReference不会影响对象的生命周期,形同于无,任何时候都可能被 GC 回收**确定何时从内存中删除对象,**这有助于安排对内存敏感的任务。例如,我们可以等待一个大对象被删除再加载另一个对象。**避免使用 finalize 方法,并改进 finalization 过程
FinalReference用于收尾机制(finalization)

4.4 基准测试

我们可以通过执行基准测试来衡量和分析 Java 代码的性能。这样,我们可以比较替代方法执行相同任务的性能。这可以帮助我们选择更好的方法,并且可以帮助我们节省内存。

4.5 代码评测

最后,我们通过最经典的,古老的方式来进行简单的代码演练。

在某些情况下,即使是这种简单的方法也可以帮助消除一些常见的内存泄漏问题。

5. 结论

内存泄漏很难解决,要找到它们需要对 Java 语言进行复杂的掌握和命令。在处理内存泄漏时,没有一种千篇一律的解决方案,因为泄漏可能通过各种多样的事件发生。

但是,如果我们采用最佳实践并定期执行严格的代码演练和分析,则可以最大程度地减少应用程序中内存泄漏的风险。

与往常一样,可以在 GitHub 上获得用于生成本教程中描述的 VisualVM 响应的代码段。