由 ObjectInputStream 所引起的 Java 内存泄漏问题

Java 的 ObjectOutputStream 和 ObjectInputStream 各自保留一个对已发送/已接收对象的引用的列表。就是这些引用,会阻止垃圾收集器对这些对象内存的释放。
当新对象的数量不断增长时(比方说在服务器中),最终将抛出"Java.lang.OutOfMemoryError"。解决办法就是使用 writeUnshared() 和 readUnshared() 方法来取代 writeObject() 和 readObject() 方法。
介绍
怎样在 Java 中创建一个内存泄漏?这是一个很好地面试题,因为 Java 编程中由于底层的自动垃圾收集功能而无需担心内存释放问题。但在复杂的情况下你仍然会遭遇 Java.lang.OutOfMemoryError 问题。这里就描述了一种这样的情况:由 java.io.ObjectInputStream 和 java.io.ObjectOutputStream 所实现的 Java 序列化机制所引起的内存泄漏。
原因
ObjectOutputStream 和 ObjectInputStream 都各自维护了一个对其已发送/已接收对象的引用表。所以当 ObjectOutputStream 重新发送某对象时,可以仍发送该对象的句柄,相应的 ObjectInputStream 则将接收到的句柄转换为先前接收到的对象的引用。Java 文档中这样描述:

使用共享机制 (…) 对单个对象的多个引用进行编码。

可以说,Java 的这一 feature 减少了带宽和内存的使用量,但也仅适用于通过链接定期重新发送对象的程序中。但在通过链接发送新对象的情况下,此功能除了不必要的(不断增长的)引用表以外没有任何作用。
垃圾收集器的工作原理就是清理不再被引用的对象。但由于 ObjectInputStream 始终保持了对其所接收的每个对象的引用,它阻止了垃圾收集器对通过流所接收的任何对象的清理。就在诸如服务器之类的程序中,它们接收到越来越多的对象,最终内存将被耗尽,引发 OutOfMemoryError。
情况识别和分析
OOM 可能有很多种不同的原因所造成。为了能够确定 OOM 是上文所讨论的 ObjectInputStream 相关 feature 所造成的,我们首先得来分析该进程的 heap dump。有很多种办法可以针对一个 Java 进程生成一个 heap dump,也有很多种办法来分析这些 dump 文件。我们可以通过将 JVM 命令行选项添加到进程执行命令行来生成 dump。还可以针对一个已经在运行中的程序来生成一个 dump。其中,通过添加命令行选项的方式可以在有 OOM 错误时生成 dump 文件(当然,还有一些其他命令行选项通过信号控制方式来在任意时间生成 dump,不管程序状态如何)。命令行选项如下:

-XX:+HeapDumpOnOutOfMemoryError

在 OOM 抛出时,一个名为 java_pid.hprof 的文件将会在该进程的执行目录所生成。有 n 多 dump 分析程序可以对 dump 文件进行分析,本文只对 jvm 原生自带的 VisualVM 进行介绍。
在 VisualVM 中,打开你收集到的 hprof 文件。在 classes 选项卡中,可以找到所有的类,这些类默认是按照 dump 中所存在的它们所拥有的实例数进行排序。当然,顶部的类极可能就是实例数暴多且导致内存错误的那个类。按字母顺序对类列表进行排序也是很有帮助的,这样可以将同一包的类分组在一起,进而可以对它们进行比较。
双击该列表中的一个类,视图切换至 instances 选项卡。对于每个实例,都有两个窗口。下边的那个保存指向该实例的参考路径。每个路径的边缘是一个垃圾收集 root(由三角形标记)。这样每个边缘点都是一个对象,该对象持有一个引用,防止 GC 对该对象的清理。如果很多这种爆炸的类实例都指向了一个 ObjectInputStream 对象,那么导致 OOM 的罪魁祸首可能就是本文所讨论的主题。
解决方案
首先,不要做的事情:尝试让 GC 更好的工作是没有好处的,比如调用

System.gc()

命令。OOM 事件的先决条件就是 GC 已经尽其最大努力分配了所需要的内存,但并未成功分配。
为了使对象流不再使用上文讨论的引用机制,I/O 方法:

writeObject()
readObject()

需要替换为以下方法:

writeUnshared()
readUnshared()

不同于 readObject() 方法的是,readUnshared() 方法不会在 ObjectInputStream 的引用列表中保存引用,因此能够防止内存泄漏。需要注意的是,如果传输对象已经实现了来自 Serializable 接口的 readResolve() 方法,则该对象的引用可能还是会被传递并保留在引用列表中。可以在 VisualVM 中对该引用的持有者进行进一步分析。
还有一种解决方案是在每次写入后调用 ObjectOutputStream reset() 方法。这将具有相同的效果。
关于使用 C vs. Java 所开发服务器的讨论
本文所讨论的内存泄漏很难被预料到。具有自动垃圾收集功能的语言进行开发的本质是,开发人员无需投入精力来了解处于不同状态的程序的内存状态。当然,这也是自动垃圾收集的优势之一。使用自动决策算法的陷阱是其决策必须保守。当关注 GC 时,这意味着在不确定是否需要清除内存时,它将始终选择不清除可能不需要的内存,而不会冒清除所需内存的风险。这就是为什么每个引用对象都不会被清除的原因,尽管程序不会使用所有引用对象。在由 ObjectInputStream 引起泄漏的情况下,对于开发人员来说,引用是完全隐藏和意外的。导致内存泄漏的其他情况可能是,例如,当保存对静态对象的引用数量不断增长时(永远不会清除自身),或者在传递内部类对象时(保留对外部类的隐式引用)。这些示例对开发人员来说更为明确,但泄漏被提前发现的机会仍然很小。
另一种选择是不使用自动垃圾收集,而是让开发人员自己处理所有内存,就像在 C 和 C++ 中进行开发时所做的那样。这种开发需要开发人员做更多的工作,但是对程序的内存状态的理解水平要高得多。它虽然也不能防止内存错误的发生,但是可以使开发人员在分析它们时更了解发生了什么。
C 语言和 Java 语言对开发人员对内存责任的不同态度的另一个例子是,在 C 网络程序中,总是很清楚通过链接发送的每个数据段的大小,而不是 Java 的 Object 流那样对其中完全不清楚。在 C 语言中,开发人员甚至还必须考虑操作系统对数据结构进行的地址对齐(使用 attribute((packed)))。
大多数服务器程序都是用 Java 或 Python 等带有自动垃圾收集器的语言所编写的。对于大部分人来讲,节省开发人员的时间比这里提到的陷阱更有价值。
原文链接:Java memory leak caused by ObjectInputStream

posted @ 2020-12-04 19:01  Defonds  阅读(122)  评论(0编辑  收藏  举报