摘要:
IO API的可伸缩性对Web应用有着极其重要的意义。Java 1.4版以前的API中,阻塞I/O令许多人失望。从J2SE 1.4版本开始,Java终于有了可伸缩的I/O API。本文分析并计算了新旧I/O API在可伸缩性方面的差异。
提纲:
正文:
一、概述
IO API的可伸缩性对Web应用有着极其重要的意义。Java 1.4版以前的API中,阻塞I/O令许多人失望。从J2SE 1.4版本开始,Java终于有了可伸缩的I/O API。本文分析并计算了新旧IO API在可伸缩性方面的差异。Java向Socket写入数据时必须调用关联的OutputStream的write()方法。只有当所有的数据全部写入时,write()方法调用才会返回。倘若发送缓冲区已满且连接速度很低,这个调用可能需要一段时间才能完成。如果程序只使用单一的线程,其他连接就必须等待,即使那些连接已经做好了调用write()的准备也一样。为了解决这个问题,你必须把每一个Socket和一个线程关联起来;采用这种方法之后,当一个线程由于I/O相关的任务被阻塞时,另一个线程仍旧能够运行。
尽管线程的开销不如进程那么大,但是,考虑到底层的操作平台,线程和进程都属于消耗大量资源的程序结构。每一个线程都要占用一定数量的内存,而且除此之外,多个线程还意味着线程上下文的切换,而这种切换也需要昂贵的资源开销。因此,Java需要一个新的API来分离Socket与线程之间过于紧密的联系。在新的Java I/O API(java.nio.*)中,这个目标终于实现了。
本文分析和比较了用新、旧两种I/O API编写的简单Web服务器。由于作为Web协议的HTTP不再象原来那样只用于一些简单的目的,因此这里介绍的例子只包含关键的功能,或者说,它们既不考虑安全因素,也不严格遵从协议规范。
二、用旧API编写的HTTP服务器
首先我们来看看用旧式API编写的HTTP服务器。这个实现只使用了一个类。main()方法首先创建了一个绑定到8080端口的ServerSocket:
public static void main() throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
for (int i=0; i < Integer.parseInt(args[0]); i++) {
new Httpd(serverSocket);
}
}
接下来,main()方法创建了一系列的Httpd对象,并用共享的ServerSocket初始化它们。在Httpd的构造函数中,我们保证每一个实例都有一个有意义的名字,设置默认协议,然后通过调用其超类Thread的start()方法启动服务器。此举导致对run()方法的一次异步调用,而run()方法包含一个无限循环。
在run()方法的无限循环中,ServerSocket的阻塞性accpet()方法被调用。当客户程序连接服务器的8080端口,accept()方法将返回一个Socket对象。每一个Socket关联着一个InputStream和一个OutputStream,两者都要在后继的handleRequest()方法调用中用到。这个方法将读取客户程序的请求,经过检查和处理,然后把合适的应答发送给客户程序。如果客户程序的请求合法,通过sendFile()方法返回客户程序请求的文件;否则,客户程序将收到相应的错误信息(调用sendError())方法。
while (true) {
...
socket = serverSocket.accept();
...
handleRequest();
...
socket.close();
}
现在我们来分析一下这个实现。它能够出色地完成任务吗?答案基本上是肯定的。当然,请求分析过程还可以进一步优化,因为在性能方面StringTokenizer的声誉一直不佳。但这个程序至少已经关闭了TCP延迟(对于短暂的连接来说它很不合适),同时为外发的文件设置了缓冲。而且更重要的是,所有的线程操作都相互独立。新的连接请求由哪一个线程处理由本机的(因而也是速度较快的)accept()方法决定。除了ServerSocket对象之外,各个线程之间不共享可能需要同步的任何其他资源。这个方案速度较快,但令人遗憾的是,它不具有很好的可伸缩性,其原因就在于,很显然地,线程是一种有限的资源。
三、非阻塞的HTTP服务器
下面我们来看看另一个使用非阻塞的新I/O API的方案。新的方案要比原来的方案稍微复杂一点,而且它需要各个线程的协作。它包含下面四个类:
·NIOHttpd
·Acceptor
·Connection
·ConnectionSelector
NIOHttpd的主要任务是启动服务器。就象前面的Httpd一样,一个服务器Socket被绑定到8080端口。两者主要的区别在于,新版本的服务器使用java.nio.channels.ServerSocketChannel而不是ServerSocket。在利用bind()方法显式地把Socket绑定到端口之前,必须先打开一个管道(Channel)。然后,main()方法实例化了一个ConnectionSelector和一个Acceptor。这样,每一个ConnectionSelector都可以用一个Acceptor注册;另外,实例化Acceptor时还提供了ServerSocketChannel。
public static void main() throws IOException {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress(8080));
ConnectionSelector cs = new ConnectionSelector();
new Acceptor(ssc, cs);
}
为了理解这两个线程之间的交互过程,首先我们来仔细地分析一下Acceptor。Acceptor的主要任务是接受传入的连接请求,并通过ConnectionSelector注册它们。Acceptor的构造函数调用了超类的start()方法;run()方法包含了必需的无限循环。在这个循环中,一个阻塞性的accept()方法被调用,它最终将返回一个Socket对象——这个过程几乎与Httpd的处理过程一样,但这里使用的是ServerSocketChannel的accept()方法,而不是ServerSocket的accept()方法。最后,以调用accept()方法获得的socketChannel对象为参数创建一个Connection对象,并通过ConnectionSelector的queue()方法注册它。
while (true) {
...
socketChannel = serverSocketChannel.accept();
connectionSelector.queue(new Connection(socketChannel));
...
}
总而言之:Acceptor只能在一个无限循环中接受连接请求和通过ConnectionSelector注册连接。与Acceptor一样,ConnectionSelector也是一个线程。在构造函数中,它构造了一个队列,并用Selector.open()方法打开了一个java.nio.channels.Selector。Selector是整个服务器中最重要的部分之一,它使得程序能够注册连接,能够获取已经允许读取和写入操作的连接的清单。
构造函数调用start()方法之后,run()方法里面的无限循环开始执行。在这个循环中,程序调用了Selector的select()方法。这个方法一直阻塞,直到已经注册的连接之一做好了I/O操作的准备,或Selector的wakeup()方法被调用。
while (true) {
...
int i = selector.select();
registerQueuedConnections();
...
// 处理连接...
}
当ConnectionSelector线程执行select()时,没有一个Acceptor线程能够用该Selector注册连接,因为对应的方法是同步方法,理解这一点是很重要的。因此这里使用了队列,必要时Acceptor线程向队列加入连接。
public void queue(Connection connection) {
synchronized (queue) {
queue.add(connection);
}
selector.wakeup();
}
紧接着把连接放入队列的操作,Acceptor调用Selector的wakeup()方法。这个调用导致ConnectionSelector线程继续执行,从正在被阻塞的select()调用返回。由于Selector不再被阻塞,ConnectionSelector现在能够从队列注册连接。在registerQueuedConnections()方法中,其实施过程如下:
if (!queue.isEmpty()) {
synchronized (queue) {
while (!queue.isEmpty()) {
Connection connection =
(Connection)queue.remove(queue.size()-1);
connection.register(selector);
}
}
}
四、注册与处理过程详解
接下来我们要分析Connection的register()方法。前面我们总是说用Selector注册的连接,其实这是一种简化的说法。实际上,用Selector注册的是一个java.nio.channels.SocketChannel对象,但只针对特定的I/O操作。注册之后,有一个java.nio.channels.SelectionKey被返回。这个选择键可以通过attach()方法关联到任意对象。为了通过键获得连接,这里把Connection对象关联到键。这样,我们就可以从Selector间接地获得一个Connection。
public void register(Selector selector)
throws IOException {
key = socketChannel.register(selector, SelectionKey.OP_READ);
key.attach(this);
}
回过头来看ConnectionSelector。select()方法的返回值表示有多少连接已经做好了I/O操作的准备。如果返回值是0,则返回;否则,调用selectedKeys()获得键的集合(Set),从这些键获得以前关联的Connection对象,然后调用其readRequest()或writeResponse()方法,具体调用哪一个方法由连接被注册为读取操作还是写入操作决定。
现在再来看Connection类。Connection类代表着连接,处理所有协议有关的细节。在构造函数中,通过参数传入的SocketChannel被设置成非阻塞模式,这对于服务器来说是很重要的。另外,构造函数还设置了一些默认值,分配了缓冲区requestLineBuffer。由于分配直接缓冲区代价稍高,且这里的每一个连接都用一个新的缓冲区,因此这里使用java.nio.ByteBuffer.allocate()而不是ByteBuffer.allocateDirect()。如果重用缓冲区,直接缓冲区可能具有更高的效率。
public Connection(SocketChannel socketChannel)
throws IOException {
this.socketChannel = socketChannel;
...
socketChannel.configureBlocking(false);
requestLineBuffer = ByteBuffer.allocate(512);
...
}
完成所有初始化工作且SocketChannel做好了读取准备之后,ConnectionSelector调用了readRequest()方法,利用socketChannel.read(requestLineBuffer)方法把所有可用的数据读入缓冲区。如果不能读取完整的行,则返回发出调用的ConnectionSelector,允许另一个连接进入处理过程;反之,如果成功地读取了整个行,接下来应该做的是象在Httpd中一样解析请求。如果当前的请求合法,程序为请求目标文件创建一个java.nio.Channels.FileChannel,并调用prepareForResponse()方法。
private void prepareForResponse() throws IOException {
StringBuffer responseLine = new StringBuffer(128);
...
responseLineBuffer = ByteBuffer.wrap(
responseLine.toString().getBytes("ASCII")
);
key.interestOps(SelectionKey.OP_WRITE);
key.selector().wakeup();
}
prepareForResponse()方法构造出缓冲区responseLine以及(如果必要的话)应答头或错误信息,并把这些数据写入responseLineBuffer。这个ByteBuffer是一个byte数组的简单的封装器。生成待输出的数据之后,我们还要通知ConnectionSelector:从现在开始不再读取数据,而是要写入数据了。这个通知通过调用选择键的interestedOps(SelectionKey.OP_WRITE)方法完成。为了保证选择器能够迅速认识到连接操作状态的变化,接着还要调用wakeup()方法。接下来ConnectionSelector调用连接的writeResponse()方法。首先,responseLineBuffer被写入到Socket管道。如果缓冲区的内容全部被写入,而且还有被请求的文件需要发送,接着调用前面打开的FileChannel的transferTo()方法。transferTo()方法通常能够高效地把数据从文件传输到管道,但实际的传输效率依赖于底层的操作系统。任何时候,被传输的数据量至多相当于在无阻塞的情况下可写入目标管道的数据量。为安全和确保各个连接之间的公平起见,这里把上限设置成64 KB。
如果所有数据都已经传输完毕,close()执行清理工作。取消Connection的注册是这里的主要任务,具体通过调用键的cancel()方法完成。
public void close() {
...
if (key != null) key.cancel();
...
}
这个新的方案性能如何呢?答案是肯定的。从原理上看,一个Acceptor和一个ConnectionSelector足以支持任意数量的打开的连接。因此,新的实现方案在可伸缩性方面占有优势。但是,由于两个线程必须通过同步的queue()方法通信,它们可能互相阻塞对方。解决这个问题有两种途径:
·改进实现队列的方法
·采用多个Acceptor/ConnectionSelector对
与Httpd相比,NIOHttpd的一个缺点是,对于每一个请求,就有一个新的带缓冲的Connection对象被创建。这就导致了垃圾收集器产生的额外的CPU占用,这部分附加代价的具体程度又与VM的类型有关。然而,Sun不厌其烦地强调说,有了Hotspot,短期生存的对象不再成为问题。
五、可伸缩性的定量分析和比较
在可伸缩性方面,NIOHttpd到底比Httpd好多少?下面我们来看看具体的数字。首先要声明的是,这里的数字具有大量的推测成分,一些重要的环境因素,例如线程同步、上下文切换、换页、硬盘速度和缓冲等,都没有考虑到。首先评估处理r个并发的请求需要多少时间,假设被请求的文件大小是s字节,客户端的带宽是b字节/秒。对于Httpd,这个时间显然直接依赖于线程的数量t,因为同一时刻只能处理t个请求。所以Httpd的处理时间可以从公式一得到,其中c是执行请求分析之类操作的开销常量,这个值对于每一个请求来说都是一样的。另外,这里假定从磁盘读取数据的速度总是快于写入Socket的速度,服务器带宽总是大于客户机带宽之和,且CPU未满载。因此,服务器端的带宽、缓冲和硬盘速度等因素都不必在该公式中考虑。
图一:公式一
然而,NIOHttpd的处理时间不再依赖于t。对于NIOHttpd,传输时间l在很大程度上依赖于客户端的带宽b、文件大小s以及前面提到的常数c。由此可以得出公式二,从该公式可以得到NIOHttpd的最小传输时间。
图二:公式二
注意公式三的比值d,它度量了NIOHttpd和Httpd的性能对比关系。
图三:公式三
进一步的分析表明,如果s、b、t和c是常数,r 趋向无穷时d的增长趋向于一个极限,从公式四可以方便地计算出这个极限。
图四:公式四
因此,除了线程的数量和常量性的开销,连接的时长s/b对d具有极端重要的影响。连接持续的时间越长,d值越小,NIOHttpd对比Httpd的优势也就越高。表一显示出,当c=10ms,t=100,s=1mb,b=8kb/s时,NIOHttpd要比Httpd快126倍。如果连接持续了很长一段时间,NIOHttpd表现出巨大的优势。当连接时间较短时,例如在100 Mb的局域网内,如果文件较大,NIOHttpd表现出10%的优势;如果文件较小,优势不明显。
上述计算假定NIOHttpd和Httpd的常量性开销大致相同,且服务器的不同实现方式也没有带来新的开销。如前所述,这个比较是一个理想条件下的比较。然而,对于形成哪一种实现方式占有更多优势这一概念来说,上述比较已经足够了。值得指出的是,大多数Web文件的体积都较小,但HTTP 1.1客户端会试图让连接持续尽可能长的时间(打开Keep-Alive选项)。很多时候,许多不再传输任何数据的连接会保持打开状态。假设服务器上每一个线程对应着一个连接,这可能导致难以置信的资源浪费。因此,特别是对于HTTP服务器来说,利用新的Java I/O API能够戏剧性地提高可伸缩性。
结束语: Java新的I/O API能够有效地提高服务器的可伸缩性。与旧的API相比,新的API要复杂一些,需要更深入地了解多线程和同步。然而,一旦你跨越了这些障碍,就会发现新的I/O API是对Java 2平台的必要的、有用的改进。