在上一篇文章中,我们讨论了内存泄漏的最基本方面,内存泄漏的原因,以及如何从程序中消除内存泄漏。
作为前导,当垃圾回收器(GC)无法从工作内存中清除未引用的对象时,就会发生内存泄漏。考虑到Java在很大程度上归功于它的垃圾收集器,这怎么可能呢?事实证明,GC有几个弱点:
未引用的静态字段:除非拥有静态字段的类被卸载,否则GC无法清除静态字段,只有当调用它的类加载器被垃圾回收时才会发生这种情况。
未关闭的系统资源:GC间接释放文件,因为像FileInputStream
这样的类被编写成这样,如果一个实例被垃圾回收,那么将首先调用“close()
”方法。这样,未关闭的系统资源并不总是会带来风险,因此许多开发人员倾向于查看它们。
大多数系统对一次可以打开的文件数量有严格的限制,除了难以重现的错误(如不同进程无法访问文件或操作系统错误)之外,此类问题在调试时可能会非常麻烦。它们不是确切意义上的内存泄漏,但在流保持打开状态时,内存使用率仍然很高。
此外,还需要记住,类卸载可能发生,也可能不发生,这取决于JVM实现。
未关闭的连接:与未关闭的资源一样,未关闭的数据库或网络连接如果不卸载,可能会导致大量内存使用。
可能发生内存泄漏的其他原因包括:
- 堆空间小
- 操作系统进行过多的页面交换
- 垃圾收集的长时间延迟
本文的重点是在您认识到内存泄漏是如何发生的之后,可以使用各种技术来查找内存泄漏。
在Java中诊断内存泄漏
内存泄漏是一个问题,因为它们很难诊断。他们需要你很好的理解和调试的一些技能。与大多数其他错误不同,你的程序在运行正常之前都会运行得很好。在应用程序神秘地崩溃之前,你可能不会注意到内存泄漏。
内存泄漏的一个标志就是可怕的OutOfMemoryError(OOM)。然而,这绝不是一种可靠的检测方法。虽然oom通常是内存泄漏的症状,但情况并非总是如此。
它们的发生有很多原因,但最常见的四个原因如下:
- Java堆泄漏:这是Java堆中“泄漏”的典型案例。对象正在被创建,但是不管出于什么原因,它们没有从Java堆中释放出来。如果这种情况持续足够长的时间,堆将被填满并且`java.lang.OutOfMemoryError:Java heap space`错误出现。
- 小堆空间:默认情况下,分配给应用程序的堆空间量由物理内存量和Java版本决定。如果您的应用程序很小并且不需要太多内存,那么拥有一个小的堆空间应该不是问题。但是,如果负载更大,则可能需要增加负载。
- 本机问题:这是诊断和解决oom比较困难的类型之一,因为内存分配失败发生在JNI或本机方法中,而不是JVM代码中。在32位系统上,由于分配了太多堆空间,这种错误经常发生。
- 大型对象分配请求:当应用程序需要的Java堆超过所能提供的数量时,可能会抛出OOM。例如,尝试从1024MB堆中获取1GB字符串对象将永远不会成功。
如何检测内存泄漏
要可靠地检测内存泄漏,您必须依赖于不同工具和技术的组合。这些方法有十几种,但为了简单起见,三种最有效的方法是:
- 使用内存分析器。
- 详细的垃圾收集。
- 正在分析堆转储。
根据您的偏好,有十几种不同的工具可用于内存分析。请记住,本教程使用的是运行在Ubuntu18.04上的这些软件的最新版本。
我亲自尝试过的(以及我们将要探索的)是:
- yourkit java profiler:这里提供了用户友好、功能丰富的工具。我发现一个特别有用的特性是能够以各种格式导出图形。缺点是价格太贵了。
- VisualVM:我个人最喜欢的,因为 1.它是开源的、2.完全免费的、3.非常简单。用户界面有点欠缺,经常让人觉得很噱头,它的功能几乎没有你的工具包那么多,而且我也无法让创意集成工作,无论什么原因。不过,如果你像我一样,你会欣赏它带来的简单。
- JProfiler:JProfiler也是无价的。在我看来,它比你的工具包更容易使用,而且提供了比VisualVM更多的特性。然而,在它变得那么容易使用之前,您可能需要进行一些探索才能使一切正常工作。
现在,谈正事。
让我们考虑上一个项目中的同一个例子,只是有一些变化。值得注意的是,这次我们依靠JUnit4来运行测试。
public class Main {
private Random random = new Random();
private static final ArrayList<Integer> list = new ArrayList<>();
@Test(expected = OutOfMemoryError.class)
public void givenArrayList_whenStatic_thenShouldLeakMemory() throws InterruptedException {
for (int i = 0; i < 100000000; i++) {
list.add(random.nextInt());
}
Thread.sleep(30000);
}
}
这是一个简单的程序,在我们的类中向ArrayList
添加一千万个整数。我们将继续分析这个程序,包括静态字段和不包含静态字段的情况。
VisualVM
如何在VisualVM上运行内存分析取决于应用程序的类型以及需要分析的程序部分。不幸的是,VisualVM不支持开箱即用启动时的内存评测。您需要安装Startup Profiler插件。
按照插件附带的说明运行程序后,VisualVM将生成以下图形:
请注意已用堆(蓝色)图的逐渐上升,然后下降,最终达到平台。使用的堆空间量随着应用程序的运行而增加,而在JVM回收未引用的对象时略有下降。
相比之下,程序的泄漏部分没有内存使用量的任何下降。有点像这样:
Yourkit
你的工具包有一个方便的IDEA集成,可以让你直接从IDE启动profiler。运行时,同一程序生成的图形如下所示:
这持续了一段时间。每一个峰值都是程序运行时的一个实例,下降是GC工作的一个指示。
JProfiler
设置JProfiler与VisualVM几乎是一样的事情。您需要为代码运行时添加一个新的JVM参数。在一天结束时,运行程序的代码应该如下所示:
/usr/lib/JVM/java-11-oracle/bin/java-ea-Xmx300M-Xms100M-agentpath:/home/me/installs/jprofiler11/bin/linux-x64/libjprofilerti.so=port=8849
(-Xmx300M
将最大堆大小设置为300MB,-Xms100M
将初始堆大小设置为100MB)
我们泄漏程序的输出看起来与VisualVM之前生成的非常相似。
详细的垃圾收集
详细垃圾收集允许您收集有关垃圾收集过程的详细信息,而不是默认设置。这是一个非常有用的特性,在调优和调试可能遇到的各种内存问题时通常是必需的。
可以使用-verbose:gc
到应用程序的JVM配置。例如,让我们使用附加参数运行我们的经典应用程序,以更好地了解发生了什么。
GC日志是揭示改进程序中堆和垃圾收集配置的潜在方法的重要工具。它提供了诸如持续时间和GC会话结果等详细信息,帮助我们微调诸如收集时间之类的性能细节,并找出什么样的堆大小最有效。
出于我们的目的,我们只需要使用-XX:+UseSerialGC
启用的简单垃圾收集器。
正在分析详细的垃圾收集输出
运行泄漏程序将生成以下详细的垃圾收集输出:
[0.646s][info][gc] GC(0) Pause Young (Allocation Failure) 26M->16M(96M) 40.106ms
[0.721s][info][gc] GC(1) Pause Young (Allocation Failure) 43M->35M(96M) 56.025ms
[0.815s][info][gc] GC(2) Pause Young (Allocation Failure) 60M->60M(96M) 76.046ms
[1.151s][info][gc] GC(4) Pause Full (Allocation Failure) 86M->69M(113M) 238.082ms
[1.151s][info][gc] GC(3) Pause Young (Allocation Failure) 86M->69M(168M) 318.858ms
[1.273s][info][gc] GC(5) Pause Young (Allocation Failure) 115M->113M(168M) 83.191ms
[1.686s][info][gc] GC(7) Pause Full (Allocation Failure) 134M->118M(180M) 306.419ms
[1.686s][info][gc] GC(6) Pause Young (Allocation Failure) 135M->118M(287M) 390.527ms
[1.929s][info][gc] GC(8) Pause Young (Allocation Failure) 198M->197M(287M) 173.211ms
让我们把它分解一下:
[0.721s]
这是一个显示GC发生时间的时间戳。
[info]
日志级别
[gc]
日志级别来自的通道。
Pause Young (Allocation Failure)
在应用程序生命周期的开始,并发阶段还没有执行,所以它以完全年轻的模式运行。然而,一旦年轻一代人填满,年轻区域内的实时数据就会复制到幸存者区域。这个过程叫做疏散。在此过程中,所有线程都会在安全点停止,以使复制能够进行。
通过一些额外的配置,可以获得额外的信息,比如疏散暂停的时间和时间。这超出了本文的范围。
43M->35M
垃圾回收运行前后占用的堆内存量。他们被一支箭隔开了。
(96M)
堆的当前容量。
56.025ms
GC事件花费了多长时间。
把一切都集中起来
如果垃圾收集之后占用的内存量仍然很高,即使在垃圾收集之后,也很有可能发生内存泄漏。它不能保证,因为它只表明内存耗尽,这可能是由许多其他事情造成的,但它应该会给你指明正确的方向。
分析heap dumps堆转储
最后,分析堆转储是另一种很好的方法,可以减少那些可能耗尽可用内存的难以捉摸的错误。这对于程序崩溃而没有任何有用的指示导致程序崩溃的情况尤其有用,就像某些OutOfMemoryErrors的情况一样
堆转储是给定时间内Java进程堆内存的快照,在我们的例子中,是程序上次崩溃(并产生堆转储)的时间。
就工具而言,没有比Eclipse内存分析器工具(MAT)更好的应用程序了。
专业提示:在第一次使用程序之前,您可能需要增加程序可用的堆大小(.init文件修改Xmx等参数)。
让我们稍微调整一下我们的经典泄漏程序。
import org.junit.Test;
import java.util.ArrayList;
import java.util.Random;
public class Main {
private Random random = new Random();
private static final ArrayList<Integer> list = new ArrayList<>();
@Test(expected = OutOfMemoryError.class)
public void givenArrayList_whenStatic_thenOutOfMemory() throws InterruptedException {
for (int i = 0; i < 1000000000; i++) {
list.add(random.nextInt());
}
Thread.sleep(10000);
}
}
这一次,我们只创建了堆所能容纳的几个对象,并让它耗尽内存,从而在进程中生成一个堆转储。(您可以按照以下说明配置应用程序,以便在出现OOM时自动生成堆转储)。
或者,如果出现OOM,用Yourkit启动程序将自动生成堆转储。
在MAT中打开生成的堆转储应该可以让您选择查找可疑的内存泄漏。以下是MAT生成的报告的一部分:
报告相当详细和直截了当。例如,我们现在可以将错误跟踪到com.testapp.memories.Main
类,以及MAT提供的其他详细信息(可能与探查器一起使用),我们可以将错误跟踪到导致问题的方法。
小结
在本文中,我们介绍了在Java中追踪内存泄漏的三种不同方法。根据你的经验和专业知识的多少,任何一个都可以工作得很好。然而,这些工具对于跟踪所有类型的bug也非常有用,不仅仅是内存泄漏。用他们的知识武装自己会有很大的帮助。
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/958.html
暂无评论