Netty初体验

JAVA NIO核心

1、selector

selector的作用就是通过一个线程去轮训client发过来的连接、读、写请求,启动一个线程或者丢到线程池里面去处理相应的请求。这样相对于原来的阻塞的模式,serverSocketChannel和socketChannel无须在accept和read的时候阻塞。
在这里插入图片描述

selector的原理就是把所有socketChannel注册到selector当中,当没有任何读写请求进入的时候,selector.select()就是阻塞的(当然这里可以设置超时时间,当到达超时时间时可以继续执行下面的逻辑),当selector监听到相应的读写请求时,就会触发selector然后遍历一遍所有注册的好的channel,实际是SelectionKey,判断进入的请求的类型,接着进行接收读写请求的处理逻辑,如果发生异常的话必须将SelectionKey.cancel()

2、buffer

buffer的基本原理:jdk14引入的,类似java的数组,快捷便利地操作数组
两种创建的方式

底层是创建了堆的缓冲区
ByteBuffer allocate = ByteBuffer.allocate(10);

byte[] arr = new byte[]{1,2,3};
ByteBuffer allocate2 = ByteBuffer.wrap(arr);
arr[0] = 99; // 可以修改,因为ByteBuffer只是一个引用arr的对象

// 可以修改
ByteBuffer.put(1, 8);

源码:
在这里插入图片描述
属性:
mark就是一个标记位置,position是移动的位置,正常是小于capacity就行,但是如果设置了limit,那么就得以limit为界限,mark就是标记位(比如读写了两个位置,后续还是得读取这个位置的话,那么就可以用mark来标记,调用reset就会到标记的位置了position=mark)
下面是flip方法的源码
limit设置为postion,就是说上次写的位置就是下次读的limit
在这里插入图片描述
还有一些postion重置为0,判断是否还有位置读的
在这里插入图片描述
读写操作:
主要是get和put,都会导致position移动,比如写入一个元素,postion的角标从0移动到了1。
底层也是维护了一个名为hb的数组会缓存数据的,offset是数组读写到哪一个位置,isReadOnly是设置数组只能读不能写
在这里插入图片描述
其他操作

 // compact
 byte[] arr = new byte[]{1,2,3,4,5,6,7,10,8,7,8,8};
 ByteBuffer wrap = ByteBuffer.wrap(arr);
 for (int i=0; i<4; i++) {
     wrap.get();
 }
 // position=4, 从index=4开始到limit这段压缩到最前面
 wrap.compact();
 // position=8
 
 // copy reference
 ByteBuffer duplicate = wrap.duplicate();

for (int i=0; i<4; i++) {
    wrap.get();
}
// 进行切片
ByteBuffer slice = wrap.slice();
// 逻辑上的位移,实际的position不变
slice.arrayOffset();
// position = 0
slice.position();
// 逻辑上还有
slice.hasRemaining();
// slice这个ByteBuffer打印出来和wrap是一样的,但是维护的一个arrayOffset作为当前的位移
System.out.println(Arrays.toString(slice.array()));

比较的方法比较特别,从limit开始往后比较,剩余量也必须一样,否则认定为不一致
在这里插入图片描述
因为讲到了buffer,那就必须提及一下直接缓冲区,可以使用本地系统的io,比HeapBuffer会快一些,调用的是unsafe下面的方法

ByteBuffer allocate = ByteBuffer.allocateDirect(10);
allocate.put(new byte[]{'a', 'v', 'd', 'q'});
allocate.flip();

源码:
在这里插入图片描述
在这里插入图片描述
直接内存如何清理:通过生成一个叫cleaner的对象清理,底层维护了一个队列referenceQueue,存放cleaner。对于堆内内存来说,如果对象buffer没有被任何对象引用的时候,那么就会被gc掉,那么对于直接内存来说,一般只能通过调用unsafe的方法去释放,那么对于nio的directBuffer来说,对象不被引用了,也会被gc,有一个reference Handle的线程,执行tryHandlepending方法,当cleaner引用的DirectBuffer对象准备回收时,pending会变成cleaner对象,pending不会null就进行销毁
在这里插入图片描述
读写都是调用底层的unsafe的读写方法,重要是直接内存维护的address变量,通过这个变量来表示当前缓存的位置,比如duplicate或者slice操作时,就会在原来的address上移动,除此之外,由于复制出来的buffer引用的是原来的buffer,所以通过这个att属性标记,防止被强引用回收,所以只要原来和复制出来的buffer不被回收,就不会被强引用回收
在这里插入图片描述

