Java NIO教程(中)

由于原文比较长,我分成了上、中、下三部分介绍,各个部分链接如下:
Java NIO 教程(上)

Java NIO 教程(中)

Java NIO 教程(下)

原文:Java NIO Tutorial,作者:Jakob Jenkov,译文版本:version 1.0

7.Java NIO之Selector

Selector(选择器)是Java NIO中一个能够检测到一个或多个Channel,并且确定哪些Channel做好了读或写的准备。这样一来,单线程就可以管理多个Channel,和多个网络连接。


7.1为什么使用Selector

使用单线程控制多个Channel的好处是,可以使用更少的线程控制Channel。实际上,你可以仅仅使用一个线程控制所有的Channel。对操作系统来说,线程之间的切换代价太高,并且每个线程都会占用系统的一些资源(如内存)。因此,使用越少的线程越好。
请注意,现在的操作系统和CPU在多任务处理上变得越来越好,所以多线程的开销也随之变得越来越少。事实上,如果一个CPU有多个核,那么不使用多任务处理可能是在浪费CPU的能力。不管怎样,设计的讨论应该属于另外一些文章。在这里,知道单线程可以使用Selector控制多个Channel就行了。
下面是一个线程使用Selector控制3个Channel的示意图:

7.2创建Selector

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

7.3向Selector注册Channel

为了将Channel和Selector一起使用,你必须向Selector注册Channel。调用SelectableChannel.register()方法可以做到这点,如下:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

和Selector一起使用的Channel必须处于非阻塞模式。这意味着你不能和Selector一起使用FileChannel,因为FileChannel不能切换到非阻塞模式。而SocketChannel却能。
注意register()方法的第二个参数。这是一个“interest set”,意思是通过Selector,你监听Channel的感兴趣的事件。有4个不同的事件可以监听:
  1. Connect
  2. Accept
  3. Read
  4. Write
Channel触发了一个事件,就意味着这个事件“准备就绪”。所以,某个Channel成功连接到另一个服务器就是“连接就绪(connect ready)”。某个ServerSocketChannel接收到新来的连接就是“接收就绪(accept ready)”。某个Channel有可读的数据就是“读就绪(read ready)”。可以向某个Channel中写数据就是“写就绪(write ready)”。
4个事件对应4个SelectionKey常量:
  1. SelectionKey.OP_CONNECT
  2. SelectionKey.OP_ACCEPT
  3. SelectionKey.OP_READ
  4. SelectionKey.OP_WRITE
如果你对多个事件感兴趣,那么可以使用“逻辑或”将它们连起来,如下:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;   

7.4SelectionKey

如你所见,在前面的章节中,当你用register()方法向Selector注册一个Channel时,将会返回一个SelectionKey对象。这个SelectionKey对象包含几个你感兴趣的属性:
  • interest集合
  • ready集合
  • Channel
  • Selector
  • 一个附加对象(可选的)

7.4.1Interest集合

就像“向Selector注册Channel”那一节描述的,interest集合是你所选择的感兴趣的事件集合。你可以通过SelectionKey读写interest集合,如下:
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE; 

如你所见,你可以使用“逻辑与”将interest集合和给定的SelectionKey常量连起来,以确定某个确定的事件是否在interest集合中。


7.4.2Ready集合

ready集合Channel准备就绪的操作集合。在一次选择(selection)之后,你会首先访问ready集合。选择(selection)会在后面讲到。访问ready集合如下所示:
int readySet = selectionKey.readyOps();

你可以像interest集合那样测试Channel的什么事件/操作准备就绪。但是,你也可以使用下面的方法,它们都返回一个boolean值:
  • selectionKey.isAcceptable();
  • selectionKey.isConnectable();
  • selectionKey.isReadable();
  • selectionKey.isWritable();

7.4.3Channel和Selector

从SelectionKey访问Channel和Selector是很简单的。如:
Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector();   

