BIO实战、NIO编程与直接内存、零拷贝深入辨析-02

网络通信编程基本常识
什么是 Socket
Socket 是应用层与 TCP/IP 协议族通信的中间软件抽象层,它是一组接口,一般由操作 系统提供。在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议处理和 通信缓存管理等等都隐藏在 Socket 接口后面,对用户来说,使用一组简单的接口就能进行 网络应用编程,让Socket 去组织数据,以符合指定的协议。主机 A 的应用程序要能和主机 B 的 应用程序通信,必须通过 Socket 建立连接。 客户端连接上一个服务端,就会在客户端中产生一个 socket 接口实例,服务端每接受 一个客户端连接,就会产生一个 socket 接口实例和客户端的 socket 进行通信,有多个客户 端连接自然就有多个 socket 接口实例。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第1张图片

短连接
连接 -> 传输数据 -> 关闭连接
传统 HTTP 是无状态的,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,但任 务结束就中断连接。 也可以这样说:短连接是指 SOCKET 连接后发送后接收完数据后马上断开连接。
长连接
连接 -> 传输数据 -> 保持连接 -> 传输数据 -> 。。。 -> 关闭连接。
长连接指建立 SOCKET 连接后不管是否使用都保持连接。
什么时候用长连接,短连接?
长连接多用于操作频繁,点对点的通讯。每个 TCP 连接都需要三步握手,这需要时 间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都 不断开,下次处理时直接发送数据包就 OK 了,不用建立 TCP 连接。例如:数据库的连接用 长连接, 如果用短连接频繁的通信会造成 socket 错误,而且频繁的 socket 创建也是对资源 的浪费
而像 WEB 网站的 http 服务按照 Http 协议规范早期一般都用短链接,因为长连接对于 服务端来说会耗费一定的资源,而像 WEB 网站这么频繁的成千上万甚至上亿客户端的连接 用短连接会更省一些资源。但是现在的 Http 协议, Http1.1 ,尤其是 Http2 Http3 已经开始 向长连接演化。 总之,长连接和短连接的选择要视情况而定
Java 原生网络编程 -BIO
原生 JDK 网络编程 BIO
BIO ,意为 Blocking I/O ,即阻塞的 I/O
BIO 中类 ServerSocket 负责绑 定 IP 地址,启动监听端口,等待客户连接;客户端 Socket 类的实例发起连接操作, ServerSocket 接受连接后产生一个新的服务端 socket 实例负责和客户端 socket 实例通过输入和输出流进 行通信
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第2张图片

bio 的阻塞,主要体现在两个地方。
①若一个服务器启动就绪,那么主线程就一直在等待着客户端的连接,这个等待过程中 主线程就一直在阻塞。
②在连接建立之后,在读取到 socket 信息之前,线程也是一直在等待,一直处于阻塞 的状态下的。 启动该程序后,启 动一个 Client 程序实例,并让这个 Client 阻塞住,位置就在向服务器输出具体请求之前,再 启动一个新的Client 程序实例,会发现尽管新的 Client 实例连接上了服务器,但是 ServerSingle 服务端程序仿佛无感知一样?为何,因为执行的主线程被阻塞了一直在等待第一个 Client 实例发送消息过来。 所以在 BIO 通信里,我们往往会在服务器的实现上结合线程来处理连接以及和客户端的 通信

 

传统 BIO 通信模型:采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负 责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链 路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答模 型,同时数据的读取写入也必须阻塞在一个线程内等待其完成
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第3张图片

该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程 个数和客户端并发访问数呈 1:1 的正比关系, Java 中的线程也是比较宝贵的系统资源,线程 数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就 - - 。 为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现 1 个或 多个线程处理 N 个客户端的模型(但是底层还是使用的同步阻塞 I/O ),通常被称为 伪异 步 I/O 模型 。 我们知道,如果使用 CachedThreadPool 线程池(不限制线程数量,如果不清楚请参考 文首提供的文章),其实除了能自动帮我们管理线程(复用),看起来也就像是 1:1 的客户 端:线程数模型,而使用 FixedThreadPool 我们就有效的控制了线程的最大数量,保证了系 统有限的资源的控制,实现了 N:M 的伪异步 I/O 模型。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第4张图片
但是,正因为限制了线程数量,如果发生读取数据较慢时(比如数据量大、网络传 输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。

 

 

为什么要有 RPC
我们最开始开发的时候,一个应用一台机器,将所有功能都写在一起,比如说比较常见 的电商场景,服务之间的调用就是我们最熟悉的普通本地方法调用。 随着我们业务的发展,我们需要提示性能了,我们会怎么做?将不同的业务功能放到线 程里来实现异步和提升性能,但本质上还是本地方法调用。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第5张图片
但是业务越来越复杂,业务量越来越大,单个应用或者一台机器的资源是肯定背负不起 的,这个时候,我们会怎么做?将核心业务抽取出来,作为独立的服务,放到其他服务器上 或者形成集群。这个时候就会请出 RPC ,系统变为分布式的架构。 为什么说千万级流量分布式、微服务架构必备的 RPC 框架?和 LocalCall 的代码进行比 较,因为引入 rpc 框架对我们现有的代码影响最小,同时又可以帮我们实现架构上的扩展。 现在的开源 rpc 框架,有什么? dubbo grpc 等等 当服务越来越多,各种 rpc 之间的调用会越来越复杂,这个时候我们会引入中间件,比 如说 MQ 、缓存,同时架构上整体往微服务去迁移,引入了各种比如容器技术 docker DevOps 等等。最终会变为如图所示来应付千万级流量,但是不管怎样,rpc 总是会占有一席之地。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第6张图片

 

 

什么是 RPC
RPC Remote Procedure Call ——远程过程调用),它是一种通过网络从远程计算机程 序上请求服务,而不需要了解底层网络的技术。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第7张图片

 

