这段时间本身在看一些Java中BIO和NIO之类的东西,看了不少博客,发现各类关于NIO的概念说的天花乱坠头头是道,能够说是很是的完整,可是整个看下来以后,本身对NIO仍是只知其一;不知其二的状态,因此这篇文章不会提到不少的概念,而是站在一个实践的角度,写一些我本身关于NIO的看法,站在实践事后的高度下再回去看概念,应该对概念会有一个更好的理解。java
要讲明白BIO和NIO,首先咱们应该本身实现一个简易的服务器,不用太复杂,单线程便可。linux
由于在单线程环境下能够很好地对比出BIO和NIO的一个区别,固然我也会演示在实际环境中BIO的所谓一个请求对应一个线程的情况。编程
public class Server { public static void main(String[] args) { byte[] buffer = new byte[1024]; try { ServerSocket serverSocket = new ServerSocket(8080); System.out.println("服务器已启动并监听8080端口"); while (true) { System.out.println(); System.out.println("服务器正在等待链接..."); Socket socket = serverSocket.accept(); System.out.println("服务器已接收到链接请求..."); System.out.println(); System.out.println("服务器正在等待数据..."); socket.getInputStream().read(buffer); System.out.println("服务器已经接收到数据"); System.out.println(); String content = new String(buffer); System.out.println("接收到的数据:" + content); } } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
public class Consumer { public static void main(String[] args) { try { Socket socket = new Socket("127.0.0.1",8080); socket.getOutputStream().write("向服务器发数据".getBytes()); socket.close(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
咱们首先建立了一个服务端类,在类中实现实例化了一个SocketServer并绑定了8080端口。以后调用accept方法来接收链接请求,而且调用read方法来接收客户端发送的数据。最后将接收到的数据打印。windows
完成了服务端的设计后,咱们来实现一个客户端,首先实例化Socket对象,而且绑定ip为127.0.0.1(本机),端口号为8080,调用write方法向服务器发送数据。数组
当咱们启动服务器,但客户端尚未向服务器发起链接时,控制台结果以下:缓存
当客户端启动并向服务器发送数据后,控制台结果以下:服务器
从上面的运行结果,首先咱们至少能够看到,在服务器启动后,客户端尚未链接服务器时,服务器因为调用了accept方法,将一直阻塞,直到有客户端请求链接服务器。数据结构
在上文中,咱们实现的客户端的逻辑主要是,创建Socket --> 链接服务器 --> 发送数据,咱们的数据是在链接服务器以后就当即发送的,如今咱们来对客户端进行一次扩展,当咱们链接服务器后,不当即发送数据,而是等待控制台手动输入数据后,再发送给服务端。(服务端代码保持不变)多线程
public class Consumer { public static void main(String[] args) { try { Socket socket = new Socket("127.0.0.1",8080); String message = null; Scanner sc = new Scanner(System.in); message = sc.next(); socket.getOutputStream().write(message.getBytes()); socket.close(); sc.close(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
当服务端启动,客户端尚未请求链接服务器时,控制台结果以下:架构
当服务端启动,客户端链接服务端,但没有发送数据时,控制台结果以下:
当服务端启动,客户端链接服务端,而且发送数据时,控制台结果以下:
从上文的运行结果中咱们能够看到,服务器端在启动后,首先须要等待客户端的链接请求(第一次阻塞),若是没有客户端链接,服务端将一直阻塞等待,而后当客户端链接后,服务器会等待客户端发送数据(第二次阻塞),若是客户端没有发送数据,那么服务端将会一直阻塞等待客户端发送数据。
服务端从启动到收到客户端数据的这个过程,将会有两次阻塞的过程。这就是BIO的很是重要的一个特色,BIO会产生两次阻塞,第一次在等待链接时阻塞,第二次在等待数据时阻塞。
在上文中,咱们实现了一个简易的服务器,这个简易的服务器是以单线程运行的,其实咱们不难看出,当咱们的服务器接收到一个链接后,而且没有接收到客户端发送的数据时,是会阻塞在read()方法中的,那么此时若是再来一个客户端的请求,服务端是没法进行响应的。换言之,在不考虑多线程的状况下,BIO是没法处理多个客户端请求的。
在刚才的服务器实现中,咱们实现的是单线程版的BIO服务器,不难看出,单线程版的BIO并不能处理多个客户端的请求,那么如何能使BIO处理多个客户端请求呢。
其实不难想到,咱们只须要在每个链接请求到来时,建立一个线程去执行这个链接请求,就能够在BIO中处理多个客户端请求了,这也就是为何BIO的其中一条概念是服务器实现模式为一个链接一个线程,即客户端有链接请求时服务器端就须要启动一个线程进行处理。
public class Server { public static void main(String[] args) { byte[] buffer = new byte[1024]; try { ServerSocket serverSocket = new ServerSocket(8080); System.out.println("服务器已启动并监听8080端口"); while (true) { System.out.println(); System.out.println("服务器正在等待链接..."); Socket socket = serverSocket.accept(); new Thread(new Runnable() { @Override public void run() { System.out.println("服务器已接收到链接请求..."); System.out.println(); System.out.println("服务器正在等待数据..."); try { socket.getInputStream().read(buffer); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } System.out.println("服务器已经接收到数据"); System.out.println(); String content = new String(buffer); System.out.println("接收到的数据:" + content); } }).start(); } } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
很明显,如今咱们的服务器的状态就是一个线程对应一个请求,换言之,服务器为每个链接请求都建立了一个线程来处理。
多线程BIO服务器虽然解决了单线程BIO没法处理并发的弱点,可是也带来一个问题:若是有大量的请求链接到咱们的服务器上,可是却不发送消息,那么咱们的服务器也会为这些不发送消息的请求建立一个单独的线程,那么若是链接数少还好,链接数一多就会对服务端形成极大的压力。因此若是这种不活跃的线程比较多,咱们应该采起单线程的一个解决方案,可是单线程又没法处理并发,这就陷入了一种很矛盾的状态,因而就有了NIO。
咱们先来看看单线程模式下BIO服务器的代码,其实NIO须要解决的最根本的问题就是存在于BIO中的两个阻塞,分别是等待链接时的阻塞和等待数据时的阻塞。
public class Server { public static void main(String[] args) { byte[] buffer = new byte[1024]; try { ServerSocket serverSocket = new ServerSocket(8080); System.out.println("服务器已启动并监听8080端口"); while (true) { System.out.println(); System.out.println("服务器正在等待链接..."); //阻塞1:等待链接时阻塞 Socket socket = serverSocket.accept(); System.out.println("服务器已接收到链接请求..."); System.out.println(); System.out.println("服务器正在等待数据..."); //阻塞2:等待数据时阻塞 socket.getInputStream().read(buffer); System.out.println("服务器已经接收到数据"); System.out.println(); String content = new String(buffer); System.out.println("接收到的数据:" + content); } } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
咱们须要再老调重谈的一点是,若是单线程服务器在等待数据时阻塞,那么第二个链接请求到来时,服务器是没法响应的。若是是多线程服务器,那么又会有为大量空闲请求产生新线程从而形成线程占用系统资源,线程浪费的状况。
那么咱们的问题就转移到,如何让单线程服务器在等待客户端数据到来时,依旧能够接收新的客户端链接请求。
若是要解决上文中提到的单线程服务器接收数据时阻塞,而没法接收新请求的问题,那么其实可让服务器在等待数据时不进入阻塞状态,问题不就迎刃而解了吗?
第一种解决方案(等待链接时和等待数据时不阻塞)
public class Server { public static void main(String[] args) throws InterruptedException { ByteBuffer byteBuffer = ByteBuffer.allocate(1024); try { //Java为非阻塞设置的类 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(8080)); //设置为非阻塞 serverSocketChannel.configureBlocking(false); while(true) { SocketChannel socketChannel = serverSocketChannel.accept(); if(socketChannel==null) { //表示没人链接 System.out.println("正在等待客户端请求链接..."); Thread.sleep(5000); }else { System.out.println("当前接收到客户端请求链接..."); } if(socketChannel!=null) { //设置为非阻塞 socketChannel.configureBlocking(false); byteBuffer.flip();//切换模式 写-->读 int effective = socketChannel.read(byteBuffer); if(effective!=0) { String content = Charset.forName("utf-8").decode(byteBuffer).toString(); System.out.println(content); }else { System.out.println("当前未收到客户端消息"); } } } } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
运行结果
不难看出,在这种解决方案下,虽然在接收客户端消息时不会阻塞,可是又开始从新接收服务器请求,用户根原本不及输入消息,服务器就转向接收别的客户端请求了,换言之,服务器弄丢了当前客户端的请求。
解决方案二(缓存Socket,轮询数据是否准备好)
public class Server { public static void main(String[] args) throws InterruptedException { ByteBuffer byteBuffer = ByteBuffer.allocate(1024); List<SocketChannel> socketList = new ArrayList<SocketChannel>(); try { //Java为非阻塞设置的类 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(8080)); //设置为非阻塞 serverSocketChannel.configureBlocking(false); while(true) { SocketChannel socketChannel = serverSocketChannel.accept(); if(socketChannel==null) { //表示没人链接 System.out.println("正在等待客户端请求链接..."); Thread.sleep(5000); }else { System.out.println("当前接收到客户端请求链接..."); socketList.add(socketChannel); } for(SocketChannel socket:socketList) { socket.configureBlocking(false); int effective = socket.read(byteBuffer); if(effective!=0) { byteBuffer.flip();//切换模式 写-->读 String content = Charset.forName("UTF-8").decode(byteBuffer).toString(); System.out.println("接收到消息:"+content); byteBuffer.clear(); }else { System.out.println("当前未收到客户端消息"); } } } } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
运行结果
代码解析
在解决方案一中,咱们采用了非阻塞方式,可是发现一旦非阻塞,等待客户端发送消息时就不会再阻塞了,而是直接从新去获取新客户端的链接请求,这就会形成客户端链接丢失,而在解决方案二中,咱们将链接存储在一个list集合中,每次等待客户端消息时都去轮询,看看消息是否准备好,若是准备好则直接打印消息。
能够看到,从头至尾咱们一直没有开启第二个线程,而是一直采用单线程来处理多个客户端的链接,这样的一个模式能够很完美地解决BIO在单线程模式下没法处理多客户端请求的问题,而且解决了非阻塞状态下链接丢失的问题。
从刚才的运行结果中其实能够看出,消息没有丢失,程序也没有阻塞。可是,在接收消息的方式上可能有些许不妥,咱们采用了一个轮询的方式来接收消息,每次都轮询全部的链接,看消息是否准备好,测试用例中只是三个链接,因此看不出什么问题来,可是咱们假设有1000万链接,甚至更多,采用这种轮询的方式效率是极低的。
另外,1000万链接中,咱们可能只会有100万会有消息,剩下的900万并不会发送任何消息,那么这些链接程序依旧要每次都去轮询,这显然是不合适的。
在真实NIO中,并不会在Java层上来进行一个轮询,而是将轮询的这个步骤交给咱们的操做系统来进行,他将轮询的那部分代码改成操做系统级别的系统调用(select函数,在linux环境中为epoll),在操做系统级别上调用select函数,主动地去感知有数据的socket。
咱们在以前实现了一个使用Java作多个客户端链接轮询的逻辑,可是在真正的NIO源码中其实并非这么实现的,NIO使用了操做系统底层的轮询系统调用 select/epoll(windows:select,linux:epoll)
,那么为何不直接实现而要去调用系统来作轮询呢?
假设有A、B、C、D、E五个链接同时链接服务器,那么根据咱们上文中的设计,程序将会遍历这五个链接,轮询每一个链接,获取各自数据准备状况,那么和咱们本身写的程序有什么区别呢?
首先,咱们写的Java程序其本质在轮询每一个Socket的时候也须要去调用系统函数,那么轮询一次调用一次,会形成没必要要的上下文切换开销。
而Select会将五个请求从用户态空间全量复制一份到内核态空间,在内核态空间来判断每一个请求是否准备好数据,彻底避免频繁的上下文切换。因此效率是比咱们直接在应用层写轮询要高的。
若是select没有查询到到有数据的请求,那么将会一直阻塞(是的,select是一个阻塞函数)。若是有一个或者多个请求已经准备好数据了,那么select将会先将有数据的文件描述符置位,而后select返回。返回后经过遍历查看哪一个请求有数据。
select的缺点:
poll的工做原理和select很像,先来看一段poll内部使用的一个结构体。
struct pollfd{ int fd; short events; short revents; }
poll一样会将全部的请求拷贝到内核态,和select同样,poll一样是一个阻塞函数,当一个或多个请求有数据的时候,也一样会进行置位,可是它置位的是结构体pollfd中的events或者revents置位,而不是对fd自己进行置位,因此在下一次使用的时候不须要再进行从新赋空值的操做。poll内部存储不依赖bitmap,而是使用pollfd数组的这样一个数据结构,数组的大小确定是大于1024的。解决了select 一、2两点的缺点。
epoll是最新的一种多路IO复用的函数。这里只说说它的特色。
epoll和上述两个函数最大的不一样是,它的fd是共享在用户态和内核态之间的,因此能够没必要进行从用户态到内核态的一个拷贝,这样能够节约系统资源;另外,在select和poll中,若是某个请求的数据已经准备好,它们会将全部的请求都返回,供程序去遍历查看哪一个请求存在数据,可是epoll只会返回存在数据的请求,这是由于epoll在发现某个请求存在数据时,首先会进行一个重排操做,将全部有数据的fd放到最前面的位置,而后返回(返回值为存在数据请求的个数N),那么咱们的上层程序就能够没必要将全部请求都轮询,而是直接遍历epoll返回的前N个请求,这些请求都是有数据的请求。
一般一些文章都是在开头放上概念,可是我此次选择将概念放在结尾,由于经过上面的实操,相信你们对Java中BIO和NIO都有了本身的一些理解,这时候再来看概念应该会更好理解一些了。
概念整理于:
https://blog.csdn.net/guanghuichenshao/article/details/79375967
先来个例子理解一下概念,以银行取款为例:
- 同步 : 本身亲自出马持银行卡到银行取钱(使用同步IO时,Java本身处理IO读写)。
- 异步 : 委托一小弟拿银行卡到银行取钱,而后给你(使用异步IO时,Java将IO读写委托给OS处理,须要将数据缓冲区地址和大小传给OS(银行卡和密码),OS须要支持异步IO操做API)。
- 阻塞 : ATM排队取款,你只能等待(使用阻塞IO时,Java调用会一直阻塞到读写完成才返回)。
- 非阻塞 : 柜台取款,取个号,而后坐在椅子上作其它事,等号广播会通知你办理,没到号你就不能去,你能够不断问大堂经理排到了没有,大堂经理若是说还没到你就不能去(使用非阻塞IO时,若是不能读写Java调用会立刻返回,当IO事件分发器会通知可读写时再继续进行读写,不断循环直到读写完成)。
Java对BIO、NIO的支持:
- Java BIO (blocking I/O): 同步并阻塞,服务器实现模式为一个链接一个线程,即客户端有链接请求时服务器端就须要启动一个线程进行处理,若是这个链接不作任何事情会形成没必要要的线程开销,固然能够经过线程池机制改善。
- Java NIO (non-blocking I/O): 同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的链接请求都会注册到多路复用器上,多路复用器轮询到链接有I/O请求时才启动一个线程进行处理。
BIO、NIO适用场景分析:
- BIO方式适用于链接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4之前的惟一选择,但程序直观简单易理解。
- NIO方式适用于链接数目多且链接比较短(轻操做)的架构,好比聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。
本文介绍了一些关于JavaBIO和NIO从本身实操的角度上的一些理解,我我的认为这样去理解BIO和NIO会比光看概念会有更深的理解,也但愿各位同窗能够本身去敲一遍,经过程序的运行结果得出本身对JavaBIO和NIO的理解。
欢迎访问我的博客:http://blog.objectspace.cn/