当前位置: 首页 > news >正文

分布式专题——26 BIO、NIO编程与直接内存、零拷贝深入辨析

1 网络通信编程基本知识

1.1 Socket

  • Socket 是应用层与 TCP/IP 协议族通信的中间软件抽象层,是操作系统提供的一组接口。它采用门面模式,隐藏了复杂的 TCP/IP 协议处理和通信缓存管理等工作,让用户能通过简单接口进行网络应用编程。主机间应用程序通信需通过 Socket 建立连接,客户端连接服务器会产生 Socket 接口实例,服务器每接受一个客户端连接也会产生对应的 Socket 接口实例来通信,多个客户端连接就会有多个 Socket 接口实例;

    在这里插入图片描述

1.2 短连接

  • 流程:连接 -> 传输数据 -> 关闭连接;
  • 传统 HTTP 是无状态的,浏览器和服务器每次 HTTP 操作都建立一次连接,任务结束就中断;
  • 简单说,就是 Socket 连接后发送并接收完数据马上断开连接。

1.3 长连接

  • 流程:连接 -> 传输数据 -> 保持连接 -> 传输数据 -> … -> 关闭连接;
  • 即建立 Socket 连接后,不管是否使用都保持连接。

1.4 短连接与长连接的使用场景

  • 短连接:早期 Web 网站的 HTTP 服务常用短连接,因为长连接对服务端耗费资源,而 Web 网站客户端众多且连接频繁,短连接更省资源。不过现在的 HTTP/1.1,尤其是 HTTP/2、HTTP/3 已开始向长连接演化;

  • 长连接:多用于操作频繁、点对点的通讯。因为 TCP 连接建立的三次握手耗时,若每次操作都重新连接,处理速度会降低。像数据库连接就用长连接,若用短连接频繁通信,会造成 Socket 错误,且频繁创建 Socket 也是资源浪费。

2 Java 原生网络编程

2.1 原生JDK网络编程-BIO

  • BIO(Blocking I/O)是阻塞式 I/O;

  • 在 BIO 中:

    • ServerSocket类负责绑定 IP 地址、监听端口,等待客户端连接;
    • 客户端Socket类发起连接,ServerSocket 接受连接后会生成新的服务端 Socket 实例,通过输入输出流与客户端 Socket 通信;

    在这里插入图片描述

  • BIO 的阻塞,主要体现在以下两个地方

    • 服务器启动就绪后,主线程会一直阻塞等待客户端连接;

    • 连接建立后,在读取 Socket 信息前,线程也会一直阻塞等待;

  • 传统 BIO 通信模型

    • 服务端由独立的 Acceptor 线程监听客户端连接,收到连接请求后为每个客户端创建新线程处理链路,处理完通过输出流回应客户端,然后线程销毁,是典型的一请求一应答模型,数据读写也需在一个线程内阻塞等待完成;

    • 该模型缺陷是缺乏弹性伸缩能力,客户端并发访问量增加时,服务端线程数与客户端并发数呈 1:1 正比,而 Java 线程是宝贵系统资源,线程数快速膨胀会使系统性能急剧下降,甚至崩溃;

    在这里插入图片描述

  • 改进的伪异步 I/O 模型

    • 为改进一连接一线程的模型,可使用线程池管理线程,实现 1 个或多个线程处理 N 个客户端(底层仍用同步阻塞 I/O),即“伪异步 I/O 模型”;

      • 使用 CachedThreadPool 线程池(不限制线程数量),能自动管理线程(复用),但仍类似 1:1 的客户端 - 线程数模型;

      • 使用 FixedThreadPool 可有效控制线程最大数量,保证系统资源可控,实现 N:M 的伪异步 I/O 模型;

    • 伪异步 I/O 模型的弊端是,因限制了线程数量,若出现读取数据慢(如数据量大、网络传输慢等)的情况,大量并发时,其他接入的消息只能一直等待;

    在这里插入图片描述

2.2 RPC 框架

2.2.1 为什么要有RPC

  • 最初阶段:开发时一个应用在一台机器上,所有功能写在一起,服务间调用是普通本地方法调用;

  • 性能优化阶段:随着业务的发展,需要提升系统性能,会把不同业务功能放到线程里实现异步,但本质还是本地方法调用,从“单WEB单线程”(订单、库存、短信服务依次调用)发展到“单WEB多线程”(库存、短信服务分别在不同线程执行);

    在这里插入图片描述

  • 分布式需求阶段:随着业务越来越复杂、业务量增大,单个应用或机器资源不足,会抽取核心业务作为独立服务放到其他服务器或形成集群,此时引入RPC,系统变为分布式架构;

  • 千万级流量分布式、微服务架构必备RPC框架,因为它对现有代码影响小,还能实现架构扩展,开源RPC框架有Dubbo、gRPC等。随着服务增多,RPC调用变复杂,会引入中间件(如MQ、缓存),架构向微服务迁移,引入容器技术(如Docker)、DevOps等,RPC在其中始终占据重要地位;

    在这里插入图片描述

2.2.2 什么是RPC

  • RPC(Remote Procedure Call,远程过程调用),是一种通过网络从远程计算机程序上请求服务,且无需了解底层网络的技术;

    在这里插入图片描述

  • 同步调用流程

    1. 服务消费方(Client)以本地调用方式调用客户端存根(Client Stub)
    2. 客户端存根是远程方法在本地的模拟对象,有方法名和参数,收到调用后负责将方法名、参数等包装,通过网络发送到服务端
    3. 服务端收到消息后,交给服务端存根(Server Stub),解码为实际的方法名和参数
    4. 服务端存根根据解码结果调用服务器上本地的实际服务
    5. 本地服务执行并将结果返回给服务端存根
    6. 服务端存根将返回结果打包成消息并发送至消费方
    7. 客户端存根接收到消息并进行解码
    8. 服务消费方得到最终结果
  • 目标:RPC框架要封装中间步骤,让远程方法调用像本地方法调用一样。

