1. 概述

在Java应用中,内存泄漏会导致严重的性能下降和系统崩溃。开发者必须理解内存泄漏的产生原因以及如何识别和解决它们。

本教程将指导你如何通过一个"失效监听器"问题在Java中创建内存泄漏。我们还会讨论多种检测内存泄漏的方法,包括日志分析、性能分析、详细垃圾回收日志和堆转储。

2. 创建内存泄漏

我们以失效监听器问题为例演示内存泄漏。这是学习Java内存分配和垃圾回收的绝佳方式。

创建一个向登录并订阅服务的用户随机发送电影台词的应用。这个应用很简单,一次只能服务一个用户:

public static void main(String[] args) {
    while (true) {
        User user = generateUser();
        logger.debug("{} logged in", user.getName());
        user.subscribe(movieQuoteService);
        userUsingService();
        logger.debug("{} logged out", user.getName());
    }
}

UserGenerator是一个提供无限随机用户的简单类。我们使用Datafaker进行随机化:

public class UserGenerator {

    private final static Faker faker = new Faker();

    public static User generateUser() {
        System.out.println("Generating user");
        String name = faker.name().fullName();
        String email = faker.internet().emailAddress();
        String phone = faker.phoneNumber().cellPhone();
        String street = faker.address().streetAddress();
        String city = faker.address().city();
        String state = faker.address().state();
        String zipCode = faker.address().zipCode();
        return new User(name, email, phone, street, city, state, zipCode);
    }
}

用户与服务的关系基于观察者模式。因此,Users可以订阅服务,我们的MovieQuoteService会向用户更新新的电影台词。

这个示例的主要问题是:用户从不取消订阅服务。 这造成了内存泄漏,即使用户超出作用域,垃圾回收器也无法移除它们,因为服务持有它们的引用。

我们可以显式取消用户订阅来缓解问题,这确实有效。但最佳方案是使用WeakReferences来自动化这个过程。

3. 检测内存泄漏

上一节我们创建了一个存在严重问题的应用——内存泄漏。虽然这个问题可能是灾难性的,但通常很难检测。让我们回顾几种发现问题的方法。

3.1. 日志分析

从最直接的方法开始:使用日志发现系统问题。这不是检测内存泄漏最先进的方法,但简单易用,可能帮助发现异常。

运行服务时,日志输出会显示用户活动:

21:58:24.280 [pool-1-thread-1] DEBUG c.b.lapsedlistener.MovieQuoteService - New quote: Go ahead, make my day.
21:58:24.358 [main] DEBUG c.b.l.LapsedListenerRunner - Earl Runolfsdottir logged in
21:58:24.358 [main] DEBUG c.b.lapsedlistener.MovieQuoteService - Current number of subscribed users: 0
21:58:24.371 [main] DEBUG c.b.l.LapsedListenerRunner - Earl Runolfsdottir logged out
21:58:24.372 [main] DEBUG c.b.l.LapsedListenerRunner - Barbra Rosenbaum logged in
21:58:24.372 [main] DEBUG c.b.lapsedlistener.MovieQuoteService - Current number of subscribed users: 1
21:58:24.383 [main] DEBUG c.b.l.LapsedListenerRunner - Barbra Rosenbaum logged out
21:58:24.383 [main] DEBUG c.b.l.LapsedListenerRunner - Leighann McCullough logged in
21:58:24.383 [main] DEBUG c.b.lapsedlistener.MovieQuoteService - Current number of subscribed users: 2
21:58:24.396 [main] DEBUG c.b.l.LapsedListenerRunner - Leighann McCullough logged out
21:58:24.397 [main] DEBUG c.b.l.LapsedListenerRunner - Mr. Charlie Keeling logged in
21:58:24.397 [main] DEBUG c.b.lapsedlistener.MovieQuoteService - Current number of subscribed users: 3
21:58:24.409 [main] DEBUG c.b.l.LapsedListenerRunner - Mr. Charlie Keeling logged out
21:58:24.410 [main] DEBUG c.b.l.LapsedListenerRunner - Alvin O'Connell logged in
21:58:24.410 [main] DEBUG c.b.lapsedlistener.MovieQuoteService - Current number of subscribed users: 4
21:58:24.423 [main] DEBUG c.b.l.LapsedListenerRunner - Alvin O'Connell logged out
21:58:24.423 [main] DEBUG c.b.l.LapsedListenerRunner - Tracey Stoltenberg logged in
21:58:24.423 [main] DEBUG c.b.lapsedlistener.MovieQuoteService - Current number of subscribed users: 5

在日志片段中能发现一个有趣的现象。如前所述,我们的应用一次只能服务一个用户。

