3.3 通道

通道是Java NIO对I/O操作提供的另外一种新的抽象方式。通道不是从I/O操作所处理的数据这个层次上去抽象,而是表示为一个已经建立好的到支持I/O操作的实体的连接。这个连接一旦建立,就可以在这个连接上进行各种I/O操作。在通道上所进行的I/O操作的类型取决于通道的特性,一般的操作包括数据的读取和写入等。在Java NIO中,不同的实体有不同的通道实现,比如文件通道和网络通道等。通道在进行读写操作时使用的都是3.2节介绍的新的缓冲区的实现,而不是字节数组。

Java NIO中的通道都实现了java.nio.channels.Channel接口。Channel接口本身很简单,只有关闭通道的close方法和判断通道是否被打开的isOpen方法。由于Channel接口继承了java.lang.AutoCloseable接口,通道的所有实现对象都可以用在try-with-resources语句中,方便了对通道的使用。从API设计的角度来说,Java NIO更多地采用了面向接口的设计思路,很多功能都被抽象到不同的接口中。

对于一个支持读取操作的通道来说,应该实现java.nio.channels.ReadableByte-Channel接口。这个接口只有一个read方法来读取数据。读取的时候把数据读取到一个ByteBuffer类的对象中。在读取的时候,数据的填充从ByteBuffer类的对象的当前读写位置开始,直到写入到读写限制所指定的位置为止。类似的java.nio.channels.WritableByteChannel接口用来进行数据的写入。该接口的write方法也使用ByteBuffer类的对象作为参数。写入时的数据来源也与ReadableByteChannel接口中read方法类似,取决于ByteBuffer类的对象中的可用字节。

有些通道除了支持读写操作之外,还支持移动读写操作的位置。这种通道一般实现java.nio.channels.SeekableByteChannel接口,该接口中的position方法用来获取和设置当前的读写位置,而truncate方法则将通道对应的实体截断。如果调用truncate方法时指定的新的长度值小于实体的当前长度,那么实体被截断成指定的新长度。另外的一个方法size可以获取实体的当前长度。

另外一个实用的通道接口是java.nio.channels.ScatteringByteChannel。这个接口的read方法不同于ReadableByteChannel接口中的read方法,支持使用一个ByteBuffer类的对象的数组作为参数。在进行读取操作时,从通道对应的实体中得到的数据被依次写入这些ByteBuffer类的对象中。向每个ByteBuffer类的对象中写入的字节数是该ByteBuffer类的对象中当前可用的字节数。与ScatteringByteBuffer接口对应的是java.nio.channels.GatheringByteBuffer接口,这个接口用来将多个ByteBuffer类的对象包含的数据同时写入到通道中。

3.3.1 文件通道

文件是I/O操作的一个常见实体。与文件实体对应的表示文件通道的java.nio.channels.FileChannel类也是一个功能强大的通道实现。FileChannel类除了可以进行读写操作之外,还实现了前面介绍的大部分接口,最主要的是实现了SeekableByteChannel接口。除了这些接口之外,FileChannel类还提供了一些与文件操作相关的特有功能。这些功能会在后面进行介绍。

1.基本用法

在使用文件通道之前,首先需要获取到一个FileChannel类的对象。FileChannel类的对象既可以通过直接打开文件的方式来创建,也可以从已有的流中得到。通过直接打开文件来创建文件通道的方式是Java 7中新增的。代码清单3-7给出了一个简单的打开文件通道并写入数据的示例。FileChannel类的open方法用来打开一个新的文件通道。调用时的第一个参数是要打开的文件的路径,第二个参数是打开文件时的选项。不同的选项会对通道的能力产生影响。比如,当一个文件通道以只读的方式打开时,就不能通过write方法来写入数据。

代码清单3-7 打开文件通道并写入数据的示例


