IO模型就是说用什么样的通道进行数据的发送和接收,首先要明确一点:IO是操作系统与其他网络进行数据交互,JDK底层并没有实现IO,而是对操作系统内核函数做的一个封装,IO代码进入底层其实都是native形式的。Java共支持3种网络编程IO模式:BIO,NIO,AIO。下文进行介绍

 

1. BIO

        BIO(Blocking IO) 又称同步阻塞IO,一个客户端由一个线程来进行处理,线程模型如下所示
深入理解BIO、NIO、AIO线程模型_线程模型、netty
BIO代码示例

public class SocketServer {
    public static void main(String[] args) throws IOException {
        //创建socket连接,端口为9000
        ServerSocket serverSocket = new ServerSocket(9000);
        while (true) {
            System.out.println("等待连接。。");
            //阻塞方法
            Socket clientSocket = serverSocket.accept();
            System.out.println("有客户端连接了。。");
            
            //单线程连接,性能不好,下面开启多线程
            //handler(clientSocket);

            //开启多线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        handler(clientSocket);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }

    private static void handler(Socket clientSocket) throws IOException {
        byte[] bytes = new byte[1024];
        System.out.println("准备read。。");
        //接收客户端的数据,阻塞方法,没有数据可读时就阻塞
        int read = clientSocket.getInputStream().read(bytes);
        System.out.println("read完毕。。");
        if (read != -1) {
            System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
        }
        clientSocket.getOutputStream().write("HelloClient".getBytes());
        clientSocket.getOutputStream().flush();
    }
}

上边是BIO连接的示例代码,启动后可以通过telnet与 localhost 9000 建立连接,并发送字符串信息123,测试结果如下:
深入理解BIO、NIO、AIO线程模型_线程模型、netty_02
深入理解BIO、NIO、AIO线程模型_线程模型、netty_03
测试成功,但BIO现在已经用的不多了,因为它在大并发下有几个致命的缺点:

  1. 如果BIO使用单线程接受连接,则会阻塞其他连接,效率较低。
  2. 如果使用多线程虽然减弱了单线程带来的影响,但当有大并发进来时,会导致服务器线程太多,压力太大而崩溃。
  3. 就算使用线程池,也只能同时允许有限个数的线程进行连接,如果并发量远大于线程池设置的数量,还是与单线程无异
  4. IO代码里read操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,就是说只占用连接,不发送数据,则会浪费资源。比如线程池中500个连接,只有100个是频繁读写的连接,其他占着茅坑不拉屎,浪费资源!
  5. 另外多线程也会有线程切换带来的消耗

综上所述,BIO方式已经不适用于如下的大并发场景,仅适用于连接数目比较小且固定的架构。这种方式对服务器资源要求比较高,但BIO程序简单易理解。

 

2. NIO

        为了解决BIO在大并发下存在的问题,诞生了NIO。NIO(Non Blocking IO)又称同步非阻塞IO。服务器实现模式为把多个连接(请求)放入集合中,只用一个线程可以处理多个请求(连接),也就是多路复用。JDK1.4开始引入。

NIO的同步非阻塞体现在:

  1. 同步:调用的结果会在本次调用后返回,不存在异步线程回调之类的。
  2. 非阻塞:表现为线程不会一直在等待,把连接加入集合后,线程会一直轮询集合中的连接,有则处理,无则继续接受请求。

        NIO的多路复用底层主要用的是Linux 内核函数(select,poll,epoll)来实现的。windows不支持epoll实现,windows底层是基于winsock2的select函数实现的(不开源)。三种内核模型的区别如下所示!

  select poll epoll(jdk 1.5及以上)
操作方式 遍历 遍历 回调
底层实现数组 数组 链表 哈希表
IO效率 每次调用都进行线性遍历,时间复杂度为O(n) 每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式,每当有IO事件就绪,系统注册的回调函数就会被调用,时间复杂度O(1)
最大连接 有上限(1024个) 无上限 无上限

NIO 有三大核心组件:

  1. Buffer(缓冲区):buffer 底层就是个数组
  2. Channel(通道):channel 类似于流,每个 channel 对应一个 buffer缓冲区
  3. Selector(多路复用器):channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理

注意:NIO 的 Buffer 和 channel 都是既可以读也可以写,NIO的多路复用示意图如下:
深入理解BIO、NIO、AIO线程模型_线程模型、netty_04

应用场景:
NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂

 

2.1 select、poll模型

        NIO底层在JDK1.4版本是用linux的内核函数select()或poll()来实现,selector每次都会轮询所有的sockchannel看下哪个channel有读写事件,有的话就处理,没有就继续遍历,select和poll模型作为NIO的早期实现,存在一定弊端。下面是一段代码,简单表述一下他们的弊端!

第一个版本:NIO的早期版本模拟代码实现(select、poll)

public class NioServer {

    // 保存客户端连接
    static List<SocketChannel> channelList = new ArrayList<>();

    public static void main(String[] args) throws IOException, InterruptedException {

        // 创建NIO ServerSocketChannel,与BIO的serverSocket类似
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.socket().bind(new InetSocketAddress(9000));
        
        // 设置ServerSocketChannel为非阻塞, 配置为true,则和BIO类似
        serverSocket.configureBlocking(false);
        System.out.println("服务启动成功");

        while (true) {
            // 非阻塞模式accept方法不会阻塞,否则会阻塞
            // NIO的非阻塞是由操作系统内部实现的,底层调用了linux内核的accept函数
            SocketChannel socketChannel = serverSocket.accept();
            
            if (socketChannel != null) { // 如果有客户端进行连接
                System.out.println("连接成功");
                // 设置SocketChannel为非阻塞
                socketChannel.configureBlocking(false);
                // 保存客户端连接在List中
                channelList.add(socketChannel);
            }
            
            // 遍历连接进行数据读取
            Iterator<SocketChannel> iterator = channelList.iterator();
            
            while (iterator.hasNext()) {
                SocketChannel sc = iterator.next();
                ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                // 非阻塞模式read方法不会阻塞,否则会阻塞
                int len = sc.read(byteBuffer);
                // 如果有数据,把数据打印出来
                if (len > 0) {
                    System.out.println("接收到消息:" + new String(byteBuffer.array()));
                } else if (len == -1) { // 如果客户端断开,把socket从集合中去掉
                    iterator.remove();
                    System.out.println("客户端断开连接");
                }
            }
        }
    }
}

从上述代码可以看到,NIO使用一个mian线程 + 一个数组 解决了BIO的痛点。具体解决方式如下

  1. 首先设置服务端连接的阻塞方式为false,代表非阻塞方式
  2. 非阻塞式接受客户端连接,意味着这段代码一直在轮询的跑,不会阻塞。
  3. 如果有客户端连接进来,就把这个连接放入list集合中
  4. 后续遍历list集合,使用非阻塞式读取数据
  5. 读取完成再次轮询跑代码

测试如下:
深入理解BIO、NIO、AIO线程模型_线程模型、netty_05

        这种方式虽然解决了BIO的部分痛点,但并不是很完美。因为select和poll模型的底层实现、io效率、最大连接数在面对高并发时还存在一定弊端!他们的多路复用采用的是遍历Selector中所有的连接,然后对有事件的连接做出响应。 假如连接数太多,有10000个连接,其中只有1000个连接有写数据,但是由于其他9000个连接并没有断开,我们还是要每次轮询遍历一万次,其中有十分之九的遍历都是无效的,这显然不是一个让人很满意的状态。为了处理无效遍历的问题,在jdk1.5及以上版本引入了epoll模型

 

2.2 epoll模型

        JDK1.5开始引入了epoll基于事件响应机制来优化NIO。epoll模型解决了elect和poll模型的无效遍历问题,是NIO的核心。epool是基于事件响应的,类似于观察者模式!

NIO第二个版本:使用epoll模型后的代码示例

public class NioSelectorServer {

    public static void main(String[] args) throws IOException, InterruptedException {

        // 创建NIO ServerSocketChannel
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.socket().bind(new InetSocketAddress(9000));
        // 设置ServerSocketChannel为非阻塞
        serverSocket.configureBlocking(false);
        // 打开Selector处理Channel,即创建epoll
        Selector selector = Selector.open();
        // 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务启动成功");

        while (true) {
            // 阻塞等待需要处理的事件发生
            selector.select();

            // 获取selector中注册的全部事件的 SelectionKey 实例
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            // 遍历SelectionKey对事件进行处理
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                // 如果是OP_ACCEPT事件,则进行连接获取和事件注册
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel socketChannel = server.accept();
                    socketChannel.configureBlocking(false);
                    // 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
                    socketChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("客户端连接成功");
                } else if (key.isReadable()) {  // 如果是OP_READ事件,则进行读取和打印
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                    int len = socketChannel.read(byteBuffer);
                    // 如果有数据,把数据打印出来
                    if (len > 0) {
                        System.out.println("接收到消息:" + new String(byteBuffer.array()));
                    } else if (len == -1) { // 如果客户端断开连接,关闭Socket
                        System.out.println("客户端断开连接");
                        socketChannel.close();
                    }
                }
                //从事件集合里删除本次处理的key,防止下次select重复处理
                iterator.remove();
            }
        }
    }
}

        在使用epoll模型之后,对简单版本的NIO做了优化处理,可以理解为在第一个版本的NIO上,又增加了一个就绪事件列表集合,这个集合中存放着有事件响应的连接,然后开启一个线程去监听这个集合,有元素的话就进行处理。

        总结:NIO整个调用流程就是Java调用了操作系统的内核函数来创建Socket,获取到Socket的文件描述符,再创建一个Selector对象,对应操作系统的Epoll描述符,将获取到的Socket连接的文件描述符的事件绑定到Selector对应的Epoll文件描述符上,进行事件的异步通知,这样就实现了使用一条线程,并且不需要太多的无效的遍历,将事件处理交给了操作系统内核(操作系统硬中断程序实现),大大提高了效率。

 

