Netty系列:基础篇 BIO-NIO-AIO

Posted roykingw

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Netty系列:基础篇 BIO-NIO-AIO相关的知识,希望对你有一定的参考价值。

配合示例代码

​ 这一篇主要是讲基础的网络IO模型,也就是用什么样的通道进行数据的发送和接收。这在很大程度上决定了程序通信的性能。

一、IO模型

​ JAVA有三种网络IOC模型:BIO、NIO、AIO。

BIO 同步阻塞IO。服务器为每一个客户端连接分配一个线程。BIO方式适用于连接数目小且固定的架构。这种方式对服务器资源要求比较高。编程模型简单,程序简单容易理解。是JDK1.4以前的唯一选择。

NIO 同步非阻塞IO。 服务器以一个线程来处理多个连接,客户端发送的连接请求都会被注册到多路复用器上,多路复用器轮询到有IO请求的连接,就进行业务处理。是目前应用最广泛的IO模型。适用于连接数多并且连接比较短的轻操作架构。比如聊天服务器、弹幕系统、服务器间心跳通讯等。编程模型最为复杂,JDK1.4开始支持。

AIO 异步非阻塞IO。 AIO引入了异步通道的概念,有效的请求才会启动线程。他的特点是先由操作系统完成后才通知服务端程序启动线程去处理。JDK7开始支持,适用于连接数比较多且连接时间比较长的应用。

​ 这里两个重要的概念 同步异步 与 阻塞非阻塞。只需要注意一点,就是他们的对象。同步异步的概念是针对请求而言的。而阻塞非阻塞是针对应用而言。

二、BIO

​ BIO的功能代码都在java.io模块中。BIO的工作模型比较简单,就是来一个连接就启动一个线程。连接处理完了,线程就结束了。具体流程如下:

  1. 服务器端启动一个 ServerSocket

  2. 客户端启动 Socket 对服务器进行通信,默认情况下服务器端需要对每个客户 建立一个线程与之通讯

  3. 客户端发出请求后, 先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝

  4. 如果有响应,客户端线程会等待请求结束后,在继续执行

​ 简单示例:

public class Bioserver {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("服务启动完成");
        while (true) {
            //BIO每次会在这个地方阻塞住,只到有请求进来。
            final Socket socket = serverSocket.accept(); //<====阻塞点1
            System.out.println("有请求进来了。");
            //通过inputStream解析客户端传过来的消息
            final InputStream inputStream = socket.getInputStream();
            byte[] buffer = new byte[1024];
            int len;
            len = inputStream.read(buffer); //<====阻塞点2
            //通过outputStream给客户端返回响应
            final OutputStream outputStream = socket.getOutputStream();
            outputStream.write(("你的消息 :" + new String(buffer) + " 收到了。").getBytes());
            outputStream.flush();
            System.out.println("返回消息响应。");

            inputStream.close();
            outputStream.close();
            socket.close();
        }
    }
}
public class BioClient {
    public static void main(String[] args) throws IOException {
        final Socket socket = new Socket("127.0.0.1", 8080);
        System.out.println("建立连接完成");
        //直接通过outputStream提交信息
        socket.getOutputStream().write(("我是客户端;"+socket.getLocalAddress()).getBytes());
        System.out.println("发送消息完成");
        //通过inputStream获取信息
        final InputStream inputStream = socket.getInputStream();
        byte[] buffer = new byte[1024];
        inputStream.read(buffer);
        System.out.println("收到服务端的响应:"+new String(buffer));
        inputStream.close();
        socket.close();
    }
}

BIO的问题:

​ 1、对线程资源消耗非常大。 连接一旦多起来,线程数就会增多,服务器的资源压力就会比较大。

​ 2、程序阻塞点。我们看这个简单应用的服务端,有两个阻塞点。serverSocket.accept这是第一个阻塞点。程序运行时会阻塞在这个地方,直到有客户端发起连接,程序才会往下走。

​ 然后还有第二个阻塞点,就是在读取数据的地方inputStream.read(buffer); 应用每次读取信息都必须要读到数据。如果没有读到数据,程序也会阻塞在这个地方,直到读到数据也就是客户端有数据发过来为止。比如在这个应用中,如果服务端把阻塞点这样改造一下:

            while (true) {
                len = inputStream.read(buffer);
                if (len == -1) {
                    break;
                }
                System.out.println("收到消息:" + new String(buffer, 0, len));
            }

​ 本来服务端是想要读取inputStream中的所有数据,但是程序最终都会在inputStream.read这个地方阻塞住。程序即不会终止,也不会往下走,一直阻塞住不动。这就是因为读不到数据时,BIO就会阻塞住。这也是BIO使用时非常容易出错的地方。
​ 示例代码参见com.roy.bio包

三、AIO

