您的位置:首页 > 编程语言

使用NIO的server编程框架

2009-11-23 17:23 429 查看
使用NIO的server编程框架
使用NIO的server编程框架
17.1 NIO简介 GlassFish:开源的Java EE应用服务器
作为Java EE Web层面的最前端,HTTP引擎是负责接收客户请求的最开始的部分,这部分的性能在很大程度上决定了整个Java EE产品的性能和可扩展性。回顾现有的J2EE产品,大部分的HTTP引擎都不是用纯Java编写的。例如,Sun的JES应用服务器内置了一个用本地语言(C/C++)开发Web服务器,JBoss的Web Server也不是纯Java的,它使用了大量与平台相关的运行库,只不过通过Apache的APR项目(http://apr.apache.org)来维护跨平台的特性。而那些纯Java的J2EE服务器,在部署的时候也推荐前置一个其他的Web服务器,例如(Apache、IIS等)。
使用纯Java来构建具有扩展性很好的服务器软件,一直是一个比较困难的事情,特别是在单个的Java虚拟机上(非集群的环境)。这是由Java的线程模型和网络IO的特性所决定的。在JDK 1.4以前,Java的网络IO的接口都是阻塞式的,这意味着网络的阻塞会引起处理线程的停止,因此每个用户请求的处理从开始到最后完成,需要单独的处理线程。而Java的线程资源的分配和线程的调度都是有很大开销的,这使得在大量请求(数千个甚至上万个)同时到达的情况下,单个Java虚拟机很难满足大并发性的需要。为了解决可扩展性的问题,一些解决方案使用了多个Java虚拟机或者多个机器节点进行集群来满足大并发的请求。
JDK 1.4版本(包括之后的版本)最显著的新特性就是增加了NIO(New IO),能够以非阻塞的方式处理网络的请求,这就使得在Java中只需要少量的线程就能处理大量的并发请求了。但是使用NIO不是一件简单的技术,它的一些特点使得编程的模型比原来阻塞的方式更为复杂。
Grizzly作为GlassFish中非常重要的一个项目,就是用NIO的技术来实现应用服务器中的高性能纯Java的HTTP引擎。Grizzly还是一个独立于GlassFish的框架结构,可以单独用来扩展和构建自己的服务器软件。
本章重点:
l NIO的基本特点和编程方式
l Grizzly的基本结构
l Grizzly对NIO技术的运用手段
l Grizzly对性能上的考虑和优化
17.1 NIO简介
理解NIO是学习本章的重要前提,因为Grizzly本身就是基于NIO的框架结构,所有的技术问题都是在NIO的技术上进行讨论的。如果读者对NIO不了解的话,建议首先了解NIO的基本概念。对NIO的介绍和学习指南很多,本章不会对NIO做详细的讲解。下面仅对NIO做一个简单的介绍,并列出与本章内容相关的一些NIO特性。
17.1.1 NIO的基本概念
在JDK 1.4的新特性中,NIO无疑是最显著和鼓舞人心的。NIO的出现事实上意味着Java虚拟机的性能比以前的版本有了较大的飞跃。在以前的JVM的版本中,代码的执行效率不高(在最原始的版本中Java是解释执行的语言),用Java编写的应用程序通常所消耗的主要资源就是CPU,也就是说应用系统的瓶颈是CPU的计算和运行能力。在不断更新的Java虚拟机版本中,通过动态编译技术使得Java代码执行的效率得到大幅度提高,几乎和操作系统的本地语言(例如C/C++)的程序不相上下。在这种情况下,应用系统的性能瓶颈就从CPU转移到IO操作了。尤其是服务器端的应用,大量的网络IO和磁盘IO的操作,使得IO数据等待的延迟成为影响性能的主要因素。NIO的出现使得Java应用程序能够更加紧密地结合操作系统,更加充分地利用操作系统的高级特性,获得高性能的IO操作。
NIO在磁盘IO处理和文件处理上有很多新的特性来提高性能,本文不作详细的解释,而仅仅介绍NIO在处理网络IO方面的新特点,这些特点是理解Grizzly的最基本的概念。
1. 数据缓冲(Buffer)处理
数据缓冲(Buffer)是IO操作的基本元素。其实从本质上来说,无论是磁盘IO还是网络IO,应用程序所作的所有事情就是把数据放到相应的数据缓冲当中去(写操作),或者从相应的数据缓冲中提取数据(读操作)。至于数据缓冲中的数据和IO设备之间的交互,则是操作系统和硬件驱动程序所关心的事情了。因此,数据缓冲在IO操作中具有重要的作用,是操作系统与应用之间的IO桥梁。在NIO的包中,Buffer类是所有类的基础。Buffer类当中定义数据缓冲的基本操作,包括put、get、reset、clear、flip、rewind等,这些基本操作是进行数据输入输出的手段。每一个基本的Java类型(boolean除外)都有相应的Buffer类,例如CharBuffer、IntBuffer、DoubleBuffer、ShortBuffer、LongBuffer、FloatBuffer和ByteBuffer。我们所关心的是ByteBuffer,因为操作系统与应用程序之间的数据通信最原始的类型就是Byte。
“Direct ByteBuffer”是一个值得关注的Buffer类型。在创建ByteBuffer的时候可以使用ByteBuffer.allocateDirect()来创建一块直接(Direct)的ByteBuffer。这一块数据缓冲和一般的缓冲不一样。第一,它是一块连续的空间。第二,它的实现不是纯Java的代码,而是本地代码,它内存的分配不在Java的堆栈中,不受Java内存回收的影响。这种直接的ByteBuffer是NIO用来保证性能的重要手段。刚才提到,数据缓冲是操作系统和应用程序之间的IO接口。应用程序将需要“写出去”的数据放到数据缓冲中,操作系统从这块缓冲中获得数据执行写的操作。当IO设备数据传进来的时候,操作系统就会将数据放到相应的数据缓冲中,应用程序从缓冲中“读进”数据进行处理。一般的Java对象很难胜任这个直接的数据缓冲的工作。因为Java对象所占用的内存空间不一定是连续的,而且经常由于内存回收而改变地址。而操作系统需要的是一片连续的不变动的地址空间,才能完成IO操作。在原来的Java版本中需要Java虚拟机的介入,将数据进行转换、拷贝才能被操作系统所使用。而通过“Direct ByteBuffer”,应用程序能够直接与操作系统进行交流,大大减少了系统调用的次数,提高了执行的效率。
数据缓冲的另外一个重要的特点是可以在一个数据缓冲上再建立一个或多个视图(View)缓冲。这个概念有些类似于数据库视图的概念:在数据库的物理表(Table)结构之上可以建立多个视图。同样,在一个数据缓冲之上也可以建立多个逻辑的视图缓冲。视图缓冲的用处很多,例如可以将Byte类型的缓冲当作Int类型的视图,来进行类型转换。视图缓冲也可以将一个大的缓冲看成是很多小的缓冲视图。这对提高性能很有帮助,因为创建物理的数据缓冲(特别是直接的数据缓冲)是非常耗时的操作,而创建视图却非常快。在Grizzly中就有这方面的考虑。
2. 异步通道(Channel)
Channel(后文又称频道,译法仅暗示存在多通道可选)是NIO的另外一个比较重要的新特点。Channel并不是对原有Java类的扩充和完善,而是完全崭新的实现。通过Channel,Java应用程序能够更好地与操作系统的IO服务结合起来,充分地利用上文提到的ByteBuffer,完成高性能的IO操作。Channel的实现也不是纯Java的,而是和操作系统结合紧密的本地代码。
Channel的一个重要的特点是在网络套接字频道(SocketChannel)中,可以将其设置为异步非阻塞的方式。
【例17.1】非阻塞方式的频道使用:
SocketChannel sc = SocketChannel.open();
sc.configureBlocking(false); // nonblocking
...
if (!sc.isBlocking()) {
doSomething(cs);
}
通过SocketChannel.configureBlocking(false)就可以将网络套接字频道设置为异步非阻塞模式。一旦设置成非阻塞的方式,从Socket中读和写就再也不会阻塞。虽然非阻塞只是一个设置问题,但是对应用程序的结构和性能却产生了天翻地覆的变化。
3. 有条件的选择(Readiness Selection)
熟悉UNIX的程序员对POSIX的select()或poll()函数应该比较熟悉。在现在大多数流行的操作系统中,都支持有条件地选择已经准备好的IO通道,这就使得只需要一个线程就能同时有效地管理多个IO通道。在JDK 1.4以前,Java语言是不具备这个功能的。
NIO通过几个关键的类来实现这种有条件的选择的功能:
(1) Selector
Selector类维护了多个注册的Channel以及它们的状态。Channel需要向Selector注册,Selector负责维护和更新Channel的状态,以表明哪些Channel是准备好的。
(2) SelectableChannel
SelectableChannel是可以被Selector所管理的Channel。FileChannel不属于Selectable- Channel,而SocketChannel是属于这类的Channel。因此在NIO中,只有网络的IO操作才有可能被有条件地选择。
(3) SelectionKey
SelectionKey用于维护Selector和SelectableChannel之间的映射关系。当一个Channel向Selector注册之后,就会返回一个SelectionKey作为注册的凭证。SelectionKey中保存了两类状态值,一是这个Channel中哪些操作是被注册了的,二是有哪些操作是已经准备好的。
17.1.2 NIO之前的Server程序的架构
在NIO出现以前(甚至在NIO出现了很长时间的现在),在用Java编写服务器端的程序时,服务请求的接收模块大多数都会采用以下的框架(例如在Tomcat中的连接接入点:org.apache.tomcat.util.net.PoolTcpEndpoint就有相类似的结构)。
【例17.2】阻塞方式的server编程框架:
class Server implements Runnable {
public void run() {
try {
ServerSocket ss = new ServerSocket(PORT);
while (!Thread.interrupted())
new Thread(new Handler(ss.accept())).start();
} catch (IOException ex) { /* ... */ }
}
static class Handler implements Runnable {
final Socket socket;
Handler(Socket s) { socket = s; }
public void run() {
try {
byte[] input = new byte[MAX_INPUT];
socket.getInputStream().read(input);
byte[] output = process(input);
socket.getOutputStream().write(output);
} catch (IOException ex) { /* ... */ }
}
private byte[] process(byte[] cmd) { /* ... */ }
}
}
上面的结构比较简单:在主线程的run()方法中,会有ServerSocket的accept()方法,它被循环地调用着,直到服务停止。accept()方法会被阻塞,直到新的连接请求的到来。当新的连接请求进来以后,系统会使用另外的线程来处理这个请求。处理线程在socket端口进行read()调用,读取所有的请求数据。read()也是一个阻塞的方法,一直到读取完所有的数据才会返回。数据经过处理以后,在同一个处理线程中将请求结果返回给客户端。在实际情况中,会比这个结构复杂得多,例如,处理线程是从一个线程池中获取,而不是每次都产生一个新的线程。
这种结构在大多数情况下都可以获得很好的性能。例如Tomcat在性能指标的测试中获得了很高的吞吐量测量值。但是在并发性很大的情况下,这种结构不具有很好的可扩展性。例如有2000个客户请求同时到来,如果想要这2000个请求被同时处理,则需要2000个处理线程。这些线程在大多数的情况下可能都不在运行,而是阻塞在read()或write()的方法上了。在一台机器或者一个Java虚拟机上运行上千个线程是个挑战,线程经常会阻塞,因此CPU会在这些线程之间来回调度和切换,这会引起大量的系统调用和资源竞争,使得整个系统的扩展性能不高。
17.1.3 使用NIO来提高系统扩展性
NIO使用非阻塞的API,通过实现少量的线程就能服务于大量的并发用户的请求。并且通过操作系统都支持的POSIX标准的select方式,来获得系统准备就绪的资源。使用这些手段,NIO就能够充分利用每个活动的线程来服务于大量的请求,减少系统资源的浪费。通常来说,一个NIO的服务架构会采用以下的结构。
【例17.3】使用NIO的server编程框架:
public class Server {
public static void main(String[] argv) throws Exception {
ServerSocketChannel serverCh = ServerSocketChannel.open();
Selector selector = Selector.open();
ServerSocket serverSocket = serverCh.socket();
serverSocket.bind(new InetSocketAddress(80));
serverCh.configureBlocking(false);
serverCh.register(selector,SelectionKey.OP_ACCEPT);
while(true){
selector.select();
Iterator it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = (SelectionKey)it.next();
if (key.isAcceptable()) {
ServerSocketChannel server =
(ServerSocketChannel)key.channel();
SocketChannel channel = server.accept();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()) {
readDataFromSocket(key);
}
it.remove();
}
}
}
}
上面的结构比起阻塞式的框架都复杂一些。具体说明如下:
l 通过ServerSocketChannel.open()获得一个Server的Channel对象。
l 通过Selector.open()来获得一个Selector对象。
l 从Server的Channel对象上可以获得一个Server的Socket,并让它在80端口监听。
l 通过ServerSocketChannel.configureBlocking(false)可以将当前的Channel配置成异步非阻塞的方式。如果没有这一步,那么Channel默认的方式跟传统的一样,是阻塞式的。
l 将当前的Channel注册到Selector对象中去,并告诉Selector当前的Channel关心的操作是OP_ACCEPT,也就是当有新的请求的时候,Selector负责更新此Channel的状态。
l 在循环当中调用selector.select(),如果当前没有任何新的请求过来,并且原来的连接也没有新的请求数据到达,这个方法会阻塞住,一直等到新的请求数据过来为止。
l 如果当前都请求的数据到达,那么selector.select()就会立刻退出,这时候可以从selector.selectedKeys()获得所有在当前selector注册过的并且有数据到达的这些Channel的信息(SelectionKey)。
l 遍历所有的这些SelectionKey来获得相关的信息。如果某个SelectionKey的操作是OP_ACCEPT,也就是isAcceptable,那么可以判定这是那个Server Channel,并且是有新的连接请求到达了。
l 当有新的请求来的时候,通过accept()方法可以获得新的channel服务于这个新来的请求。然后通过configureBlocking(false)可以将当前的Channel配置成异步非阻塞的方式。
l 接着将这个新的channel也注册到selector中,并告诉Selector当前的Channel关心的操作是OP_READ,也就是当前Channel有新的数据到达的时候,Selector负责更新此Channel的状态。
l 如果在循环当中发现某个SelectionKey的操作是OP_READ,也就是isReadable,那么可以判定这不是那个Server Channel,而是在循环内部注册的连接Channel,表明当前SelectionKey对应的这个Channel有数据到达了。
l 有数据到达之后的处理方式是下面要详细讨论的问题,在这里,我们简单地用一个方法readDataFromSocket(key)来表示,功能就是从这个Channel中读取数据。
从这个框架结构中可以看到,在一个线程中可以同时服务于多个连接,包括Server的监听服务。在同一个时刻,并不是所有的连接都会有数据到达,因此为每一个连接分配单独的线程没有必要。使用异步非阻塞方式,可以使用很少的线程,通过Select的方式来服务于多个连接请求,效率大大提高。
17.1.4 使用NIO来制作HTTP引擎的最大挑战
程序实例17.3使用了configureBlocking(false)方法来将一个Channel设置成非阻塞式的。如何使用这个非阻塞的特性,请参看下面的方法调用:
count = socketChannel.read(byteBuffer)); //非阻塞的方式
阻塞式的方法调用如下:
count = socket.getInputStream().read(input); //阻塞的方式
阻塞的方式下的read,会一直等到byte[]类型的input被充满,或者InputStream遇到EOF(socket连接被关闭)的时候,这个函数调用才会被返回。而非阻塞的方式,立刻就返回了,当前连接中有多少数据就读多少。正因为有了这种非阻塞的模式,当前的线程在读了某个通道的数据之后,可以接着再读另外一个通道的数据,线程的利用率大大提高。
虽然线程的利用率提高了,却带来了一些其他的挑战。最大的挑战就在于:当一个请求过来的时候,很难判断什么时候所有请求的数据全部读进来了。因为每次非阻塞方式的read都可能只读了一部分数据,甚至什么也没有读到。例如,一个HTTP请求:
HTTP/1.1 206 Partial content
GET http://www.w3.org/pub/WWW/TheProject.html 所有的请求数据都是以文本方式传输。在非阻塞的方式下,每一次对Channel进行读取的数据量大小不可预测,也许第一次读了“HTTP/1.1 206 Partial content”,第二次读取了“GET http://www.w3.org/pub/WWW”,第三次什么也没有读到。到底什么时候能把请求全部读完很难预测,在极端的情况下,也许最后几个字符永远也读不到。在请求没有完全读到以前,一般不进行请求处理,因为请求还不完整。在阻塞的情况下,读取的函数会一直等到请求的数据全部到来并且连接关闭以后才会返回,处理起来比较简单。但是非阻塞的方式就很复杂了。因为工作线程从一个连接读取完准备好的数据之后,又要为另一个连接服务。下次再转到先前连接的时候,以前读取的数据还需要恢复。还需要判断到底所有的请求数据是否都读完,是否可以开始对该请求的处理了。 在本章的后面各节中,我们会看到Grizzly采用了一个有限状态机来解析HTTP请求的header信息,读取其中的content-length数值,以便预先判断什么时候到达请求的末尾。
17.2 Grizzly简介
正如前文所说,用Java技术来编写一个扩展性能很高的服务器软件是件很困难的事情。Java虚拟机的线程管理机制使得纯Java写的HTTP引擎很难响应成千上万的并发用户。正如Tomcat一样,在并发用户数不是很高的情况下能够获得很高的吞吐量,但是在高并发的情况下性能下降很快,变得不太稳定。
在JDK 1.4推出NIO之后,有很多基于NIO的框架出现,利用NIO的新特性,来编写高性能的HTTP引擎。其中以Jean-Francois Arcand的Grizzly最为引人瞩目。Grizzly最早被用于Sun Java System Application Server, Platform Edition 8.1。随后成为开源软件GlassFish的一部分。在今后,Sun Java System Application Server 9.x的Platform Edition和Enterprise Edition都会使用Grizzly作为HTTP引擎。
17.2.1 Grizzly的基本架构
图17-1描述了Grizzly的基本架构。