2.2.3 RPC和HTTP

  • RPC是不同应用间相互调用的描述和思想,实现方式多样,可直接用TCP通信,也可用HTTP方式,还能通过消息中间件实现
  • 例如Dubbo基于TCP通信,gRPC基于HTTP/2.0协议且底层用Netty框架支持
  • 总结:RPC和HTTP是不同层级的东西,不可直接比较

2.2.4 RPC框架的实现问题

  • 代理问题

    • 核心是解决调用远程服务时,调用者无需知晓服务是远程的,只需关注结果,具体远程调用相关操作由代理对象负责;

    • 代理是一种设计模式,通过代理对象访问目标对象,可在目标对象基础上增强额外功能,这里的额外功能就是通过网络访问远程服务;

    • JDK 提供了静态代理和动态代理两种代理实现方式;

  • 序列化问题

    • 在计算机中,方法调用包含的方法名、方法参数等(可能是字符串、自定义 Java 类等),在网络传输或存储到硬盘时,网络和硬盘只识别二进制的 01 串,所以需要序列化,将这些内容转换为二进制 01 串;而网络传输后要进行实际调用,又需要反序列化,把二进制 01 串变回实际的 Java 类;

    • Java 中提供了 Serializable 机制来支持序列化和反序列化;

  • 通信问题:序列化后得到了可在网络传输的二进制 01 串,具体的网络传输可使用 JDK 提供的 BIO(阻塞式 I/O)来实现;

  • 登记的服务实例化

    • 登记的服务可能只是系统中的一个名字,要将其变成实际执行的对象实例,需要使用反射机制;

    • 反射机制是指在运行状态下,对于任意一个类,能知道它的所有属性和方法;对于任意一个对象,能调用它的任意一个方法和属性,还能动态获取信息以及动态调用对象的方法;

    • 反射机制主要功能有:在运行时判断任意一个对象所属的类;在运行时构造任意一个类的对象;在运行时判断任意一个类所具有的成员变量和方法;在运行时调用任意一个对象的方法;生成动态代理。

2.3 原生JDK网络编程-NIO

2.3.1 简介

  • NIO(New IO/Non - blocking IO)库在 JDK 1.4 中引入,它弥补了原来 BIO(Blocking IO,阻塞式 IO)的不足,在标准 Java 代码中提供了高速的、面向块的 I/O,也被称为非阻塞 IO 或者新 IO;

  • 和 BIO 的主要区别

    • 面向流与面向缓冲

      • Java IO 是面向流的,每次从流中读取一个或多个字节,直到读取所有字节,且这些字节没有被缓存,也不能前后移动流中的数据,若要移动需先缓存到缓冲区;
      • Java NIO 是面向缓冲区的,数据读取到缓冲区后,可在缓冲区中前后移动,增加了处理灵活性,但需要检查缓冲区是否包含所有需要处理的数据,且要确保新读入数据不覆盖缓冲区中未处理的数据;
    • 阻塞与非阻塞IO

      • Java IO 的各种流是阻塞的,当线程调用 read()write() 方法时,线程会被阻塞,直到有数据被读取或数据完全写入,期间线程无法做其他事;
      • Java NIO 采用非阻塞模式,线程从通道发送读取数据请求时,只能获取当前可用数据,若没有可用数据,线程不会阻塞,可去做其他事;非阻塞写同理,线程请求写入数据到通道后,无需等待完全写入,可去做别的事。线程通常会利用非阻塞 IO 的空闲时间在其他通道执行 IO 操作,所以一个单独的线程可以管理多个输入和输出通道(channel)。

2.3.2 NIO 的 Reactor 模式

  • Reactor 译为“反应”,体现“倒置”“控制逆转”,具体事件处理程序不主动调用反应器,而是向反应器注册事件处理器,表明对某些事件感兴趣。当对应事件发生时,具体事件处理程序通过事件处理器做出反应,这一“不要调用我,让我来调用你”的控制逆转也叫“好莱坞法则”;

  • 以路人甲做男士 SPA 为例:

    • 路人甲(具体事件处理程序)向大堂经理(反应器)注册对“10000 技师上班”“10000 号房间空闲”等事件的兴趣;
    • 大堂经理掌握“实际事情登记本”(记录发生的事件,如“10000 号空闲了”“10002 号房间空闲了”等)和“客人需求小本本”(记录路人甲、乙、丙等对各类事件的兴趣);
    • 当“实际事情登记本”中路人甲感兴趣的事件发生时,大堂经理就会通知路人甲,路人甲做出反应(如占用 10000 技师、使用 10000 号房间);
    • 而且大堂经理可同时服务多个客人(路人乙、丙等),根据每个客人感兴趣的事件进行通知;

    在这里插入图片描述

  • 核心逻辑:反应器(大堂经理)负责监听各类事件,管理事件与对事件感兴趣的处理程序(客人)的对应关系,当事件发生时,主动通知对应的处理程序,处理程序再进行后续操作,实现了事件驱动的“控制逆转”式处理。

