为什么流不关闭会导致内存泄漏

引言

经常有人告诉你流用完要记得关,不然会导致内存泄漏,但你是否考虑过下面这些问题:

  1. 为什么流不关会导致内存泄漏?
  2. JVM不是有垃圾回收机制吗?这些引用我用完不就变垃圾了为什么不会被回收呢?
  3. 流未关闭除了导致内存泄漏?是否还会引发别的问题?

这对这些问题,本文就再次对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相关,内存泄漏问题很明显是出在我们对文件的操作上。

为什么流不关闭会导致内存泄漏_第1张图片

先来说说排名第二的FileDescriptor,每个FileInputStrean内部都会维护一个FileDescriptorFileDescriptor可视为一个文件描述符,是打开一个文件的句柄。

public
class FileInputStream extends InputStream
{
    /* File Descriptor - handle to the open file */
    private final FileDescriptor fd;

	//略
}

对应的我们上文构造方法的调用如下:

  1. 将传入的文件名生成一个File对象,并调用另一个构造方法。
  2. 另一个构造方法进行安全以及文件有效性检查。
  3. 创建文件描述符,并让文件描述符和当前流进行关联,确保后续可以关闭。
  4. 通过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。

为什么流不关闭会导致内存泄漏_第2张图片

为了验证流是否持有资源,我们也可以在上述代码执行完成后,尝试在计算机上删除一下文件看看,最终结果会如下图所示,可以看到文件始终无法删除,很明显它被FileDescriptor所有持有。

由此我们得出,当IO流未关闭时,FileDescriptor将一直持有系统资源,所以GC进行垃圾回收时,无法将FileDescriptor对象及时回收,流不关闭不仅会导致内存泄漏,还会导致对系统资源持续占用,影响其他进程对系统资源的使用。

为什么流不关闭会导致内存泄漏_第3张图片

再来看看排名第一的Finalizer,因为是和垃圾回收相关,我们可以直接通过Finalizer类来定位问题,所以我们通过点击with outgoing references查看其引用了那些类:

为什么流不关闭会导致内存泄漏_第4张图片

可以看到该类内部引用了FileInputStream(占用内存排名第3的类),而FileInputStream又引用了FileDescriptor(排名第二的类)。

为什么流不关闭会导致内存泄漏_第5张图片

我们在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线程会将我们的FileInputStreamFileDescriptor对应的其从Finalizer引用中移除,下一次GC时即可被回收。

为什么流不关闭会导致内存泄漏_第6张图片

因为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();
        }
    }

更进一步

其实某些类我们操作完成后,可以不关闭流,例如:ByteArrayOutputStreamByteArrayInputStream,我们查看它的close方法,可以看到是空实现的,原因很简单,它们操作数据流时是在内存中操作字节的,并不会持有操作系统文件资源,当然了,为了统一开发习惯,我们还是建议读者操作流时,调用一下close。

public void close() throws IOException {
    }

小结

当IO流不关闭时,可能会导致以下对象无法被回收:

  1. FileInputStream 或其他输入流对象:如果你没有关闭 FileInputStream 对象,它会一直持有底层文件的句柄,这可能会导致文件资源无法释放。这样的对象将无法被垃圾回收器回收。
  2. FileOutputStream 或其他输出流对象:类似地,如果你没有关闭 FileOutputStream 对象,它可能会持有底层文件的句柄,并且可能导致写入缓冲区中的数据无法刷新到磁盘。这可能会导致资源泄漏和数据丢失。
  3. Socket 或其他网络连接相关的对象:如果你没有关闭 Socket 或其他网络连接相关的对象,它们可能会保持与远程主机的连接状态,这会导致网络资源无法释放,这些对象将无法被垃圾回收器回收,同样也可能导致端口号占用导致其他线程无法使用该端口的情况。
  4. BufferedReaderBufferedWriter 或其他缓冲流对象:如果你没有关闭这些缓冲流对象,它们可能会持有底层的输入流或输出流对象,并且可能会导致数据未能刷新或缓冲区数据未能清空。这可能会导致资源泄漏和数据丢失。

需要注意的是,即使没有显式地关闭这些对象,某些情况下它们可能会在垃圾回收器执行时被自动回收。但是,这取决于具体的垃圾回收算法和实现,所以我们不能依赖这种行为。正确的做法是在使用完这些对象后,显式地调用它们的 close() 方法来关闭流并释放相关资源,以防止资源泄漏和数据丢失。

你可能感兴趣的:(Java,java)