基本的Java套接字对于小规模系统可以很好地运行,但当涉及到要同时处理上千个客户端的服务器时,可能就会产生一些问题。其实在第4章已经可以看到一些迹象:由于创建、维护和切换线程需要的系统开销,一客户一线程方式在系统扩展性方面受到了限制。使用线程池可以节省那种系统开销,同时允许实现者利用并行硬件的优势。但对于连接生存期比较长的协议来说,线程池的大小仍然限制了系统可以同时处理的客户端数量。考虑一个在客户端之间传递消息的即时消息服务器(Instant Messaging)。客户端必须不停地连接服务器以接收即时消息,因此线程池的大小限制了系统可以同时服务的客户端总数。如果增加线程池的大小,将带来更多的线程处理开销,而不能提升系统的性能,因为在大部分的时间里客户端是处于闲置状态的。
如果这就是所有问题,可能NIO还不是必要的。不幸的是,在使用线程的扩展性方面还涉及一些更加难以把握的挑战。其中一个挑战就是程序员几乎不能对什么时候哪个线程将获得服务进行控制。你可以设置一个线程实例的优先级(priority)(高优先级的线程相对于低优先级的线程有优先权),但是这个优先级只是一种"建议"--下一个选择执行的线程完全取决于具体实现。[ ]因此,如果程序员想要保证某些连接优先获得服务,或想要指定一定的服务顺序,线程可能就很难做到。
然而,有关线程的最重要的问题可能是我们至今还未提及。那是因为在"回显服务"示例程序中,每个客户端都与其他客户端相互独立,客户端之间没有交互,也不会影响服务器的状态。但是在实际情况中,大多数的服务器有一些信息(称为"状态")需要由不同的客户端同时访问或修改。例如,考虑一种允许大城市的市民保留一个小时停车位的服务。计划什么时间段由谁获得哪个停车位必须保持一致,而且,该服务必须保证同一用户在同一时间段内最多只能获得一个停车位。这些限制就要求在所有客户之间共享一些状态信息(即调度表)。这需要通过使用锁(locks)机制或其他互斥机制对依次访问状态进行严格的同步(synchronized)。否则,由于调度程序能够使不同线程上的程序段在一定程度上交错执行,如果不同线程试图同时更新调度表,它们就可能改写掉其他线程所作的修改。
由于需要对共享状态进行同步访问,要同时考虑到多线程服务器的正确性和高效性就变得非常困难。至于其为什么会增加复杂性已经超出了本书的讨论范围,只要进行简单的了解就足够了:使用同步机制将增加更多的系统调度和上下文切换开销,而程序员对这些开销又无法控制。由于其复杂性,一些程序员宁愿继续使用单线程(single-threaded)方法。这类服务器只用一个线程来处理所有的客户端--不是顺序处理,而是一次全部处理。这种服务器
不能为任何客户端提供I/O操作的阻塞等待,而必须排他地使用非阻塞式(nonblocking)I/O。回顾前面所介绍的非阻塞式I/O,我们需要指定调用I/O方法时的最长阻塞时间(包括0)。
在前面我们见过一个为accept操作设置超时(通过使用ServerSocket类的setSoTimeout()方法)的例子。当在ServerSocket实例上调用accept()方法时,如果有一个新的连接请求正在等待,accept()方法则立即返回;否则该方法将阻塞等待,直到有新的连接请求到来或计时器超时,这取决于哪个先发生(有连接请求或超时)。这里只有一个线程来处理多个连接。不幸的是,这种方法要求我们不断地轮询(poll)所有I/O源,而这种"忙等(busy waiting)"方法又会引入很多系统开销,因为程序要在连接之间反复循环,却又发现什么都不用做。
我们需要一种方法来一次轮询一组客户端,以查找哪个客户端需要服务。这正是NIO中将要介绍的Selector和Channel抽象的关键点。一个Channel实例代表了一个"可轮询的(pollable)"I/O目标,如套接字(或一个文件、设备等)。Channel能够注册一个Selector类的实例。Selector的select()方法允许你询问"在一组信道中,哪一个当前需要服务(即,被接受,读或写)?"大量的细节将在后文中介绍,但这就是使用Selector和Channel的基本动机。这两个类都包含在java.nio.channels包中。
NIO中将介绍的另一个主要特性是Buffer类。就像selector和channel为一次处理多个客户端的系统开销提供了更高级的控制和可预测性,Buffer则提供了比Stream抽象更高效和可预测的I/O。 Stream 抽象好的方面是隐藏了底层缓冲区的有限性,提供了一个能够容纳任意长度数据的容器的假象。坏的方面是要实现这样一个假象,要么会产生大量的内存开销,要么会引入大量的上下文切换,甚至可能两者都有。在使用线程时,这些开销都隐藏在了具体实现中,因此也失去了对其的可控性和可预测性。这种方法使编写程序变得容易,但要调整它们的性能则变得更困难。不幸的是,如果要使用Java的Socket抽象,流就是唯一的选择。
这就是为什么要把channel设计为使用Buffer实例来传递数据。Buffer抽象代表了一个有限容量(finite-capacity)的数据容器--其本质是一个数组,由指针指示了在哪存放数据和从哪读取数据。使用Buffer有两个主要好处。第一,与读写缓冲区数据相关联的系统开销暴露给了程序员。例如,如果想要向缓冲区存入数据,但又没有足够的空间时,就必须采取一些措施来获得空间(即,移出一些数据,或移开已经在那个位置的数据来获得空间,或者创建一个新的实例)。这意味着需要额外的工作,但是你(程序员)可以控制它什么时候发生,如何发生,以及是否发生。一个聪明的程序员如果清楚地了解了应用程序的需求,就那能通过权衡这些选择来降低系统开销。第二,一些对Java对象的特殊Buffer映射操作能够直接操作底层平台的资源(例如,操作系统的缓冲区)。这些操作节省了在不同地址空间中复制数据的开销--这在现代计算机体系结构中是开销很大的操作。
相关下载:
Java_TCPIP_Socket编程(doc)
http://download.csdn.net/detail/undoner/4940239
文献来源:
UNDONER(小杰博客) :http://blog.csdn.net/undoner
LSOFT.CN(琅软中国) :http://www.lsoft.cn