NIO主要包括两个部分:
java.nio.channels包介绍了Selector和Channel抽象,
java.nio包介绍了Buffer抽象
5.1为什么需要NIO(单线程实现Socket多线程的效果)
考虑一个在客户端之间传递消息的即时消息服务器(Instant Messaging)。客户端必须不停地连接服务器以接收即时消息,因此线程池的大小限制了系统可以同时服务的客户端总数。如果增加线程池的大小,将带来更多的线程处理开销,而不能提升系统的性能,因为在大部分的时间里客户端是处于闲置状态的
大多数的服务器有一些信息(称为"状态")需要由不同的客户端同时访问或修改.
这些限制就要求在所有客户之间共享一些状态信息(即调度表)。这需要通过使用锁(locks)机制或其他互斥机制对依次访问状态进行严格的同步(synchronized)
使用同步机制将增加更多的系统调度和上下文切换开销,而程序员对这些开销又无法控制
我们需要一种方法来一次轮询一组客户端,以查找哪个客户端需要服务。这正是NIO中将要介绍的Selector和Channel抽象的关键点
一个Channel实例代表了一个"可轮询的(pollable)"I/O目标,如套接字(或一个文件、设备等)。Channel能够注册一个Selector类的实例.Selector的select()方法允许你询问"在一组信道中,哪一个当前需要服务(即,被接受,读或写)?"
selector和channel为一次处理多个客户端的系统开销提供了更高级的控制和可预测性,Buffer则提供了比Stream抽象更高效和可预测的I/O。
。Buffer抽象代表了一个有限容量(finite-capacity)的数据容器--其本质是一个数组,由指针指示了在哪存放数据和从哪读取数据
Buffer对比Stream的好处
与读写缓冲区数据相关联的系统开销暴露给了程序员
一些对Java对象的特殊Buffer映射操作能够直接操作底层平台的资源(例如,操作系统的缓冲区)。这些操作节省了在不同地址空间中复制数据的开销
5.2 与Buffer一起使用Channel
Channel实例代表了一个与设备的连接,通过它可以进行输入输出操作(类似SOCKET,不同点是Sokcet是new对象,Channel是通过静态方法 open())
对于TCP协议,可以使用ServerSocketChannel和SocketChannel
Channel使用的不是流,而是缓冲区来发送或读取数据, Buffer类或其任何子类的实例都可以看作是一个定长的Java基本数据类型元素序列
与流不同,缓冲区有固定的、有限的容量,并由内部(但可以被访问)状态记录了有多少数据放入或取出
Buffer是一个抽象类,只能通过创建它的子类来获得Buffer实例,而每个子类都设计为用来容纳一种Java基本数据类型(boolean除外)
在channel中使用Buffer实例通常不是使用构造函数创建的,而是通过调用allocate()方法创建指定容量的Buffer实例或者通过包装一个已有的数组来创建
ByteBuffer buffer = ByteBuffer.allocate(length)
ByteBuffer buffer = ByteBuffer.wrap(byteArray)
NIO的强大功能部分来自于channel的非阻塞特性
NIO的channel抽象的一个重要特征就是可以通过配置它的阻塞行为,以实现非阻塞式的信道
传统SOCKET在调用一个方法之前无法知道其是否会阻塞
在非阻塞式信道上调用一个方法总是会立即返回。这种调用的返回值指示了所请求的操作完成的程度
5.3Selector
Selector类可用于避免使用非阻塞式客户端中很浪费资源的"忙等"方法
NIO的选择器就实现了这样的功能。一个Selector实例可以同时检查(如果需要,也可以等待)一组信道的I/O状态。用专业术语来说,选择器就是一个多路开关选择器,因为一个选择器能够管理多个信道上的I/O操作。
5.4Buffer详解
NIO,数据的读写操作始终是与缓冲区相关联的.Channel将数据读入缓冲区,然后我们又从缓冲区访问数据. 缓冲区只是一个列表,它的所有元素都是基本数据类型(通常为字节型)
缓冲区是定长的,它不像一些类那样可以扩展容量.
注意,ByteBuffer是最常用的缓冲区,因为:1)它提供了读写其他数据类型的方法,2)信道的读写方法只接收ByteBuffer
5.4.1Buffer索引
缓冲区不仅仅是用来存放一组元素的列表。在读写数据时,它有内部状态来跟踪缓冲区的当前位置,以及有效可读数据的结束位置
position和limit之间的距离指示了可读取/存入的字节数。Java中提供了两个方便的方法来计算这个距离
Boolean hasRemaining()
Int remaining()返回剩余元素个数
0 ≤ mark ≤ position ≤ limit ≤ capacity
5.4.2创建Buffer
通常使用分配空间或包装一个现有的基本类型数组来创建缓冲区。
要分配一个新的实例,只需要简单地调用想要创建的缓冲区类型的allocate()静态方法,并指定元素的总数
一般操作系统必须使用自己的缓冲区来进行I/O,并将结果复制到缓冲区的后援数组中。这些复制过程可能非常耗费系统资源,尤其是在有很多读写需求的时候,Java的NIO提供了一种直接缓冲区*(direct buffers)来解决这个问题.
。使用直接缓冲区,Java将从平台能够直接进行I/O操作的存储空间中为缓冲区分配后援存储空间,从而省略了数据的复制过程。这种低层的、本地的I/O通常在字节层进行操作,因此只能为 ByteBuffer进行直接缓冲区分配。
使用直接缓冲区注意:(用于频繁读写的场合)
1.要知道调用allocateDirect()方法并不能保证能成功分配直接缓冲区--有的平台或JVM可能不支持这个操作,因此在尝试分配直接缓冲区后必须调用isDirect()方法进行检查
2.要知道分配和销毁直接缓冲区通常比分配和销毁非直接缓冲区要消耗更多的系统资源,因为直接缓冲区的后援存储空间通常存在与JVM之外,对它的管理需要与操作系统进行交互。所以,只有当需要在很多I/O操作上长时间使用时,才分配直接缓冲区
5.4.3存储和接收数据
作为数据的"容器",缓冲区既可用来输入也可用来输出(不同于流,流只能往一个方向传递数据)
使用put()方法可以将数据放入缓冲区,使用get()方法则可以从缓冲区获取数据。信道(Channel)的read()方法隐式调用了给定缓冲区的put(),而其write()方法则隐式调用了缓冲区的get()方法
每次调用put()方法,都是在缓冲区中的已有元素后面追加数据,每次调用get()方法,都是读取缓冲区的后续元素
5.4.4准备Buffer: clear(), flip(),和rewind()
在使用缓冲区进行输入输出数据之前,必须确定缓冲区的position,limit都已经设置了正确的值
Clear()不会改变缓冲区中的数据,而只是简单地重置了缓冲区的主要索引值。
将数据read()/put()进缓冲区前调用clear() ,读之前调用clear,写之前调用flip
flip()(将数据从缓冲区中write()/get()之前调用flip())方法用来将缓冲区准备为数据传出状态,这通过将limit设置为position的当前值,再将 position的值设为0来实现:
后续的get()/write()调用将从缓冲区的第一个元素开始检索数据,直到到达limit指示的位置
Rewind()方法将position设置为0,并使mark值无效.类似flip(),只是limit的值没变
典型情景(当你想要将在网络上发送的所有数据都写入日志时就会用到)
从缓冲区write()/get()时
5.4.5 压缩Buffer中的数据
compact()方法将 position与limit之间的元素复制到缓冲区的开始位置,从而为后续的 put()/read()调用让出空间. position的值将设置为要复制的数据的长度,limit的值将设置为capacity,mark则变成未定义
在调用write()方法后和添加新数据的read()方法前调用compact()方法,则将所有"剩余"的数据移动到缓冲区的开头,从而为释放最大的空间来存放新数据。
5.4.6Buffer透视:duplicate(),slice()
新缓冲区(一个与给定缓冲区共享内容的缓冲区)有自己独立的状态变量(position,limit,capacity和mark),但与原始缓冲区共享了同一个后援存储空间。任何对新缓冲区内容的修改都将反映到原始缓冲区上。可以将新缓冲区看作是从另一个角度对同一数据的透视
duplicate()方法用于创建一个与原始缓冲区共享内容的新缓冲区。新缓冲区的position,limit,mark和capacity都初始化为原始缓冲区的索引值,然而,它们的这些值是相互独立的。
由于共享了内容,对原始缓冲区或任何复本所做的改变在所有复本上都可见。
如果使用了缓冲区复制操作,向网络写数据和写日志就可以在不同的线程中并行进行。
slice()方法用于创建一个共享了原始缓冲区子序列的新缓冲区。新缓冲区的position值是0,而其limit和capacity的值都等于原始缓冲区的limit和position的差值。slice()方法将新缓冲区数组的offset值设置为原始缓冲区的position值,然而,在新缓冲区上调用array()方法还是会返回整个数组。
Channel在读写数据时只以ByteBuffer为参数,然而我们可能还对使用其他基本类型的数据进行通信感兴趣。ByteBuffer能够创建一种独立的"视图缓冲区(view buffer)",用于将ByteBuffer的内容解释成其他基本类型(如CharBuffer)
5.4.7字符编码
字符是由字节序列进行编码的,而且在字节序列与字符集合之间有各种映射(称为字符集)方式
NIO缓冲区的另一个用途是在各种字符集之间进行转换,CharsetEncoder和CharsetDecoder类
5.5.流(TCP)信道详解
客户端SocketChannel是相互连接的终端进行通信的信道
读操作的最基本形式以一个ByteBuffer为参数,并将读取的数据填入该缓冲区所有的剩余字节空间中
服务器端:ServerSocketChannel是用来监听客户端连接的信道
阻塞式信道除了能够(必须)与Buffer一起使用外,对于普通套接字来说几乎没有优点。因此,可能总是需要将信道设置成非阻塞式的。
通过调用configureBlocking(false)可以将SocketChannel或ServerSocketChannel设置为非阻塞模式
可以使用open()方法的无参数形式,配置信道为非阻塞模式,再调用connect()方法,指定远程终端地址。如果在没有阻塞的情况下连接已经建立,connect()方法返回true;否则需要有检查套接字是否连接成功的方法。
5.6 Selector详解
调用Selector的open()工厂方法可以创建一个选择器实例。选择器的状态是"打开"或"关闭"的
5.6.1在信道中注册
每个选择器都有一组与之关联的信道,选择器对这些信道上"感兴趣的"I/O操作进行监听。Selector与Channel之间的关联由一个SelectionKey实例表示
任何对key(信道)所关联的兴趣操作集的改变,都只在下次调用了select()方法后才会生效。
interestOps(int value)通过位运算 修改兴趣集合
.
ServerSocketChannel来说,accept是惟一的有效操作,而对于SocketChannel来说,有效操作包括读、写和连接
5.6.2选取和识别准备就绪的信道
Selector.select()方法用于从已经注册的信道中返回在感兴趣的I/O操作集上准备就绪的信道总数
通过调用selectedKeys()方法可以访问已选键集,该方法返回一组SelectionKey。我们可以在这组键上进行迭代,分别处理等待在每个键关联的信道上的I/O操作。
Key()方法返回的键集不可修改
SelectedKeys()可以修改
5.6.3信道附件
当一个信道准备好进行I/O操作时,通常还需要额外的信息来处理请求
SelectionKey通过使用附件使保存每个信道的状态变得容易
SelectKey:查找准备就绪的I/O操作
Object attach(Object ob)
Object attachment()
每个键可以有一个附件,数据类型只能是Object类。附件可以在信道第一次调用register()方法时与之关联,或者后来再使用attach()方法直接添加到键上。通过SelectionKey的attachment()方法可以访问键的附件。
5.6.4Selector小结
只有非阻塞模式的信道才能与选择器进行注册
总的来说,使用Selector的步骤如下:
I.创建一个Selector实例。
II.将其注册到各种信道,指定每个信道上感兴趣的I/O操作。
III.重复执行:
1.调用一种select方法。
2.获取选取的键列表。
3.对于已选键集中的每个键,
a.获取信道,并从键中获取附件(如果合适的话)
b.确定准备就绪的操作并执行。如果是accept操作,将接受的信道设置为非阻塞模式,并将其与选择器注册。
c.如果需要,修改键的兴趣操作集
d.从已选键集中移除键
6.1缓冲和TCP
在使用TCP套接字时需要记住的最重要的一点是:
不能假设在连接的一端将数据写入输出流和在另一端从输入流读出数据之间有任何一致性。
尤其是在发送端由单个输出流的write()方法传输的数据,可能会通过另一端的多个输入流的read()方法来获取;而一个read()方法可能会返回多个write()方法传输的数据。
我们可以认为TCP连接上发送的所有字节序列在某一瞬间被分成了三个FIFO队列:
1. SendQ:在发送端底层实现中缓存的字节,这些字节已经写入输出流,但还没在接收端主机上成功接收。
2. RecvQ:在接收端底层实现中缓存的字节,等待分配到接收程序--即从输入流中读取。
3. Delivered:接收者从输入流已经读取到的字节。
调用out.write()方法将向SendQ追加字节。TCP协议负责将字节按顺序从SendQ移动到RecvQ。有重要的一点需要明确,这个转移过程无法由用户程序控制或直接观察到,并且在块中(chunks)发生,这些块的大小在一定程度上独立于传递给write()方法的缓冲区大小。
字节从RecvQ移动到Delivered中,转移的块的大小依赖于RecvQ中的数据量和传递给read()方法缓冲区大小.
下次调用read()方法返回的字节数,取决于缓冲区数组的大小,以及发送方套接字/TCP实现通过网络向接收方实现传输数据的时机
6.2死锁风险
例如 在连接建立后,客户端和服务器端都立即尝试接收数据,就会导致死锁
一旦RecvQ已满,TCP流控制机制就会产生作用。它将阻止传输发送端主机的SendQ中的任何数据,直到接收者调用输入流的read()方法后腾出了空间。(使用流控制机制的目的是为了保证发送者不会传输太多数据,而超出了接收系统的处理能力。),容易发生死锁
造成死锁产生的原因是因为客户端在发送数据的同时,没有及时读取反馈回来的数据,从而使数据都阻塞在了底层的传输队列中。
解决方法是在不同的线程中执行客户端的write循环和read循环
或者使用NIO(???)
6.3性能相关
当程序要一次发送比缓冲区容量大很多的数据时才需要考虑程序的数据吞吐量
(通过Socket的setSendBufferSize()和setReceiveBufferSize()方法来改变发送和接收缓冲区的大小)
6.4TCP套接字的生存周期
6.4.1连接
调用Socket的构造函数时,底层实现将创建一个套接字实例,该实例的初始状态是关闭状态(Closed)
TCP的开放握手也称为3次握手(3-way handshake),因为这通常包括3条消息:一条从客户端到服务器端的连接请求,一条从服务器端到客户端的确认消息,以及另一条从客户端到服务器端的确认消息
互联网是一种尽力而为(best-effort)的网络,客户端的起始消息或服务器端的回复消息都可能在传输过程中丢失
TCP协议实现将以递增的时间间隔重复发送几次握手消息。如果TCP客户端在一段时间后还没有收到服务器的回复消息,则发生超时并放弃连接(超时时间一般会有好几分钟)
现在服务器可以调用ServerSocket的accept()方法,该方法将阻塞等待,直到与某个客户端完成了开放握手信息交换,并成功建立了新的连接
在ServerSocket关联的列表中的每个数据结构,都代表了一个与另一端的客户端已经完成建立的TCP连接。实际上,客户端只要接收到了开放握手的第2条消息,就可以立即发送数据--这可能比服务器调用accept()方法为其获取一个Socket实例要早很长时间。
6.4.2关闭TCP连接
流程
说明数据已经发送完毕,TCP实现将留存在SendQ队列中的数据传输出去(考虑另一端RecvQ队列的空间),然后发送一个关闭TCP连接的握手消息(通知不再发送新消息)
Time-Wait状态用于保证每个TCP连接都在一段平静时间内结束,这期间不会有数据发送
Time-Wait状态最重要的作用是,只要底层套接字数据结构还存在,就不允许在相同的本地端口上关联其他套接字。尤其是试图使用该端口创建新的Socket实例时(会包含上一个SOCKET的信息)
6.5解调多路复用揭秘
同一个机器上的不同套接字可以有相同的本地地址和端口号
要确定传入的分组报文应该分配到那个套接字(即,解调多路复用)不仅仅是查看分组报文的目的地址和端口。
分组报文匹配原则:
套接字数据结构中的本地端口号必须与传入的分组报文的目的端口号相匹配。
在套接字数据结构中,任何包含了通配符(*)的字段可以匹配分组报文中相应字段的任何值。
如果有一个以上的套接字数据结构与传入的分组报文地址的四个字段匹配,那么谁使用的通配符少,谁就获得该分组报文。
ServerSocket的远程地址和端口号始终是通配符.