图17-1 Grizzly的基本架构
Grizzly的基本架构主要包含以下几个方面:Pipeline、SelectorThread和Task。下面分别加以介绍。
1. Pipeline
在com.sun.enterprise.web.connector.grizzly包下,有许多与Pipeline相关的类,例如Pipeline、KeepAlivePipeline、ThreadPoolExecutorPipeline、LinkedListPipeline等。Pipeline是个不太好理解的词汇,其实把这些类叫做ThreadPoolWrapper可能更加合适和容易理解。只要熟悉服务器端的软件,对Thread Pool(线程池)一定不会陌生。线程比起进程来说,消耗的资源要少,共享数据更加简单。因此,现在大多数服务器软件(特别是HTTP服务器)都会采用多线程模式。但是线程的创建和关闭仍然是比较慢的系统服务,聪明的服务器软件设计者会在系统启动的时候,预先创建一些线程,并且将这些线程管理起来,在系统正常运行的时候服务于客户的请求。通过这样的手段,线程不需要在使用的时候临时创建,大大提高了软件的运行速度和效率。对这种线程的管理方法叫做线程池。线程池中的线程需要互相协作,有序地执行客户的请求。一般用于同步线程的结构叫任务队列。客户的请求根据先后顺序被放到了任务队列中,线程池中空闲的线程会从任务队列中获得任务并执行。
Grizzly中的Pipeline实际上封装了一个Thread Pool(线程池)和一个任务队列。Pipeline的主要目的是封装了一个统一的接口,可以让Grizzly根据配置文件任意选择不同算法的线程池,来获得不同的特点和性能。在Grizzly中已经实现了好几种线程池。其中有ThreadPoolExecutorPipeline(基于java.util.concurrent.ThreadPoolExecutor来实现的线程池),还有LinkedListPipeline(使用简单的linklist数据结构管理的线程池)。在早期的Grizzly中还会看到一些其他的实现。经过测试以后,淘汰了一些性能不好的算法,目前只剩下这两种Pipeline了。事实上在大并发用户的测试中,LinkedListPipeline的性能是最好的,因此被设置为默认的选择。在以后的版本中,ThreadPoolExecutorPipeline也可能会消失,只保留性能最好的算法是明智的选择。但是现在还存在两种算法,其主要原因是java.util.concurrent.ThreadPoolExecutor的名声太响,所有的文章和测试都曾经证明过它的高性能。就连Grizzly的作者本身都不相信LinkedListPipeline的性能要比ThreadPoolExecutorPipeline好,只不过当前的测试结果事实如此。因此该作者自己也说,一旦有证据证明ThreadPoolExecutorPipeline的性能又重新超过LinkedListPipeline,他会立即将默认的设置指向ThreadPoolExecutorPipeline。
KeepAlivePipeline是一个特例,它并不是用来执行特定任务的,而是用来维护HTTP协议中的持久连接的状态,例如维护最大的持久连接数,持久连接的timeout时间等。另外,异步的socketChannel中缺少一个类似socket.setSoTimeout的函数,这个函数在保证服务器软件的可靠性和安全性(抗DOS攻击)上,具有重要的作用。Grizzly是用KeepAlivePipeline类来模拟socket.setSoTimeout的作用。
2. SelectorThread
这是Grizzly的主要入口类,位于com.sun.enterprise.web.connector.grizzly的包下。在SelectorThread中,SocketChannel和Selector被创建并被初始化。当网络有请求进来的时候,Selector会根据不同的请求类型和NIO的不同事件进行不同的处理。
当NIO的事件为OP_READ的时候,表明是原有的连接中有新的请求数据传过来了。这类请求属于ReadTask,应该交给负责处理ReadTask的处理器来处理。ReadTask有自己的Pipeline(也就是线程池)来处理,这样就不会占用主线程来处理Read的请求。
当NIO的事件为OP_ACCEPT的时候,表明是有新的请求进来了,这类请求属于AcceptTask,应该交给负责处理AcceptTask的处理器来处理。在老版本的GlassFish中,AcceptTask也有自己的Pipeline来处理,这样就让AcceptTask在主线程以外的线程中执行。但是经过多次性能测试和比较,发现当AcceptTask在主线程(SelectorThread)中执行的时候,性能最好。因此,在读最新的Grizzly源代码的时候,会发现图17-1中的AcceptPipeline根本不存在,因为AcceptTask已经由SelectThread类中HandleAccept函数来执行了。
当ReadTask执行完以后,表明整个请求的数据已经完全接收到,就可以进行请求处理了,请求处理属于ProcessTask,交给负责处理ProcessTask的处理器来处理。ProcessTask有自己的Pipeline(也就是线程池)来处理,这样就不会占用主线程来处理请求。
3. Task
在Grizzly的框架中包含下面几种任务。
(1) AcceptTask:用于响应新的连接请求。前面已经说过,这个任务的类事实上已经不存在,没有单独抽象出来。因为处理Accept已经成为SelectThread内部的一部分了。
(2) ProcessTask:用于处理并且响应请求。这个任务通常是对请求的数据进行解析,解析完后再将请求传递给其他服务的容器(如Servlet容器)进行真正的业务处理。
(3) ReadTask:用于SocketChannel最初的读取操作。由于NIO是非阻塞的操作,最初的读取往往不能获得全部的请求数据,这时候,ReakTask会将任务委托给StreamAlgorithm,根据不同实现,用不同的方法将剩下的请求数据获取。
在com.sun.enterprise.web.connector.grizzly.algorithms的包下,Grizzly默认实现了4个算法:
l ContentLengthAlgorithm
l SeekHeaderAlgorithm
l StateMachineAlgorithm
l NoParsingAlgorithm
前3个算法主要是围绕HTTP请求中的Content-length字段来进行解析。只要能读到这个字段的值,那么我们就可以预先判断整个请求的长度,从而确定什么时候完成请求读取,接着进行请求处理了。第4个算法是对请求数据根本不进行预处理,假设所有的数据都读进来了。如果最后发现请求数据读得不完全,再交给请求处理任务(ProcessTask)来负责将剩下的内容读取过来。
17.2.2 源码阅读指南
根据图17-1的结构,结合Grizzly的源代码,可以看到Grizzly的大致脉络。
SelectorThread是个入口,根据Grizzly所在的不同环境,启动的方法有所不同。如果Grizzly作为单独可运行的应用(Grizzly可以从GlassFish中独立出来),在com.sun.enterprise. web.connector.grizzly.standalone包下的Main类是这样使用SelectorThread的:
【例17.4】单独运行的Grizzly对SelectorThread的调用:
private static void start(String args[]) throws Exception {
...
SelectorThread selectorThread = null;
String selectorThreadClassname = System.getProperty(SELECTOR_THREAD);
if (selectorThreadClassname != null){
selectorThread = loadInstance(selectorThreadClassname);
} else {
selectorThread = new SelectorThread();
}
selectorThread.setPort(port);
StaticResourcesAdapter adapter = new StaticResourcesAdapter();
adapter.setRootFolder(folder);
selectorThread.setAdapter(adapter);
selectorThread.setDisplayConfiguration(true);
selectorThread.initEndpoint();
selectorThread.startEndpoint();
}
如果Grizzly是在GlassFish中,它作为服务线程,run()方法是整个线程启动的钥匙。从源码中很容易看出在run()方法中调用了startEndpoint()方法,startEndpoint()在做好一些准备工作之后,调用了startListener()。startListener()便进入了主线程的循环之中。在循环中只有一个方法,那就是doSelect()方法。
在doSelect()中,可以很清楚地看到NIO的框架结构。
【例17.5】SelectorThread中的doSelect():
selectorState = selector.select(selectorTimeout);
...
readyKeys = selector.selectedKeys();
iterator = readyKeys.iterator();
while (iterator.hasNext()) {
key = iterator.next();
iterator.remove();
if (key.isValid()) {
handleConnection(key);
} else {
cancelKey(key);
}
}
与大多数NIO的架构一样,先是调用selector.select(selectorTimeout),看看当前的频道有没有数据准备好了。如果有的话,通过selector.selectedKeys()将准备好的这些频道的SelectionKey取到。对这些频道的处理就交给handleConnection(key)函数了。
【例17.6】SelectorThread中的handleConnection:
protected void handleConnection(SelectionKey key) throws
IOException,InterruptedException
{
Task task = null;
if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT){
handleAccept(key);
return;
} else if ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ){
task = handleRead(key);
}
if (((SocketChannel)key.channel()).isOpen()) {
task.execute();
} else {
cancelKey(key);
}
}
handleConnection函数很短,但是有一些重要的特点需要指出来。handleConnection的主要功能是区分那些已经准备好的频道,看看它们是属于新的连接(OP_ACCEPT)还是有新的请求数据(OP_READ)。
如果是OP_ACCEPT,那么就调用函数handleAccept(key)。这个函数会在当前的线程内执行,主要的功能就是根据新来的连接创建新的频道,再将这个频道注册到Selector中。如果是OP_READ,那么就调用函数handleRead(key)。这个函数返回了一个Task。通过task.execute()将这个任务的实际运行交给Pipeline中的线程池来执行。换句话说,对新的请求数据的处理是在另外的线程中来处理的,而不是当前的线程。
事实上,在早期的Grizzly的版本中,对OP_ACCEPT的处理与OP_READ一样,也是有单独的任务(AcceptTask)和单独的线程来执行。但是经过性能测试,证明当对OP_ACCEPT的处理在主线程的时候性能最好。因此就取消了AcceptTask在单独线程中的处理,演化为当前的模型。
再随后的工作主要就交给ReadTask和ProcessTask去做了。这里不作详细的介绍。
高性能的HTTP引擎—— Grizzly(三) Grizzly的特点。

