从NIO到Netty

Netty的使用其实难度不到,其关键的点是了解IO的编程模型。了解其编程模型再对应看Netty就会容易的很多。

Java的网络编程IO模式:BIO,NIO,AIO

BIO:同步阻塞模型,一个线程处理一个连接。(时代发展,现在已经弃用)

缺点明显,阻塞线程资源浪费,一个线程处理一个客户端,吞吐量有限。唯一的好处:架构简单。

代码示例:

1、accept 方法是一个阻塞方法,执行过后启动服务端,阻塞等待用户连接

2、read方法也是一个阻塞方法,没有数据可读时则阻塞

当客户端没有数据时,处理此链接的线程将阻塞,造成资源浪费。而且每个客户端都需要一个线程来处理其链接数据

public class ServerSockets {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(1);
        while (true) {
            /**
             * accept方法是一个阻塞方法,执行过后服务端将阻塞等待用户连接
             */
            Socket socket = serverSocket.accept();

            /**
             * 执行到这证明客户端已经有连接了
             */

            new Thread(new Runnable() {
            @Override
            public void run() {
                    try {
                        /**
                         * handler 就是处理客户端连接后的逻辑的
                         */
                        handler(socket);
                        } catch (IOException e) {
                        e.printStackTrace();
                        }
                    }
             }).start();
            }
         }

    private static void handler(Socket socket) throws IOException {
         byte[] bytes = new byte[1024];
        /**
         * 接收客户端的数据,没有数据可读时则阻塞
         */
        int read = socket.getInputStream().read(bytes);

        /**
         * read返回如果不是-1则怎么有数据.则读取read长度的字节
         */
        new String(bytes, 0, read);
        /**
         * 给客户端一些响应的信息
         */
         socket.getOutputStream().write("我收到你的消息啦".getBytes());
         socket.getOutputStream().flush();
         }
}

NIO:同步非阻塞。一个线程能处理多个客户端连接。如何实现的呢?

1、channel 类似于流,每个 channel 对应一个 buffer缓冲区,buffer 底层就是个数组。数据是通过在buffer中的数据积累后一起发送。

ByteBuf 提供了两个索引,一个用于读取数据,一个用于写入数据。这两个索引通过在字节数
组中移动,来定位需要读或者写信息的位置。
当从 ByteBuf 读取时,它的 readerIndex(读索引)将会根据读取的字节数递增。
同样,当写 ByteBuf 时,它的 writerIndex 也会根据写入的字节数进行递增。需要注意的是极限的情况是 readerIndex 刚好读到了 writerIndex 写入的地方。如果read超出了write的索引大小则报错


2、channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理
3、selector 可以对应一个或多个线程
4、NIO 的 Buffer 和 channel 都是既可以读也可以写

1、服务端启动后(ServerSocketChannel 这个channel其实就是服务端的应用端口,可以理解为其就是一个服务器。这个名称有点迷惑性,可以理解为server,不是一个channel),将在 selector 多路复用器 上注册服务,并且关注accept连接事件。

2、当客户端连接这个端口的时候,selector 多路复用器就会收到客户端的accept的连接请求事件,selector通过select方法获取到客户端连接事件,并且获取到ServerSocketChannel 注册时绑定的selectionKey,通过此key获取到ServerSocketChannel 。ServerSocketChannel 通过 accept() 方法得到 SocketChannel并且将将 SocketChannel 注册到 Selector 上,关心 read 事件。注册后此SocketChannel同样会有一个绑定的selectionKey。用来获取此channel。

3、当客户发送数据时,selector多路复用选择器将监听到read事件,通过编列所有的selectionKey获取到有事件的key并且拿到有事件的SocketChannel,读取出数据。同样,根据此channel可以写回数据到客户端。

NIO模型中的selector 就像一个注册管理中心,负责监听各种IO事件,转交给后端线程去处理

JDK会根据操作系统的能力自行选择最优的NIO实现方式

代码示例:客户端代码类似,就不写了。。