​ AIO目前还没有广泛的应用。所以虽然在示例代码中整理了一个简单的示例,但是目前基本上还不需要深究。因为这样简单的Demo虽然代码很简单,但是其中的问题还是非常多的。更重要的是,AIO模式需要操作系统的支持,有非常多的细节需要操作系统的参与,这就导致对他的定制开发非常艰难。

​ 从示例代码中可以看到AIO编程模型的简单特点,就是服务端与客户端的交互都完全没有阻塞,双方的交互过程都跟点外卖一样自由随意。

​ Server端的serverChannel会在accept方法中注册一个回调函数,接收到Client端请求后,不会有任何的阻塞,直接执行后面的代码去了。而等业务处理完成后,会主动调用这个回调函数。然后Server端在回调函数中对数据进行处理。而Clinet端在通过socketChannel写入请求后,也不用做任何等待,之后可以在任意时刻通过socketChannel.read方法读到服务端的响应。
​ 示例代码参见com.roy.aio包

四、NIO

​ 在这三种IO模型中,我们的重点肯定就是这个NIO了。NIO在JDK中有一个单独的模块java.nio。Netty就是基于NIO的一个IO框架。目前应用最为广泛的IO模型就是NIO了。

1、NIO的IO模型:

在这里插入图片描述

对这个模型的简单解读:

1、NIO的三大核心组件: Buffer、Channel和Selector。

  • Buffer:本质上是一个可读写的数据内存块,底层就是一个数组。客户端本地数据会有各种各样,但是要进行网络交互必须是二进制的数据流。这个Buffer就相当于是本地数据与网络数据之间的缓冲地带。Buffer是可读可写的,不过他在读写之间需要进行切换。
  • Channel:客户端与服务端交互的通道。与BIO中的Stream的区别在于,BIO中的Stream都是单向的,而Channel是双向交互的,即可进行读操作,也可进行写操作。
  • Selector:NIO中的多路复用器。NIO正是通过这个多路复用器来对接多个客户端的。

这里有个非常经典的面试题,就是关于多路复用器的三种实现机制:Select、Poll和Epoll。这三种多路复用器是Linux操作系统提供的多路复用支持。实际上,这就是三个底层操作系统的API。

其中Select机制会维护一个文件描述符FD的结合fd_set。将fd_set从用户空间复制到内核空间,激活Socket。他是一个数组结构,所以是有大小限制的。

Poll机制:和Select机制是差不多的。不过他对fd_set结构进行了优化,集合大小突破了操作系统的限制。并且使用Pollfd结构代替了fd_set结构,通过链表实现。

EPoll机制:Event Poll。不再去扫描所有的文件描述符,只将用户关心的FD的事件存放到内核的一个事件表中。减少了用户空间与内核空间之间需要拷贝的数量大小。

多路复用机制是由操作系统提供的底层实现,java只是上层调用,所以这些概念,记住就行。关于多路复用器,可以查看JDK的rt.jar下的sun.nio.ch.DefaultSelectorProvider这个类。在这里可以看到,windows操作系统下的jdk采用的是WindowsSelectorProvider实现。而在Linux下,会根据Linux操作系统的内核版本进行选择。2.6版本以上采用EPollSelectorProvider实现,而2.6以下的版本采用的是PollSelectorProvider实现。对这三个机制的简单对比如下:

提出时间操作方式底层实现最大连接数IO效率
select1984遍历数组受限于内核一般
poll1997遍历链表无上限一般
epoll2002事件回调红黑树无上限

2、NIO整体的模型就是每个Channel会对应一个Buffer。而Channel都需要注册到Selector上才能被服务端处理。Selector对应操作系统中的一个线程,处理多个channel连接。

3、NIO是一个事件驱动的模型。 Selector切换到哪个Channel,是由Channel上是否有事件反生来决定的。所以事件Event也是NIO中很重要的一个概念。

了解了整个NIO的模型之后,就分别从各个组件逐一理解。

2、Buffer缓冲

​ Buffer是网路IO数据与本地数据的一个缓冲。Channel提供了网络字节流与本地文件、内存等数据之间的交互渠道,而这些所有的交易都需要经过Buffer。

Buffer的类定义

​ 在rt.jar的java.nio模块中,Buffer是一个顶级的抽象类,他还提供了非常多的子实现类,有ByteBuffer,ShortBuffer,CharBuffer,IntBuffer,LongBuffer,DoubleBuffer,FloatBuffer,分别用来处理不同的基础数据类型。这其中最为基础的就是ByteBuffer,任何数据最终都要转换成Byte字节流才能在网路上进行传输。例如对于字符串类型String,是没有StringBuffer的,字符串类型必须转为ByteBuffer才能进行传输。

​ 然后,在Buffer抽象类中,定义了NIO的Buffer中最为核心的四个属性:

private int mark = -1; --标记位
private int position = 0; --当前操作的位置。指定下一次读写操作的起点位置。
private int limit;  --缓冲区的当前终点。对缓冲的读写操作不能超过这个终点位置。这个终点位置是可以调整的。
private int capacity; --底层数组的容量

​ Buffer底层就是一个数组,而他正是通过这四个属性来定义相关的操作限制。例如读数据时,position不能超过limit。具体可以参见示例中的BufferDemo。

public class BufferDemo {
    public static void main(String[] args) {
        //可以跟踪下IntBuffer的limit、position、mark三个属性来理解下这些操作
        //定义Buffer的数组大小
        final IntBuffer intBuffer = IntBuffer.allocate(5);
        //往Buffer数组中添加数据,不能超过他的容量。超过容量会报错。
        for(int i = 0 ; i < intBuffer.capacity();i ++){
            intBuffer.put( i*5 );
        }
        //由写转为读必须要进行一下切换。本质是limit变更为position,限定后面的读操作不能超过之前写入的数据范围
        // 另外将position调整到0,表示后续从数组的开始位置读
        intBuffer.flip();
        //查看第二个位置的数据
        System.out.println("读取指定位置的数据:"+intBuffer.position(2));
        while (intBuffer.hasRemaining()){
            //顺序读
            System.out.println("依次读取数组中的数据:"+ intBuffer.get());
        }

        //创建一个字节流
        ByteBuffer buffer = ByteBuffer.allocate(64);
        //按类型写入数据,本质就是按固定的大小写入字节流。
        buffer.putInt(100);
        buffer.putLong(9);
        buffer.putChar('王');
        buffer.putShort((short) 4);
        //读写切换
        buffer.flip();
        //按照固定的大小顺序读字节,才能获取正确的值。顺序换了依然能读到字节流,但是无法还原成原始数据。
        System.out.println(buffer.getInt());
        System.out.println(buffer.getLong());
        System.out.println(buffer.getChar());
        System.out.println(buffer.getShort());
    }
}

3、Channel通道

​ NIO中的Channel类似于流,只是BIO中的Stream是单向的,InputStream只能读取数据,OutputStream只能输出数据。但是NIO中的Channel是双向的,即可以读,也可以写。利用Channel通道可以实现异步读写数据。并且,Channel本身可以缓存数据,可以将数据先读入到Channel,然后再写入到其他的缓冲区。

Channel的类定义

​ Channel在NIO中是一个顶级的接口java.nio.channels.Channel。由此衍生出非常多的子接口与实现类。例如AIO中的AsynchronousChannel接口。

​ 几个最为常用的实现类有:

FileChannel:实现文件读写的通道。

DatagramChannel:处理UDP连接的数据通道。

ServerSocketChannel和SocketChannel:处理TCP连接的数据通道。后面也主要是会用这两个通道做网络IO

​ 其中关于FileChannel见示例代码中的FileChannelDemo1。示例中实现了文件的写入、复制以及读取。

​ 然后关于NIO的零拷贝,也整理了两个实例,在nio/zerocopy包下。

另外,关于NIO中的零拷贝,是很多中间件的重要优化方式。

有两种方式实现零拷贝。一种是mmap,文件映射的方式。一种是sendFile文件传输的方式。

关于零拷贝:Linux操作系统分为【用户态】和【内核态】,文件操作、网络操作需要涉及这两种形态的切换,免不了进行数据复制。

一台服务器 把本机磁盘文件的内容发送到客户端,一般分为两个步骤:
1)read;读取本地文件内容;
2)write;将读取的内容通过网络发送出去。
这两个看似简单的操作,实际进行了4 次数据复制,分别是:
1 - 从磁盘复制数据到内核态内存;
2 - 从内核态内存复 制到用户态内存;
3 - 然后从用户态 内存复制到网络驱动的内核态内存;
4 - 最后是从网络驱动的内核态内存复 制到网卡中进行传输。

在这个过程中,mmap方式,可以省去第二步的内存复制,用户内存中只保留文件的映射,而不需要保留文件的内容。这样可以减少一次拷贝次数以及上下文切换,提高速度。适合小文件的读取。在RocketMQ中就大量的运用了这种机制来进行消息持久化。Kafka中也有一小部分文件是用的这种方式来保存的。

而sendFile方式可以在内核态使用DMA内存直接拷贝,减少了CPU的参与。适合大文件的传输。Kafka中大量的运用这种方式将消息从硬盘拷贝到网卡。

这东西实现很简单,API都封装好了。只是面试喜欢问。记一下就行。

4、Selector选择器

​ NIO中会在服务端以一个线程来管理所有客户端的请求,这时就会使用到Selector选择器。关于Selector选择器首先要注意下之前介绍过的三种实现机制。然后还有几个需要注意的地方:

1、Netty的IO线程NioEventLoop中聚合了Selector,一个Selector可以同时处理成百上千个客户端连接。但是如果客户端太多,那还是需要搭建集群横向扩展了。

2、NIO中,所有客户端的连接以Channel的形式注册到Selector上,然后一个处理线程会监听所有Channel上的事件。

3、线程只有在Channel上有特定事件发生时才来处理对应的事件,没有事件发生时,线程可以进行其他任务。

Selector的类定义

​ 在java.nio包中,Selector对应一个顶级的抽象类java.nio.channels.Selector。往下就可以找到对应的最终实现类。windows操作系统下的jdk采用的是WindowsSelectorProvider实现。而在Linux下,会根据Linux操作系统的内核版本进行选择。2.6版本以上采用EPollSelectorProvider实现,而2.6以下的版本采用的是PollSelectorProvider实现。

​ 然后Selector中有几个关键的方法需要了解下。

  • Selector.open():获取一个Selector对象。
  • selector.select(): 这个方法是个阻塞的方法,直到这个selector上有某一个Channel上发生了对应的关键事件时才会通过。他返回的是有IO操作的对应Channel的个数。这个方法也可以传入一个timeout超时时间,当阻塞时间超过超时时间时,也会放开阻塞。
  • select.selectNow(): 这个方法是个非阻塞的方法,只返回当时有事件发生的channel的个数。

5、NIO的基础编程模型

NIO基础的编程模型如下图:
在这里插入图片描述

图中的关键点:

1、不光客户端的SocketChannel需要注册到selector上,服务端的ServerSocketChannel也需要注册到selector上。
2、客户端的SocketChannel注册到selector上后,服务端的ServerSocketChannel可以得到所有的SocketChannel。
3、服务端通过Selector进行监听,select方法阻塞,随时返回有事件发生的通道的个数。
4、服务端可以在运行过程中在selector上动态注册SocketChannel。
5、服务端处理业务时,通过注册时的SelectionKey,反向获取对应客户端的SocketChannel。
6、通过channel完成业务处理,并与客户端进行沟通。

然后关于NIO的编程方式,在Demo中做了两个示例。一个基础的客户端与服务端简单通信的示例。这个示例其实也不需要强行去记忆,大部分都是模板化的代码。重点是要理解清楚上面提到的NIO模型。

在这里插入图片描述

还一个是基于NIO的聊天室。如果理解了上面的简单通信机制,那这个聊天室也就很容易理解了。无非就是上一个Demo只找一个Channel,向一个客户端发消息。而聊天室就是找所有的Channel去通信。

6、NIO的问题

原生的NIO虽然也能做出比较好的JAVA应用,但是IO其实是一个操作系统底层的操作。大量的功能都是由JVM底层的C、C++代码来完成的。JAVA只是做了上层封装,所以终归是有些别扭的。面对复杂的IO系统,还是容易暴露出一些问题,比如:
1、NIO的类库和API非常繁杂,使用起来非常麻烦。需要对底层的这些selector、SocketChannel、ServerSocketChannel等这些操作非常熟练。很多NIO的代码,如果对这些底层操作不是很熟悉的话,很多业务代码,从整体上来看,都是完全脱节的,上下并没有太多逻辑关联。这导致开发和维护都非常困难。
2、开发的难度和工作量也很大。我们的示例大都是基于本地网卡实现的,一般来说还不会有太多的变数。但是如果真正面对互联网这样复杂的网络环境时,还有非常多的问题需要去解决。比如客户端心跳检测、网络闪断、异常流、TCP粘包拆包等。而java的nio包,更多的是面向底层机制的封装,应用层面的封装并不多。这些问题,原生NIO也能解决,但是解决起来非常的麻烦。
3、还一个最大的问题就是,JAVA其实对底层的一些核心机制能做的事情非常有限。比如Selector的多路复用机制,面试最喜欢问的问题。但是JAVA的NIO中Selector有一个非常著名的Epoll BUG,若Selector的轮询结果为空,也没有wakeup或者新消息处理,就会造成空轮询,CPU使用率迅速上升到100%。这个BUG,JAVA官方就一直没有解决,只到Netty出现后,才在业务使用的层面处理了这个BUG。
因此,在实际项目中,很少有人会用原生的NIO进行编程。大家都需要一个完善的应用框架来处理NIO的各种各样的问题。而这个框架,就是Netty。

以上是关于Netty系列:基础篇 BIO-NIO-AIO的主要内容,如果未能解决你的问题,请参考以下文章

Netty系列・高级篇Netty核心源码解析

Netty系列进阶篇一:阻塞和多路复用到底是个啥?

Netty系列二Netty原理篇

Netty基础招式——ChannelHandler的最佳实践

Netty版本升级血泪史之线程篇(上)

Netty_02_高性能的NIO框架