JAVA 拷贝文件的几种方式
1. 前言
闲话少叙,今天主要讲讲 JAVA 四种拷贝文件的方式,分析一下他们对内存使用的方式和各自应用的场景,其实也是对之前学过的知识做一个回顾吧,毕竟太久不回顾的话,记忆就像拼图,随着时间流逝就只剩下散落一地的碎片了。
2. 普通拷贝
java
protected void copyFile(File source, File target) {
try (FileInputStream is = new FileInputStream(source);
FileOutputStream os = new FileOutputStream(target);) {
// 分配内存空间
byte[] buffer = new byte[4096];
while (is.read(buffer) != -1) {
os.write(buffer);
}
} catch (Exception e) {
logger.error(e);
}
}
第一种是最简单的,就是初始化一个输入输出流,然后在 JAVA 内部分配一块 4096 字节的内存空间,然后不断将文件写入这个内存空间中,并输出到指定文件。
但是需要注意的是,这样的方式虽然简单,但是它的数据流实际上是经过了 4 层传输的
也就是我们的文件需要经过内核到我们 JAVA 虚拟机内部的内存 再到 内核的 socket 缓冲区,再到文件。
3. mmap 内存映射的方式拷贝
java
protected void copyFile(File source, File target) {
try (FileInputStream is = new FileInputStream(source);
FileOutputStream os = new FileOutputStream(target);
FileChannel ic = is.getChannel();
FileChannel oc = os.getChannel();) {
// 这里开辟的内存直接映射在内核中
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
while (ic.read(buffer) != -1) {
buffer.flip();
oc.write(buffer);
buffer.clear();
}
} catch (Exception e) {
logger.error(e);
}
}
第二种由于直接将内存映射在了堆外,也就可以节省普通拷贝中第二步的过程,即不在需要将内核缓冲区中的内容再读到给 java 虚拟机分配的内存中了,比较适合需要 JAVA程序进行文件处理,或者一些小文件的传输
或者也可以通过封装好的
java
new RandomAccessFile(file, "r").getChannel().map(FileChannel.MapMode.READ_ONLY, 0, 1024);
来实现,底层是通过 directByteBufferConstructor.newInstance
分配堆外内存来实现的。
4. 零拷贝 sendFile 方式实现
java
protected void copyFile(File source, File target) {
try {
if (!target.exists()) {
target.createNewFile();
}
} catch (Exception e) {
logger.error(e);
}
try (FileChannel is = new RandomAccessFile(source, "r").getChannel();
FileChannel os = new RandomAccessFile(target, "rw").getChannel()) {
is.transferTo(0, source.length(), os);
} catch (Exception e) {
logger.error(e);
}
}
第三种其实也就是我们俗称的零拷贝的方式,在 Linux 2.1 版本中,引入了 sendFile
方法,也就是可以跳过用户空间直接实现传输,java 程序中通过 新io 中 file 的 transformTo
方法,底层调用 liunx
内核级的 sendFile
方法,将内核数据直接拷贝到了 socket
缓冲区,从而节省了拷贝次数和消耗。
既然第三种方式相对于第一种和第二种来说,可以完全不经过 java 应用程序,为什么不都直接都用第三种就好了呢?
正是因为它完全不经过 java 程序,也就是说我们无法对文件内容进行二次修改了,第三种方式比较适用于我们将无需经过程序处理的大文件。
需要注意的是,之所以会有程序的内存空间和内核的内存空间的区别,其实主要就是为了隔离,防止恶意程序可以直接访问内核的内存空间
5. 多线程的方式实现拷贝
java
// 定义一个线程的数量
private Integer threadCount = 5;
protected void copyFile(File source, File target) {
long workLoad = source.length() / threadCount;
for (Integer i = 0; i < threadCount; i++) {
ThreadFileRunnable threadFileRunnable = new ThreadFileRunnable(source, target, i * workLoad, workLoad);
new Thread(threadFileRunnable, "copy-thread" + i).start();
}
}
private class ThreadFileRunnable implements Runnable {
private File source;
private File target;
// 定义每个线程开始复制时跳过的字节长度和工作负载大小
private long skipLen;
private long workLoad;
// 定义IO操作的单位大小,这里设置为1024字节
private final int IO_UNIT = 1024;
public ThreadFileRunnable (File source, File target, long skipLen, long workLoad) {
this.source = source;
this.target = target;
this.skipLen = skipLen;
this.workLoad = workLoad;
}
@Override
public void run() {
try {
try (FileInputStream is = new FileInputStream(this.source);
BufferedInputStream bis = new BufferedInputStream(is);
// 创建目标文件的RandomAccessFile,以读写模式打开
RandomAccessFile rof = new RandomAccessFile(this.target, "rw");) {
// 跳过指定偏移量
bis.skip(this.skipLen);
// 将读写指针移动到指定偏移量
rof.seek(this.skipLen);
byte[] bytes = new byte[IO_UNIT];
// 计算需要进行的IO操作次数
long io_num = this.workLoad / IO_UNIT + 1;
// 如果工作负载大小能被IO_UNIT整除,则IO操作次数减1
if (this.workLoad % IO_UNIT == 0) {
io_num--;
}
int count = bis.read(bytes);
while (io_num != 0) {
rof.write(bytes,0,count);
count = bis.read(bytes,0,count);
io_num--;
}
}
} catch (Exception e) {
// 捕获并打印异常信息
e.printStackTrace();
}
}
}
第四种如果文件特别大的时候,我们还可以通过多线程的方式来进行文件的读写,可以充分利用 CPU 多核效率来进一步提升文件的处理效率。
5. 总结
对于 JAVA 文件拷贝来说,本文只是展示和介绍了冰山一角,实际上对于读写流操作,操作系统的实现经过了长时间的演化,从 CPU 中断
到 pagecache
,从 sendFile
到 DMA
,以及网络传输过程中的 bio
nio
poll
和 epoll
,操作系统经过很多年的演化其中文件和网络的传输处理的复杂程度可想而知。
我想我们可以通过一些小的点管中窥豹,了解一些基础的知识,不用太深入,也能对日常的开发工作和面试有一定帮助。
参考链接 Linux 中的零拷贝