Java IO和NIO

Contents
  1. Java IO
    1. 前言
    2. 分类
      1. 按操作方式(类结构)
      2. 按操作对象
    3. 其他类:RandomAccessFile
  2. Java NIO
    1. NIO简介
    2. NIO和IO的区别
    3. 读写数据方式
    4. NIO核心组件
    5. Buffer
      1. Buffer的容量,位置,上限
        1. 容量(Capacity)
        2. 位置(Position)
        3. 上限(Limit)
      2. Buffer常见方法
      3. Buffer的使用方式
        1. 分配缓冲区(Allocating a Buffer)
        2. 写入数据到缓冲区(Writing Data to a Buffer)
        3. 翻转(flip())
        4. 从Buffer读取数据(Reading Data from a Buffer)
        5. rewind()
        6. clear() and compact()
        7. mark()与reset()方法
        8. equals() and compareTo()
    6. Channel
      1. 与流的区别
      2. 重要的几个Channel的实现
      3. FileChannel的使用
        1. 打开FileChannel
        2. 从FileChannel读取数据
        3. 向FileChannel写数据
        4. 关闭FileChannel
        5. FileChannel的position方法
        6. FileChannel的size方法
        7. FileChannel的truncate方法
        8. FileChannel的force方法
    7. Scatter/Gather
      1. Scattering Reads
      2. Gathering Writes
    8. 通道之间的数据传输
      1. transferFrom()
      2. transferTo()
    9. Selector
      1. 为什么使用Selector?
      2. Selector的创建
      3. 向Selector注册通道
      4. SelectionKey
        1. interest集合
        2. ready集合
        3. Channel + Selector
        4. 附加的对象
      5. 通过Selector选择通道
        1. selectedKeys()
        2. wakeUp()
        3. close()

帮助理解Java IO分类,及Java NIO主要模块和工作机制。

Java IO

Java IO是传统的Java处理IO的包的方式,但其中前前后后牵扯到几十个类。如果对它们不做分类总结,是很难掌握这一块内容的,很容易脑子一团浆糊,看过就忘。总体来说,这是一块很硬的骨头。

我们在此找出Java IO的共性,给它们分分类。本文会有些许表达和概念并非那么严谨,旨在帮助理解和记忆。

前言

我们先来看一张网上流传的关于Java IO包的类结构图:

java_io_no

不知道多少人看到这幅图心里暗自一句woc,继而内心崩溃。有些人不怕枯燥和艰难,硬着头皮看源码,但是坚持看下去全部看完的有几个呢?就算源码看完看懂,过一周后依然有清晰记忆的又有几个呢?

上面的图虽然有分类,但是还不够细,而且没有总结出方便记忆的规律,所以我们要重新整理和归类。

分类

按操作方式(类结构)

字节流和字符流:

  • 字节流:以字节为单位,每次读入或读出是8位数据可以读任何类型数据
  • 字符流:以字符为单位,每次读入或读出是16位数据。其只能读取字符类型数据

输出流和输入流:

  • 输出流:从内存读出到文件。只能进行写操作。
  • 输入流:从文件读入到内存。只能进行读操作。

注意:这里的出和入,都是相对于系统内存而言的。

节点流和处理流:

  • 节点流:直接与数据源相连,读入或读出。
  • 处理流:与节点流一块使用,在节点流的基础上,再套接一层,套接在节点流上的就是处理流。

为什么要有处理流?直接使用节点流,读写不方便,为了更快的读写文件,才有了处理流。

按操作方式分类结构图:

根据以上分类,以及jdk的说明,我们可以画出更详细的类结构图:

io_op_way

分类说明:

1)输入字节流InputStream

  • ByteArrayInputStreamStringBufferInputStreamFileInputStream 是三种基本的介质流,它们分别从Byte数组、StringBuffer、和本地文件中读取数据。
  • PipedInputStream 是从与其它线程共用的管道中读取数据。PipedInputStream的一个实例要和PipedOutputStream的一个实例共同使用,共同完成管道的读取写入操作。主要用于线程操作。
  • DataInputStream 将基础数据类型读取出来。
  • ObjectInputStream 和所有 FilterInputStream 的子类都是装饰流(装饰器模式的主角)。

