为什么使用 java.nio.files.File::list 会导致此广度优先文件遍历程序因 "Too many open files" 错误而崩溃?

Why does usage of java.nio.files.File::list is causing this breadth-first file traversal program to crash with the "Too many open files" error?

假设:

Streams 是惰性的,因此以下语句不会将 path 引用的目录的整个子目录加载到内存中;相反,它会一个一个地加载它们,并且在每次调用 forEach 之后,p 引用的目录有资格进行垃圾回收,因此它的文件描述符也应该关闭:

Files.list(path).forEach(p -> 
   absoluteFileNameQueue.add(
      p.toAbsolutePath().toString()
   )
);

基于这个假设,我实现了一个广度优先的文件遍历工具:

public class FileSystemTraverser {

    public void traverse(String path) throws IOException {
        traverse(Paths.get(path));
    }

    public void traverse(Path root) throws IOException {
        final Queue<String> absoluteFileNameQueue = new ArrayDeque<>();
        absoluteFileNameQueue.add(root.toAbsolutePath().toString());

        int maxSize = 0;
        int count = 0;

        while (!absoluteFileNameQueue.isEmpty()) {
            maxSize = max(maxSize, absoluteFileNameQueue.size());
            count += 1;
            Path path = Paths.get(absoluteFileNameQueue.poll());

            if (Files.isDirectory(path)) {
                Files.list(path).forEach(p ->
                        absoluteFileNameQueue.add(
                                p.toAbsolutePath().toString()
                        )
                );
            }

            if (count % 10_000 == 0) {
                System.out.println("maxSize = " + maxSize);
                System.out.println("count = " + count);
            }
        }

        System.out.println("maxSize = " + maxSize);
        System.out.println("count = " + count);
    }

}

而且我以一种相当直接的方式使用它:

public class App {

    public static void main(String[] args) throws IOException {
        FileSystemTraverser traverser = new FileSystemTraverser();
        traverser.traverse("/media/Backup");
    }

}

挂载在/media/Backup的磁盘大约有300万个文件。

由于某种原因,在 140,000 标记附近,程序崩溃并显示以下堆栈跟踪:

Exception in thread "main" java.nio.file.FileSystemException: /media/Backup/Disk Images/Library/Containers/com.apple.photos.VideoConversionService/Data/Documents: Too many open files
    at sun.nio.fs.UnixException.translateToIOException(UnixException.java:91)
    at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102)
    at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:107)
    at sun.nio.fs.UnixFileSystemProvider.newDirectoryStream(UnixFileSystemProvider.java:427)
    at java.nio.file.Files.newDirectoryStream(Files.java:457)
    at java.nio.file.Files.list(Files.java:3451)

在我看来,由于某种原因,文件描述符没有关闭或者 Path 对象没有被垃圾回收,导致应用程序最终崩溃。

系统详细信息


任何想法我在这里遗漏了什么以及如何解决这个问题(不求助于 java.io.File::list(即留在 NIO2 和 Paths 的范围内)?


更新 1:

我怀疑 JVM 是否使文件描述符保持打开状态。我在 120,000 个文件标记附近进行了堆转储:

更新 2:

我在 VisualVM 中安装了一个文件描述符探测插件,它确实显示 FD 没有被处理掉(正如 cerebrotecnologico 和 k5 正确指出的那样):

似乎从 Files.list(Path) 返回的 Stream 没有正确关闭。此外,您不应该在您不确定它不是并行的流上使用 forEach(因此使用 .sequential())。

    try (Stream<Path> stream = Files.list(path)) {
        stream.map(p -> p.toAbsolutePath().toString()).sequential().forEach(absoluteFileNameQueue::add);
    }

来自 Java 文档:

"The returned stream encapsulates a DirectoryStream. If timely disposal of file system resources is required, the try-with-resources construct should be used to ensure that the stream's close method is invoked after the stream operations are completed"

其他答案为您提供了解决方案。我只是想纠正你问题中的这种误解,这是你问题的根本原因

... the directory referenced by p is eligible for garbage collection, so its file descriptor should also become closed.

这个假设是不正确的。

是的,目录(实际上是 DirectoryStream)将符合 的垃圾回收条件。但是,这并不意味着它 将被 垃圾收集。 GC 运行s 当 Java 运行 时间系统确定它是 运行 的好时机。一般来说,它不考虑您的应用程序创建的打开文件描述符的数量。

换句话说,您不应该依赖垃圾收集和终结来关闭资源。如果您需要及时关闭某个资源,那么您的应用程序应该自行处理。 "try-with-resources" 构造是推荐的方法。


您发表评论:

I actually thought that because nothing references the Path objects and that their FDs are also closed, then the GC will remove them from the heap.

一个Path对象没有文件描述符。如果您查看 API,也没有 Path.close() 操作。

您的示例中泄露的文件描述符实际上与 list(path) 创建的 DirectoryStream 个对象相关联。 Stream.forEach() 调用完成后,这些对象将符合条件。

My misunderstanding was that the FD of the Path objects are closed after each forEach invocation.

嗯,这没有意义;见上文。

但即使它确实有意义(即如果 Path 对象确实有文件描述符),GC 也没有机制来 知道 它需要在那一点对 Path 对象做一些事情。

Otherwise I know that the GC does not immediately remove eligible objects from the memory (hence the term "eligible").

这确实是>><<问题的根源...因为符合条件的文件描述符对象将>>仅<<在 GC 运行s 时被最终确定。