public void openAndWrite()throws IOException{

File Channel channel=File Channel.open(Paths.get("my.txt"),StandardOpenOption.CREATE, StandardOpenOption.WRITE);

ByteBuffer buffer=ByteBuffer.allocate(64);

buffer.putChar('A').flip();

channel.write(buffer);

}


在打开文件通道时可以选择的选项有很多,其中最常见的是读取和写入模式的选择,分别通过java.nio.file.StandardOpenOption枚举类型中的READ和WRITE来声明。CREATE表示当目标文件不存在时,需要创建一个新文件;而CREATE_NEW同样会创建新文件,区别在于如果文件已经存在,则会产生错误;APPEND表示对文件的写入操作总是发生在文件的末尾处,即在文件的末尾添加新内容;当声明了TRUNCATE_EXISTING选项时,如果文件已经存在,那么它的内容将被清空;DELETE_ON_CLOSE用在需要创建临时文件的时候。声明了这个选项之后,当文件通道关闭时,Java虚拟机会尽力尝试去删除这个文件。

另外一种创建文件通道的方式是从已有的FileInputStream类、FileOutputStream类和RandomAccessFile类的对象中得到。这3个类都有一个getChannel方法来获取对应的FileChannel类的对象,所得到的FileChannel类的对象的能力取决于其来源流的特征。对InputStream类的对象来说,它所得到的FileChannel类的对象是只读的,而FileOutputStream类的对象所得到的通道是可写的,RandomAccessFile类的对象所得到的通道的能力则取决于文件打开时的选项。

对于FileChannel类所实现的来自SeekableByteChannel、ScatteringByteChannel和GatheringByteChannel接口的方法,这里不再赘述。下面要介绍的是FileChannel类中独有的方法。首先介绍在文件通道的任意位置进行读写的能力。调用ReadableByteChannel接口中的read方法和WritableByteChannel接口中的write方法都只能进行相对读写操作。而对于FileChannel类来说,得益于文件本身的特性,可以在任意绝对位置进行读写操作,只需额外传入一个参数来指定读写的位置即可。在代码清单3-8中,对于一个新创建的文件,同样可以指定任意的写入位置。文件的大小会根据写入的位置自动变化。

代码清单3-8 对文件通道的绝对位置进行读写操作的示例


public void readWriteAbsolute()throws IOException{

FileChannel channel=FileChannel.open(Paths.get("absolute.txt"),StandardOpenOption.READ, StandardOpenOption.CREATE, StandardOpenOption.WRITE);

ByteBuffer writeBuffer=ByteBuffer.allocate(4).putChar('A').putChar('B');

writeBuffer.flip();

channel.write(writeBuffer,1024);

ByteBuffer readBuffer=ByteBuffer.allocate(2);

channel.read(readBuffer,1026);

readBuffer.flip();

char result=readBuffer.getChar();//值为'B'

}


2.文件数据传输

在使用文件进行I/O操作时的一些典型场景包括把来自其他实体的数据写入文件中,以及把文件中的内容读取到其他实体中,按照通道的概念来说,就是文件通道和其他通道之间的数据传输。对于这种常见的需求,FileChannel类提供了transferFrom和transferTo方法用来快速地传输数据,其中transferFrom方法把来自一个实现了ReadableByteChannel接口的通道中的数据写入文件通道中,而transferTo方法则把当前文件通道的数据传输到一个实现了WritableByteChannel接口的通道中。在进行这两种方式的数据传输时都可以指定当前文件通道中的传输的起始位置和数据长度。

使用FileChannel类中的这两个数据传输方法比传统的使用缓冲区进行循环读取的做法要简单,性能也更好。这主要是因为这两个方法在实现中尽可能地使用了底层操作系统的支持。比如,当需要通过HTTP协议来获取一个网页的内容并保存在文件中时,可以使用代码清单3-9中的代码实现。

代码清单3-9 使用文件通道保存网页内容的示例