2)输出字节流OutputStream

  • ByteArrayOutputStreamFileOutputStream 是两种基本的介质流,它们分别向Byte数组和本地文件中写入数据。
  • PipedOutputStream 是向与其它线程共用的管道中写入数据。
  • DataOutputStream 将基础数据类型写入到文件中。
  • ObjectOutputStream 和所有 FilterOutputStream 的子类都是装饰流。

字节流的输入和输出类结构图:

stream_io

3)字符输入流Reader

  • FileReaderCharReaderStringReader 是三种基本的介质流,它们分在本地文件、Char数组、String中读取数据。
  • PipedReader 是从与其它线程共用的管道中读取数据。
  • BufferedReader 加缓冲功能,避免频繁读写硬盘。
  • InputStreamReader 是一个连接字节流和字符流的桥梁,它将字节流转变为字符流。

4)字符输出流Writer

  • StringWriter 向String中写入数据。
  • CharArrayWriter 实现一个可用作字符输入流的字符缓冲区。
  • PipedWriter 是向与其它线程共用的管道中写入数据。
  • BufferedWriter 增加缓冲功能,避免频繁读写硬盘。
  • PrintWriterPrintStream 将对象的格式表示打印到文本输出流。极其类似,功能和使用也非常相似。
  • OutputStreamWriter 是OutputStream到Writer转换的桥梁,它的子类FileWriter其实就是一个实现此功能的具体类。功能和使用和OutputStream极其类似,后面会有它们的对应图。

字符流的输入和输出类结构图:

char_stream_io

按操作对象

按操作对象分类结构图:

io_op_object

分类说明:

1)对文件进行操作(节点流)

  • FileInputStream(字节输入流)
  • FileOutputStream(字节输出流)
  • FileReader(字符输入流)
  • FileWriter(字符输出流)

2)对管道进行操作(节点流)

  • PipedInputStream(字节输入流)
  • PipedOutputStream(字节输出流)
  • PipedReader(字符输入流)
  • PipedWriter(字符输出流)

PipedInputStream的一个实例要和PipedOutputStream的一个实例共同使用,共同完成管道的读取写入操作。主要用于线程操作

3)字节/字符数组流(节点流)

  • ByteArrayInputStream
  • ByteArrayOutputStream
  • CharArrayReader
  • CharArrayWriter

除了上述三种是节点流,其他都是处理流,需要跟节点流配合使用。

1)Buffered缓冲流(处理流)

带缓冲区的处理流,缓冲区的作用的主要目的是:避免每次和硬盘打交道,提高数据访问的效率。

  • BufferedInputStream
  • BufferedOutputStream
  • BufferedReader
  • BufferedWriter

2)转化流(处理流)

  • InputStreamReader:把字节转化成字符;
  • OutputStreamWriter:把字节转化成字符。

3)基本类型数据流(处理流)

用于操作基本数据类型值。因为平时若是我们输出一个8个字节的long类型或4个字节的float类型,那怎么办呢?可以一个字节一个字节输出,也可以把转换成字符串输出,但是这样转换费时间,若是直接输出该多好啊,因此这个数据流就解决了我们输出数据类型的困难。数据流可以直接输出float类型或long类型,提高了数据读写的效率。

  • DataInputStream
  • DataOutputStream

4)打印流(处理流)

一般是打印到控制台,可以进行控制打印的地方。

  • PrintStream
  • PrintWriter

5)对象流(处理流)

把封装的对象直接输出,而不是一个个在转换成字符串再输出。

  • ObjectInputStream 对象反序列化
  • ObjectOutputStream 对象序列化

6)合并流(处理流)

  • SequenceInputStream 可以认为是一个工具类,将两个或者多个输入流当成一个输入流依次读取。

其他类:RandomAccessFile