3、channel

java的的普通的io就是通过流的方式一个字节一个字节的传输,而对于Nio来说,那就是按照buffer缓存数据,channel进行,这样速度就很快。

使用RandomAccessFile读写文件,这样就不需要new两个流(inputStream和outputStream)

try (RandomAccessFile f = new RandomAccessFile("file.txt", "rw");
     FileChannel channel1 = f.getChannel()) {
    // 只留前面20个字节
//            channel1.truncate(20);

    // 使用mappedByteBuffer,数据存在内存,但是修改内存中的数据,就可以修改文件中的数据,原因就是内核和用户公用同一块缓存
    // 从缓存的第4个位置开始,映射10个字节的内容到内存中
    // 必须是readwrite模式,否则无法保存到文件
    MappedByteBuffer buffer = channel1.map(FileChannel.MapMode.READ_WRITE, 4, 10);
    // 从第4个字节开始
    buffer.put("asca".getBytes(StandardCharsets.UTF_8));
    // 编辑内存结束, 其实就是刷鞋到文件
    buffer.force();

	// true代表是一把共享锁,都可以来读,但是写不能一起写,false代表读写都只能一个进程
    channel1.lock(0, 10, true);
}

原始的多个client连接server,会导致client长时间占领一个server端的线程,非常浪费资源
在这里插入图片描述
可以使用nio的多路复用模型,其实理解比较简单:

  • 第一种,就是通过selector去监听,这也是为什么实际上用户线程是被这个select阻塞,但只要有的连接的状态发生改变时,就知道有socket(或者是socketChannel)已经准备好了,但是不确定是哪一个socket,所以需要遍历所有连接,所以存在最大的连接限制,这个是select的缺点。
  • 第二种,epoll,所有的连接是采用链表的形式,所以没有限制。
  • 第三种,epoll,采用事件通知的机制,能够进行精准通知,可以理解为每一个连接带上了一个id,通过这个id绑定了一个callback函数,只要连接的状态发生改变,就会触发callback,但是只有linux支持这种方式
    总结:避免了使用多个线程去获取多个client的连接,只需要一个线程即可获取所有client连接
    在这里插入图片描述

serverSocketChannel只能监听accpet事件,socketChannel监听的是read或者是write事件

Reactor模式

单Reactor模式

  • 单Reactor的多线程模式
    上面讲的其实还是单线程的处理,可以优化为两个组件,一个是Reactor线程:负责响应IO事件,并分发dispatcher到Handle处理器;一个是Handle线程:执行非阻塞的操作,其实就是读写操作,Acceptor线程,可以执行监听连接的操作
    在这里插入图片描述
  • 单Reactor的多线程(指业务逻辑的线程)模式
    在这里插入图片描述
  • 主从reactor的多线程模式
    意义:Handle是一个多线程的模式,因为读写可能频繁,但是连接的请求可能也会有压力,所以需要多个线程来分担单线程压力,accpet的Reactor1有一个selector,专门等待serverSocketChannel的连接请求,读写的Reactor2(可以有多个)就是socketChannel的读写请求,新起一个selector监听读写请求,Reactor1就是通过接受到请求之后,将创建出来的socketChannel分发给子Reactor2
    在这里插入图片描述

Netty

Nio一些问题:client断开连接,导致server端产生空轮询,但实际也没有发送任何数据,selector.select()方法在连接断开之后会判断为true,但数据为空,除此之后还有一些bug,但是netty能解决。

粘包或者拆包

