NIO 五 文件通道

一 通道和流的区别

  前一篇介绍了NIO的通道接口,十余个接口看起来极为复杂,但实际上NIO提供的实现类,且应用场景覆盖较广泛的并不多。

  从目前了解到的信息来看,通道仅仅是用于将缓冲区数据写入,或者从其中读取数据到缓冲区,咋一看和流类似,但从接口设定上来看还是有一些区别的:

  1. Java提供的流多为单向操作,或读或写,而通道的实现多为双向,可读可写;
  2. 通道实现类提供的API基本上都是将缓冲区数据写入通道,或从通道中读取数据到缓冲区,二者联系紧密;
  3. NIO提供的通道可支持异步读写,这是最重要的特性。

二 已实现的主要通道

  NIO提供了非常多的通道实现,但是常用的无非以下四个:

  1. FileChannel
  2. DatagramChannel
  3. SocketChannel
  4. 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实例,对文件的操作权限是不一样的:

  1. FileInputStream提供的FileChannel可读文件
  2. FileOutputStream提供的FileChannel可写文件
  3. 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:

writeAPI

4.2.1 write(ByteBuffer src)

  write(ByteBuffer src)方法是实现的是WritableByteChannel接口,实现的是将参数src的剩余可操作字节(字节长度为ByteBuffer的remaining方法返回值)写入FileChannel,再细致点说:

  1. 这是个阻塞方法(前文说了FileChannel的方法都是阻塞的,即当前线程写入动作未结束时,其他线程的写入动作均被阻塞,其他的IO操作是否允许并发的处理,取决于FileChannel的实际类型后文不再赘述);
  2. 将src缓冲区的数据写入通道
  3. 从通道的当前位置position开始写入(此position不是缓冲区的position)
  4. 写入长度为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多了两个参数:

  1. offset指参数srcs的偏移量,即从第几个字节缓冲区才开始需要向通道写入数据
  2. 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:

readAPI

  这四个API对应前文中的4个写入API,下面仅介绍下对应的实现接口。

4.3.1 read(ByteBuffer dst)

  这个API实现的是ReadableByteChannel接口,以阻塞方式从通道中将数据读到缓冲区dst中,需要注意以下几点:

  1. 从通道的position开始读;
  2. 读到的数据会写入dst中,从dst的position位置开始写入;
  3. 写入的数据长度为dsc的remaining方法返回值

  另外需要注意的是此API的返回值,返回值类型为int,可能出现的结果有以下三种场景:

  1. 正整数,表示读取到的字节数
  2. 0,没有读到任何数据,不排除dst.remaining返回的值为0
  3. -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有些特殊,它的值可以大于通道关联的文件大小,但方法本身却不会更改文件的大小,而是在后续的读写过程中发生作用:

  1. 后续读数据时直接返回已读到文件末尾
  2. 后续写数据时会对文件进行扩容,扩容大小可满足写入数据,在原文件数据和新写入数据之间的部分是未指定的字节数据。

  这部分需要结合章节4.2、4.3中的数据读写来理解。

4.5 获取文件大小

  没啥好介绍的,size()方法。

4.6 切分文件数据

  这个API和前几篇介绍缓冲区的切分是一样的道理,truncate(long size)方法会将通道关联的文件按参数大小进行切分,这里参数值可能出现以下几种情况:

  1. 如果参数值大于文件大小,没有任何影响
  2. 参数值小于文件大小,切分文件,并且丢掉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,因涉及两个通道,那么实际上数据传输能够成功执行就出现了多种可能:

  1. 如果当前通道position后的数据长度不足count值,又或者目标通道可接收的数据长度不足count值,则传输的实际字节数小于count值;
  2. 如果参数position值大于当前通道关联的文件大小,则不传输任何数据;
  3. 如果数据可写入,那么目标通道的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提供了三种枚举定义:

  1. MapMode.READ_ONLY,只读,如果对此区域内数据进行修改会抛出异常;
  2. MapMode.READ_WRITE,读写,操作MappedByteBuffer对象会直接同步到文件,但是需要注意的是其他关联了此文件的通道、缓冲区等未必能及时看到;
  3. MapMode.PRIVATE,私有,一个完全独立的部分,操作MappedByteBuffer对象不会同步到文件,其他关联了此文件的通道、缓冲区也不可见。

  和章节4.8类型,内存映射也涉及操作系统的支持与否,并且用法较多,这里不再详细介绍每一个点,后面如果有时间我会单独写一个章节来介绍相关内容。

五 结语

  如果想关注更多硬技能的分享,可以参考积少成多系列传送门,未来每一篇关于硬技能的分享都会在传送门中更新链接。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章