引言
经常有人告诉你流用完要记得关,不然会导致内存泄漏,但你是否考虑过下面这些问题:
- 为什么流不关会导致内存泄漏?
- JVM不是有垃圾回收机制吗?这些引用我用完不就变垃圾了为什么不会被回收呢?
- 流未关闭除了导致内存泄漏?是否还会引发别的问题?
这对这些问题,本文就再次对IO流底层工作工作原理展开探讨。
问题复现
代码演示
我们首先来一段示例代码,每次请求时就会创建1w个文件输入流,创建完成后并没有关闭,后续我们会通过压测工具请求这个接口。
@RequestMapping("noClose")
public String noClose() throws FileNotFoundException {
//每次请求进来就创建1w次输入文件输入流
for (int i = 0; i < 10000; i++) {
openFileStream();
}
return "success";
}
private static void openFileStream() throws FileNotFoundException {
InputStream is = new FileInputStream("data.txt");
}
为了更快看到效果,我们调整堆内存为50m:
-Xmx50m
问题定位
随后我们通过jmeter进行接口压测,不久后问题就出现了:
Exception in thread "RMI TCP Connection(idle)" java.lang.OutOfMemoryError: GC overhead limit exceeded
.....
Caused by: java.lang.OutOfMemoryError: GC overhead limit exceeded
Exception in thread "RMI TCP Connection(idle)" java.lang.OutOfMemoryError: GC overhead limit exceeded
Exception in thread "RMI TCP Connection(idle)" java.lang.OutOfMemoryError: GC overhead limit exceeded
Exception in thread "RMI TCP Connection(idle)" java.lang.OutOfMemoryError: GC overhead limit exceeded
原因分析
对此我们通过jps定位进程号,然后将内存信息导出:
jmap -dump:live,format=b,file=xxxx.hprof pid
通过mat将导出的xxxx.hprof打开,可以看到排名前3的几个类中包含了File相关,内存泄漏问题很明显是出在我们对文件的操作上。
先来说说排名第二的FileDescriptor
,每个FileInputStrean
内部都会维护一个FileDescriptor
,FileDescriptor
可视为一个文件描述符,是打开一个文件的句柄。
public
class FileInputStream extends InputStream
{
/* File Descriptor - handle to the open file */
private final FileDescriptor fd;
//略
}
对应的我们上文构造方法的调用如下:
- 将传入的文件名生成一个File对象,并调用另一个构造方法。
- 另一个构造方法进行安全以及文件有效性检查。
- 创建文件描述符,并让文件描述符和当前流进行关联,确保后续可以关闭。
- 通过open调用操作系统的open函数打开文件并获得文件句柄,此时我们的流就和系统资源关联起来了。
public FileInputStream(String name) throws FileNotFoundException {
this(name != null ? new File(name) : null);
}
public FileInputStream(File file) throws FileNotFoundException {
//安全性检查
String name = (file != null ? file.getPath() : null);
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkRead(name);
}
//文件有效性检查
if (name == null) {
throw new NullPointerException();
}
if (file.isInvalid()) {
throw new FileNotFoundException("Invalid file path");
}
//创建文件描述符,并获得文件句柄并设置到fd上
fd = new FileDescriptor();
fd.attach(this);
path = name;
open(name);
}
所以,当我们使用完流之后不将流关闭,FileDescriptor
将会一直持有着操作系统资源,所以当JVM
进行垃圾回收时,因为文件资源还没有释放,这些类就无法及时被及时GC。
为了验证流是否持有资源,我们也可以在上述代码执行完成后,尝试在计算机上删除一下文件看看,最终结果会如下图所示,可以看到文件始终无法删除,很明显它被FileDescriptor
所有持有。
由此我们得出,当IO流未关闭时,FileDescriptor
将一直持有系统资源,所以GC进行垃圾回收时,无法将FileDescriptor
对象及时回收,流不关闭不仅会导致内存泄漏,还会导致对系统资源持续占用,影响其他进程对系统资源的使用。
再来看看排名第一的Finalizer
,因为是和垃圾回收相关,我们可以直接通过Finalizer
类来定位问题,所以我们通过点击with outgoing references
查看其引用了那些类:
可以看到该类内部引用了FileInputStream
(占用内存排名第3的类),而FileInputStream
又引用了FileDescriptor
(排名第二的类)。
我们在FileInputStream
会看到,它重写了finalize
方法,从代码上可以看出该方法会对没有及时回收的FileDescriptor
进行流释放和系统资源归还。
protected void finalize() throws IOException {
if ((fd != null) && (fd != FileDescriptor.in)) {
close();
}
}
查阅资料笔者发现,重写finalize
方法的类将会被Finalizer
所引用,正因被Finalizer
所引用,所以即使我们使用完成并退出函数后,进行GC时这些对象并不会被回收。
只有当GC完成之后,JVM才会将这些仅仅被Finalizer
引用的类标记出来,并存放到ReferenceQueue
这个队列中,直到被Finalizer
线程发现并调用finalize
后,以本文为例finalize即释放文件句柄和系统资源,Finalizer
线程会将我们的FileInputStream
和FileDescriptor
对应的其从Finalizer
引用中移除,下一次GC时即可被回收。
因为Finalizer
线程优先级非常低,所以这些垃圾被回收的频率是非常低的,这也就是为什么我们会在内存快照中看到大量Finalizer
指向的类没有被及时回收。
因为我们手动关闭的流的缘故,导致大量的FileDescriptor
类持有文件流和系统资源,使得FileDescriptor
无法被GC回收,需要借助Finalizer
线程调用finalize
释放系统资源后才具备被GC的资格,由于Finalizer
线程优先级极低,流的创建速度远远大于回收速度,最终就导致堆内存无法及时释放出现内存泄漏。
解决方案
解决方案也很简单,及时关闭流就好了,而且jdk7也为我们提供了try-with-resource
,语法简洁需多。
protected void finalize() throws IOException {
if ((fd != null) && (fd != FileDescriptor.in)) {
close();
}
}
更进一步
其实某些类我们操作完成后,可以不关闭流,例如:ByteArrayOutputStream
和ByteArrayInputStream
,我们查看它的close方法,可以看到是空实现的,原因很简单,它们操作数据流时是在内存中操作字节的,并不会持有操作系统文件资源,当然了,为了统一开发习惯,我们还是建议读者操作流时,调用一下close。
public void close() throws IOException {
}
小结
当IO流不关闭时,可能会导致以下对象无法被回收:
FileInputStream
或其他输入流对象:如果你没有关闭FileInputStream
对象,它会一直持有底层文件的句柄,这可能会导致文件资源无法释放。这样的对象将无法被垃圾回收器回收。FileOutputStream
或其他输出流对象:类似地,如果你没有关闭FileOutputStream
对象,它可能会持有底层文件的句柄,并且可能导致写入缓冲区中的数据无法刷新到磁盘。这可能会导致资源泄漏和数据丢失。Socket
或其他网络连接相关的对象:如果你没有关闭 Socket 或其他网络连接相关的对象,它们可能会保持与远程主机的连接状态,这会导致网络资源无法释放,这些对象将无法被垃圾回收器回收,同样也可能导致端口号占用导致其他线程无法使用该端口的情况。BufferedReader
、BufferedWriter
或其他缓冲流对象:如果你没有关闭这些缓冲流对象,它们可能会持有底层的输入流或输出流对象,并且可能会导致数据未能刷新或缓冲区数据未能清空。这可能会导致资源泄漏和数据丢失。
需要注意的是,即使没有显式地关闭这些对象,某些情况下它们可能会在垃圾回收器执行时被自动回收。但是,这取决于具体的垃圾回收算法和实现,所以我们不能依赖这种行为。正确的做法是在使用完这些对象后,显式地调用它们的 close()
方法来关闭流并释放相关资源,以防止资源泄漏和数据丢失。