文章目录
一 通道和流的区别
前一篇介绍了NIO的通道接口,十余个接口看起来极为复杂,但实际上NIO提供的实现类,且应用场景覆盖较广泛的并不多。
从目前了解到的信息来看,通道仅仅是用于将缓冲区数据写入,或者从其中读取数据到缓冲区,咋一看和流类似,但从接口设定上来看还是有一些区别的:
- Java提供的流多为单向操作,或读或写,而通道的实现多为双向,可读可写;
- 通道实现类提供的API基本上都是将缓冲区数据写入通道,或从通道中读取数据到缓冲区,二者联系紧密;
- NIO提供的通道可支持异步读写,这是最重要的特性。
二 已实现的主要通道
NIO提供了非常多的通道实现,但是常用的无非以下四个:
- FileChannel
- DatagramChannel
- SocketChannel
- SocketServerChannel
其中FileChannel支持从文件中读写数据;DatagramChannel则支持UDP协议的网络通信数据读写;SocketChannel支持TCP协议的网络通信数据读写,SocketServerChannel和SocketChannel有关联,是服务端监听客户端TCP请求的用的,一旦建立请求会创建一个SocketChannel来支持此连接通道中的数据读写。
这里先铺垫一下,NIO的主要应用场景是网络通信,文件读写方面未必比传统流式方便,但是为了介绍通道的使用的方法,本文以FileChannel为例,介绍与其相关的主要API。SocketChannel则在后续的网络通信方面介绍中细说。
三 FileChannel
先看FileChannel的定义:
/**
*
* @see java.io.FileInputStream#getChannel()
* @see java.io.FileOutputStream#getChannel()
* @see java.io.RandomAccessFile#getChannel()
*
* @author Mark Reinhold
* @author Mike McCloskey
* @author JSR-51 Expert Group
* @since 1.4
*/
public abstract class FileChannel extends AbstractInterruptibleChannel implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel {
...
}
首先FileChannel是一个抽象类,继承自AbstractInterruptibleChannel,从注释中可以很明显的看出来,io包的FileInputStream、FileOutputStream以及功能更为全面的RandomAccessFile类都提供了返回FileChannel实例的方法。
其次FileChannel要求实现类必须实现SeekableByteChannel、GatheringByteChannel以及ScatteringByteChannel接口,算上基类AbstractInterruptibleChannel的Channel和InterruptibleChannel接口,可以断定FileChannel必然支持中断机制,支持数据读写,以及对position的维护操作,从这几个方面着手,再看FileChannel提供的抽象方法定义,基本上就把FileChannel吃透了。
上面的学习方法适用所有的Java技术体系。
四 API介绍
FileChannel在其内部维护一个与文件相关联的position参数,基于此参数可支持对文件数据的读写,且FileChannel是阻塞的。
除常见的文件读写、关闭等操作,FileChannel还支持将文件映射到内存中,常见于大文件的读写操作,内存映射后的读写更为高效。如果在写文件时为了防止意外情况导致的文件数据丢失,FileChannel还支持实时更新——强制更新存储设备,甚至可以对文件中的部分数据进行加锁操作,以防止其他应用程序对文件内容进行访问。
因通道的阻塞特性,FileChannel是线程安全的,比如说任意执行线程可执行通道的关闭操作,那么其他线程也发起关闭操作,则会被阻塞。
需要注意的是,多个线程同时操作文件数据时,可操作的数据未必一致,取决于操作系统对数据写入文件的执行策略。
由于FileChannel提供的文件操作方法较多,后文会根据FileChannel接口的实现来介绍不同的文件操作API,以此来加深对接口的理解,冗余的说明则尽量掠过以节省篇幅,所以读者需要格外注意接口的特性。
4.1 获取FileChannel对象
FileChannel类没有提供任何打开文件的方法(我本机使用的JDK1.8,未来的版本中可能会提供),正如前文中介绍的,如果想获得FileChannel对象,可通过FileInputStream、FileOutputStream、RandomAccessFile对象的getChannel()方法实现。
这里需要注意的是,通过FileChannel对象对文件进行数据读写后,会影响提供getChannel方法的对象——称之为源文件操作对象,此时通过源文件操作对象访问文件数据和FileChannel对象操作后的数据一致,比如说通过FileChannel来改变文件大小,那么通过源文件操作对象访问到的文件大小是改变后的。
另一个需要注意的点是不同的源文件操作对象提供的FileChannel实例,对文件的操作权限是不一样的:
- FileInputStream提供的FileChannel可读文件
- FileOutputStream提供的FileChannel可写文件
- RandomAccessFile提供的FileChannel,创建RandomAccessFile对象的模式不同则操作权限不同,“r”模式可读,“w”模式可写,“rw”模式可读可写
public class FileChannelTest {
public static void main(String[] args) throws Exception {
FileInputStream fis = new FileInputStream(new File("TestFile"));
FileChannel fileChannel = fis.getChannel();
System.out.println("文件是否已经打开:" + fileChannel.isOpen() + " 文件大小:" + fileChannel.size());
fileChannel.close();
RandomAccessFile raf = new RandomAccessFile(new File("TestFile"), "rw");
fileChannel = raf.getChannel();
System.out.println("文件是否已经打开:" + fileChannel.isOpen() + " 文件大小:" + fileChannel.size());
fileChannel.close();
FileOutputStream fos = new FileOutputStream(new File("TestFile"));
fileChannel = fos.getChannel();
System.out.println("文件是否已经打开:" + fileChannel.isOpen() + " 文件大小:" + fileChannel.size());
fileChannel.close();
}
}
输出结果:
文件是否已经打开:true 文件大小:45
文件是否已经打开:true 文件大小:45
文件是否已经打开:true 文件大小:0
4.2 数据写入
通过IDE工具查看和write相关的API,可以看到下述四个API:
4.2.1 write(ByteBuffer src)
write(ByteBuffer src)方法是实现的是WritableByteChannel接口,实现的是将参数src的剩余可操作字节(字节长度为ByteBuffer的remaining方法返回值)写入FileChannel,再细致点说:
- 这是个阻塞方法(前文说了FileChannel的方法都是阻塞的,即当前线程写入动作未结束时,其他线程的写入动作均被阻塞,其他的IO操作是否允许并发的处理,取决于FileChannel的实际类型后文不再赘述);
- 将src缓冲区的数据写入通道
- 从通道的当前位置position开始写入(此position不是缓冲区的position)
- 写入长度为src的剩余可操作字节数(即remaining方法返回值)
此方法的应用请参考下面的示例:
public class FileChannelTest {
public static void main(String[] args) throws Exception {
FileOutputStream fos = new FileOutputStream(new File("TestFile"));
FileChannel fileChannel = fos.getChannel();
// 设置通道的position为3
fileChannel.position(3);
ByteBuffer byteBuffer = ByteBuffer.wrap("abcdefg".getBytes(Charset.defaultCharset()));
System.out.println("初始的缓冲区大小为:" + byteBuffer.limit());
// 设置缓冲区的position为2
byteBuffer.position(2);
System.out.println("缓冲区remaining长度为:" + byteBuffer.remaining() + " 通道的position为:" + fileChannel.position() + " 通道的长度为:" + fileChannel.size());
fileChannel.write(byteBuffer);
System.out.println("写入数据后通道的position为:" + fileChannel.position() + " 通道的长度为:" + fileChannel.size());
}
}
输出结果:
初始的缓冲区大小为:7
缓冲区remaining长度为:5 通道的position为:3 通道的长度为:0
写入数据后通道的position为:8 通道的长度为:8
从示例中很明显的看出来写入通道的数据长度为缓冲去区的remaining长度5,加上预先移动的3字节,写入后通道的数据长度为8。
接下来在验证下阻塞特性,这里只需要启动多个线程,同时向通道中写入数据,然后我们看看数据是否会出现交叉乱序:
public class FileChannelTest {
public static void main(String[] args) throws Exception {
FileOutputStream fos = new FileOutputStream(new File("TestFile"));
FileChannel fileChannel = fos.getChannel();
for (int i = 0; i < 5; i++) {
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
try {
ByteBuffer byteBuffer = ByteBuffer.wrap("abcdefg".getBytes(Charset.defaultCharset()));
fileChannel.write(byteBuffer);
} catch (Exception e) {
e.printStackTrace();
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
try {
ByteBuffer byteBuffer = ByteBuffer.wrap("1234567".getBytes(Charset.defaultCharset()));
fileChannel.write(byteBuffer);
} catch (Exception e) {
e.printStackTrace();
}
}
});
thread1.start();
thread2.start();
}
}
}
文件内容:
12345671234567abcdefg1234567abcdefg1234567abcdefg1234567abcdefgabcdefg
一目了然,没有出现数字和字母交叉的排序,出现数字或字母重复追加的情况是因为线程的调度顺序是非线性的,但数字和字母没有交叉则证明了API的阻塞特性是确实存在的,在数字或字母没有完全写入前,其他线程无法写入任何数据。
4.2.2 write(ByteBuffer[] srcs)
此API实现的是GatheringByteChannel接口,GatheringByteChannel派生自WritableByteChannel接口,因此包含章节4.2.1中介绍的特性,此外从参数中也可以看出它支持将多个缓冲区的remaining字节写入通道。
需要注意的是缓冲区数组的元素顺序决定了写入数据的顺序,因阻塞特性多个缓冲区数组同时写入时,不会出现数据交叉错乱的场景。
4.2.3 write(ByteBuffer[] srcs, int offset, int length)
此API实现的接口依然是GatheringByteChannel,所以和write(ByteBuffer[] srcs)几乎一致,唯一不同的是,此API多了两个参数:
- offset指参数srcs的偏移量,即从第几个字节缓冲区才开始需要向通道写入数据
- length指从offset位置开始有length长度个字节缓冲区需要向通道写入数据
如果还觉得比较难理解,读者可认为write(ByteBuffer[] srcs)等价于write(ByteBuffer[] srcs, 0, srcs.length),这样是不是就清楚了。
4.2.4 write(ByteBuffer src, long position)
这个API和write(ByteBuffer src)单纯的区别在于不是从通道的position开始写入数据,而是可以指定通道位置了,其他的特性完全一致。
唯一需要说明的是如果指定的position大于了通道关联的文件大小,不会报错,而是对文件进行扩容,在参数position之前的被扩容的部分,写入的是未指定的字节数据。另外调用此方法,不会影响通道的position值,这些特性读者可自行编写测试函数验证。
补充一个不需要解释的细节,参数position不能为负。
4.3 数据读取
同样的通过IDE查看read相关的API:
这四个API对应前文中的4个写入API,下面仅介绍下对应的实现接口。
4.3.1 read(ByteBuffer dst)
这个API实现的是ReadableByteChannel接口,以阻塞方式从通道中将数据读到缓冲区dst中,需要注意以下几点:
- 从通道的position开始读;
- 读到的数据会写入dst中,从dst的position位置开始写入;
- 写入的数据长度为dsc的remaining方法返回值
另外需要注意的是此API的返回值,返回值类型为int,可能出现的结果有以下三种场景:
- 正整数,表示读取到的字节数
- 0,没有读到任何数据,不排除dst.remaining返回的值为0
- -1,读到了通道末尾
4.3.2 read(ByteBuffer[] dsts)
这个API实现的是ScatteringByteChannel接口,ScatteringByteChanne派生自ReadableByteChannel,因此具备章节4.3.1中介绍的特性,此外它还支持将通道当前位置开始的数据写入多个字节缓冲区中,每个缓冲区写入数据的多少取决于缓冲区的remaining。
4.3.3 read(ByteBuffer[] dsts, int offset, int length)
对应章节4.2.3的写操作,实现的接口是ScatteringByteChanne,这意味着和read(ByteBuffer[] dsts)方法的行为一致,差异在于两个参数,不再赘述。
4.3.4 read(ByteBuffer dst, long position)
对应章节4.2.4的写方法,用于从指定的通道的位置,将通道数据读入到缓冲区的当前位置。除了可指定文件位置外,其他特性同read(ByteBuffer dst)方法。
需要注意的是,首先参数不能为负,不解释。另外如果参数position大于文件的大小,那么则不会读取任何数据。同write(ByteBuffer dst, long position),调用此方法不会改变通道的position值。
4.4 设置通道位置
前文中的读写API和通道position是紧密相关的,那么在读写数据时时刻掌握通道position值就显得尤为重要,必要时还需要对通道位置进行设置。
FileChannel提供了position(long newPosition)方法来设置通道位置,参数newPosition有些特殊,它的值可以大于通道关联的文件大小,但方法本身却不会更改文件的大小,而是在后续的读写过程中发生作用:
- 后续读数据时直接返回已读到文件末尾
- 后续写数据时会对文件进行扩容,扩容大小可满足写入数据,在原文件数据和新写入数据之间的部分是未指定的字节数据。
这部分需要结合章节4.2、4.3中的数据读写来理解。
4.5 获取文件大小
没啥好介绍的,size()方法。
4.6 切分文件数据
这个API和前几篇介绍缓冲区的切分是一样的道理,truncate(long size)方法会将通道关联的文件按参数大小进行切分,这里参数值可能出现以下几种情况:
- 如果参数值大于文件大小,没有任何影响
- 参数值小于文件大小,切分文件,并且丢掉size后面的数据,注意!切分后可以认为是一个新文件
另一个需要注意的点是,如果切分的时候通道position值已经大于了参数size值,那么切分后position被重置为size值,比如说原文件内容“1234567”:
public class FileChannelTest {
public static void main(String[] args) throws Exception {
FileOutputStream fos = new FileOutputStream(new File("TestFile"));
FileChannel fileChannel = fos.getChannel();
fileChannel.write(ByteBuffer.wrap("1234567".getBytes(Charset.defaultCharset())));
fileChannel.position(6);
System.out.println("原文件大小:" + fileChannel.size() + " 通道position:" + fileChannel.position());
FileChannel newFileChannel = fileChannel.truncate(3);
System.out.println("切分文件大小:" + newFileChannel.size() + " 通道position:" + newFileChannel.position());
}
}
输出结果:
原文件大小:7 通道position:6
切分文件大小:3 通道position:3
4.7 通道传输
前文中介绍的数据读写都是通道和缓冲区之间进行数据的传输,本小节则介绍通道间进行数据传输的API。
4.7.1 从当前通道传输给其他可写通道
第一个API是transferTo(long position, long count, WritableByteChannel dest),我们可以认为此API等同于章节4.2介绍的数据写入API,只不过目标变成了其他可写通道(第三个参数已经明确告知,目标通道必须是WritableByteChannel接口类型)。
此API用于从当前通道的position位置开始,长count个字节的数据写入目标通道dest,因涉及两个通道,那么实际上数据传输能够成功执行就出现了多种可能:
- 如果当前通道position后的数据长度不足count值,又或者目标通道可接收的数据长度不足count值,则传输的实际字节数小于count值;
- 如果参数position值大于当前通道关联的文件大小,则不传输任何数据;
- 如果数据可写入,那么目标通道的position值会增加实际写入数据的长度;
此外还有其他的可能,这里不一一列举,读者可在实际应用中进行函数测试验证,但是需要注意的是,调用此方法不会改变当前通道的position。
4.7.2 从其他可读通道传输给当前通道
transferFrom(ReadableByteChannel src, long position, long count),此API和章节4.7.1刚好相反,各参数含义互换位置即可,这里不再赘述。
4.8 锁文件区域
这里和前文一开始的介绍遥相呼应,FileChannel支持将关联的文件的部分区域进行锁定,以防止其他其他线程对此区域数据进行操作。
锁文件区域方法为lock(long position, long size, boolean shared),需要注意的是参数position和size可以和文件的数据不一致 ,即此方法仅针对通道从position位置开始,size长度的数据区域锁定,且参数shared可以指定锁类型为独占锁或是共享锁,至于最终采用何种类型的锁,还要看实际的操作系统支不支持(有些操作系统不支持共享锁,那么此方法会自动将锁类型转为独占锁 )。
锁定文件区域是非常复杂的,有诸多场景上的差异,甚至和操作系统有关,所以这里不过多的介绍,如果有机会,后面会单独开一个章节来介绍,有兴趣的朋友可以自行搜索相关信息 。
那么既然有部分区域锁定,就必然支持全通道锁定,无参数lock方法实现了此功能,我们完全可以认为lock()等价于lock(0L, Long.MAV_VALUE, false),实际上源码中也的确是这样实现的。
因为篇幅原因,不再介绍和锁相关的内容(尝试获取通道锁)。
4.9 内存映射
这是一个比较重量级的应用,方法map(FileChannel.MapMode mode, long position, long size)支持将通道关联的文件,在参数区域内的数据映射到内存中,以此来实现更高效率的数据访问。
此方法返回一个MappedByteBuffer对象,可认为是一个文件数据缓冲区副本。
其中参数mode提供了三种枚举定义:
- MapMode.READ_ONLY,只读,如果对此区域内数据进行修改会抛出异常;
- MapMode.READ_WRITE,读写,操作MappedByteBuffer对象会直接同步到文件,但是需要注意的是其他关联了此文件的通道、缓冲区等未必能及时看到;
- MapMode.PRIVATE,私有,一个完全独立的部分,操作MappedByteBuffer对象不会同步到文件,其他关联了此文件的通道、缓冲区也不可见。
和章节4.8类型,内存映射也涉及操作系统的支持与否,并且用法较多,这里不再详细介绍每一个点,后面如果有时间我会单独写一个章节来介绍相关内容。
五 结语
如果想关注更多硬技能的分享,可以参考积少成多系列传送门,未来每一篇关于硬技能的分享都会在传送门中更新链接。