7.4.4附加对象(Attaching Objects)

你可以附加一个对象或更多的信息到SelectionKey上,这是一个识别给定Channel的简单方法。例如,你可以附加一个和Channel一起使用的Buffer,或者一个包含聚集数据的对象。如下:
selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

当向Selector注册Channel时,你可以在register()方法中附加一个对象。如:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

7.5通过Selector选择Channel

一旦你向一个Selector注册了一个或多个Channel,你就可以调用select()方法中的一种。这些方法返回包含你感兴趣的事件(如connect、accept、read或者write)的那些已经准备就绪的Channel。换句话说,如果你对读事件准备就绪的Channel感兴趣,那么你将从select()方法得到读事件已经准备就绪的Channel。
下面是几种select()方法:
  • int select()
  • int select(long timeout)
  • int selectNow()
select()会阻塞直到至少有一个Channel在你注册的事件上准备就绪。
select(long timeout)和select()一样,只不过它阻塞直到一个最大timeout(超时时间)(该参数的单位是毫秒)。
selectNow()不会阻塞。无论Channel是否准备就是,它都会立即返回。
select()方法的int类型返回值,表示有多少Channel准备就绪。意思是从你最后一次调用select(),有多少Channel变成了就绪状态。如果你调用select(),并且放回值是1,是因为有1个Channel变成了就绪状态。如果你再次调用select(),同时又有一个Channel变成就绪状态,它就还会返回1。如果你对第一个就绪的Channel没有进行任何处理,你就会有2个就绪的Channel,但是在每次调用select()的时候,仅仅有一个Channel变成就绪状态。


7.5.1selectedKeys()

一旦你调用一种select()方法,其返回值表示有一个或多个Channel准备就绪,然后你就可以通过调用Selector的selectedKeys()方法,访问“选择键集合(selected key set)”里就绪的Channel。如:
Set<SelectionKey> selectedKeys = selector.selectedKeys(); 

当你向Selector注册一个Channel,Channel.register()方法就返回一个SelectionKey对象。这个Key对象代表注册到该Selector的Channel。你可以从SelectionKey的selectedKeySet()方法访问到这些Key对象。
你可以迭代这些选择键集合(selected key set),来访问就绪的Channel。
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> 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();
}

这个循环迭代选择键集合(selected key set)中的每个key对象。对每个key对象检测对应的Channel引用就绪的事件。
注意在每次迭代的最后调用keyIterator.remove()。这个Selector并没有从它自己的选择键集合(selected key set)移除SelectionKey实例。你必须在处理完Channel时,自己移除。下一次Channel变成就绪状态时,这个Selector将会再次把它添加到选择键集合(selected key set)。
通过SelectionKey.channel()方法返回的Channel必须转换成你需要处理的Channel类型,如ServerSocketChannel或者SocketChannel等。


7.6wakeup()

调用select()方法的线程阻塞了,即使没有一个Channel就绪,也有办法让select()方法返回。通过其他线程在第一个线程调用selec()方法的那个Selector上调用Selector.wakeup()即可。等待在select()方法上的线程将会立即返回。
如果其他线程调用wakeup()方法,并且当前没有一个线程阻塞在select()上,那么下一个调用select()的线程将会立即苏醒(wake up)。


7.7close()

当用完Selector后,你需要调用它的close()方法。它关闭这个Selector,并且使注册在这个Selector上的所有SelectionKey实例无效。但是Channel本身并不会关闭。


7.8完整的Selector例子

下面是一个完整的Selector例子,包括打开一个Selector,向该Selector注册Channel(Channel的初始化省略),以及监控Selector的4中事件(accept、connect、read、write)。
Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while(true) {
  int readyChannels = selector.select();
  if(readyChannels == 0) continue;
  Set<SelectionKey> selectedKeys = selector.selectedKeys();
  Iterator<SelectionKey> 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();
  }
}

8.Java NIO之FileChannel

