如何在Windows平台上使用Java创建并进行原子重命名文件?

7

我正在尝试在Windows上使用Java正确地实现“写临时文件并重命名”的操作。

如何在Java中原子地重命名文件,即使目标文件已经存在?建议重命名文件是“原子操作”(无论“原子”实际上意味着什么)。 https://stackoverflow.com/a/20570968/65458建议编写tmp文件并重命名是跨平台的,并确保最终文件不存在或可以被其他进程处理。

因此,我尝试实际实现这种方法。以下是我的尝试摘要。有关实际问题,请跳到底部。

写入方法

我尝试了各种写入和重命名文件的方式(contentcharset分别为StringCharset):

使用java.nio.file.Files

Files.copy(new ByteArrayInputStream(content.getBytes(charset)), tmpFile);
Files.move(tmpFile, finalFile, StandardCopyOption.ATOMIC_MOVE);

使用 Guava(14)和 java.io.File:
com.google.common.io.Files.write(content, tmpFile, charset);
tmpFile.renameTo(finalFile);

甚至更为晦涩的方法:
try (OutputStream os = new FileOutputStream(tmpFile);
        Writer writer = new OutputStreamWriter(os, charset)) {
    writer.write(content);
}
Runtime.getRuntime().exec(
        new String[] { "cmd.exe", "/C", "move " + tmpFile + " " + finalFile }).waitFor();

读取方法

现在假设另一个线程(因为我在测试中,实际上可能是另一个进程)正在执行以下版本的代码之一:

使用常规函数:

void waitUntilExists() throws InterruptedException {
    while (!java.nio.file.Files.exists(finalFile)) {
        NANOSECONDS.sleep(1);
    }
}

使用java.nio.file.Files:

waitUntilExists();
return new String(Files.readAllBytes(finalFile), charset);

使用Guava(14):
waitUntilExists();
return new String(com.google.common.io.Files.toByteArray(finalFile.toFile()), charset);

甚至更为晦涩的方法:
waitUntilExists();
StringBuilder sb = new StringBuilder();
try (InputStream is = new FileInputStream(finalFile.toFile())) {
    byte[] buf = new byte[8192];
    int n;
    while ((n = is.read(buf)) > 0) {
        sb.append(new String(buf, 0, n, charset));
    }
}
return sb.toString();

结果

如果我使用“java.nio.file.Files方法”进行读取,一切都正常。

如果我在Linux上运行此代码(超出本问题的范围,我知道),一切都正常。

然而,如果我使用Guava或FileInputStream实现read,那么在0.5%(0.005)以上的可能性下,测试将失败,并显示以下错误:

java.io.FileNotFoundException: 另一个程序正在使用此文件,进程无法访问。

(由于我的Windows不是英语,所以消息是我自己翻译的;引用“另一个进程”是误导性的,因为即使这是相同的进程,Windows 也会告诉这个错误,我通过显式阻塞进行了验证。)

问题

如何在Windows上使用Java实现创建后重命名,以便最终文件出现具有原子性,即要么不存在,要么可以被读取?

由于我对将拾取文件的进程具有控制权,因此不能假设使用任何特定的读取方法,甚至不能假设它们是用Java编写的。因此,解决方案应适用于上述所有读取方法。


没错。但我不能只说“让我们使用NIO来读取”,因为我只控制写入者。 - Piotr Findeisen
你的原子写入实现看起来正确。这可能是Java能做到的最好的了。你的读者可能只需要捕获错误并重试。 - Andrew Janke
Guava文件处理是基于java.io.FileInputStream和其他java.io类实现的,因此它可能具有相同的行为。 - Andrew Janke
@AndrewJanke,是的,Guava只是FileInput/OutputStream,但我可能会错过一些细节...顺便问一下,为什么使用NIO进行读取时行为不同?它是否使用不同的Windows API函数、不同的参数或其他什么东西? - Piotr Findeisen
1
或许将这两种方法都放在“procmon”下面会很有趣,以便查看它们各自执行了哪些系统调用以及所需的时间。这将让你了解它们是否在功能上不同,其中一种只是更快,或者其他情况。 - Andrew Janke
显示剩余6条评论
2个回答

0

1
那个错误涉及到当目标文件已经存在时的“非原子重命名”问题。我的问题是在更简单的情况下,“原子重命名”,当目标文件尚不存在时。 - Piotr Findeisen

0

这似乎是Windows/NTFS的行为方式。

此外,使用旧IO和NIO进行读取的行为差异可能是因为它们使用不同的Windows API。

文件锁定的维基百科说:

对于在Windows中使用文件读/写API的应用程序,文件系统执行强制性字节范围锁定(也称为强制锁定)。对于在Windows中使用文件映射API的应用程序,不执行字节范围锁定(也称为咨询锁定)。

虽然维基百科不是Windows的文档,但仍然有所启示。

我只是回答这个问题,以便其他人不必写这个问题。真正的答案,附带文档或报告的错误,非常感谢。


1
这里有关于 Windows 特定讨论: https://dev59.com/I3VC5IYBdhLWcg3w1E_w - Andrew Janke
1
也许以下关于[TxF - 事务性NTFS](https://msdn.microsoft.com/zh-cn/library/hh802690%28v=vs.85%29.aspx)的链接可以证明你的假设是与NTFS有关的。 - SubOptimal
1
也许这个也可以 在golang-nuts新闻组的讨论 - SubOptimal
@AndrewJanke,我之前看到了那个链接,但是我不太理解。那不是更多关于重命名并覆盖吗? - Piotr Findeisen
1
如果您想了解JVM正在执行什么操作,可以在Procmon下运行测试程序,它会告诉您它正在进行哪些系统调用。但这只告诉您此特定JVM实现正在执行什么操作,而不一定是API规范所保证的。ReplaceFile可能太高级而无法显示本身,但您仍然可能获得一些有趣的信息。 - Andrew Janke
显示剩余3条评论

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