该对象并不是流体系中的一员,其封装了字节流,同时还封装了一个缓冲区(字符数组),通过内部的指针来操作字符数组中的数据。 该对象特点:

  1. 该对象只能操作文件,所以构造函数接收两种类型的参数:a.字符串文件路径;b.File对象。
  2. 该对象既可以对文件进行读操作,也能进行写操作,在进行对象实例化时可指定操作模式(r,rw)。

Java NIO

NIO简介

Java NIO是java 1.4之后新出的一套IO接口,这里的的新是相对于原有标准的Java IO和Java Networking接口。NIO提供了一种完全不同的操作方式。

NIO中的N可以理解为Non-blocking,不单纯是New。

它支持面向缓冲的,基于通道的I/O操作方法。 随着JDK 7的推出,NIO系统得到了扩展,为文件系统功能和文件处理提供了增强的支持。 由于NIO文件类支持的这些新的功能,NIO被广泛应用于文件处理。

NIO和IO的区别

1. Channels and Buffers(通道和缓冲区)

IO是面向流的,NIO是面向缓冲区的

  • 标准的IO编程接口是面向字节流和字符流的。而NIO是面向通道和缓冲区的,数据总是从通道中读到buffer缓冲区内,或者从buffer缓冲区写入到通道中;(NIO中的所有I/O操作都是通过一个通道开始的。)
  • Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方;
  • Java NIO是面向缓存的I/O方法。将数据读入缓冲器,使用通道进一步处理数据。在NIO中,使用通道和缓冲区来处理I/O操作。

2. Non-blocking IO(非阻塞IO)

IO流是阻塞的,NIO流是不阻塞的。

  • Java NIO使我们可以进行非阻塞IO操作。比如说,单线程中从通道读取数据到buffer,同时可以继续做别的事情,当数据读取到buffer中后,线程再继续处理数据。写数据也是一样的。另外,非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
  • Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。

3. Selectors(选择器)

NIO有选择器,而IO没有。

  • 选择器用于使用单个线程处理多个通道。因此,它需要较少的线程来处理这些通道。
  • 线程之间的切换对于操作系统来说是昂贵的。 因此,为了提高系统效率选择器是有用的。

读写数据方式

通常来说NIO中的所有IO都是从 Channel(通道) 开始的。

  • 从通道进行数据读取:创建一个缓冲区,然后请求通道读取数据。
  • 从通道进行数据写入:创建一个缓冲区,填充数据,并要求通道写入数据。

数据读取和写入操作图示:

nio_read_write_data

NIO核心组件

NIO包含下面几个核心的组件:

  • Channels
  • Buffers
  • Selectors

整个NIO体系包含的类远远不止这三个,只能说这三个是NIO体系的核心。

Buffer

Java NIO Buffers用于和NIO Channel交互。我们从Channel中读取数据到buffers里,从Buffer把数据写入到Channels。

Buffer本质上就是一块内存区,可以用来写入数据,并在稍后读取出来。这块内存被NIO Buffer包裹起来,对外提供一系列的读写方便开发的接口。

在Java NIO中使用的核心缓冲区如下(覆盖了通过I/O发送的基本数据类型:byte, char、short, int, long, float, double ,long):

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • FloatBuffer
  • DoubleBuffer
  • LongBuffer

buffer_structure

利用Buffer读写数据,通常遵循四个步骤:

  1. 把数据写入buffer;
  2. 调用flip;
  3. 从Buffer中读取数据;
  4. 调用buffer.clear()或者buffer.compact()。

当写入数据到buffer中时,buffer会记录已经写入的数据大小。当需要读数据时,通过flip()方法把buffer从写模式调整为读模式;在读模式下,可以读取所有已经写入的数据。

当读取完数据后,需要清空buffer,以满足后续写入操作。清空buffer有两种方式:调用clear()compact()方法。clear会清空整个buffer,compact则只清空已读取的数据,未被读取的数据会被移动到buffer的开始位置,写入位置则近跟着未读数据之后。

Buffer的容量,位置,上限

Buffer缓冲区实质上就是一块内存,用于写入数据,也供后续再次读取数据。这块内存被NIO Buffer管理,并提供一系列的方法用于更简单的操作这块内存。

一个Buffer有三个属性是必须掌握的,分别是:

  • capacity容量
  • position位置
  • limit限制

