Netty系列-2021年国庆陪在我身边的.....宝!

1,158 阅读17分钟

朋友们,俺也想出去玩!!!奈何单位不允许,明令严禁出省。

所以....响应号召吧,那就来复习一下netty相关知识点吧。

IO模型

在学习Netty之前,先了解一下 IO 模型:

IO模型就是说用什么样的通道进行数据的发送和接收。我们JAVA支持3种网络IO编程:BIONIOAIO

PS: AIO这里不讲了,Linux上不太成熟学了也没啥用 !!!

BIO(Blocking IO)

同步阻塞模型,一个客户端连接对应一个处理线程

image.png

服务端代码示例:

public class SocketServer {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(9000);
        while (true){
            System.out.println("等待连接...");

            Socket accept = serverSocket.accept();
            System.out.println("有客户端连接了...准备read....");
            byte[] bytes = new byte[1024];
            int read = accept.getInputStream().read(bytes);
            System.out.println("read完毕....");
            if (read != -1){
                System.out.println("接收到客户端的数据为: "+new String(bytes, 0, read));
            }
            accept.getOutputStream().write("宝!我收到了你的消息了".getBytes());
            accept.getOutputStream().flush();
        }
    }
}

客户端代码示例:

public class SocketClient {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 9000);
        System.out.println("准备向服务端发送数据....");
        socket.getOutputStream().write("hello server".getBytes());
        socket.getOutputStream().flush();
        System.out.println("向服务端发送数据结束....");

        byte[] bytes = new byte[1024];
        int read = socket.getInputStream().read(bytes);
        if (read!=-1){
            System.out.println("接收到服务端的数据为: "+new String(bytes, 0, read));
        }
        socket.close();
    }
}

缺点accept(), read(), write()全是阻塞操作,会导致线程阻塞,系统负载过高,浪费资源。

应用场景:BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,但程序简单易理解。

NIO(Non Blocking IO)

同步非阻塞模型,服务器实现模式为一个线程可以处理多个连接请求,客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮询到连接有IO请求就进行处理。

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

NIO初版代码示例

public class NioServer {

    private static List<SocketChannel> channelList = new ArrayList<>();

    public static void main(String[] args) throws IOException {
        ServerSocketChannel open = ServerSocketChannel.open();
        open.socket().bind(new InetSocketAddress(9000));
        open.configureBlocking(false);

        System.out.println("服务端启动完成。。。");

        while (true){
            SocketChannel accept = open.accept();
            if (accept!=null){
                System.out.println("有新的连接了。。。");
                accept.configureBlocking(false);
                channelList.add(accept);
            }

            Iterator<SocketChannel> iterator = channelList.iterator();
            while (iterator.hasNext()){
                SocketChannel socketChannel = iterator.next();
                //分配缓冲区的容量
                ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                int read = socketChannel.read(byteBuffer);
                if (read >0){
                    System.out.println("收到消息:"+new String(byteBuffer.array(), 0, read));
                }else if (read == -1){
                    iterator.remove();
                    System.out.println("客户端断开连接");
                }
            }
        }
    }
}

总结:虽然做到了非阻塞,但是如果连接数太多的话,会有大量的无效遍历,假如有10000个连接,其中只有1000个连接有写数据,但是由于其他9000个连接并没有断开,我们还是要每次轮询遍历一万次,其中有90%的遍历都是无效的,这显然不是一个最终方案。

NIO进阶版代码示例:即引入多路复用器

public class NioSelectorServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel socketChannel = ServerSocketChannel.open();
        socketChannel.socket().bind(new InetSocketAddress(9000));
        socketChannel.configureBlocking(false);

        //打开Selector, 即创建epoll
        Selector selector = Selector.open();
        //把ServerSocketChannel注册到selector上, 并且selector对客户端的accept连接操作感兴趣
        socketChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务启动完成....");

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

            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                if (key.isAcceptable()){
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel channel = server.accept();
                    channel.configureBlocking(false);
                    channel.register(selector, SelectionKey.OP_READ);
                    System.out.println("客户端连接成功....");
                }else if (key.isReadable()){
                    SocketChannel socketC = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                    int read = socketC.read(byteBuffer);
                    if (read>0){
                        System.out.println("接收到消息: "+new String(byteBuffer.array()));
                    }else if (read==-1){
                        System.out.println("客户端断开连接");
                        socketC.close();
                    }
                }
                iterator.remove();
            }
        }
    }
}

NIO有三大核心组件:Channel(通道)Buffer(缓冲区)Selector(多路复用器)

  1. channel类似于流,每个channel对应一个buffer缓冲区,buffer底层就是个数组
  2. channel会注册到selector上,由selector根据channel读写事件的发生将其交由某个空闲的线程处理
  3. NIO的Buffer和channel都是既可以读也可以写

流程图如下:

image.png

NIO底层在JDK1.4版本是用Linux的内核函数select()或poll()来实现,跟上面的NioServer代码类似,selector每次都会轮询所有的sockchannel看下哪个channel有读写事件,有的话就处理,没有就继续遍历,JDK1.5开始引入了epoll基于事件响应机制来优化NIO

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

I/O多路复用底层主要用的Linux内核函数(selectpollepoll)来实现,windows不知道,因为不开源。

image.png

为什么Netty使用NIO而不是AIO?

在Linux系统上,AIO的底层实现仍使用Epoll,没有很好实现AIO,因此在性能上没有明显的优势,而且被JDK封装了一层不容易深度优化,Linux上AIO还不够成熟。Netty是异步非阻塞框架,Netty在NIO上做了很多异步的封装。

Reactor模型的演变过程

Reactor模型:即事件响应模型

单Reactor单线程模型

这是最简单的Reactor模型,整个过程中的事件处理全部发生在一个线程里: image.png 流程如下:

  1. Reactor对象通过select监听客户端的请求事件,收到事件消息后通过dispatch进行任务分发。
  2. 如果是连接请求,则交由Acceptor对象处理连接请求,然后创建一个Handler对象继续完成后续处理。
  3. 若不是连接请求,则dispatch会调用对应连接的Handler进行处理,Handle负责完成连接成功后的后续处理(读操作、写操作、业务处理等)

此模型很简单,易于理解,但是存在一定的问题,比如单线处理程模型下,无法发挥多核CPU的性能,如果Handler上的业务处理很慢,则意味着整个程序无法处理其他连接事件,造成性能问题。

适用于业务处理快速、客户端连接较少的情况。

单Reactor多线程模型

相较于上面的模型,对业务处理模块进行了异步处理,流程图如下: image.png 流程如下:

  1. Reactor对象通过select监听客户端的请求事件,收到事件消息后通过dispatch进行任务分发。
  2. 如果是连接请求,则交由Acceptor对象处理连接请求,然后创建一个Handler对象继续完成后续处理。
  3. 如果不是连接请求,则dispatch会调用对应连接的Handler进行处理,Handle负责完成连接成功后的读操作,读出来数据后的业务处理部分交由线程池异步处理,业务处理完成后发送给Handler处理完成的消息,然后再由Handler发送处理响应信息给对应的Client。

本模型充分利用了多核CPU的处理能力,降低了由业务处理引起的性能问题,Reactor线程仅负责接收连接、读写操作。但是Reactor除了负责连接处理外仍然负责读写操作,大量的请求下仍然可能仍然存在性能问题

主从Reactor多线程模型

这个模型中将会独立出另一个Reactor对象来处理非连接处理的其他处理,命名为从Reactor(SubReactor),流程图如下: image.png 流程如下:

  1. 主Reactor对象(MainReactor)通过select监听客户端的连接事件,收到连接事件后交由Acceptor处理。
  2. Acceptor处理完成后,MainReactor将此连接分配给SubReactor处理,SubReactor将此连接加入连接队列进行事件监听并建立Handler进行后续的各种操作,同上面的模型一致,SubReactor会监听新的事件,如果有新的事件发生,则调用Handler进行相应的处理。
  3. Handler读出来数据后的业务处理部分交由线程池异步处理,业务处理完成后发送给Handler处理完成的消息,然后再由Handler发送处理响应信息给对应的Client。

该模型存在两个线程分别处理Reactor事件,主线程只负责处理连接事件,子线程只负责处理读写事件,这样主线程可以处理更多的连接,而不用关心子线程里的读写处理是否会影响到自己。目前这种模型被广泛使用在各种项目中,如Netty

Netty的核心功能与线程模型

为什么会有Netty的诞生?

  1. NIO的类库和API繁杂,使用麻烦,需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer等。
  2. NIO的开发工作量和难度都非常大:例如客户端面临断线重连、网络闪断、心跳处理、半包读写、网络拥塞和异常流的处理等等。

Netty对JDK自带的NIO的API进行了良好的封装,解决了上述问题。且Netty拥有高性能、吞吐量更高,延迟更低,减少资源消耗等优点。

Netty通讯示例

Netty的maven依赖

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.35.Final</version>
</dependency>

服务端代码 :

public class NettySerer {