Java NIO的FileChannel是一个连接文件的Channel。使用FileChannel,你可以从文件中读数据,和向文件中写数据。
FileChannel不能设置成非阻塞模式。它只有阻塞模式。


8.1打开FileChannel

在使用FileChannel之前,必须先打开它。你不能直接打开一个FileChannel。你需要通过InputStream、OutputStream或RandomAccessFile,获取FileChannel。下面是通过RandomAccessFile打开FileChannel的例子:
RandomAccessFile aFile     = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel      inChannel = aFile.getChannel();

8.2从FileChannel读数据

为了从FileChannel中读数据,你需要调用一种read()方法。
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);

首先,分配一个Buffer。数据从FileChannel读入到这个Buffer。
然后,调用FileChannel.read()方法。该方法将数据从FileChannel读入到那个Buffer。read()方法的int类型放回值表示有多少字节被写入到那个Buffer。如果返回值是-1,说明文件已读完。


8.3向FileChannel写数据

使用FileChannel.write()方法向FileChannel中写数据,该方法的参数是一个Buffer。
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的所有字节都写入Channel。


8.4关闭FileChannel

当使用完FileChannel,必须把它关闭。
channel.close();

8.5FileChannel的position方法

当在FileChannel的指定位置进行读/写操作,你可以通过调用position()方法,获取FileChannel的当前位置。
你也可以通过调用position(long pos)方法,设置FileChannel的位置。
long pos channel.position();
channel.position(pos +123);

如果你把位置设置在文件的结束符之后,并且尝试从FileChannel中读数据,那么你就得到-1——文件结尾的标志。
如果你把位置设置在文件的结束符之后,并且向FileChannel中写数据,那么文件将会撑大到设置的位置,并写入数据。这可能导致“文件空洞(file hole)”,即磁盘上的物理文件中写入的数据间有空隙。


8.6FileChannel的size方法

FileChannel对象的size()方法返回FileChannel连接的文件大小。
long fileSize = channel.size();

8.7FileChannel的truncate方法

你可以调用FileChannel.truncate()方法截取一个文件。当你截取文件时,你通过指定的长度截取。
channel.truncate(1024);

上例中截取长度为1024byte的文件。


8.8FileChannel的force方法

FileChannel.force()方法将所有为写到磁盘的数据强制写到磁盘上。出于性能考虑,操作系统将数据缓存到内存中,所以你无法保证写入到Channel的数据都及时写入到了磁盘上,除非你调用force()方法。
force()方法有一个boolean类型参数,表示文件的元数据(如权限信息等)是否要写到磁盘上。
下面是将文件数据和元数据都写到磁盘上的例子:
channel.force(true);

9.Java NIO之SocketChannel

Java NIO的SocketChannel是一个连接TCP网络套接字的Channel。它在Java NIO中等价于Java网络套接字。有两种方法创建SocketChannel:
  1. 打开SocketChannel,并且连接互联网中的某一台服务器。
  2. 当一个新连接到达ServerSocketChannel时,SocketChannel被创建。

9.1打开SocketChannel

下面是打开SocketChannel的例子:
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));

9.2关闭SocketChannel

使用完SocketChannel后,调用SocketChannel.close()关闭SocketChannel。
socketChannel.close(); 

9.3从SocketChannel读数据

为了从SocketChannel读数据,你需要调用一种read()方法。
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);

首先,分配一个Buffer。数据从SocketChannel读入到Buffer。
然后,调用SocketChannel.read()方法。该方法从SocketChannel向Buffer中读数据。read()方法的int类型返回值表示有多少字节被写入到Buffer。如果返回值是-1,已经读到了流的末尾(连接已关闭)。


9.4向SocketChannel写数据

通过调用SocketChannel.write()方法向SocketChannel中写数据,该方法的参数是一个Buffer。
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);
}