因此,同一时间只应有一个用户订阅服务。但日志显示订阅者数量超过了这个值。 进一步阅读提供了更多证据证明系统存在问题。

虽然日志没有显示问题发生的位置,但这是防止系统出问题的第一步。

3.2. 性能分析

这一步与上一步类似,旨在发现运行应用中的异常。但性能分析器能极大简化监控应用内存占用的过程。

第一个明显迹象是:已用内存随时间单调递增。 这不一定是内存泄漏的标志。但在我们的应用中,内存使用量增加可能意味着存在问题。

我们将使用JConsole性能分析器。这是个基础工具,但提供了所有必需功能,且包含在每个JDK发行版中。在任何系统上启动它都很简单:

$ jconsole

启动应用后,看看JConsole会告诉我们什么。应用启动后,内存消耗增加了:

内存消耗增加

然而,内存使用量增加不一定是内存泄漏的标志。让我们尝试提示垃圾回收器清理一些死亡对象:

垃圾回收器清理

可以看到,垃圾回收器工作得很好,清理了一些空间。因此我们可能认为完全没有问题。但让我们看看老年代(Old Generation)。这是存放在应用中存活了多次垃圾回收的对象的区域。可以看到它的大小在持续增加:

老年代

一种解释是:除了用户,我们还有电影台词。应用中没有存储台词的引用,所以垃圾回收器清理它们没有问题。同时,我们的服务持有每个用户的引用,阻止它们被垃圾回收,导致它们被提升到老年代:

引用关系

尽管垃圾回收器定期清理,但总体内存消耗显然在随时间增长。几分钟内内存从约10MB增长到30MB。 在服务器上这可能几小时甚至几天都不会造成问题。如果服务器定期重启,我们可能永远不会看到OutOfMemoryError

内存溢出错误

老年代的情况同样:内存消耗只增不减。对于一次只能服务一个用户的应用来说,这是存在问题的标志。

3.3. 详细的垃圾回收日志

这是检查堆状态和垃圾回收过程的另一种方法。根据Java版本,我们可以使用几个标志开启详细垃圾回收日志。日志输出会反映之前在JConsole中获得的信息:

[0.004s][info][gc] Using G1
[0.210s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->6M(392M) 1.693ms
[33.169s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 38M->7M(392M) 1.994ms
[250.890s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 203M->16M(392M) 11.420ms
[507.259s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 228M->25M(392M) 14.321ms
[786.181s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 229M->33M(392M) 17.410ms
[1073.277s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 241M->41M(392M) 11.251ms
[1341.717s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 241M->48M(392M) 17.132ms

这些日志使用特定格式显示总体内存消耗随时间增长。这是检查应用内存占用和发现问题的快速直接方法。

但这一步之后,我们仍需找到问题根源。在我们的应用中只有几个类,任务可能很简单,通过检查代码就能解决。但在大型应用中,仅通过代码检测问题可能是不可能的。

3.4. 堆转储

有几种方法可以捕获堆转储,JDK包含多个控制台工具。我们将使用VisualVM来捕获和读取堆转储:

VisualVM堆转储

这是捕获堆转储的便捷工具,包含JConsole的所有功能,使过程变得简单。

捕获堆转储后,我们可以检查和分析它。在我们的案例中,我们将寻找那些本不该存在的存活对象。幸运的是,VisualVM为堆转储生成了摘要,显示重要信息:

VisualVM摘要

在系统中,用户对象在实例数量和总体大小上排名第三。我们已经知道存在内存消耗问题,现在找到了罪魁祸首。

此外,VisualVM允许我们更彻底地分析堆转储,检查堆中的所有实例:

堆分析

这在对象交互复杂的大型应用中很有帮助。对应用调优和发现问题区域也很有用。

找到问题实例后,我们仍需检查代码以确定内存泄漏出现的位置,但现在可以缩小搜索范围。

4. 结论

内存泄漏会严重影响Java应用,导致内存逐渐耗尽和潜在的系统崩溃。本教程中,我们为教学目的创建了一个内存泄漏,并讨论了多种检测技术,包括日志分析、性能分析、详细垃圾回收日志和堆转储。

每种方法都能提供应用运行时行为和内存消耗的宝贵见解:

  • ✅ 日志帮助识别异常
  • ✅ 性能分析和详细垃圾回收日志监控内存使用和垃圾回收过程
  • ✅ 堆转储识别问题对象及其引用,缩小内存泄漏源

理解Java中的内存分配和垃圾回收机制,能帮助开发者预防内存泄漏,构建更高效、更健壮的应用。 如往常一样,源代码可在GitHub上获取。


原始标题:Create and Detect Memory Leaks in Java