本文是我在学习 jackdov.com 的 Java NIO Tutorial 时的中文翻译。最初是在 CSDN 写的,所以图片会有 CSDN 的水印。
如果文章哪里有问题,请联系我,我会及时修改。
Java NIO 教程
Java NIO(New IO)是 Java 的另一种 IO API,意指替代标准的 Java IO 和 Java Networking API。Java NIO 提供了一个不同于传统 IO API 的 IO 编程模型。注意:有时 NIO 被称为非阻塞 IO。然而,这并不是 NIO 最初的意思。另外,NIO API 的部分内容实际上是阻塞的——例如文件API,所以“非阻塞”这个标签会有一点误导性。
Non-blocking IO(非阻塞IO)
NIO 让你可以使用非阻塞 IO。例如,一个线程可以从一个 channel(通道)将数据读到一个Buffer(缓冲区)中。当Channel将数据读入Buffer时,线程可以做其他事情。一旦数据读入Buffer结束,线程就可以继续处理数据。向Channel写入数据也是如此。
Channels and Buffers(通道和缓冲区)
在标准的 IO API 中,你可以使用字节流和字符流。在 NIO 中,你使用的是Channel和Buffer。数据总是从一个Channel读到一个Buffer,或者从一个Buffer写到一个Channel。
Selectors(选择器)
NIO 包含“selectors”的概念。一个Selector是一个对象,它可以监控多个Channel的事件(如:连接打开,数据到达等)。因此,一个线程可以监控多个Channel的数据。
NIO 的概念
与旧的 IO 模式相比,NIO 有几个新的概念需要学习。这些概念列举如下:
- Channels
- Buffers
- Scatter - Gather
- Channel to Channel Transfers
- Selectors
- FileChannel
- SocketChannel
- ServerSocketChannel
- Non-blocking Server Design
- DatagramChannel
- Pipe
- NIO vs. IO
- Path
- Files
- AsynchronousFileChannel
概述
NIO 由以下核心组件组成:
- Channel(通道)
- Buffers(缓冲区)
- Selectors(选择器)
NIO 的类和组件不止这些,但Channel、Buffer和Selector构成了 API 的核心。其余的组件,如Pipe和FileLock只是与这三个核心组件配合使用的工具类。因此,我将在本篇 NIO 概述中重点介绍这三个组件。其他组件在本教程的其他地方有自己的文字解释。请看本页上方的目录。
Channels and Buffers(通道和缓冲区)
通常情况下,NIO 中所有的 IO 都是从一个Channel开始的。一个Channel有点像一个流。从Channel中可以将数据读到Buffer中。数据也可以从Buffer写入Channel。下面是一个说明:
有多种Channel和Buffer类型。以下几个类是 NIO 中重要的几个Channel实现:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
如你所见,这些 channel 涵盖了 UDP + TCP 网络 IO,以及文件 IO。
还有一些有趣的接口伴随着这些类,但为了简单起见,我将不把它们放在这个 NIO 概述中。它们将在后文中进行相关解释。
下面是 NIO 中关键的几个Buffer实现:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
这些Buffer涵盖了你可以通过 IO 发送的基本数据类型:byte、short、int、long、float、double和char。
NIO 还有一个 MappedByteBuffer ,它与内存映射文件一起使用。
Selectors(选择器)
一个Selector允许一个线程处理多个Channel。如果您的应用程序有许多连接(Channel)打开,但每个连接的流量很小,这就很方便。例如聊天服务器。
下面是一个线程使用Selector来处理3个Channel的例子:
要使用一个Selector,你需要在它那里注册Channel,然后调用它的select()方法。这个方法会阻塞,直到有一个已注册的Channel事件就绪。一旦该方法返回,线程就可以处理这些事件。事件的例子有传入连接、接收数据等。
Channel
Channel类似于流,但有一些区别:
- 你可以同时对 Channels 进行读写。流通常是单向的(读或者写)。
- Channels 可以异步读写。
- Channels 总是将数据读到或写到一个Buffer。
如上所述,你从Channel读数据到Buffer,从Buffer写数据到Channel。如下图所示:
Channel 的多个实现
下面是 NIO 中几个重要的Channel实现:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
FileChannel可以从文件中读取数据,也可以向文件中写入数据。
DatagramChannel可以通过 UDP 在网络上读写数据。
SocketChannel 可以通过 TCP 在网络上读写数据。
ServerSocketChannel 允许您监听传入的 TCP 连接,就像 Web 服务器一样。对于每个传入的连接,都会创建一个 SocketChannel。
基本 Channel 示例
下面是一个使用FileChannel将一些数据读入Buffer的基本例子:
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在与Channel交互时使用。如你所知,数据从Channel读到Buffer,又从Buffer写到Channel。
Buffer本质上是一个内存块,你可以将数据写入其中,然后你可以在以后再次读取。这个内存块被包裹在一个Buffer对象中,它提供了一组方法,使其更容易使用内存块。
Buffer 的基本用法
使用Buffer读写数据通常遵循以下4个步骤:
- 向
Buffer写入数据 - 调用
buffer.flip() - 从
Buffer中读取数据 - 调用
buffer.clear()或buffer.compact()
当你把数据写入Buffer时,Buffer会跟踪你写了多少数据。一旦你需要读取数据,你需要调用flip()方法将Buffer从写模式切换到读模式。在读取模式下,Buffer让你读取写入Buffer的所有数据。
一旦你读取了所有的数据,你需要清除Buffer,使其为再次写入做好准备。你可以通过两种方式来实现。通过调用clear()或调用compact()。clear()方法可以清除整个Buffer。compact()方法只清除你已经读取的数据。未读的数据都会被移动到Buffer的开头,之后读取的数据将被写入Buffer中的未读数据之后。
下面是一个简单的Buffer使用示例:
RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();
//create`Buffer`with capacity of 48 bytes
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);//read into buffer.
while (bytesRead != -1) {
buf.flip(); //make`Buffer`ready for read
while(buf.hasRemaining()){
System.out.print((char) buf.get()); // read 1 byte at a time
}
buf.clear(); //make`Buffer`ready for writing
bytesRead = inChannel.read(buf);
}
aFile.close();
Buffer Capacity, Position and Limit(Buffer 容量、位置和限制)
Buffer本质上是一个内存块,你可以将数据写入其中,然后再读取。这个内存块被包裹在一个Buffer对象中,它提供了一组方法,使其更容易地处理内存块。
为了理解Buffer的工作原理,你需要熟悉Buffer的三个属性。这三个属性是:
- capacity
- positon
- limit
position和limit的含义取决于Buffer是处于读还是写模式。但无论在哪种Buffer模式下,容量的含义都是一样的。
下面是写和读模式下容量、位置和限制的说明:
Capacity
作为一个内存块,Buffer有一定的固定大小,也就是所谓的 "capacity"。你只能向Buffer中写入对应容量的byte、long、char等数据。一旦Buffer满了,你需要清空它(读取数据,或者调用对应方法),然后才能向它写入更多的数据。
Position
当你向Buffer中写入数据时,你会在某个位置写入数据。初始位置是 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被设置为写模式的下一个将要写的位置。换句话说,你可以读取和写入一样多的字节(limit通过position设置为写入的字节数,)。
Buffer Types(Buffer 类型)
NIO 有以下几种Buffer类型:
- ByteBuffer
- MappedByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
如你所见,这些Buffer类型代表了不同的数据类型。换句话说,它们让你可以把Buffer中的字节当作char、short、int、long、float或double来处理。
MappedByteBuffer有点特殊,将在后文中介绍。
Allocating a Buffer(分配 Buffer)
要获得一个Buffer对象,你必须首先分配它。每个Buffer类都有一个allocate()方法来完成这个任务。下面是一个例子,展示了一个容量为 48 字节的ByteBuffer的分配:
ByteBuffer buf = ByteBuffer.allocate(48);
下面是一个分配一个容量为 1024 个char的CharBuffer的例子:
CharBuffer buf = CharBuffer.allocate(1024);
Writing Data to a Buffer(向Buffer写入数据)
您可以通过两种方式将数据写入Buffer:
- 将数据从
Channel写入Buffer - 通过
Buffer的put()方法,自己将数据写入Buffer。
下面是一个例子,展示了一个Channel如何向Buffer中写入数据:
int bytesRead = inChannel.read(buf); //read into buffer.
下面是一个通过put()方法向Buffer中写入数据的例子:
buf.put(127);
put()方法还有很多其他的版本,允许你以很多不同的方式将数据写入Buffer中。例如,在特定的位置写入,或者将一个字节数组写入Buffer。更多细节请参见 JavaDoc 中Buffer的具体实现。
flip()
flip()方法将一个Buffer从写模式切换到读模式。调用flip()方法将position设置为 0,并将limit设置为position刚才的位置。
换句话说,position现在标志着读取的位置,limit标志着有多少字节、字符等被写入Buffer(可以读取多少字节、字符等的极限)。
Reading Data from a Buffer(从 Buffer 读取数据)
有两种方式可以从Buffer读取数据。
- 从
Buffer中读取数据到一个Channel中。 - 使用
get()方法之一,自己从Buffer中读取数据。
下面是一个如何从Buffer读取数据到Channel的例子:
//从`Buffer`读取数据到`Channel`中。
int bytesWritten = inChannel.write(buf)。
下面是一个使用get()方法从Buffer中读取数据的例子:
byte aByte = buf.get();
get()方法还有许多其他版本,允许你以许多不同的方式从Buffer中读取数据。例如,在特定的位置读取,或者从Buffer中读取一个字节数组。更多细节请参见JavaDoc中的具体Buffer实现。
rewind()
Buffer.rewind()将position设回 0,因此可以重新读取Buffer中的所有数据。limit保持不变,仍然标志着可以从Buffer中读取多少元素(字节、字符等)。
clear() and compact()
一旦你完成了从Buffer中读出数据,你必须让Buffer为再次写入做好准备。你可以通过调用clear()或者调用compact()来完成。
如果你调用clear(),那么position将被设置为 0,limit变为capacity。换句话说,Buffer被清空了,Buffer中的数据不会被清除。标记(position和limit)会告诉你从哪里继续写入数据。
如果当你调用clear()时,Buffer中还有任何未读数据,那么这些数据将被遗忘,这意味着你不再有任何标记告诉你哪些数据已经被读取,哪些数据没有被读取。
如果在Buffer中还有未读数据,而你想稍后再读,但你需要先做一些写的工作,调用compact()而不是clear()。
compact()将所有未读数据复制到Buffer的开头。然后它将position设置在最后一个未读元素之后。limit属性仍然设置为capacity,就像clear()一样。现在Buffer已经准备好写入了,但不会覆盖未读数据。
mark() and reset()
你可以通过调用Buffer.mark()方法来标记Buffer中的某个位置。之后你可以通过调用Buffer.reset()方法将该位置重置回标记的位置。下面是一个例子:
buffer.mark()。
//调用buffer.get()方法几次,比如在解析过程中。
buffer.reset(); //将位置设回mark.get()方法。
equals()和compareTo()
可以使用equals()和compareTo()来比较两个Buffer。
equals()
两个Buffer在以下情况下是相等的:
- 它们的类型相同(
byte、char、int等)。 - 它们在
Buffer中的剩余字节、字符等数量相同。 - 所有剩余的字节、字符等都相等。
正如你所看到的,equals只比较Buffer的一部分,而不是里面的每一个元素。事实上,它只是比较Buffer中剩余的元素。
compareTo()
compareTo()方法比较两个Buffer的剩余元素(字节、字符等)的大小,用于例如排序程序。以下情况一个Buffer被认为比另一个Buffer "小":
- 相应元素,比另一个
Buffer中的元素小。 - 所有元素都相等,但第一个
Buffer会在第二个Buffer之前用完了元素(它的元素较少)。
Scatter / Gather(分散 / 收集)
NIO 具有内置的 scatter / gather 支持。scatter / gather是用于从Channel读取和向Channel写入的概念。
从Channel的 scattering read 是一种读取操作,它将数据读取到多个Buffer中。因此,Channel将数据从Channel 散布到多个Buffer。
对Channel的 gathering write 是将数据从多个Buffer写入单个Channel的写入操作。因此,Channel将多个Buffer的数据收集到一个Channel中。
在需要分别处理传输数据的各个部分的情况下,scatter / gather 可以非常有用。例如,如果一个消息由头和主体组成,您可以将头和主体分别保存在不同的Buffer中。这样做可以使您更容易地分别处理报头和报文。
Scattering Reads
scattering read 将数据从一个Channel读取到多个Buffer。下面是该原理的一个说明。
这里是Scatter原理的说明:
下面是一个代码示例,展示了如何执行散布式读取:
ByteBuffer header = ByteBuffer.allocate(128); ByteBuffer body = ByteBuffer;
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] bufferArray = { header, body };
channel.read(bufferArray)。
请注意Buffer是如何先插入一个数组,然后将数组作为参数传递给channel.read()方法。然后read()方法按照Buffer在数组中出现的顺序从Channel中写入数据。一旦一个Buffer满了,Channel就会继续填充下一个Buffer。
事实上,散布式读取在移动到下一个Buffer之前填满一个Buffer,这意味着它不适合动态大小的消息部分。所以,如果你有一个头和一个体,而且头是固定大小的(比如128字节),那么散点读就可以正常工作。
Gathering Writes
gathering write 将多个Buffer的数据写入一个Channel。下面是该原理的一个说明:
下面是一个代码示例,展示了如何执行收集写入:
ByteBuffer header = ByteBuffer.allocate(128); ByteBuffer body = ByteBuffer;
ByteBuffer body = ByteBuffer.allocate(1024)。
//将数据写入`Buffer`
ByteBuffer[] bufferArray = { header, body };
channel.write(bufferArray)。
Buffer数组被传递到write()方法中,该方法按照数组中遇到的顺序写入Buffer的内容。只有Buffer的position和limit之间的数据被写入。因此,如果一个Buffer的容量为 128 个字节,但只包含 58 个字节,那么只有 58 个字节从该Buffer写入Channel。因此,聚集写对动态大小的消息部分工作得很好,与分散读相反。
Channel to Channel Transfers(通道间传递数据)
在 NIO 中,如果其中一个Channel是一个FileChannel的话,你可以直接将数据从一个Channel传输到另一个Channel。FileChannel类有一个transferTo()和transferFrom()方法,可以帮你完成这个任务。
transferFrom()
FileChannel.transferFrom()方法将数据从一个源Channel传输到FileChannel中。下面是一个简单的例子。
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(fromChannel, position, count)。
参数position和count,告诉在目标文件的什么位置开始写(position),以及最大限度地传输多少字节(count)。如果源Channel的字节数少于count,则传输的字节数就会减少。
此外,一些SocketChannel的实现可能只传输SocketChannel此时此刻在其内部Buffer中准备好的数据——即使SocketChannel以后可能有更多的数据可用。因此,它可能不会把从SocketChannel请求的全部数据(count)传输到FileChannel中。
transferTo()
transferTo()方法将一个FileChannel中的数据转移到其他Channel中。下面是一个简单的例子。
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的实现可能只从FileChannel传输字节,直到发送Buffer满了,然后停止。
Selector
Selector是一个可以检查一个或多个Channel的组件,并确定哪些Channel已经准备好了,例如读或写。这样,一个线程可以管理多个Channel,从而管理多个网络连接。
Why Use a Selector(为什么使用 Selector)
只使用一个线程来处理多个Channel的好处是,你需要更少的线程来处理Channel。实际上,你可以只用一个线程来处理所有的Channel。对于一个操作系统来说,线程之间的切换是很昂贵的,而且每个线程也会占用操作系统中的一些资源(内存)。因此,你使用的线程越少越好。
但要记住,现代操作系统和 CPU 的多任务处理能力越来越好,所以多线程的开销会随着时间的推移变得越来越小。事实上,如果一个 CPU 有多个核心,你不进行多任务处理可能会浪费 CPU 的电量。总之,这个设计的讨论属于另一篇文章,这里只需要说,你可以用一个线程,用一个Selector来处理多个Channel。
下面是一个线程使用Selector处理3个Channel的例子:
Creating a Selector(创建一个 Selector)
你可以通过调用Selector.open()方法创建一个Selector,就像这样:
Selector selector = Selector.open()。
Registering Channels with the Selector(用 Selector 注册 Channel)
为了使用带有Selector的Channel,你必须将该Channel注册到Selector中。这是用SelectableChannel.register()方法完成的,就像这样:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ)。
Channel必须在非阻塞模式下才能与Selector一起使用。这意味着你不能将FileChannel与Selector一起使用,因为FileChannel不能被切换到非阻塞模式。不过SocketChannel可以正常工作。
注意register()方法的第二个参数。这是一个 "interest set(兴趣集)",意思是你有兴趣通过Selector在Channel中监听什么事件。有四个不同的事件你可以监听:
- Connect
- Accept
- Read
- Write
Channel的 "fires an event" 也被称为 "ready(就绪)"。所以,一个已经成功连接到另一个服务器的Channel是 "connect ready"。一个接受了传入连接的服务器套接字Channel是 "accept " 就绪。一个已经准备好读取数据的Channel是 "read" 就绪。一个Channel如果已经准备好让你向它写入数据,就是 "write" 就绪。
这四个事件由四个SelectionKey常量来表示:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
如果你对多个事件感兴趣,就把常量一起 OR,像这样:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
我将在这段的后面一点回到 "interest set"。
SelectionKey
正如你在上一节所看到的,当你用一个Selector注册一个Channel时,register()方法会返回一个SelectionKey对象。这个SelectionKey对象包含一些有趣的属性:
- The interest set
- The ready set
- The Channel
- The Selector
- An attached object (optional)
下面我将介绍这些属性。
Interest Set
interest set 是您对 "selecting "感兴趣的事件集,如 "Registering Channels with the Selector" 一节所述。您可以像这样通过SelectionKey来读取和写入该兴趣集:
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;
正如您所看到的,您可以用给定的SelectionKey常量与兴趣集进行 AND,以找出某个事件是否在兴趣集中。
Ready Set
就绪集是Channel准备好的操作集。您主要是在选择之后访问就绪集。选择将在后面的章节中解释,您可以像这样访问就绪集:
int readySet = selectionKey.readyOps();
你可以用与兴趣集相同的方式测试,Channel准备好了哪些事件/操作。但是,你也可以用这四个方法来代替,它们都返回一个布尔值:
selectionKey.isAcceptable()。
selectionKey.isConnectable(); selectionKey.isConnectable()。
selectionKey.isReadable(); selectionKey.isReadable()。
selectionKey.isWritable()。
Channel + Selector
从SelectionKey访问Channel+Selector是很简单的。例如:
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();
Attaching Objects (附加对象)
您可以将一个对象附加到一个SelectionKey上,这是一个识别给定Channel的方便方法,或者将更多的信息附加到Channel上。例如,您可以附加您正在使用的Channel的Buffer,或者一个包含更多集合数据的对象。下面是您如何附加对象:
selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment()。
你也可以在注册Channel时,在register()方法中已经附加一个对象,例如:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
Selecting Channels via a Selector(通过选择器选择通道)
一旦你用一个Selector注册了一个或多个Channel,你就可以调用select()方法。这些方法会返回你感兴趣的事件(accept、connect、read、write)的已就绪的Channel集合。换句话说,如果你对那些准备好读取的Channel感兴趣,你将从select()方法中接收那些准备好读取的Channel。
下面是select()方法:
- int select()
- int select(long timeout)
- int selectNow()
select()会一直阻塞,直到至少有一个Channel为你注册的事件做好准备。
select(long timeout)与select()的操作相同,只是它会在最大超时毫秒(参数)内进行阻塞。
selectNow() 完全不阻塞。它立即返回任何准备好的Channel。
select()方法返回的int表示有多少个Channel已经准备好了。也就是说,自从上次调用select()后,有多少个Channel已经准备好了。如果你调用select(),它返回 1,表明一个Channel已经准备好了,你再调用select()一次,又有一个Channel准备好了,它将再次返回 1。如果你没有对第一个Channel进行任何操作,那么现在你有两个准备好的Channel,但是在每次调用select()之间只有一个Channel已经准备好。
selectedKeys()
一旦你调用了其中一个select()方法,并且它的返回值表明一个或多个Channel已经准备好了,你就可以通过 "selected key set",通过调用selector.selectedKeys()方法来访问这些准备好的Channel。下面是这样的情况:
Set<SelectionKey> selectedKeys = selector.selectedKeys();
当你用一个Selector注册一个Channel时,Channel.register()方法会返回一个SelectionKey对象。这个键代表该Channel在该Selector中的注册。你可以通过selectedKeySet()方法访问这些键。从SelectionKey.register()方法中。
你可以迭代这个选定的键集来访问准备好的Channel。例如:
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) { {
SelectionKey key = keyIterator.next()。
if(key.isAcceptable()) {
// 一个连接被ServerSocketChannel接受了。
} else if (key.isConnectable()) {
// 与远程服务器建立了连接。
} else if (key.isReadable()) {
// 一个`Channel`可以读了
} else if (key.isWritable()) {
// 一个`Channel`可以写了
}
keyIterator.remove()。
}
这个循环在所选的键集中迭代键。对于每个键,它都会测试该键,以确定该键所引用的Channel已经准备好了。
请注意在每次迭代结束时的keyIterator.remove()调用。Selector不会从所选键集本身移除SelectionKey实例。你必须这样做,当你处理完Channel后。下一次当Channel变得 "准备好 "时,Selector将再次把它添加到选定的键组中。
SelectionKey.channel()方法返回的Channel应该被转换为你需要处理的Channel,例如ServerSocketChannel或SocketChannel等。
wakeUp()
一个调用了select()方法的线程被阻塞了,可以让它退出select()方法,即使还没有Channel准备好。这可以通过让另一个线程在第一个线程调用select()的Selector上调用Selector.wakeup()方法来实现。然后,在select()里面等待的线程会立即返回。
如果不同的线程调用了wakeup(),而当前没有线程在select()里面被阻塞,那么下一个调用select()的线程将立即 "唤醒"。
close()
当你完成对Selector的操作后,你可以调用它的close()方法。这将关闭Selector,并使所有与该Selector注册的SelectionKey实例无效。Channel本身不会被关闭。
完整的 Selector 示例
这里是一个完整的例子,它打开一个Selector,用它注册一个Channel(Channel实例化被省略了),并一直监控Selector的四个事件(accept、connect、read、write)的就绪情况:
Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while(true) {
int readyChannels = selector.selectNow();
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();
}
}
FileChannel
一个FileChannel是一个连接到文件的Channel。你可以从文件中读取数据,并向文件写入数据通过使用FileChannel。FileChannel类是 NIO 的一种替代方式,可以用标准的 Java IO API来
要从一个FileChannel中读取数据,你可以调用read()方法之一。下面是一个例子:
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);
首先分配一个Buffer。从FileChannel中读取的数据将被读入Buffer中。
其次调用FileChannel.read()方法。该方法将FileChannel中的数据读入Buffer中。read()方法返回的int表示有多少字节被读到Buffer中。如果返回 -1,则表示达到了文件的终点。
Writing Data to a FileChannel(向 FileChannel 中写入数据)
向FileChannel写入数据是通过FileChannel.write()方法完成的,该方法使用一个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-loop 中调用的。write()方法向FileChannel写入多少字节是没有保证的。因此,我们重复调用write()方法,直到Buffer没有更多的字节要写。
Closing a FileChannel(关闭一个FileChannel)
当你使用完一个FileChannel后,你必须关闭它。以下是关闭的方法:
channel.close();
FileChannel Positon(FileChannel 位置)
当你对一个FileChannel进行读写时,你会在一个特定的位置进行读写。你可以通过调用position()方法来获取FileChannel对象的当前位置。
你也可以通过调用position(long pos)方法来设置FileChannel的位置。
下面是两个例子:
long pos channel.position();
channel.position(pos +123)。
如果你将position设置到文件结尾,并尝试从Channel中读取,你将得到 -1 文件结束的标记。
如果你将position设置到文件结尾,并向Channel写入,文件将被扩展以适应位置和写入的数据。这可能会导致 "file hole",即磁盘上的物理文件在写入的数据中存在空隙。
FileChannel Size(FileChannel 大小)
FileChannel对象的size()方法返回Channel所连接的文件大小。下面是一个简单的例子:
long fileSize = channel.size();
FileChannel Truncate(文件截断)
你可以通过调用FileChannel.truncate()方法来截断一个文件。当你截断一个文件时,你将它截断在一个给定的长度。下面是一个例子:
channel.truncate(1024);
这个例子将文件截断为 1024 字节的长度。
FileChannel Force(写入硬盘)
FileChannel.force()方法会将Channel中所有未写入的数据刷新到磁盘上。操作系统可能会出于性能考虑在内存中缓存数据,所以在调用force()方法之前,你不能保证写入Channel的数据真的被写入磁盘。
force()方法采用一个布尔值作为参数,告诉你文件元数据(权限等)是否也应该被刷新。
下面是一个同时刷新数据和元数据的例子:
channel.force(true);
SocketChannel
SocketChannel是一个连接到 TCP network socket 的 Channel。它相当于 Socket。有两种方式可以创建一个SocketChannel:
- 你打开一个
SocketChannel,然后连接到互联网上的某个服务器。 - 当一个传入的连接到达
ServerSocketChannel时,可以创建一个SocketChannel。
Opening a SocketChannel(打开一个 SocketChannel)
下面是如何打开一个SocketChannel:
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80))。
Closing a SocketChannel(关闭一个 SocketChannel)
在使用SocketChannel结束,你可以通过调用SocketChannel.close()方法来关闭它。例如:
socketChannel.close();
Reading from a SocketChannel(从 SocketChannel 读取数据)
要从一个SocketChannel中读取数据,你可以调用其中一个read()方法。下面是一个例子:
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
首先分配一个Buffer。从SocketChannel中读取的数据将被读入Buffer中。
其次调用SocketChannel.read()方法。该方法将SocketChannel中的数据读入Buffer中。read()方法返回的int表示有多少字节被读到Buffer中。如果返回 -1,则表示数据流结束(连接被关闭)。
Writing to a SocketChannel(写入数据到 SocketChannel)
向SocketChannel写入数据是通过SocketChannel.write()方法完成的,该方法使用一个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-loop 中调用的。write()方法向SocketChannel写入多少字节是没有保证的。因此,我们重复write()方法的调用,直到Buffer没有更多的字节要写。
Non-blocking Mode(非阻塞模式)
你可以将一个SocketChannel设置为非阻塞模式。当你这样做时,你可以在异步模式下调用connect()、read()和write()。
connect()
如果SocketChannel处于非阻塞模式,并且你调用connect(),那么该方法可能会在连接建立之前返回。要确定连接是否建立,你可以调用finishConnect()方法,就像这样:
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80))。
while(!socketChannel.finishConnect()) {
//等待,或者做其他事情......。
}
write()
在非阻塞模式下,write()方法可能会在没有写任何东西的情况下返回。因此,你需要在循环中调用write()方法。但是,由于在前面的写法示例中已经做了,所以这里不需要做任何不同的事情。
read()
在非阻塞模式下,read()方法可能会在没有读取任何数据的情况下返回。因此你需要注意返回的int,它告诉你有多少字节被读取。
使用 Selector 的 Non-blocking Mode
SocketChannel的非阻塞模式与Selector一起使用效果更好。通过将一个或多个SocketChannel注册到一个选择器上,你可以向Selector索取准备好的Channel进行读写等操作。如何组合Selector与SocketChannel一起使用,在后文中会有更详细的解释。
ServerSocketChannel
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()。
//对socketChannel做一些事情...。
}
Opening a ServerSocketChannel(打开一个 ServerSocketChannel)
您可以通过调用 ServerSocketChannel.open()方法打开 ServerSocketChannel。如下:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()。
Closing a ServerSocketChannel(关闭一个 ServerSocketChannel)
关闭 ServerSocketChannel是通过调用 ServerSocketChannel.close()方法来完成的。如下:
serverSocketChannel.close()。
Listening for Incoming Connections(监听接入连接)
通过调用ServerSocketChannel.accept()方法来监听接入的连接。当accept()方法返回时,它将返回一个带有接入连接的SocketChannel。因此,accept()方法会阻塞,直到一个传入的连接到达。
服务端通常会接入多个连接,所以再 while-loop 里面调用accept()。如下:
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept()。
//对socketChannel做一些事情...。
}
当然,你会在 while-loop 里面使用一些其他的停止标准,而不是true。
Non-blocking Mode(非阻塞模式)
ServerSocketChannel可以被设置为非阻塞模式。在非阻塞模式下,accept()方法会立即返回,因此,如果没有传入的连接到达,则可能返回null。因此,您必须检查返回的SocketChannel是否为空。如下:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999))。
serverSocketChannel.configureBlocking(false);
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept()。
if(socketChannel != null){ }。
//对socketChannel做一些事情... ...
}
}
Non-blocking Server(非阻塞服务器)
即使你了解 NIO 非阻塞功能的工作原理(Selector、Channel、Buffer等),设计一个非阻塞服务器仍然是很难的。与阻塞 IO 相比,非阻塞 IO 包含了几个难点。本非阻塞服务器教程将讨论非阻塞服务器的主要难点,并描述一些潜在的解决方案。
本教程中描述的想法是围绕 NIO 设计的。然而,我相信这些想法可以在其他语言中重用,只要它们有某种类似于Selector的结构。据我所知,这样的构造是由底层操作系统提供的,所以你很有可能在其他语言中也能使用这些构造。
Non-blocking Server - GitHub Repository(非阻塞服务器 - GitHub 仓库)
我已经为本教程中提出的想法创建了一个简单的概念验证,并将其放在GitHub仓库中供大家查看。这里是GitHub仓库。
Non-blocking IO Pipelines(非阻塞式 IO 管道)
非阻塞 IO 管道是一个处理非阻塞 IO 的组件链。这包括以非阻塞方式读和写 IO。下面是一个简化的非阻塞 IO 流水线的说明:
一个组件使用Selector来检查一个Channel是否有数据要读取。然后组件读取输入数据,并根据输入生成一些输出。输出的数据又被写入到一个Channel中。
一个非阻塞 IO 流水线不需要同时读取和写入数据。有些流水线可能只读数据,有些流水线可能只写数据。
上图只显示了一个组件。一个非阻塞 IO 管道可能有多个组件处理传入数据。非阻塞 IO 流水线的长度取决于流水线需要做什么。
一个非阻塞 IO 流水线也可能同时从多个Channel读取数据。例如,从多个SocketChannels读取数据。
上图中的控制流也被简化了。是通过Selector发起从Channel读取数据的组件。而不是由Channel将数据推送到Selector中,然后再从那里将数据推送到组件中,即使这就是上图所暗示的。
Non-blocking vs. Blocking IO Pipelines(非阻塞 VS 阻塞 IO 管道)
非阻塞和阻塞 IO 管道最大的区别在于如何从底层Channel(socket或文件)读取数据。
IO 管道通常从一些流(从socket或文件)中读取数据,并将这些数据分割成一致的消息。这类似于将数据流分解成标记,以便使用标记器进行解析。相反,你将数据流分解成更大的消息。我将把数据流分解成消息的组件称为 Message Reader。下面是一个 Message Reader 将数据流分解成消息的示例:
阻塞 IO 管道可以使用类似InputStream的接口,每次可以从底层Channel中读取一个字节,类似InputStream的接口会阻塞,直到有数据可以读取。这就形成了一个阻塞式的消息读取器的实现。
将阻塞 IO 接口用于流,简化了 Message Reader 的实现。一个阻塞的 Message Reader 永远不需要处理没有从流中读取数据的情况,或者只从流中读取了部分消息而需要稍后恢复消息解析的情况。
同样,阻塞消息写入器(向流写入消息的组件)永远不必处理只有部分消息被写入,以及稍后需要恢复消息写入的情况。
Blocking IO Pipeline Drawbacks(阻塞 IO 管道的缺点)
虽然阻塞式的 Message Reader 比较容易实现,但它有一个不幸的缺点,那就是需要为每个需要分割成消息的流单独设置一个线程。之所以要这样做,是因为每个流的 IO 接口都会阻塞,直到有一些数据要从它那里读取。也就是说,一个线程不能尝试从一个流中读取数据,如果没有数据,就从另一个流中读取。只要一个线程试图从一个流中读取数据,该线程就会阻塞,直到真的有一些数据可以读取。
如果 IO 管道是服务器的一部分,必须处理大量的并发连接,那么服务器每一个活动的传入连接需要一个线程。如果服务器在任何时候只有几百个并发连接,这可能不是问题。但是,如果服务器有几百万个并发连接,这种类型的设计就不能很好地扩展。每个线程将占用 320K(32 位 JVM)到 1024K (64 位 JVM)之间的内存作为堆栈。所以,1,000,000个线程将占用 1 TB 内存! 而这还没等服务器使用任何内存来处理传入的消息(例如:为消息处理过程中使用的对象分配的内存)。
为了减少线程数量,许多服务器采用了这样的设计:服务器保留一个线程池(例如 100 个),每次从入站连接中读取消息。入站连接被保存在一个队列中,线程按照入站连接被放入队列的顺序处理每个入站连接的消息。这里以这种设计为例进行说明:
但是,这种设计要求入站连接合理频繁地发送数据。如果入站连接可能较长时间不活动,大量的不活动连接实际上可能会阻塞线程池中的所有线程。这意味着服务器的响应速度会变得很慢,甚至是无响应。
一些服务器设计试图通过线程池中的线程数量有一定的弹性来缓解这个问题。例如,如果线程池的线程用完了,线程池可能会启动更多的线程来处理负载。这种解决方案意味着需要更多的慢速连接来使服务器无响应。但请记住,你可以运行的线程数量还是有上限的。所以,在 1,000,000 个慢连接的情况下,这个方案无法很好的扩展。
Basic Non-blocking IO Pipeline Design(基本的非阻塞 IO 管道设计)
非阻塞 IO 管道可以使用一个线程从多个流中读取消息。这就要求可以将流切换到非阻塞模式。当处于非阻塞模式时,当你试图从流中读取数据时,流可能会返回 0 个或更多字节。如果流没有数据可读,则返回 0 个字节。当流确实有一些数据要读取时,将返回 1+ 字节。
为了避免检查有 0 字节读取的流,我们使用一个 NIO Selector。一个或多个SelectableChannel实例可以用一个Selector注册。当你在Selector上调用select()或selectNow()时,它只给你提供实际有数据可读的SelectableChannel实例。这里以这种设计为例进行说明:
Reading Partial Messages(读取部分信息)
当我们从SelectableChannel中读取一个数据块时,我们不知道这个数据块所包含的消息多少。一个数据块可能包含一个部分消息(小于一个消息),一个完整的消息,或者大于一个消息,例如 1.5 或 2.5 个消息。这里说明了各种部分消息的可能性:
在处理部分消息时有两个难点:
- 检测数据块中是否有完整的消息。
- 在其余消息到达之前,如何处理部分消息。
检测完整信息需要信息读取器查看数据块中的数据,看看数据是否包含至少一条完整信息。如果数据块中包含一个或多个完整消息,这些消息就可以被发送到管道中进行处理。寻找完整消息的过程会重复很多,所以这个过程必须尽可能快。
每当一个数据块中存在一个部分消息,无论是其本身还是在一个或多个完整消息之后,这个部分消息都需要被存储,直到该消息的其余部分从Channel到达。
检测完整报文和存储部分报文都是 Message Reader 的责任。为了避免混合来自不同Channel实例的消息数据,我们将在每个Channel使用一个 Message Reader。设计是这样的:
在获取到有数据要从Selector中读取的Channel实例,与该Channel相关联的 Message Reader 读取数据并试图将其分解成消息。如果这导致任何完整的消息被读取,这些消息可以通过读取管道传递给任何需要处理它们的组件。
当然,Message Reader 是特定于协议的。一个 Message Reader 需要知道它试图读取的消息的消息格式。如果我们的服务器实现可以跨协议重用,它需要能够插入 Message Reader 的实现——可能通过接受 Message Reader 工厂作为配置参数。
Storing Partial Messages(存储部分消息)
现在我们已经确定了 Message Reader 的责任是存储部分消息,直到收到完整的消息,我们需要弄清楚应该如何实现这种部分消息的存储。
有两个设计上的考虑,我们应该考虑到:
- 我们希望尽可能少地复制消息数据。复制越多,性能越低。
- 我们希望完整的消息以连续的字节序列存储,以便于解析消息。
A Buffer Per Message Reader(每个 Message Reader 的缓冲区)
显然,部分消息需要存储在某种缓冲区中。直接的实现就是在每个 Message Reader 内部有一个缓冲区。然而,这个缓冲区应该有多大呢?它需要足够大,以便能够存储最大的允许消息。所以,如果最大的允许消息是 1 MB,那么每个 Message Reader 的内部缓冲区至少需要 1 MB。
当我们达到数百万个连接时,使用每个连接的 1 MB 并不真正有效。1.000.000 x 1 MB 仍然是 1 TB 的内存! 如果最大的消息大小是 16 MB 呢?或者是 128 MB?
Resizable Buffers(可调整大小的 Buffer)
另一个选择是实现一个可调整大小的缓冲区,用于每个 Message Reader 内部。一个可调整大小的缓冲区将从小的开始,如果一个消息对缓冲区来说太大了,缓冲区将被扩大。这样每个连接就不一定需要 1 MB 的缓冲区。每个连接只需要占用它们需要的内存来容纳下一条消息。
有几种方法可以实现一个可调整大小的缓冲区。所有这些方法都有优缺点,所以我将在下面的章节中讨论它们。
Resize by Copy(通过复制调整大小)
实现可调整大小的缓冲区的第一个方法是用一个小的缓冲区,例如 4 KB。如果一个消息不能放入 4 KB 的缓冲区,可以分配一个较大的缓冲区,例如 8 KB,然后将 4 KB 缓冲区的数据复制到较大的缓冲区。
resize-by-copy 缓冲区的优点是,消息的所有数据都被保存在一个连续的字节数组中。这使得解析消息更加容易。
resize-by-copy 缓冲区实现的缺点是,对于较大的消息,它会导致大量的数据复制。
为了减少数据复制,你可以分析流经你系统的消息的大小,找到一些可以减少复制量的 缓冲区大小。例如,你可能会看到大多数消息都小于 4 KB,因为它们只包含非常小的请求/响应。这意味着第一个缓冲区大小应该是 4 KB。
然后你可能会看到,如果一个消息大于 4 KB,通常是因为它包含一个文件。然后你可能会注意到,大部分流经系统的文件都小于 128 KB。那么将第二个 缓冲区大小定为 128 KB就很有意义了。
最后你可能会发现,一旦消息超过了 128 KB,消息的大小就没有真正的规律可言了,所以也许最后的 缓冲区大小应该只是最大的消息大小。
根据流经你系统的消息大小,有了这 3 个 缓冲区大小,你会在一定程度上减少数据复制。低于 4 KB 的消息将永远不会被复制。对于 1,000,000 个并发连接来说,结果是1,000,000 × 4 KB = 4 GB,这在今天(2015 年)的大多数服务器中是可能的。4 KB 和 128 KB 之间的消息将被复制一次,只有 4 KB 的数据需要复制到 128 KB 的缓冲区。128 KB 和最大消息大小之间的消息将被复制两次。第一次会复制 4 KB,第二次会复制 128 KB,所以最大的信息总共要复制 132 KB。假设没有那么多超过 128 KB 的信息,这可能是可以接受的。
当一个消息被完全处理后,分配的内存应该再次被释放。这样从同一个连接接收到的下一个消息就会再次以最小的缓冲区大小开始。这对于确保内存在连接之间更有效的共享是必要的。很可能不是所有的连接都同时需要大的缓冲区。
Resize by Append(通过追加调整大小)
另一种调整缓冲区大小的方法是使缓冲区由多个数组组成。当你需要调整缓冲区的大小时,你只需分配另一个字节数组并将数据写入该数组。
有两种方法可以增长这样一个缓冲区。一种方法是分配单独的字节数组,并保留这些字节数组的列表。另一种方法是分配一个更大的、共享的字节数组的片断,然后保留一个分配给缓冲区的片断列表。我个人觉得切片的方法稍微好一点,但差别不大。
通过追加单独的数组或分片来增长缓冲区的好处是,在写入过程中不需要复制数据。所有的数据都可以直接从套接字(Channel)直接复制到数组或分片中。
这种方式增长缓冲区的缺点是数据不是存储在一个单一的、连续的数组中。这就增加了消息解析的难度,因为解析器需要同时寻找每个单独数组的结束和所有数组的结束。由于你需要在写入的数据中寻找消息的结尾,所以这种模型不太容易操作。
TLV Encoded Messages(TLV 编码的消息)
一些协议消息格式使用 TLV 格式(类型、长度、值)进行编码。这意味着,当一个消息到达时,消息的总长度被存储在消息的开头。这样,你就能立即知道要为整个消息分配多少内存。
TLV 编码使内存管理变得更加容易。你可以立即知道要为消息分配多少内存。在一个只使用了一部分的缓冲区结束时,不会浪费内存。
TLV 编码的一个缺点是,在消息的所有数据到达之前,你就为消息分配了所有的内存。一些慢速的连接发送大的消息会因此分配掉你所有的可用内存,使你的服务器无法响应。
解决这个问题的一个变通方法是使用一个消息格式,里面包含多个TLV 字段。这样,内存是为每个字段分配的,而不是为整个消息分配的,内存只在字段到达时分配。不过,一个大字段对你的内存管理的影响还是和一个大消息一样。
另一个变通的方法是超时,例如 10-15 秒内没有收到的消息。这可以使你的服务器从许多大消息的巧合、同时到达中恢复过来,但它仍然会使服务器在一段时间内不响应。此外,故意的 DoS(拒绝服务)攻击仍然会导致你的服务器内存的全部分配。
TLV 编码存在不同的变化。究竟使用多少字节,所以指定字段的类型和长度取决于每个单独的 TLV 编码。也有一些 TLV 编码把字段的长度放在第一位,然后是类型,然后是值(一种 LTV 编码)。虽然字段的顺序不同,但仍然是 TLV 的一种变化。
事实上, TLV 编码使内存管理更容易,这也是HTTP 1.1协议如此糟糕的原因之一。这也是他们在HTTP 2.0中试图解决的问题之一,在HTTP 2.0中,数据是以 LTV 编码的帧来传输的。这也是为什么我们为 VStack.co 项目设计了自己的网络协议,使用 TLV 编码。
Writing Partial Messages(写入部分消息)
在非阻塞 IO 管道中,写入数据也是一个挑战。当你在非阻塞模式下对一个Channel调用write(ByteBuffer)时,无法保证ByteBuffer中的字节有多少被写入。write(ByteBuffer)方法会返回被写入的字节数,因此可以跟踪写入的字节数。这就是我们面临的挑战:跟踪部分写入的消息,以便最后所有消息的字节都被发送。
为了管理部分消息的写入通道,我们将创建一个Message Writer。就像 Message Reader 一样,我们需要为每个Channel创建一个 Message Writer。在每个 Message Write r中,我们跟踪当前正在写入的消息的确切字节数。
如果一个 Message Writer 收到的消息超过了它可以直接写到Channel的数量,那么这些消息就需要在 Message Writer 内部排队。然后,Message Writer 会以最快的速度将消息写到Channel上。
下面是一张图,显示了目前部分消息写入的设计方式:
为了让 Message Writer 能够继续发送之前只发送了一部分的消息,Message Writer 需要时常被调用,这样它才能发送更多的数据。
如果你有很多连接,你会有很多 Message Writer 实例。检查例如一百万个 Message Writer 实例以查看它们是否能写入任何数据是很慢的。首先,许多 Message Writer 实例没有任何消息可以发送。我们不想检查这些 Message Writer 实例。其次,不是所有的Channel实例都可以写数据。我们不想浪费时间去写数据到一个不能接受任何数据的通道。
要检查一个Channel是否已经准备好写数据,你可以用一个Selector注册这个Channel。然而,我们不希望用选择器注册所有的Channel实例。想象一下,如果你有 1,000,000 个连接,其中大部分是空闲的,而所有的 1,000,000 个连接都被注册到Selector中。那么,当你调用select()时,这些Channel实例中的大部分都会是可写的(它们大部分都是空闲的,还记得吗)。然后,你将不得不检查所有这些连接的 Message Writer 来查看它们是否有任何数据要写。
为了避免检查所有的 Message Writer 实例是否有消息,以及所有的Channel实例是否有任何消息要发送给它们,我们使用这个两步法。
当一个消息被写入一个 Message Writer 时,Message Writer 会在Selector注册它的相关Channel(如果它还没有被注册)。
当你的服务器有时间时,它会检查Selector,看看哪些注册的Channel实例已经准备好写了。对于每个已准备好写入的Channel,它的相关 Message Writer 被要求向Channel写入数据。如果一个 Message Writer 把所有的消息都写到了它的Channel上,那么这个Channel就会再次从Selector中被取消注册。
这个小的两步方法确保了只有那些有消息要写入的Channel实例才会被实际注册到选择器中。
Putting it All Together(把它整合在一起)
正如你所看到的,一个非阻塞服务器需要不时地检查传入的数据,以查看是否有任何新的完整消息收到。服务器可能需要检查多次,直到收到一个或多个完整的消息。检查一次是不够的。
同样,一个非阻塞服务器需要不时检查是否有任何数据要写入。如果有,服务器需要检查是否有任何一个相应的连接已经准备好将该数据写入它们。只在消息第一次排队时检查是不够的,因为消息可能会被写入一部分。
总而言之,一个非阻塞服务器最终需要定期执行三个 "管道":
- 读取管道,检查从开放的连接中是否有新的数据传入。
- 进程流水线,它处理任何收到的完整消息。
- 写入流水线,它检查是否可以向任何一个开放的连接写入任何传出的消息。
这三个管道在一个循环中反复执行。你或许可以对执行进行一定的优化。例如,如果没有消息排队,你可以跳过写流水线。或者,如果我们没有收到新的、完整的消息,也许你可以跳过进程管道。
下图说明了完整的服务器循环:
如果你还是觉得有点复杂,记得查看GitHub仓库。
也许看到实际操作中的代码会帮助你理解如何实现这个功能。
Server Thread Model(服务器线程模型)
GitHub 仓库中的非阻塞服务器实现使用了 2 个线程的线程模型。第一个线程接受来自ServerSocketChannel的传入连接。第二个线程处理接受的连接,也就是读取消息,处理消息,并将响应写回连接。这里说明了这个 2 线程模型:
上一节解释的服务器处理循环由处理线程执行。
DatagramChannel
DatagramChannel是一个可以发送和接收 UDP 数据包的Channel。由于 UDP 是一个无连接的网络协议,你不能像其他通道一样,默认情况下对DatagramChannel进行读写。相反,你可以发送和接收数据包。
Opening a DatagramChannel(打开一个数据报通道)
下面是如何打开一个DatagramChannel:
DatagramChannel channel = DatagramChannel.open();
channel.socket().bind(new InetSocketAddress(9999));
这个例子打开了一个DatagramChannel,它可以在 UDP 端口 9999 上接收数据包。
Receiving Data(接收数据)
你可以通过调用DatagramChannel的receive()方法来接收数据,就像这样:
ByteBuffer buf = ByteBuffer. allocate(48);
buf.clear();
channel.receive(buf)。
receive()方法将把接收到的数据包的内容复制到给定的缓冲区中。如果接收到的数据包中的数据超过了缓冲区所能容纳的范围,那么剩余的数据将被丢弃。
Sending Data(发送数据)
你可以通过DatagramChannel调用它的send()方法来发送数据,像这样:
String newData = "要写入文件的新字符串..."
+ System.currentTimeMillis()。
ByteBuffer buf = ByteBuffer. allocate(48);
buf.clear();
buf.put(newData.getBytes())。
buf.flip()。
int bytesSent = channel.send(buf, new InetSocketAddress("jenkov.com", 80))。
这个例子将字符串发送到 UDP 端口 80 上的 jenkov.com 服务器。但该端口上没有任何东西在监听,所以不会发生任何事情。您不会被告知是否收到了发送数据包,因为 UDP 不对数据的传输做任何保证。
Connecting to a Specific Address(连接到一个特定的地址)
可以将一个DatagramChannel“连接”到网络上的一个特定地址。由于 UDP 是无连接的,这种连接到地址的方式并没有创建一个真正的连接,就像 TCP 通道一样。相反,它锁定了你的DatagramChannel,所以你只能从一个特定的地址发送和接收数据包。
下面是一个例子:
channel.connect(new InetSocketAddress("jenkov.com", 80))。
当连接后,你也可以使用read()和write()方法,就像你使用传统的Channel一样。你只是对发送数据的交付没有任何保证。这里有几个例子:
int bytesRead = channel.read(buf);
int bytesWritten = channel.write(buf);
Pipe(管道)
Pipe 是两个线程之间的单向数据连接。一个Pipe有一个 source channel 和一个 sink channel。您将数据写入 sink channel。然后可以从 source channel 通道读取这些数据。
这里是Pipe原理的一个说明:
Creating a Pipe(创建一个 Pipe)
您可以通过调用Pipe.open()方法打开一个管道。下面是它的样子。
Pipe pipe = Pipe.open();
Writing to a Pipe(写入一个 Pipe)
要向Pipe写入数据,您需要访问 sink channel。如下:
Pipe.SinkChannel sinkChannel = pipe.sink();
你通过调用SinkChannel的write()方法来写入它,就像这样。
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()) {
sinkChannel.write(buf)。
}
Reading from a Pipe(从 Pipe 中读取)
要从Pipe中读取数据,您需要访问 source channel。如下:
Pipe.SourceChannel sourceChannel = pipe.source();
要从 source channel 读取,你可以像这样调用它的read()方法:
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf)。
read()方法返回的int告诉我们有多少字节被读入Buffer。
NIO vs. IO
在研究 NIO 和 IO API的时候,一个问题很快就会出现在脑海中。
什么时候应该使用 IO,什么时候应该使用 NIO?
在本文中,我将尝试阐明 NIO 和 IO 之间的差异,它们的使用案例,以及它们如何影响你的代码设计。
Main Differences Betwen Java NIO and IO(Java NIO与IO的主要区别)
下表总结了Java NIO和IO的主要区别。我将在表格后面的章节中详细介绍每一个区别。
| IO | NIO |
|---|---|
| 面向流的 | 缓冲区的 |
| 阻塞IO | 非阻塞IO |
Stream Oriented vs. Buffer Oriented(面向流 vs. 面向缓冲区)
NIO 和 IO 的第一个大区别是,IO 是面向流的,而 NIO 是面向缓冲区的。那么,这意味着什么呢?
IO 是面向流的,意味着你每次从一个流中读取一个或多个字节。你如何处理这些读取的字节是由你决定的。它们不会被缓存在任何地方。此外,你不能在数据流中来回移动。如果你需要从一个流中读取的数据来回移动,你需要先把它缓存在一个缓冲区中。
NIO 的面向缓冲区的方法略有不同。数据被读取到一个缓冲区中,之后再从缓冲区中进行处理。你可以根据需要在缓冲区中来回移动。这让你在处理过程中更加灵活。但是,你也需要检查缓冲区是否包含了你所需要的所有数据,以便完全处理它。而且,你需要确保在向缓冲区中读取更多数据时,不会覆盖缓冲区中尚未处理的数据。
Blocking vs. Non-blocking IO(阻塞 vs. 非阻塞 IO)
IO 的各种流是阻塞的。也就是说,当一个线程调用read()或write()时,该线程会被阻塞,直到有一些数据要读取,或者数据被完全写入。在此期间,该线程不能做任何其他事情。
NIO 的非阻塞模式使线程能够请求从通道中读取数据,并且只得到当前可用的数据,如果当前没有数据,则什么也得不到。在数据变得可用来读取之前,线程不是一直被阻塞,而是可以继续做其他事情。
对于非阻塞的写入也是如此。一个线程可以请求将一些数据写入一个通道,但不需要等待数据被完全写入。然后,该线程可以在这段时间里继续做其他事情。
当线程在 IO 调用中没有被阻塞时,线程的空闲时间是用来做什么的,通常是在这段时间内对其他通道进行 IO。也就是说,现在一个线程可以管理多个通道的输入和输出。
Selectors(选择器)
NIO 的Selector允许一个线程监视多个输入通道。你可以用一个选择器注册多个通道,然后用一个线程来 "选择 "那些有输入可供处理的通道,或者选择那些准备写的通道。这种选择器机制使得单线程可以轻松管理多个通道。
How NIO and IO Influences Application Design(NIO 和 IO 如何影响应用设计?)
选择 NIO 还是 IO 作为您的 IO 工具包可能会影响您的应用设计的以下方面:
- 对 NIO 或 IO 类的 API 调用。
- 数据的处理。
- 用于处理数据的线程数量。
The API Calls(API 调用)
当然,使用 NIO 时的 API 调用与使用 IO 时的 API 调用看起来是不同的。这并不奇怪。数据必须首先被读入一个缓冲区,然后再从那里进行处理,而不是仅仅从InputStream中一个字节一个字节地读取数据。
The Processing of Data(数据的处理)
当使用纯 NIO 设计与 IO 设计时,数据的处理也会受到影响。
在 IO 设计中,你从一个InputStream或Reader中逐字节读取数据。想象一下,你在处理一个基于行的文本数据流。例如,你要处理的是一个基于行的文本数据流:
Name: Anna
Age: 25
Email: anna@mailserver.com
Phone: 1234567890
这个文本行流可以这样处理:
InputStream input = ... ; // 从客户端套接字中获取InputStream。
BufferedReader reader = new BufferedReader(new InputStreamReader(input));
String nameLine = reader.readLine();
String ageLine = reader.readLine();
String emailLine = reader.readLine();
String phoneLine = reader.readLine()。
注意处理状态是如何由程序执行到什么程度决定的。换句话说,一旦第一个reader.readLine()方法返回,你就可以确定已经读取了一整行文本。readLine()会一直阻塞,直到读完一行完整的文字,这就是原因。你也知道这一行包含了名字。同样,当第二个readLine()调用返回时,你也知道这一行包含了年龄等。
正如你所看到的,只有当有新的数据要读取时,程序才会进步,对于每一步,你都知道这些数据是什么。一旦执行线程已经进展过了读取代码中的某条数据,线程就不会在数据中倒退了(大多不会)。这张图也说明了这个原理:
NIO 的实现会显得不同。这里是一个简化的例子:
ByteBuffer buffer = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buffer)。
注意第二行,它从通道中读取字节到ByteBuffer中。当该方法调用返回时,你不知道你需要的数据是否都在缓冲区内。你只知道缓冲区包含了一些字节。这使得处理变得有些困难。
想象一下,如果在第一次read(buffer)调用后,读到缓冲区里的都是半行的内容。例如:“Name: An”。你能处理这些数据吗?不可以。你需要等到至少有一整行的数据进入缓冲区后,才有意义处理任何数据。
那么你怎么知道缓冲区是否有足够的数据来处理呢?嗯,你不知道。唯一的办法就是查看缓冲区中的数据。结果是,你可能要检查缓冲区中的数据好几次才知道是否所有的数据都在里面。这样既效率低下,又会使程序设计变得混乱。例如
ByteBuffer buffer = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buffer);
while(!bufferFull(bytesRead) ) {
bytesRead = inChannel.read(buffer);
}
bufferFull()方法必须跟踪有多少数据被读入缓冲区,并根据缓冲区是否已满,返回true或false。换句话说,如果缓冲区已经准备好进行处理,就认为它已经满了。
bufferFull()方法会扫描缓冲区,但必须让缓冲区处于调用bufferFull()方法之前的状态。否则,下一个读入缓冲区的数据可能不会在正确的位置读入。这不是不可能的,但这又是一个需要注意的问题。
如果缓冲区是满的,就可以进行处理。如果它没有满,你可能可以部分处理任何数据,如果这在你的特殊情况下是有意义的。在很多情况下,这是不可能的。
is-data-in-buffer-ready 循环在下图中得到了说明:
总结
NIO 允许你只用一个(或几个)线程来管理多个通道(网络连接或文件),但代价是解析数据可能比从阻塞流中读取数据时要复杂一些。
如果你需要同时管理成千上万个开放的连接,而每个连接只发送一点数据,例如一个聊天服务器,在 NIO 中实现服务器可能是一个优势。同样,如果你需要保持大量的与其他计算机的开放连接,例如在 P2P 网络中,使用一个线程来管理所有的出站连接可能是一个优势。这种一个线程,多个连接的设计在这张图中得到了说明:
如果你的连接数较少,带宽非常高,一次发送大量数据,也许经典的IO服务器实现可能是最合适的。这张图说明了一个经典的IO服务器设计: