感兴趣的朋友可以关注"猿学堂社区",系统化技术内容分享平台。或加入“猿学堂社区”微信交流群。
猿学堂师资均为一线互联网资深技术专家,提供专业、深度、体系化的技术课程,一站式技术解决方案。与技术专家线上线下深度互动、答疑解惑。在线学习笔记与课程完美结合。
本章主要是为如何诊断内存泄露问题提供一些建议方法。
如果应用程序执行时间越来越长,或者如果操作系统的执行速度越来越慢,这说明可能存在内存泄露问题。换句话说,虚拟机持续分配内存,但是内存不再需要时却无法回收。最终,应用程序或系统运行耗尽内存,并且应用系统异常终止。
本章包含以下部分:
- 使用Flight Recorder调试内存泄露
- 理解OutOfMemoryError异常
- 系统Crash诊断
- 诊断Java代码中的内存泄露
- 诊断Native代码中的内存泄露
使用Flight Recorder调试内存泄露
Flight Recorder记录了Java Runtime以及在其中运行的应用的详细信息。这些信息可以用于识别内存泄露。
检测缓慢的内存泄露非常困难。一个典型的症状是,应用程序在运行了很长一段时间之后,由于频繁的垃圾回收变得越来越慢。最后,可能会出现OutOfMemoryError异常。
为了检测内存泄露,Flight Recorder必须在内存泄露发生时是运行的。Flight Recorder的开销非常低,低于1%,并且它的设计是安全的,可以一直在生产环境中使用。
当应用程序使用java命令启动时开启记录,如下所示:
$ java -XX:StartFlightRecording
当JVM运行发生内存溢出并且由于OutMemoryError,一个以hs_oom_pid作为前缀(通常但是并不总是)的记录会写到JVM的启动目录。获取这些记录的另一种方式是在应用程序内存溢出之前使用jcmd工具进行dump,如下所示:
$ jcmd pid JFR.dump filename=recording.jfr path-to-gc-roots=true
拿到这些记录后,使用JAVA_HOME/bin目录下的jfr工具打印Old Object Sample事件,这些事件包含了潜在的内存泄露信息。以下示例显示了PID为16276的应用程序的打印命令以及记录的输出:
jfr print --events OldObjectSample pid16276.jfr
...
jdk.OldObjectSample {
startTime = 18:32:52.192
duration = 5.317 s
allocationTime = 18:31:38.213
lastKnownHeapUsage = 63.9 MB
object = [
java.util.HashMap$Node
[15052855] : java.util.HashMap$Node[33554432]
table : java.util.HashMap Size: 15000000
map : java.util.HashSet
users : java.lang.Class Class Name: Application
]
arrayElements = N/A
root = {
description = "Thread Name: main" system = "Threads"
type = "Stack Variable"
}
eventThread = "main" (javaThreadId = 1)
}
...
jdk.OldObjectSample {
startTime = 18:32:52.192
duration = 5.317 s
allocationTime = 18:31:38.266
lastKnownHeapUsage = 84.4 MB
object = [
java.util.HashMap$Node
[8776975] : java.util.HashMap$Node[33554432]
table : java.util.HashMap Size: 15000000
map : java.util.HashSet
users : java.lang.Class Class Name: Application
]
arrayElements = N/A
root = {
description = "Thread Name: main" system = "Threads"
type = "Stack Variable"
}
eventThread = "main" (javaThreadId = 1)
}
...
jdk.OldObjectSample {
startTime = 18:32:52.192
duration = 5.317 s
allocationTime = 18:31:38.540
lastKnownHeapUsage = 121.7 MB
object = [
java.util.HashMap$Node
[393162] : java.util.HashMap$Node[33554432]
table : java.util.HashMap Size: 15000000
map : java.util.HashSet
users : java.lang.Class Class Name: Application
]
arrayElements = N/A
root = {
description = "Thread Name: main" system = "Threads"
type = "Stack Variable"
}
eventThread = "main" (javaThreadId = 1)
}
...
为了识别可能的内存泄露,检查记录中的以下元素:
- 首先,注意Old Object Sample中的lastKnownHeapUsage元素随着时间增加,由第一个事件的63.9MB增加到最后一个事件的121.7MB。这种增加表示这是一个内存泄露。大多数应用程序在启动期间分配对象,还分配周期性垃圾回收可以回收的临时对象。无论出于何种原因,未被垃圾回收的对象会随着时间的推移而积累,并增加lastKnownHeapUsage的值。
- 接下来,看一下allocationTime元素,查看对象是何时被分配的。在启动期间分配的对象通常不是内存泄露,临近dump时分配的对象也不是。startTime元素显示了dump执行的时间,duration元素显示了dump花费的时间。
- 然后,看一下可以看出可能的内存泄露的对象元素。在本例中,是java.util.HashMap$Node类型的对象。它由java.util.HashMap类中的table字段持有,java.util.HashMap由java.util.HashSet的map字段持有,java.util.HashSet则由Application类的users字段持有。
- root元素包含了GC根的信息。在这个例子中,Application类由主线程中的一个stack变量持有。eventThread元素提供了分配对象的线程的信息。
如果应用程序启动时添加了-XX:StartFlightRecording:settings=profile选项,那么记录还包含来自对象分配处的堆栈信息,如下所示:
stackTrace = [
java.util.HashMap.newNode(int, Object, Object, HashMap$Node) line: 1885
java.util.HashMap.putVal(int, Object, Object, boolean, boolean) line:
631
java.util.HashMap.put(Object, Object) line: 612
java.util.HashSet.add(Object) line: 220
Application.storeUser(String, String) line: 53
Application.validate(String, String) line: 48
Application.login(String, String) line: 44
Application.main(String[]) line: 30
]
在这个例子中,我们可以看到对象在storeUser(String, String)方法调用时被放到HashSet。这表明导致内存泄露的原因可能是用户注销时这个对象没有冲HashSet中移除。
由于某些分配密集型应用程序的开销,建议不要总是使用-XX:StartFlightRecording:settings=profile选项运行应用程序,但是在debugging环境一般是可以的。开销通常低于2%。
设置path-to-gc-roots=true导致的开销类似于完全垃圾回收,但是它会提供追溯到GC根的引用链,这通常可以为找到内存泄露原因提供充足的信息。
理解OutOfMemoryError异常
在Java堆中没有充足的空间来分配给一个对象时会抛出java.lang.OutOfMemoryError错误。
内存泄露的一个常见现象就是java.lang.OutOfMemoryError。在这种情况下,垃圾收集器无法提供空间来容纳新对象,并且堆无法进一步扩展。此外,当本机没有充足的内存来支持加载Java类时也可能会引发此错误。在一个罕见的示例中,当花费过多的时间进行垃圾回收,而很少内存被释放时,也会抛出java.lang.OutOfMemoryError。
当java.lang.OutOfMemoryError发生时,会同时打印一个堆栈信息。
java.lang.OutOfMemoryError还可能会由本地库代码抛出,当本地分配无法满足时(例如,如果swap空间不足)。
诊断OutOfMemoryError异常的初期步骤是确定异常的原因。抛出这个异常是因为Java堆已满还是因为本地堆已满?为了帮助你找到原因,异常文本在末尾包含了一条详细信息,如下所示:
Exception in thread thread_name: java.lang.OutOfMemoryError: Java heap space
原因:详细信息“Java heap space”表明无法在Java堆中分配对象。这个错误并不意味着内存泄露。这个问题也可以简单到是配置问题,如应用程序指定的堆大小(或默认大小,如果不指定)不合适。
在其它情况下,尤其是对于长期运行的应用程序,这个信息可能表示应用程序无意中持有对对象的引用,这将阻碍对象垃圾回收。这相当于Java语言中的内存泄露。注意:应用程序调用的API也可能无意中持有对象引用。
这个错误的另一个潜在来源是应用程序过度使用finalizer。如果类存在finalize方法,那么在垃圾回收时,该类型的对象空间不会回收。相反,在垃圾回收之后,对象会排队等待终结(finalization),这会在稍后发生。在Oracle Sun实现中,finalizer由为终结(finalization)队列提供服务的守护线程执行。如果finalizer线程无法跟上终结(finalization)队列,那么Java堆可能会被填满,并引发此类OutOfMemoryError异常。可能导致这种情况的另一种场景是,应用程序创建高优先级线程,导致终结(finalization)队列的增长速度比finalizer线程为该队列提供服务的速度更快。
措施:若要了解如何监控终结(finalization)挂起的对象,请查看“监控挂起终结(finalization)的对象”。
Exception in thread thread_name: java.lang.OutOfMemoryError: GC Overhead limit exceeded
原因:详细信息“GC Overhead limit exceeded”标识垃圾收集器一直在运行,Java程序进行非常慢。在一次垃圾回收之后,如果Java进程花费了超过大约98%的时间进行垃圾回收,而如果它回收小于2%的堆,并且在最后5次(编译时常数)连续垃圾收集中均如此,那么会抛出java.lang.OutOfMemoryError。抛出这个异常通常是由于Java堆无法容纳存活的数据量,没有多少可用空间用于新的分配。
措施:增加堆大小。“GC Overhead limit exceeded”的java.lang.OutOfMemoryError异常可以使用命令行标志-XX:- UseGCOverheadLimit关闭。
Exception in thread thread_name: java.lang.OutOfMemoryError: Requested array size exceeds VM limit
原因:详细信息“Requested array size exceeds VM limit”表示应用程序(或者应用程序使用的API)试图分配大于堆大小的数组。例如,如果应用程序试图分配一个512MB的数组,但是最大堆大小是256MB,那么将抛出OutOfMemoryError,原因是“Requested array size exceeds VM limit”。
措施:通常这个问题要么是配置问题(堆大小过小),要么是存在bug导致应用程序试图创建一个巨大的数组(例如,使用算法计算数组中元素的个数时计算错误)。
Exception in thread thread_name: java.lang.OutOfMemoryError: Metaspace
原因:Java类元数据(Java类的虚拟机内部表示)是在本地内存(此处称为metaspace,即元空间)中分配的。如果类元数据的元空间耗尽,那么会抛出包含“MetaSpace”详细信息的java.lang.OutOfMemoryError。可以用于类元数据的元空间量受MaxMetaSpaceSize参数限制,该参数可以在命令行中指定。当类元数据所需的本地内存量超过MaxMetaSpaceSize时,将引发带有“MetaSpace”详细信息的java.lang.OutOfMemoryError。
措施:如果命令行中已设置MaxMetaSpaceSize,增大该参数的值。MetaSpace是从与Java堆相同的地址空间分配的。减小Java堆的大小将会为MetaSpace提供更多的可用空间。当然这只有在Java堆中有富余的可用空间时,才是一种正确的权衡策略。有关“Out of swap space”的异常,请参见下面的措施。
Exception in thread thread_name: java.lang.OutOfMemoryError: request size bytes for reason. Out of swap space?
原因:详细信息“request size bytes for reason. Out of swap space?”看上去可能是OutOfMemoryError异常。不管怎样,当本地堆分配失败以及本地堆可能接近耗尽时,Java HotSpot VM代码会报告这个异常。该消息显示失败请求的大小(字节)以及内存请求原因。通常的原因是报告分配失败的源模块名称。
措施:抛出此错误时,VM调用致命错误处理机制(会生成一个致命错误日志文件,其中包含有关崩溃时线程、进程和系统的有用信息)。在本地内存耗尽的情况下,日志中的堆内存和内存映射信息会很有用。具体可以参见“致命错误日志”。
如果抛出这种类型的OutOfMemoryError,你可能需要使用操作系统中的故障诊断工具来进一步诊断该问题。具体参见“本地操作系统工具”。
Exception in thread thread_name: java.lang.OutOfMemoryError: Compressed class space
原因:在64位平台上,指向类元数据的指针可以用32位偏移量表示(使用UseCompressedOops)。这通过命令行标志UseCompressedClassPointers控制(默认为on)。如果使用了UseCompressedClassPointers,那么类元数据的可用空间量固定为CompressedClassSpaceSize。如果UseCompressedClassPointers所需空间超过了CompressedClassSpaceSize,便会抛出一个java.lang.OutOfMemoryError,详细信息为“Compressed class space”。
措施:增加CompressedClassSpaceSize以关闭UseCompressedClassPointers。注意:CompressedClassSpaceSize可接受的大小是有范围的。例如-XX: CompressedClassSpaceSize=4g,超出了可接受的范围会导致诸如“CompressedClassSpaceSize of 4294967296 is invalid; must be between 1048576 and 3221225472”错误。
注意:JVM存在多于一种的类元数据:klass元数据及其它元数据。只有klass元数据存储在受CompressedClassSpaceSize范围限制的空间。其它元数据存储在Metaspace。
Exception in thread thread_name: java.lang.OutOfMemoryError: reason stack_trace_with_native_method
原因:如果错误信息的详细部分是“reason stack_trace_with_native_method”,此时会打印一个堆栈信息,其中顶部的帧是一个本地方法,这表明一个本地方法遇到了分配失败。这类消息与之前消息的区别在于分配失败是在Java本地接口(JNI)或者本地方法中检测到的,而不是JVM代码。
措施:如果抛出此类OutOfMemoryError异常,可能需要使用操作系统本地工具来进一步诊断该问题。请参见“本地操作系统工具”。
系统Crash诊断
使用致命错误日志中的信息或者Crash dump来诊断系统Crash。
有时应用程序在本地堆分配失败后很快就会崩溃。如果本地代码不检查内存分配函数返回的错误,便会发生这种情况。
例如,如果没有可用内存,malloc系统调用将返回null。如果未检查malloc返回,应用程序在尝试访问无效内存位置时便可能会崩溃。根据具体情况,这种问题可能比较难定位。
不过,有时致命错误日志或者Crash dump的信息足以诊断这类问题。致命错误日志会在“致命错误日志”一节详细介绍。如果崩溃的原因是分配失败,接下来需要确定分配失败的原因。与其它任何本地堆问题一样,系统可能配置的swap空间不足,系统中的其它进程可能正在消耗所有内存资源,或者应用程序(或其调用的API)存在导致系统内存不足的泄露。
诊断Java代码中的内存泄露
使用NetBeans Profiler来诊断Java代码中的内存泄露。
诊断Java代码中的内存泄露比较困难。通常需要应用程序的非常详细的信息。此外,这一过程往往反复且漫长。本节提供了有关可用于诊断Java代码中内存泄露的工具的信息。
注意:除了本节提到的工具之外,还有大量的第三方内存调试工具可以使用。Eclipse内存分析工具(MAT)和YourKit(www.yourkit.com)是具有内存调试功能的商业工具的两个示例。还有很多其他工具,不推荐特定产品。
以下工具用于诊断Java代码中的泄露。
- NetBeans Profiler:它可以很快定位内存泄露。商业内存泄露调试工具可能花费很长时间才能在大型应用程序中找到泄露。但是NetBeans Profiler使用的是这类对象通常显露的内存分配和重新聚集的模式。这个过程还包括内存重新聚集的缺失。Profiler可以检查这些对象的分配位置,这通常足以确定泄露的根本原因。
以下部分描述了诊断Java代码泄露的其它方法。
- 得到Heap Histogram
- 监控挂起终结(finalization)的对象
得到Heap Histogram
可以使用不同的命令和选项获取Heap Histogram来识别内存泄露。
通过检查Heap Histogram,可以快速缩小内存泄露的排查范围。可以通过以下几种方式获取Heap Histogram:
- 如果Java进程采用-XX:+PrintClassHistogram命令行选项启动,那么按Control+Break会生成一个Heap Histogram。
- 可以使用jmap工具从正在运行的进程中获取Heap Histogram。建议使用最新工具jcmd,而不是jmap,以增强诊断并且减小性能开销。参见“jcmd工具的可用命令”。下面示例中的命令使用jcmd为正在运行的进程创建Heap Histogram,结果类似于以下jmap命令。
jcmd <process id/main class> GC.class_histogram filename=Myheaphistogram
jmap -histo pid
输出会显示堆中每种类型的总大小和实例数量。如果获得一个Heap Histogram序列(例如每2分钟一次),那么你会看到一个趋势,这会驱使进一步的分析。
- 可以使用jhsdb jmap从一个核心文件中获取Heap Histogram,如下所示。
jhsdb jmap --histo --exe jdk-home/bin/java --core core_file
例如,如果你运行应用程序时指定了-XX:+CrashOnOutOfMemoryError命令行选项,那么当抛出OutOfMemoryError异常时,JVM会生成一个core dump。你可以在这个core文件上执行jhsdb jmap命令来获取Heap Histogram,如下所示。
$ jhsdb jmap --histo --exe /usr/java/jdk-11/bin/java --core core. 21844
Attaching to core core.21844 from executable /usr/java/jdk-11/bin/java, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 11-ea+24
Iterating over heap. This may take a while... Object Histogram:
num #instances #bytes Class description
------------------------------------------------------------------------ --
1: 2108 112576 byte[]
2: 546 66112 java.lang.Class
3: 1771 56672 java.util.HashMap$Node
4: 574 53288 java.lang.Object[]
5: 1860 44640 java.lang.String
6: 349 40016 java.util.HashMap$Node[]
7: 16 33920 char[]
8: 977 31264
java.util.concurrent.ConcurrentHashMap$Node
9: 327 15696 java.util.HashMap
10: 266 13800 java.lang.String[]
11: 485 12880 int[]
:
Total : 14253 633584
Heap traversal took 1.15 seconds.
上面的例子显示OutOfMemoryError异常是由字节数组的数量(堆中2108个实例)引起的。如果不进一步分析,就不清楚字节数组的分配位置。但是这些信息仍然有用。
监控挂起终结(finalization)的对象
可以使用不同的命令和选项来监控挂起终结(finalization)的对象。
当抛出的OutOfMemoryError异常包含“Java heap space”详细信息时,原因有可能是Finalizer过度使用。要诊断此问题,你有几个选项可以用于监控挂起finalization对象的数量:
-
JConsole管理工具可以用于监控挂起finalization的对象数。该工具在Summary标签面板的内存统计信息中报告了挂起的finalization计数。计数是近似值,但是它可以用来描述应用程序,并且了解它是否非常依赖finalization。
-
在Linux操作系统中,jmap工具可以使用-finalizerinfo选项来打印等待finalization的对象。
-
使用java.lang.management.MemoryMXBean类的getObjectPendingFinalizationCount方法应用程序可以报告finalization挂起对象的近似数量。API文档链接以及示例代码参见“自定义诊断工具”。示例代码可以很容易的扩展,以包含挂起finalization计数的报告。
诊断Native代码中的内存泄露
有几种技术可以用于发现和隔离本地代码内存泄露。一般来说,没有针对全部平台的理想解决方案。
以下是一些诊断本地代码泄露的技术。
- 跟踪所有内存分配和空间调用
- 跟踪JNI库中的所有内存分配
- 跟踪操作系统支持的内存分配
跟踪所有内存分配和空间调用
可用于跟踪所有内存分配和该内存使用情况的工具。
一种非常常见的做法是跟踪本地分配的所有分配和自由调用。这可能是一个相当简单的过程,也可能是一个非常复杂的过程。多年来,许多产品都是围绕跟踪本地堆分配和内存使用情况而构建的。
像IBM Rational Purify这样的工具可以用来在正常的本地代码情况下查找这些泄露,还可以查找对本地堆内存的任何访问,这些访问意味着对未初始化内存的分配或对已释放内存的访问。
并非所有这类工具都能与使用了本地代码的Java应用程序一起使用,通常这些工具都是针对特定平台的。由于虚拟机在运行时动态创建代码,这些工具可能错误的解释代码并且根本无法运行或者提供错误信息。请与工具供应商联系,确保该工具的版本与你正在使用的虚拟机版本兼容。
请参阅sourceforge以获取更多简单且可移植的本地内存泄露检测示例。大多数库和工具假定你可以重新编译或者编辑应用程序源代码,并将包装函数放在分配函数上。这些工具功能更强大,允许你通过动态插入这些分配函数来运行应用程序。
跟踪JNI库中的所有内存分配
如果你编写了一个JNI库,那么考虑使用简单的包装器方法创建一个局部方法来确保你的库不会内存泄露。
以下示例中的过程是用于JNI库的一种简单局部分配跟踪方法。首先,在所有源文件中定义以下行。
#include <stdlib.h>
#define malloc(n) debug_malloc(n, __FILE__, __LINE__)
#define free(p) debug_free(p, __FILE__, __LINE__)
然后,可以使用以下示例中的函数来监控泄露。
/* Total bytes allocated */
static int total_allocated;
/* Memory alignment is important */
typedef union { double d; struct {size_t n; char *file; int line;} s; }
Site;
void *
debug_malloc(size_t n, char *file, int line)
{
char *rp;
rp = (char*)malloc(sizeof(Site)+n);
total_allocated += n;
((Site*)rp)->s.n = n;
((Site*)rp)->s.file = file;
((Site*)rp)->s.line = line;
return (void*)(rp + sizeof(Site));
}
void
debug_free(void *p, char *file, int line)
{
char *rp;
rp = ((char*)p) - sizeof(Site);
total_allocated -= ((Site*)rp)->s.n;
free(rp);
}
然后,JNI库需要定期(或shutdown时)检查total_allocated变量的值,以验证它是否有意义。前面的代码也可以扩展为将剩余的分配保存在链表中,并报告泄露内存的位置。这是一种本地化的可移植的方法,用于跟踪单个源代码集合中的内存分配。你需要确保调用debug_free()的指针来自debug_malloc(),如果使用了realloc()、calloc()、strdup()等函数,你也需要创建类似的函数。
跟踪操作系统支持的内存分配
可用于跟踪操作系统内存分配的工具。
大多数操作系统都包含某种形式的全局分配跟踪支持。
- 在Windows系统,搜索MSDN库以获得Debug支持。微软C++编译器有/Md和/Mdd编译选项,它们将自动包含跟踪内存分配的额外支持。
- Linux系统有诸如mtrace和libnjamd等工具来帮助处理分配跟踪。
扫码关注我们