2.3 深入hotpost源码分析epoll模型

        NIO第二个版本的代码中,与简单版本最主要的代码区别在以下三行代码

// 1. 打开Selector处理Channel,即创建epoll ,创建多路复用器
Selector selector = Selector.open();

// 2. 把ServerSocketChannel链接注册到selector多路复用器上,并且selector对客户端accept连接操作感兴趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);

// 3. 阻塞等待需要处理的事件发生
selector.select();

这三行代码内部是优化的核心,下面一一介绍!

2.3.1. Selector selector = Selector.open();

    public static Selector open() throws IOException {
        return SelectorProvider.provider().openSelector();
    }
    
 ========================= SelectorProvider.provider() =======================
 
    public static SelectorProvider provider() {
        synchronized (lock) {
            if (provider != null)
                return provider;
            return AccessController.doPrivileged(
                new PrivilegedAction<SelectorProvider>() {
                    public SelectorProvider run() {
                            if (loadProviderFromProperty())
                                return provider;
                            if (loadProviderAsService())
                                return provider;
                            // 我们在Windows环境下,默认创建的是WindowsSelectorProvider,如下所示
                            provider = sun.nio.ch.DefaultSelectorProvider.create();
                            return provider;
                        }
                    });
        }
    }

======================= 默认创建的是WindowsSelectorProvider =======================
	  public static SelectorProvider create() {
	        return new WindowsSelectorProvider();
	    }

        进入open方法源码中,可以看到创建的是WindowsSelectorProvider,而项目发布一般都是在linux上,所以需要进入hotport源码中看一下Linux平台的实现。这中现象也是JVM的跨平台性的体现。进入hotpost源码发现如果是linux环境,则创建EpollSelectorProvider。也发现Linux上实现是基于Epoll模型!深入理解BIO、NIO、AIO线程模型_线程模型、netty_06
