Java FileOutputStream 连续关闭需要很长时间

9

我遇到了一个有点奇怪的情况。

我正在将大小约为500MB的文件从FileInputStream复制到FileOutputStream。 这个过程非常顺利(大约需要500毫秒)。 当我第一次关闭这个FileOutputStream时,它只需要大约1毫秒。

但是问题来了,当我再次运行此操作时,每次连续的关闭都需要大约1500-2000毫秒! 删除此文件后,持续时间会回归到1毫秒。

我是否缺少了一些重要的java.io知识?

这似乎与操作系统有关。 我在ArchLinux上运行(相同的代码在Windows 7上每次都不到20毫秒)。 请注意,无论它在OpenJDK还是Oracle的JDK中运行,结果都一样。 硬盘是具有ext4文件系统的固态驱动器。

下面是我的测试代码:

public void copyMultipleTimes() throws IOException {
    copy();
    copy();
    copy();
    new File("/home/d1x/temp/500mb.out").delete();
    copy();
    copy();
    // Runtime.getRuntime().exec("sync") => same results
    // Thread.sleep(30000) => same results
    // combination of sync & sleep => same results
    copy();
}

private void copy() throws IOException {
    FileInputStream fis = new FileInputStream("/home/d1x/temp/500mb.in");
    FileOutputStream fos = new FileOutputStream("/home/d1x/temp/500mb.out");
    IOUtils.copy(fis, fos); // copyLarge => same results
    // copying takes always the same amount of time, only close "enlarges"

    fis.close(); // input stream close this is always fast
    // fos.flush(); // has no effect 
    // fos.getFD().sync(); // Solves the problem but takes ~2.5s

    long start = System.currentTimeMillis();
    fos.close();
    System.out.println("OutputStream close took " + (System.currentTimeMillis() - start) + "ms");
}

输出结果如下:
OutputStream close took 0ms
OutputStream close took 1951ms
OutputStream close took 1934ms
OutputStream close took 1ms
OutputStream close took 1592ms
OutputStream close took 1727ms

Java7在Ubuntu 13.10上运行时具有相同的行为。 - Chris K
你能在 close(当然是在 long start = System.currentTimeMillis(); 之前)之前明确地 flush fos 吗? - A4L
这确实似乎是与操作系统有关的问题,因为我在Win7下无法复现它(得到不一致的结果)。 - blgt
这是正确的,Chris,但“经典”硬盘上的时间是相同的。 - zdenda.online
1
如果这与Java有任何关系,我会感到很惊讶。尝试使用“cp”命令。我预计你会得到非常相似的结果。这很可能与固态硬盘的工作原理有关。 - user207421
你是完全正确的,EJP。我写了一个小的bash脚本,得到了完全相同的结果。 - zdenda.online
3个回答

2
@Duncan提出了以下解释:
第一次调用close()很快就返回了,但操作系统仍在将数据刷新到磁盘。后续的close()调用无法完成,直到前一个刷新完成。
我认为这很接近实际情况,但并不完全正确。
我认为实际上正在发生的是,第一个副本正在使用大量脏页填充操作系统的文件缓存。内部守护程序会开始处理这些脏页,但当您启动第二个副本时,它仍在工作。
当您进行第二次复制时,操作系统会尝试获取缓存页面以进行读写。但由于缓存已满,其中包含大量脏页,因此读取和写入调用会被重复阻塞,等待可用的空闲页面。但在可以回收脏页之前,需要将页面中的数据写入磁盘。其净结果是复制速度下降到有效数据写入速率。
30秒暂停可能不足以完成将脏页刷新到磁盘的操作。
您可以尝试的一件事是在复制之间执行fsync(fd)或fdatasync(fd)。在Java中,执行此操作的方法是调用FileDescriptor.sync()。
现在,我不能确定这是否会提高总复制吞吐量,但我希望sync操作在写出(仅)一个文件方面要比依赖页面驱逐算法更好。

为什么会这样做呢? - Stephen C
我尝试在fos.close()之前添加fos.getFD().sync()。这确实有所帮助,因为现在close只需要1毫秒(但同步需要2-2.5秒)。 - zdenda.online
你认为添加这个同步功能(即使它比流式复制需要更多的时间)能证明你的想法吗?如果是,我可能会将你的答案标记为正确答案 - 这是目前最好的解释。 - zdenda.online
1
我不认为这证明了什么。虽然它支持了我的理论。此外,同步可能不是最大化复制吞吐量的方法。但话说回来,我并不认为你的测试是衡量复制吞吐量的现实方式。(反复复制同一个文件...) - Stephen C
1
将多个输入复制到一个输出文件中(除非您正在进行附加操作),同样是不现实的。我认为这些只是Linux开发人员认为不值得调整的“用例”。 - Stephen C
显示剩余2条评论

1
你似乎在做一些有趣的事情。在Linux下,当你打开一个文件时,有人可能正在持有原始文件的文件句柄,实际上删除目录条目并重新开始。这不会影响原始文件(句柄)。关闭时,可能会发生一些磁盘目录工作。
使用IOUtils.copyLarge和Files.copy进行测试。
Path target = Paths.get("/home/d1x/temp/500mb.out");
Files.copy(fis, target, StandardCopyOption.REPLACE_EXISTING);

我曾经看到过一个只调用了copyLarge的IOUtils.copy,但是Files.copy表现更好。


copyLarge 没有效果,但使用 Files.copy 可以正常工作 - 它总是只需要复制所需的时间(没有流关闭,而且 Files.copy 本身所需的时间与 IOUtils 相同)。 - zdenda.online
因此,当一个人将关闭操作留给Files.copy时,它就完成了。 (愉快的)也许IOUtils在InputStream周围包装了一个BufferedInputStream,并且在flush / close时发生了一些奇怪的事情? 不理解,而且由于IOUtils现在已经过时... - Joop Eggen
这可能与IOUtils无关,而是与FileOutputStream处理有关。如果您自己进行复制(使用自己的byte[]缓冲区和outputStream.write-s),结果将相同(关闭之间的长时间延迟)。如果您查看IOUtils.copy内部,您会发现它并没有做任何魔术(只是在while循环中简单地执行outputStream.write)。 - zdenda.online
那么值得研究的是 Files.copy(InputStream, Path) - 在某个时候。 - Joop Eggen
如果文件存在,则在复制之前会进行删除。这就是为什么它能正常工作的原因(例如,在调用复制之前进行删除时,示例也能正常工作)。 - zdenda.online
@d1x aha,Files.copy 检查 REPLACE_EXISTING 然后自己执行删除。这几乎让我有开始 C 编译器的冲动。 - Joop Eggen

0
请注意,这个问题是因为我好奇为什么会发生这种情况而提出的,它并不意味着要测量复制吞吐量。
总结一下:
正如EJP所指出的那样,整个问题与Java无关。如果在bash脚本中运行多个连续的cp命令,结果是相同的。
为什么会发生这种情况的最佳答案是Stephen的答案 - 在复制调用之间进行fsync可以解决这个问题(但fsync本身需要大约2.5秒)。
解决这个问题的最佳方法是使用Files.copy(I, o, REPLACE_EXISTING)(如Joop的答案中所示)=>首先检查目标文件是否存在,如果存在则删除它(而不是“覆盖”)。然后你就可以快速写入和关闭流了。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接