webIM系列之七——java NIO

183 阅读5分钟

最近重读netty源码,发现读到最底层netty封装Java NIO的Selector/Channel的操作有点生疏,再重读一下Java NIO。

Java NIO包括三个核心组件:

  • Channel
  • Selector
  • Buffer

Java NIO的底层,是通过操作系统提供的IO多路复用提供的支持实现的,Java 通过约定统一的API,屏蔽掉操作系统发型版本的差异。

IO多路复用,是指一个进程/线程可以同时监视多个文件描述符(一个网络连接,操作系统底层使用一个文件描述符表示),一旦其中的一个或者多个文件描述符可读或者可写,系统内核通知该进程/线程。在Java应用层面,如何实现对多个文件描述符的监视呢?需要用到的一个非常重要的NIO组件就是Selector选择器。

实现IO多路复用,首选需要把通道注册到选择器上,然后通过选择器内部的机制,可以查询这些注册的通道是否有已经就绪的IO事件(可读、可写、网络连接完成等)。

一个选择器只需要一个线程监控,我们可以简单的使用一个线程,通过选择器去管理多个通道,这是非常高效的。

Channel有很多种实现类,我们在使用过程中,主要关注两种channel:

  • SocketChannel
  • ServerSocketChannel

SocketChannel负责连接传输,ServerSocketChannel负责连接的监听。

ServerSocketChannel 应用于服务器端,而SocketChannel同时处于服务器端和客户端。换句话说,对应于一个连接,两端都需要一个负责传输的SocketChannel传输通道。

无论是ServerSocketChannel还是SocketChannel,都支持阻塞和非阻塞两种模式,通过调用configureBlocking方法设置:

  • socketChannel.configureBlocking(false) 设置为非阻塞模式
  • socketChannel.configureBlocking(true) 设置为阻塞模式

在阻塞模式下,SocketChannel通道的connect连接、read读、write写操作,都是同步和阻塞的,效率较低,非阻塞模式下,通道的操作是异步、高效的。

获取SocketChannel传输通道

在客户端,先通过SocketChannel静态方法open()获取到一个套接字传输通道,然后将socket套接字设置为非阻塞模式,最后通过connect()实例方法,对服务器的IP和端口发起连接。

SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("127.0.0.1",80));

非阻塞情况下,与服务器的连接可能还没有真正简历,socketChannel方法就返回了,因此需要不断的自旋,检查当前是否是连接到了服务器。

while(!socketChannel.finishConnect()){
	//不断自旋、等待
}

在服务器端,如何获取传输套接字呢?

当新的连接事件到来时,服务器端的ServerSocketChannel能成功地查询出一个新连接事件,并且通过调用服务器端ServerSocketChannel监听套接字的accept()方法,来获取新连接的套接字通道:

ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(false);

读取SocketChannel传输通道

当SocketChannel通道可读时,可以从SocketChannel读取数据,调用read方法,将数据读入缓冲区ByteBuffer:

ByteBuffer buf = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buf);

在读取时,因为是异步的,所以我们必须检查read的返回值,以便判断当前是否读取到了数据。read()方法的返回值,是读取的字节数。如果返回-1,说明读取到对方的输出结束标志,对方已经输出结束,准备关闭连接。

写入到SocketChannel传输通道

//写入前需要读取缓冲区,要求ByteBuffer是读取模式
buffer.flip();
socketChannel.write(buffer);

关闭SocketChannel传输通道

在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用shutdownOutput()终止输出方法,向对方发送一个输出结束标志。然后调用socketChannel.close()方法,关闭套接字:

//终止输出方法,向对方发送一个输出结束标志
socketChannel.shutdownOutput();
IOUtil.closeQuietly(socketChannel);

选择器以及注册

选择器是什么呢?选择器和通道的关系又是什么?

简单的说:选择器的使命是完成IO的多路复用。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系,是监控和被监控的关系。

一般来说,一个单线程处理一个选择器,一个选择器可以监控很多通道。通过选择器,一个单线程可以处理数百上千甚至上万的通道。在极端情况下(数万个连接),只用一个线程就可以处理所有的通道,这样会大量地减少线程之间上下文切换的开销。

通道和选择器之间的关系,通过register的方式完成。调用通道的Channel.register(Selector sel,int ops)方法,可以将通道实例注册到一个选择器中。register方法有两个参数:第一个参数,指定通道注册的选择器实例;第二个参数,指定选择器要监控的IO事件类型。

可供选择器监控的通道IO事件类型,包括以下四种:

  • 可读:SelectionKey.OP_READ
  • 可写:SelectionKey.OP_WRITE
  • 连接:SelectionKey.OP_CONNECT
  • 接收:SelectionKey.OP_ACCEPT

事件类型的定义在SelectionKey类中。如果选择器要监控通道的多种事件,可以用“按位或”运算符来实现。例如,同时监控可读和可写IO事件:

int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

什么是IO事件呢?这个概念容易混淆,这里特别说明一下。这里的IO事件不是对通道的IO操作,而是通道的某个IO操作的一种就绪状态,表示通道具备完成某个IO操作的条件。

比方说,某个SocketChannel通道,完成了和对端的握手连接,则处于“连接就绪”(OP_CONNECT)状态。