注意SocketChannel.write()方法是在while循环中调用的。无法保证write()方法把多少字节被写入到SocketChannel。所以重复调用write()方法,直到Buffer没有更多的数据可写。


9.5非阻塞模式

SocketChannel可以设置成非阻塞模式。当设置成非阻塞模式时,可以在异步模式下调用connect()、read()和write()。


9.5.1connect()

如果SocketChannel在非阻塞模式下,你可以调用connect(),该方法在连接被建立之前返回。为决定连接是否被建立,你可以调用finishConnect()方法。如:
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));
while(! socketChannel.finishConnect() ){
    //wait, or do something else...    
}

9.5.2write()

在非阻塞模式下,即使没有写任何数据,write()方法也可能返回。所以你需要在循环中调用write()方法。但是,由于之前已经有类似的例子,这里就不在赘述。


9.5.3read()

在非阻塞模型下,即使没有读任何数据,read()方法也可能返回。所以你需要注意int类型的返回值,它表示已经读了多少字节。


9.5.4非阻塞模式与Selector

SocketChannel的非阻塞模式和Selector一起可以工作的更好。通过向Selector注册一个或多个SocketChannel,你可以询问Selector哪个Channel的读、写等是就绪的。Selector和SocketChannel的联合使用在后续章节讲。


10.Java NIO之ServerSocketChannel

Java NIO的ServerSocketChannel是一个监听来自TCP连接的Channel,就像标准Java网络编程中的ServerSocket。ServerSocketChannel在java.nio.channels包中。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

serverSocketChannel.socket().bind(new InetSocketAddress(9999));

while(true){
    SocketChannel socketChannel =
            serverSocketChannel.accept();

    //do something with socketChannel...
}

10.1打开ServerSocketChannel

通过调用ServerSocketChannel.open()方法打开一个ServerSocketChannel。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

10.2关闭ServerSocketChannel

通过调用ServerSocketChannel.close()方法关闭ServerSocketChannel。
serverSocketChannel.close();

10.3监听新的连接

通过调用ServeSocketChannel.accept()方法,监听新来的连接。当accept()方法返回,它会返回一个SocketChannel和一个新的连接。因此,accept()方法会阻塞直到新连接到达。
由于通常不会监听单个连接,所以你需要在一个while循环中调用accept()方法。
while(true){
    SocketChannel socketChannel =
            serverSocketChannel.accept();

    //do something with socketChannel...
}

当然,你也可以在while循环中使用其他终止条件。


10.4非阻塞模式

ServerSocketChannel可以设置成非阻塞模式。在非阻塞模式下,accept()方法会立即返回,如果没有新连接到来,它将返回“null”。所以,你必须检查返回的SocketChannel是否为“null”。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);

while(true){
    SocketChannel socketChannel =
            serverSocketChannel.accept();

    if(socketChannel != null){
        //do something with socketChannel...
        }
}

11.Java NIO之非阻塞服务器

即使你理解了Java NIO非阻塞特性怎么工作的(Selector、Channel、Buffer等),但是设计一个非阻塞服务器依然很难。相对于阻塞IO,非阻塞IO还包含几个挑战。本章将讨论非阻塞服务器的主要挑战,并且介绍一些解决方案。
找到关于设计非阻塞服务器的有用信息是困难的。所以,本教程提供的解决方案是基于我自己的工作经验和想法。如果你有一些其他的或者更好的想法,我将洗耳恭听。

本教程的设计思想围绕着Java NIO。但是,我相信在其他语言中可以重用这些思想,只要它们有某种类Selector构造。据我说知,这种构造有底层操作系统提供,所以很可能你也可以用于其他语言。


11.1非阻塞服务器——GitHub仓库

我已经写好的例子,对我的想法进行了概念验证,并且把代码放在了GitHub仓库中。你可以在下面链接找到:
https://github.com/jjenkov/java-nio-server


11.2非阻塞IO管道