对企业级的服务器软件,高性能和可扩展性是基本的要求。除此之外,还应该有应对各种不同环境的能力。例如,一个好的服务器软件不应该假设所有的客户端都有很快的处理能力和很好的网络环境。如果一个客户端的运行速度很慢,或者网络速度很慢,这就意味着整个请求的时间变长。而对于服务器来说,这就意味着这个客户端的请求将占用更长的时间。这个时间的延迟不是由服务器造成的,因此CPU的占用不会增加什么,但是网络连接的时间会增加,处理线程的占用时间也会增加。这就造成了当前处理线程和其他资源得不到很快的释放,无法被其他客户端的请求来重用。例如Tomcat,当存在大量慢速连接的客户端时,线程资源被这些慢速的连接消耗掉,使得服务器不能响应其他的请求了。

前面介绍过,NIO的异步非阻塞的形式,使得很少的线程就能服务于大量的请求。通过Selector的注册功能,可以有选择性地返回已经准备好的频道,这样就不需要为每一个请求分配单独的线程来服务。

  在一些流行的NIO的框架中,都能看到对OP_ACCEPT和OP_READ的处理。很少有对OP_WRITE的处理。我们经常看到的代码就是在请求处理完成后,直接通过下面的代码将结果返回给客户端:
  【例17.7】不对OP_WRITE进行处理的样例:
while (bb.hasRemaining()) {
    int len = socketChannel.write(bb);
    if (len < 0) {
        throw new EOFException();
    }
}

这样写在大多数的情况下都没有什么问题。但是在客户端的网络环境很糟糕的情况下,服务器会遭到很沉重的打击。
  因为如果客户端的网络或者是中间交换机的问题,使得网络传输的效率很低,这时候会出现服务器已经准备好的返回结果无法通过TCP/IP层传输到客户端。这时候在执行上面这段程序的时候就会出现以下情况。
(1) bb.hasRemaining()一直为“true”,因为服务器的返回结果已经准备好了。
(2) socketChannel.write(bb)的结果一直为0,因为由于网络原因数据一直传不过去。
(3)   因为是异步非阻塞的方式,socketChannel.write(bb)不会被阻塞,立刻被返回。
(4)   在一段时间内,这段代**被无休止地快速执行着,消耗着大量的CPU的资源。事实上什么具体的任务也没有做,一直到网络允许当前的数据传送出去为止。
  这样的结果显然不是我们想要的。因此,我们对OP_WRITE也应该加以处理。在NIO中最常用的方法如下。
  【例17.8】一般NIO框架中对OP_WRITE的处理:
while (bb.hasRemaining()) {
    int len = socketChannel.write(bb);
    if (len < 0){
        throw new EOFException();
    }
    if (len == 0) {
        selectionKey.interestOps( selectionKey.interestOps() | SelectionKey.OP_WRITE);
        mainSelector.wakeup();
        break;
    }
}

