当前位置: 首页 > 科技观察

BIO和NIO你了解多少?让我们从实际的角度重新理解它

时间:2023-03-14 10:19:02 科技观察

01前言这段时间在看Java中的BIO、NIO等一些东西。看了很多博客,发现关于NIO的各种概念都说的很肤浅。可以说是很全了,但是看完全文,我对NIO还是一知半解,所以这篇文章不会提很多概念,而是从实用的角度,写一些我的自己对NIO的看法,站在实践之后回过头来看更高层次的概念,应该对概念有更深的理解。02实现一个简单的单线程服务器要理解BIO和NIO,首先要自己实现一个简单的服务器。不需要太复杂,单线程即可。2.1为什么用单线程来演示?因为在单线程环境下可以很好的比较BIO和NIO的区别。当然我也会在实际环境中演示一个线程对应一个BIO的所谓onerequest。2.2服务器publicclassServer{publicstaticvoidmain(String[]args){byte[]buffer=newbyte[1024];try{ServerSocketserverSocket=newServerSocket(8080);System.out.println("服务器已启动,监听8080端口");while(true){System.out.println();System.out.println("服务器正在等待连接...");Socketsocket=serverSocket.accept();System.out.println("服务器已收到连接请求...");System.out.println();System.out.println("服务器正在等待数据...");socket.getInputStream().read(buffer);System.out.println("服务器数据已接收");System.out.println();Stringcontent=newString(buffer);System.out.println("接收数据:"+content);}}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}2.3ClientpublicclassConsumer{publicstaticvoidmain(String[]args){try{Socketsocket=newSocket("127.0.0.1",8080);socket.getOutputStream().write("向服务器发送数据".getBytes());socket.close();}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}2.4代码分析我们首先创建了一个服务器类,在类中的实现实例化一个SocketServer并绑定8080端口,然后调用accept方法接收连接请求,调用read方法接收客户端发送的数据。最后打印接收到的数据。完成服务端的设计之后,我们来实现一个客户端。首先实例化Socket对象,绑定ip为127.0.0.1(本机),端口号为8080,调用write方法向服务器发送数据。2.5运行结果当我们启动服务器,但是客户端还没有向服务器发起连接时,控制台结果如下:当客户端启动并向服务器发送数据时,控制台结果如下:2.6结论来自上面的运行结果,首先我们至少可以看出,在服务端启动后,当客户端还没有连接到服务端时,服务端会一直阻塞,直到有客户端请求连接到服务端,因为调用了accept方法.03扩展客户端功能上面我们实现的客户端的逻辑主要是建立Socket-->连接服务器-->发送数据。我们的数据在连接到服务器后立即发送。现在我们来分析一下客户端执行扩展。当我们连接到服务器时,并不会立即发送数据,而是等待控制台手动输入数据,然后再发送给服务器。(服务端代码不变)3.1代码.next();socket.getOutputStream().write(message.getBytes());socket.close();sc.close();}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}3.2测试服务端启动,客户端未请求连接服务端时,控制台结果如下:服务端启动时,客户端连接服务端,但没有发送数据,控制台结果如下如下:当客户端连接到服务器并发送数据时,控制台结果如下:3.3结论从上面的运行结果可以看出,服务器启动后,首先需要等待客户端的连接请求(第一个块)。如果没有客户端连接,服务器会一直阻塞等待,然后当客户端连接时,服务器会等待客户端发送数据(第二次阻塞),如果客户端不发送数据,那么服务器会一直阻塞等待客户端发送数据。从服务器启动到接收客户端数据的过程中会有两个阻塞进程。这是BIO的一个非常重要的特性。BIO会阻塞两次,第一次在等待连接时阻塞,第二次在等待数据时阻塞。04BIO4.1单线程条件下BIO的弱点上面我们实现了一个简单的单线程运行的服务器。其实不难看出,当我们的服务端收到连接,并没有收到客户端发送的数据时,会阻塞在read()方法中,那么此时如果客户端再有请求时间,服务器无法响应。也就是说,如果不考虑多线程,BIO就无法处理多个客户端请求。4.2BIO如何处理并发在刚才的服务器实现中,我们实现了单线程版本的BIO服务器。不难看出单线程版本的BIO无法处理多个客户端的请求,那么BIO如何处理多个客户端呢?结束请求。其实不难想象,我们只需要在每次连接请求来的时候创建一个线程来执行连接请求,然后我们就可以在BIO中处理多个client请求,这也是为什么BIO的概念之一是server执行。模式是一个线程一个连接,即当客户端有连接请求时,服务端需要启动一个线程进行处理。4.3多线程BIO服务器的简单实现publicclassServer{publicstaticvoidmain(String[]args){byte[]buffer=newbyte[1024];try{ServerSocketserverSocket=newServerSocket(8080);System.out.println("服务器已启动并侦听端口8080");while(true){System.out.println();System.out.println("服务器正在等待连接...");Socketsocket=serverSocket.accept();newThread(newRunnable(){@Overridepublicvoidrun(){System.out.println("服务器收到连接请求...");System.out.println();System.out.println("服务器正在等待数据...");try{socket.getInputStream().read(buffer);}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}System.out.println("服务器已收到数据");System.out.println();Stringcontent=newString(buffer);System.out.println("接收到的数据:"+content);}}).start();}}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}4.4运行结果明显。现在我们服务器的状态是一个线程对应一个请求。换句话说,服务器为每个连接请求创建一个线程来处理。4.5多线程BIO服务器的缺点虽然多线程BIO服务器解决了单线程BIO无法处理并发的弱点,但是也带来了一个问题:如果有大量的请求连接到我们的服务器但是没有消息发送,那么我们服务器也会为这些不发送消息的请求创建一个单独的线程,所以如果连接数少的话还好,但是如果连接数太大的话,就会对服务器造成很大的压力.所以如果这种不活跃的线程很多,我们应该采用单线程的方案,但是单线程不能处理并发,就陷入了一个很矛盾的状态,所以就有了NIO。05NIO5.1NIO的介绍我们来看看单线程模式下BIO服务器的代码。其实NIO需要解决的最根本的问题就是BIO中的两个block,分别是等待连接和等待数据时的block。时间阻塞。publicclassServer{publicstaticvoidmain(String[]args){byte[]buffer=newbyte[1024];try{ServerSocketserverSocket=newServerSocket(8080);System.out.println("服务器已经启动,监听8080端口");while(true){System.out.println();System.out.println("服务器正在等待连接...");//块1:等待连接时阻塞Socketsocket=serverSocket.accept();System.out.println("服务器收到连接请求...");System.out.println();System.out.println("服务器正在等待数据...");//阻塞2:阻塞socket.getInputStream().read(buffer);System.out.println("服务器已收到数据");System.out.println();Stringcontent=newString(buffer);System.out.println("Receiveddata:"+content);}}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}需要重复的一点是,如果单线程服务器在等待数据的时候阻塞了,那么第二次连接请求来的时候,服务器是没有响应的。如果是多线程服务器,大量空闲请求会产生新的线程,导致线程占用系统资源,线程被浪费。那么我们的问题就转移到如何让单线程服务器在等待客户端数据到达的同时,仍然接收到新的客户端连接请求。5.2模拟NIO的解决方案如果要解决上面提到的单线程服务器在接收数据时阻塞,无法接收新请求的问题,那么其实可以防止服务器在等待数据时进入阻塞状态。问题不是解决了吗?(1)第一种方案(等待连接和等待数据时不阻塞)publicclassServer{publicstaticvoidmain(String[]args)throwsInterruptedException{ByteBufferbyteBuffer=ByteBuffer.allocate(1024);try{//java是为非类设置的blockingServerSocketChannelserverSocketChannel=ServerSocketChannel.open();serverSocketChannel.bind(newInetSocketAddress(8080));//设置为非阻塞serverSocketChannel.configureBlocking(false);while(true){SocketChannelsocketChannel=serverSocketChannel.accept();){//表示没有连接System.out.println("等待客户端请求连接...");Thread.sleep(5000);}else{System.out.println("正在接收客户端请求Connect...");}if(socketChannel!=null){//设置为非阻塞socketChannel.configureBlocking(false);byteBuffer.flip();//切换模式写-->读无效=socketChannel.read(byteBuffer);if(effective!=0){Stringcontent=Charset.forName("utf-8").decode(byteBuffer).toString();System.out.println(content);}else{System.out.println("当前没有收到客户端消息");}}}}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}从运行结果不难看出,在这种方案下,虽然在接收客户端消息时不会阻塞,但是又开始接收服务器请求,用户来不及输入消息,服务器转而接收其他客户端请求,也就是说服务器丢失了当前客户端请求(2)方案二(缓存Socket,轮询数据是否就绪)try{//Java设置为非阻塞类ServerSocketChannelserverSocketChannel=ServerSocketChannel.open();serverSocketChannel.bind(newInetSocketAddress(8080));//设置为非阻塞serverSocketChannel.configureBlocking(false);while(true){SocketChannelsocketChannel=hanelSocket.accept();if(socketChannel==null){//表示没有连接System.out.println("Waitingforclientrequesttoconnect...");Thread.sleep(5000);}否则{系统。out.println("当前正在接收客户端连接请求...");socketList.add(socketChannel);}for(SocketChannelsocket:socketList){socket.configureBlocking(false);inteffective=socket.read(byteBuffer);if(effective!=0){byteBuffer.flip();//切换模式写入-->读取Stringcontent=Charset.forName("UTF-8").decode(byteBuffer).toString();System.out.println("收到消息:“+内容”;byteBuffer.clear();}else{System.out.println("Noclientmessageiscurrentlyreceived");}}}}catch(IOExceptione){//TODOAuto-generatedcatchblocke.printStackTrace();}}}运行结果代码分析中方案一,我们采用了非阻塞的方式,但是发现一旦是非阻塞的,在等待客户端发送消息的时候就不会再阻塞了,而是直接重新获取新客户端的连接请求,这会导致客户端连接丢失,而第二种解决方案,我们将连接存储在一个列表集合中,每次等待客户端消息时轮询,看消息是否准备好,如果准备好,则打印直接留言。可以看到,从头到尾,我们并没有开启第二个线程,而是一直使用单线程来处理多个客户端的连接。这样的模式可以完美解决单线程模式下BIO无法处理多客户端请求的问题。并解决非阻塞状态下的连接丢失问题。(3)存在的问题(方案2)从刚才的运行结果可以看出,消息没有丢失,程序也没有被阻塞。但是,接收消息的方式可能有问题。我们使用轮询的方式来接收消息,每次都轮询所有的连接,看消息是否准备好了。测试用例只有三个连接,所以我看不出有什么问题,但是我们假设有1000万个连接甚至更多。这种轮询方式效率极低。另外,1000万个连接中,我们可能只有100万条消息,剩下的900万不会发送任何消息,所以这些连接程序每次还是要轮询,显然是不合适的。在realNIO中如何解决在realNIO中,java层并没有进行轮询,而是将轮询这一步交给了我们的操作系统,将轮询的部分代码改成了运行系统级的系统调用(select函数,linux环境下的epoll),在操作系统层面调用select函数,主动感知有数据的socket。06关于在应用层使用select/epoll和直接轮询的区别,我们之前用Java实现了轮询多个client连接的逻辑,但是在真正的NIO源码中并没有真正实现。NIO使用了操作系统底层的轮询系统调用select/epoll(windows:select,linux:epoll),那为什么不直接实现而是调用系统做轮询呢?6.1select底层逻辑假设有A,B,C,D,E同时连接到服务器,那么根据我们上面的设计,程序会遍历这五个连接,轮询每个连接,并获取各自的数据准备状态,那么和自己写的程序有什么区别呢?什么?首先,我们写的Java程序的本质是需要在轮询每个Socket的时候调用系统函数,所以轮询调用一次会造成不必要的上下文切换开销。Select会将用户态空间的5个请求全部复制到内核态空间,并判断每个请求是否准备好内核态空间的数据,完全避免了频繁的上下文切换。所以效率比直接在应用层写轮询要高。如果select没有找到有数据的请求,它就会一直阻塞(没错,select就是阻塞函数)。如果一个或多个请求已经准备好数据,那么select会先用数据设置文件描述符,然后select返回。返回后通过遍历查看哪个请求有数据。select的缺点是底层存储依赖bitmap,处理的请求数限制在1024个。文件描述符会被设置,所以设置的文件描述符如果需要重用,需要重新赋值null。将fd(文件描述符)从用户模式复制到内核模式仍然存在开销。select返回后,需要再次遍历才能知道是哪个request有数据。6.2poll函数的底层逻辑poll的工作原理和select非常相似。我们先来看一下poll内部使用的一个结构体。结构pollfd{intfd;短期活动;shortrevents;}poll也会将所有请求复制到内核态。和select一样,poll也是一个阻塞函数。当一个或多个请求有数据时,它也会设置该位,但它设置的是结构体pollfd中的events或revents,而不是设置fd本身,所以下次使用时不需要重新赋null值。poll内部存储不依赖bitmap,而是使用了pollfd数组这样的数据结构,数组的大小必须大于1024。解决了select1和2的不足。6.3epollepoll是最新的IO多路复用功能。这里只说说它的特点。epoll与上述两个函数最大的区别是它的fd是用户态和内核态共享的,所以不需要从用户态到内核态做拷贝,可以节省系统资源;另外,在select和poll中,如果某个请求的数据准备好了,它们会返回所有的请求,让程序遍历看哪个请求有数据,但是epoll只会返回有数据的请求,因为epoll发现某个请求有数据时,会先进行重排操作,把所有有数据的fd放在最前面的位置,然后返回(返回值是请求数据的次数N),那么我们上层程序并没有需要对所有请求进行轮询,但是直接遍历epoll返回的前N个请求,而这些请求都是带数据的请求。07Java中BIO和NIO的概念通常有些文章会把概念放在开头,这次我选择把概念放在最后,因为通过上面的实际操作,相信大家对BIO和NIO都有了自己的理解在Java中有所了解,这个时候重新理解一下概念应该会比较好。7.1先举个例子来理解概念。以银行取款为例进行同步:亲自持银行卡去银行取款(使用同步IO时,Java自己处理IO读写)。异步:委托小弟拿着银行卡去银行取钱,然后给你(使用异步IO时,Java将IO读写委托给OS处理,需要传地址和大小数据缓冲区到OS(银行卡和密码),OS需要支持异步IO操作API)。阻塞:取款ATM排队,只能等待(使用阻塞IO时,Java调用会阻塞,直到读写完成才返回)。非阻塞:在柜台取现,取号,然后坐在椅子上做其他事情。等待号码广播会通知您办理。不到号就不能去。未到不能走(使用非阻塞IO时,如果不能读写,Java调用会立即返回,等IO事件分发器通知可以读写时,继续读写,不断循环,直到读写完成)。7.2Java对BIO和NIO的支持JavaBIO(blockingI/O):同步和阻塞,服务端实现方式为一个线程一个连接,即当客户端有连接请求时,服务端需要启动一个线程进行处理,如果连接什么都不做会造成不必要的线程开销,当然可以通过线程池机制来改善。JavaNIO(非阻塞I/O):同步非阻塞,服务端实现方式为一个线程一个请求,即客户端发送的连接请求会注册到多路复用器上,多路复用器轮询连接时有I/O请求,启动线程处理。7.3BIO和NIO适用场景分析BIO方式适用于连接数比较少且固定的架构。这种方式对服务器资源要求比较高,并发限制在应用。在JDK1.4之前是唯一的选择,但是程序直观、简单、易懂。NIO方式适用于连接数较多,连接比较短(轻操作)的架构,比如聊天服务器。并发仅限于应用程序,编程更复杂。JDK1.4开始支持了。08结语本文从自己实际操作的角度介绍了对JavaBIO和NIO的一些认识。我个人认为这样理解BIO和NIO,会比只看概念有更深刻的理解。也希望同学们自己打出来,通过程序的运行结果得到自己对JavaBIO和NIO的理解。