非阻塞IO管道(non-blocking IO pipeline)是处理非阻塞IO的组件链。它包括非阻塞方式中的读写IO。下面是非阻塞IO管道的简化示意图:

该组件使用一个Selector检测什么时候Channel有数据可以读。然后,该组件读输入数据,并且依赖输入生成相应的输出。输出的数据会再次被写入到Channel。
一个非阻塞IO管道不需要同时读写数据。因为有一些管道专门读数据,另外一些管道专门写数据。
上图只使用了一个组件。一个非阻塞IO管道可能有多个处理新来数据的组件。非阻塞IO管道的需求决定该管道的长度。
非阻塞IO管道可以同时从多个Channel中读数据。例如,从多个SocketChannel中读数据。
上图中的控制流也很简单。组件初始化从Channel经由Selector读出的数据。这不是说Channel推送数据到Selector,并从那里进入组件,即使上图表明的是那样。

11.3非阻塞IO管道vs阻塞IO管道

非阻塞IO管道和阻塞IO管道之间最大的区别在于数据怎么从底层Channel(Socket或文件)中读出。
IO管道通常从一些Stream(从Socket或文件)中读数据,并且把数据拆分成一致的信息。这类似于将数据流分解成标记,以便于使用分词器解析。相反,你分解的数据流会更大的信息。我将调用组件分解Stream为消息阅读器(Message Reader)的消息。如:

阻塞IO管道使用一次从底层Channel读一个字节的类InputStream接口,并且这个类InputStream接口阻塞直到有数据准备好读。这就是Message Reader的实现。
使用阻塞IO关联到使Message Reader的实现简化了很多的Stream。在没有数据从Stream中读出,或者只有部分数据从Steam中读出,以及消息的解析需要稍后才被恢复的情况下,阻塞的Message Reader不必进行处理。
类似的,在只有部分消息被写,和消息的写稍后才会恢复的情况下,阻塞消息编辑器(Message Writer)(向Stream中写信息的组件)不必进行处理。


11.3.1阻塞IO管道的缺点

阻塞Message Reader是很容易实现的,但它要求每个需要拆分成消息的Stream有一个单独线程的不幸的缺点。原因是每一个IO接口阻塞直到有一些数据从中读取。这意味着一个单独的线程不能试图从一个Stream中读数据,同时如果它没有数据,需要从另一个Stream中读数据。如果一个线程试图从一个Stream里读数据,那么这个线程阻塞直到有数据可读。
如果IO管道是一个具有很多并发组件的服务器的一部分,那么这个服务器将需要为每一个活跃的新连接分配一个线程。如果服务器在任何时候只有几百个并发连接,这样做就没有什么问题。但是,如果服务器有百万级的并发连接,这种设计将不能很好的扩展。每一个线程的栈会占用320K(32bit JVM)到1024K(64 bit JVM)的内存。所以,1,000,000个线程会占用1TB内存!这还是在服务器开始使用内存处理新来的消息之前(如,在处理消息期间,内存还要分配给对象)。
为了减少线程的数量,许多服务器都会设计一个线程池(如,100个线程的线程池),从进来的连接中每次读去一个消息。进来的连接放在一个队列里,并且线程按队列里进来连接的顺序处理每一个新连接。如下图:

这种设计要求进来的连接发送数据相对频繁。如果进来的连接在很长一段时间内不活跃,那么会有很大数量的不活跃连接阻塞线程池中所有的连接。这意味着服务器会响应地很慢,甚至不响应。
一些服务器的设计通过线程池中变化的线程数目来缓解这个问题。例如,如果一个线程池用完了所有线程,这个线程池可能创建更多的线程处理负载。这种解决方案意味着需要更多的迟缓连接才能使服务器不响应。但是,这对于运行的线程数量仍然有上限。所以,这也不能很好的扩展到1,000,000个迟缓连接。

11.4简单的非阻塞IO管道设计