2.3.3 NIO 三大核心组件

  • Selector(选择器)

    • 也可称为“轮询代理器”、“事件订阅器”、“channel 容器管理机”;
    • 允许单个线程监视多个输入通道,应用程序向 Selector 注册关注的 Channel 及对应的 I/O 事件,Selector 会维护已注册 Channel 的容器,从而让单个线程能轻松管理多个通道。
  • Channel(通道)

    • 是应用程序与操作系统交互事件、传递内容的渠道,应用程序可通过通道读写数据,且能同时进行;
    • 所有被 Selector 注册的通道必须是 SelectableChannel 类的子类;
    • ServerSocketChannel:应用服务器程序的监听通道,通过它应用程序能向操作系统注册支持“多路复用 I/O”的端口监听,同时支持 UDP 和 TCP 协议;
    • SocketChannel:TCP Socket 套接字的监听通道,一个 Socket 套接字对应客户端 IP:端口服务器 IP:端口的通信连接;
    • 通道中的数据需先读到 Buffer,或从 Buffer 中写入;
  • Buffer(缓冲区)

    • 是 NIO 面向缓冲特性的体现,用于与 NIO 通道交互,数据从通道读入缓冲区,再从缓冲区写入通道(写操作时,应用程序先写数据到缓冲,再通过通道发送;读操作时,数据先从通道读到缓冲,应用程序再读缓冲数据);
    • 本质是一块可写入和读取数据的内存(数组),被包装成 NIO Buffer 对象,提供方法方便访问;

    在这里插入图片描述

2.3.4 实现代码与相关操作

  • Selector 实例化:通过调用静态工厂方法 Selector.open() 来实例化 Selector 对象;

    Selector selector = Selector.open();
    
  • 要让 Selector 管理 Channel,需Channel 注册到对应的 Selector

    • 首先要将 Channel 设为非阻塞模式

      • 否则会抛出 IllegalBlockingModeException 异常(FileChannel 因不能切换到非阻塞模式,无法与 Selector 一起使用,而套接字通道可以);
      • 另外通道一旦被注册,将不能再回到阻塞状态,此时若调用通道的configureBlocking(true)将抛出BlockingModeException异常
      serverChannel.configureBlocking(false);
      
    • 通过register()方法注册

      • register 方法的第二个参数interest 集合是比特掩码,表示 Selector 关心的通道操作(如对读、写操作感兴趣),若对多种操作类型感兴趣,可用“位或”操作符(如 SelectionKey.OP_READ | SelectionKey.OP_WRITE);
      • 一个 Channel 只能注册到一个 Selector 一次,多次注册相当于更新 SelectionKeyinterest set
      channel.register(selector, SelectionKey.OP_READ)
      
    • SelectionKey

      • SelectionKeyinterest集合的子集,并且表示了interest集合中从上次调用select()以后已经就绪的那些操作;

      • 通过 SelectionKeyreadyOps() 方法获取通道已就绪的操作;

      • 检查操作是否就绪,比如selectionKey.isAcceptable()

      • 通过SelectionKey也可以取出这个SelectionKey所关联的 SelectorChannel

      • 可通过 SelectionKey 判断 Selector 是否对 Channel 的某种事件感兴趣;

        int interestSet = selectionKey.interestOps();
        // 判断是否对接受连接事件感兴趣
        boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
        
    • 若要取消注册关系,可调用 SelectionKey 对象的 cancel() 方法;

    • 在实际应用中,也可为 SelectionKey 绑定附加对象,在需要的时候取出;

      // 绑定附加对象
      SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
      // 或
      selectionKey.attach(theObject);
      // 取出附加对象
      Object attachedObj = key.attachment();
      
  • Selectorselect() 方法

    • 用于选择已准备就绪的通道(包含感兴趣的事件),有多个重载方法:
      • select():阻塞到至少有一个通道在注册的事件上就绪
      • select(long timeout):最长阻塞 timeout 毫秒,其他同 select()
      • selectNow():非阻塞,立刻返回
    • select() 方法返回值表示自上次调用后有多少通道变成就绪状态。返回值不为 0 时,可通过 selector.selectedKeys() 获取已选择键集合;
    • 遍历已选择键集合时,需处理每个键对应的通道就绪事件,处理完后要调用 keyIterator.remove() 移除该键(Selector 不会自动移除,若不手动移除,下次通道就绪时会再次放入已选择键集合),然后通过 SelectionKey 关联的 SelectorChannel 进行业务处理,这样仅用一个线程就能处理多个客户端的连接。

2.3.5 SelectionKey

2.3.5.1 什么是SelectionKey
  • SelectionKey 是一个抽象类,用于表示 SelectableChannelSelector 中注册的标识。每个 ChannelSelector 注册时,都会创建一个 SelectionKey。它在 ChannelSelector 之间建立关系,并维护 Channel 相关的事件;
  • 可以通过 cancel 方法取消键,被取消的键不会立即从 Selector 中移除,而是添加到 cancelledKeys 中,在下一次 select 操作时移除。所以在调用某个 SelectionKey 时,需要使用 isValid 方法进行校验,确保其有效。
2.3.5.2 SelectionKey类型和就绪条件
  • Java NIO 定义了四种事件类型,定义在 SelectionKey 中,分别对应不同的网络 Socket 操作:
    • OP_READ:当操作系统读缓冲区有数据可读时就绪。由于并非时刻都有数据可读,所以一般仅在就绪时才发起读操作,避免浪费 CPU;

    • OP_WRITE:当操作系统写缓冲区有空闲空间时就绪。一般情况下写缓冲区都有空闲空间,小块数据可直接写入,没必要注册该操作类型,否则会因条件不断就绪而浪费 CPU;但对于写密集型任务(如文件下载),缓冲区很可能满,注册该操作类型就很有必要,同时要注意写完后取消注册;

    • OP_CONNECT:当 SocketChannel.connect() 请求连接成功后就绪,该操作只给客户端使用;

    • OP_ACCEPT:当接收到一个客户端连接请求时就绪,该操作只给服务器使用。

