- 本文会从传统的BIO到NIO再到AIO自浅至深介绍,并附上完整的代码讲解。
- 下面代码中会使用这样一个例子:客户端发送一段算式的字符串到服务器,服务器计算后返回结果到客户端。
- 代码的所有说明,都直接作为注释,嵌入到代码中,看代码时就能更容易理解,代码中会用到一个计算结果的工具类,见文章代码部分。
1 BIO编程
1.1、传统的BIO编程
- 网络编程的基本模型是C/S模型,即两个进程间的通信。
- 服务端提供IP和监听端口,客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
- 传统的同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功后,双方通过输入和输出流进行同步阻塞式通信。
- 简单的描述一下BIO的服务端通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答通信模型。
传统BIO通信模型图:
该模型最大的问题就是 缺乏弹性伸缩能力 ,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,Java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死-掉-了。
同步阻塞式I/O创建的Server源码:
123456789101112131415161718192021222324252627282930313233343536public final class ServerNormal {//默认的端口号private static int DEFAULT_PORT = 12345;//单例的ServerSocketprivate static ServerSocket server;//根据传入参数设置监听端口,如果没有参数调用以下方法并使用默认值public static void start() throws IOException{//使用默认值start(DEFAULT_PORT);}//这个方法不会被大量并发访问,不太需要考虑效率,直接进行方法同步就行了public synchronized static void start(int port) throws IOException{if(server != null) return;try{//通过构造函数创建ServerSocket//如果端口合法且空闲,服务端就监听成功server = new ServerSocket(port);System.out.println("服务器已启动,端口号:" + port);//通过无线循环监听客户端连接//如果没有客户端接入,将阻塞在accept操作上。while(true){Socket socket = server.accept();//当有新的客户端接入时,会执行下面的代码//然后创建一个新的线程处理这条Socket链路new Thread(new ServerHandler(socket)).start();}}finally{//一些必要的清理工作if(server != null){System.out.println("服务器已关闭。");server.close();server = null;}}}}客户端消息处理线程ServerHandler源码:
|
|
- 同步阻塞式I/O创建的Client源码 :
|
|
- 测试代码,为了方便在控制台看输出结果,放到同一个程序(jvm)中运行:
|
|
其中一次的运行结果:
1234567891011121314151617服务器已启动,端口号:12345算术表达式为:4-2服务器收到消息:4-2___结果为:2算术表达式为:5-10服务器收到消息:5-10___结果为:-5算术表达式为:0-9服务器收到消息:0-9___结果为:-9算术表达式为:0+6服务器收到消息:0+6___结果为:6算术表达式为:1/6服务器收到消息:1/6___结果为:0.16666666666666666...从以上代码,很容易看出,BIO主要的问题在于每当有一个新的客户端请求接入时,服务端必须创建一个新的线程来处理这条链路,在需要满足高性能、高并发的场景是没法应用的(大量创建新的线程会严重影响服务器性能,甚至罢工)。
1.2 伪异步I/O编程
- 为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现1个或多个线程处理N个客户端的模型(但是底层还是使用的同步阻塞I/O),通常被称为“伪异步I/O模型“。
伪异步I/O模型图:
实现很简单,我们只需要将新建线程的地方,交给线程池管理即可,只需要改动刚刚的Server代码即可:
|
|
- 测试运行结果是一样的。
- 我们知道,如果使用CachedThreadPool线程池(不限制线程数量,如果不清楚请参考文首提供的文章),其实除了能自动帮我们管理线程(复用),看起来也就像是1:1的客户端:线程数模型,而使用FixedThreadPool我们就有效的控制了线程的最大数量,保证了系统有限的资源的控制,实现了N:M的伪异步I/O模型。
- 但是,正因为限制了线程数量,如果发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中的有空闲的线程可以被复用。而对Socket的输入流就行读取时,会一直阻塞,直到发生:
- 有数据可读
- 可用数据以及读取完毕
- 发生空指针或I/O异常
- 所以在读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。而后面即将介绍的NIO,就能解决这个难题。
2 NIO 编程
- JDK 1.4中的
java.nio.*
包中引入新的Java I/O库,其目的是提高速度。实际上,“旧”的I/O包已经使用NIO重新实现过,即使我们不显式的使用NIO编程,也能从中受益。速度的提高在文件I/O和网络I/O中都可能会发生,但本文只讨论后者。
2.1 简介
- NIO我们一般认为是New I/O(也是官方的叫法),因为它是相对于老的I/O类库新增的(其实在JDK 1.4中就已经被引入了,但这个名词还会继续用很久,即使它们在现在看来已经是“旧”的了,所以也提示我们在命名时,需要好好考虑),做了很大的改变。但民间跟多人称之为Non-block I/O,即非阻塞I/O,因为这样叫,更能体现它的特点。而下文中的NIO,不是指整个新的I/O库,而是 非阻塞I/O 。
- NIO提供了与传统BIO模型中的Socket和ServerSocket相对应的SocketChannel和ServerSocketChannel两种不同的套接字通道实现。
- 新增的着两种通道都支持阻塞和非阻塞两种模式。
- 阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。
- 对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;对于 高负载、高并发的(网络)应用,应使用NIO的非阻塞模式来开发 。
2.2 缓冲区 Buffer
- Buffer是一个对象,包含一些要写入或者读出的数据。
- 在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,也是写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
- 缓冲区实际上是一个数组,并提供了对 数据结构 化访问以及维护读写位置等信息。
- 具体的缓存区有这些:ByteBuffe、CharBuffer、 ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。他们实现了相同的接口:Buffer。
2.3 通道 Channel
- 我们对数据的读取和写入要通过Channel,它就像水管一样,是一个通道。通道不同于流的地方就是通道是双向的,可以用于读、写和同时读写操作。
- 底层的操作系统的通道一般都是 全双工 的,所以全双工的Channel比流能更好的映射底层操作系统的API。
- Channel主要分两大类:
- SelectableChannel:用户网络读写
- FileChannel:用于文件操作
- 后面代码会涉及的ServerSocketChannel和SocketChannel都是SelectableChannel的子类。
2.4 多路复用器 Selector
selector是一个选择器,它可以选择某一个Channel,然后做些事情。
一个线程可以对应一个selector,而一个selector可以轮询多个Channel,而每个Channel对应了一个Socket。
与上面一个线程对应一个Socket相比,使用NIO后,一个线程可以轮询多个Socket。
当selector调用select()时,会查看是否有客户端准备好了数据。当没有数据被准备好时,select()会阻塞。平时都说NIO是非阻塞的,但是如果没有数据被准备好还是会有阻塞现象。
当有数据被准备好时,调用完select()后,会返回一个SelectionKey,SelectionKey表示在某个selector上的某个Channel的数据已经被准备好了。
只有在数据准备好时,这个Channel才会被选择。
这样NIO实现了一个线程来监控多个客户端。
selectNow()与select()的区别在于,selectNow()是不阻塞的,当没有客户端准备好数据时,selectNow()不会阻塞,将返回0,有客户端准备好数据时,selectNow()返回准备好的客户端的个数。
2.5 NIO服务端
- 代码比传统的Socket编程看起来要复杂不少。
- NIO创建的Server源码 :
|
|
- ServerHandle :
|
|
可以看到,创建NIO服务端的主要步骤如下:
- 打开ServerSocketChannel,监听客户端连接
- 绑定监听端口,设置连接为非阻塞模式
- 创建Reactor线程,创建多路复用器并启动线程
- 将ServerSocketChannel注册到Reactor线程中的Selector上,监听ACCEPT事件
- Selector轮询准备就绪的key
- Selector监听到新的客户端接入,处理新的接入请求,完成TCP三次握手,简历物理链路
- 设置客户端链路为非阻塞模式
- 将新接入的客户端连接注册到Reactor线程的Selector上,监听读操作,读取客户端发送的网络消息
- 异步读取客户端消息到缓冲区
- 对Buffer编解码,处理半包消息,将解码成功的消息封装成Task
- 将应答消息编码为Buffer,调用SocketChannel的write将消息异步发送给客户端
因为应答消息的发送,SocketChannel也是异步非阻塞的,所以不能保证一次能把需要发送的数据发送完,此时就会出现写半包的问题。我们需要注册写操作,不断轮询Selector将没有发送完的消息发送完毕,然后通过Buffer的hasRemain()方法判断消息是否发送完成。
2.6 NIO客户端
- 还是直接上代码吧,过程也不需要太多解释了,跟服务端代码有点类似。
- Client :
|
|
- ClientHandle :
|
|
2.7 演示结果
- 首先运行服务器,顺便也运行一个客户端:
|
|
- 我们也可以单独运行客户端,效果都是一样的。
一次测试的结果:
1234567服务器已启动,端口号:123451+2+3+4+5+6服务器收到消息:1+2+3+4+5+6客户端收到消息:211*2/3-4+5*6/7-8服务器收到消息:1*2/3-4+5*6/7-8客户端收到消息:-7.0476190476190474运行多个客户端,都是没有问题的。
3 AIO编程
- NIO 2.0引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。
- 异步的套接字通道是真正的 异步非阻塞I/O ,对应于UNIX网络编程中的事件驱动I/O(AIO)。他不需要过多的Selector对注册的通道进行轮询即可实现异步读写,从而简化了NIO的编程模型。
3.1 Server端代码
- Server :
|
|
- AsyncServerHandler :
|
|
- AcceptHandler :
|
|
- ReadHandler :
|
|
- OK,这样就已经完成了,其实说起来也简单,虽然代码感觉很多,但是API比NIO的使用起来真的简单多了,主要就是监听、读、写等各种CompletionHandler。此处本应有一个WriteHandler的,确实,我们在ReadHandler中,以一个匿名内部类实现了它。
3.2 Client端代码
- Client :
|
|
- AsyncClientHandler :
|
|
- ReadHandler :
|
|
3.3 测试
- Test :
|
|
- 我们可以在控制台输入我们需要计算的算数字符串,服务器就会返回结果,当然,我们也可以运行大量的客户端,都是没有问题的,以为此处设计为单例客户端,所以也就没有演示大量客户端并发。
- 读者可以自己修改Client类,然后开辟大量线程,并使用构造方法创建很多的客户端测试。
下面是其中一次参数的输出:
1234567891011服务器已启动,端口号:12345请输入请求消息:客户端成功连接到服务器...连接的客户端数:1123456+789+456服务器收到消息: 123456+789+456客户端收到结果:1247019526*56服务器收到消息: 9526*56客户端收到结果:533456...AIO是真正的异步非阻塞的,所以,在面对超级大量的客户端,更能得心应手。
4 各种I/O的对比
先以一张表来直观的对比一下:
具体选择什么样的模型或者NIO框架,完全基于业务的实际应用场景和性能需求,如果客户端很少,服务器负荷不重,就没有必要选择开发起来相对不那么简单的NIO做服务端;相反,就应考虑使用NIO或者相关的框架了。
5 附录
- 上文中服务端使用到的用于计算的工具类:
|
|
原文出处:anxpp