非阻塞IO管道可以使用单个线程从多个Stream中读消息。这要求Stream能够切换到非阻塞模式。当在非阻塞模式下,当你试图从一个Stream中读数据时,它可以返回0个或多个字节。如果这个Stream没有数据可读,则返回0。当这个Stream有一些数据可以读,则返回多个字节。
为了避免检查Stream是否有0字节可读,我们使用Java NIO Selector。一个或多个SelectableChannel实例注册到Selector。当你在Selector上调用select()或selectNow(),它将返回有数据可读的SelectableChannel实例。如下图:

11.5读取部分消息

当从SelectableChannel中读取一个数据块时,我们不知道这个数据块包含的信息比真正的消息多还是少。数据块可能包含了一部分消息,或完整的消息,或更多的消息。多种可能的消息描述如下:

在处理部分消失时,有两个挑战:

  1. 检查数据块中是否有完整的消息
  2. 不处理部分消息直到其余的消息都到达
检查完整的消息要求Message Reader监视数据块中的数据,看是否包含至少一个完整的消息。如果数据块包含一个或多个完整的消息,这些消息就可以发送给管道处理。寻找完整消息的处理会被多次重复,所以这个处理必须尽可能地快。
无论何时数据块中有部分消息,不管是它本身还是其后还有一个或多个完整的消息,这个部分消息都必须被保存直到其余的消息从Channel中到来。

从Selector检测到某个Channel实例有数据可以读之后,与这个Channel相关的Message Reader就会读取数据,并且试图把数据分解成消息。如果那样,任何完整的消息都会被读取,这些消息会被传输到读管道中需要处理它们的组件。
Message Reader是特殊的协议。Message Reader需要知道它将读取的消息格式。如果我们的服务器实现通过协议是可重用的,那么它需要有Message Reader实现的接口——可能以某种方式接收Message Reader工厂作为配置参数。


11.6存储部分消息

现在我们已经知道了Message Reader的职责是存储部分消息直到整个完整的消息都被接收到。我们需要弄清楚部分消息的存储应该怎么被实现。
我们应该考虑两种设计思路:
  1. 尽可能少的复制消息数据。复制越多,性能越低。
  2. 为了便于解析消息,完整的消息应该被存储在连续的字节列表里。

11.6.1每个Message Reader有一个Buffer

很明显部分消息需要存储在某些类型的缓冲区中。简单实现是每个Message Reader中有一个缓冲区。但是,缓冲区应该有多大呢?它应该足够的大,能存储最大的消息。所以,如果最大的消息是1MB,那么每个Message Reader中的缓冲区至少要有1MB。
当接收到百万级的连接,那么每个连接使用1MB是不能工作的。1,000,000*1MB是1TB内存!如果最大消息是16MB或者128MB将会怎样呢?


11.6.2动态缓存区(Resizable Buffers)

另一个选择是每个Message Reader中使用一个动态缓冲区。动态缓冲区开始的时候很小,并且如果消息对于缓冲区而言太大,那么缓冲区就自动扩展。这样一来,每个连接就不需要有1MB的缓冲区。每个连接只需要它们将要携带的下一个消息大小的内存即可。
有几种方法可以实现动态缓冲区。这些方法都有优点和缺点,所以我将在下一节讨论它们。


11.6.3通过复制调整大小