上面的程序在网络不好的时候,将此频道的OP_WRITE操作注册到Selector上,这样,当网络恢复,频道可以继续将结果数据返回客户端的时候,Selector会通过SelectionKey来通知应用程序,再去执行写的操作。这样就能节约大量的CPU资源,使得服务器能适应各种恶劣的网络环境。
  可是,Grizzly中对OP_WRITE的处理并不是这样的。我们先看看Grizzly的源码吧。在Grizzly中,对请求结果的返回是在 ProcessTask中处理的,经过SocketChannelOutputBuffer的类,最终通过OutputWriter类来完成返回结果的动作。在OutputWriter中处理OP_WRITE的代码如下:
【例17.9】Grizzly中对OP_WRITE的处理:
public static long flushChannel(SocketChannel socketChannel,
        ByteBuffer bb, long writeTimeout) throws IOException
{
    SelectionKey key = null;
    Selector writeSelector = null;
    int attempts = 0;
    int bytesProduced = 0;
    try {
        while (bb.hasRemaining()) {
            int len = socketChannel.write(bb);
            attempts++;
            if (len < 0){
                throw new EOFException();
            }
            bytesProduced += len;
            if (len == 0) {
                if (writeSelector == null){
                    writeSelector = SelectorFactory.getSelector();
                    if (writeSelector == null){
                        // Continue using the main one
                        continue;
                    }
                }
                key = socketChannel.register(writeSelector, key.OP_WRITE);
                if (writeSelector.select(writeTimeout) == 0) {
                    if (attempts > 2)
                        throw new IOException("Client disconnected");
                } else {
                    attempts--;
                }
            } else {
                attempts = 0;
            }
        }
    } finally {
        if (key != null) {
            key.cancel();
            key = null;
        }
        if (writeSelector != null) {
            // Cancel the key.
            writeSelector.selectNow();
            SelectorFactory.returnSelector(writeSelector);
        }
    }
    return bytesProduced;
}
上面的程序例17.9与例17.8的区别之处在于:当发现由于网络情况而导致的发送数据受阻(len==0)时,例17.8的处理是将当前的频道注册到当前的Selector中;而在例17.9中,程序从SelectorFactory中获得了一个临时的Selector。在获得这个临时的Selector之后,程序做了一个阻塞的操作:writeSelector.select(writeTimeout)。这个阻塞操作会在一定时间内(writeTimeout)等待这个频道的发送状态。如果等待时间过长,便认为当前的客户端的连接异常中断了。
  这种实现方式颇受争议。有很多开发者置疑Grizzly的作者为什么不使用例17.8的模式。另外在实际处理中,Grizzly的处理方式事实上放弃了 NIO中的非阻塞的优势,使用writeSelector.select(writeTimeout)做了个阻塞操作。虽然CPU的资源没有浪费,可是线程资源在阻塞的时间内,被这个请求所占有,不能释放给其他请求来使用。
  Grizzly的作者对此的回应如下。
  (1)   使用临时的Selector的目的是减少线程间的切换。当前的Selector一般用来处理OP_ACCEPT,和OP_READ的操作。使用临时的 Selector可减轻主Selector的负担;而在注册的时候则需要进行线程切换,会引起不必要的系统调用。这种方式避免了线程之间的频繁切换,有利于系统的性能提高。

  (2)   虽然writeSelector.select(writeTimeout)做了阻塞操作,但是这种情况只是少数极端的环境下才会发生。大多数的客户端是不会频繁出现这种现象的,因此在同一时刻被阻塞的线程不会很多。

  (3)   利用这个阻塞操作来判断异常中断的客户连接。

  (4)   经过压力实验证明这种实现的性能是非常好的。

  17.3.2  如何避免内存泄漏

在NIO的框架模型中,值得注意的是有一个API由于NIO非阻塞的特点,其使用比较频繁,那就是java.nio.channel.SelectionKey.attach()。
这是因为在非阻塞的频道中,在socketChannel.read(byteBuffer)的调用中,往往不能返回所有的请求数据,其他的部分数据可能要在下一次(或几次)的读取中才能完全返回。因此在读取一些数据之后,需要将当前的频道重新注册到Selector上:

selectionKey.interestOps(

selectionKey.interestOps() | SelectionKey.OP_READ);

  这样还不够,因为前几次读取的部分数据也需要保留,将所有读取的数据综合起来才是完整的数据,因此需要调用下面的函数将部分数据保存起来供以后使用:

selectionKey.attach(...)

这个函数设计的目的也在于此,主要用于异步非阻塞的情况保存恢复与频道相关的数据。但是,这个函数非常容易造成内存泄漏。这是因为在非阻塞的情况下,你无法保证这个带有附件的SelectionKey什么时候再次返回到准备好的状态。在一些特殊的情况下(例如,客户端的突然断电或网络问题)导致代表这些连接的 SelectionKey永远也不会返回到准备好状态了,而一直存放在Selector中,它们所带的附件也就不会被Java自动回收内存的机制释放掉。内存泄漏对长时间运行的服务器端软件是不能容忍的重大隐患。那么我们看看在Grizzly中是如何处理这种问题的。

  事实上,在Grizzly的实现中很少看到selectionKey.attach(...)的代码。在入口程序SelectThread中的enableSelectionKeys()方法中有这个方法的调用。

  【例17.10】SelectThread中的enableSelectionKeys()方法:

public void enableSelectionKeys(){

    SelectionKey selectionKey;

    int size = keysToEnable.size();

    long currentTime = (Long)System.currentTimeMillis();

    for (int i=0; i < size; i++) {

        selectionKey = keysToEnable.poll();

        selectionKey.interestOps(

                selectionKey.interestOps() | SelectionKey.OP_READ);

        if (selectionKey.attachment() == null)

            selectionKey.attach(currentTime);

            keepAlivePipeline.trap(selectionKey);  

        }

}

}
显而易见,这个函数的目的在这里是要给每个selectionKey加上一个时间戳。这个时间戳是为KeepAlive系统而加的。怎样防止这个long类型对象的内存泄漏呢?在SelectThread的doSelect()方法中有一个expireIdleKeys()的调用。

  【例17.11】SelectThread的expireIdleKeys()方法:

protected void expireIdleKeys(){

    if (keepAliveTimeoutInSeconds <= 0 || !selector.isOpen()) return;

    long current = System.currentTimeMillis();

    if (current < nextKeysExpiration) {
        return;
    }

    nextKeysExpiration = current + kaTimeout;  

    Set<SelectionKey> readyKeys = selector.keys();

    if (readyKeys.isEmpty()){

        return;

    }

    Iterator<SelectionKey> iterator = readyKeys.iterator();

    SelectionKey key;

    while (iterator.hasNext()) {

        key = iterator.next();

        if (!key.isValid()) {

            keepAlivePipeline.untrap(key);

            continue;

        } 
        // Keep-alive expired

        if (key.attachment() != null) {
            if (!defaultAlgorithmInstalled

                        && !(key.attachment() instanceof Long)) {

                continue;

            }
            try{

                long expire = (Long)key.attachment();

                if (current - expire >= kaTimeout) {

                    if (enableNioLogging){

                        logger.log(Level.INFO,

                                  "Keep-Alive expired for SocketChannel " +

                                  key.channel());

                    }                   

                    cancelKey(key);

                } else if (expire + kaTimeout < nextKeysExpiration){

                    nextKeysExpiration = expire + kaTimeout;

                }

            } catch (ClassCastException ex){                           

                if (logger.isLoggable(Level.FINEST)){

                    logger.log(Level.FINEST,

                               "Invalid SelectionKey attachment",ex);

                }

            }

        }

    }                   

}
上面代码的作用显而易见:在每次doSelect()的调用中,expireIdleKeys()都会被执行一次,来查看selector中的每个SelectionKey,将它们的时间戳与当前的时间相比,判断是否当前的SelectionKey很长时间没有响应了,然后根据配置的timeout时间,强行将其释放和回收。

  那么系统用来存放每一次请求读取的数据放在哪里了呢?一般来说这个存放频道数据的对象应该是ByteBuffer。在DefaultReadTask类中,可以看到ByteBuffer的使用情况。

  【例17.12】DefaultReadTask中的doTask()方法:

public void doTask() throws IOException {  

    if (byteBuffer == null) {

        WorkerThread workerThread = (WorkerThread)Thread.currentThread();

        byteBuffer = workerThread.getByteBuffer();

        if (workerThread.getByteBuffer() == null){

            byteBuffer = algorithm.allocate(useDirectByteBuffer,

                      useByteBufferView,selectorThread.getBufferSize());

            workerThread.setByteBuffer(byteBuffer);

        }

    }

    doTask(byteBuffer);

}

上面的方法透露出两个重要的信息:

l   对ByteBuffer的分配,并不是每个SelectionKey(或者说每个网络连接)都有自己的ByteBuffer,而是每个工作线程拥有一个ByteBuffer。

l   ByteBuffer的分配也不是新创建的ByteBuffer对象,而是通过ByteBufferView来对原有的ByteBuffer对象进行重新分割。原因是新建一个ByteBuffer对象的系统消耗比较大,因此Grizzly在启动的时候初始创建了一个大的ByteBuffer对象。以后每个线程再需要ByteBuffer对象的时候,就通过ByteBufferView来在原有ByteBuffer之上创建一个视图,这样的性能要好得多。
如果说每个线程只使用一个ByteBuffer对象(确切地说是ByteBufferView对象),而在NIO中,每个线程是要服务于多个连接请求的,那么线程是怎样维护每个连接请求的数据的**性呢?从DefaultReadTask中的doTask(ByteBuffer byteBuffer)方法中,我们可以看到最初始的读取调用以及对读取数据的处理过程。

  【例17.13】DefaultReadTask中的doTask(ByteBuffer byteBuffer)方法:

protected void doTask(ByteBuffer byteBuffer){

    int count = 0;

    Socket socket = null;

    SocketChannel socketChannel = null;

    boolean keepAlive = false;

    Exception exception = null;

    key.attach(null);

    

    try {

        socketChannel = (SocketChannel)key.channel();

        socket = socketChannel.socket();

        algorithm.setSocketChannel(socketChannel);           

          

        int loop = 0;

        int bufferSize = 0;

        while (socketChannel.isOpen() && (bytesAvailable ||

                ((count = socketChannel.read(byteBuffer))> -1))){  // [1]

            ...

            byteBuffer = algorithm.preParse(byteBuffer);

            inputStream.setByteBuffer(byteBuffer);   // [2]

            inputStream.setSelectionKey(key);

           

            // try to predict which HTTP method we are processing

            if (algorithm.parse(byteBuffer) ){   // [3]

                keepAlive = executeProcessorTask();  //  [4]

                if (!keepAlive) {

                    break;

                }

            }

            ...

        }

}

...

}
在例17.13的方法中,可以清楚地看到,在此方法中程序作了初始的读取动作[1]socketChannel.read(byteBuffer)。初始读取完后,其实并不知道是否所有的请求数据都已经读进来了。于是程序交给HTTP的一个解析算法类(algorithm)来决定是否所有的请求数据都已经读取进来了。接着这个请求就交给 [4]executeProcessorTask()去执行了。在executeProcessorTask()中使用了一个 ByteBufferInputStream类,这个类是对ByteBuffer的一个封装,并在[2]中进行了设置和初始化。事实上,在默认的解析算法中,客户端的请求在第一次读取动作中如果没有全部完成,那剩余部分的数据其实就交给ByteBufferInputStream来完成了。

  【例17.14】ByteBufferInputStream中的doRead()方法:

/**

* Read bytes using the ReadSelector

*/

protected int doRead() throws IOException{       

    if (key == null) return -1;

       

    byteBuffer.clear();

    int count = 1;

    int byteRead = 0;

    Selector readSelector = null;

    SelectionKey tmpKey = null;

    try{

        SocketChannel socketChannel = (SocketChannel)key.channel();

        while (count > 0){

            count = socketChannel.read(byteBuffer);  //[1]

            if (count > -1)

                byteRead += count;

            else

                byteRead = count;

        }           

           

        if (byteRead == 0){

            readSelector = SelectorFactory.getSelector(); //[2]

            if (readSelector == null){

                return 0;

            }

            count = 1;

            tmpKey = socketChannel

                    .register(readSelector,SelectionKey.OP_READ);              

            tmpKey.interestOps(

tmpKey.interestOps() | SelectionKey.OP_READ);

            int code = readSelector.select(readTimeout); // [3]

            tmpKey.interestOps(

                    tmpKey.interestOps() & (~SelectionKey.OP_READ));

            if (code == 0){

                return 0; // Return on the main Selector and try again.

            }

            while (count > 0){

                count = socketChannel.read(byteBuffer); // [4]

                if (count > -1)

                    byteRead += count;

                else

                    byteRead = count;                   

            }

        }

    } finally {

        if (tmpKey != null)

            tmpKey.cancel();

        if (readSelector != null){

            // Bug 6403933

            try{

                readSelector.selectNow();

            } catch (IOException ex){

                ;

            }

            SelectorFactory.returnSelector(readSelector);

        }

    }

    byteBuffer.flip();

    return byteRead;

}
查看过这个方法之后,觉得很有意思:对每个连接保存的数据的ByteBuffer对象,在Grizzly中根本不会有什么内存泄漏的问题。因为在Grizzly中根本没有使用NIO模式中设计方法(将 ByteBuffer附加到SelectionKey中,再将SelectionKey重新注册到Selector中等待下次激活)。在Grizzly中对请求数据的读取完全使用了传统的阻塞方式,根本不需要attach和将SelectionKey重新注册到Selector。

当读取数据的任务交给ByteBufferInputStream的时候,ByteBufferInputStream会再做一次最大的努力来读取可能有的数据[1]。如果还是没有读取到什么数据的话,Grizzly并没有将SelectionKey重新注册到主线程的Selector,而是从 Selector池中获得一个临时的Selector[2],将SelectionKey重新注册到这个临时的Selector中。接着这个临时的 Selector做了一个阻塞的操作readSelector.select(readTimeout)[3],这个动作一直会阻塞到当前频道有数据进来,或者阻塞时间超过Timeout的时间。

这种算法也颇受争议。有的人认为使用阻塞的模式性能不会比NIO中非阻塞的模式好,特别是在有很多网络速度很慢的客户端的情况下,这样会大量造成线程的占用而变得不具有很好的可扩展性。

  Grizzly的作者也承认,如果在大量慢速的客户端的情况下,使用非阻塞模式肯定要好些。但是他为自己的实现算法也给了下面一些理由。

  (1)   假设大多数客户端的速度良好是合理的。因此大多数的请求数据在一到两次都能全部读取。

  (2)   对连接异常的客户端可以在最早时间范围内进行判断和做出放弃的决定,保护系统的资源不被浪费。

  (3)   这样实现没有内存泄漏的问题,而且内存消耗也要小些,能够获得更好的性能。

  (4)   因为每个连接所有的读取过程都在一个线程中完成,不用在主线程(Selector所在的线程)之间切换,可以减少操作系统的线程调度负担,并且减少主线程的消耗。
17.3.3  使用多个Selector

经常会有人问:什么是企业级应用?也经常看到一些产品的说明书标称该软件产品为企业级产品。究竟什么样的产品才能有资格被称作企业级产品?这个问题很难被回答,每个人的标准是不一样的。最近在进行一些企业级别应用测试的时候,发现扩展性是比较重要的一个指标。

当时测试的硬件是比较高档的服务器,具有32个或64个以上的CPU。由于稳定性和安全性的原因,这些多CPU的UNIX服务器是当前大型企业应用关键业务系统所愿意使用的运行环境。但是在测试的时候发现,在少量CPU的情况下(2到6个),绝大多数软件系统都能比较充分地利用机器提供的资源, 获得不错的性能指标。当测试压力不断增加,需要更多的CPU的时候,不同的应用系统所表现出来的扩展性就大不相同了。很多开源的软件,包括一些开源的数据库软件和应用服务器在CPU超过8个的时候性能不升反降,无法充分利用硬件系统提供CPU资源。而那些商业的数据库软件(包括Oracle、DB2、 Informix、Sybase)和应用服务器(BEA Weblogic、Sun JES)都能够在多达64个CPU的系统上扩展得很好。并不是说开源软件不好,这是个定位问题。如果是企业级软件系统,那么就应该在最初的设计和最后的测试环节都应该考虑到扩展性的问题:当这个系统给予了更多的硬件资源的时候,是不是能够运行得更快,或者能支持更多的服务请求。

  从源码中可以看到,GlassFish到处都考虑到扩展性的问题,真正将自己定位于企业级的应用了,先不提GlassFish中对负载均衡和集群的支持,在Grizzly中Selector的设计和实现就充分考虑了扩展性的要求。

  一般NIO的框架结构应该是这样的:首先创建ServerSocketChannel的实例,并且获得一个Selector的实例,将它绑定到相应的端口上,再将ServerSocketChannel配置成非阻塞模式,接着将OP_ACCEPT注册到这个Selector上。
例17.15】传统NIO中的主线程:

serverSocketChannel = ServerSocketChannel.open();

selector = Selector.open();

serverSocket = serverSocketChannel.socket();

serverSocket.setReuseAddress(true);

serverSocket.bind(new InetSocketAddress(port),ssBackLog);

serverSocketChannel.configureBlocking(false);

serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

当OP_ACCEPT事件发生的时候,需要将新产生的SocketChannel的OP_READ注册到这个Selector中去。

  【例17.16】传统对OP_ACCEPT的处理:

protected void handleAccept(SelectionKey key) throws IOException{

    ServerSocketChannel server = (ServerSocketChannel)key.channel();

    SocketChannel channel = server.accept();

    if (channel != null) {

        channel.configureBlocking(false);

        SelectionKey readKey =

                channel.register(selector, SelectionKey.OP_READ);

        setSocketOptions(((SocketChannel)readKey.channel()).socket());

    }

    ...

}