数据通过网络发送前是在序列化成二进制流存储在缓冲区(大小是自定义的)里面,缓冲区满了之后,进行数据的传输,导致可能会出现拆包如3、4的情况,也有可能出现2的粘包的情况,这样的话就必须采用一些协议的帮助来解决了:
在这里插入图片描述
1、消息定长:报文长度固定,比如每个报文长度就是200B,不够空位时补空格,这样接收者读取的时候就稳定在200字节。
2、报尾添加分隔符,接收者通过分隔符划分不同的报文 =》 LineBasedFrameDecoder、DelimiterBasedFrameDecoder(自定分隔符)
3、将消息分为消息头和消息体,消息体好理解,就是数据本身,消息头就是数据的总长度 =》 LengthFieldBasedFrameDecoder(netty提供的)
4、自定义的协议
netty框架是采用编解码框架才实现上述的方案,包括Bytes,JSON,Protobuf、Serialization、XML都支持

ByteBuf

与原先的ByteBuffer相比,维护两个指针readIndex和writeIndex,wrtie永远大于read

  • 无需flip进行postion的切换
  • 动态扩容
  • 更快的响应的速度
  • 有复合模式,两个ByteBuf拼接一起的时候,不需要开辟一块新的内存,相当于做了一个多个缓冲区拼接后的视图
  • 具有UnpooledByteBufAllocator和PooledByteBufAllocator,PooledByteBufAllocator就是一种池化内存的技术,复用堆外或者堆内的申请出来没被占用的内存地址,避免重复申请内存的造成的性能损失,申请内存,然后release,下一次申请可能还是原来的内存地址
//包含了一个数组arr,是一个byte[10]
//在netty的buffer中,不需要像nio一样需要进行flip,读写切换
//底层维护writeindex 和 readindex,还有capacity
//0 - readindex 已经度过的
//readindex - writeindex 可读的区域
//writeindex - capcity 可写的区域
ByteBuf buf = Unpooled.buffer(100);
for (int i = 0; i<10; i++){
    buf.writeByte(i);
}
System.out.println("capacity" + buf.capacity());
//        for (int i = 0; i<buf.capacity(); i++){
//            System.out.println(buf.getByte(i));//这个方式会输出100个数据,并且readIndex不变
//
//        }
for (int i = 7; i>0; i--){
    System.out.println(buf.readByte());//这个方式会输出100个数据,并且readIndex不变

}
// 将可读的字节移动到最前面,读写指针往前移动
buf.discardReadBytes();
// 读写指针归零
buf.clear();

零拷贝

操作系统分为了内核空间和用户空间,早期OS是没有区分,后续通过cpu指令的级别进行了区分,总共分为了4级,linux只有两级。那么用户空间其实就是应用层面的,一些实际的io操作就是通过调用内核执行的包括read、write的操作。那么实际上,进行数据的读写时,从磁盘(内核)中读取数据copy到内核的缓冲区(页缓存),接着需要进行内核与用户空间之间的转换,这个过程其实上下文的切换,非常消耗cpu,zero-copy技术就是在这个地方进行优化的。接着用户空间进行数据的操作之后,需要发送数据到另外一台机器,需要从用户空间切换到内核空间,那么就需要将数据copy到socket缓冲区,然后copy发送给网络
在这里插入图片描述

过程经历了两次cpu copy操作和两次DMA(硬件的数据到软件层面)的copy操作,那么有三种方式来减少copy的次数
1、使用mmap和write内存映射
NIO中的MappedByteBuffer就是这样的原理,内核空间的页缓存映射到用户空间的缓存,这样也无需copy,相当于页缓存的改变会间接导致用户空间的缓存改变,copy次数没有改变,内核和用户空间的切换次数没有改变
在这里插入图片描述

由于是读写两个过程,涉及到读磁盘和socket存储数据通过网卡进行发送的过程,总共是四次内核态和用户态的切换,切换1,发送读的系统调用给内核空间;切换2,内核收到请求,从磁盘中读取数据,存在页缓存中,用户空间进行读取;切换3,用户空间发送写的系统调用给内核把数据写入到socket缓冲区;切换4,用户空间接收到wrtie调用返回
切换1和切换2的图例
在这里插入图片描述