一次完整的 RPC 同步调用流程:
1 )服务消费方( client )以本地调用方式调用客户端存根;
2 )什么叫客户端存根?就是远程方法在本地的模拟对象,一样的也有方法名,也有方 法参数,client stub 接收到调用后负责将方法名、方法的参数等包装,并将包装后的信息通 过网络发送到服务端;
3 )服务端收到消息后,交给代理存根在服务器的部分后进行解码为实际的方法名和参 数
4 server stub 根据解码结果调用服务器上本地的实际服务;
5 )本地服务执行并将结果返回给 server stub
6 server stub 将返回结果打包成消息并发送至消费方;
7 client stub 接收到消息,并进行解码;
8 )服务消费方得到最终结果。
RPC 框架的目标就是要中间步骤都封装起来,让我们进行远程方法调用的时候感觉到就 像在本地方法调用一样
RPC HTTP
rpc 字面意思就是远程过程调用,只是对不同应用间相互调用的一种描述,一种思想。 具体怎么调用?实现方式可以是最直接的 tcp 通信,也可以是 http 方式,在很多的消息中间 件的技术书籍里,甚至还有使用消息中间件来实现 RPC 调用的,我们知道的 dubbo 是基于 tcp 通信的, gRPC Google 公布的开源软件,基于最新的 HTTP2.0 协议,底层使用到了 Netty 框架的支持。所以总结来说,rpc http 是完全两个不同层级的东西,他们之间并没有什么 可比性
实现 RPC 框架
实现 RPC 框架需要解决的那些问题
代理问题
代理本质上是要解决什么问题?要解决的是被调用的服务本质上是远程的服务,但是调 用者不知道也不关心,调用者只要结果,具体的事情由代理的那个对象来负责这件事。既然 是远程代理,当然是要用代理模式了。 代理(Proxy) 是一种设计模式 , 即通过代理对象访问目标对象 . 这样做的好处是 : 可以在目 标对象实现的基础上, 增强额外的功能操作 , 即扩展目标对象的功能。那我们这里额外的功能 操作是干什么,通过网络访问远程服务。
jdk 的代理有两种实现方式:静态代理和动态代理。
序列化问题
序列化问题在计算机里具体是什么?我们的方法调用,有方法名,方法参数,这些可能 是字符串,可能是我们自己定义的 java 的类,但是在网络上传输或者保存在硬盘的时候, 网络或者硬盘并不认得什么字符串或者 javabean ,它只认得二进制的 01 串,怎么办?要进 行序列化,网络传输后要进行实际调用,就要把二进制的 01 串变回我们实际的 java 的类, 这个叫反序列化。java 里已经为我们提供了相关的机制 Serializable
通信问题
我们在用序列化把东西变成了可以在网络上传输的二进制的 01 串,但具体如何通过网 络传输?使用 JDK 为我们提供的 BIO
登记的服务实例化
登记的服务有可能在我们的系统中就是一个名字,怎么变成实际执行的对象实例,当然 是使用反射机制。反射机制是什么? 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对 于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用 对象的方法的功能称为 java 语言的反射机制。
反射机制能做什么
反射机制主要提供了以下功能:
•在运行时判断任意一个对象所属的类;
•在运行时构造任意一个类的对象;
•在运行时判断任意一个类所具有的成员变量和方法;
•在运行时调用任意一个对象的方法;
•生成动态代理
原生 JDK 网络编程 - NIO
什么是 NIO
NIO 库是在 JDK 1.4 中引入的。 NIO 弥补了原来的 BIO 的不足,它在标准 Java 代码 中提供了高速的、面向块的 I/O NIO 被称为 no-blocking io 或者 new io 都说得通。
BIO 的主要区别
面向流与面向缓冲
Java NIO IO 之间第一个最大的区别是, IO 是面向流的, NIO 是面向缓冲区的。 Java IO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地 方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它 缓存到一个缓冲区。 Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓 冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查 是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要 覆盖缓冲区里尚未处理的数据。 阻塞与非阻塞 IO Java IO 的各种流是阻塞的。这意味着,当一个线程调用 read() write() 时,该线程被 阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。 Java NIO 的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目 前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以 直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线 程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞 IO 的空闲时间用于在其它通道上执行 IO 操作,所以一个单独的线程现在 可以管理多个输入和输出通道(channel )。
NIO Reactor 模式
“反应”器名字中”反应“的由来:
“反应”即“倒置”,“控制逆转” , 具体事件处理程序不调用反应器,而向反应器注 册一个事件处理器,表示自己对某些事件感兴趣,有时间来了,具体事件处理程序通过事件 处理器对某个指定的事件发生做出反应;这种控制逆转又称为“好莱坞法则”(不要调用我, 让我来调用你) 例如,路人甲去做男士 SPA ,大堂经理负责服务,路人甲现在只对 10000 技师感兴趣, 但是路人甲去的比较早,就告诉大堂经理,等 10000 技师上班了或者是空闲了,通知我。等 路人甲接到大堂经理通知,做出了反应,把 10000 技师占住了。 然后,路人甲想起上一次的那个 10000 号房间不错,设备舒适,灯光暧昧,又告诉大堂 经理,我对 10000 号房间很感兴趣,房间空出来了就告诉我,我现在先和 10000 这个小姐 聊下人生,10000 号房间空出来了,路人甲再次接到大堂经理通知,路人甲再次做出了反应。 路人甲就是具体事件处理程序,大堂经理就是所谓的反应器,“10000 技师上班了”和 “10000 号房间空闲了”就是事件,路人甲只对这两个事件感兴趣,其他,比如 10001 号技 师或者 10002 号房间空闲了也是事件,但是路人甲不感兴趣。 大堂经理不仅仅服务路人甲这个人,他还可以同时服务路人乙、丙…….. ,每个人所感兴 趣的事件是不一样的,大堂经理会根据每个人感兴趣的事件通知对应的每个人
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第8张图片

 NIO 三大核心组件

