现在的位置: 首页 > 综合 > 正文

Java NIO 详解

2013年07月09日 ⁄ 综合 ⁄ 共 3698字 ⁄ 字号 评论关闭



前记:


      


有一个周末的晚上,我突然想阅读以前写过的一些引以为自豪的程序;于是乎,我就把
9
个月前做过的一个项目的源码翻出来读了,这是一个
Socket
项目,服务器端是用
Java
实现的,客户端是用
Flash
实现的;这个项目的目的就是需要保证服务器端和客户端的连接不掉链,并且如果客户端有心跳超时或者连接断开的情形
,
我们会立即通知另一个应用程序(
probe
)。

   

这个程序本身的业务非常简单,也就是:与客户端建立连接,客户端发送事件数据,转发事件数据,检测连接状态,通知
probe
,监控等。然而因为这是一个网络编程并且又是
socket
,而且还要支持大并发操作,当然从技术上来讲还是有一点难度的;直观的印象肯定是服务器端肯定需要用到很多线程池,流操作,字符操作,移位操作,锁操作等比较高级的技术;确实,我们在开发这个项目的时候,为了权衡高并发和数据一致性方面做了很多的工作,利用各种各样的锁。

   

当时,我也是第一次写这样的程序,这个项目做完之后我还是挺有成就感的。因为从中我学会很多很复杂所谓很高级的技术。
9
个月之后的这个晚上,当我再次拜读这个项目的代码的时候,我有一种莫名的忧伤,我似乎再也不可能以此段代码来引以自豪了。代码本身没有什么漏洞和
bug
,但从架构上和效率上来讲实在是有点低下了,程序中应用了大量的锁,大量的轮询,大量的阻塞等待,大量的线程休眠操作,这些操作都会浪费资源并且导致效率的低下。

.架构图



      上图
就是这个项目的总体结构图,从图中可以看出该程序分为这几大块:连接侦听线程、连接对象队列、发送线程池、接收线程池、分发线程、事件处理对象、监控处理对象。下面我将描述下整个连接处理过程:

1、


连接侦听线程循环接收一个连接请求,如果有连接请求过来,则返回一个连接
Socket
对象,否则该线程就阻塞等待,直到有一个连接请求过来。

2、


封装该返回的
Socket
对象(主要是封装获取完整包数据,发送方法,关闭方法等)成
Connection
对象,并把封装好的
Connection
对象放入连接对象队列。

3、


分发线程不停的轮询连接对象队列,如果发现有可接收数据的连接对象,则扔给接收线程池去处理;如果发现有可发送数据的连接对象,则扔给发送线程池去处理。如果轮询一圈发现既没有可发送数据的连接对象也没有可接收数据的连接对象,则该线程会休眠一段时间,休眠过后又接着循环。

4、


发送线程池内有一个连接对象队列,从队列中取出一个连接对象并发送数据,且记录连接状态信息。

5、


接收线程池内也有一个连接对象队列,从队列中取出一个连接对象并接收一个数据包,且记录连接状态信息。如果接收的数据包是心跳检测包则更新连接状态,如果是数据包则通过事件处理对象发送给
probe
系统。

   

从上面的过程来看,我们可能看不出设计上面的漏洞,但有几个地方确实非常影响效率,在这里我想先提出来:

1、


连接侦听线程一直在侦听,有连接请求过来则会返回,没有则会阻塞;这样这个线程就会一直挂着;如果时时刻刻都有很多的连接过来,这个线程还会充分发挥它的作用,但其实大部分时候,连接请求并没有这么频繁,所以这个线程大部分时间是阻塞的;这样为了这样一个功能单独利用一个线程就有点浪费了。

2、


分发线程不停的轮询过程是导致整个系统效率低下最严重的一块,分发线程不停的轮询连接对象队列,其实分发线程并不知道哪个线程需要发送数据,哪些线程需要接收数据,而他只是盲目地从队列的头遍历到队列的尾部,如果发现没有可操作的连接对象则休眠一段时间;其实在大部分情况下,连接对象并不是时时刻刻都有数据发送和接收,所以这个分发线程大部分时间空循环,白忙了;并且这个休眠时间也不好控制,如果时间长了,则程序的即时性不够,如果太短了,程序似乎就是在空跑了。

3、


在连接对象上发送和接收数据包的时候,这些方法都是阻塞操作的;所以当有大量的数据可接收和发送的时候,这种阻塞的操作时非常浪费资源的。

   

以上所提出的问题,如果是在并发规模比较小的情况下,是没有什么问题;但确实有很大的改进空间。上面的问题归结起来主要是两个:

1、


当有连接请求过来或者有
Socket
连接有数据可读可写的时候,我们不会立即知道,我们必须要一个一个的轮询,我们能否有一种机制,即是,当有连接请求过来或者连接有数据可读或者可写的时候,直接通知我们来处理,而不需要我们主动轮询。

