IO API的可伸缩性对Web应用有着极其重要的意义Java 版以前的API中阻塞I/O令许多人失望从JSE 版本开始Java终于有了可伸缩的I/O API本文分析并计算了新旧I/O API在可伸缩性方面的差异
一概述
IO API的可伸缩性对Web应用有着极其重要的意义Java 版以前的API中阻塞I/O令许多人失望从JSE 版本开始Java终于有了可伸缩的I/O API本文分析并计算了新旧IO API在可伸缩性方面的差异Java向Socket写入数据时必须调用关联的OutputStream的write()方法只有当所有的数据全部写入时write()方法调用才会返回倘若发送缓沖区已满且连接速度很低这个调用可能需要一段时间才能完成如果程序只使用单一的线程其他连接就必须等待即使那些连接已经做好了调用write()的准备也一样为了解决这个问题你必须把每一个Socket和一个线程关联起来采用这种方法之后当一个线程由于I/O相关的任务被阻塞时另一个线程仍旧能够运行
尽管线程的开销不如进程那么大但是考虑到底层的操作平台线程和进程都属于消耗大量资源的程序结构每一个线程都要占用一定数量的内存而且除此之外多个线程还意味着线程上下文的切换而这种切换也需要昂贵的资源开销因此Java需要一个新的API来分离Socket与线程之间过于紧密的联系在新的Java I/O API(javanio*)中这个目标终于实现了
本文分析和比较了用新旧两种I/O API编写的简单Web服务器由于作为Web协议的HTTP不再象原来那样只用于一些简单的目的因此这里介绍的例子只包含关键的功能或者说它们既不考虑安全因素也不严格遵从协议规范
二用旧API编写的HTTP服务器
首先我们来看看用旧式API编写的HTTP服务器这个实现只使用了一个类main()方法首先创建了一个绑定到端口的ServerSocket
public static void main() throws IOException {
ServerSocket serverSocket = new ServerSocket();
for (int i=; i < IntegerparseInt(args[]); i++) {
new Httpd(serverSocket);
}
}
接下来main()方法创建了一系列的Httpd对象并用共享的ServerSocket初始化它们在Httpd的构造函数中我们保证每一个实例都有一个有意义的名字设置默认协议然后通过调用其超类Thread的start()方法启动服务器此举导致对run()方法的一次异步调用而run()方法包含一个无限循环
在run()方法的无限循环中ServerSocket的阻塞性accpet()方法被调用当客户程序连接服务器的端口accept()方法将返回一个Socket对象每一个Socket关联着一个InputStream和一个OutputStream两者都要在后继的handleRequest()方法调用中用到这个方法将读取客户程序的请求经过检查和处理然后把合适的应答发送给客户程序如果客户程序的请求合法通过sendFile()方法返回客户程序请求的文件否则客户程序将收到相应的错误信息(调用sendError())方法
while (true) {
socket = serverSocketaccept();
handleRequest();
socketclose();
}
现在我们来分析一下这个实现它能够出色地完成任务吗?答案基本上是肯定的当然请求分析过程还可以进一步优化因为在性能方面StringTokenizer的声誉一直不佳但这个程序至少已经关闭了TCP延迟(对于短暂的连接来说它很不合适)同时为外发的文件设置了缓沖而且更重要的是所有的线程操作都相互独立新的连接请求由哪一个线程处理由本机的(因而也是速度较快的)accept()方法决定除了ServerSocket对象之外各个线程之间不共享可能需要同步的任何其他资源这个方案速度较快但令人遗憾的是它不具有很好的可伸缩性其原因就在于很显然地线程是一种有限的资源
三非阻塞的HTTP服务器
下面我们来看看另一个使用非阻塞的新I/O API的方案新的方案要比原来的方案稍微复杂一点而且它需要各个线程的协作它包含下面四个类
NIOHttpd
Acceptor
Connection
ConnectionSelector
NIOHttpd的主要任务是启动服务器就象前面的Httpd一样一个服务器Socket被绑定到端口两者主要的区别在于新版本的服务器使用javaniochannelsServerSocketChannel而不是ServerSocket在利用bind()方法显式地把Socket绑定到端口之前必须先打开一个管道(Channel)然后main()方法实例化了一个ConnectionSelector和一个Acceptor这样每一个ConnectionSelector都可以用一个Acceptor注册另外实例化Acceptor时还提供了ServerSocketChannel
public static void main() throws IOException {
ServerSocketChannel ssc = ServerSocketChannelopen();
sscsocket()bind(new InetSocketAddress());
ConnectionSelector cs = new ConnectionSelector();
new Acceptor(ssc cs);
}
为了理解这两个线程之间的交互过程首先我们来仔细地分析一下AcceptorAcceptor的主要任务是接受传入的连接请求并通过ConnectionSelector注册它们Acceptor的构造函数调用了超类的start()方法run()方法包含了必需的无限循环在这个循环中一个阻塞性的accept()方法被调用它最终将返回一个Socket对象——这个过程几乎与Httpd的处理过程一样但这里使用的是ServerSocketChannel的accept()方法而不是ServerSocket的accept()方法最后以调用accept()方法获得的socketChannel对象为参数创建一个Connection对象并通过ConnectionSelector的queue()方法注册它
while (true) {
socketChannel = serverSocketChannelaccept();
connectionSelectorqueue(new Connection(socketChannel));
}
总而言之Acceptor只能在一个无限循环中接受连接请求和通过ConnectionSelector注册连接与Acceptor一样ConnectionSelector也是一个线程在构造函数中它构造了一个队列并用Selectoropen()方法打开了一个javaniochannelsSelectorSelector是整个服务器中最重要的部分之一它使得程序能够注册连接能够获取已经允许读取和写入操作的连接的清单
构造函数调用start()方法之后run()方法里面的无限循环开始执行在这个循环中程序调用了Selector的select()方法这个方法一直阻塞直到已经注册的连接之一做好了I/O操作的准备或Selector的wakeup()方法被调用
while (true) {
int i = selectorselect();
registerQueuedConnections();
// 处理连接
}
当ConnectionSelector线程执行select()时没有一个Acceptor线程能够用该Selector注册连接因为对应的方法是同步方法理解这一点是很重要的因此这里使用了队列必要时Acceptor线程向队列加入连接
public void queue(Connection connection) {
synchronized (queue) {
queueadd(connection);
}
selectorwakeup();
}
紧接着把连接放入队列的操作Acceptor调用Selector的wakeup()方法这个调用导致ConnectionSelector线程继续执行从正在被阻塞的select()调用返回由于Selector不再被阻塞ConnectionSelector现在能够从队列注册连接在registerQueuedConnections()方法中其实施过程如下
if (!queueisEmpty()) {
synchronized (queue) {
while (!queueisEmpty()) {
Connection connection =
(Connection)queueremove(queuesize());
connectionregister(selector);
}
}
}