经过前两篇文章的学习,相信对Channel有了一定的整体性认识。接下来通过学习本篇文章,更进一步认识Channel,学习FileChannel的细节
- 用途
- 特点
- api
- 原理
一.用途
传统IO中的FileInputStream和FileOutputStream,相信读者都不陌生。在NIO中提供新的方式对文件进行I/O操作。
A channel for reading, writing, mapping, and manipulating a file.
上面的描述来自Java SE8中FileChannel的javadocs(Class FileChannel)对该通道的描述:用于读、写、映射、维护一个文件的通道。
二.特点
FileInputStream/FileOutputStream vs FileChannel
FileInputStream/FileOutputStream | FileChannel |
---|---|
单向 | 双向 |
面向字节的读写 | 面向Buffer读写 |
不支持 | 支持内存文件映射 |
不支持 | 支持转入或转出其他通道 |
不支持 | 支持文件锁 |
不支持操作文件元信息 | 不支持操作文件元信息 |
三.api
接口 | 描述 |
---|---|
open | 创建FileChannel |
read/write | 基于FileChannel读写 |
force | 强制将FileChannel中的数据刷入文件中 |
map | 内存文件映射 |
transferTo和transferFrom | 转入与转出通道 |
lock/tryLock | 获取文件锁 |
FileChannel大致提供了以上的重要操作接口。下面详细介绍每个接口的作用和用法:
1)open:用于创建一个FileChannel对象。具有两种重载形式
public static FileChannel open(Path path, OpenOption... options) throws IOException
public static FileChannel open(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs) throws IOException
OpenOption
主要用于控制文件的操作模式:
- READ:只读方式
- WRITE:只写方式
- APPEND:只追加方式
- CREATE:创建新文件
- CREATE_NEW:创建新文件,如果存在则失败
- TRUNCATE_EXISTING:如果以读方式访问文件,它的长度将被清除至0
示例:
Path path = FileSystems.getDefault().getPath("D:/test.txt");
FileChannel channel2 = FileChannel.open(path, StandardOpenOption.READ);
除了以上的方式能够创建FileChannel,还有以下两种方式:
a. 通过FileInputStream/FileOutputStream提供的getChannel
FileInputStream inputStream = new FileInputStream("D:/test.txt");
FileChannel channel = inputStream.getChannel();
FileOutputStream outputStream = new FileOutputStream("D:/test.txt");
FileChannel channel1 = outputStream.getChannel();
b. 通过RandomAccessFile的getChannel
RandomAccessFile randomAccessFile = new RandomAccessFile("./test.txt", "rw");
FileChannel channel2 = randomAccessFile.getChannel();
通过以上的示例,open相比流和随机文件的方式创建FileChannel更具有操作模式上的选择性。
2)read/write:在通道上用于读写文件
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
int count = channel2.read(byteBuffer);
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
byte[] bs = "s".getBytes();
byteBuffer.put(bs);
byteBuffer.flip();
channel2.write(byteBuffer);
read时返回int型值,代表读入buffer内的字节数,如果返回-1,表示已经读到文件尾,无内容可读。
3)force:强制将在这个Channel更新的内容写入文件中(因为每次更新时不可能一直都更新到文件中,这样频繁的进行外存I/O,读写效率很低,一般都是用缓冲区)。
使用force可以避免在系统宕机情况下数据丢失,但是如果文件不是本地的设备上文件,那force将不能得到保证。
channel2.force(false);
该方法具有布尔类型参数用来限制I/O次数。false表示只对文件内容更新写入存储的文件中,true表示关于更新的内容和文件的元信息都写入,这个通常至少需要一次甚至更多的I/O。
这个方法不保证使用内存映射文件(MappedByteBuffer
)的情况下对MappedByteBuffer的修改也写入内存,如果需要,则调用MappedByteBuffer.force
4)map:将外存文件某段映射至内存,返回MappedByteBuffer,具有以下几种映射模式:
- READ_ONLY:以只读的方式映射,如果发生修改,则抛出
ReadOnlyBufferException
- READ_WRITE:读写方式
- PRIVATE:对这个MappedByteBuffer的修改不写入文件,且其他程序是不可见的。
一旦经过map映射后,将于用于映射的FileChannel没有联系,即使Channel关闭,也对MappedByteBuffer没有影响。
关于映射的很多细节都是不确定的,因为都是依赖操作系统。两个程序对文件的同一个区域进行映射,其一程序进行了修改,这个修改不一定就立即传播到另一个程序的Buffer中,这依赖操作系统的实行。
map通产应用在超大文件的处理中时使用,整体的性能才得以提升。对于数十Kb的文件处理,使用map的性能不一定比传统基于流式的读写好,因为直接映射进入内存的代价开销较大。需要在这两者之间进行权衡选择。
Path path = FileSystems.getDefault().getPath("./test.txt");
FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE);
MappedByteBuffer mappedByteBuffer = channel.map(MapMode.READ_ONLY, 0, 100000);
byte[] bs = new byte[100];
mappedByteBuffer.get(bs);
内存文件映射大幅度提高了文件的读写效率,特别是针对超大文件的处理,这里不做深究,后续中会介绍其原理。
5)transferTo和transferFrom:将该文件通道的内容转出到另一个通道或者将另一个通道的内容转入
srcChannel.transferTo(0, Integer.MAX_VALUE, dstChannel);
srcChannel.transferFrom(fromChannel, 0, Integer.MAX_VALUE);
该两个方法可以实现通道数据的快速转移,不仅在简化代码量(少了中间内存的数据拷贝转移)而且还大幅到提高了性能,关于性能的提高,后续会讲述,这里不再深究。
6)lock/tryLock
在了解lock和tryLock之前,先来了解下文件锁的概念。
文件锁是作用在文件区域上的锁,即文件区域是同步资源,多个程序访问时,需要先获取该区域的锁,才能进入访问文件,访问结束释放锁,实现程序串行化访问文件。这里可以类比Java中的对象锁或者lock锁理解。
FileChannel channel = rf.getChannel();
FileLock lock = channel.lock(0L, 23L, false);
如上,如果两个java程序都获取同一文件的通道并对文件加独占式锁(两个程序对文件加锁的区域存在重叠),则同一时间只能有一个程序能获取,另一个程序则处于等待获取锁的状态。
这里我基于操作系统windows7和MacOS Sierra验证。
下表展示独占锁和共享锁的相互作用于同一文件的统一区域的叠加性关系:
共享锁 | 独占锁 | |
---|---|---|
共享锁 | Yes | false |
独占锁 | false | false |
下面列出FileLock的几个重要关注事项:
-
文件锁FileLock是被整个Java Vitrual Machine持有的,即FileLock是进程级别的,所以不可用于作为多线程安全控制的同步工具。
-
虽然上面提到FileLock不可用于多线程访问安全控制,但是多线程访问是安全的。如果线程1获取了文件锁FileLock(共享或者独占),线程2再来请求获取该文件的文件锁,则会抛出
OverlappingFileLockException
-
一个程序获取到FileLock后,是否会阻止另一个程序访问相同文件具重叠内容的部分取决于操作系统的实现,具有不确定性。FileLock的实现依赖于底层操作系统实现的本地文件锁设施。
-
以上所说的文件锁的作用域是文件的区域,可以时整个文件内容或者只是文件内容的一部分。独占和共享也是针对文件区域而言。程序(或者线程)获取文件0至23范围的锁,另一个程序(或者线程)仍然能获取文件23至以后的范围。只要作用的区域无重叠,都相互无影响。
注:笔者使用文件的绝对路径创建通道,然后获取文件锁时,遵循以上表中列的独占或者共享特性,但是使用相对路径时,以上的独占和共享不成立。关于这点,笔者这里没在做深入学习,如果读者有兴趣可以相互交流。
本节主要介绍了FileChannel的重要api,以及使用时的注意事项。接下来再来了解下FileChannel的通道转移和内存文件映射的工作原理。
四.原理
map原理
在了解原理之前,先看下传统文件I/O和内存文件映射的过程图的区别:
![](https://images2018.cnblogs.com/blog/1286175/201806/1286175-20180612002142449-1530022086.png)
从以上的图可以看出,内存文件映射是将文件直接映射至用户空间内存,未经过内核空间缓冲区的拷贝,相对于传统的I/O减少一次内存拷贝。
再进一步了解内存文件映射原理之前,需要认识下操作系统的几个基本概念:
- 物理内存:即计算机中实际的硬件内存区域
- 虚拟内存:计算机中的内存管理技术,提供了对物理内存的理想抽象。即逻辑内存连续,物理内存不一定连续。
- 页:虚拟内存中定长的连续块,存储数据和内存管理的最小单元
- MMU:内存管理单元(memory management unit)
- 内核空间和用户空间:现代计算机操作系统通常将虚拟内存分为内核空间和用户空间两部分。主要是为了保护内存和硬件。内核空间主要是操作系统的内存运行区域,用户空间通常是用户程序运行的内存区域。内核空间一般在虚拟内存高位部分
![](https://images2018.cnblogs.com/blog/1286175/201806/1286175-20180612002210960-739958844.png)
上图摘自维基百科中对虚拟内存和物理内存之间的关系描述。从中可以看出,虚拟内存是计算机中用于表示实际物理内存的一种方式。
传统的文件IO流程(这里以读为例说明):
- 应用进程对本地接口发出调用请求(system call)
- 用户上下文切换至内核上下文,操作系统调用驱动接口读外存文件
- 将磁盘上文件数据读入内核缓冲区(这里的读的方式,暂不考虑)
- 然后再将内核缓冲区的内容拷贝至用户空间的应用缓冲区中
- 内核上下文切换至用户上下文
- 用户进程处理缓冲区中的数据
整个流程中不仅涉及到用户空间和内核空间的频繁切换,而且还需要讲内核缓冲区中的数据再拷贝一次至用户空间的缓冲区,操作过程复杂且性能降低。
接下来再看下mmap内存文件映射的流程(仍然以读为例说明):
- 应用进程进行系统调用
- 用户上下文切换至内核上下文
- 建立用户空间与磁盘文件的映射关系(此时只是关系的建立,并没有将文件数据读入用户缓冲区)
- 当用户进程第一次读数据时,这是会发生缺页异常
- 操作系统根据缺页异常,将相应的部分数据读入用户缓冲区
从以上的比较中可以看出,mmap内存文件映射,减少了一次内存拷贝:内核缓冲区至用户缓冲区。
内存文件映射对于超大型文件处理的优势相对于面向字节流的I/O操作很大。
transferFrom和transferTo原理
同样在更进一步学习通道转移之前,先来看下图直观的感受下数据流:
![](https://images2018.cnblogs.com/blog/1286175/201806/1286175-20180612002237393-1116099944.png)
传统的方式将读取文件内容然后写入其他的文件或者网络协议栈中流程:
- 应用进程对本地接口发出读调用请求(system call)
- 用户上下文切换至内核上下文,操作系统调用驱动接口读外存文件
- 将磁盘上文件数据读入内核缓冲区(这里的读的方式,暂不考虑)
- 然后再将内核缓冲区的内容拷贝至用户空间的应用缓冲区中
- 内核上下文切换至用户上下文
- 应用进程对本地接口发出写调用请求(system call)
- 用户上下文切换至内核上下文
- 将用户缓冲区数据拷贝至内核缓冲区或者协议栈
- 将内核缓冲区的数据写到网络接口或者文件中
接下来再看看transferTo和transferFrom过程:
- 应用进程进行系统调用
- 用户上下文切换至内核上下文
- 将文件数据写入内核缓冲区
- 内核将数据拷贝至内核缓冲区或者协议栈
- 将内核缓冲区的数据写到网络接口或者文件中
transferTo和transferFrom的主要过程非常简单。主要采用的是zero-copy技术。
参考
通过零拷贝实现有效数据传输
Zero Copy I: User-Mode Perspective
零复制(zero copy)技术
NIO包-Buffer类:内存映射文件DirectByteBuffer与MappedByteBuffer(二)
由于个人技术有限,如果有错误的地方,不吝指出,欢迎交流。