第一种实现动态缓冲区的方法是刚开始的用比较小的缓冲区,如4KB。如果如果一个消息大于4KB,就分配一个更大的缓冲区,如8KB的缓冲区。并且把数据从这个4KB的缓冲区中拷贝到那个更大的缓冲区中。
复制调整缓冲区大小的方法的优点是,消息的所有数据被保存在一起,并存储在一个连续的字节数组中。这样一来,解析消息就容易得多。
复制调整缓冲区大小的方法的缺点是,这种方法对于更大的消息而言,会导致很多复制数据产生。
为了减少数据拷贝,你可以分析通过你系统的消息流的大小,来找到可以减少拷贝数量的合理的缓冲区大小。例如,你发现由于大多数消息只包含非常小的请求/响应,所以大都小于4KB。这意味着第一个缓冲区大小应该是4KB。
如果你发现有一个消息包含了一个文件,且大于4KB。同时,你注意到通过系统的最大的文件流不超过128KB。这意味着第二个缓冲区需要是128KB。
最后,你发现一旦有一个消息大于128K,那么一个消息究竟有多大就没有固定的模式。所以,可能最后的缓冲区大小就是最大的消息的大小了。
用这三种基于通过系统的消息流大小的缓冲区,你将会多少能减少些数据拷贝。小于4KB的消息不需要拷贝。对于1,000,000的并发连接,会产生1,000,000*4KB=4GB消耗,这对于今天(2015)的大多数服务器来时是可能的。介于4KB和128KB之间的消息会有一次拷贝,并且只有4KB的数据需要拷贝到128KB的缓冲区中。介于128KB和最大消息大小之间的消息会有两次拷贝。第一次是拷贝4KB的数据,第二次拷贝128KB的数据,所以对于最大的消息有一个132KB的拷贝数据。假设没有其他消息大于128KB,那么这就可以被接受了。
一旦一个消息被处理完,它占用的内存需要再次释放掉。这样从同一个连接接收的下一个消息,就可以再次用从最小的缓冲区开始。必须确保内存在所有连接间高效共享。最大的可能是所有连接不会同时使用最大的缓冲区。


11.6.4通过追加调整大小

另一种调整缓冲区大小的方法是,用多个数组构造缓冲区。当你需要调整缓冲区时,你可以简单的分配其他的字节数组,并把数据写到里面。
有两种方法去扩展这样的缓冲区。一种方法是分配独立的字节数组,并保存这些字节数组的列表。另一种方法是分配更大的分片,用来共享字节数组,并且保存已分配到缓冲区的分片的列表。我个人感觉分片的方式是可能是更好的,但差别不大。
通过追加独立数组或分片增大缓冲区的方法的优点是,没有数据在写的时候需要拷贝。所有的数据直接从Channel拷贝到数组或分片中。

缺点是数据没有保存到一个单独的连续数组中。由于解析器需要同时找到每一个数组的结尾和所有数组的结尾,这使得消息的解析更加困难。因为在写数据的时候,需要找到消息的末尾,所以这种模式不是太容易实现。


11.6.5TLV编码消息

消息协议格式使用TLV格式(Type、Length、Value)编码。意味着当一个消息到达,该消息的全长被保存在这个消息的开头。这样一来,你就会立即知道需要给这个完整的消息分配多大的内存。
TLV编码是内存管理更加简单。你立刻知道需要给消息分配多大内存,那么缓冲区就不会只有部分被使用,没有内存被浪费。
TLV编码的缺点是你需要在消息的全部数据达到之前,给这个消息分配所有的内存。少数迟缓的发送大消息的连接可能分配所有可用的内存,从而是服务器无响应。
这种问题的解决措施是使用一个包含多个TLV字段的消息格式。因此,为每个字段分配内存,而不是整个消息。而且当字段达到时,内存才被分配。尽管如此,对于一个大消息,一个大字段在内存管理上可能有同样的影响。
另一种措施是暂停超时未接收到的消息,如10-15秒内。这样可以让服务器在同时有许多大消息达到的巧合中恢复,但是这仍然会是服务器有一段时间不能响应。另外,对于服务器,一个故意的DoS(拒绝服务)攻击仍然会导致完全分配内存。
TLV编码有很多变种。究竟有多少字节用于指定字段的类型和长度取决于每个TLV编码。也有把字段长度放在第一位,接着是类型和值的TLV编码。虽然字段的顺序是不同的,但都是TLV的变种。
TLV编码使内存管理更容易的事实,是为什么HTTP1.1是如此糟糕的协议的原因之一。这个问题在HTTP2.0中修复,它的数据传输使用LTV编码结构。