position和limit的具体含义取决于当前buffer的模式。capacity在两种模式下都表示容量。

读写模式下position和limit的含义:

buffer_read_write_mode

容量(Capacity)

作为一块内存,buffer有一个固定的大小,叫做capacit(容量)。也就是最多只能写入容量值得字节,整形等数据。一旦buffer写满了就需要清空已读数据以便下次继续写入新的数据。

位置(Position)

当写入数据到Buffer的时候需要从一个确定的位置开始,默认初始化时这个位置position为0,一旦写入了数据比如一个字节,整形数据,那么position的值就会指向数据之后的一个单元,position最大可以到capacity-1.

当从Buffer读取数据时,也需要从一个确定的位置开始。buffer从写入模式变为读取模式时,position会归零,每次读取后,position向后移动。

上限(Limit)

在写模式,limit的含义是我们所能写入的最大数据量,它等同于buffer的容量。

一旦切换到读模式,limit则代表我们所能读取的最大数据量,他的值等同于写模式下position的位置。换句话说,您可以读取与写入数量相同的字节数(限制设置为写入的字节数,由位置标记)。

Buffer常见方法

方法 介绍
abstract Object array() 返回支持此缓冲区的数组 (可选操作)
abstract int arrayOffset() 返回该缓冲区的缓冲区的第一个元素的在数组中的偏移量 (可选操作)
int capacity() 返回此缓冲区的容量
Buffer clear() 清除此缓存区。将position = 0;limit = capacity;mark = -1;
Buffer flip() flip()方法可以把Buffer从写模式切换到读模式。调用flip方法会把position归零,并设置limit为之前的position的值。 也就是说,现在position代表的是读取位置,limit标示的是已写入的数据位置。
abstract boolean hasArray() 告诉这个缓冲区是否由可访问的数组支持
boolean hasRemaining() return position < limit,返回是否还有未读内容
abstract boolean isDirect() 判断个缓冲区是否为 direct
abstract boolean isReadOnly() 判断告知这个缓冲区是否是只读的
int limit() 返回此缓冲区的限制
Buffer position(int newPosition) 设置这个缓冲区的位置
int remaining() return limit - position; 返回limit和position之间相对位置差
Buffer rewind() 把position设为0,mark设为-1,不改变limit的值
Buffer mark() 将此缓冲区的标记设置在其位置

Buffer的使用方式

分配缓冲区(Allocating a Buffer)

为了获得缓冲区对象,我们必须首先分配一个缓冲区。在每个Buffer类中,allocate()方法用于分配缓冲区。

下面来看看ByteBuffer分配容量为28字节的例子:

1
ByteBuffer buf = ByteBuffer.allocate(28);

下面来看看另一个示例:CharBuffer分配空间大小为2048个字符。

1
CharBuffer buf = CharBuffer.allocate(2048);

写入数据到缓冲区(Writing Data to a Buffer)

写数据到Buffer有两种方法:

  • 从Channel中写数据到Buffer
  • 手动写数据到Buffer,调用put方法

下面是一个实例,演示从Channel写数据到Buffer:

1
int bytesRead = inChannel.read(buf); // read into buffer.

通过put写数据:

1
buf.put(127);

put方法有很多不同版本,对应不同的写数据方法。例如把数据写到特定的位置,或者把一个字节数据写入buffer。

翻转(flip())

flip()方法可以把Buffer从写模式切换到读模式。调用flip方法会把position归零,并设置limit为之前的position的值。 也就是说,现在position代表的是读取位置,limit标示的是已写入的数据位置。

从Buffer读取数据(Reading Data from a Buffer)

从Buffer读数据也有两种方式。

  • 从buffer读数据到channel
  • 从buffer直接读取数据,调用get方法

读取数据到channel的例子:

1
int byteWritten = inChannel.write(buf);

调用get读取数据的例子:

1
byte aByte = buf.get();

get也有诸多版本,对应了不同的读取方式。

rewind()

Buffer.rewind()方法将position置为0,这样我们可以重复读取buffer中的数据。limit保持不变。