所以 SelectorProvider.provider().openSelector() 其实是EpollSelectorProvider.openSelector(),代码如下所示!

深入理解BIO、NIO、AIO线程模型_线程模型、netty_07
可以看到 EPollSelectorImpl 是具体的实现,那么在EPollSelectorImpl实例化时做了什么事情呢?
深入理解BIO、NIO、AIO线程模型_线程模型、netty_08
        在这个 EPollArray 包装类中调用了一个 epoll_create 的本地方法,创建了一个epoll对象,并返回一个非负数作为文件描述符,用于对epoll接口的所有后续调用。这个方法属于linux操作系统内核中的方法。至此Selector.open()方法解释完毕,总的来说就是创建了一个linux内核层面的Epoll对象!
深入理解BIO、NIO、AIO线程模型_线程模型、netty_09

 

2.3.2. serverSocket.register(selector, SelectionKey.OP_ACCEPT)

查看这个方法的源码,同样要使用LInux下的平台中的EpollSelectorProvider的register方法,核心代码如下:

深入理解BIO、NIO、AIO线程模型_线程模型、netty_10
这个方法是把当前连接注册到第一步EPollSelectorImpl实例化时生成的EPollArray的集合中,此时并没有事件驱动。直到 selector.select()方法调用linux内核函数epoll_ctl

 

2.3.3. selector.select()