NIO 有三大核心组件: Selector 选择器、 Channel 管道、 buffer 缓冲区。
Selector
Selector 的英文含义是 选择器 ,也可以称为为 轮询代理器 事件订阅器 “channel 容器管理机” 都行。
Java NIO 的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用 一个选择器(Selectors) ,然后使用一个单独的线程来操作这个选择器,进而“选择”通道: 这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个 单独的线程很容易来管理多个通道。 应用程序将向 Selector 对象注册需要它关注的 Channel ,以及具体的某一个 Channel 会 对哪些 IO 事件感兴趣。 Selector 中也会维护一个 已经注册的 Channel” 的容器。
Channels
通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操 作系统)。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数 据,也可以通过通道向操作系统写数据,而且可以同时进行读写。
• 所有被 Selector (选择器)注册的通道,只能是继承了 SelectableChannel 类的子类。
• ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才
能向操作系统注册支持 多路复用 IO” 的端口监听。同时支持 UDP 协议和 TCP 协议。
• ScoketChannel: TCP Socket 套接字的监听通道,一个 Socket 套接字对应了一个客户 端 IP :端口 到 服务器 IP :端口的通信连接。 通道中的数据总是要先读到一个 Buffer ,或者总是要从一个 Buffer 中写入。
buffer 缓冲区
我们前面说过 JDK NIO 是面向缓冲的。 Buffer 就是这个缓冲,用于和 NIO 通道进行交互。 数据是从通道读入缓冲区,从缓冲区写入到通道中的。以写为例,应用程序都是将数据写入 缓冲,再通过通道把缓冲的数据发送出去,读也是一样,数据总是先从通道读到缓冲,应用 程序再读缓冲的数据。 缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。 这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。 后面的附录详细讲到其中的 api 等相关内容
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第9张图片

实现代码

BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第10张图片 

1、
Selector 对象是通过调用静态工厂方法 open()来实例化的,如下:
Selector Selector=Selector.open();
2、 要实现 Selector 管理 Channel,需要将 channel 注册到相应的 Selector 上,如下:
channel.configureBlocking(false);
SelectionKey key= channel.register(selector,SelectionKey,OP_READ);
通过调用通道的 register()方法会将它注册到一个选择器上。与 Selector 一起使用时, Channel 必须处于非阻塞模式下,否则将抛出 IllegalBlockingModeException 异常,这意 味着不能将 FileChannel 与 Selector 一起使用,因为 FileChannel 不能切换到非阻塞模式, 而套接字通道都可以。另外通道一旦被注册,将不能再回到阻塞状态,此时若调用通道的 configureBlocking(true)将抛出 BlockingModeException 异常。 register()方法的第二个参数是“interest 集合”,表示选择器所关心的通道操作, 它实际上是一个表示选择器在检查通道就绪状态时需要关心的操作的比特掩码。比如一个选择器对通道的 read 和 write 操作感兴趣,那么选择器在检查该通道时,只会检通道的 read 和 write 操作是否已经处在就绪状态。 具体的操作类型和通道上能被支持的操作类型前面已经讲述过。 如果 Selector 对通道的多操作类型感兴趣,可以用“位或”操作符来实现: int interestSet=SelectionKey.OP_READ|SelectionKey.OP_WRITE; 同时 一个 Channel 仅仅可以被册到一个 Selector 一次, 如果将 Channel 注册 到 Selector 多次, 那么其实就是相当于更新 SelectionKey 的 interest set。 通过 SelectionKey 可以判断 Selector 是否对 Channel 的某种事件感兴趣,比如 int interestSet = selectionKey.interestOps(); boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
通过 SelctionKey 对象的 readyOps()来获取相关通道已经就绪的操作。它是 interest 集合的子集,并且表示了 interest 集合中从上次调用 select()以后已经就绪的那些操作。 JAVA 中定义几个方法用来检查这些操作是否就绪,比如 selectionKey.isAcceptable(); 同时,通过 SelectionKey 可以取出这个 SelectionKey 所关联的 Selector 和 Channel。 如果我们要取消关联关系,怎么办?SelectionKey 对象的 cancel()方法来取消特定的 注册关系。 在实际的应用中,我们还可以为 SelectionKey 绑定附加对象,在需要的时候取出。 SelectionKey  key=channel.register(selector,SelectionKey.OP_READ,theObject); 或 selectionKey.attach(theObject); 取出这个附加对象,通过: Object attachedObj = key.attachment();
3、 在实际运行中,我们通过 Selector 的 select()方法可以选择已经准备就绪的通道(这 些通道包含你感兴趣的的事件)。
下面是 Selector 几个重载的 select()方法:
select():阻塞到至少有一个通道在你注册的事件上就绪了。
select(long timeout):和 select()一样,但最长阻塞事件为 timeout 毫秒。
selectNow():非阻塞,立刻返回。
select()方法返回的 int 值表示有多少通道已经就绪,是自上次调用 select()方法后有 多少通道变成就绪状态。 一旦调用 select()方法,并且返回值不为 0 时,则可以通过调用 Selector 的
selectedKeys()方法来访问已选择键集合。
Set selectedKeys=selector.selectedKeys();
这个时候,循环遍历 selectedKeys 集中的每个键,并检测各个键所对应的通道的就绪 事件,再通过 SelectionKey 关联的 Selector 和 Channel 进行实际的业务处理。
注意每次迭代末尾的 keyIterator.remove()调用。Selector 不会自己从已选择键集中 移除 SelectionKey 实例。必须在处理完通道时自己移除,否则的话,下次该通道变成就绪 时,Selector 会再次将其放入已选择键集中。

 重要概念 SelectionKey

