Franz`s blog

Java文件拷贝最佳实践的思考

原始时代

对于Java中的文件IO我们最初学到也是最熟悉的方式就是通过BIO的方式去拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class FileCopyExample {
public static void main(String[] args) {
String sourceFile = "path/to/source/file.txt";
String destinationFile = "path/to/destination/file.txt";

try (FileInputStream fis = new FileInputStream(sourceFile);
FileOutputStream fos = new FileOutputStream(destinationFile)) {

// 创建一个字节数组缓冲区
byte[] buffer = new byte[1024];
int length;

// 从源文件读取数据到缓冲区,然后写入目标文件
while ((length = fis.read(buffer)) > 0) {
fos.write(buffer, 0, length);
}

System.out.println("文件拷贝成功!");
} catch (IOException e) {
System.err.println("拷贝文件时出错:" + e.getMessage());
}
}
}

为什么不好?

但是通过这种方式拷贝文件有什么不好的地方呢?

我们最先想到的可能就是存在多次拷贝。

其实我们仔细分析,这里面存在了四次文件的拷贝。

  1. 硬盘 –DMA–> 内核缓冲区(PageCache)

    这里还有一次用户态和内核态的转换

  2. 内核缓冲区 –CPU–> 用户空间

    这里也还有一次用户态和内核态的转换

  3. 用户空间 –CPU–> 硬盘缓冲区

  4. 硬盘缓冲区 –DMA–> 硬盘

这种拷贝的实现方式太慢了,所以我们自然而然的就想到使用零拷贝

而Java中的NIO存在零拷贝的实现。

Java中的Files.copy()底层其实也是通过NIO去实现的

NIO优化?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileCopyWithNIO {
public static void main(String[] args) {
// 指定源文件和目标文件的路径
String sourcePath = "path/to/source/file.txt";
String destinationPath = "path/to/destination/file.txt";

try (
FileInputStream fis = new FileInputStream(sourcePath);
FileOutputStream fos = new FileOutputStream(destinationPath);

// 获取输入输出通道
FileChannel sourceChannel = fis.getChannel();
FileChannel destinationChannel = fos.getChannel();
) {
// 创建缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);

// 从源通道读取数据到缓冲区
while (sourceChannel.read(buffer) != -1) {
// 切换为读模式
buffer.flip();
// 将缓冲区数据写入到目标通道,即复制数据
destinationChannel.write(buffer);
// 清空缓冲区,准备下一次写入
buffer.clear();
}

System.out.println("文件复制完成");
} catch (IOException e) {
e.printStackTrace();
}
}
}

这里面也涉及到了IO多路复用对于这个场景的优化,这里就不展开讲了,有兴趣的读者可以自己去查询资料。

零拷贝复制真的是最佳解?

问题

上面的零拷贝看起来好像已经完美的解决了这个问题,但是事实确实是如此吗?

我们不妨来分析一下零拷贝的这个过程。

  1. 硬盘 –DMA–> 内核缓冲区(PageCache)
  2. 内核缓冲区(PageCache) –CPU–> 硬盘缓冲区
  3. 硬盘缓冲区 –DMA–> 硬盘

我们发现零拷贝的过程其实依赖了一个内核缓冲区(PageCache)的东西,假如当我们拷贝一个超大文件时,我们发现大文件是无法利用内核缓冲区(PageCache)的,而因为内核缓冲区(PageCache)被占据,导致小文件也无法使用。反而减慢了拷贝的过程。

解决方法

所以我们自然而然的就想到了当大文件读写时绕过内核缓冲区,一种称为直接IO的技术。对于直接IO的方案,AIO通常是个好方法。

最终方案

通过上面的讨论,我们发现大文件(异步IO + 直接IO) + 小文件零拷贝是一种很好的解决方案。以下是这种方案代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

public class FileCopyDemo {

public static void main(String[] args) throws Exception {
Path source = Paths.get("path/to/source/file");
Path target = Paths.get("path/to/target/file");

// 获取文件大小
long fileSize = Files.size(source);

// 如果文件小于或等于5MB,则使用零拷贝方法
if (fileSize <= 5 * 1024 * 1024) {
zeroCopyFile(source, target);
} else {
// 对于大文件,使用AIO方式复制
aioCopyFile(source, target, fileSize);
}
}

private static void zeroCopyFile(Path source, Path target) throws IOException {
try (FileChannel sourceChannel = new FileInputStream(source.toFile()).getChannel();
FileChannel targetChannel = new FileOutputStream(target.toFile()).getChannel()) {
sourceChannel.transferTo(0, sourceChannel.size(), targetChannel);
}
}

private static void aioCopyFile(Path source, Path target, long fileSize) throws Exception {
try (AsynchronousFileChannel sourceChannel = AsynchronousFileChannel.open(source, StandardOpenOption.READ);
AsynchronousFileChannel targetChannel = AsynchronousFileChannel.open(target, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 使用1MB的缓存
futureAIOCopy(sourceChannel, targetChannel, buffer, 0, fileSize);
}
}

private static void futureAIOCopy(AsynchronousFileChannel sourceChannel, AsynchronousFileChannel targetChannel, ByteBuffer buffer, long position, long fileSize) throws Exception {
if (position >= fileSize) {
return; // 拷贝完成
}
buffer.clear();
Future<Integer> operation = sourceChannel.read(buffer, position);
Integer bytesRead = operation.get(); // 等待读操作完成
if (bytesRead > 0) {
buffer.flip();
targetChannel.write(buffer, position).get(); // 等待写操作完成
futureAIOCopy(sourceChannel, targetChannel, buffer, position + bytesRead, fileSize);
}
}
}