2、sendFile的方式代替mmap,只需要进行两次内核和用户的切换,属于linux2.1的优化
在这里插入图片描述
如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo 方法:

@Overridepublic 
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { 
    return fileChannel.transferTo(position, count, socketChannel);
}

如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。

3、优化了sendFile如果网卡支持 SG-DMAThe Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程,真正的零拷贝就是无需要cpu的拷贝,所以总共是两次DMA拷贝和两次上下文切换
在这里插入图片描述

Netty工作模型

咋一看,就是和主从Reactor的多线程模型类似的,不过是把监听accept请求的抽象为BossGroup,读写的抽象为workerGroup,EventLoop(事件循环)是进行是事件监听的,代替的是while(true) {Selector.select()}的逻辑,一个事件循环可以理解为一个socketChannel或者serverSocketChannel,一个channel只能在它的生命周期注册一个EventLoop
在这里插入图片描述
server端代码

public static void main(String[] args) {
NioEventLoopGroup worker = new NioEventLoopGroup();
NioEventLoopGroup boss = new NioEventLoopGroup();
try {
    // 1.启动器,其实是一个引导
    ChannelFuture future = new ServerBootstrap()
            // 2、bossEventLoop 和 workerEventLoop(selector和thread),
            .group(boss, worker)
            // 3、选择服务器的serverSocketChannel实现
            .channel(NioServerSocketChannel.class)
            // 4、boss负责处理连接,worker负责处理读写,决定了worker能执行哪些handle
            .childHandler(new ChannelInitializer<NioSocketChannel>() {
                @Override
                // 5、chaneel代表了和客户端的读写通道的初始化,负责添加别的handle
                protected void initChannel(NioSocketChannel ch) throws Exception {
                    // 6、添加具体handle
                    // 这里添加了一个解码器,根据换行符划分不同的包
                    ch.pipeline().addLast(new LineBasedFrameDecoder(30));
//                        ch.pipeline().addLast(new StringDecoder());  // 将bytebuf转为str
                    ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { // 自定义处理读事件

                        @Override
                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                            // 可以自定参数-Dio.netty.noPreferDirect来控制是否加载直接内存,但是io的读写默认都是direct的
                            // 像这种直接new出来的可以控制是对内还是对外
                            ByteBufAllocator alloc = ctx.alloc();
                            log.debug("alloc buf{}," , alloc);
                            System.out.println("connect.." + ctx.channel());
                            System.out.println("");
                            super.channelActive(ctx);
                        }
                    });
                }
            })
            // 7、绑定监听端口
            .bind(8888);
    future.sync(); // 阻塞main等待结果
    future.channel().closeFuture().sync(); // 阻塞的等待channel关闭,mian线程再结束
}catch (Exception e){
    e.printStackTrace();
}finally {
    boss.shutdownGracefully();
    worker.shutdownGracefully();
}


// fireChannel方法就是发送这个buf给下一个handle,handle通过pipelinea.addLast()连接

Netty的Channel中,所有的io操作都是异步的,执行完之后立马返回,返回数据的话采用阻塞main线程,没错就是 Future这个玩意来返回相应的数据结果

channel的生命周期

registered、active(channel被激活了,后面只要链接没断开,就是频繁读写、完成)、read、complete、read、complete。。。client关闭操作、会触发多一次complete、然后inactive、unregistered

Channle的入栈和出栈顺序

无论是server还是client的出栈还是入栈,都是从上往下是入,
在这里插入图片描述

public class MyServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();
        //入站的handle(从socket这边过来的)进行解码MyByteToLongDecoder
        //server端这边入站就是从上往下执行,client端是出栈(虽然是inbound类),执行顺序正好相反,所以同样都是先加编码或者解码器
        pipeline.addLast(new MyByteToLongDecoder());
         //client向server发送数据,开始进行编码,入站找inbound线程,出战找outbound线程
        pipeline.addLast(new MyLongToByteEncoder());//继承了outbound
        pipeline.addLast(new MyServerHandler());
        pipeline.addLast(new MyLongToByteEncoder2());//继承了outbound
		// 执行顺序 
		// 出栈 MyLongToByteEncoder2 -> MyLongToByteEncoder
		// 入栈 MyByteToLongDecoder -> MyServerHandler
    }
}

Future & Promise

juc里面的Future只有get()阻塞和超时阻塞两种方法,没办法实现精准时间的获取数据返回或者不阻塞主线程去执行别的任务,而netty的Future提供了这样的方法,通过注册一个监听线程,比如zk,可以通过监听器监听节点的创建销毁的状态,而有了Listener线程之后,可以更加准确地获取任务完成的时间,且在未返回数据的过程,不阻塞main线程

// 普通的jdk的future
final FutureTask<Integer> integerFutureTask = new FutureTask<>(new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        Thread.sleep(2000);
        return 2;
    }
});
// 超时阻塞
// integerFutureTask.get(1000L , TimeUnit.MILLISECONDS);

// netty的future
final EventLoop next = new NioEventLoopGroup().next();
final Future<Object> submit = next.submit((Callable<Object>) () -> {
    Thread.sleep(2000);
    return 33;
});
// 1、会阻塞,等待最终结果,但这种方式就和原来juc的future提供的get()类似 sync()也一样
//        submit.await();


// 2、不阻塞main线程,新启动一个线程(非守护线程,所以mian线程执行完了,也会等待这个监听器)去监听结果
submit.addListener(new GenericFutureListener<Future<? super Object>>() {
    @Override
    public void operationComplete(Future<? super Object> future) throws Exception {
        if (future.isDone()) {
            System.out.println("i have done, i'm back" + System.currentTimeMillis());
        } else {
            System.out.println("还没完成");
        }
    }
});
System.out.println(String.valueOf(submit.isDone()) + System.currentTimeMillis());

Promise比Future多了手动设置成功或者是失败的方法,常用的方式是把他当作线程之间交互的容器

final Promise<Object> objectPromise = next.newPromise();
objectPromise.setFailure(new Throwable("fail"));
objectPromise.setSuccess("win");

Netty Server启动源码解析

初始化的时候创建一个Acceptor的inboundHandle
在这里插入图片描述
Acceptor将在channel,也就是与客户端的连接注册给workerGroup
在这里插入图片描述

server进行监听的源码过程
在这里插入图片描述
对于NIO的空轮训的情况,netty这边做了相关的解决,NioEventLoop里面有一个死循环,维护一个变量i,调用unexpected的逻辑,如果是出现空轮训这种异常状态,变量i自增,超过一个阈值512的时候,重新创建一个selector,原来的channel的注册到selector,旧的selector关闭

多路复用I/O模型、BIO模型、NIO模型

在这里插入图片描述
BIO和多路复用I/O模型的其实很相似,其实都有两个阶段的阻塞过程(一个阶段是等待数据,一个是复制数据的过程),但是对于多路复用io来说,第一阶段是通过一个selector的复用器来进行阻塞,会监听注册进来的所有io请求,然后统一等待数据的准备,轮询注册的io请求,当数据可读的时候,就会通知之前注册到selector的进程进行数据的拷贝的阶段。当有多个连接的时候,多路复用io才能够发挥出高性能,因为它统一处理等待数据的过程,避免了过多的等待数据准备的时间。但是,当连接少的时候,效果不如BIO,因为多路复用进行了两个系统调用,select和recvfrom,而BIO只有recvfrom。
特点:主要应用在java NIO(selector,channle,buffer,不同于下面的NIO模型),对于每一个socket来说,其实一直都是阻塞的,只不过BIO阻塞的是socket io过程,多路复用第一个阶段是select阻塞,第二个阶段是socket io的阻塞

在这里插入图片描述
对于socket的NIO来说,在数据包没有准备好之前,是不会阻塞用户进程的,他会不停的一直调用recvfrom,查看数据包是否准备好,这样会造成cpu资源过多的浪费,所以主要是应用在并发小且不需要及时响应的场景。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值