2、


当读数据或者写数据的时候,所有的方法都阻塞了,能不能有一种办法当我们写数据或者接收数据的时候不用阻塞,而是直接返回,这样就明显提高了线程的使用率了。

   

值得我们庆幸的是,在
Java

JDK1.4
之后的版本,提供了
NIO
包,这里提出了事件驱动的
I/O
编程模式和非阻塞信道的概念,
NIO
里面的
Selector
对象解决了上面提出分发和轮询的问题,
Channel
接口解决了阻塞读写的问题。我相信这些组件能够帮我们解决上面所提出的所有问题。所以下面有很大一部分篇幅来介绍
NIO
的使用和一些底层的机制。

 

NIO

详解:


   
NIO

主要包括两个部分:
java.nio.channels
包介绍
Selector

Channel
抽象,
java.nio
包介绍
Buffer
抽象。这都是一些高级的特性,有许多微妙的使用细节。但是,我在下面不会对
Buffer
抽象上介绍太多,如果想要更深入的了解
Buffer
对象请阅读相关的书籍和资料。

1、



为什么需要

NIO

基本的
Java
套接字对于小规模系统可以很好地运行,但当涉及同时处理几千甚至上万个客户端的服务器时,可能会产生一些问题。如果一个客户端一个线程的方式去处理,则由于创建、维护和切换线程需要的系统开销导致系统扩展性方面受到了很大限制;当然你也可以使用线程池,也可以节省一些开销,也同时可以使用并行硬件的优势,比如
F5
,网络连接均衡服务器等等。但对于连接生存期比较长的协议来说,线程池的大小仍然限制了系统可以同时处理的客户端数量。考虑一个在客户端之间传递消息的即时消息服务器
IM
。客户端必须不停地连接服务器以接收即时消息,因此线程池的大小限制了系统可以同时服务的客户端总数。如果增加线程池的大小,将带来更多的线程处理开销,而不能提升系统的性能,因为在大部分的时间里客户端是处于空闲状态的。

如果这是所有问题,可能
NIO
还不是必须的。不幸的是,在使用线程的扩展性方面还涉及一些更加难把握的挑战。其中一个挑战就是程序员几乎不能对什么时候哪个线程将获得服务进行控制。你可以设置一个线程实例的优先级,但是这个优先级只是一种“建议”,下一个选择执行的线程完全取决于具体实现。因此,如果程序员想要保证某些连接优先获得服务,或想要制定一定的服务顺序,线程可能就很难做到。

然而,有关线程的最重要的问题可能要保证数据的一致性,但很多客户端之间共享一些状态信息时,这就需要使用锁机制或者其他互斥机制对依次访问状态进行严格的同步。否则,由于不同线程上的程序段交错执行,他们之间会改掉其他线程说做的修改。

由于需要对共享状态进行同步访问,要同时考虑到多线程服务器的正确性和高效性就变得非常困难。使用锁机制将增加更多的的系统调度和上下文切换开销,而程序员对这些开销又无法控制。由于其复杂性,一些程序员宁愿继续使用单线程方法。这类服务器只用一个线程来处理所有客户端,但不是顺序处理,而是一次全部处理。这种服务器不能为任何客户端提供
I/O
操作的阻塞等待,而必须排他地使用非阻塞
I/O

在我们写
Socket
服务器端的时候,肯定会用到
ServerSocket
类的
accept
方法,当在
ServerSocket
实例上调用
accept
方法时,如果有一个新的连接来了,则
accept
方法会立即返回一个
socket
实例,否则该方法将一直阻塞直到有新的连接到来或计时器超时。假设我们用一个线程专门来处理连接的请求,也就是
accept
方法;不幸的是,我们会发现这种方法要求我们不断地轮询所有的
I/O
源,而这种“忙等”方法又会引入很多系统开销,因为程序要反复循环地连接
I/O
源,却又发现什么都不用做。以下代码就是一个典型的处理客户端请求方式,循环一直在跑,除非有人把循环标志给修改了,
server.accept()
方法一直在阻塞直到有一个新的的连接过来,如果有新的连接过来这返回一个
socket
实例,并扔给连接管理器去处理,如果一直都没有连接过来则一直阻塞在那里死等。

while

(!
bCanExit
) {

try

{

   
 
  

 
//

该方法一直会阻塞,直到有新的连接过来

   
   
 
Socket
socket =

server
.accept();

      
 
Connection connection =

new

Connection(socket);

      
 
connection.setClientId(Util.random32UUID
());

      
 

connectionManager
.add(connection);

      
 

if

(
logger
.isInfoEnabled()){

             

logger
.info(
"
有一个新的连接
!"
);

抱歉!评论已关闭.