    public static void main(String[] args) {
        // 创建两个线程组 bossGroup和workGroup, 线程个数默认为CPU核数的两倍
        // bossGroup处理连接请求, workGroup处理其他业务请求
        NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workGroup = new NioEventLoopGroup();

        try {
            // 创建服务端的启动对象
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(bossGroup, workGroup)   //设置父子线程组
                    .channel(NioServerSocketChannel.class)  // 使用NioServerSocketChannel作为服务器的通道实现
                    //初始化服务器连接队列大小,服务端处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接。
                    //多个客户端同时来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    //创建通道初始化对象,设置初始化参数
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            //对workerGroup的SocketChannel设置处理器
                            socketChannel.pipeline().addLast(new NettySererHandler());
                        }
                    });

            System.out.println("netty server start....");

            //绑定一个端口并且同步,生成了一个ChannelFuture异步对象,通过isDone()等方法可以判断异步事件的执行情况
            //启动服务器(并绑定端口),bind是异步操作,sync方法是等待异步操作执行完毕
            ChannelFuture cf = serverBootstrap.bind(9000).sync();

            cf.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture channelFuture) throws Exception {
                    if (channelFuture.isSuccess()){
                        System.out.println("监听9000端口成功");
                    }
                }
            });

            cf.channel().closeFuture().sync();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }
}

/**
 * 自定义Handler需要继承netty规定好的某个HandlerAdapter(规范)
 */