clear() and compact()

一旦我们从buffer中读取完数据,需要复用buffer为下次写数据做准备。只需要调用clear()或compact()方法。

如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。

如果Buffer还有一些数据没有读取完,调用clear就会导致这部分数据被“遗忘”,因为我们没有标记这部分数据未读。

针对这种情况,如果需要保留未读数据,那么可以使用compact。 因此compact()和clear()的区别就在于: 对未读数据的处理,是保留这部分数据还是一起清空。

mark()与reset()方法

通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。例如:

1
2
3
buffer.mark();
//call buffer.get() a couple of times, e.g. during parsing.
buffer.reset(); //set position back to mark.

equals() and compareTo()

可以用eqauls和compareTo比较两个buffer

equals():

判断两个buffer相对,需满足:

  • 类型相同
  • buffer中剩余字节数相同
  • 所有剩余字节相等

从上面的三个条件可以看出,equals只比较buffer中的部分内容,并不会去比较每一个元素。

compareTo():

compareTo也是比较buffer中的剩余元素,只不过这个方法适用于比较排序的。

Channel

通常来说NIO中的所有IO都是从Channel(通道)开始的。

  • 从通道进行数据读取:创建一个缓冲区,然后请求通道读取数据。
  • 从通道进行数据写入:创建一个缓冲区,填充数据,并要求通道写入数据。

数据读取和写入操作图示:

nio_channel_rw

与流的区别

  • 通道可以读也可以写,流一般来说是单向的(只能读或者写,所以之前我们用流进行IO操作的时候需要分别创建一个输入流和一个输出流)。
  • 通道可以异步读写
  • 通道总是基于缓冲区Buffer来读写。

重要的几个Channel的实现

  • FileChannel:用于文件的数据读写
  • DatagramChannel:用于UDP的数据读写
  • SocketChannel:用于TCP的数据读写,一般是客户端实现
  • ServerSocketChannel:允许我们监听TCP链接请求,每个请求会创建会一个SocketChannel,一般是服务器实现

类层次结构:

channel_structure

FileChannel的使用

Java NIO中的FileChannel是一个连接到文件的通道。可以通过文件通道读写文件。

FileChannel无法设置为非阻塞模式,它总是运行在阻塞模式下。

打开FileChannel

在使用FileChannel之前,必须先打开它。但是,我们无法直接打开一个FileChannel,需要通过使用一个InputStream、OutputStream或RandomAccessFile来获取一个FileChannel实例。下面是通过RandomAccessFile打开FileChannel的示例:

1
2
RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();

从FileChannel读取数据

调用多个read()方法之一从FileChannel中读取数据。如:

1
2
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);

首先,分配一个Buffer。从FileChannel中读取的数据将被读到Buffer中。

然后,调用FileChannel.read()方法。该方法将数据从FileChannel读取到Buffer中。read()方法返回的int值表示了有多少字节被读到了Buffer中。如果返回-1,表示到了文件末尾。

向FileChannel写数据

使用FileChannel.write()方法向FileChannel写数据,该方法的参数是一个Buffer。如:

1
2
3
4
5
6
7
8
9
10
11
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
channel.write(buf);
}

注意FileChannel.write()是在while循环中调用的。因为无法保证write()方法一次能向FileChannel写入多少字节,因此需要重复调用write()方法,直到Buffer中已经没有尚未写入通道的字节。

关闭FileChannel

用完FileChannel后必须将其关闭。如:

1
channel.close();

FileChannel的position方法

有时可能需要在FileChannel的某个特定位置进行数据的读/写操作。可以通过调用position()方法获取FileChannel的当前位置。

也可以通过调用position(long pos)方法设置FileChannel的当前位置。

这里有两个例子:

1
2
long pos = channel.position();
channel.position(pos + 123);

如果将位置设置在文件结束符之后,然后试图从文件通道中读取数据,读方法将返回-1 —— 文件结束标志。

如果将位置设置在文件结束符之后,然后向通道中写数据,文件将撑大到当前位置并写入数据。这可能导致“文件空洞”,磁盘上物理文件中写入的数据间有空隙。

