毕玄老师发表了一篇公众号文章:来测试下你的Java编程能力,本系列文章为其中问题的个人解答。
第一个问题:
基于BIO实现的Server端,当建立了100个连接时,会有多少个线程?如果基于NIO,又会是多少个线程? 为什么?
说实话,如果面试被问到这个问题,也不敢保证能完全答对。那么就回炉重造一下吧。
最简单的BIO Server
服务端
1 | package com.xetlab.javatest.question1; |
客户端
1 | package com.xetlab.javatest.question1; |
对应的输出(已按顺序组织)
1 | 2019-03-23 23:36:39,480 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 0.主线程启动 |
如果我们按上面的方式实现Server端,答案会是:BIO Server端,一个线程就够了。我们来分析下这种实现方式的优缺点。
优点
- 简单,适合java socket编程入门。
- 好像只有简单了。
缺点
一次只能服务一个客户端,别的客户端只能等待,具体表现是:如果同时启动两个慢客户端,那么两个客户端的底层TCP连接是建立好的,先启动的客户端会先得到服务,但后启动的那个客户端会在读取数据时一直被阻塞,如下所示(windows):
netstat -ano|find “9999”
1
2TCP 127.0.0.1:9999 127.0.0.1:29712 ESTABLISHED 16996
TCP 127.0.0.1:9999 127.0.0.1:29740 ESTABLISHED 16996服务端输出
1
2
32019-03-24 10:47:48,881 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 0.主线程启动
2019-03-24 10:47:52,549 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 1.客户端 127.0.0.1:29712 已连接
2019-03-24 10:47:52,550 [INFO] com.xetlab.javatest.question1.ServerMain1 [main] - 2.向客户端发欢迎消息客户端1收到消息后,休眠
1
2019-03-24 10:47:52,555 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服务端消息:你好,请报上名来!
客户端2
1
2//客户端2在此处被阻塞
socket.getInputStream().read(byteBuf);
- 实现不了同时服务100个客户端。
因此这种方式实现的Server端,只能用于入门示例,不能用于生产环境。另外BIO全称是Blocking IO,即阻塞式IO,这个BIO体现在哪呢?体现在这两处:
1 | //1.当客户端没发消息过来时,此时服务端读取消息时就会阻塞 |
Tips
- BIO其实包含两层含义:读取时数据未准备好,当前线程会阻塞;数据的读写是耗时的操作。
- server和client之间的通信通过socket的InputStream和OutputStream进行。
- server和client之间的通信需要预先定义好通信协议(如示例中就隐含了一个规定,大家每次发送的消息不超过1024个字节,读取时也是读取最多1024个字节,如果违反了这个规定,要吗数据乱了,要吗server或client在读取数据时被阻塞)。
- 写数据时要记得flush一下,不然数据只是写到缓存里,并没有发送出去。
引入多线程
服务端
1 | package com.xetlab.javatest.question1; |
输出
客户端保持不变,只是把其中一个在回复名字前故意休眠很久,另一个保持正常。此时各端的输出如下:
服务端
1 | 2019-03-24 12:50:56,514 [INFO] com.xetlab.javatest.question1.ServerMain2 [main] - 0.主线程启动 |
慢客户端先连接,收到消息后,休眠
1 | 2019-03-24 12:51:02,619 [INFO] com.xetlab.javatest.question1.ClientMain1 [main] - 3.收到服务端消息:你好,请报上名来! |
正常客户端后连接
1 | 2019-03-24 12:51:08,336 [INFO] com.xetlab.javatest.question1.ClientMain2 [main] - 3.收到服务端消息:你好,请报上名来! |
可以看到,引入多线程后,每个线程服务一个客户端,可以同时服务100个连接了,如果这样实现Server端,IO还是BIO,线程数需要101个,一个线程用于接受客户端连接,100个线程用于服务客户端。同样来分析下优缺点。
优点
- 简单,和最简单版本相比,只是把和客户端IO相关的处理放到了线程里处理。
- 可以同时服务N个连接。
缺点
- 每个线程都要占用内存,当客户端保持长连接,数量越来越多达到一定值时,就会出现错误:OutOfMemoryError:unable to create new native thread。
- 一个客户端分配一个线程,太浪费资源了,因为BIO的缘故,线程大部分时间都处于阻塞或等待读写状态。
- 即使机器性能高,内存大,当线程很多时,线程上下文切换也会带来很大的开销。
Tips
编写多线程任务时,可以把执行任务的逻辑使用Runnable接口来实现,这样任务可以直接放到Thread线程对象里执行,也可以提交到线程池中去执行。
NIO上场
有没有可能同时具备方式一和二的优点呢,具体来说就是,一个线程同时服务N个客户端?Yes,NIO就可以!那什么是NIO?NIO即New IO,更多时候我们是看成Non blocking IO,就是非阻塞IO。
具体NIO如何实现一个线程服务N个客户端,在深入代码细节前,我们先理一理。
回顾上面的BIO实现,我们知道有这几个点会阻塞或者响应慢:
- serverSocket.accept(),这里是服务端等待客户端连接。
- clientSocket.getInputStream().read(),这里是等待客户端传送数据过来。
- clientSocket.getOutputStream().write(),这里是往客户端写数据。
由于会阻塞或者响应慢BIO用了不同的线程去分别处理,如果可以只由一个线程去负责检查是否有客户端连接,客户端的数据是否可读,是否可以往客户端写数据,当有对应的事件已经准备好时,再由于当前线程去处理相应的任务,那就完美了。
NIO里有个对象是Selector,这个Selector就是用于注册事件,并检查事件是否已准备好。现在来看下具体代码。
1 | package com.xetlab.javatest.question1; |
上面我们用NIO实现了和原来BIO一模一样的逻辑,NIO确实是只用一个线程高效的解决了问题,但是代码看起来复杂多了。不过我们用伪代码总结一下,会简单一点:
- 准备好Selector(源代码注释中叫channel多路复用器)。
- 准备好ServerSocketChannel(对应BIO里的ServerSocket)。
- ServerSocketChannel向Selector注册accept事件(即客户端连接就绪事件)
- 循环
- 检查Selector是否有新的就绪事件,如果没有就阻塞等待,如果有就返回产生的就绪事件列表。
- 如果是accept事件(客户端连接就绪事件),就接受客户端连接得到SocketChannel(对应BIO中的Socket),SocketChannel向Selector注册读写就绪事件。
- 如果是读就绪事件,那么读取对应SocketChannel的数据,并进行相应的处理。
- 如果是写就绪事件,那么就把数据写到对应的SocketChannel。
Tips
NIO中,由于是单线程,不能在连接就绪,读写就绪之后的事件处理逻辑执行耗时操作,那样将会让服务性能急剧下降,正确方法应该是把耗时的逻辑放在独立的线程中去执行,或放到专门的worker线程池中执行。
源代码
1 | https://github.com/huangyemin/javatest |