Java NIO 文件通道 FileChannel 用法

Java
297
0
0
2023-05-26

FileChannel 提供了一种通过通道来访问文件的方式,它可以通过带参数 position(int) 方法定位到文件的任意位置开始进行操作,还能够将文件映射到直接内存,提高大文件的访问效率。本文将介绍其详细用法和原理。

1. 通道获取

FileChannel 可以通过 FileInputStream, FileOutputStream, RandomAccessFile 的对象中的 getChannel() 方法来获取,也可以通通过静态方法 FileChannel.open(Path, OpenOption …) 来打开。

1.1 从 FileInputStream / FileOutputStream 中获取

从 FileInputStream 对象中获取的通道是以读的方式打开文件,从 FileOutpuStream 对象中获取的通道是以写的方式打开文件。

 FileOutputStream ous = new FileOutputStream(new File("a.txt"));
FileChannel out = ous.getChannel(); // 获取一个只读通道
FileInputStream ins = new FileInputStream(new File("a.txt"));
FileChannel in = ins.getChannel();  // 获取一个只写通道  

1.2 从 RandomAccessFile 中获取

从 RandomAccessFaile 中获取的通道取决于 RandomAccessFaile 对象是以什么方式创建的,”r”, “w”, “rw” 分别对应着读模式,写模式,以及读写模式。

 RandomAccessFile file = new RandomAccessFile("a.txt", "rw");
FileChannel channel = file.getChannel(); // 获取一个可读写文件通道  

1.3 通过 FileChannel.open() 打开

通过静态静态方法 FileChannel.open() 打开的通道可以指定打开模式,模式通过 StandardOpenOption 枚据类型指定。

 FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ); // 以只读的方式打开一个文件 a.txt 的通道  

2. 读取数据

读取数据的 read(ByteBuffer buf) 方法返回的值表示读取到的字节数,如果读到了文件末尾,返回值为 -1。读取数据时,position 会往后移动。

2.1 将数据读取到单个缓冲区

和一般通道的操作一样,数据也是需要读取到1个缓冲区中,然后从缓冲区取出数据。在调用 read 方法读取数据的时候,可以传入参数 position 和 length 来指定开始读取的位置和长度。

 FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ);
ByteBuffer buf = ByteBuffer.allocate(5);
while(channel.read(buf)!=-1){
    buf.flip();
    System.out.print(new String(buf.array()));
    buf.clear();
}
channel.close();  

2.2 读取到多个缓冲区

文件通道 FileChannel 实现了 ScatteringByteChannel 接口,可以将文件通道中的内容同时读取到多个 ByteBuffer 当中,这在处理包含若干长度固定数据块的文件时很有用。

 ScatteringByteChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ);
ByteBuffer key = ByteBuffer.allocate(5), value=ByteBuffer.allocate(10);
ByteBuffer[] buffers = new ByteBuffer[]{key, value};
while(channel.read(buffers)!=-1){
    key.flip();
    value.flip();
    System.out.println(new String(key.array()));
    System.out.println(new String(value.array()));
    key.clear();
    value.clear();
}
channel.close();  

3. 写入数据

3.1 从单个缓冲区写入

单个缓冲区操作也非常简单,它返回往通道中写入的字节数。

 FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE);
ByteBuffer buf = ByteBuffer.allocate(5);
byte[] data = "Hello, Java NIO.".getBytes();
for (int i = 0; i < data.length; ) {
    buf.put(data, i, Math.min(data.length - i, buf.limit() - buf.position()));
    buf.flip();
    i += channel.write(buf);
    buf.compact();
}
channel.force(false);
channel.close();  

3.2 从多个缓冲区写入

FileChannel 实现了 GatherringByteChannel 接口,与 ScatteringByteChannel 相呼应。可以一次性将多个缓冲区的数据写入到通道中。

 FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE);