什么是 SelectionKey
SelectionKey 是一个抽象类 , 表示 selectableChannel Selector 中注册的标识 . 每个 Channel 向 Selector 注册时 , 都将会创建一个 SelectionKey SelectionKey Channel Selector 建立了 关系,并维护了 channel 事件。 可以通过 cancel 方法取消键 , 取消的键不会立即从 selector 中移除 , 而是添加到 cancelledKeys 中 , 在下一次 select 操作时移除它 . 所以在调用某个 key , 需要使用 isValid 进行 校验.
SelectionKey 类型和就绪条件
在向 Selector 对象注册感兴趣的事件时,
JAVA NIO 共定义了四种: OP_READ OP_WRITE 、 OP_CONNECT、 OP_ACCEPT (定义在 SelectionKey 中),分别对应读、写、请求连接、接受 连接等网络 Socket 操作。
OP_READ
当操作系统读缓冲区有数据可读时就绪。并非时刻都有数据可读,所以一 般需要注册该操作,仅当有就绪时才发起读操作,有的放矢,避免浪费 CPU
OP_WRITE
当操作系统写缓冲区有空闲空间时就绪。一般情况下写缓冲区都有空闲空 间,小块数据直接写入即可,没必要注册该操作类型,否则该条件不断就绪浪 费 CPU ;但如果是写密集型的任务,比如文件下载等,缓冲区很可能满,注册 该操作类型就很有必要,同时注意写完后取消注册。
OP_CONNECT
SocketChannel.connect() 请求连接成功后就绪。该操作只给客户端使用。
OP_ACCEPT
当接收到一个客户端连接请求时就绪。该操作只给服务器使用。
服务端和客户端分别感兴趣的类型
ServerSocketChannel SocketChannel 可以注册自己感兴趣的操作类型,当对应操作类 型的就绪条件满足时 OS 会通知 channel ,下表描述各种 Channel 允许注册的操作类型, Y 表 示允许注册,N 表示不允许注册,其中服务器 SocketChannel 指由服务器 ServerSocketChannel.accept()返回的对象。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第11张图片

 

服务器启动 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 事件
附录 - Buffer 详解
重要属性
capacity
作为一个内存块, Buffer 有一个固定的大小值,也叫 “capacity”. 你只能往里写 capacity 个 byte long char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据) 才能继续写数据往里写数据。
position
当你写数据到 Buffer 中时, position 表示当前能写的位置。初始的 position 值为 0. 当一 个 byte long 等数据写到 Buffer 后, position 会向前移动到下一个可插入数据的 Buffer 单 元。position 最大可为 capacity – 1. 当读取数据时,也是从某个特定位置读。当将 Buffer 从写模式切换到读模式, position 会被重置为 0. 当从 Buffer position 处读取数据时, position 向前移动到下一个可读的位置。
limit
在写模式下, Buffer limit 表示你最多能往 Buffer 里写多少数据。 写模式下, limit 等 于 Buffer capacity 。 当切换 Buffer 到读模式时, limit 表示你最多能读到多少数据。因此,当切换 Buffer 到 读模式时,limit 会被设置成写模式下的 position 值。换句话说,你能读到之前写入的所有数 据(limit 被设置成已写数据的数量,这个值在写模式下就是 position
Buffer 的分配
要想获得一个 Buffer 对象首先要进行分配。 每一个 Buffer 类都有 allocate 方法 ( 可以在
堆上分配,也可以在直接内存上分配 ) 。 分配 48 字节 capacity ByteBuffer 的例子 :ByteBuffer buf = ByteBuffer.allocate(48); 分配一个可存储 1024 个字符的 CharBuffer CharBuffer buf = CharBuffer.allocate(1024);
wrap 方法 :把一个 byte 数组或 byte 数组的一部分包装成 ByteBuffer
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length)
直接内存
HeapByteBuffer DirectByteBuffer ,在原理上,前者可以看出分配的 buffer 是在 heap 区域的,其实真正 flush 到远程的时候会先拷贝到直接内存,再做下一步操作;在 NIO 的框 架下,很多框架会采用 DirectByteBuffer 来操作,这样分配的内存不再是在 java heap 上,经 过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比 HeapByteBuffer 要快速好几倍。
直接内存( Direct Memory )并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机 规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致
OutOfMemoryError 异常出现。
NIO 可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能, 因为避免了在 Java 堆和 Native 堆中来回复制数据。
直接内存(堆外内存)与堆内存比较
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存 IO 读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
Buffer 的读写
Buffer 中写数据
写数据到 Buffer 有两种方式:
读取 Channel 写到 Buffer
通过 Buffer put() 方法写到 Buffer 里。
Channel 写到 Buffer 的例子
int bytesRead = inChannel.read(buf); //read into buffer.
通过 put 方法写 Buffer 的例子:
buf.put(127);
put 方法有很多版本,允许你以不同的方式把数据写入到 Buffer 中。例如, 写到一个 指定的位置,或者把一个字节数组写入到 Buffer 。在比如:
put(byte b)
相对写,向 position 的位置写入一个 byte ,并将 postion+1 ,为下次读写 作准备。
flip() 方法
flip 方法将 Buffer 从写模式切换到读模式。调用 flip() 方法会将 position 设回 0 ,并将 limit 设置成之前 position 的值。 换句话说, position 现在用于标记读的位置, limit 表示之前写进了多少个 byte char 等 —— 现在能读取多少个 byte char 等。
Buffer 中读取数据
Buffer 中读取数据有两种方式:
1. Buffer 读取数据写入到 Channel
2. 使用 get() 方法从 Buffer 中读取数据。
Buffer 读取数据到 Channel 的例子:
int bytesWritten = inChannel.write(buf);
使用 get() 方法从 Buffer 中读取数据的例子
byte aByte = buf.get();
get 方法有很多版本,允许你以不同的方式从 Buffer 中读取数据。例如,从指定 position 读取,或者从 Buffer 中读取数据到字节数组,再比如 get()属于相对读,从 position 位置读取一个 byte ,并将 position+1 ,为下次读写作准备 ;
使用 Buffer 读写数据常见步骤
1. 写入数据到 Buffer
2. 调用 flip() 方法
3. Buffer 中读取数据
4. 调用 clear() 方法或者 compact() 方法,准备下一次的写入
当向 buffer 写入数据时, buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 buffer 的所有数据。 一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空 缓冲区:调用 clear() compact() 方法。 clear() 方法会清空整个缓冲区。 compact() 方法只会清 除已经读过的数据。
其他常用操作
绝对读写
put(int index, byte b)
绝对写,向 byteBuffer 底层的 bytes 中下标为 index 的位置插入
byte b ,不改变 position 的值。
get(int index) 属于绝对读,读取 byteBuffer 底层的 bytes 中下标为 index byte ,不改 变 position 。 更多 Buffer 实现的细节参考 JavaDoc
rewind() 方法
Buffer.rewind() position 设回 0 ,所以你可以重读 Buffer 中的所有数据。 limit 保持不变, 仍然表示能从 Buffer 中读取多少个元素( byte char 等)。
clear() compact() 方法
一旦读完 Buffer 中的数据,需要让 Buffer 准备好再次被写入。可以通过 clear() compact() 方法来完成。 如果调用的是 clear() 方法, position 将被设回 0 limit 被设置成 capacity 的值。换句话 说,Buffer 被清空了。 Buffer 中的数据并未清除,只是这些标记告诉我们可以从哪里开始往 Buffer 里写数据。 如果 Buffer 中有一些未读的数据,调用 clear() 方法,数据将 被遗忘 ,意味着不再有任 何标记会告诉你哪些数据被读过,哪些还没有。 如果 Buffer 中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据, 那么使用 compact() 方法。
compact() 方法将所有未读的数据拷贝到 Buffer 起始处。然后将 position 设到最后一个未 读元素正后面。limit 属性依然像 clear() 方法一样,设置成 capacity 。现在 Buffer 准备好写数 据了,但是不会覆盖未读的数据。
mark() reset() 方法
通过调用 Buffer.mark() 方法,可以标记 Buffer 中的一个特定 position 。之后可以通过调 用 Buffer.reset() 方法恢复到这个 position 。例如:
buffer.mark();//call buffer.get() a couple of times, e.g. during parsing.
buffer.reset(); //set position back to mark.
equals() compareTo() 方法
可以使用 equals() compareTo() 方法两个 Buffer
equals()
当满足下列条件时,表示两个 Buffer 相等:
1. 有相同的类型( byte char int 等)。
2. Buffer 中剩余的 byte char 等的个数相等。
3. Buffer 中所有剩余的 byte char 等都相同。
如你所见, equals 只是比较 Buffer 的一部分,不是每一个在它里面的元素都比较。实际 上,它只比较 Buffer 中的剩余元素。
compareTo() 方法
compareTo() 方法比较两个 Buffer 的剩余元素 (byte char ) , 如果满足下列条件,则
认为一个 Buffer“ 小于 另一个 Buffer
1. 第一个不相等的元素小于另一个 Buffer 中对应的元素 。
2. 所有元素都相等,但第一个 Buffer 比另一个先耗尽 ( 第一个 Buffer 的元素个数比另一
个少 )
Buffer 方法总结
limit(), limit(1 0)等
其中读取和设置这 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 和 li mit 的取值也分别变成 limit-position、capacity。如果先将 positon 设置到 limit,再 c ompact,那么相当于 clear()
get()
相对读,从 position 位置读取一个 byte,并将 position+1,为下次读写作准备
get(int index)
绝对读,读取 byteBuffer 底层的 bytes 中下标为 index 的 byte,不改变 position
get(byte[] dst,
int offset, int len
gth)
从 position 位置开始相对读,读 length 个 byte,并写入 dst 下标从 offset 到 offs et+length 的区域
put(byte b)
相对写,向 position 的位置写入一个 byte,并postion+1,为下次读写作准备
put(int index,
byte b)
绝对写,向 byteBuffer 底层的 bytes 中下标为 index 的位置插入 byte b,不改变 p osition
put(ByteBuffer
src)
用相对写,把 src 中可读的部分(也就是 position 到 limit)写入此 byteBuffer
put(byte[] src,
int offset, int len
gth)
从 src 数组中的 offset 到 offset+length 区域读取数据并使用相对写写入此 byteBu ffer
Reactor 模式类型
单线程 Reactor 模式流程:
服务器端的 Reactor 是一个线程对象,该线程会启动事件循环,并使用 Selector( 选 择器) 来实现 IO 的多路复用。注册一个 Acceptor 事件处理器到 Reactor 中, Acceptor 事件处 理器所关注的事件是 ACCEPT 事件,这样 Reactor 会监听客户端向服务器端发起的连接请求 事件(ACCEPT 事件 )
② 客户端向服务器端发起一个连接请求, Reactor 监听到了该 ACCEPT 事件的发生并将 该 ACCEPT 事件派发给相应的 Acceptor 处理器来进行处理。 Acceptor 处理器通过 accept() 法得与这个客户端对应的连接 (SocketChannel) ,然后将该连接所关注的 READ 事件以及对 应的READ 事件处理器注册到 Reactor 中,这样一来 Reactor 就会监听该连接的 READ 事件了。
③ 当 Reactor 监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行 处理。比如,读处理器会通过 SocketChannel read() 方法读取数据,此时 read() 操作可以直 接读取到数据,而不会堵塞与等待可读的数据到来。
④ 每当处理完所有就绪的感兴趣的 I/O 事件后, Reactor 线程会再次执行 select() 阻塞等 待新的事件就绪并将其分派给对应处理器进行处理。 注意,Reactor 的单线程模式的单线程主要是针对于 I/O 操作而言,也就是所有的 I/O 的 accept()、 read() write() 以及 connect() 操作都在一个线程上完成的。 但在目前的单线程 Reactor 模式中,不仅 I/O 操作在该 Reactor 线程上,连非 I/O 的业务 操作也在该线程上进行处理了,这可能会大大延迟 I/O 请求的响应。所以我们应该将非 I/O 的业务逻辑操作从 Reactor 线程上卸载,以此来加速 Reactor 线程对 I//O 请求的响应。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第12张图片

 

