分布式专题——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,远程过程调用),是一种通过网络从远程计算机程序上请求服务,且无需了解底层网络的技术;
-
同步调用流程:
- 服务消费方(Client)以本地调用方式调用客户端存根(Client Stub)
- 客户端存根是远程方法在本地的模拟对象,有方法名和参数,收到调用后负责将方法名、参数等包装,通过网络发送到服务端
- 服务端收到消息后,交给服务端存根(Server Stub),解码为实际的方法名和参数
- 服务端存根根据解码结果调用服务器上本地的实际服务
- 本地服务执行并将结果返回给服务端存根
- 服务端存根将返回结果打包成消息并发送至消费方
- 客户端存根接收到消息并进行解码
- 服务消费方得到最终结果
-
目标: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)。
- Java IO 的各种流是阻塞的,当线程调用
-
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
一次,多次注册相当于更新SelectionKey
的interest set
;
channel.register(selector, SelectionKey.OP_READ)
-
SelectionKey
:-
SelectionKey
是interest集合
的子集,并且表示了interest集合
中从上次调用select()
以后已经就绪的那些操作; -
通过
SelectionKey
的readyOps()
方法获取通道已就绪的操作; -
检查操作是否就绪,比如
selectionKey.isAcceptable()
; -
通过
SelectionKey
也可以取出这个SelectionKey
所关联的Selector
和Channel
; -
可通过
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();
-
-
Selector
的select()
方法:- 用于选择已准备就绪的通道(包含感兴趣的事件),有多个重载方法:
select()
:阻塞到至少有一个通道在注册的事件上就绪select(long timeout)
:最长阻塞timeout
毫秒,其他同select()
selectNow()
:非阻塞,立刻返回
select()
方法返回值表示自上次调用后有多少通道变成就绪状态。返回值不为 0 时,可通过selector.selectedKeys()
获取已选择键集合;- 遍历已选择键集合时,需处理每个键对应的通道就绪事件,处理完后要调用
keyIterator.remove()
移除该键(Selector
不会自动移除,若不手动移除,下次通道就绪时会再次放入已选择键集合),然后通过SelectionKey
关联的Selector
和Channel
进行业务处理,这样仅用一个线程就能处理多个客户端的连接。
- 用于选择已准备就绪的通道(包含感兴趣的事件),有多个重载方法:
2.3.5 SelectionKey
2.3.5.1 什么是SelectionKey
?
SelectionKey
是一个抽象类,用于表示SelectableChannel
在Selector
中注册的标识。每个Channel
向Selector
注册时,都会创建一个SelectionKey
。它在Channel
和Selector
之间建立关系,并维护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 服务端和客户端分别感兴趣的类型
-
ServerSocketChannel
和SocketChannel
可注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时,操作系统会通知Channel
;- 服务器
SocketChannel
指由服务器ServerSocketChannel.accept()
返回的对象;
- 服务器
-
不同
Channel
允许注册的操作类型不同(“Y”表示允许注册,“N”表示不允许注册):OP_READ OP_WRITE OP_CONNECT OP_ACCEPT 服务器ServerSocketChannel Y 服务器SocketChannel Y Y 客户端SocketChannel Y Y Y -
服务器
ServerSocketChannel
:仅允许注册OP_ACCEPT
操作 -
服务器
SocketChannel
:允许注册OP_READ
、OP_WRITE
操作 -
客户端
SocketChannel
:允许注册OP_READ
、OP_WRITE
、OP_CONNECT
操作
-
-
不同阶段的操作关注
-
服务器启动
ServerSocketChannel
,关注OP_ACCEPT
事件,用于接收客户端连接请求 -
客户端启动
SocketChannel
,连接服务器,关注OP_CONNECT
事件,用于判断连接是否成功 -
服务器接受连接后,启动服务器的
SocketChannel
,该SocketChannel
可关注OP_READ
、OP_WRITE
事件,一般连接建立后会直接关注OP_READ
事件,准备读取客户端数据 -
客户端的
SocketChannel
发现连接建立后,可关注OP_READ
、OP_WRITE
事件,通常在需要发送数据时才关注OP_READ
事件 -
连接建立后,客户端与服务器端开始相互发送消息(读写),会根据实际情况关注
OP_READ
、OP_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 堆之间的数据来回复制,在一些场景显著提升性能;
- 直接内存(Direct Memory)不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范定义的内存区域,但其被频繁使用,也可能导致
- 直接内存(堆外内存)与堆内存比较
- 直接内存申请空间耗费性能更高,频繁申请时更明显;
- 直接内存 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 读写数据常见步骤
- 写入数据到 Buffer
- Buffer 会记录下写了多少数据;
- 调用
flip()
方法- 将 Buffer 从写模式写换到读模式;
- 从 Buffer 中读取数据
- 读取之前写入到 Buffer 中的所有数据;
- 调用
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
底层字节数组下标为index
的byte
,不改变position
;
rewind()
方法:将position
设回 0,可重读Buffer
中的所有数据,limit
保持不变,仍表示能从Buffer
中读取的元素数量;clear()
与compact()
方法:clear()
:position
设回 0,limit
设为capacity
,Buffer
被“清空”(数据未清除,只是标记可从起始写),若有未读数据且后续无需,用此方法,否则数据会“被遗忘”;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
是否相等,需满足:有相同的类型(byte
、char
、int
等);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 监听到读或写事件发生时,将事件派发给对应处理器处理。例如读处理器通过
SocketChannel
的read()
方法读取数据,该操作可直接读取数据,不会阻塞等待; -
处理完所有就绪的感兴趣 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 线程(mainReactor
或subReactor
线程)中完成,线程池仅用于处理非 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 的中断请求,但四次拷贝存在“不必要的拷贝”,实际上不需要第二次和第三次数据副本,应用程序除了缓存数据并将其传输回套接字缓冲区外什么都不做,数据可直接从读缓冲区传输到套接字缓冲区,第二次和第三次数据拷贝在这种场景下没有帮助反而带来开销,这也是零拷贝出现的背景和意义;
read
和send
都属于系统调用,每次调用都牵涉到两次上下文切换(用户态到内核态,内核态到用户态)。传统的数据传送所消耗的成本为 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 buffer
,sendfile()
系统调用会返回,代表数据转化完成,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
;
- 数据从磁盘读取到 OS 内核缓冲区后,在内核缓冲区可直接转成内核空间其他数据
-
拷贝与上下文切换情况:
splice
会经历 2 次拷贝(0 次 CPU 拷贝,2 次 DMA 拷贝);以及 2 次上下文切换;
3.4.4.4 小结 Linux 中零拷贝
- 最早的零拷贝定义源于 Linux 2.4 内核新增的
sendfile
系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核态Buffer
后,直接通过 DMA 拷贝到 NIOBuffer
(socket 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 中
FileChannel
有transferTo
和transferFrom
两个方法,可直接在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[]
数组、ByteBuf
、ByteBuffer
等包装成 NettyByteBuf
对象,避免拷贝操作。ByteBuf
支持slice
操作,可分解为多个共享同一存储区域的ByteBuf
,避免内存拷贝; -
文件传输:Netty 通过
FileRegion
包装的FileChannel.transferTo
实现文件传输,可直接将文件缓冲区的数据发送到目标Channel
,避免传统循环write
方式导致的内存拷贝问题。
-