Netty知识图谱

Posted 朱培

tags:

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

Netty

  • netty的线程模型
    • Reactor模式类型
      • 单线程Reactor
        • Reactor是一个线程对象,该线程会启动事件循环,并使用Selector(选择器)来实现IO的多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样Reactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。
      • 单线程Reactor,工作者线程池(多线程模型)
        • 添加了一个工作者线程池,并将非I/O操作从Reactor线程中移出转交给工作者线程池来执行。
      • 多线程主从Reactor模式
        • Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的事件循环逻辑。
    • Reactor 模型
      • 工作流程图。BossGroup专门负责接收客户端的连接, WorkerGroup专门负责网络的读写。主要的4个元素:Acceptor处理 client 的连接,并绑定具体的事件处理器;Event具体发生的事件,比如图中s的read、send等;Handler执行具体事件的处理者,比如处理读写事件的具体逻辑;Reactor将具体的事件分配(dispatch)给 Handler。
    • Boss NioEventLoop
      • 每个Boss NioEventLoop线程内部循环执行的步骤有 3 步 :处理accept事件 , 与client 建立连接 , 生成 NiosocketChannel 将NioSocketChannel注册到某个worker NIOEventLoop上的selector 处理任务队列的任务 , 即runAllTasks
    • Worker NIOEventLoop
      • 每个worker NIOEventLoop线程循环执行的步骤: 轮询注册到自己selector上的所有NioSocketChannel 的read, write事件 处理 I/O 事件, 即read , write 事件, 在对应NioSocketChannel 处理业务 runAllTasks处理任务队列TaskQueue的任务 ,一些耗时的业务处理一般可以放入TaskQueue中慢慢处 理,这样不影响数据在 pipeline 中的流动处理
      • 线程启动时调用SingleThreadEventExecutor的构造方法,执行NioEventLoop类的run方法,首先会调用hasTasks()方法判断当前taskQueue是否有元素。如果taskQueue中有元素,执行 selectNow() 方法,最终执行selector.selectNow(),该方法会立即返回。如果taskQueue没有元素,执行 select(oldWakenUp) 方法。在这里select ( oldWakenUp) 方法解决了 Nio 中的 bug
      • processSelectedKeys
        • 当selectedKeys != null时,调用processSelectedKeysOptimized方法,迭代 selectedKeys 获取就绪的 IO 事件的selectkey存放在数组selectedKeys中, 然后为每个事件都调用 processSelectedKey 来处理它,processSelectedKey 中分别处理OP_READ;OP_WRITE;OP_CONNECT事件。
      • runAllTasks
        • 该方法首先会调用fetchFromScheduledTaskQueue方法,把scheduledTaskQueue中已经超过延迟执行时间的任务移到taskQueue中等待被执行,然后依次从taskQueue中取任务执行,每执行64个任务,进行耗时检查,如果已执行时间超过预先设定的执行时间,则停止执行非IO任务,避免非IO任务太多,影响IO任务的执行。
  • Netty核心组件
    • Bootstrap和ServerBootstrap
      • Netty 中 Bootstrap 类是客户端程序的启动引导类,ServerBootstrap 是服务端启动引导类。
    • Future、ChannelFuture
      • 以注 册一个监听,当操作执行成功或失败时监听会自动触发注册的监听事件。
    • Channel
      • Netty 网络通信的组件,能够用于执行网络 I/O 操作。例如当前网络连接的通道的状态、如接收缓冲区大小、提供异步的网络 I/O 操作(如建立连接,读写,绑定端口)等
      • channel 的注册过程:首先在 AbstractBootstrap.initAndRegister中, 通过 group().register(channel), 调用 MultithreadEventLoopGroup.register 方法在MultithreadEventLoopGroup.register 中, 通过 next() 获取一个可用的 SingleThreadEventLoop, 然后调用它的 register在 SingleThreadEventLoop.register 中, 通过 channel.unsafe().register(this, promise) 来获取 channel 的 unsafe() 底层操作对象, 然后调用它的 register.在 AbstractUnsafe.register 方法中, 调用 register0 方法注册 Channel在 AbstractUnsafe.register0 中, 调用 AbstractNioChannel.doRegister 方法AbstractNioChannel.doRegister 方法通过 javaChannel().register(eventLoop().selector, 0, this) 将 Channel 对应的 Java NIO SockerChannel 注册到一个 eventLoop 的 Selector 中, 并且将当前 Channel 作为 attachment.
      • 总的来说, Channel 注册过程所做的工作就是将 Channel 与对应的 EventLoop 关联,当关联好之后,会继续调用底层的 Java NIO SocketChannel 的 register 方法, 将底层的 Java NIO SocketChannel 注册到指定的 selector 中
    • Selector
      • 通过 Selector 一个线程可以监听多个连接的 Channel 事件。 当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是 否有已就绪的 I/O 事件
    • NioEventLoop
      • NioEventLoop 中维护了一个线程和任务队列,支持异步提交执行任务,线程启动时会调用 NioEventLoop 的 run 方 法,执行 I/O 任务(processSelectedKeys)和非 I/O 任务(runAllTasks)
      • NioEventLoop 的继承关系,NioEventLoop -> SingleThreadEventLoop -> SingleThreadEventExecutor -> AbstractScheduledEventExecutor。所以我们可以通过调用一个 NioEventLoop 实例的 schedule 方法来运行一些定时任务
    • NioEventLoopGroup
      • 主要管理 eventLoop 的生命周期,可以理解为一个线程池,内部维护了一组线程,每个线程 (NioEventLoop)负责处理多个 Channel 上的事件,而一个 Channel 只对应于一个线程。
      • 如果我们在实例化 NioEventLoopGroup 时, 如果指定线程池大小, 则 nThreads 就是指定的值, 反之是处理器核心数 * 2
    • ChannelHandler
      • ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的 下一个处理程序。
      • 基于 Pipeline 的自定义 handler 机制。主要和ChannelInitializer有关,里面有一个initChannel
      • 服务器端的 handler 与 childHandler 的区别与联系在服务器 NioServerSocketChannel 的 pipeline 中添加的是 handler 与 ServerBootstrapAcceptor.当有新的客户端连接请求时, ServerBootstrapAcceptor.channelRead 中负责新建此连接的 NioSocketChannel 并添加 childHandler 到 NioSocketChannel 对应的 pipeline 中, 并将此 channel 绑定到 workerGroup 中的某个 eventLoop 中.handler 是在 accept 阶段起作用, 它处理客户端的连接请求.childHandler 是在客户端连接建立以后起作用, 它负责客户端连接的 IO 交互.
    • ChannelPipeline
      • ChannelPipeline提供了ChannelHandler链的容器。
    • ChannelHandlerContext
      • 保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象。
    • ChannelPipline
      • 用于处理或拦截 Channel 的入站事件和出站操作。是一个过滤器链
  • ByteBuf
    • ByteBuf 由一串字节数组构成。数组中每个字节用来存放信息。 ByteBuf 提供了两个索引,一个用于读取数据,一个用于写入数据。这两个索引通过在字节数组中移动,来定 位需要读或者写信息的位置
    • 当从 ByteBuf 读取时,它的 readerIndex(读索引)将会根据读取的字节数递增。 同样,当写 ByteBuf 时,它的 writerIndex 也会根据写入的字节数进行递增。每读取一个字节,readerIndex递增1;直到readerIndex等于writerIndex,表示ByteBuf已经不可读;每写入一个字节,writerIndex递增1;直到writerIndex等于capacity,表示ByteBuf已经不可写;当writerIndex等于capacity表示底层字节数组需要扩容,且最大扩容不能超过max capacity。
    • ByteBuf浅复制实现
      • ByteBuf支持浅复制分片,其中分为slice浅复制和duplicate浅复制。duplicate与slice的区别是,duplicate是对整个ByteBuf的浅复制,而slice只是对ByteBuf中的一部分进行浅复制。他们都没有调用retain()方法来改变底层ByteBuf的引用计数。 所以,如果底层ByteBuf调用release()后被释放,那么所有基于该ByteBuf的浅复制对象都不能进行读写。
    • ByteBuf扩容机制
      • minNewCapacity:表用户需要写入的值大小 threshold:阈值,为Bytebuf内部设定容量的最大值 maxCapacity:Netty最大能接受的容量大小,一般为int的最大值
      •                 int minNewCapacity = writerIndex + minWritableBytes;                int newCapacity = this.alloc().calculateNewCapacity(minNewCapacity, this.maxCapacity);                int fastCapacity = writerIndex + this.maxFastWritableBytes();                if (newCapacity > fastCapacity && minNewCapacity <= fastCapacity)                    newCapacity = fastCapacity;                                this.capacity(newCapacity);
        • public int calculateNewCapacity(int minNewCapacity, int maxCapacity)        ObjectUtil.checkPositiveOrZero(minNewCapacity, "minNewCapacity");        if (minNewCapacity > maxCapacity)            throw new IllegalArgumentException(String.format("minNewCapacity: %d (expected: not greater than maxCapacity(%d)", minNewCapacity, maxCapacity));         else            int threshold = 4194304;            if (minNewCapacity == 4194304)                return 4194304;             else                int newCapacity;                if (minNewCapacity > 4194304)                    newCapacity = minNewCapacity / 4194304 * 4194304;                    if (newCapacity > maxCapacity - 4194304)                        newCapacity = maxCapacity;                     else                        newCapacity += 4194304;                                        return newCapacity;                 else                    for(newCapacity = 64; newCapacity < minNewCapacity; newCapacity <<= 1)                                        return Math.min(newCapacity, maxCapacity);                                        
      • 扩容过程: 默认门限阈值为4MB,当需要的容量等于门限阈值,使用阈值作为新的缓存区容量 目标容量,如果大于阈值,采用每次步进4MB的方式进行内存扩张((需要扩容值/4MB)*4MB),扩张后需要和最大内存(maxCapacity)进行比较,大于maxCapacity的话就用maxCapacity,否则使用扩容值 目标容量,如果小于阈值,采用倍增的方式,以64(字节)作为基本数值,每次翻倍增长64 -->128 --> 256,直到倍增后的结果大于或等于需要的容量值。
    • 池化和非池化
      • ByteBuf分为两类池化(Pooled)和非池化(Unpooled)。非池化的ByteBuf每次新建都会申请新的内存空间,并且用完即弃,给JVM的垃圾回收带来负担;而池化的ByteBuf通过内部栈来保存闲置的对象空间,每次新建ByteBuf的时候,优先向内部栈申请闲置的对象空间,并且用完之后重新归还给内部栈,从而减少了JVM的垃圾回收压力。
      • 池化的ByteBuf都继承自PooledByteBuf<T>类,新建池化的ByteBuf都是优先从栈中获取闲置对象;当栈没有闲置对象再新建。另外为了抹去历史的使用痕迹,每个新申请的ByteBuf对象,都会调用reuse方法进行初始化
    • 释放 ByteBuf
      • Netty中使用引用计数机制来管理资源,ByteBuf实现了ReferenceCounted接口,当实例化ByteBuf对象时,引用计数加1。当应用代码保持一个对象引用时,会调用retain方法将计数增加1,对象使用完毕进行释放,调用release将计数器减1.当引用计数变为0时,对象将释放所有的资源,返回内存池。
      • TailHandler 自动释放
        • Netty默认会在ChannelPipline的最后添加的那个 TailHandler 帮你完成 ByteBuf的release。
      • SimpleChannelInboundHandler 自动释放
        • SimpleChannelInboundHandler 会完成ByteBuf 的自动释放,释放的处理工作,在其入站处理方法 channelRead 中。
        • @Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception    boolean release = true;    try        if (acceptInboundMessage(msg))            @SuppressWarnings("unchecked")            I imsg = (I) msg;            channelRead0(ctx, imsg);         else            release = false;            ctx.fireChannelRead(msg);             finally         //执行完重写的channelRead0()后,在 finally 语句块中,ByteBuf 的生命被结束掉了。        if (autoRelease && release)            ReferenceCountUtil.release(msg);            
      • HeadHandler 自动释放
        • 出站处理流程中,申请分配到的 ByteBuf,通过 HeadHandler 完成自动释放。
        • 出站处理用到的 Bytebuf 缓冲区,一般是要发送的消息,通常由应用所申请。在出站流程开始的时候,通过调用 ctx.writeAndFlush(msg),Bytebuf 缓冲区开始进入出站处理的 pipeline 流水线 。在每一个出站Handler中的处理完成后,最后消息会来到出站的最后一棒 HeadHandler,再经过一轮复杂的调用,在flush完成后终将被release掉。
  • 基本概念
    • 应用场景
      • 互联网行业
        • 远程调用、Netty 作为异步 高性能的通信框架
      • 游戏行业
        • Netty 作为高性能的基 础通信组件
      • 大数据领域
        • 经典的 Hadoop 的高性能通信和序列化组件 Avro 的 RPC 框架,默认采用 Netty 进行跨界点通信
    • 示例代码
      • public static void main(String[] args)        // 创建mainReactor        NioEventLoopGroup boosGroup = new NioEventLoopGroup();        // 创建工作线程组        NioEventLoopGroup workerGroup = new NioEventLoopGroup();        final ServerBootstrap serverBootstrap = new ServerBootstrap();        serverBootstrap                  // 组装NioEventLoopGroup                 .group(boosGroup, workerGroup)                 // 设置channel类型为NIO类型                .channel(NioServerSocketChannel.class)                // 设置连接配置参数                .option(ChannelOption.SO_BACKLOG, 1024)                .childOption(ChannelOption.SO_KEEPALIVE, true)                .childOption(ChannelOption.TCP_NODELAY, true)                // 配置入站、出站事件handler                .childHandler(new ChannelInitializer<NioSocketChannel>()                    @Override                    protected void initChannel(NioSocketChannel ch)                        // 配置入站、出站事件channel                        ch.pipeline().addLast(...);                        ch.pipeline().addLast(...);                        );        // 绑定端口        int port = 8080;        serverBootstrap.bind(port).addListener(future ->            if (future.isSuccess())                System.out.println(new Date() + ": 端口[" + port + "]绑定成功!");             else                System.err.println("端口[" + port + "]绑定失败!");                    );
    • BIO
      • 同步阻塞的I/O,若一个服务器启动就绪,那么主线程就一直在等待着客户端的连接,这个等待过程中主线程就一直在阻塞。②在连接建立之后,在读取到socket信息之前,线程也是一直在等待,一直处于阻塞的状态下的。
      • 采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答模型,同时数据的读取写入也必须阻塞在一个线程内等待其完成。
    • NIO
      • 同步非阻塞,IO是面向流的,NIO是面向缓冲区的,是非阻塞模式,引入了 Channel 和 Buffer 的概念
      • NIO之所以是同步,是因为它的accept/read/write方法的内核I/O操作都会阻塞当前线程
      • 非阻塞是指:它的发起请求这一步,不会阻塞请求者的线程。会通过不断的轮询,来实现获取已经准备好内核I/O操作的流。
      • NIO有三大核心组件:Selector选择器、Channel管道、buffer缓冲区。
      • Channels
        • 所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。
        • ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。ScoketChannel:TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口 到 服务器IP:端口的通信连接。
      • Selector
        • SelectionKey:JAVA NIO共定义了四种:OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT(定义在SelectionKey中)
        • 工作流程:1、通过 Selector.open() 打开一个 Selector.2、将 Channel 注册到 Selector 中, 并设置需要监听的事件(interest set)不断重复:      调用 select() 方法      调用 selector.selectedKeys() 获取 selected keys      迭代每个 selected key:               *从 selected key 中获取 对应的 Channel 和附加信息(如果有的话)               *判断是哪些 IO 事件已经就绪了, 然后处理它们              *根据需要更改 selected key 的监听事件.              *将已经处理过的 key 从 selected keys 集合中删除.
      • Buffer详解
        • capacity、position、limit其中 position 和 limit 的含义与 Buffer 处于读模式或写模式有关, 而 capacity 的含义与 Buffer 所处的模式无关.
        • rewind() 主要针对于读模式. 在读模式时, 读取到 limit 后, 可以调用 rewind() 方法, 将读 position 置为0.
        • 调用 Buffer.flip()方法, 将 NIO Buffer从写模式切换到读模式。调用 Buffer.clear() 或 Buffer.compact()方法, 将 Buffer 转换为写模式.
        • 分配 Buffer:ByteBuffer.allocate(48);
        • 直接内存(堆外内存)与堆内存比较
          • 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显Buffer的读写
    • AIO
      • 异步非阻塞,一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,
    • 编解码
      • 当你通过Netty发送或者接受一个消息的时候,就将会发生一次数据转换。入站消息会被解码:从字节转换为另一种格式(比如java对 象);如果是出站消息,它会被编码成字节。通过ChannelInboundHadnler或者ChannelOutboundHandler接口来实现
      • 解码器例如:网络加密报文 -> 经过ByteToMessageDecoder -> String类型的JSON明文;String类型的JSON文本-> 经过MessageToMessageDecoder -> Java里的对象
      • 编码器,例如:Java里的对象-> 经过MessageToMessageEncoder -> String类型的JSON文本String类型的JSON明文 -> 经过MessageToByteEncoder-> 网络加密报文;
    • 粘包拆包
      • 业务上认为是一个完整的包,可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成 一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。
      • 解决方案
        • 消息定长度,传输的数据大小固定长度,例如每段的长度固定为100字节,如果不够空位补空格
        • 在数据包尾部添加特殊分隔符,比如下划线,中划线等,这种方法简单易行,但选择分隔符的时候一定要注意每条数据的内部一定不 能出现分隔符。
        • 发送长度:发送每条数据的时候,将数据的长度一并发送,比如可以选择每条数据的前4位是数据的长度,应用层处理时可以根据长度 来判断每条数据的开始和结束。
        • 总结:通过设置消息长度+消息内容的解码方式是最常用的,例如LengthFieldBasedFrameDecoder,其他类型的有FixedLengthFrameDecoder、LineBasedFrameDecoder、DelimiterBasedFrameDecoder。
    • Netty心跳检测机制
      • 在 Netty 中, 实现心跳机制的关键是 IdleStateHandler。pipeline.addLast(new IdleStateHandler(3, 0, 0, TimeUnit.SECONDS));
      • readerIdleTimeSeconds: 读超时. 即当在指定的时间间隔内没有从 Channel 读取到数据时, 会触发一个 READER_IDLE 的 IdleStateEvent 事件. writerIdleTimeSeconds: 写超时. 即当在指定的时间间隔内没有数据写入到 Channel 时, 会触发一个 WRITER_IDLE 的 IdleStateEvent 事件. allIdleTimeSeconds: 读/写超时. 即当在指定的时间间隔内没有读或写操作时, 会触发一个 ALL_IDLE 的 IdleStateEvent 事件. 注:这三个参数默认的时间单位是秒。
    • Netty断线自动重连实现
      • 服务端:系统运行过程中网络故障或服务端故障,导致客户端与服务端断开连接了也需要重连,可以在客户端处理数据的Handler的 channelInactive方法中进行重连。捕获IdleState.READER_IDLE
      • 客户端:重写了userEventTriggered方法,用于捕获IdleState.WRITER_IDLE事件(未在指定时间内向服务器发送数据),然后向Server端发送一个心跳包。
  • 内存池
    • 对象池
      • 通过ThreadLocal无锁机制消除了多线程的竞争
    • 为什么需要池化内存
      • 堆外内存在 JVM 之外,在有效降低 JVM GC 压力的同时,还能提高传输性能。
      • 但是对外内存创建堆外内存的速度比堆内存慢了10到20倍,他的申请和释放都是高成本的操作
      • 所以我们可以需要通过一个内存池来高效分配内存的同时又解决内存碎片化的问题。
      • Netty内存池的思想源于 jemalloc github ,可以说是 jemalloc 的 Java 版本。
    • 内存碎片
      • 在linux中,物理内存会被划分成若干个 4KB 大小的内存页(page),这是分配内存大小的最小粒度。
      • 内存碎片:是内存被分割成很小的块,虽然这些块是空闲且地址连续的,但却小到无法使用。
      • 外部碎片:还没有被分配出去(不属于任何进程),但由于太小了无法分配给申请内存空间的新进程的内存空闲区域。
    • 常见的内存分配器算法
      •    动态内存分配(堆内存分配),也叫 DMA
        • DMA 就是从一整块内存中 按需分配,对于已分配的内存会记录元数据,同时还会使用空闲分区维护空闲内存,便于在下次分配时快速查找可用的空闲分区。
        • 查找策略
          • 首次适应算法(first fit)
            • 空闲分区按内存地址从低到高的顺序以双向链表形式连接在一起。内存分配每次从低地址开始查找并分配。因此造成低地址使用率较高而高地址使用率很低。同时会产生较多的小内存。
          • 循环首次适应算法(next fit)
            • 和first fit类似,不过他的第二次的分配是从下一个空闲分区开始查找,这种方法将内存分配得更加均匀,但是依然会导致严重的内存碎片问题。
          • 最佳适应算法(best fit)
            • 也是从低到高的顺序,当完成分配请求后,空闲分区链重新按分区大小排序。每次分配完后需要重新排序,因此存在 CPU 消耗。
        • 存在的问题
          • 会导致严重的内存碎片,随着申请和释放次数的增长,内存将变得愈来愈不连续。
      • 伙伴算法(Buddy)
        • 是一种内存分配算法,它将内存划分为分区,以最合适的大小满足内存请求。按照不同的规格,以块为单位进行分配。各个内存块可分可合,但不是任意的分与合。每一个块都有个朋友,或叫“伙伴”,既可与之分开,又可与之结合。所以,只有伙伴关系的内存块,才能分开和合并。
        • Linux采用伙伴系统解决外部碎片的问题,采用slab解决内部碎片的问题
        • 伙伴算法的原理
          • 例如伙伴算法把所有的空闲页面分为10个块组,每组中块的大小是2的幂次方个页面,每一组中块的大小是相同的,且这同样大小的块形成一个链表(类似于一个hashmap里面的桶)。把内存块存放在比链接表更先进的数据结构中。这些结构常常是桶型、树型和堆型的组合或变种。一般来说,由于所选数据结构的不同,而各伙伴分配程序的工作方式是相差很大。在linux里面伙伴算法把所有的空闲页框分组成 11 个块链表,每一个块链表分别包含大小为1、2、4、8、16、32、64、128、256、512 和 1024 个连续的页框。
          • 寻找到伙伴块的方法:当块大小为n时,寻找到的伙伴块必须满足,合并后的大块的左边(低内存)区域的大小应该是合并大块的k倍,即2nk的大小
          • 分配和释放
            • 假设,一个最初由256KB的物理内存。假设申请21KB的内存,内核需分片过程如下:内核将256KB的内存进行分割,变成两个128KB的内存块,AL和AR,这两个内存块称为伙伴。随后他发现128KB也远大于21KB,于是他继续分割为两个64KB的内存块,发现64KB 也不是满足需求的最小的内存块,于是他继续分割为两个32KB的。32KB再往下就是16KB,就不满足需求了,所以32KB是它满足需求的最下的内存块了,所以他就分割出来的CL 或者CR 分配给需求方。
            • 当用完了,进行释放的时候:他把32KB的内存还回来,它的另一个伙伴如果没被占用,那么他们地址连续,就合并成一个64KB的内存块,以此类推,进行合并。
        • 伙伴位图mem_map
          • 用一位描述伙伴块的状态位码,称之为伙伴位码。为1就表示其中一块忙,为0表示两块都闲或都在使用。系统每次分配和回收伙伴块时都要对它们的伙伴位跟1进行异或运算。
        • 分配的过程
          • 假如系统需要4(2^2)个页面大小的内存块,该算法就到free_area[2]中查找,如果free_area[2]链表中有空闲块,就直接从中摘下并分配出去。如果没有,算法将顺着数组向上查找free_area[3], 如果free_area[3]中有空闲块,则将其从链表中摘下,分成等大小的两部分,前四个页面作为一个块插入free_area[2],后一部分4个页面分配出去free_area[3]中也没有,就再向上查找,如果free_area[4]中有,就将这16(2^4)个页面等分成两份,前一半挂到free_area[3]的链表头部,后一半的8个页等分成两等分,前一半挂free_area[2]的链表中,后一半分配出去。假如free_area[4]也没有,则重复上面的过程,直到到达free_area数组的最后,如果还没有则放弃分配。
        • 存在的问题
          • 伙伴 算法 在小内存场景下并不适用,因为每次都会分配一个 page,导致非常严重的内部碎片。有效减少了外部碎片,但最小粒度还是 page(4K),因此有可能造成非常严重的内部碎片
      • Slab算法
        • Slab 算法 则是在 伙伴算法 的基础上对小内存分配场景做了专门的优化。slab 分配器将分配的内存分割成各种尺寸的块,并把相同尺寸的块分成组。另外分配到的内存用完之后,不会释放,而是返回到对应的组,重复利用。
    • Netty内存规格
      • Netty 对内存大小划分为:Tiny、Small、Normal 和 Huge 四类。对于大于 16MB 的内存定义为 Huge 类型,大型内存不做缓存、不做池化,直接以 Unpool 的形式分配内存,用完后回收。Normal 级别分配的大小范围是 [4097B, 16M)一个 small的范围是(496B, 4096B],有且只有四种大小: 512B、1024B、2048B 和 4096B,以 2 倍递增。
      • Chunk
        • netty 内存向系统或者JVM堆申请是大块的内存,单位是chunk块, 不是一点一点申请,而是一大块一大块的申请,然后再内部高效率的二次分配,一个chunk 的大小是16MB, 实际上每个chunk, 都以双向链表的形式保存在一个chunkList 中。netty使用一棵树完全二叉树来管理这16M的内存,这棵完全二叉树的深度为12,从第0层开始,第11层有2048个叶子节点
      • Page
        • Page 的默认大小为 8KB,是 Chunk 用于管理内存的基本单位。
      • SubPage
        • 是 Page 下的管理单位。对于 SubPage 没有固定的大小,最小为 16Byte
      • 内存规格化:对于small和normal ,规格化成获取最接近 2^n 的数,便于计算和管理。
    • Netty 内存池分配整体思路
      • Netty 底层的内存分配是采用 jemalloc 算法思想。jemalloc 是基于 buddy+Slab 而来,通过 Arena 和 Thread Cache来实现。Arena 是分而治之思想的体现,与其让一个人管理全部内存,到不如将任务派发给多个人,每个人独立管理,互不干涉(线程竞争)。Netty中将内存池分为五种不同的形态从大到小依次是PoolArena、PoolChunkList、PoolChunk、PoolPage、PoolSubPage。首先netty会去申请一个16M的连续内存,也就是Chunk块,然后拆分为2048个8k大小的Page块,然后会进一步拆分为subpage。计算: 当请求内存分配时,将所需要内存大小进行内存规格化,获得规格化的内存请求值。根据值确认准确的树的高度。搜索: 在内存映射数据中,进行空闲内存序列的搜索。标记: 分组被标记为全部已使用,且通过循环更新其父节点标记信息。父节点的标记值取两个子节点标记值的最小的一个。
      • PoolArena
        • 由于 操作系统的读写内存屏障 存在, 会导致多个线程的读写并不能做到真正的并行。因此Netty用了多个PoolArena 来减轻这种不能并行的行为,从而提升效率。
        • 储 PoolChunk 的 6 个 PoolChunkList,他是根据内存的使用率来划分的。q000没有前驱节点,所以一旦PoolChunk使用率为0,就从PoolChunkList中移除,释放掉这部分空间,避免在高峰的时候申请过内存一直缓存到池中。
        • 各个容量区间,PoolChunkList的额定使用率区间存在交叉,这样设计是因为如果基于一个临界值的话,当PoolChunk内存申请释放后的内存使用率在临界值上下徘徊的话,会导致在PoolChunkList链表前后来回移动
      •  PoolSubpage
        • tiny和small的详细结构
      • 分配直接内存入口
        • @Override    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity)        // 若当前线程没有PoolThreadCache 则创建一份 PoolThreadCache        // 若当前线程有 则直接获取        PoolThreadCache cache = threadCache.get();        // 拿到当前线程cache中绑定的directArena        PoolArena<ByteBuffer> directArena = cache.directArena;        final ByteBuf buf;        // 这个条件正常逻辑 都会成立        if (directArena != null)                        //这是咱们的核心入口 ******              // 参数1: cache 当前线程相关的PoolThreadCache对象            // 参数2:initialCapactiy 业务层需要的内存容量            // 参数3:maxCapacity 最大内存大小            buf = directArena.allocate(cache, initialCapacity, maxCapacity);         else            // 一般不会走到这里,不用看            buf = PlatformDependent.hasUnsafe() ?                    UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :                    new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);                return toLeakAwareBuffer(buf);    
    • 缓存架构
      • 在每个线程去申请内存的时候,他首先会通过ThreadLocal 这种方式去获得当前线程的PoolThreadCache对象,然后调用他的allocate方法去申请内存
      • PoolThreadCache是Netty内存管理中能够实现高效内存申请和释放的一个重要原因,Netty会为每一个线程都维护一个PoolThreadCache对象,当进行内存申请时,首先会尝试从PoolThreadCache中申请,如果无法从中申请到,则会尝试从Netty的公共内存池中申请。
      • PoolThreadCache
        • PoolThreadCache是将其分为tiny,small和normal三种不同的方法来调用的,首先根据规格化后的内容规模,去计算不同类型的slot槽位在对应的内存数组中,相对应的slot槽位找到MemoryRegionCache对象之后,通过调用allocate()方法来申请内存,申请完之后还会检查当前缓存申请次数是否达到了8192次,达到了则对缓存中使用的内存块进行检测,将较少使用的内存块返还给PoolArena。
  • epoll模型
    • select,poll,epoll都是IO多路复用的机制。一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
    • 文件描述符,当应用程序请求内核打开/新建一个文件时,内核会返回一个文件描述符用于对应这个打开/新建的文件,其fd本质上就是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
    • select
      • select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
      • 单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低
      • 大量文件返回后需要轮询遍历,造成效率低下
      • 内核需要将消息传递到用户空间,都需要内核拷贝动作
    • poll
      • 它是基于链表来存储的,poll使用一个 pollfd的指针实现。
      • pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
      • 大量文件返回后需要轮询遍历后才可以确定是read/write/execption
      • 内核需要将消息传递到用户空间,都需要内核拷贝动作
    • epoll
      • epoll 在内核维护了一个红黑树,可以保存所有待检测的 socket 。所有socket都很活跃的情况下,可能会有性能问题。它在一个函数里面做的事情分3个步骤做 创建、注册、等待(select.open,register,selectedKeys)
      • epoll通过内核和用户空间共享一块内存来实现的。
      • epoll_create
        • int epoll_create(int size)。创建一个epoll的句柄,其实是在内核申请一空间,用来存放你想关注的socket fd上是否发生以及发生了什么事件。size就是你在这个epoll fd上能关注的最大socket fd数。
      • epoll_ctl
        • 该函数用于控制某个epoll文件描述符上的事件,可以注册事件,修改事件,删除事件。int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
        • epfd:由 epoll_create 生成的epoll专用的文件描述符;
        • op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
        • epoll_event:是告诉内核需要监听什么事,
        • 创建epoll对象后,内核会将eventpoll添加到这三个socket的等待队列中。当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。中断程序会给eventpoll的“就绪列表”添加socket引用。eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
      • epoll_wait
        • 该函数用于轮询I/O事件的发生;int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
        • 参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,
      • 工作流程总结
        • 当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。
        • 同时,所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。
        • 当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_wait效率非常高,可以轻易地处理百万级别的并发连接。
      • epoll的工作方式(可查阅操作系统原理这个章节)
        • 边缘触发
          • 就绪的事件只能处理一次,若没有处理完会在下次的其它事件就绪时再进行处理。
        • 水平触发
          • 若就绪的事件一次没有处理完要做的事件,就会一直去处理。
  • 直接内存和零拷贝技术
    • 直接内存
      • Java里用DirectByteBuffer可以分配一块直接内存(堆外内存),元空间 对应的内存也叫作直接内存,它们对应的都是机器的物理内存。不占用堆内存空间,减少了发生GC的可能
      • 直接内存申请较慢,但访问效率高。在java虚拟机实现上,本地IO一般会直接操作直接内存(直接内存=>系统调用 =>硬盘/网卡),而非直接内存则需要二次拷贝(堆内存=>直接内存=>系统调用=>硬盘/网卡)。
    • 零拷贝(Zero-copy) 技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
    • 注意:零拷贝可以参考我画的“操作系统原理”这个文件里面的,里面也有详细介绍
    • 传统数据传送机制
      • 会存在4次磁盘拷贝。传统的数据传送所消耗的成本:4次拷贝,4次上下文切换。4次拷贝,其中两次是DMA copy,两次是CPU copy。第二次和第三次数据copy 其实在这种场景下没有什么帮助反而带来开销,这也正是零拷贝出现的背景和意义。
    • linux中常见的零拷贝技术
      • mmap内存映射
        • mmap内存映射将会经历:3次拷贝: 1次cpu copy,2次DMA copy;以及4次上下文切换,调用mmap函数2次,write函数2次。
      • sendfile
        • 当调用sendfile()时,DMA将磁盘数据复制到kernel buffer,然后将内核中的kernel buffer直接拷贝到socket buffer;但是数据并未被真正复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。但是要注意,这个需要DMA硬件设备支持,如果不支持,CPU就必须介入进行拷贝。
        • sendfile会经历:3(2,如果硬件设备支持)次拷贝,1(0,,如果硬件设备支持)次CPU copy, 2次DMA copy;以及2次上下文切换
      • splice
        • 数据从磁盘读取到OS内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据buffer,而不需要拷贝到用户空间。sendfile是DMA硬件设备不支持的情况下将磁盘数据加载到kernel buffer后,需要一次CPU copy
        • splice会经历 2次拷贝: 0次cpu copy 2次DMA copy;以及2次上下文切换
    • Java生态圈中的零拷贝
      • NIO
        • MappedByteBuffer:FileChannel.map(),将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件,同时也能对文件内容进行更改,但是如果其后要通过SocketChannel发送,还是需要CPU进行数据的拷贝。
        • sendfile: FileChannel 拥有 transferTo 和 transferFrom 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel
      • Kafka
        • Producer生产的数据持久化到broker,broker里采用mmap文件映射,实现顺序的快速写入;Customer从broker读取数据,broker里采用sendfile,将磁盘文件读到OS内核缓冲区后,直接转到socket buffer进行网络发送。
      • Netty
        • Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。
        • Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。
        • 通过wrap操作,我们可以将byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf对象,进而避免了拷贝操作。
        • ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。
        • 在文件传输上,Netty 的通过FileRegion包装的FileChannel.tranferTo实现文件传输,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
  • 场景分析题
    • ByteBuf和Buffer的对比?
      • Buffer的缺点(1)ByteBuffer长度固定,一旦分配完成,它的容量不能动态扩展和收缩,当需要编码的对象大于ByteBuffer的容量时,会发生索引越界异常;(2)ByteBuffer只有一个标识位置的指针position,读写的时候需要手工调用flip()和rewind()等,使用者必须小心谨慎地处理这些API,否则很容易导致程序处理失败;(3)ByteBuffer的API功能有限,一些高级和实用的特性它不支持,需要使用者自己编程实现。
      • ByteBuf的优点:它可以被用户自定义的缓冲区类型扩展通过内置的符合缓冲区类型实现了透明的零拷贝容量可以按需增长在读和写这两种模式之间切换不需要调用 #flip() 方法读和写使用了不同的索引支持方法的链式调用支持引用计数支持池化,可以减少内存复制和GC,提升效率
    • Netty能处理高并发高性能架构的原因?