5.1 Java网络通信
5.1.1 传统BIO编程
通信的本质其实就是I/O,Java的网络编程主要涉及的内容是Socket编程,其他还有多线程编程、协议栈等相关知识。在JDK 1.4推出Java NIO之前,基于Java的所有Socket通信都采用同步阻塞模式(BIO),类似于一问一答模式。客户端发起一次请求,同步等待调用结果的返回。同步阻塞模式易于调试且容易理解,但是存在严重的性能问题。
传统的同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功后,双方通过输入和输出流进行同步阻塞式通信。服务端提供IP和监听端口,客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
这里简单地描述一下BIO的服务端通信模型。采用BIO通信模型的服务端,通常由一个独立的Acceptor(消费者)线程负责监听客户端的连接,它接收到客户端连接请求之后,为每个客户端创建一个新的线程进行链路处理。处理完成后,通过输出流返回应答给客户端,线程销毁,即典型的一请求一应答通信模型,具体原理如图5-1所示。
图5-1 传统BIO通信模型
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,Java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死掉了。下面我们开发一个简单的Socket通信实例,具体步骤如下:
开发Socket客户端,具体代码如下所示:
创建一个Socket流套接字并将其连接到指定主机上的指定端口号。
socket.getInputStream()用于获取此套接字的输入流,并包装为BufferedReader对象。
socket.getOutputStream()用于获取此套接字的输出流,并包装为PrintWriter对象。
out.println(expression)用于往服务端写数据。
in.readLine()用于获取服务端返回的数据。
结束后关闭相关的流。
开发Socket服务端,具体代码如下所示:
new ServerSocket(port)通过构造函数创建ServerSocket,如果端口合法且空闲,服务端就监听成功。
while(true)通过无限循环监听客户端连接,服务器调用ServerSocket类的accept()方法,该方法将一直等待,直到客户端连接到服务器上给定的端口。
当有新的客户端接入时,创建一个新的线程处理这条Socket链路。
服务器关闭时,关闭套接字。
ServerHandler类用于在服务端接收到客户端的请求后,创建新的线程执行任务,具体代码如下所示。
获取此套接字的输入流,并包装为BufferedReader对象。
获取此套接字的输出流,并包装为PrintWriter对象。
通过BufferedReader读取一行,如果已经读到输入流尾部,就返回null,退出循环。如果得到非空值,就尝试计算结果并返回。
计算客户端传递字符串,这里简单处理,全部返回123。
将结果写入输出流,返回给客户端。
清理相关的资源。
开发测试类BioTest,具体代码如下所示:
启动线程,运行服务器。
主线程sleep 100毫秒,避免客户端在服务器启动前执行代码。
启动线程,运行客户端。
在无限循环中,随机产生算术表达式,发送算术表达式字符串到服务端。
线程sleep 1秒。
假设存在这么一个场景,由于网络延迟,导致数据发送缓慢,由于使用的是阻塞IO,read方法一直处于阻塞状态,要等到数据传送完成才结束(返回-1)。在这种情况且高并发的场景下,直接导致线程暴增,服务器宕机。
5.1.2 伪异步I/O编程
我们可以使用线程池来管理这些线程,实现一个或多个线程处理N个客户端的模型,但是底层还是使用同步阻塞I/O,通常被称为“伪异步I/O模型”,具体如图5-2所示。
图5-2 伪异步IO通信模型
我们知道,如果使用CachedThreadPool线程池,除了能自动帮我们管理线程(复用)外,看起来就像是1:1的客户端线程数模型,而使用FixedThreadPool可以有效地控制线程的最大数量,保证系统有限资源的控制,实现N:M的伪异步I/O模型。但是,正因为限制了线程数量,如果发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中有空闲的线程可以被复用。
当对Socket的输入流进行读取操作的时候,它会一直阻塞,直到发生如下3种事件:
(1)有数据可读。
(2)可用数据已经读取完毕。
(3)发生空指针或I/O异常。
所以在读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息只能一直等待,这就是最大的弊端。而后面即将介绍的NIO就能解决这个难题。下面我们来改造代码。
Executors.newFixedThreadPool(60)用于创建线程池,线程数量固定为60个。
通过构造函数创建ServerSocket,如果端口合法且空闲,服务端就监听成功。
while(true)通过无限循环监听客户端连接,服务器调用ServerSocket类的accept()方法,该方法将一直等待,直到客户端连接到服务器上给定的端口。
当有新的客户端接入时,从线程池中获取一个新的线程处理这条Socket链路。
服务器关闭时,关闭套接字。
5.1.3 NIO编程
少量的线程如何同时为大量连接服务呢?答案就是就绪选择。这就好比到餐厅吃饭,每来一桌客人,就有一个服务员专门服务,从你进餐厅到最后结账走人。这种方式的好处是服务质量好,一对一的VIP服务,可是缺点也很明显,成本高。如果餐厅生意好,同时来100桌客人,就需要100个服务员,老板发工资的时候得心痛死了。这就是传统的一个连接一个线程的方式。
老板是什么人,精着呢。老板得捉摸怎么能用10个服务员同时为100桌客人服务。老板发现,服务员在为客人服务的过程中并不是一直都忙着。客人点完菜,上完菜,吃着的这段时间,服务员就闲下来了。可是这个服务员还是被这桌客人占用着,不能为别的客人服务。怎么把这段闲着的时间利用起来呢?餐厅老板就想了一个办法,让一个服务员(前台)专门负责收集客人的需求,登记下来。比如有客人进来、点菜、结账,都先记录下来按顺序排好。每个服务员到这里领一个需求。比如点菜,服务员拿着菜单帮客人点菜去了。客人点好菜以后,服务员马上回来,领取下一个需求,继续为别的客人服务。这种服务方式质量不如一对一的服务,当客人需求很多的时候就需要等待。但好处也很明显,由于客人吃饭时服务员不用闲着,因此服务员这段时间内可以为其他客人服务。原来10个服务员最多同时为10桌客人服务,现在可以同时为50桌客人服务。
这种服务方式跟传统服务的区别有两个:
(1)增加了一个角色:专门负责收集客人需求的人。NIO里对应的就是Selector。
(2)由阻塞服务变为非阻塞服务,客人吃着的时候服务员不用一直候在客人旁边。传统的IO操作,比如read(),当没有数据可读的时候,线程一直阻塞被占用,直到有数据到来。NIO中没有数据可读时,read()会立即返回0,线程不会阻塞。
NIO工作原理如图5-3所示。
NIO中客户端创建一个连接后,先要将连接注册到Selector。相当于客人进入餐厅后,告诉前台你要用餐。前台会告诉你,你的桌号是几号。然后你就可以到那张桌子坐下了,SelectionKey就是桌号。当某一桌需要服务时,前台就记录那一桌需要什么服务。比如1号桌要点菜、2号桌要结账,服务员从前台取一条记录,根据记录提供服务,服务完了再来取下一条需求。这样服务的时间就被有效地利用起来了。
图5-3 NIO通信模型
Java NIO和IO的主要区别如下:
1. 面向流与面向缓冲
Java NIO和IO之间最大的区别是,IO是面向流的,NIO是面向缓冲区的。Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,Java IO不能前后移动流中的数据。如果需要前后移动从流中读取的数据,就需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查该缓冲区中是否包含所有你需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
2. 阻塞与非阻塞IO
Java IO的各种流是阻塞的。这意味着,当一个线程调用read()或write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO的非阻塞模式使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用,就什么都不会获取,而不是保持线程阻塞。所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写数据也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞IO的空闲时间用在其他通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(Channel)。
无论选择IO或NIO工具箱,都可能会影响应用程序设计的以下几个方面:
(1)对NIO或IO类的API调用。
(2)数据处理。
(3)用来处理数据的线程数。
Java NIO是一个可以替代标准Java IO API的新IO,提供了与标准IO不同的工作方式。Java NIO由3个核心部分组成:Channel、Buffer和Selector。
虽然Java NIO还有很多其他类和组件,但Channel、Buffer和Selector构成了核心的API。其他组件如Pipe和FileLock,只不过是与3个核心组件共同使用的工具类。因此,我们主要将精力集中在这3个组件上。
1. Channel(通道)
Channel是对数据的源头和数据目标点流经途径的抽象,在这个意义上和InputStream、OutputStream类似。Channel可以译为“通道”或者“管道”,而传输中的数据仿佛就像是在其中流淌的水。前面也提到了Buffer,Buffer和Channel相互配合使用,才是Java的NIO。
Java NIO的通道与流的区别是:①既可以从通道中读取数据,又可以写数据到通道,但流的读写通常是单向的;②通道可以异步地读写;③通道中的数据总是先读到一个Buffer,或者总是从一个Buffer中写入。
我们对数据的读取和写入要通过Channel。它就像水管一样,通道不同于流的地方就是通道是双向的,可以用于读、写和同时读写操作。数据可以从Channel读到Buffer中,也可以从Buffer写到Channel中,具体如图5-4所示。
图5-4 Channel与Buffer交互
从广义上来说,通道可以被分为两类:File I/O和Stream I/O,也就是文件通道和套接字通道。若分得更细致一点,则是:
- FileChannel:从文件读写数据。
- SocketChannel:通过TCP读写网络数据。
- ServerSocketChannel:可以监听新进来的TCP连接,并对每个连接创建对应的SocketChannel。
- DatagramChannel:通过UDP读写网络中的数据Pipe。
(1)打开FileChannel/SocketChannel
在使用FileChannel之前,必须先打开它。但是,我们无法直接打开一个FileChannel,需要通过InputStream、OutputStream或RandomAccessFile来获取一个FileChannel实例。下面是通过RandomAccessFile打开FileChannel的实例:
Java NIO中的SocketChannel是一个连接到TCP网络套接字的通道。可以通过以下两种方式创建SocketChannel:
- 打开一个SocketChannel并连接到互联网上的某台服务器。
- 新连接到达ServerSocketChannel时,会创建一个SocketChannel。
下面是SocketChannel的打开方式:
SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress("http://ay.com",80));
(2)从FileChannel读取数据
调用read()方法,从FileChannel中读取数据,例如:
ByteBuffer buf = ByteBuffer.allocate(48); int bytesRead = inChannel.read(buf);
首先,分配一个Buffer,从FileChannel中读取数据到Buffer中。然后,调用FileChannel.read()方法将数据从FileChannel读取到Buffer中。read()方法返回的int值表示有多少字节被读到了Buffer中。如果返回-1,就表示到了文件末尾。
(3)向FileChannel/SocketChannel写数据
使用FileChannel.write()方法向FileChannel写数据,该方法的参数是一个Buffer,例如:
注 意
FileChannel.write()是在while循环中调用的。因为无法保证write()方法一次能向FileChannel写入多少字节,所以需要重复调用write()方法,直到Buffer中已经没有尚未写入通道的字节。
(4)关闭FileChannel
用完FileChannel后必须将其关闭,例如:
channel.close();
(5)FileChannel的position方法
有时可能需要在FileChannel的某个特定位置进行数据的读/写操作。可以通过调用position()方法获取FileChannel的当前位置,也可以通过调用position(long pos)方法设置FileChannel的当前位置,例如:
long pos = channel.position(); channel.position(pos +123);
如果将位置设置在文件结束符之后,然后试图从文件通道中读取数据,读方法就会返回-1(文件结束标志)。如果将位置设置在文件结束符之后,然后向通道中写数据,文件就会传递到当前位置并写入数据。这可能导致“文件洞”,即磁盘上物理文件中写入的数据间有空隙。
(6)FileChannel的size方法
FileChannel实例的size()方法将返回该实例所关联文件的大小,例如:
long fileSize = channel.size();
(7)FileChannel的truncate方法
可以使用FileChannel.truncate()方法截取一个文件。截取文件时,文件将从指定长度后面的部分删除,例如:
### 截取文件的前1024字节 channel.truncate(1024);
(8)FileChannel的force方法
FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法。force()方法有一个Boolean类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上。下面的例子同时将文件数据和元数据强制写到磁盘上:
channel.force(true);
(9)FileChannel的transferFrom()方法
FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中(注:这个方法在JDK文档中的解释为将字节从给定的可读取字节通道传输到此通道的文件中)。下面是一个简单的例子:
//在使用FileChannel之前,必须先打开它。但是,我们无法直接打开一个FileChannel,需要通过 //使用InputStream、OutputStream或RandomAccessFile来获取一个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() toChannel.transferFrom(position, count, fromChannel);
transferFrom方法的输入参数position表示从position处开始向目标文件写入数据,count表示最多传输的字节数。如果源通道的剩余空间小于count字节,所传输的字节数就小于请求的字节数。
此外要注意,在SoketChannel的实现中,SocketChannel只会传输此刻准备好的数据(可能不足count字节)。因此,SocketChannel可能不会将请求的所有数据(count字节)全部传输到FileChannel中。
(10)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() fromChannel.transferTo(position, count, toChannel);
有没有发现这个例子和上一个例子特别相似?除了调用方法的FileChannel对象不一样外,其他的都一样。
上面介绍的关于SocketChannel的问题在transferTo()方法中同样存在。SocketChannel会一直传输数据直到目标Buffer被填满。
最后,我们再看一个Channel的简单实例:
2. Buffer(缓冲区)
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便地访问这块内存。使用Buffer读写数据一般遵循以下4个步骤:
(1)写入数据到Buffer。
(2)调用flip()方法。
(3)从Buffer中读取数据。
(4)调用clear()方法或者compact()方法。
当向Buffer写入数据时,Buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入Buffer的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()方法和调用compact()方法。clear()方法会清空整个缓冲区;compact()方法只会清除已经读过的数据,任何未读的数据都会被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。下面看具体实例:
(1)Buffer的3个属性
为了理解Buffer的工作原理,需要熟悉它的3个属性:
- capacity:作为一个内存块,Buffer有一个固定大小的值,也叫“capacity”。只能往里写capacity个byte、long、char等类型的数据。一旦Buffer满了,需要将其清空(通过读数据或者清除数据),才能继续往里写数据。
- 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读写模式的简单原理如图5-5所示。
图5-5 Buffer读写模式的简单原理
(2)Buffer的类型
Java NIO有8种Buffer类型,分别是ByteBuffer、MappedByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer。
(3)Buffer的分配
要想获得一个Buffer对象,首先要进行分配。每一个Buffer类都有一个allocate方法。下面是一个分配48字节capacity的ByteBuffer的例子。
ByteBuffer buf = ByteBuffer.allocate(48);
这是分配一个可存储1024个字符的CharBuffer:
CharBuffer buf = CharBuffer.allocate(1024);
(4)Buffer写数据
写数据到Buffer有两种方式:①从Channel写到Buffer;②通过Buffer的put()方法写到Buffer中。
从Channel写到Buffer,例如:
//read into buffer int bytesRead = inChannel.read(buf);
通过put方法写Buffer,例如:
buf.put(127);
put方法有很多版本,允许以不同的方式把数据写入Buffer中。例如,写到一个指定的位置,或者把一个字节数组写入Buffer。
(5)Buffer的flip()方法
flip()方法将Buffer从写模式切换到读模式。调用flip()方法会将position重置为0,并将limit设置成之前position的值。换句话说,position现在用于标记读的位置,limit之前表示能写进多少个byte、char,现在表示能读取多少个byte、char。
(6)Buffer中读取数据
从Buffer中读取数据有两种方式:
- 从Buffer读取数据到Channel。
- 使用get()方法从Buffer中读取数据。从Buffer读取数据到Channel的例子:
//read from buffer into channel. int bytesWritten = inChannel.write(buf);
使用get()方法从Buffer中读取数据的例子:
byte aByte = buf.get();
get方法有很多版本,允许以不同的方式从Buffer中读取数据。例如,从指定position读取,或者从Buffer中读取数据到字节数组。
(7)Buffer的rewind()方法
Buffer.rewind()将position重置为0,所以可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。
(8)Buffer的clear()与compact()方法
一旦读完Buffer中的数据,需要让Buffer准备好再次被写入,可以通过clear()或compact()方法来完成。如果调用的是clear()方法,position就会被重置为0,limit被设置成capacity的值。换句话说,Buffer被清空了。如果Buffer中有一些未读的数据,就调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先写些数据,那么调用compact()方法。compact()方法将所有未读的数据复制到Buffer起始处,然后将position设到最后一个未读元素后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。
(9)Buffer的mark()与reset()方法
通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position,例如:
buffer.mark(); //set position back to mark. buffer.reset();
(10)Buffer的equals()与compareTo()方法
可以使用equals()和compareTo()方法对比两个Buffer。当满足下列条件时,表示两个Buffer相等:
- 有相同的类型(byte、char、int等)。
- Buffer中剩余的byte、char等类型的数据个数相等。
- Buffer中所有剩余的byte、char等类型的数据都相同。
如你所见,equals只是比较Buffer的一部分,不是每一个在Buffer里面的元素都比较。实际上,它只比较Buffer中的剩余元素。
compareTo()方法比较两个Buffer的剩余元素(byte、char等),如果满足下列条件,就认为一个Buffer“小于”另一个Buffer:
- 第一个不相等的元素小于另一个Buffer中对应的元素。
- 第一个Buffer的元素个数比另一个少。
3. Selector(选择器)
Java NIO引入了选择器的概念,选择器用于监听多个通道的事件(比如连接打开、数据到达)。Selector提供选择已经就绪的任务的能力。Selector会不断轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey获取就绪Channel的集合,进行后续的I/O操作。一个Selector可以同时轮询多个Channel,因为JDK使用了epoll()代替传统的select实现,没有最大连接句柄1024/2048的限制,所以只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端。
要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞直到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子:新连接进来、数据接收等。
(1)Selector的创建
通过调用Selector.open()方法创建一个Selector,例如:
Selector selector = Selector.open();
(2)Selector注册通道
为了将Channel和Selector配合使用,必须将Channel注册到Selector上。通过SelectableChannel.register()方法来实现,例如:
channel.configureBlocking(false); SelectionKey key = channel.register(selector, Selectionkey.OP_READ);
与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式,而套接字通道可以。注意register()方法的第二个参数,这是一个“interest集合”,意思是通过Selector监听Channel时对什么事件感兴趣。可以监听4种不同类型的事件:Connect、Accept、Read和Write。
通道触发了一个事件,意思是该事件已经就绪。所以,某个Channel成功连接到另一个服务器称为“连接就绪”。一个Server Socket Channel准备好接收新进入的连接称为“接收就绪”。一个有数据可读的通道可以说是“读就绪”。等待写数据的通道可以说是“写就绪”。这4种事件用SelectionKey的4个常量来表示:SelectionKey.OP_CONNECT、SelectionKey.OP_ACCEPT、SelectionKey.OP_READ和SelectionKey.OP_WRITE。
如果对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,例如:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
(3)SelectionKey
当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。这个对象包含一些有用的属性:interest集合、ready集合、Channel、Selector和附加的对象(可选)。下面我们来简单学习这些属性。
- interest集合
就像Selector注册通道中所描述的,interest集合是你所选择的感兴趣的事件集合。可以通过SelectionKey读写interest集合,例如:
int interestSet = selectionKey.interestOps(); boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT; boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT; boolean isInterestedInRead = interestSet & SelectionKey.OP_READ; boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;
可以看到,用“位与”操作interest集合和给定的SelectionKey常量,可以确定某个确定的事件是否在interest集合中。
- ready集合
ready集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,首先访问readySet,可以这样访问ready集合:
int readySet = selectionKey.readyOps();
可以用像检测interest集合那样的方法来检测Channel中什么事件或操作已经就绪,也可以使用以下4种方法,它们都会返回一个布尔类型:
selectionKey.isAcceptable(); selectionKey.isConnectable(); selectionKey.isReadable(); selectionKey.isWritable();
- Channel+Selector
从SelectionKey访问Channel和Selector很简单,例如:
Channel channel = selectionKey.channel(); Selector selector = selectionKey.selector();
- 附加的对象
可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便地识别某个给定的通道。例如,可以附加与通道一起使用的Buffer,或者包含聚集数据的某个对象。使用方法如下:
selectionKey.attach(theObject); Object attachedObj = selectionKey.attachment();
还可以用register()方法向Selector注册Channel的时候附加对象,例如:
selectionKey.attach(theObject); Object attachedObj = selectionKey.attachment();
还可以用register()方法向Selector注册Channel的时候附加对象,例如:
(4)通过Selector选择通道
一旦向Selector注册了一个或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“读就绪”的通道感兴趣,select()方法会返回读事件已经就绪的那些通道。
下面介绍select()方法:
- int select()
- int select(long timeout)
- int selectNow()
select()阻塞到至少有一个通道在注册的事件上就绪为止。select(long timeout)和select()一样,除了最长会阻塞timeout毫秒(参数)外。selectNow()不会阻塞,无论什么通道就绪都立刻返回。
select()方法返回的int值表示有多少通道已经就绪,即自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,所以返回了1,再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的通道没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之前,只有一个通道就绪了。
(5)selectedKeys()方法
一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,可以通过调用Selector的selectedKeys()方法访问“已选择键集(Selected Key Set)”中的就绪通道,如下所示:
Set selectedKeys = selector.selectedKeys();
当向Selector注册通道(Channel)时,Channel.register()方法会返回一个SelectionKey对象。这个对象代表注册到该Selector的通道。可以通过SelectionKey的selectedKeySet()方法访问这些对象。可以遍历这个已选择的键集合来访问就绪的通道,代码如下:
这个循环遍历已选择键集中的每个键,并检测各个键所对应的通道就绪事件。注意每次迭代末尾的keyIterator.remove()调用。Selector不会自己从已选择键集中移除SelectionKey实例,必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。SelectionKey.channel()方法返回的通道需要转型成要处理的类型,如ServerSocketChannel或SocketChannel等。
(6)Selector.wakeup()方法
某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其他线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可,阻塞在select()方法上的线程会立即返回。如果有其他线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下一个调用select()方法的线程会立即“醒来(wake up)”。
(7)Selector.close()方法
用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。
下面看一个完整的实例。打开一个Selector,注册一个通道到这个Selector上(通道的初始化过程略去),然后持续监控这个Selector的4种事件(接受、连接、读、写)是否就绪。
下面我们再来看一个完整的例子,巩固刚刚所学的知识。
获取ServerSocket通道,设置为非阻塞方式,绑定端口。
获取通道管理器。
将通道管理器与通道绑定,并为该通道注册SelectionKey.OP_ACCEPT事件,只有当该事件到达时,Selector.select()才会返回,否则一直阻塞。
在与客户端连接成功后,为客户端通道注册SelectionKey.OP_READ事件。
向客户端发消息。
当有可读事件时,获取客户端传输数据可读取消息通道,创建读取数据缓冲器,读取数据。
获取Socket通道,并设置为非阻塞方式。
获得通道管理器。
为该通道注册SelectionKey.OP_CONNECT事件。
连接成功后,注册接收服务器消息的事件。
向服务器发送消息。
当有可读数据事件时,获取客户端传输数据可读取消息通道,创建读取数据的缓存区,大小为10字节,读取数据。