ByteBuffer key = ByteBuffer.allocate(10), value = ByteBuffer.allocate(10);
byte[] data = "017 Robothy".getBytes();
key.put(data, 0, 3);
value.put(data, 4, data.length-4);
ByteBuffer[] buffers = new ByteBuffer[]{key, value};
key.flip();
value.flip();
channel.write(buffers);
channel.force(false); // 将数据刷出到磁盘
channel.close();  

3.3 数据刷出

为了减少访问磁盘的次数,通过文件通道对文件进行操作之后可能不会立即刷出到磁盘,此时如果系统崩溃,将导致数据的丢失。为了减少这种风险,在进行了重要数据的操作之后应该调用 force() 方法强制将数据刷出到磁盘。

无论是否对文件进行过修改操作,即使文件通道是以只读模式打开的,只要调用了 force(metaData) 方法,就会进行一次 I/O 操作。参数 metaData 指定是否将元数据(例如:访问时间)也刷出到磁盘。

 channel.force(false); // 将数据刷出到磁盘,但不包括元数据  

4. 文件锁

可以通过调用 FileChannel 的 lock() 或者 tryLock() 方法来获得一个文件锁,获取锁的时候可以指定参数起始位置 position,锁定大小 size,是否共享 shared。如果没有指定参数,默认参数为 position = 0, size = Long.MAX_VALUE, shared = false。

位置 position 和大小 size 不需要严格与文件保持一致,position 和 size 均可以超过文件的大小范围。例如:文件大小为 100,可以指定位置为 200, 大小为 50;则当文件大小扩展到 250 时,[200,250) 的部分会被锁住。

shared 参数指定是排他的还是共享的。要获取共享锁,文件通道必须是可读的;要获取排他锁,文件通道必须是可写的。

由于 Java 的文件锁直接映射为操作系统的文件锁实现,因此获取文件锁时代表的是整个虚拟机,而非当前线程。若操作系统不支持共享的文件锁,即使指定了文件锁是共享的,也会被转化为排他锁。

 FileLock lock = channel.lock(0, Long.MAX_VALUE, false);// 排它锁,此时同一操作系统下的其它进程不能访问 a.txt
System.out.println("Channel locked in exclusive mode.");
Thread.sleep(30 * 1000L); // 锁住 30 s
lock.release(); // 释放锁

lock = channel.lock(0, Long.MAX_VALUE, true); // 共享锁,此时文件可以被其它文件访问
System.out.println("Channel locked in shared mode.");
Thread.sleep(30 * 1000L); // 锁住 30 s
lock.release();  

与 lock() 相比,tryLock() 是非阻塞的,无论是否能够获取到锁,它都会立即返回。若 tryLock() 请求锁定的区域已经被操作系统内的其它的进程锁住了,则返回 null;而 lock() 会阻塞,直到获取到了锁、通道被关闭或者线程被中断为止。

5. 通道转换

普通的读写方式是利用一个 ByteBuffer 缓冲区,作为数据的容器。但如果是两个通道之间的数据交互,利用缓冲区作为媒介是多余的。文件通道允许从一个 ReadableByteChannel 中直接输入数据,也允许直接往 WritableByteChannel 中写入数据。实现这两个操作的分别为 transferFrom(ReadableByteChannel src, position, count) 和 transferTo(position, count, WritableChannel target) 方法。

在进行通道间的数据传输时,这两个方法比使用 ByteBuffer 作为媒介的效率要高;很多操作系统支持文件系统缓存,两个文件之间实际可能并没有发生复制。

transferFrom 或者 transferTo 在调用之后并不会改变 position 的位置。

下面示例是一个 spring 源码 中的一个工具方法。

 public static void copy(File source, File target) throws IOException {
    FileInputStream sourceOutStream = new FileInputStream(source);
    FileOutputStream targetOutStream = new FileOutputStream(target);
    FileChannel sourceChannel = sourceOutStream.getChannel();
    FileChannel targetChannel = targetOutStream.getChannel();
    sourceChannel.transferTo(0, sourceChannel.size(), targetChannel);
    sourceChannel.close();
    targetChannel.close();
    sourceOutStream.close();
    targetOutStream.close();
}  

需要注意的是,调用这两个转换方法之后,某些情况下并不保证数据能够全部完成传输,确切传输了多少字节的数据需要根据返回的值来进行判断。例如:从一个非阻塞模式下的 SocketChannel 中输入数据就不能够一次性将数据全部传输过来,或者将文件通道的数据传输给一个非阻塞模式下的 SocketChannel 不能一次性传输过去。

下面给出一个示例,客户端连接到服务端,然后从服务端下载一个叫 video.mp4 文件,文件在当前目录存在。

错误示例:

 /** 服务端 **/ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 打开服务通道
serverSocketChannel.bind(new InetSocketAddress(9090)); // 绑定端口号
SocketChannel clientChannel = serverSocketChannel.accept(); // 等待客户端连接,获取 SocketChannel
FileChannel fileChannel = FileChannel.open(Paths.get("video.mp4"), StandardOpenOption.READ); // 打开文件通道
fileChannel.transferTo(0, fileChannel.size(), clientChannel); // 【可能出错位置】文件通道数据输出转化到 socket 通道,输出范围为整个文件。文件太大将导致输出不完整

/** 客户端 **/SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9090)); // 打卡 socket 通道并连接到服务端
FileChannel fileChannel = FileChannel.open(Paths.get("video-downloaded.mp4"), StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE, StandardOpenOption.CREATE); // 打开文件通道
fileChannel.transferFrom(socketChannel, 0, Long.MAX_VALUE); // 【非阻塞模式下可能出错】
fileChannel.force(false); // 确保数据刷出到磁盘  

正确的姿势是:transferTo/transferFrom 的时候应该用一个循环检查实际输出内容大小是否和期望输出内容大小一致,特别是通道处于非阻塞模式下,极大概率不能够一次传输完成。

所以服务端正确的转换方式是:

 long transfered = 0;
while (transfered < fileChannel.size()){
    transfered += fileChannel.transferTo(transfered, fileChannel.size(), clientChannel);
}  

本例中客户端使用的是阻塞模式,服务端通道关闭输出(socketChannel.shutdownOutput())之后 transferFrom 才退出,服务端正常关闭通道的情况下数据传输不会出错,这里就不处理非正常关闭的情况了。( 完整代码 )。

6. 截取文件

FileChannel.truncate(long size) 可以截取指定的文件,指定大小之后的内容将被丢弃。size 的值可以超过文件大小,超过的话不会截取任何内容,也不会增加任何内容。

 FileChannel fileChannel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE);
fileChannel.truncate(1);
System.out.println(fileChannel.size()); // 输出 1
fileChannel.write(ByteBuffer.wrap("Hello".getBytes()));
System.out.println(fileChannel.size()); // 输出 5
fileChannel.force(true);
fileChannel.close();  

7. 映射文件到直接内存

文件通道 FileChannel 可以将文件的指定范围映射到程序的地址空间中,映射部分使用字节缓冲区的一个子类 MappedByteBuffer 的对象表示,只要对映射字节缓冲区进行操作就能够达到操作文件的效果。与之相对应的,前面介绍的内容是通过操作文件通道和堆内存中的字节缓冲区 HeapByteBuffer 来达到操作文件的目的。

通过 ByteBuffer.allocate() 分配的缓冲区是一个 HeapByteBuffer,存在于 JVM 堆中;而 FileChannle.map() 将文件映射到直接内存,返回的是一个 MappedByteBuffer,存在于堆外的直接内存中;这块内存在 MappedByteBuffer 对象本身被回收之前有效。

主存 主存 JVM进程内存 HeapByteBuffer JVM 堆内存 a) HeapByteBuffer 在内存中的位置 b) MappedByteBuffer 在内存中的位置 JVM 堆内存 JVM进程内存 MappedByteBuffer

7.1 内存映射原理

