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来捕获和读取堆转储:
这是捕获堆转储的便捷工具,包含JConsole的所有功能,使过程变得简单。
捕获堆转储后,我们可以检查和分析它。在我们的案例中,我们将寻找那些本不该存在的存活对象。幸运的是,VisualVM为堆转储生成了摘要,显示重要信息:
在系统中,用户对象在实例数量和总体大小上排名第三。我们已经知道存在内存消耗问题,现在找到了罪魁祸首。
此外,VisualVM允许我们更彻底地分析堆转储,检查堆中的所有实例:
这在对象交互复杂的大型应用中很有帮助。对应用调优和发现问题区域也很有用。
找到问题实例后,我们仍需检查代码以确定内存泄漏出现的位置,但现在可以缩小搜索范围。
4. 结论
内存泄漏会严重影响Java应用,导致内存逐渐耗尽和潜在的系统崩溃。本教程中,我们为教学目的创建了一个内存泄漏,并讨论了多种检测技术,包括日志分析、性能分析、详细垃圾回收日志和堆转储。
每种方法都能提供应用运行时行为和内存消耗的宝贵见解:
- ✅ 日志帮助识别异常
- ✅ 性能分析和详细垃圾回收日志监控内存使用和垃圾回收过程
- ✅ 堆转储识别问题对象及其引用,缩小内存泄漏源
理解Java中的内存分配和垃圾回收机制,能帮助开发者预防内存泄漏,构建更高效、更健壮的应用。 如往常一样,源代码可在GitHub上获取。