11.7写部分消息

在非阻塞IO管道中写数据是一个挑战。当非阻塞模式下,用Channel调用write(ByteByffer)方法时,无法保证ByteBuffer中有多少字节被写。write(ByteBuffer)方法返回写了多少个字节,所以它可能追踪了写的字节数量。挑战是:追踪部分地写消息,为了在最后消息的所以字节被发送。
为了管理部分消息写入到Channel,我们将创建一个Message Writer。就像Message Reader,需要写入消息的每一个Channel都有一个Message Writer。在Message Writer里,追踪当前写了消息的多少个字节。
为了防止到达Message Writer的消息比它能直接写到Channel的消息多,这些消息需要在该Message Writer中排队。然后Message Writer尽可能快地向Channel中写。如:

由于Message Writer能够发送已经发了部分的消息,Message Writer需要不时的被调用,所以它可以发送更多的数据。
如果你很多的连接,就需要很多的Message Writer实例。检查如1百万个Message Writer实例看是否它们写任何数据都慢。首先,许多Message Writer实例没有消息可发送。我们不希望检查这些Message Writer实例。第二,不是所有的Channel实例都准备好了写数据。我们不想浪费时间在向不接收数据的Channel中写数据。
为了检查Channel是否准备好写数据,需要向Selector注册该Channel。但是,我们不想向Selector注册全部的Channel实例。想象一下,如果有1,000,000个连接,大部分都是空闲的,并且向Selector注册了全部的1,000,000个连接。然后,当调用select(),大部分Channel实例将是“写准备”状态(它们大部分是空闲的,记得吗?)。你就不得不检查这些连接的Message Writer,看它们是否有数据可以写。
为了避免检查全部的Message Writer,和没有任何消息可以被发送到全部的Channel,我们用以下两步方法:

  1. 当一个消息被写到Message Writer时,该Message Writer把它相应的Channel注册到Selector中(如果该Channel事先没被注册)。
  2. 当服务器有空闲时间,它检查Selector看哪一个已注册的Channel实例准备好写了。对于每个准备好写的Channel,与它相关的Message Writer被要求向这个Channel写数据。如果Message Writer向它的Channel写它的所有消息,那么这个Channel不再向该Selector注册。
这两步方法确保只有有消息可被写的Channel实例,被注册到Selector中。


11.8汇总

如你所见,非阻塞服务器需要不时地检查新来的数据,以便发现是否有完整的消息被接收到。该服务器可能需要检查多次,直到一个或多个完整的消息已经被接收到。检查一次时不够的。
类似地,非阻塞服务器需要不时地检查是否有任何数据可写。如果有,服务器需要检查相应的连接是否准备了数据写给它们。当一个消息排队时,第一时间检查是不够的,因为这个消息可以已经部分被写了。
在整个非阻塞服务器中,用3个周期性地执行“管道”结束全部:
  • 读管道(read pipeline),检查从打开的连接中新来的数据
  • 处理管道(process pipeline),处理任何接收到的完整的消息
  • 写管道(write pipeline),检查它是否可以向任何打开的连接,写任何传出的消息。
这三个管道在一个循环中重复地执行。你可以以某种方式优化该执行过程。例如,如果没有消息排队,可以跳过写管道。或者,没有收到新的完整的消息,可以跳过处理管道。
下面是完整的服务器循环:

如果你依然觉得它有点儿复杂,可以参考GitHub的代码:
https://github.com/jjenkov/java-nio-server
看实例代码可能帮助你理解它的实现。

11.9服务器线程模式

GitHub仓库中的非阻塞服务器的实现使用了2个线程的线程模式。第一个线程接收来自ServerSocketChannel的连接。第二个线程处理接收的连接,意思是读消息、处理消息和向该连接写回响应。如:



  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值