public class ServerSockets {
     public static void main(String[] args) throws IOException {
        //创建一个服务端的server。
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //必须配置为非阻塞才能往selector上注册,selector模式本身就是非阻塞模式
         ssc.configureBlocking(false);
         ssc.socket().bind(new InetSocketAddress(9000));
         // 创建一个选择器selector
         Selector selector = Selector.open();
         // 把ServerSocketChannel注册到selector上,并且selector对客户端accept事件关注
         ssc.register(selector, SelectionKey.OP_ACCEPT);

         while (true) {
             //这里select方法一样是阻塞的。
             int selects = selector.select();

             //到这里证明selector多路复用器监听到了事件
             Iterator<SelectionKey> it = selector.selectedKeys().iterator();
             while (it.hasNext()) {
                 SelectionKey key = it.next();

                 //收到后即可删除此事件
                 it.remove();

                 //处理事件
                 handle(key);
                 }
            }
        }

     private static void handle(SelectionKey key) throws IOException {
         //如果这个是accept事件,则证明是一个客户端注册事件
         if (key.isAcceptable()) {
           ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
           /**
            * accept方法是阻塞的,但是由于selector是自旋遍历注册的key,一但有事件这里轮询到就会立马执行accept
            * 但执行到此证明肯定是有连接时间了。所以accept不会阻塞立马执行
            */
           SocketChannel sc = ssc.accept();
           sc.configureBlocking(false);
           //为连接的SocketChannel添加read事件
           sc.register(key.selector(), SelectionKey.OP_READ);
         }
         //如果是read事件
         else if (key.isReadable()) {
           SocketChannel sc = (SocketChannel) key.channel();

           ByteBuffer buffer = ByteBuffer.allocate(1024);
           /**
            * 非阻塞提现:
            * read方法不会阻塞,其次当调用到read方法时肯定是有了客户端发送数据的事件
            */
           int len = sc.read(buffer);
           if (len != -1) {
               //这里有数据来啦。。。。。。。。。。。。
           }
           ByteBuffer bufferToWrite = ByteBuffer.wrap("我得到了你的数据了".getBytes());
           sc.write(bufferToWrite);
           key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
           }
         // write事件
         else if (key.isWritable()) {
           SocketChannel sc = (SocketChannel) key.channel();
           key.interestOps(SelectionKey.OP_READ);
           }
        }
}

AIO:异步非阻塞,由操作系统完成后回调通知服务端程序启动线程去处理。其与NIO模型类似,只是任务处理部分交给异步线程去处理。

Netty是基于NIO同步非阻塞网络模型封装的一个异步事件驱动高性能框架,异步事件驱动框架体现在所有的I/O操作是异步的,所有的IO调用会立即返回,并不保证调用成功与否,但是调用会返回ChannelFuturenetty会通过ChannelFuture通知你调用是成功了还是失败了亦或是取消了。

由刚才的NIO线程模型看下下面(图片源自于 Scalable IO in Java 一书)

单reactor单线程模型图:

 Reactor是由selector和dispatch实现的一个监听和分发器。客户端的注册、读写、逻辑处理都由同一个线程执行。

单reactor多线程模型:

 由一个Reactor处理所有的注册、读取事件、但是其中的逻辑处理以及编解码等工作都交由一个线程池处理。

主从Reactor多线程模型:

 目前Netty选择的主从多线程模型。其所有的客户端连接由朱Reactor负责,注册后在从Reactor上面再注册,负责其以后的读写事件。网络上有一张更加生动的图:

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

Netty模块:

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

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

Channel:能够用于执行网络 I/O 操作。能够提供连接信息,并且支持异步回调的操作通道

Selector:上面讲了:向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是否有已就绪的 I/O 事件(如可读,可写,网络连接完成等)

NioEventLoop :NioEventLoop 中维护了一个线程和任务队列,支持异步提交执行任务,线程启动时会调用NioEventLoop 的 run 方法,执行 I/O 任务和非 I/O 任务:I/O 任务,即 selectionKey 中 ready 的事件,如 accept、connect、read、write 等,由processSelectedKeys 方法触发。非 IO 任务,添加到 taskQueue 中的任务,如 register0、bind0 等任务,由 runAllTasks 方法触发。

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

ChannelHandler :ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其ChannelPipeline(业务处理链)中的下一个处理程序。这个接口有许多的方法需要实现进行自己的业务处理。与一个对应的ChannelHandlerContext关联