public void loadWebPage(String url)throws IOException{

try(FileChannel destChannel=FileChannel.open(Paths.get("content.txt"),StandardOpenOption.WRITE, StandardOpenOption.CREATE)){

InputStream input=new URL(url).openStream();

ReadableByteChannel srcChannel=Channels.newChannel(input);

destChannel.transferFrom(srcChannel,0,Integer.MAX_VALUE);

}

}


在代码清单3-9中,打开一个HTTP连接并获取到其对应的数据输入流之后,可以将其转换成一个通道,最后通过transferFrom方法来把通道中的内容写入文件中。这里使用了try-with-resources语句来简化对通道的使用。

文件通道中的这两个传输方法的另一个重要的好处是可以简洁和高效地实现文件的复制。在前面的章节中介绍过使用字节数组作为缓冲区的文件复制操作的基本实现方式。如果采用传统的循环读取的方式,使用新的ByteBuffer类会比字节数组简单一些,如代码清单3-10所示。使用ByteBuffer类的时候并不需要记录每次实际读取的字节数,但是要注意flip和compact方法的使用。

代码清单3-10 使用字节缓冲区进行文件复制操作的示例


public void copyUseByteBuffer()throws IOException{

ByteBuffer buffer=ByteBuffer.allocateDirect(32*1024);

try(FileChannel src=FileChannel.open(Paths.get(srcFilename),StandardOpenOption.READ);

FileChannel dest=FileChannel.open(Paths.get(destFilename),StandardOpenOption.WRITE, StandardOpenOption.CREATE)){

while(src.read(buffer)>0||buffer.position()!=0){

buffer.flip();

dest.write(buffer);

buffer.compact();

}

}

}


如果使用FileChannel类中的传输方法来实现,代码就更加简单了,如代码清单3-11所示,进行复制的逻辑只需要一行代码即可。

代码清单3-11 使用文件通道进行文件复制的示例


public void copyUseChannelTransfer()throws IOException{

try(FileChannel src=FileChannel.open(Paths.get(srcFilename),

StandardOpenOption.READ);

FileChannel dest=FileChannel.open(Paths.get(destFilename),StandardOpenOption.WRITE, StandardOpenOption.CREATE)){

src.transferTo(0,src.size(),dest);

}

}


3.内存映射文件

在对大文件进行操作时,性能问题一直比较难处理。通过操作系统的内存映射文件支持,可以比较快速地对大文件进行操作。内存映射文件的原理在于把系统的内存地址映射到要操作的文件上。读取这些内存地址就相当于读取文件的内容,而改变这些内存地址的值就相当于修改文件中的内容。被映射到内存地址上的文件在使用上类似于操作系统中使用的虚拟内存文件。通过内存映射的方式对文件进行操作时,不再需要通过I/O操作来完成,而是直接通过内存地址访问操作来完成,这就大大提高了操作文件的性能,因为I/O操作比访问内存地址要慢得多。

FileChannel类的map方法可以把一个文件的全部或部分内容映射到内存中,所得到的是一个ByteBuffer类的子类MappedByteBuffer的对象,程序只需要对这个MappedByteBuffer类的对象进行操作即可。对这个MappedByteBuffer类的对象所做的修改会自动同步到文件内容中。代码清单3-12给出了使用文件通道的内存映射功能的一个示例。在进行内存映射时需要指定映射的模式,一共有3种可用的模式,由FileChannel.MapMode这个枚举类型来表示:READ_ONLY表示只能对映射之后的MappedByteBuffer类的对象进行读取操作;READ_WRITE表示是可读可写的;而PRIVATE的含义是通过MappedByteBuffer类的对象所做的修改不会被同步到文件中,而是被同步到一个私有的复本中。这些修改对其他同样映射了该文件的程序是不可见的。如果希望对MappedByteBuffer类的对象所做的修改被立即同步到文件中,可以使用force方法。

代码清单3-12 内存映射文件的使用示例


