目录
1、FileChannel
(1)获取 FileChannel
(2)读取文件
(3)写入文件
(4)关闭通道
(5)当前位置与文件大小
(6)强制写入磁盘
2、两个 FileChannel 之间的数据传输
(1)使用 transferTo() 进行文件传输
(2)处理大于 2GB 的文件
(3)为什么说 transferTo() 高效?
3、文件的操作
3.1、Path类
(1)创建 Path 实例
(2)目录中的特殊符号:. 和 ..
3.2、Files
(1)检查文件是否存在
(2)创建一级目录
(3)创建多级目录
(4)拷贝文件
(5)移动文件
(6)删除文件或目录
(7)遍历目录文件
(8)统计 .jar 文件数量
(9)删除多级目录
(10)拷贝多级目录
1、FileChannel
在 Java 的 NIO(New I/O)框架中,FileChannel
是一个强大的工具,它能让我们更高效地操作文件,但它和传统的 I/O 操作方式有很多不同。下面,让我们一起了解如何通过 FileChannel
来高效的进行文件的读写操作。
注意:一个需要注意的地方是,FileChannel
只能在阻塞模式下工作。虽然 NIO 中的大部分组件都支持非阻塞模式(比如 SocketChannel
),但 FileChannel
并不支持。换句话说,它会像传统 I/O 一样在读写时等待操作完成。
(1)获取 FileChannel
FileChannel
不能被直接创建 ,而是要通过以下几种方式间接获取:
FileInputStream
: 通过它获取的FileChannel
只能用于读操作。FileOutputStream
: 通过它获取的FileChannel
只能用于写操作。RandomAccessFile
: 这个类比较特殊,它可以同时支持读写,具体取决于你在创建RandomAccessFile
时的模式。
示例:
FileInputStream fis = new FileInputStream("data.txt");
FileChannel readChannel = fis.getChannel(); // 只读
FileOutputStream fos = new FileOutputStream("data.txt");
FileChannel writeChannel = fos.getChannel(); // 只写
RandomAccessFile raf = new RandomAccessFile("data.txt", "rw");
FileChannel readWriteChannel = raf.getChannel(); // 读写
(2)读取文件
从 FileChannel
读取数据需要借助 ByteBuffer
。当我们调用 read()
方法时,它会把数据读入 ByteBuffer
,并返回读到的字节数。如果返回值是 -1,则表示文件已经读取到末尾。
示例:
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buffer);
if (bytesRead == -1) {
System.out.println("文件已读完");
}
(3)写入文件
写入文件时,我们也需要通过 ByteBuffer
。先把数据写入 ByteBuffer
,然后再通过 write()
方法把 ByteBuffer
中的数据写入 FileChannel
。但有个特别需要注意的地方是,write()
方法不能保证会一次性把整个 ByteBuffer
的内容全部写完(如果 ByteBuffer
中的数据量大于操作系统或通道能够处理的最大写入量,write()
方法将只能写入部分数据。),因此需要循环调用 write()
。
示例:
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("Hello, FileChannel".getBytes());
buffer.flip(); // 切换成读模式
while (buffer.hasRemaining()) {
channel.write(buffer);
}
这里的 flip()
很重要(上一篇博客有讲到),因为它把 ByteBuffer
从写模式切换到了读模式,确保我们可以把数据从 ByteBuffer
中读出来并写入文件。
(4)关闭通道
使用完 FileChannel
后,务必要记得关闭它。但好消息是,如果我们关闭了对应的 FileInputStream
、FileOutputStream
或者 RandomAccessFile
,它们会自动关闭 FileChannel
,我们不用额外再关闭一次。
(5)当前位置与文件大小
可以通过 position()
方法获取当前的文件指针位置,也可以通过 position(long newPos)
方法来设置它的位置。如果把位置设置到文件末尾,再进行写操作时,新的内容会直接追加在文件末尾。如果位置超过了文件末尾,会在新内容和末尾之间填充空白数据。
long pos = channel.position(); // 获取当前位置
channel.position(pos + 1024); // 移动到当前位置之后的 1024 字节处
通过 size()
方法获取文件的大小。
long fileSize = channel.size();
System.out.println("文件大小: " + fileSize);
(6)强制写入磁盘
在写文件时,操作系统通常会先将数据缓存起来,而不是立即写入磁盘。如果你需要确保数据即时写入磁盘,可以调用 force(true)
方法。该方法会将文件内容和元数据(如权限信息)立即写入磁盘,防止数据丢失。
2、两个 FileChannel
之间的数据传输
当我们需要在文件之间进行拷贝时,NIO 提供了一种高效的方式,利用操作系统底层的零拷贝技术,大大提升了传输速度。就是使用两个 FileChannel
进行文件的拷贝操作。
(1)使用 transferTo()
进行文件传输
FileChannel
提供了两个非常实用的方法来进行文件传输:transferTo()
和 transferFrom()
。这两个方法让我们可以高效地从一个通道复制数据到另一个通道。下面是一个简单的例子,展示了如何通过 transferTo()
方法把一个文件的内容传输到另一个文件。
String FROM = "helloword/data.txt"; // 源文件路径
String TO = "helloword/to.txt"; // 目标文件路径
long start = System.nanoTime(); // 记录开始时间
try (FileChannel from = new FileInputStream(FROM).getChannel(); // 获取源文件的通道
FileChannel to = new FileOutputStream(TO).getChannel(); // 获取目标文件的通道
) {
from.transferTo(0, from.size(), to); // 通过 transferTo 方法传输数据
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime(); // 记录结束时间
System.out.println("transferTo 用时:" + (end - start) / 1000_000.0); // 输出耗时
输出结果会显示传输的时间
transferTo 用时:8.2011
transferTo()
方法可以让我们在两个 FileChannel
之间直接传输数据。参数 0
表示从文件开头开始传输,from.size()
表示传输的字节数等于源文件的大小。它利用了底层的操作系统机制(如 Linux 中的 sendfile()
),因此在大文件传输时具有非常高的效率。
(2)处理大于 2GB 的文件
对于小文件,上面那样的传输是没有问题的。但是,如果我们要处理超过 2GB 的大文件,transferTo()
可能会遇到一些限制,特别是在 32 位的系统中或者由于操作系统的内部限制。因此,我们需要进行分段传输。
以下代码展示了如何处理超过 2GB 的大文件:
public class TestFileChannelTransferTo {
public static void main(String[] args) {
try (
FileChannel from = new FileInputStream("data.txt").getChannel();
FileChannel to = new FileOutputStream("to.txt").getChannel();
) {
long size = from.size(); // 获取文件大小
// left 变量表示剩余未传输的字节数
for (long left = size; left > 0; ) {
System.out.println("position:" + (size - left) + " left:" + left);
// transferTo 支持的最大传输量是 2GB,因此我们用循环分段传输
left -= from.transferTo((size - left), left, to);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
- 由于
transferTo()
的一次传输量限制为 2GB(2147483647
字节),我们通过一个循环进行多次传输,直到传输完所有数据。每次调用transferTo()
后,会更新剩余的字节数,直到left
变为 0 表示传输完成。 - 这个方法非常高效,因为它利用了操作系统的零拷贝机制,避免了不必要的上下文切换和数据拷贝。
(3)为什么说 transferTo()
高效?
transferTo()
之所以高效,是因为它通过操作系统的低层 API 直接进行文件的传输,避免了从用户空间到内核空间的多次数据拷贝(零拷贝)。
(一)传统的 I/O 读写操作
在传统的 I/O 读写操作中,数据从一个文件传输到另一个文件通常需要经过如下步骤:
-
从磁盘读取数据到内核缓冲区: 操作系统首先从磁盘中读取数据,存放在操作系统的内核缓冲区(Kernel Buffer)中。
-
从内核缓冲区拷贝到用户空间: 应用程序从内核缓冲区中读取数据,并将其拷贝到用户空间的缓冲区(User Buffer),这是一个从操作系统到应用程序层的数据传递。
-
从用户空间写回内核缓冲区: 应用程序在获取到数据后,再将数据传回给另一个文件的内核缓冲区,即从用户空间的缓冲区拷贝回内核缓冲区,这里再次发生一次拷贝。
-
从内核缓冲区写入目标磁盘: 最后,操作系统将数据从目标文件的内核缓冲区写入到目标磁盘。
在这种情况下,数据从磁盘到磁盘的传输经历了 4 次拷贝 和 2 次上下文切换(应用程序和内核之间的切换),这导致了性能损耗。
(二)使用 transferTo()
或 transferFrom()
transferTo()
或 transferFrom()
的优化过程主要是依赖于零拷贝技术,省略了不必要的用户空间数据拷贝。使用这两个方法时,操作系统直接将数据从一个文件的内核缓冲区传输到另一个文件的内核缓冲区,过程如下:
-
从源文件读取数据到内核缓冲区: 操作系统从源文件中读取数据,直接存放在源文件的内核缓冲区中。
-
通过 DMA 将数据从内核缓冲区传输到目标缓冲区: 操作系统使用 DMA(Direct Memory Access) 机制,将数据从源文件的内核缓冲区直接传输到目标文件的内核缓冲区,而无需经过用户空间。这一步通过操作系统内核中的数据传输通道完成。
-
将数据从目标缓冲区写入磁盘: 最后,操作系统将目标缓冲区中的数据写入到目标磁盘。
这样,整个过程中只发生了 2 次拷贝 和 1 次上下文切换,并且跳过了用户空间的数据传输,从而大幅减少了 CPU 的参与,降低了 I/O 操作的成本,特别是在处理大文件时,这种优化表现非常显著。
(3)零拷贝的操作系统支持
-
Linux:在 Linux 中,
transferTo()
和transferFrom()
底层通过sendfile()
系统调用实现,它允许文件描述符之间直接传输数据,避免了数据拷贝到用户空间的过程。 -
Windows:在 Windows 操作系统中,类似的功能是通过
TransmitFile()
系统调用来实现的,提供了类似的零拷贝优化。
总结比较:
- 传统 I/O:4 次拷贝,2 次上下文切换
transferTo()
/transferFrom()
:2 次拷贝,1 次上下文切换
3、文件的操作
3.1、Path类
Path类是在 JDK 7 中引入的一个新类,它用于替代之前繁琐的 File
类来处理文件路径。相较于 File
类,Path
提供了更灵活的方式来表示和操作文件路径。与之配套的还有 Paths
工具类,它帮助我们更便捷地创建 Path
实例。下面,是一些简单的 Path
用法的例子。
(1)创建 Path 实例
使用 Paths.get()
来获取 Path
实例,可以通过传入不同形式的路径来表示文件的位置:
Path source = Paths.get("1.txt");
这个表示相对路径,它使用的是当前项目目录作为起点(可以通过 System.getProperty("user.dir")
查看当前项目所在目录)。
如果你想指定一个绝对路径,直接写绝对路径即可:
Path source = Paths.get("d:\\1.txt"); // 代表 d:\1.txt
Path source = Paths.get("d:/1.txt"); // 斜杠也可以正常使用
另外,Paths.get()
也可以通过多个参数来拼接路径:
Path projects = Paths.get("d:\\data", "projects"); // 这里的代码相当于 d:\data\projects。
(2)目录中的特殊符号:. 和 ..
.
表示当前目录。..
表示上一级目录。
假设你有以下目录结构:
d:
|- data
|- projects
|- a
|- b
如果你想表示 b
目录,但从 a
目录“退回”一步再进入 b
,你可以写成:
Path path = Paths.get("d:\\data\\projects\\a\\..\\b");
System.out.println(path);
这段代码输出的是 d:\data\projects\a\..\b
,看起来还不是很直观。不过,Path
提供了一个 normalize()
方法,它可以将路径中的 ..
等符号“正常化”:
System.out.println(path.normalize());
经过 normalize()
处理后,输出就变成了 d:\data\projects\b
,清晰地展示了最终的路径。
3.2、Files
Files
类是 NIO 中一套强大的文件操作工具,无论是检查文件是否存在、创建目录、拷贝文件,还是删除文件,Files
类都可以轻松搞定。
(1)检查文件是否存在
使用 Files.exists()
可以轻松判断某个文件或目录是否存在。
Path path = Paths.get("helloword/data.txt");
System.out.println(Files.exists(path)); // true 表示文件存在,false 表示文件不存在
(2)创建一级目录
如果目录已经存在,会抛出 FileAlreadyExistsException
,同时 createDirectory()
方法不能一次创建多级目录。如果父目录不存在,将抛出 NoSuchFileException
。
Path path = Paths.get("helloword/d1");
Files.createDirectory(path); // 创建单一的一级目录
(3)创建多级目录
与 createDirectory()
不同,createDirectories()
方法会自动创建不存在的父目录,非常方便。
Path path = Paths.get("helloword/d1/d2");
Files.createDirectories(path); // 创建多级目录
(4)拷贝文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/target.txt");
Files.copy(source, target); // 拷贝文件,如果目标文件已存在,会抛 FileAlreadyExistsException
如果希望在目标文件已存在时进行覆盖,可以使用 StandardCopyOption.REPLACE_EXISTING
:
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); // 覆盖已有文件
(5)移动文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/data_moved.txt");
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); // 原子性文件移动
StandardCopyOption.ATOMIC_MOVE
选项确保文件移动的操作是原子的,也就是说,要么移动成功,要么不做任何更改。
(6)删除文件或目录
删除文件
Path target = Paths.get("helloword/target.txt");
Files.delete(target); // 删除文件,如果文件不存在,会抛 NoSuchFileException
注意:如果文件不存在,Files.delete()
会抛出 NoSuchFileException
。如果不确定文件是否存在,可以使用 Files.deleteIfExists()
来避免异常抛出。
删除目录
Path target = Paths.get("helloword/d1");
Files.delete(target); // 删除空目录
注意:如果目录不为空,会抛出 DirectoryNotEmptyException
,需要先删除目录中的文件。
(7)遍历目录文件
使用 Files.walkFileTree()
可以递归遍历目录中的所有文件。以下代码将遍历指定目录,并统计文件和目录的数量:
public static void main(String[] args) throws IOException {
Path path = Paths.get("C:\\Program Files\\Java\\jdk1.8.0_91"); // 要遍历的根目录
AtomicInteger dirCount = new AtomicInteger(); // 记录目录数量
AtomicInteger fileCount = new AtomicInteger(); // 记录文件数量
// 遍历目录和文件
Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
System.out.println(dir); // 打印目录
dirCount.incrementAndGet(); // 统计目录数量
return super.preVisitDirectory(dir, attrs);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
System.out.println(file); // 打印文件
fileCount.incrementAndGet(); // 统计文件数量
return super.visitFile(file, attrs);
}
});
System.out.println("目录数:" + dirCount); // 输出目录总数
System.out.println("文件数:" + fileCount); // 输出文件总数
}
(8)统计 .jar
文件数量
假设我们想要统计某个目录中 .jar
文件的数量,可以在 visitFile
方法中添加对文件后缀的判断:
Path path = Paths.get("C:\\Program Files\\Java\\jdk1.8.0_91");
AtomicInteger fileCount = new AtomicInteger();
Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (file.toFile().getName().endsWith(".jar")) { // 判断文件是否为 .jar 结尾
fileCount.incrementAndGet();
}
return super.visitFile(file, attrs);
}
});
System.out.println("JAR 文件数:" + fileCount); // 输出 .jar 文件总数
(9)删除多级目录
我们可以使用 Files.walkFileTree()
来递归删除目录及其内容。以下代码演示了如何递归删除指定路径下的所有文件和目录:
Path path = Paths.get("d:\\a");
Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file); // 删除文件
return super.visitFile(file, attrs);
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir); // 删除空目录
return super.postVisitDirectory(dir, exc);
}
});
注意删除操作的风险:删除是一个非常危险的操作,特别是递归删除目录时,务必要确保该目录中的文件没有重要数据。
(10)拷贝多级目录
想要递归地拷贝整个多级目录,以下代码演示了如何遍历目录并进行拷贝操作:
long start = System.currentTimeMillis();
String source = "D:\\Snipaste-1.16.2-x64"; // 源目录
String target = "D:\\Snipaste-1.16.2-x64_copy"; // 目标目录
// 遍历源目录
Files.walk(Paths.get(source)).forEach(path -> {
try {
String targetName = path.toString().replace(source, target); // 将源路径转换为目标路径
if (Files.isDirectory(path)) { // 如果是目录
Files.createDirectory(Paths.get(targetName)); // 创建目录
} else if (Files.isRegularFile(path)) { // 如果是文件
Files.copy(path, Paths.get(targetName)); // 拷贝文件
}
} catch (IOException e) {
e.printStackTrace();
}
});
推荐:
【NIO基础】NIO(非阻塞 I/O)和 IO(传统 I/O)的区别,以及 NIO 的三大组件详解-CSDN博客https://blog.csdn.net/m0_65277261/article/details/142662308?spm=1001.2014.3001.5501【Redis】Redis中的 AOF(Append Only File)持久化机制-CSDN博客https://blog.csdn.net/m0_65277261/article/details/142661193?spm=1001.2014.3001.5501