单线程 Reactor ,工作者线程池
与单线程 Reactor 模式不同的是,添加了一个工作者线程池,并将非 I/O 操作从 Reactor 线程中移出转交给工作者线程池来执行。这样能够提高 Reactor 线程的 I/O 响应,不至于因 为一些耗时的业务逻辑而延迟对后面 I/O 请求的处理。
使用线程池的优势:
① 通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和 销毁过程产生的巨大开销。
② 另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待 创建线程而延迟任务的执行,从而提高了响应性。
③ 通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态。 同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。 改进的版本中,所以的 I/O 操作依旧由一个 Reactor 来完成,包括 I/O accept() read() 、 write()以及 connect() 操作。
对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发或大数据量 的应用场景却不合适,主要原因如下:
① 一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,即便 NIO 线程的 CPU 负 荷达到 100% ,也无法满足海量消息的读取和发送; ② 当 NIO 线程负载过重之后,处理速度将变慢,会导致大量客户端连接超时,超时 之后往往会进行重发,这更加重了 NIO 线程的负载,最终会导致大量消息积压和处理超时, 成为系统的性能瓶颈;
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第13张图片
多线程主从 Reactor 模式
Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector 、线程和分发的事件循环逻 辑。
mainReactor 可以只有一个,但 subReactor 一般会有多个。 mainReactor 线程主要负责接 收客户端的连接请求,然后将接收到的 SocketChannel 传递给 subReactor ,由 subReactor 来 完成和客户端的通信。
流程:
① 注册一个 Acceptor 事件处理器到 mainReactor 中, Acceptor 事件处理器所关注的事 件是 ACCEPT 事件,这样 mainReactor 会监听客户端向服务器端发起的连接请求事件 (ACCEPT 事件) 。启动 mainReactor 的事件循环。
② 客户端向服务器端发起一个连接请求, mainReactor 监听到了该 ACCEPT 事件并将该ACCEPT 事件派发给 Acceptor 处理器来进行处理。 Acceptor 处理器通过 accept() 方法得到与这 个客户端对应的连接(SocketChannel) ,然后将这个 SocketChannel 传递给 subReactor 线程池。
subReactor 线程池分配一个 subReactor 线程给这个 SocketChannel ,即,将 SocketChannel 关注的 READ 事件以及对应的 READ 事件处理器注册到 subReactor 线程中。当 然你也注册 WRITE 事件以及 WRITE 事件处理器到 subReactor 线程中以完成 I/O 写操作。 Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector 、线程和分发的循环逻辑。
④ 当有 I/O 事件就绪时,相关的 subReactor 就将事件派发给响应的处理器处理。注意, 这里 subReactor 线程只负责完成 I/O read() 操作,在读取到数据后将业务逻辑的处理放入 到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的 I/O write 操作还 是会被提交回 subReactor 线程来完成。 注意,所以的 I/O 操作 ( 包括, I/O accept() read() write() 以及 connect() 操作 ) 依旧还 是在 Reactor 线程 (mainReactor 线程 或 subReactor 线程 ) 中完成的。 Thread Pool( 线程池 ) 仅用 来处理非 I/O 操作的逻辑。 多 Reactor 线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个 Reactor 线程来完成。 mainReactor 完成接收客户端连接请求的操作,它不负责与客户端的通 信,而是将建立好的连接转交给 subReactor 线程来完成与客户端的通信,这样一来就不会 因为 read() 数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多 Reactor 线程模式在海量的客户端并发请求的情况下,还可以通过实现 subReactor 线程池来将海量 的连接分发给多个 subReactor 线程,在多核的操作系统中这能大大提升应用的负载和吞吐 量。

 BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第14张图片

 