FileChannel的size方法

FileChannel实例的size()方法将返回该实例所关联文件的大小。如:

1
long fileSize = channel.size();

FileChannel的truncate方法

可以使用FileChannel.truncate()方法截取一个文件。截取文件时,文件将中指定长度后面的部分将被删除。如:

1
channel.truncate(1024);

这个例子截取文件的前1024个字节。

FileChannel的force方法

FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法。

force()方法有一个boolean类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上。

下面的例子同时将文件数据和元数据强制写到磁盘上:

1
channel.force(true);

Scatter/Gather

Java NIO开始支持scatter/gather,scatter/gather用于描述从 Channel中读取或者写入到Channel的操作。

分散(scatter)从Channel中读取是指在读操作时将读取的数据写入多个buffer中。因此,Channel将从Channel中读取的数据“分散(scatter)”到多个Buffer中。

聚集(gather)写入Channel是指在写操作时将多个buffer的数据写入同一个Channel,因此,Channel 将多个Buffer中的数据“聚集(gather)”后发送到Channel。

scatter/gather经常用于需要将传输的数据分开处理的场合,例如传输一个由消息头和消息体组成的消息,你可能会将消息体和消息头分散到不同的buffer中,这样你可以方便的处理消息头和消息体。

Scattering Reads

Scattering Reads是指数据从一个channel读取到多个buffer中。如下图描述:

scattering_reads

代码示例如下:

1
2
3
4
5
6
ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] bufferArray = { header, body };
channel.read(bufferArray);

注意buffer首先被插入到数组,然后再将数组作为channel.read() 的输入参数。read()方法按照buffer在数组中的顺序将从channel中读取的数据写入到buffer,当一个buffer被写满后,channel紧接着向另一个buffer中写。

Scattering Reads在移动下一个buffer前,必须填满当前的buffer,这也意味着它不适用于动态消息(译者注:消息大小不固定)。换句话说,如果存在消息头和消息体,消息头必须完成填充(例如 128byte),Scattering Reads才能正常工作。

Gathering Writes

Gathering Writes是指数据从多个buffer写入到同一个channel。如下图描述:

gathering_writes

代码示例如下:

1
2
3
4
5
6
7
8
ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
//write data into buffers
ByteBuffer[] bufferArray = { header, body };
channel.write(bufferArray);

buffers数组是write()方法的入参,write()方法会按照buffer在数组中的顺序,将数据写入到channel,注意只有position和limit之间的数据才会被写入。因此,如果一个buffer的容量为128byte,但是仅仅包含58byte的数据,那么这58byte的数据将被写入到channel中。因此与Scattering Reads相反,Gathering Writes能较好的处理动态消息。

通道之间的数据传输

在Java NIO中,如果两个通道中有一个是FileChannel,那你可以直接将数据从一个channel(译者注:channel中文常译作通道)传输到另外一个channel。

transferFrom()

FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中(译者注:这个方法在JDK文档中的解释为将字节从给定的可读取字节通道传输到此通道的文件中)。

下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");
FileChannel fromChannel = fromFile.getChannel();
RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");
FileChannel toChannel = toFile.getChannel();
long position = 0;
long count = fromChannel.size();
toChannel.transferFrom(position, count, fromChannel);

方法的输入参数position表示从position处开始向目标文件写入数据,count表示最多传输的字节数。如果源通道的剩余空间小于 count 个字节,则所传输的字节数要小于请求的字节数。

此外要注意,在SoketChannel的实现中,SocketChannel只会传输此刻准备好的数据(可能不足count字节)。因此,SocketChannel可能不会将请求的所有数据(count个字节)全部传输到FileChannel中。

transferTo()

transferTo()方法将数据从FileChannel传输到其他的channel中。

下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");
FileChannel fromChannel = fromFile.getChannel();
RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");
FileChannel toChannel = toFile.getChannel();
long position = 0;
long count = fromChannel.size();
fromChannel.transferTo(position, count, toChannel);

是不是发现这个例子和前面那个例子特别相似?除了调用方法的FileChannel对象不一样外,其他的都一样。

上面所说的关于SocketChannel的问题在transferTo()方法中同样存在。SocketChannel会一直传输数据直到目标buffer被填满。

Selector

Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。

为什么使用Selector?

仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。

但是,需要记住,现代的操作系统和CPU在多任务方面表现的越来越好,所以多线程的开销随着时间的推移,变得越来越小了。实际上,如果一个CPU有多个内核,不使用多任务可能是在浪费CPU能力。不管怎么说,关于那种设计的讨论应该放在另一篇不同的文章中。在这里,只要知道使用Selector能够处理多个通道就足够了。

下面是单线程使用一个Selector处理3个channel的示例图:

selector

Selector的创建

通过调用Selector.open()方法创建一个Selector,如下:

1
Selector selector = Selector.open();

向Selector注册通道

为了将Channel和Selector配合使用,必须将channel注册到selector上。通过SelectableChannel.register()方法来实现,如下:

1
2
3
channel.configureBlocking(false);
SelectionKey key = channel.register(selector,
Selectionkey.OP_READ);

与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。

注意register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件:

  • Connect
  • Accept
  • Read
  • Write

通道触发了一个事件意思是该事件已经就绪。所以,某个channel成功连接到另一个服务器称为“连接就绪”。一个server socket channel准备好接收新进入的连接称为“接收就绪”。一个有数据可读的通道可以说是“读就绪”。等待写数据的通道可以说是“写就绪”。

这四种事件用SelectionKey的四个常量来表示:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:

1
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

SelectionKey

在上一小节中,当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。这个对象包含了一些你感兴趣的属性:

  • interest集合
  • ready集合
  • Channel
  • Selector
  • 附加的对象(可选)

interest集合

就像向Selector注册通道一节中所描述的,interest集合是你所选择的感兴趣的事件集合。可以通过SelectionKey读写interest集合,像这样:

1
2
3
4
5
6
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;

可以看到,用“位与”操作interest 集合和给定的SelectionKey常量,可以确定某个确定的事件是否在interest 集合中。

ready集合

ready 集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,你会首先访问这个ready set。Selection将在下一小节进行解释。可以这样访问ready集合:

1
int readySet = selectionKey.readyOps();

可以用像检测interest集合那样的方法,来检测channel中什么事件或操作已经就绪。但是,也可以使用以下四个方法,它们都会返回一个布尔类型:

1
2
3
4
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();

Channel + Selector

从SelectionKey访问Channel和Selector很简单。如下:

1
2
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();

附加的对象

可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:

1
2
selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

还可以在用register()方法向Selector注册Channel的时候附加对象。如:

1
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

通过Selector选择通道

一旦向Selector注册了一或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“读就绪”的通道感兴趣,select()方法会返回读事件已经就绪的那些通道。

下面是select()方法:

  • int select()
  • int select(long timeout)
  • int selectNow()

select()阻塞到至少有一个通道在你注册的事件上就绪了。

select(long timeout)和select()一样,除了最长会阻塞timeout毫秒(参数)。

selectNow()不会阻塞,不管什么通道就绪都立刻返回(译者注:此方法执行非阻塞的选择操作。如果自从前一次选择操作后,没有通道变成可选择的,则此方法直接返回零。)。

select()方法返回的int值表示有多少通道已经就绪。亦即,自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。

selectedKeys()

一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道。如下所示:

当像Selector注册Channel时,Channel.register()方法会返回一个SelectionKey 对象。这个对象代表了注册到该Selector的通道。可以通过SelectionKey的selectedKeySet()方法访问这些对象。

可以遍历这个已选择的键集合来访问就绪的通道。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if(key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
// a connection was established with a remote server.
} else if (key.isReadable()) {
// a channel is ready for reading
} else if (key.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove();
}

这个循环遍历已选择键集中的每个键,并检测各个键所对应的通道的就绪事件。

注意每次迭代末尾的keyIterator.remove()调用。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。

SelectionKey.channel()方法返回的通道需要转型成你要处理的类型,如ServerSocketChannel或SocketChannel等。

wakeUp()

某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。

如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。

close()

用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。