为了更好的演示BIO与NIO之间的区别,咱们先用一个服务器示例来了解一个BIO实现网络通行的过程。java
public class BioServer { public static void main(String[] args) throws IOException { byte[] bs = new byte[1024]; // 建立一个新的ServerSocket,绑定一个InetSocketAddress,监听8000端口上的链接请求 ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(8000)); // accept专门负责通讯 while(true) { System.out.println("等待链接---"); // =====①:accept()函数的执行 Socket accept = serverSocket.accept(); // 这里会阻塞以释放CPU资源 System.out.println("链接成功---"); System.out.println("等待数据传输---"); // =====②:getInputStrea()函数获取客户端传送的输入流 int read = accept.getInputStream().read(bs); // 这里也可能阻塞 System.out.println("数据传输成功---" + read); String content = new String(bs); System.out.println(content); } } }
public class Client { public static void main(String[] args) throws IOException { // 创建一个socket去链接服务端 Socket socket = new Socket(); socket.connect(new InetSocketAddress("127.0.0.1", 8000)); Scanner scanner = new Scanner(System.in); while (true) { // =====③:getOutputStream()函数中写入的是从控制台输入的字符 String next = scanner.next(); socket.getOutputStream().write(next.getBytes()); } // socket.close(); } }
首先咱们先开启服务端,开启后的控制台输出以下,程序会在运行到①的地方停下来阻塞掉,等待客户端链接上来。若是没有客户端链接的话,这个线程将会一直停在这里。
c++
那么咱们如今先开启客户端,而后不在控制台输入数据,以下图所示,服务端程序会一直卡在②的地方停下来,由于客户端卡在了③的位置,你一直没有在控制台输入字符,客户端的没有输出流,那么服务端没办法接收到客户端发送过来的数据,从而阻塞在②的位置。
数组
假设如今客户端传来一条信息,那么客户端程序就能够接受到这条数据,阻塞在②处的线程就会重新运行下去。
缓存
从这里咱们很容易想到这种模式的服务器的缺陷,首先,它一次只能接收一个接收一个客户端的请求,要是有多个,没办法,在处理完前面的链接前,它是没办法往下执行的,那么若是前面链接一直不传送消息过来,就像咱们刚刚将程序阻塞在③处同样,那么服务端就没法往下运行了,面对这种问题,咱们想到用多线程来解决,一个请求对应一个线程,那么就没有线程在③阻塞的问题了。服务器
public static void main(String[] args) throws IOException { byte[] bs = new byte[1024]; // 建立一个新的ServerSocket,绑定一个InetSocketAddress,监听8000端口上的链接请求 ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(8000)); // accept专门负责通讯 while(true) { System.out.println("等待链接---"); // =====①:accept()函数的执行 Socket socket = serverSocket.accept(); // 这里会阻塞以释放CPU资源 System.out.println("链接成功---"); // =====④:新建一个线程来处理这个客户端链接 Thread thread = new Thread(new ExecuteSocket(socket)); thread.start(); } } static class ExecuteSocket implements Runnable { byte[] bs = new byte[1024]; Socket socket; // 处理每一个客户端链接——读写 public ExecuteSocket(Socket socket) { this.socket = socket; } @Override public void run() { try { // =====⑤:这里仍是有阻塞的,不过是在线程里阻塞,不影响主线程 socket.getInputStream().read(bs); } catch (IOException e) { e.printStackTrace(); } String content = new String(bs); System.out.println(content); } } }
客户端仍是用刚才的客户端,没什么影响毕竟。网络
那么如今咱们就能够开启客户端和服务端了,咱们尝试下开启两个客户端,服务端的控制台输出以下:
数据结构
咱们能够发现如今服务端的main线程并无阻塞,而是能够继续往下执行,由于在④处它开启了一个子线程去处理这个链接的请求了,因此哪怕是客户端不发送数据,阻塞也是在子线程中的⑤处发生的,这样对服务端处理下一个请求并无太大的影响。多线程
问题到这里看似好像解决了,可是让咱们考虑一下这种方案的影响,当咱们要管理多个并发客户端时,咱们须要为每一个新的客户端Socket建立一个新的Thread,以下图所示:
并发
因此这种模型也有不少的吐槽点,首先,在任什么时候候都有可能有大量的线程处于休眠状态,只是等待输入或者输出数据就绪,这对于咱们的系统来讲就是一种巨大的资源浪费;而后,咱们须要为每一个线程都分配内存,其默认值大小区间为64kb到1M。并且,咱们还要考虑到,哪怕虚拟机自己是能够支持大量线程,可是远在达到该极限以前,上下文切换所带来的开销就会给咱们的系统带来巨大的资源消耗。socket
首先咱们来了解一下NIO的原理。假设如今Java开发了两个API,一个叫Socket.setNoBlock(boolean)
,可让socket所在线程在没有获得客户端发送过来的数据时也不会阻塞,而是继续进行。另一个叫ServerSocket.setNoBlock(boolean)
,可让ServerSocket所在线程在没有获得客户端链接时也不会阻塞而往下运行。下面咱们用伪代码来分析一波:
public class BioServer { public static void main(String[] args) throws IOException { List<Socket> socketList = null; // 用以存放链接服务端的socket byte[] bs = new byte[1024]; ServerSocket serverSocket = new ServerSocket(); // =====①:这个地方是伪代码,如今假设方法执行后serverSocket在没有客户端链接的状况下也会继续执行 serverSocket.setNoBlock(true); serverSocket.bind(new InetSocketAddress(8000)); while(true) { System.out.println("等待链接---"); Socket socket = serverSocket.accept(); // 如今这里不会阻塞以释放CPU资源 if (socket == null) { // 没客户端链接过来 // =====:②找到之前链接服务端的socket,看它们有没有发给我数据 for (Socket socket1 : socketList) { int read = socket.getInputStream().read(bs); if (read != 0) { // 这个socket有数据传过来 // 这里处理你的业务逻辑 } } } else { // 有客户端链接过来 // =====:③这个地方是伪代码,如今假设方法设置后socket不会阻塞 socket.setNoBlock(true); // =====:④将这个socket添加到socketList中 socketList.add(socket); for (Socket socket1 : socketList) { // 遍历socketList,看看哪一个socket给服务端发送数据 int read = socket.getInputStream().read(bs); if (read != 0) { // 这个socket有数据传过来 // 这里处理你的业务逻辑 } } } } } }
这里咱们声明了一个socketList,用以存放链接到服务端的socket。如今咱们在①处设置了让这个serverSocket在本次循环就算没有客户端链接上来也不会阻塞,而是继续执行下去。执行下去以后判断分两叉,一叉是没有客户端链接过来的状况,那么就在②拿出socketList,看看以前链接的socket里面有没有哪一个给我发数据,有的话就来处理一下。另一叉就是在有客户端链接上来的状况了,首先咱们在③处将socket也设置为非阻塞的,而后将这个socket添加到SocketList当中,而后继续拿出socket,看看有没有哪一个socket给我发数据,有就处理一下。
如今到这里,NIO的思路基本理清了,下面咱们用代码来实现一个简单的服务端。
这里咱们仍是利用List来缓存Socket,以后再轮询是否有传输的数据。
public class NioServer { public static void main(String[] args) { List<SocketChannel> list = new ArrayList<>(); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); try { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(8001)); ssc.configureBlocking(false); // 在这里设置为非阻塞 while (true) { SocketChannel socketChannel = ssc.accept(); if (socketChannel == null) { Thread.sleep(1000); System.out.println("没有客户端链接上来"); for (SocketChannel channel : list) { int k = channel.read(byteBuffer); System.out.println(k + "===== no connection ====="); if (k != 0) { // 有链接发来数据 byteBuffer.flip(); System.out.println(new String(byteBuffer.array())); } } } else { socketChannel.configureBlocking(false); list.add(socketChannel); // 获得套接字,循环全部的套接字,经过套接字获取数据 for (SocketChannel channel : list) { int k = channel.read(byteBuffer); System.out.println(k + "===== connection ====="); if (k != 0) { byteBuffer.flip(); System.out.println(new String(byteBuffer.array())); } } } } } catch (IOException | InterruptedException e) { e.printStackTrace(); } } }
OK,如今将上面的代码运行起来,再运行两个客户端代码,向8001端口发送数据,运行结果以下:
这种非阻塞实现可让服务端节省下许多资源。可是这样的实现仍是有弊端:
咱们在这里采用了轮询的方式来接收消息,每次都会轮询全部的链接,查看哪一个套接字中有准备好的消息。在链接到服务端的链接还少的时候,这种方式是没有问题的,可是若是如今有100w个链接,此时再使用轮询的话,效率就变得十分低下。并且很大一部分的链接基本都不发消息的,在100w个链接中可能只有10w个链接会有消息,可是每次链接程序后咱们都得去轮询,这是很不适合的。
首先咱们要知道一个class java.nio.channels.Selector
,它是实现Java的非阻塞I/O的关键。什么是Selector,这里举例作解释:
在一个养鸡场,有这么一我的,天天的工做就是不停检查几个特殊的鸡笼,若是有鸡进来,有鸡出去,有鸡生蛋,有鸡生病等等,就把相应的状况记录下来,若是鸡场的负责人想知道状况,只须要询问那我的便可。
在这里,这我的就至关Selector,每一个鸡笼至关于一个SocketChannel,每一个线程经过一个Selector能够管理多个SocketChannel。
为了实现Selector管理多个SocketChannel,必须将具体的SocketChannel对象注册到Selector,并声明须要监听的事件(这样Selector才知道须要记录什么数据),一共有4种事件:
SelectionKey.OP_CONNECT(8)
SelectionKey.OP_ACCEPT(16)
SelectionKey.OP_READ(1)
SelectionKey.OP_WRITE(4)
这个很好理解,每次请求到达服务器,都是从connect开始,connect成功后,服务端开始准备accept,准备就绪,开始读数据,并处理,最后写回数据返回。
因此,当SocketChannel有对应的事件发生时,Selector均可以观察到,并进行相应的处理。
public class NioServer { public static void main(String[] args) throws IOException { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ssc.socket().bind(new InetSocketAddress(8001)); Selector selector = Selector.open(); ssc.register(selector, SelectionKey.OP_ACCEPT); while (true) { int n = selector.select(); if (n == 0) continue; // 若是没有链接发来数据,跳过这次循环 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); if (key.isAcceptable()) { SocketChannel socketChannel = ((ServerSocketChannel) key.channel()).accept(); socketChannel.configureBlocking(false); // 将选择器注册到客户端信道 // 并指定该信道key值的属性为OP_READ, // 同时为该信道指定关联的附件 socketChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(1024)); } if (key.isReadable()) { // handle Read } if (key.isWritable() && key.isValid()) { // handle Write } if (key.isConnectable()) { System.out.println("isConnectable = true"); } iterator.remove(); } } } }
从这里咱们看出,虽然以前咱们用NIO作了多个客户端轮询,可是在真正在NIO实现时,咱们并不会去这么作,而是使用Selector
,将轮询的逻辑交由Selector
处理,而Selector
最终会调用到系统函数select()/epoll()。
假设有多个链接同时链接服务器,那么根据上下文的设计,程序将会遍历这多个链接,轮询每一个链接以获取各自数据的准备状况,那么这和咱们本身写的程序有什么区别呢?
首先,咱们本身写的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个请求,这些请求都是有数据的请求。