2.3.5.3 服务端和客户端分别感兴趣的类型
  • ServerSocketChannelSocketChannel 可注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时,操作系统会通知 Channel

    • 服务器 SocketChannel 指由服务器ServerSocketChannel.accept()返回的对象;
  • 不同 Channel 允许注册的操作类型不同(“Y”表示允许注册,“N”表示不允许注册):

    OP_READOP_WRITEOP_CONNECTOP_ACCEPT
    服务器ServerSocketChannelY
    服务器SocketChannelYY
    客户端SocketChannelYYY
    • 服务器ServerSocketChannel:仅允许注册 OP_ACCEPT 操作

    • 服务器SocketChannel:允许注册 OP_READOP_WRITE 操作

    • 客户端SocketChannel:允许注册 OP_READOP_WRITEOP_CONNECT 操作

  • 不同阶段的操作关注

    • 服务器启动 ServerSocketChannel,关注 OP_ACCEPT 事件,用于接收客户端连接请求

    • 客户端启动 SocketChannel,连接服务器,关注 OP_CONNECT 事件,用于判断连接是否成功

    • 服务器接受连接后,启动服务器的 SocketChannel,该 SocketChannel 可关注 OP_READOP_WRITE 事件,一般连接建立后会直接关注 OP_READ 事件,准备读取客户端数据

    • 客户端的 SocketChannel 发现连接建立后,可关注 OP_READOP_WRITE 事件,通常在需要发送数据时才关注 OP_READ 事件

    • 连接建立后,客户端与服务器端开始相互发送消息(读写),会根据实际情况关注 OP_READOP_WRITE 事件

2.3.6 Buffer 详解

2.3.6.1 重要属性
  • capacity
    • Buffer 作为内存块,有固定大小值即“capacity”,只能写入 capacity 个 byte、long、char 等类型数据;
    • Buffer 满后,需清空(读数据或清除数据)才能继续写;
  • position
    • 写数据时,position 表示当前可写位置,初始为 0,写入数据后 position 前移到下一个可插入位置,最大为 capacity - 1;
    • 读数据时,从特定位置读,Buffer 从写模式切换到读模式,position 重置为 0,读取数据时 position 前移到下一个可读位置;
  • limit
    • 写模式下,limit 表示最多能写入 Buffer 的数据量,等于 capacity;
    • 读模式下,limit 表示最多能读到的数据量,切换到读模式时,limit 被设置为写模式下的 position 值,即能读到之前写入的所有数据。
2.3.6.2 Buffer 的分配
  • 要获取 Buffer 对象需先分配,每个 Buffer 类都有 allocate 方法,可在堆或直接内存上分配;

    • 分配 48 字节 capacity 的 ByteBuffer

      ByteBuffer buf = ByteBuffer.allocate(48);
      
    • 分配可存储 1024 个字符的 CharBuffer

      CharBuffer buf = CharBuffer.allocate(1024);
      
  • wrap 方法可将 byte 数组或其一部分包装成 ByteBuffer,有以下两种形式:

    ByteBuffer wrap(byte[] array)
    ByteBuffer wrap(byte[] array, int offset, int length)
    
2.3.6.3 直接内存
  • HeapByteBuffer 与 DirectByteBuffer 对比
    • HeapByteBuffer 分配的 buffer 在堆(heap)区域,真正刷新到远程时会先拷贝到直接内存再操作;
    • DirectByteBuffer 分配的内存不在 Java 堆上,经性能测试,网络交互速度很快,大量网络交互时,速度比 HeapByteBuffer 快好几倍;
  • 直接内存的定义与使用
    • 直接内存(Direct Memory)不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范定义的内存区域,但其被频繁使用,也可能导致 OutOfMemoryError 异常;
    • NIO 可通过 Native 函数库直接分配堆外内存,再通过存储在 Java 堆里的 DirectByteBuffer 对象引用操作这块内存,能避免 Java 堆和 Native 堆之间的数据来回复制,在一些场景显著提升性能;
  • 直接内存(堆外内存)与堆内存比较
    • 直接内存申请空间耗费性能更高,频繁申请时更明显;
    • 直接内存 I/O 读写性能优于普通堆内存,多次读写操作时差异明显。
2.3.6.4 Buffer 的读写
向 Buffer 中写数据
  • 有以下两种方式;

  • Channel 写到 Buffer

    // 将 inChannel 中的数据读入 buf
    int bytesRead = inChannel.read(buf);
    
  • 通过 put 方法写 Buffer

    buf.put(127);
    
    • put 方法有多个版本,支持不同写入方式,比如写入到指定位置(如position)或写入一个字节数组等;
    • put() 属于相对写,向 position 位置写入一个 byte 并将 position + 1,为下次读写作准备。

    position是什么?见2.3.6.1 重要属性

flip() 方法
  • flip()Buffer 从写模式切换到读模式;
  • 调用flip()position 设回 0,limit 设置成之前 position 的值。此时 position 用于标记读的位置,limit 表示之前写入的数据量,即现在能读取的数据量。
从 Buffer 中读取数据
  • 有以下两种方式;

  • Buffer 读取数据到 Channel

    int bytesWritten = inChannel.write(buf);
    
  • 使用 get() 方法从 Buffer 中读取数据:

    byte aByte = buf.get();
    
    • get 方法有多个版本,支持不同读取方式,比如从指定 position 读取,或读取数据到字节数组等;
    • get() 属于相对读,从 position 位置读取一个 byte 并将 position + 1,为下次读写作准备。