这个select方法内部会调用doselect()方法,这个方法同样要使用LInux下的平台中的EpollSelectorProvider
深入理解BIO、NIO、AIO线程模型_线程模型、netty_11
在这个poll方法中,分别调用了Linux核心函数 epoll_ctlepoll_wait,下面分别解释其含义

  1. epoll_ctl: 负责真正去关联socketChannel 和 epoll实例(selector)的关系。如果客户端连接(socketChannel)有发送事件,就会把存在于EPollArray中的这个连接放入epoll实例中的就绪事件列表(rdlist)中去。
    其中这个放入就绪事件列表的操作是怎么做的呢?
    放入操作并非是java去做,每当服务端收到客户端的连接,且该链接有响应事件时,就是使用操作系统的硬中断功能把该连接放入就绪事件列表中去。后续遍历就绪事件列表中的元素即可,减少了无效遍历。
  2. epoll_wait:监听就绪事件列表中的元素,如果没有数据则阻塞。如果有数据,则把数据放入selectedKeys中,供java代码获取元素,响应事件!

深入理解BIO、NIO、AIO线程模型_线程模型、netty_12

2.4 NIO与redis

        Redis就是典型的基于epoll的NIO线程模型(nginx也是),epoll实例收集所有事件(连接与读写事件),把有数据交互的连接放入就绪事件列表中,由一个服务端线程连续处理所有就绪事件列表中的命令。
深入理解BIO、NIO、AIO线程模型_线程模型、netty_13
在ae_epoll.c源码中也发现有 epoll_createepoll_ctlepoll_wait,原理与上文讲的类似

问题一:为什么 redis 不建议用 bigkey?

        bigkey的big体现在单个value值很大,一般认为超过10KB就是bigkey。由于redis底层用的是NIO,多路复用一个线程,如果存在bigkey的话,这个bigkey就会占用这个线程较大的时间,导致其他连接的数据交互阻塞,所以不建议使用bigkey。注意:这里说的阻塞并不是 异步非阻塞的阻塞。

 

3. AIO

        AIO自JDK1.7以后才开始支持,是异步非阻塞的,客户端与服务端的连接(accept)、数据读写(read、write)不再由main线程去执行,而是开辟一个回调函数,当客户端与服务端建立连接时,把这个客户端的连接传入回调函数中,由服务端启动一个子线程去处理,这就完成了异步操作!适用于连接数较多且连接时间较长的应用。

AIO与BIO、NIO的不同之处在于:

  1. AIO是 异步非阻塞模型
  2. NIO是 同步非阻塞模型
  3. BIO是 同步阻塞模型

AIO代码示例

public class AIOServer {

    public static void main(String[] args) throws Exception {
    	//创建服务端
        final AsynchronousServerSocketChannel serverChannel =
                AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));

		//使用CompletionHandler异步处理客户端连接
        serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
            @Override
            public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
                try {
                    System.out.println("2--"+Thread.currentThread().getName());
                    
                    // 在此接收客户端连接,如果不写这行代码后面的客户端连接连不上服务端
                    serverChannel.accept(attachment, this);
                    System.out.println(socketChannel.getRemoteAddress());
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    
                    //使用CompletionHandler异步读取数据
                    socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer buffer) {
                            System.out.println("3--"+Thread.currentThread().getName());
                            buffer.flip();
                            System.out.println(new String(buffer.array(), 0, result));
                            socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
                        }

                        @Override
                        public void failed(Throwable exc, ByteBuffer buffer) {
                            exc.printStackTrace();
                        }
                    });
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                exc.printStackTrace();
            }
        });

        System.out.println("1--"+Thread.currentThread().getName());
        Thread.sleep(Integer.MAX_VALUE);
    }
}

AIO作为异步非阻塞模型,理论上来说应该被广泛使用,但大多数公司并没有使用AIO,而是使用了netty,为什么?

  1. 首先AIO得底层实现仍使用Epoll,并没有很好的实现异步,在性能上对比NIO没有太大优势
  2. 其次AIO的代码逻辑比较复杂,且Linux上AIO还不够成熟
  3. Netty在NIO上做了很多异步的封装,是异步非阻塞框架

 

4. BIO、NIO、AIO的对比

深入理解BIO、NIO、AIO线程模型_线程模型、netty_14
一个关于同步异步阻塞非阻塞的段子:

老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

  1. 老张把水壶放到火上,立等水开。(同步阻塞) 老张觉得自己有点傻
  2. 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
  3. 老张把响水壶放到火上,立等水开。(异步阻塞)老张觉得这样傻等意义不大
  4. 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)老张觉得自己聪明了。

所谓同步异步,只是对于水壶而言。

  1. 普通水壶,同步
  2. 响水壶,异步。
  3. 虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。
    同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。

所谓阻塞非阻塞,仅仅对于老张而言。

  1. 立等的老张,阻塞
  2. 看电视的老张,非阻塞。