ChannelPipline:ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作。ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互。在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应。read事件(入站事件)和write事件(出站事件)在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 handler,出站事件会从链表 tail 往前传递到最前一个出站的 handler,两种类型的handler 互不干扰。每次的读写事件其实就是执行pipeline中的handler的过程。handler区分out和in两种事件。在执行read操作时,会自动的只执行入站in的handler

netty的使用的代码很简单,列举一个服务端例子:

public static void main(String[] args) throws Exception {

        /**
         * 创建两个线程组bossGroup(处理连接事件)和workerGroup(读写事件和业务处理)
         * 其默认源码含有子线程NioEventLoop的个数默认为cpu核数的两倍
         */
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            //服务器端的启动对象
            ServerBootstrap bootstrap = new ServerBootstrap();
            //配置参数
            bootstrap.group(bossGroup, workerGroup)
                    //
                    .channel(NioServerSocketChannel.class) //使用NioServerSocketChannel作为服务器的通道实现
                    /**
                     * BACKLOG用于构造服务端套接字ServerSocket对象,标识当服务器请求处理线程全满时,
                     * 用于临时存放已完成三次握手的请求的队列的最大长度。如果未设置或所设置的值小于1,Java将使用默认值50。
                     * 同一时间,只能处理一个连接请求,单然连接请求非常快,几乎xxxx
                     */
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childHandler(new ChannelInitializer<SocketChannel>() {

                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            //对workerGroup的SocketChannel设置处理器。也就是我们自定义的handler
                            ch.pipeline().addLast(new NettyServerHandler());
                        }
                    });
            //服务启动器绑定端口, 生成了一个ChannelFuture异步对象,通过isDone()等方法可以判断异步事件的执行情况
            //启动服务器(并绑定端口),bind是异步操作,sync方法是等待异步操作执行完毕
            ChannelFuture cf = bootstrap.bind(9000).sync();
            //给cf注册监听器,比如说成功或者失败
            /*cf.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture future) throws Exception {
                    if (cf.isSuccess()) {
                        System.out.println("启动成功");
                    } else {
                        System.out.println("启动失败");
                    }
                }
            });*/
            /**
             * 执行这一步其实会被阻塞,只有channel被关闭了才会真正执行此逻辑
             */
            cf.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }


    /**
     * 业务处理类 Handler
     */
    public static class NettyServerHandler extends ChannelInboundHandlerAdapter {

        /**
         * 读取客户端发送的数据
         *
         * @param ctx 上下文对象, 含有通道channel,管道pipeline
         * @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("HelloClient", CharsetUtil.UTF_8);
            ctx.writeAndFlush(buf);
        }

        /**
         * 处理异常, 一般是需要关闭通道
         *
         * @param ctx
         * @param cause
         * @throws Exception
         */
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            ctx.close();
        }
    }

由代码可以看出netty的线程模型图其实是这样更加准确:

编码解码器

通过Netty发送或者接受一个消息的时候,就将会发生一次数据转换。入站消息会被解码:从字节转换为另一种格式(比如java对象);如果是出站消息会被编码成字节

Netty提供了一系列实用的编码解码器,他们都实现了ChannelInboundHadnler或者ChannelOutboundHandler接口。在这些类中,channelRead方法已经被重写了。以入站为例,对于每个从入站Channel读取的消息,这个方法会被调用。随后,它将调用由已知解码器所提供的decode()方法进行解码,并将已经解码的字节转发给ChannelPipeline中的下一个ChannelInboundHandler。

Netty提供了很多编解码器,比如编解码字符串的StringEncoder和StringDecoder,编解码对象的ObjectEncoder和ObjectDecoder等。也可以通过继承ByteToMessageDecoder自定义编解码器

如果业务比较大的情况下,建议不要使用jdk自带的序列化方式。jdk序列化效率是非常低的,因为它序列化生成的字节数非常多(包含了很多类的信息),不太适合用于存储和在网络上传输。如果要实现高效的编解码可以用protobuf,但是protobuf需要维护大量的proto文件比较麻烦,现在一般可以使用protostuff。protostuff是一个基于protobuf实现的序列化方法,它较于protobuf最明显的好处是,在几乎不损耗性能的情况下做到了不用我们写.proto文件来实现序列化

粘拆包

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

1.接收端正常收到两个数据包,即没有发生拆包和粘包的现象。