和观察者模式的区别
观察者模式:
也可以称为为 发布 - 订阅 模式,主要适用于多个对象依赖某一个对象的状态并,当某 对象状态发生改变时,要通知其他依赖对象做出更新。是一种一对多的关系。当然,如果依 赖的对象只有一个时,也是一种特殊的一对一关系。通常,观察者模式适用于消息事件处理, 监听者监听到事件时通知事件处理者对事件进行处理(这一点上面有点像是回调,容易与反 应器模式和前摄器模式的回调搞混淆)。
Reactor 模式:
reactor 模式,即反应器模式,是一种高效的异步 IO 模式,特征是回调,当 IO 完成时, 回调对应的函数进行处理。这种模式并非是真正的异步,而是运用了异步的思想,当 IO 事 件触发时,通知应用程序作出 IO 处理。模式本身并不调用系统的异步 IO 函数。 reactor 模式与观察者模式有点像。不过,观察者模式与单个事件源关联,而反应器模 式则与多个事件源关联 。当一个主体发生改变时,所有依属体都得到通知。
3 、直接内存深入辨析
在所有的网络通信和应用程序中,每个 TCP Socket 的内核中都有一个发送缓冲区 (SO_SNDBUF)和一个接收缓冲区 (SO_RECVBUF) ,可以使用相关套接字选项来更改该缓冲区大 小
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第15张图片

 

当某个应用进程调用 write 时,内核从该应用进程的缓冲区中复制所有数据到所写套接 字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据( 或是应用进程 的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据) ,假设该套 接字是阻塞的,则该应用进程将被投入睡眠。 内核将不从 write 系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发 送缓冲区。因此,从写一个 TCP 套接字的 write 调用成功返回仅仅表示我们可以重新使用原 来的应用进程缓冲区,并不表明对端的 TCP 或应用进程已接收到数据。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第16张图片

Java 程序自然也要遵守上述的规则。但在 Java 中存在着堆、垃圾回收等特性,所以在 实际的 IO 中,在 JVM 内部的存在着这样一种机制: 在 IO 读写上,如果是使用堆内存, JDK 会先创建一个 DirectBuffer ,再去执行真正的写 操作。这是因为,当我们把一个地址通过 JNI 传递给底层的 C 库的时候,有一个基本的要求, 就是这个地址上的内容不能失效。然而,在 GC 管理下的对象是会在 Java 堆中移动的。也就 是说,有可能我把一个地址传给底层的 write ,但是这段内存却因为 GC 整理内存而失效了。 所以必须要把待发送的数据放到一个 GC 管不着的地方。这就是调用 native 方法之前,数据 —定要在堆外内存的原因。可见,站在网络通信的角度 DirectBuffer 并没有节省什么内存拷贝,只是 Java 网络通信 里因为 HeapBuffer 必须多做一次拷贝,使用 DirectBuffer 就少一次内存拷贝。相比没有使 用堆内存的 Java 程序,使用直接内存的 Java 程序当然更快一点。 从垃圾回收的角度而言,直接内存不受 GC( 新生代的 Minor GC) 影响,只有当执行老 年代的 Full GC 时候才会顺便回收直接内存,整理内存的压力也比数据放到 HeapBuffer 要小。
堆外内存的优点和缺点
堆外内存相比于堆内内存有几个优势:
1 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间 片的方式,根本感觉不到)
2 加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存), 然后在发送;而堆外内存相当于省略掉了这个工作。 而福之祸所依,自然也有不好的一面:
1 堆外内存难以控制,如果内存泄漏,那么很难排查
2 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适 合。
零拷贝
什么是零拷贝 ?
零拷贝 ( 英语 : Zero-copy) 技术是指计算机执行操作时, CPU 不需要先将数据从某处内存 复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。
零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不 必要的中间拷贝次数,从而有效地提高数据传输效率
零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上 : 下文切换而带来的 开销 可以看出没有说不需要拷贝,只是说减少冗余[ 不必要 ] 的拷贝。
下面这些组件、框架中均使用了零拷贝技术: Kafka Netty Rocketmq Nginx Apache

 Linux I/O 机制与 DMA

在早期计算机中,用户进程需要读取磁盘数据,需要 CPU 中断和 CPU 参与,因此效率 比较低,发起 IO 请求,每次的 IO 中断,都带来 CPU 的上下文切换。因此出现了—— DMA
DMA(Direct Memory Access ,直接内存存取 ) 是所有现代电脑的重要特色,它允许不同 速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。
DMA 控制器,接管了数据读写请求,减少 CPU 的负担。这样一来, CPU 能高效工作了。 现代硬盘基本都支持 DMA
实际因此 IO 读取,涉及两个过程:
1 DMA 等待数据准备好,把磁盘数据读取到操作系统内核缓冲区;
2 、用户进程,将内核缓冲区的数据 copy 到用户空间。 传统数据传送机制 比如:读取文件,再用 socket 发送出去,实际经过四次 copy 。 伪码实现如下:
buffer = File.read()
Socket.send(buffer)
1 、第一次:将磁盘文件,读取到操作系统内核缓冲区;
2 、第二次:将内核缓冲区的数据, copy 到应用程序的 buffer
3 、第三步:将 application 应用程序 buffer 中的数据, copy socket 网络发送缓冲区 ( 属 于操作系统内核的缓冲区)
4 、第四次:将 socket buffer 的数据, copy 到网卡,由网卡进行网络传输。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第17张图片

 

分析上述的过程,虽然引入 DMA 来接管 CPU 的中断请求,但四次 copy 是存在“不必 要的拷贝”的。实际上并不需要第二个和第三个数据副本。应用程序除了缓存数据并将其传 输回套接字缓冲区之外什么都不做。相反,数据可以直接从读缓冲区传输到套接字缓冲区。 显然,第二次和第三次数据 copy 其实在这种场景下没有什么帮助反而带来开销,这也 正是零拷贝出现的背景和意义。
同时, read send 都属于系统调用,每次调用都牵涉到两次上下文切换:
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第18张图片

 