使用 Buffer 读写数据常见步骤
  1. 写入数据到 Buffer
    • Buffer 会记录下写了多少数据;
  2. 调用 flip() 方法
    • 将 Buffer 从写模式写换到读模式;
  3. 从 Buffer 中读取数据
    • 读取之前写入到 Buffer 中的所有数据;
  4. 调用 clear() 方法或者 compact() 方法,准备下一次的写入
    • 读完数据后,需清空缓冲区以便再次写入;
    • clear() 方法会清空整个缓冲区(position 设回 0,limit 设为 capacity,数据未实际清除,只是标记可从起始位置写);
    • Buffer 中有未读数据且后续还需,用 compact() 方法,它会将未读数据拷贝到 Buffer 起始处,position 设到最后一个未读元素后,limit 设为 capacity,这样写数据时不会覆盖未读数据。
其他常用操作
  • 绝对读写
    • put(int index, byte b):绝对写,向 ByteBuffer 底层字节数组下标为 index 的位置插入 byte b,不改变 position 的值;
    • get(int index):绝对读,读取 ByteBuffer 底层字节数组下标为 indexbyte,不改变 position
  • rewind() 方法:将 position 设回 0,可重读 Buffer 中的所有数据,limit 保持不变,仍表示能从 Buffer 中读取的元素数量;
  • clear()compact() 方法
    • clear()position 设回 0,limit 设为 capacityBuffer 被“清空”(数据未清除,只是标记可从起始写),若有未读数据且后续无需,用此方法,否则数据会“被遗忘”;
    • compact():若 Buffer 中有未读数据且后续还需,又要先写数据,用此方法。它会将所有未读数据拷贝到 Buffer 起始处,position 设到最后一个未读元素正后面,limit 设为 capacity,保证写数据时不覆盖未读数据;
  • mark()reset() 方法
    • mark():标记 Buffer 中的一个特定 position
    • reset():恢复到之前用 mark() 标记的 position,例如 buffer.mark(); 后调用 buffer.get() 若干次,再调用 buffer.reset(); 可将 position 设回标记处;
  • equals()compareTo() 方法
    • equals():判断两个 Buffer 是否相等,需满足:有相同的类型(bytecharint 等);Buffer 中剩余的元素个数相等;Buffer 中所有剩余的元素都相同。注意,只比较剩余元素;
    • compareTo():比较两个 Buffer 的剩余元素,若第一个不相等的元素小于另一个 Buffer 中对应的元素,或所有元素都相等但第一个 Buffer 元素个数更少(先耗尽),则认为该 Buffer “小于”另一个 `Buffer;
Buffer 方法总结
方法说明
limit(), limit(10) 等其中读取和设置这4个属性的方法的命名和jQuery中的val(),val(10)类似,一个负责get,一个负责set
reset()把position设置成mark的值,相当于之前做过一个标记,现在要退回到之前标记的地方
clear()position = 0;limit = capacity;mark = -1; 有点初始化的味道,但是并不影响底层byte数组的内容
flip()limit = position;position = 0;mark = -1; 翻转,也就是让flip之后的position到limit这块区域变成之前的0到position这块,翻转就是将一个处于存数据状态的缓冲区变为一个处于准备取数据的状态
rewind()把position设为0,mark设为-1,不改变limit的值
remaining()return limit - position;返回limit和position之间相对位置差
hasRemaining()return position < limit返回是否还有未读内容
compact()把从 position 到 limit 中的内容移到 0 到 limit-position 的区域内,position和limit的取值也分别变成limit-position、capacity。如果先将 positon设置到limit,再compact,那么相当于clear()
get()相对读,从position位置读取一个byte,并将position+1,为下次读写作准备
get(int index)绝对读,读取byteBuffer底层的bytes中下标为index的byte,不改变position
get(byte[] dst, int offset, int length)从position位置开始相对读,读length个byte,并写入dst下标从offset到offset+length的区域
put(byte b)相对写,向position的位置写入一个byte,并将postion+1,为下次读写作准备
put(int index, byte b)绝对写,向byteBuffer底层的bytes中下标为index的位置插入byte b,不改变position
put(ByteBuffer src)用相对写,把src中可读的部分(也就是position到limit)写入此byteBuffer
put(byte[] src, int offset, int length)从src数组中的offset到offset+length区域读取数据并使用相对写写入此byteBuffer

2.3.7 Reactor模式类型

2.3.7.1 单线程 Reactor 模式
  • 服务器端的 Reactor 是一个线程对象,该线程启动事件循环,使用 Selector(选择器)实现 I/O 多路复用。向 Reactor 注册 Acceptor 事件处理器,其关注 ACCEPT 事件,使 Reactor 监听客户端的连接请求事件;

  • 客户端发起连接请求,Reactor 监听到 ACCEPT 事件后,派发给 Acceptor 处理器处理。Acceptor 处理器通过 accept() 方法得到与客户端对应的 SocketChannel,并将该连接关注的 READ 事件及对应的 READ 事件处理器注册到 Reactor 中,Reactor 进而监听该连接的 READ 事件;

  • 当 Reactor 监听到读或写事件发生时,将事件派发给对应处理器处理。例如读处理器通过 SocketChannelread() 方法读取数据,该操作可直接读取数据,不会阻塞等待;

  • 处理完所有就绪的感兴趣 I/O 事件后,Reactor 线程再次执行 select() 阻塞等待新事件就绪并分派给对应处理器;

  • 注意:单线程主要针对 I/O 操作,所有 I/O 的 accept()read()write() 以及 connect() 操作都在一个线程上完成。但目前单线程 Reactor 模式中,非 I/O 的业务操作也在该线程处理,可能延迟 I/O 请求响应,应将非 I/O 业务逻辑从 Reactor 线程卸载,以加速对 I/O 请求的响应;

    在这里插入图片描述

2.3.7.2 单线程 Reactor + 工作者线程池
  • 与单线程 Reactor 模式不同,添加了工作者线程池,将非 I/O 操作从 Reactor 线程移出,转交给工作者线程池执行,以提高 Reactor 线程的 I/O 响应,避免因耗时业务逻辑延迟后续 I/O 请求处理;

  • 使用线程池的优势:

    • 重用现有线程,分摊线程创建和销毁的开销,处理多个请求更高效;
    • 请求到达时,工作线程通常已存在,不会因等待创建线程延迟任务执行,提高响应性;
    • 适当调整线程池大小,可使处理器保持忙碌状态,同时防止过多线程竞争资源导致应用程序内存耗尽或失败;
  • 改进版本中,所有 I/O 操作(accept()read()write()connect())仍由一个 Reactor 完成。该模式适用于小容量应用场景,对高负载、大并发或大数据量场景不合适,原因:

    • 一个 NIO 线程同时处理成百上千链路,性能无法支撑,即便 CPU 负荷达 100%,也无法满足海量消息的读取和发送;
    • NIO 线程负载过重后,处理速度变慢,导致大量客户端连接超时,超时后重发会加重负载,最终导致大量消息积压和处理超时,成为系统性能瓶颈;

    在这里插入图片描述

2.3.7.3 多线程主从 Reactor 模式
  • Reactor 线程池中的每个 Reactor 线程都有自己的 Selector、线程和分发的事件循环逻辑。mainReactor 通常只有一个,subReactor 一般有多个。mainReactor 线程主要负责接收客户端连接请求,然后将接收到的 SocketChannel 传递给 subReactor,由 subReactor 完成与客户端的通信;

  • 流程:

    • mainReactor 注册 Acceptor 事件处理器,其关注 ACCEPT 事件,mainReactor 监听客户端连接请求事件,启动事件循环;
    • 客户端发起连接请求,mainReactor 监听到 ACCEPT 事件后,派发给 Acceptor 处理器处理。Acceptor 处理器通过 accept() 方法得到 SocketChannel,并传递给 subReactor 线程池;
    • subReactor 线程池分配一个 subReactor 线程给该 SocketChannel,将 SocketChannel 关注的 READ 事件及对应的 READ 事件处理器注册到 subReactor 线程中,也可注册 WRITE 事件及处理器以完成 I/O 写操作;
    • 当有 I/O 事件就绪时,相关 subReactor 将事件派发给响应处理器处理。subReactor 线程只负责完成 I/O 的 read() 操作,读取到数据后将业务逻辑处理放入线程池,若需返回数据给客户端,I/O 的 write 操作仍提交回 subReactor 线程完成;
  • 注意:所有 I/O 操作(accept()read()write()connect())仍在 Reactor 线程(mainReactorsubReactor 线程)中完成,线程池仅用于处理非 I/O 操作逻辑。该模式将“接受客户端连接请求”和“与客户端通信”分由两个 Reactor 线程完成,mainReactor 负责接收连接,subReactor 负责通信,避免因 read() 数据量大导致客户端连接请求得不到及时处理,在海量客户端并发请求场景下,可通过 subReactor 线程池将海量连接分发给多个 subReactor 线程,在多核操作系统中提升应用的负载和吞吐量;

    在这里插入图片描述

2.3.7.4 和观察者模式的区别
  • 观察者模式
    • 也叫发布 - 订阅模式,适用于多个对象依赖某一个对象的状态,当该对象状态改变时,通知其他依赖对象更新,是一对多关系(依赖对象为一个时是特殊的一对一关系);
    • 通常用于消息事件处理,监听器监听到事件时通知事件处理者处理,这一点易与 Reactor 模式的回调混淆;
  • Reactor 模式
    • 是高效的异步 I/O 模式,特征是回调,当 I/O 完成时,回调对应函数处理。该模式并非真正异步,而是运用异步思想,当 I/O 事件触发时,通知应用程序进行 I/O 处理,模式本身不调用系统的异步 I/O 函数;
    • Reactor 模式与观察者模式类似,但观察者模式与单个事件源关联,Reactor 模式与多个事件源关联,当一个主体改变时,所有依属体都得到通知。

3 直接内存

3.1 网络通信中的缓冲区

  • 在所有网络通信和应用程序里,每个 TCP 的 Socket 内核都有发送缓冲区(SO_SNDBUF)和接收缓冲区(SO_RECVBUF),可通过相关套接字选项更改其大小;

    在这里插入图片描述

  • 当应用进程调用 write 时,内核会从应用进程的缓冲区复制所有数据到所写套接字的发送缓冲区。若套接字发送缓冲区容不下应用进程的所有数据(比如应用进程缓冲区大于套接字发送缓冲区,或发送缓冲区已有其他数据),且套接字是阻塞的,应用进程会被投入睡眠。内核直到应用进程缓冲区中所有数据都复制到套接字发送缓冲区,才会从 write 系统调用返回。所以,写 TCP 套接字的 write 调用成功返回,仅表示可重新使用原来的应用进程缓冲区,不表明对端 TCP 或应用进程已接收到数据;

    在这里插入图片描述

3.2 Java 中的 I/O 内存机制

  • Java 程序遵循上述网络通信规则,但由于 Java 存在堆、垃圾回收等特性,在实际 I/O 中,JVM 内部有特殊机制:在 I/O 读写时,若使用堆内存,JDK 会先创建一个 DirectBuffer(直接缓冲区,属于堆外内存),再执行真正的写操作。这是因为通过 JNI 把地址传递给底层 C 库时,要求该地址上的内容不能失效,而 GC 管理下的堆内对象会移动,可能导致传递给底层 write 的地址对应的内存因 GC 整理而失效。所以必须把待发送数据放到 GC 管不着的堆外内存,这就是调用 native 方法前数据要在堆外内存的原因;
  • 从网络通信角度看,DirectBuffer 并非节省内存拷贝,而是 Java 网络通信中因 HeapBuffer(堆内缓冲区)必须多一次拷贝,使用 DirectBuffer 就少一次内存拷贝,所以使用直接内存的 Java 程序比使用堆内存的更快。从垃圾回收角度,直接内存不受新生代 Minor GC 影响,只有执行老年代 Full GC 时才会顺便回收,整理内存的压力也比数据放到 HeapBuffer 小。

3.3 堆外内存的优缺点

  • 优点
    • 减少垃圾回收工作,因为垃圾回收会暂停其他工作(可能因多线程或时间片机制不易察觉,但确实存在暂停);
    • 加快复制速度,堆内存在 flush 到远程时,会先复制到直接内存(堆外内存)再发送,而堆外内存省略了这一步;
  • 缺点
    • 难以控制,若发生内存泄漏,很难排查;
    • 不适合存储很复杂的对象,一般适合存储简单对象或扁平化的对象。

3.4 零拷贝

3.4.1 什么是零拷贝?

  • 零拷贝(Zero-copy)技术是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。该技术常用于通过网络传输文件时,节省 CPU 周期和内存带宽;
  • 优势
    • 减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,有效提高数据传输效率;
    • 减少用户进程地址空间和内核地址空间之间因为上下文切换而带来的开销。需要注意的是,零拷贝并非完全不需要拷贝,而是减少冗余(不必要)的拷贝;
    • Kafka、Netty、RocketMQ、Nginx、Apache 等组件、框架均使用了零拷贝技术。

3.4.2 Linux 的I/O机制与DMA

  • 在早期计算机中,用户进程读取磁盘数据需要 CPU 中断和参与,效率低,发起 I/O 请求时,每次 I/O 中断都会带来 CPU 的上下文切换。为解决此问题,出现了 DMA(Direct Memory Access,直接内存存取);

  • DMA 是现代电脑的重要特色,允许不同速度的硬件装置通信,无需依赖 CPU 的大量中断负载;

  • DMA 控制器接管数据读写请求,减少 CPU 负担,使 CPU 能高效工作,现代硬盘基本都支持 DMA;

  • 实际 I/O 读取涉及两个过程:

    • DMA 等待数据准备好,把磁盘数据读取到操作系统内核缓冲区;
    • 用户进程将内核缓冲区的数据拷贝到用户空间;

    具体过程见下节。

3.4.3 传统数据传送机制

  • 以读取文件再用 socket 发送为例,实际经过四次拷贝,伪代码:buffer = File.read(); Socket.send(buffer);

    • 第一次:将磁盘文件读取到操作系统内核缓冲区(由 DMA 拷贝完成)
    • 第二次:将内核缓冲区的数据拷贝到应用程序的 buffer(由 CPU 拷贝完成)
    • 第三次:将应用程序 buffer 中的数据拷贝到 socket 网络发送缓冲区(属于操作系统内核的缓冲区,由 CPU 拷贝完成)
    • 第四次:将 socket buffer 的数据拷贝到网卡,由网卡进行网络传输(由 DMA 拷贝完成)

    在这里插入图片描述

  • 问题

    • 虽然引入 DMA 接管了 CPU 的中断请求,但四次拷贝存在“不必要的拷贝”,实际上不需要第二次和第三次数据副本,应用程序除了缓存数据并将其传输回套接字缓冲区外什么都不做,数据可直接从读缓冲区传输到套接字缓冲区,第二次和第三次数据拷贝在这种场景下没有帮助反而带来开销,这也是零拷贝出现的背景和意义;
    • readsend 都属于系统调用,每次调用都牵涉到两次上下文切换(用户态到内核态,内核态到用户态)。传统的数据传送所消耗的成本为 4 次拷贝(两次 DMA 拷贝,两次 CPU 拷贝)和 4 次上下文切换;

    在这里插入图片描述

3.4.4 Linux 支持的(常见)零拷贝

  • 目的:减少 I/O 流程中不必要的拷贝,零拷贝需要操作系统(OS)支持,即需要内核暴露 API。
3.4.4.1 mmap 内存映射
  • 原理:将硬盘上文件的位置和应用程序缓冲区(application buffers)建立一一对应的映射关系。由于 mmap() 将文件直接映射到用户空间,实际文件读取时,根据映射关系直接将文件从硬盘拷贝到用户空间,只进行一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的缓冲区;

  • 拷贝与上下文切换情况:mmap 内存映射会经历 3 次拷贝(1 次 CPU 拷贝,2 次 DMA 拷贝);以及 4 次上下文切换,调用 mmap 函数 2 次,write 函数 2 次;

    在这里插入图片描述

3.4.4.2 sendfile
  • Linux 2.1 支持 sendfile

  • 原理:调用 sendfile() 时,DMA 将磁盘数据复制到 kernel buffer,然后将内核中的 kernel buffer 直接拷贝到 socket buffer,但数据并未真正复制到 socket 关联的缓冲区内,而是将记录数据位置和长度的描述符附加到 socket 缓冲区中。DMA 模块将数据直接从内核缓冲区传递给协议引擎,消除了最后一次复制(需要 DMA 硬件设备支持,若不支持,CPU 必须介入进行拷贝);

  • 完成标志:一旦数据全都拷贝到 socket buffersendfile() 系统调用会返回,代表数据转化完成,socket buffer 里的数据可进行网络传输;

  • 拷贝与上下文切换情况:sendfile 会经历 3(若硬件设备支持则为 2)次拷贝,1(若硬件设备支持则为 0)次 CPU 拷贝,2 次 DMA 拷贝;以及 2 次上下文切换;

    在这里插入图片描述

3.4.4.3 splice
  • Linux 从 2.6.17 支持 splice

  • 原理:

    • 数据从磁盘读取到 OS 内核缓冲区后,在内核缓冲区可直接转成内核空间其他数据 buffer,无需拷贝到用户空间。从磁盘读取到内核 buffer 后,在内核空间直接与 socket buffer 建立 pipe 管道;
    • sendfile() 不同,splice() 不需要硬件支持。sendfile 在 DMA 硬件设备不支持时,将磁盘数据加载到 kernel buffer 后需要一次 CPU 拷贝到 socket buffer,而 splice 更进一步,连这次 CPU 拷贝也不需要,直接将两个内核空间的 buffer 进行 pipe
  • 拷贝与上下文切换情况:splice 会经历 2 次拷贝(0 次 CPU 拷贝,2 次 DMA 拷贝);以及 2 次上下文切换;

    在这里插入图片描述

3.4.4.4 小结 Linux 中零拷贝
  • 最早的零拷贝定义源于 Linux 2.4 内核新增的 sendfile 系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核态 Buffer 后,直接通过 DMA 拷贝到 NIO Buffersocket buffer),无需 CPU 拷贝,这是真正操作系统意义上的零拷贝(狭义零拷贝)。
  • 随着发展,零拷贝的概念得到延伸,现在减少不必要的数据拷贝都算作零拷贝的范畴。

3.4.5 Java 生态圈中的零拷贝

  • Linux 提供的零拷贝技术 Java 并非全支持,仅支持内存映射(mmap)和 sendfile 两种。
3.4.5.1 NIO 提供的内存映射
  • NIO 中的 FileChannel.map() 方法采用操作系统的内存映射方式,底层调用 Linux 的 mmap() 实现;
  • 它将内核缓冲区内存和用户缓冲区内存做地址映射,适合读取大文件,也能更改文件内容,但之后若通过 SocketChannel 发送数据,仍需 CPU 进行数据拷贝。
3.4.5.2 NIO 提供的 sendfile
  • Java NIO 中 FileChanneltransferTotransferFrom 两个方法,可直接在 Channel 间拷贝数据,常用于高效的网络/文件数据传输和大文件拷贝;
  • 在操作系统支持时,该方法传输数据无需将源数据从内核态拷贝到用户态再拷贝到目标通道的内核态,还避免了两次用户态和内核态间的上下文切换,使用了“零拷贝”,性能一般高于 Java IO 提供的方法。
3.4.5.3 Kafka 中的零拷贝
  • Kafka 有两个重要过程使用操作系统层面的狭义零拷贝:
    • Producer 生产的数据持久化到 broker 时,broker 采用 mmap 文件映射,实现顺序快速写入;

    • Consumer 从 broker 读取数据时,broker 采用 sendfile,将磁盘文件读到 OS 内核缓冲区后,直接转到 socket buffer 进行网络发送。

3.4.5.4 Netty 的零拷贝实现
  • Netty 的零拷贝主要包含三个方面:
    • 网络通信:Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS(堆外直接内存)进行 Socket 读写,无需字节缓冲区的二次拷贝。若用传统堆内存(HEAP BUFFERS),JVM 会将堆内存 Buffer 拷贝一份到直接内存再写入 Socket,相比堆外直接内存,消息发送多一次缓冲区内存拷贝;

    • 缓存操作:Netty 提供 CompositeByteBuf 类,可将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免各 ByteBuf 之间的拷贝。通过 wrap 操作,可将 byte[] 数组、ByteBufByteBuffer 等包装成 Netty ByteBuf 对象,避免拷贝操作。ByteBuf 支持 slice 操作,可分解为多个共享同一存储区域的 ByteBuf,避免内存拷贝;

    • 文件传输:Netty 通过 FileRegion 包装的 FileChannel.transferTo 实现文件传输,可直接将文件缓冲区的数据发送到目标 Channel,避免传统循环 write 方式导致的内存拷贝问题。

http://www.dtcms.com/a/441703.html

相关文章:

  • Redisson分布式限流
  • 计算机网络-应用层协议原理
  • 分布式文件存储系统FastDFS(入门)
  • 电机控制-PMSM无感FOC控制(五)相电流检测及重构 — 单电阻采样
  • C语言底层学习(4.数据在内存中的存储)
  • 虚幻引擎UE5专用服务器游戏开发-33 在上半身播放组合蒙太奇
  • 织梦网站栏目访问目录做网站建设哪家效益快
  • 『数据结构』消失的数字
  • 鹤山网站建设易搜互联湖南seo
  • ORB_SLAM2原理及代码解析:Tracking::CreateInitialMapMonocular() 函数
  • 【Linux】System V —— 基于建造者模式的信号量
  • VScode-ESP-IDF工程函数定义无法跳转且无注释提示
  • 最新的网站建设软件标书制作员工作内容
  • JAVA SE 基础语法 —— C / 运算符
  • SSM餐饮管理系统uto0o《开发全资源(程序 / 源码 / 数据库)+ 万言论文(文末)+ 系统界面》
  • 上饶市建设厅网站中国最新消息新冠疫苗最新消息
  • 安徽省建设银行网站关于网站建设的意见
  • 免费域名建站青岛网站有限公司
  • 广东泰通建设有限公司网站东莞人才网58
  • 什么是wap网站甘家口网站建设
  • 网站接电话中国关键词官网
  • 广州车陂网站建设公司wordpress如何修改博客模板
  • 沈阳谷歌网站建设金湖建设局网站
  • 淳化网站制作我自己的网站怎么做关键词优化
  • 深圳网站建设工作在线文字logo设计
  • 网站开发 聊天窗口镇平微网站开发
  • 网站平台延展性广州品牌网站设计价格
  • 网站开发合作意向书网站域名使用费用
  • 音乐网站的音乐怎么做音乐试听wordpress文章分页共多少页
  • 怎么选择做网站的公司专业建设情况