2.接收端只收到一个数据包,由于TCP是不会出现丢包的,所以这一个数据包中包含了发送端发送的两个数据包的信息,这种现象即为粘包。 这种情况由于接收端不知道这两个数据包的界限,所以对于接收端来说很难处理。

3.这种情况有两种表现形式,如下图。 接收端收到了两个数据包,但是这两个数据包要么是不完整的,要么就是多出来一块,这种情况即发生了拆包和粘包。 这两种情况如果不加特殊处理,对于接收端同样是不好处理的。

解决思路也很简单:

1)收发双方协商好消息定长度,传输的数据大小固定长度,例如每段的长度固定为100字节,如果不够空位补空格

2)收发双方协商好在数据包尾部添加特殊分隔符,比如下划线,中划线等,这种方法简单易行,但选择分隔符的时候一定要注意每条数据的内部一定不能出现分隔符。

3)数据和数据的字节长度一起发送:发送每条数据的时候,将数据的长度一并发送,比如可以选择每条数据的前4位表示的是数据的长度,应用层处理时可以根据长度来读取每条数据长度范围。

当然,Netty提供了多个解码器,可以进行分包的操作,如下:

  • LineBasedFrameDecoder (回车换行分包)
  • DelimiterBasedFrameDecoder(特殊分隔符分包)
  • FixedLengthFrameDecoder(固定长度报文来分包)

心跳检测机制

心跳, 即在 TCP 长连接中, 客户端和服务器之间定期发送的一种特殊的数据包, 通知对方自己还在线, 以确保 TCP 连接的有效性。其主要的实现类是。。当然也是一个handler:

三个参数:

  • readerIdleTimeSeconds: 读超时. 即当在指定的时间间隔内没有从 Channel 读取到数据时, 会触发一个 READER_IDLE 的 IdleStateEvent 事件.
  • writerIdleTimeSeconds: 写超时. 即当在指定的时间间隔内没有数据写入到 Channel 时, 会触发一个 WRITER_IDLE 的 IdleStateEvent 事件.
  • allIdleTimeSeconds: 读/写超时. 即当在指定的时间间隔内没有读或写操作时, 会触发一个 ALL_IDLE 的 IdleStateEvent 事件.
public IdleStateHandler(int readerIdleTimeSeconds, int writerIdleTimeSeconds, int allIdleTimeSeconds) {
    this((long)readerIdleTimeSeconds, (long)writerIdleTimeSeconds, (long)allIdleTimeSeconds, TimeUnit.SECONDS);
}

在server初始化的时候加入此handler到pipeline中即可。实现原理其实就是启动一个定时器去做。

断线重连:可以加在客户端,也可以加在服务端Handler的channelInactive方法中。

netty的零拷贝

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

直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,某些情况下这部分内存也会被频繁地使用,而且也可能导致OutOfMemoryError异常出现。Java里用DirectByteBuffer可以分配一块直接内存(堆外内存),元空间对应的内存也叫作直接内存,它们对应的都是机器的物理内存。

通常一次读写是 socket读取到直接内存,直接内存拷贝到用户jvm内存,用户操作后由Jvm再拷贝到直接内存再发送出去,使用直接内存避免了与jvm直接的拷贝。堆中的DirectByteBuffer中记录的只是堆外内存的offset和size。当申请堆外内存时,使用Cleaner机制注册内存回收处理函数,当直接内存引用对象DirectByteBuffer被GC清理掉时, 会提前调用这里注册的释放直接内存Deallocator线程对象的run方法释放堆外内存。当然如果一直不GC得话,可能会导致堆外内存申请的越来越多导致宕机,我们可以通过-XX:MaxDirectMemorySize来指定推外内存上限,当达到阈值的时候,调用system.gc来进行一次FULL GC回收内存。

直接内存还有一个缺点就是其初始申请堆外内存时,其速度是比申请对内内存要慢的。因此,netty做了一个bytebuf内存池的设计,需要的时候去池子里哪,用完了还放回去。

netty的高效主要体现在其 主从Reactor线程模型NIO多路复用非阻塞无锁串行化执行(一次读取时一个线程完成peline中所有handler逻辑,没有线程切换),支持高性能的序列化方式零拷贝以及内存池设计

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值