总结下,传统的数据传送所消耗的成本: 4 次拷贝, 4 次上下文切换。 4 次拷贝,其中两次是 DMA copy ,两次是 CPU copy
Linux 支持的 ( 常见 ) 零拷贝
目的:减少 IO 流程中不必要的拷贝,当然零拷贝需要 OS 支持,也就是需要 kernel 暴 露 api
mmap 内存映射
硬盘上文件的位置和应用程序缓冲区 (application buffers) 进行映射(建立一种一一对应 关系),由于 mmap() 将文件直接映射到用户空间,所以实际文件读取时根据这个映射关系, 直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到 内核空间的一个缓冲区。
mmap 内存映射将会经历: 3 次拷贝 : 1 cpu copy 2 DMA copy ; 以及 4 次上下文切换,调用 mmap 函数 2 次, write 函数 2 次。
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第19张图片

 

sendfile
linux 2.1 支持的 sendfile 当调用 sendfile() 时, DMA 将磁盘数据复制到 kernel buffer ,然后将内核中的 kernel buffer 直接拷贝到 socket buffer ;但是数据并未被真正复制到 socket 关联的缓冲区内。取而代之的 是,只有记录数据位置和长度的描述符被加入到 socket 缓冲区中。 DMA 模块将数据直接从 内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。但是要注意,这个需要 DMA 硬件设备支持,如果不支持,CPU 就必须介入进行拷贝。 一旦数据全都拷贝到 socket buffer sendfile() 系统调用将会 return 、代表数据转化的完 成。socket buffer 里的数据就能在网络传输了。 sendfile 会经历: 3 (2,如果硬件设备支持)次拷贝, 1 (0,,如果硬件设备支持)次 CPU copy, 2 DMA copy ; 以及 2 次上下文切换
BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第20张图片
splice
Linux 2.6.17 支持 splice 数据从磁盘读取到 OS 内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据 buffer,而不需要拷贝到用户空间。
如下图所示,从磁盘读取到内核 buffer 后,在内核空间直接与 socket buffer 建立 pipe 管道。 和 sendfile() 不同的是, splice() 不需要硬件支持。 注意 splice sendfile 的不同, sendfile DMA 硬件设备不支持的情况下将磁盘数据加 载到 kernel buffer 后,需要一次 CPU copy ,拷贝到 socket buffer 。而 splice 是更进一步,连 这个 CPU copy 也不需要了,直接将两个内核空间的 buffer 进行 pipe 。 splice 会经历 2 次拷贝 : 0 cpu copy 2 DMA copy ; 以及 2 次上下文切换

 BIO实战、NIO编程与直接内存、零拷贝深入辨析-02_第21张图片

最早的零拷贝定义,来源于
Linux 2.4 内核新增 sendfile 系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核 Buffer 后,直接通过 DMA 拷贝到 NIO Buffer(socket buffer) ,无需 CPU 拷贝。这也是零 拷贝这一说法的来源。这是真正操作系统 意义上的零拷贝 ( 也就是狭义零拷贝 ) 随着发展,零拷贝的概念得到了延伸,就是目前的减少不必要的数据拷贝都算作零拷贝 的范畴
Java 生态圈中的零拷贝
Linux 提供的零拷贝技术 Java 并不是全支持,支持 2 ( 内存映射 mmap sendfile)

 

NIO 提供的内存映射 MappedByteBuffer
NIO 中的 FileChannel.map() 方法其实就是采用了操作系统中的内存映射方式,底层就是 调用 Linux mmap() 实现的。 将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件, 同时也能对文件内容进行更改,但是如果其后要通过 SocketChannel 发送,还是需要 CPU 进 行数据的拷贝。
NIO 提供的 sendfile
Java NIO 中提供的 FileChannel 拥有 transferTo transferFrom 两个方法,可直接把FileChannel 中的数据拷贝到另外一个 Channel ,或者直接把另外一个 Channel 中的数据拷 贝到FileChannel 。该接口常被用于高效的网络 / 文件的数据传输和大文件拷贝。在操作系 统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户 态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用 了“零拷贝”,所以其性能一般高于 Java IO 中提供的方法。
Kafka 中的零拷贝
Kafka 两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是
Producer 生产的数据存到 broker ,二是 Consumer broker 读取数据。
Producer 生产的数据持久化到 broker broker 里采用 mmap 文件映射,实现顺序的快速 写入;
Customer broker 读取数据, broker 里采用 sendfile ,将磁盘文件读到 OS 内核缓冲区 后,直接转到 socket buffer 进行网络发送。
Netty 的零拷贝实现
Netty 的零拷贝主要包含三个方面:
在网络通信上, Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS ,使用堆外直接 内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存( HEAP BUFFERS)进行 Socket 读写, JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写 入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。 在缓存操作上,Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个 逻辑上的 ByteBuf ,避免了各个 ByteBuf 之间的拷贝。 通过 wrap 操作,我们可以将 byte[] 数组、 ByteBuf ByteBuffer 等包装成一个 Netty ByteBuf 对象,进而避免了拷贝操作。
ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf , 避免了内存的拷贝。
在文件传输上, Netty 的通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,它 可以直接将文件缓冲区的数据发送到目标 Channel ,避免了传统通过循环 write 方式导致的 内存拷贝问题。

你可能感兴趣的:(分布式框架,nio,java,网络)