class NettySererHandler extends ChannelInboundHandlerAdapter{
    /**
     * 读取客户端发送的数据
     * @param ctx 上下文
     * @param msg 客户端发的消息
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        System.out.println("收到客户端的消息: "+buf.toString(CharsetUtil.UTF_8));
    }

    /**
     * 数据读取完毕之后的处理方法
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        ByteBuf buf = Unpooled.copiedBuffer("hello client", CharsetUtil.UTF_8);
        ctx.writeAndFlush(buf);
    }

    /**
     * 异常处理
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.close();
    }
}

客户端代码 :

public class NettyClient {

    public static void main(String[] args) {
        NioEventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {

                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            socketChannel.pipeline().addLast(new NettyClientHandler());
                        }
                    });

            System.out.println("netty client start");

            ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 9000).sync();
            channelFuture.channel().closeFuture().sync();
        }catch (Exception e){
            e.printStackTrace();
        } finally {
            group.shutdownGracefully();
        }
    }
}


class NettyClientHandler extends ChannelInboundHandlerAdapter{
    /**
     * 当客户端连接服务端完成,就会触发该方法
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ByteBuf buf = Unpooled.copiedBuffer("hello server", CharsetUtil.UTF_8);
        ctx.writeAndFlush(buf);
    }

    /**
     * 当通道有读取事件时就会触发,即服务端发送数据给客户端
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        System.out.println("收到服务器的消息:"+ buf.toString(CharsetUtil.UTF_8));
        System.out.println("服务器的地址为:"+ ctx.channel().remoteAddress());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        ctx.close();
    }
}

看完代码,我们发现Netty框架的目标就是让你的业务逻辑从网络基础应用编码中分离出来,让你可以专注业务的开发,而不需写一大堆类似NIO的网络处理操作。

Netty的模块组件

BootstrapServerBootstrap】:

Bootstrap意思是引导,一个Netty应用通常由一个Bootstrap开始,Netty中Bootstrap类是客户端程序的启动引导类ServerBootstrap是服务端启动引导类

FutureChannelFuture】:

Netty中所有的IO操作都是异步的,不能立刻得知消息是否被正确处理。但是可以过一会等它执行完成或者直接注册一个监听,具体的实现就是通过 Future 和 ChannelFutures,他们可以注册一个监听,当操作执行成功或失败时监听会自动触发注册的监听事件。

Channel

Netty网络通信的管道,能够用于执行网络I/O操作。不同协议、不同的阻塞类型的连接都有不同的Channel类型与之对应。

  • NioSocketChannel,异步的客户端TCP Socket连接。

  • NioServerSocketChannel,异步的服务器端TCP Socket连接。

  • NioDatagramChannel,异步的UDP连接。

等等.....

Selector

Netty基于Selector对象实现I/O多路复用,通过Selector一个线程可以监听多个连接的Channel事件。当向一个Selector中注册Channel后,Selector内部的机制就可以自动不断地查询这些注册的Channel是否有已就绪的I/O事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个Channel。

NioEventLoop

NioEventLoop中维护了一个线程和任务队列,支持异步提交执行任务,线程启动时会调用NioEventLoop的run方法,执行I/O任务和非I/O任务:

I/O任务,即selectionKey中ready的事件,如accept、connect、read、write等,由processSelectedKeys方法触发。

非IO任务,添加到taskQueue中的任务,如register0、bind0等任务,由runAllTasks方法触发。

NioEventLoopGroup

主要管理eventLoop的生命周期,可以理解为一个线程池,内部维护了一组线程,每个线程(NioEventLoop)负责处理多个Channel上的事件,而一个Channel只对应于一个线程。

ChannelHandler

ChannelHandler是一个接口,处理I/O事件或拦截I/O操作,并将其转发到其ChannelPipeline(业务处理链)中的下一个处理程序。ChannelHandler本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承它的子类:

  • ChannelInboundHandler用于处理入站I/O事件
  • ChannelOutboundHandler用于处理出站I/O操作

ChannelHandlerContext

保存Channel相关的所有上下文信息,同时关联一个ChannelHandler对象。

ChannelPipline

保存ChannelHandler的List,用于处理或拦截Channel的入站事件和出站操作

在Netty中每个Channel都有且仅有一个ChannelPipeline与之对应,它们的组成关系如下:

image.png

Netty的线程模型

image.png

模型解释

  1. Netty抽象出两组线程池BossGroupWorkerGroup,BossGroup专门负责接收客户端的连接,WorkerGroup专门负责网络的读写。BossGroup和WorkerGroup类型都是NioEventLoopGroup
  2. NioEventLoopGroup相当于一个事件循环线程组,这个组中含有多个事件循环线程,每一个事件循环线程是NioEventLoop
  3. 每个NioEventLoop都有一个selector,用于监听注册在其上的socketChannel的网络通讯
  4. 每个Boss NioEventLoop线程内部循环执行的步骤有3步
    • 处理accept事件,与client建立连接,生成NioSocketChannel
    • 将NioSocketChannel注册到某个worker NIOEventLoop上的selector
    • 处理任务队列的任务,即runAllTasks
  5. 每个worker NIOEventLoop线程循环执行的步骤
    • 轮询注册到自己selector上的所有NioSocketChannel的read,write事件
    • 处理I/O事件,即read,write事件,在对应NioSocketChannel处理业务
    • runAllTasks处理任务队列TaskQueue的任务,一些耗时的业务处理一般可以放入TaskQueue中慢慢处理,这样不影响数据在pipeline中的流动处理
  6. 每个worker NIOEventLoop处理NioSocketChannel业务时,会使用pipeline(管道),管道中维护了很多handler处理器用来处理channel中的数据

ByteBuf

ByteBuf由一串字节数组构成。数组中每个字节用来存放信息。其内部实现了两个索引,一个用于读取数据,一个用于写入数据。这两个索引通过在字节数组中移动,来定位需要读或者写信息的位置。

当从ByteBuf读取时,它的readerIndex(读索引)将会根据读取的字节数递增。同样,当写ByteBuf时,它的writerIndex(写索引)也会根据写入的字节数进行递增。

image.png 需要注意的是极限的情况是readerIndex刚好读到了writerIndex写入的地方。如果readerIndex超过了writerIndex的时候,Netty会抛出IndexOutOf-BoundsException异常。

Netty编解码

当你通过Netty发送或者接受一个消息的时候,就将会发生一次数据转换。入站消息会被解码(字节-->另一种格式),出站消息被编码(任意格式-->字节)。

ChannelPipeline提供了ChannelHandler链的容器。客户端往服务端发送数据时,客户端会经过ChannelOutboundHand链,从tail到head方向逐个调用每个handler的逻辑。服务端会经过ChannelInboundHand链,从head到tail方向逐个调用每个handler的逻辑。

粘包拆包

TCP是一个流协议,就是没有界限的一长串二进制数据。TCP作为传输层协议并不不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行数据包的划分,所以在业务上认为是一个完整的包,可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。面向流的通信是无消息保护边界的。

如下图所示,client发了两个数据包D1和D2,但是server端可能会收到如下几种情况的数据。

image.png

解决方案

  1. 消息定长度,传输的数据大小固定长度,例如每段的长度固定为100字节,如果不够空位补空格
  2. 在数据包尾部添加特殊分隔符,比如下划线,中划线等,这种方法简单易行,但选择分隔符的时候一定要注意每条数据的内部一定不能出现分隔符。
  3. 发送长度:发送每条数据的时候,将数据的长度一并发送,比如可以选择每条数据的前4位是数据的长度,应用层处理时可以根据长度来判断每条数据的开始和结束。

Netty的零拷贝

所谓零拷贝是指,堆内存和直接内存直接发生0次拷贝

image.png

Netty的接收和发送ByteBuf采用Direct buffers(直接内存/堆外内存),使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的JVM堆内存进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才能写入Socket中。JVM堆内存的数据是不能直接写入Socket中的。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

使用直接内存的优缺点

优点

  1. 不占用堆内存空间,减少了发生GC的可能
  2. java虚拟机实现上,本地IO会直接操作直接内存(直接内存=>系统调用=>硬盘/网卡),而非直接内存则需要二次拷贝(堆内存=>直接内存=>系统调用=>硬盘/网卡)

缺点

  1. 直接内存初始分配较慢
  2. 没有JVM直接帮助管理内存,容易发生内存溢出。为了避免一直没有FULL GC,最终导致直接内存把物理内存耗完。我们可以指定直接内存的最大值,通过-XX:MaxDirectMemorySize来指定,当达到阈值的时候,调用system.gc来进行一次FULL GC,间接把那些没有被使用的直接内存回收掉。