这种处理方式在大并发客户数量的情况下,很容易使得这个主线程变得很繁忙:它既要负责OP_ACCEPT和OP_READ的注册和监控,还需要负责 OP_ACCEPT的处理(OP_READ的处理一般在另外的线程中);除此以外,主线程还有可能要负责监控客户端的连接是否异常,来保证没有内存泄漏的情况。因为单个线程只能在单个CPU中执行,在用户并发数量很多的情况下,主线程可能被延迟。一旦主线程被延迟,系统其他部分的运行都会受到很大的影响。
在Grizzly中可以配置使用多个Selector(和多个Selector线程)。在Grizzly中存在与多个Selector配置相关的参数。

  【例17.17】SelectorThread中对多个Selector配置相关参数的定义:

/**

* The number of SelectorReadThread

*/

protected int multiSelectorsCount = 0;

/**

* The Selector used to register OP_READ

*/   

protected MultiSelectorThread[] readThreads;

  【例17.18】SelectorThread中对多个Selector配置相关参数的使用:

protected void handleAccept(SelectionKey key) throws IOException{

    ServerSocketChannel server = (ServerSocketChannel)key.channel();

    SocketChannel channel = server.accept();

...

    if (channel != null) {

        if (multiSelectorsCount > 1) {

            MultiSelectorThread srt = getSelectorReadThread();

            srt.addChannel(channel);

        } else {

            channel.configureBlocking(false);

            SelectionKey readKey =

                   channel.register(selector, SelectionKey.OP_READ);

setSocketOptions(((SocketChannel)readKey

.channel()).socket());

        }

    }

}
【例17.19】MultiSelectorThread接口的实现类SelectorReadThread:

public class SelectorReadThread extends SelectorThread

        implements MultiSelectorThread{

    /**

     * List of Channel to process

     */

    ArrayList<SocketChannel> channels = new ArrayList<SocketChannel>();

    /**

     * Int used to differenciate this instance

     */

    public static int countName;

    /**

     * Add a Channel to be processed by this Selector

     */

    public synchronized void addChannel(SocketChannel channel)

            throws IOException, ClosedChannelException {

        channels.add(channel);

        getSelector().wakeup();

}

    /**

     * Register all Channel with an OP_READ opeation

     */

    private synchronized void registerNewChannels() throws IOException{

        int size = channels.size();

        for (int i = 0; i < size; i++) {

            SocketChannel sc = channels.get(i);

            sc.configureBlocking(false);

            try {

                SelectionKey readKey =

                        sc.register(getSelector(), SelectionKey.OP_READ);

setSocketOptions(((SocketChannel)readKey

.channel()).socket());

            } catch (ClosedChannelException cce) {}

        }

        channels.clear();

    }

...

}
从例17.18和例17.19的代码中可以看出,当配置有多个Selector的时候,在处理OP_ACCEPT时,新建立的连接可以交给MultiSelectorThread的类来监控和管理这些连接的OP_READ事件,分担了主线程的负担。在多CPU大并发用户的情况下,使得系统具有较好的扩展性。

  17.3.4  Grizzly其他的特点

  1. 异步请求处理

在应用服务器中,我们通常使用的请求都是同步的请求。当客户端的请求进来以后被服务器所解析,随后Servlet或JSP被调用,运行的结果被返回到客户端。但是在一些情况下,这种同步的处理过程不能很好地满足要求。例如,被执行的业务逻辑需要调用外部的一个服务,而这个服务响应得很慢;或者客户的请求介入到一个工作流程当中,被外部的因素所中断(需要老板批准等)。在这些情况下,虽然使用同步机制也能实现,但是轮询或阻塞的方法对系统资源的消耗比较大,系统结构也因此变得复杂。

  在Grizzly中有一个com.sun.enterprise.web.connector.grizzly.async包,用来实现异步的请求处理。

  2. 服务器推送技术

服务器推送技术(Comet)现在非常的流行,结合AJAX和服务器推送技术,可以实现非常灵活和高性能的应用程序。在 com.sun.enterprise.web.connector.grizzly.comet的包中,Grizzly将异步处理请求和服务器推送技术完美地结合在一起,使GlassFish成为支持服务器推送技术的开源产品之一。

3. 资源分配和管理

  Application Resource Allocation(RAR,应用资源分配)本应该是操作系统或硬件层面上的话题。现代的计算机系统提供了各种各样的资源虚拟技术,有的在操作系统中, 有的是在服务器硬件中,还有的是通过跨平台的框架(例如网格技术)将企业内部的各种资源进行划分和合理应用。这种需求非常多,因为在企业内部存在的各种应用的重要程度和优先级别都不同,级别高的重要应用不应该受到其他应用的影响,应当享有资源分配的优先权。
但是不同操作系统、不同的网格技术对应用资源分配的方式各不相同。在Grizzly中存在着三个包:com.sun.enterprise.web.ara、com.sun.enterprise.web.ara.algorithms、com.sun.enterprise. web.ara.rules。通过这三个包,在Grizzly中就可以实现对部署在它上面的应用进行资源分配和管理。管理的规则主要包括以下两类:  l   当前应用所占Java Heap的百分比。
  l   当前应用所占线程数量的百分比。
  4. 统一端口
在安装应用服务器的过程中,很重要的一件事情就是分配端口号。一般来说,一个应用服务器分配的端口不只一个,可能有三、四个,还可能更多。这些端口包括各种不同的服务或协议监听所在的Socket,有HTTP端口,有HTTPS端口,有IIOP端口,还有其他通信端口。如果是在一台共享的服务器上安装应用服务器,情况要更加糟糕,因为有的端口已经被别的应用所占用,有时不得不在启动服务器的时候手动修改端口号。
在Grizzly中有两个包:com.sun.enterprise.web.portunif和com.sun.enterprise.web.portunif. util,这两个包的功能是统一端口号。通过这两个包,GlassFish可以只启动一个端口,仍然可以服务于多个不同的协议。例如4848端口既是HTTP的端口,又是HTTPS的端口,还是IIOP的端口。这样就大大简化了管理员的工作。
  Grizzly通过可插拔的形式来定义不同的协议和协议的处理程序。主要的接口如下。
  l   ProtocolFinder:当请求进来以后,通过ProtocolFinder来确定当前的请求是什么协议。Grizzly默认实现了HttpProtocolFinder和HttpsProtocolFinder。
  l   ProtocolHandler:当确定了使用什么协议以后,相应的协议处理单元就会被调用。在协议处理单元中可以做任何想做的事情,比如进行EJB的调用,进行负载均衡或请求转发等。
  17.3.5  Grizzly的性能
  Grizzly在整个设计和开发过程中,性能和高扩展性是它的核心。从源码的各个细节都可以看出Grizzly对高性能的追求。例如,对ByteBufferView的使用、对多个Selector的支持、对不同线程模型的配置、对多个HTTP解析算法的选择,以及对OP_READ和OP_WRITE的特殊处理都反映了Grizzly对高性能一丝不苟的严格要求。
通过内部的性能测试,并且和传统的Web服务器的比较,事实数据证明了Grizzly是性能和可扩展性都非常高的HTTP引擎,请参看图17-2。在大并发压力的测试下,它的性能甚至超过了两款使用C语言编写的Web服务器。与传统的Java阻塞式的HTTP引擎相比,Grizzly的性能和扩展性远远地超过它们 

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: