jdk源码系列-NIO

jdk源码系列-NIO

Java NIO和IO之间第一个区别是, IO是面向流的, NIO是面向缓冲区的。 Java IO面向流意味着每次从流中读一个或多个字节, 直至读取所有字节, 它们没有被缓存在任何地方。 JavaNIO的缓冲导向方法略有不同。 数据读取到一个它稍后处理的缓冲区, 需要时可在缓冲区中前后移动。 还需要检查是否该缓冲区中包含所有您需要处理的数据。 而且, 需确保当更多的数据读入缓冲区时, 不要覆盖缓冲区里尚未处理的数据。

另外, Java IO的各种流是阻塞的, 而Java NIO是非阻塞的。 IO是当一个线程调用read() 或write()时, 该线程被阻塞, 直到有一些数据被读取, 或数据完全写入。 该线程在此期间不能再干任何事情了。 而Java NIO的非阻塞模式, 使一个线程从某通道发送请求读取数据, 但是它仅能得到目前可用的数据, 如果目前没有数据可用时, 就什么都不会获取。 而不是保持线程阻塞, 所以直至数据变的可以读取之前, 该线程可以继续做其他的事情。 非阻塞写也是如此。 一个线程请求写入一些数据到某通道, 但不需要等待它完全写入, 这个线程同时可以去做别的事情。 而且, Java IO: 一个典型的IO服务器设计- 一个连接通过一个线程处理, 而NIO可让您只使用一个单线程管理多个通道( 网络连接或文件) , 但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂。

java NIO主要组成部分

  • Channels
  • Buffers
  • Selectors

虽然Java NIO 中除此之外还有很多类和组件, 但在我看来, Channel, Buffer 和 Selector 构成了核心的API。 其它组件, 如Pipe和FileLock, 只不过是与三个核心组件共同使用的工具类。

Java NIO的通道类似流, 但又有些不同:

  • 既可以从通道中读取数据, 又可以写数据到通道。 但流的读写通常是单向的。
  • 通道可以异步地读写。
  • 通道中的数据总是要先读到一个Buffer, 或者总是要从一个Buffer中写入。

Channel 和 Buffer

基本上, 所有的 IO 在NIO 中都从一个Channel 开始。 Channel 有点象流。 数据可以从Channel读到Buffer中, 也可以从Buffer 写到Channel中。

Channel和Buffer有好几种类型。 下面是JAVA NIO中的一些主要Channel的实现:

  • FileChannel,从文件中读写数据。
  • DatagramChannel,能通过UDP读写网络中的数据
  • SocketChannel,能通过TCP读写网络中的数据
  • ServerSocketChannel,以监听新进来的TCP连接, 像Web服务器那样。 对每一个新进来的连接都会创建一个SocketChannel。 正如你所看到的, 这些通道涵盖了UDP 和 TCP 网络IO, 以及文件IO。

基本的Channel示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
        RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
        FileChannel inChannel = aFile.getChannel();
        ByteBuffer buf = ByteBuffer.allocate(48);
        int bytesRead = inChannel.read(buf);
        while (bytesRead != -1) {
            System.out.println("Read " + bytesRead);
            buf.flip();
            while (buf.hasRemaining()) {
                System.out.print((char) buf.get());
            }
            buf.clear();
            bytesRead = inChannel.read(buf);
        }
        aFile.close();

注意 buf.flip() 的调用, 首先读取数据到Buffer, 然后反转Buffer,接着再从Buffer中读取数据。

Java NIO中的Buffer用于和NIO通道进行交互。 如你所知, 数据是从通道读入缓冲区, 从缓冲区写入到通道中的。缓冲区本质上是一块可以写入数据, 然后可以从中读取数据的内存。 这块内存被包装成NIOBuffer对象, 并提供了一组方法, 用来方便的访问该块内存。以下是Java NIO里关键的Buffer实现:

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

这些Buffer覆盖了你能通过IO发送的基本数据类型: byte, short, int, long, float, double 和char。Java NIO 还有个 MappedByteBuffer, 用于表示内存映射文件。

当向buffer写入数据时, buffer会记录下写了多少数据。 一旦要读取数据, 需要通过flip()方法将Buffer从写模式切换到读模式。 在读模式下, 可以读取之前写入到buffer的所有数据。一旦读完了所有的数据, 就需要清空缓冲区, 让它可以再次被写入。 有两种方式能清空缓冲区: 调用clear()或compact()方法。 clear()方法会清空整个缓冲区。 compact()方法只会清除已经读过的数据。 任何未读的数据都被移到缓冲区的起始处, 新写入的数据将放到缓冲区未读数据的后面。

Buffer的capacity,position和limit

  • position 当你写数据到Buffer中时, position表示当前的位置。 初始的position值为0.当一个byte、 long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。 position最大可为capacity – 1.当读取数据时, 也是从某个特定位置读。 当将Buffer从写模式切换到读模式, position会被重置为0. 当从Buffer的position处读取数据时, position向前移动到下一个可读的位置。
  • limit 在写模式下, Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下, limit等于Buffer的capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据。 因此, 当切换Buffer到读模式时, limit会被设置成写模式下的position值。 换句话说, 你能读到之前写入的所有数据( limit被设置成已写数据的数量, 这个值在写模式下就是position)

向Buffer中写数据

写数据到Buffer有两种方式: 从Channel写到Buffer。 通过Buffer的put()方法写到Buffer里。

从Buffer中读取数据

从Buffer中读取数据有两种方式: 从Buffer读取数据到Channel。 使用get()方法从Buffer中读取数据。

Selector

Selector( 选择器) 是Java NIO中能够检测一到多个NIO通道, 并能够知晓通道是否为诸如读写事件做好准备的组件。 这样, 一个单独的线程可以管理多个channel, 从而管理多个网络连接。Selector允许单线程处理多个 Channel。 如果你的应用打开了多个连接( 通道) , 但每个连接的流量都很低, 使用Selector就会很方便。 例如, 在一个聊天服务器中

为什么使用Selector?

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

Selector的创建

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

要使用Selector, 得向Selector注册Channel, 然后调用它的select()方法。 这个方法会一直阻塞到某个注册的通道有事件就绪。 一旦这个方法返回, 线程就可以处理这些事件, 事件的例子有如新连接进来, 数据接收等。与Selector一起使用时, Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用, 因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。

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

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

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

如果你对不止一种事件感兴趣, 那么可以用“位或”操作符将常量连接起来, 如下: int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

Scatter/Gather

Java NIO开始支持scatter/gather, scatter/gather可以从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中。

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

read()方法按照buffer在数组中的顺序将从channel中读取的数据写入到buffer, 当一个buffer被写满后, channel紧接着向另一个buffer中写。Scattering Reads在移动下一个buffer前, 必须填满当前的buffer, 这也意味着它不适用于动态消息(译者注: 消息大小不固定)。 换句话说, 如果存在消息头和消息体, 消息头必须完成填充( 例如 128byte) , Scattering Reads才能正常工作

Gathering Writes

Gathering Writes是指数据从多个buffer写入到同一个channel。

1
2
3
4
5
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能较好的处理动态消息。

署名 - 非商业性使用 - 禁止演绎 4.0