前面使用堆缓冲区 ByteBuffer 和文件通道 FileChannel 对文件的操作使用的是 read()/write() 系统调用。读取数据时数据从 I/O 设备读到内核缓存,再从内核缓存复制到用户空间缓存,这里是 JVM 的堆内存。而映射磁盘文件是使用 mmap() 系统调用,将文件的指定部分映射到程序地址空间中;数据交互发生在 I/O 设备与用户空间之间,不需要经过内核空间。

文件 内核空间 用户空间 缓存 IO设备 缓存 文件 内核空间 用户空间 缓存 IO设备 缓存 a) 普通 I/O b) 内存映射 I/O

虽然映射磁盘文件减少了一次数据复制,但对于大多数操作系统来说,将文件映射到内存这个操作本身开销较大;如果操作的文件很小,只有数十KB,映射文件所获得的好处将不及其开销。因此,只有在操作大文件的时候才将其映射到直接内存。

7.2 映射缓冲区用法

文件通道 FileChanle 通过成员方法 map(MapMode mode, long position, long size) 将文件映射到应用内存。

 FileChannel fileChannel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ, StandardOpenOption.WRITE); // 以读写的方式打开文件通道
MappedByteBuffer buf = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size()); // 将整个文件映射到内存  

mode 表示打开模式,为枚举值,其值可以为 READ_ONLY, READ_WRITE, PRIVATE。

+ 模式为 READ_ONLY 时,不能对 buf 进行写操作;

+ 模式为 READ_WRITE 时,通道 fileChannel 必须具有读写文件的权限;对 buf 进行的写操作将对文件生效,但不保证立即同步到 I/O 设备;

+ 模式为 PRIVATE 时,通道 fileChannle 必须对文件有读写权限;但是对文件的修改操作不会传播到 I/O 设备,而是会在内存复制一份数据。此时对文件的修改对其它线程和进程不可见。

position 指定文件的开始映射到内存的位置;

size 指定映射的大小,值为非负 int 型整数。

调用 map() 方法之后,返回的 MappedByteBuffer 就于 fileChannel 脱离了关系,关闭 fileChannel 对 buf 没有影响。同时,如果要确保对 buf 修改的数据能够同步到文件 I/O 设备中,需要调用 MappedByteBuffer 中的无参数的 force() 方法,而调用 FileChannel 中的 force(metaData) 方法无效。

此时可以通过操作缓冲区来操作文件了。不过映射的内容存在于 JVM 程序的堆外内存中,这部分内存是虚拟内存,意味着 buf 中的内容不一定都在物理内存中,要让这些内容加载到物理内存,可以调用 MappedByteBuffer 中的 load() 方法。另外,还可以调用 isLoaded() 来判断 buf 中的内容是否在物理内存中。

 FileChannel fileChannel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE, StandardOpenOption.READ);
MappedByteBuffer buf = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());
fileChannel.close();    // 关于文件通道对 buf 没有影响
System.out.println(buf.capacity()); // 输出 fileChannel.size()
System.out.println(buf.limit());    // 输出 fileChannel.size()
System.out.println(buf.position()); // 输出 0
buf.put((byte)'R'); // 写入内容
buf.compact();      // 截掉 positoin 之前的内容
buf.force();        // 将数据刷出到 I/O 设备  

8. 小结

1)文件通道 FileChannel 能够将数据从 I/O 设备中读入(read)到字节缓冲区中,或者将字节缓冲区中的数据写入(write)到 I/O 设备中。

2)文件通道能够转换到 (transferTo) 一个可写通道中,也可以从一个可读通道转换而来(transferFrom)。这种方式使用于通道之间的数据传输,比使用缓冲区更加高效。

3)文件通道能够将文件的部分内容映射(map)到 JVM 堆外内存中,这种方式适合处理大文件,不适合处理小文件,因为映射过程本身开销很大。

4)在对文件进行重要的操作之后,应该将数据刷出刷出(force)到磁盘,避免操作系统崩溃导致的数据丢失。