public void mapFile()throws IOException{

try(FileChannel channel=FileChannel.open(Paths.get("src.data"),StandardOpenOption.READ, StandardOpenOption.WRITE)){

MappedByteBuffer buffer=channel.map(FileChannel.MapMode.READ_WRITE,0,channel.size());

byte b=buffer.get(1024*1024);

buffer.put(510241024,b);

buffer.force();

}

}


如果希望更加高效地处理映射到内存中的文件,把文件的内容加载到物理内存中是一个好办法。通过MappedByteBuffer类的load方法可以把该缓冲区所对应的文件内容加载到物理内存中,以提高文件操作时的性能。由于物理内存的容量受限,不太可能直接把一个大文件的全部内容一次性地加载到物理内存中。可以每次只映射文件的部分内容,把这部分内容完全加载到物理内存中进行处理。完成处理之后,再映射其他部分的内容。

由于I/O操作一般比较耗时,出于性能考虑,很多操作在操作系统内部都是使用缓存的。在程序中通过文件通道API所做的修改不一定会立即同步到文件系统中。如果在没有同步之前发生了程序错误,可能导致所做的修改丢失。因此,在执行完某些重要文件内容的更新操作之后,应该调用FileChannel类的force方法来强制要求把这些更新同步到底层文件中。可以强制同步的更新有两类,一类是文件的数据本身的更新,另一类是文件的元数据的更新。在使用force方法时,可以通过参数来声明是否在同步数据的更新时也同步元数据的更新。

4.锁定文件

当需要在多个程序之间进行数据交换时,文件通常是一种很好的选择。一个程序把产生的输出保存在指定的文件中,另外一个程序进行读取即可。双方只需要在文件的格式上达成一致就可以了,内部逻辑的实现都是独立的。但是在这种情况下,对这个文件的访问操作容易产生冲突,而且对两个独立的应用程序来说,也没有什么比较好的方式来实现操作的同步。对于这种情况,最好的办法是对文件进行加锁。在一个程序完成操作之前,阻止另外一个程序对该文件的访问。通过FileChannel类的lock和tryLock方法可以对当前文件通道所对应的文件进行加锁。加锁时既可以选择锁定文件的全部内容,也可以锁定指定的范围区间中的部分内容。lock和tryLock两个方法的区别在于lock方法是阻塞式的,而tryLock方法则不是。当成功加锁之后,会得到一个FileLock类对象。在完成对锁定文件的操作之后,通过FileLock类的release方法可以解除锁定状态,允许其他程序来访问。FileLock类表示的锁分共享锁和排它锁两类。共享锁不允许其他程序获取到与当前锁定范围相重叠的排它锁,而获取共享锁是允许的;排它锁不允许其他程序获取到与锁定范围相重叠的共享锁和排它锁。如果调用FileLock类的对象的isShared方法的返回值为true,则表明是一个共享锁,否则是排它锁。

注意 对FileLock类表示的共享锁和排它锁的限制只发生在待锁定的文件范围与当前已有锁的范围发生重叠的时候。不同程序可以同时在一个文件上加上自己的排它锁,只要这些锁的锁定范围不互相重叠即可。

一个系统可能由C++和Java等不同编程语言所编写的不同组件组成,这些组件可能共享一个配置文件。当Java程序要更新配置文件的时候,可以先锁定该文件,再进行更新。这样可以保证更新时内容的完整性。代码清单3-13给出了一个示例的使用模板。

代码清单3-13 锁定文件的示例


public void updateWithLock()throws IOException{

try(FileChannel channel=FileChannel.open(Paths.get("settings.config"),StandardOpenOption.READ, StandardOpenOption.WRITE);

FileLock lock=channel.lock()){

//更新文件内容

}

}


对文件进行加锁操作的主体是当前的Java虚拟机,也就是说这个加锁的功能用来协同当前Java虚拟机上运行的Java程序和操作系统上运行的其他程序。对于Java虚拟机上运行的多线程程序,不能用这种机制来协同不同线程对文件的访问。