网络通信编程基本常识
什么是 Socket?
Socket 是应用层与 TCP/IP 协议族通信的中间软件抽象层,它是一组接口,一般由操作系统提供。在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议处理和通信缓存管理等等都隐藏在 Socket 接口后面,对用户来说,使用一组简单的接口就能进行网络应用编程,让 Socket 去组织数据,以符合指定的协议。主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接。
客户端连接上一个服务端,就会在客户端中产生一个 socket 接口实例,服务端每接受一个客户端连接,就会产生一个 socket 接口实例和客户端的 socket 进行通信,有多个客户端连接自然就有多个 socket 接口实例。
短连接
连接->传输数据->关闭连接
传统 HTTP 是无状态的,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,但任务结束就中断连接。
也可以这样说:短连接是指 SOCKET 连接后发送后接收完数据后马上断开连接。
长连接
连接->传输数据->保持连接 -> 传输数据-> 。。。 ->关闭连接。
长连接指建立 SOCKET 连接后不管是否使用都保持连接。
什么时候用长连接,短连接?
长连接多用于操作频繁,点对点的通讯。每个 TCP 连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,下次处理时直接发送数据包就 OK 了,不用建立 TCP 连接。例如:数据库的连接用长连接,如果用短连接频繁的通信会造成 socket 错误,而且频繁的 socket 创建也是对资源的浪费。
而像 WEB 网站的 http 服务按照 Http 协议规范早期一般都用短链接,因为长连接对于服务端来说会耗费一定的资源,而像 WEB 网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源。但是现在的 Http 协议,Http1.1,尤其是 Http2、Http3 已经开始向长连接演化。
总之,长连接和短连接的选择要视情况而定。
网络编程里通用常识
我们首先来看一个生活中的场景。周瑜老师准备开一个心理咨询中心,嘴上光喊没用,只有到工商局注册"东吴心理诊所"并且在图灵大街 888 号挂牌了,才算正式开张。疫情来了,准备开展电话业务,申请了一个电话号码 88888888。诸葛老师有了心理问题,于是打电话过来,周瑜老师接了电话,但是周瑜老师不懂心理咨询,于是通过内部分机把电话转给请来的心理医生 A 负责接待诸葛老师,心理医生 A 和诸葛老师通过电话进行沟通,模式一般就是一个人说另个一人听,两者进行沟通交流。Fox 老师也来了,周瑜老师接了电话,又把电话转给请来的心理医生 B 负责接待 Fox 老师,心理医生 B 和 Fox 老师也通过电话进行沟通。
上述的场景和网络编程有很大的相似之处。
我们已经知道在通信编程里提供服务的叫服务端,连接服务端使用服务的叫客户端。在开发过程中,如果类的名字有 Server 或者 ServerSocket 的,表示这个类是给服务端容纳网络服务用的,如果类的名字只包含 Socket 的,那么表示这是负责具体的网络读写的。
那么对于服务端来说 ServerSocket 就只是个场所,就像上面的"东吴心理诊所",它必须要绑定某个 IP 地址,就像"东吴心理诊所在图灵大街 888 号挂牌",同时 ServerSocket 还需要监听某个端口,就像"申请了一个电话号码 88888888"。
有电话进来了,具体和客户端沟通的还是一个一个的 socket,就像"周瑜老师不懂心理咨询,于是通过内部分机把电话转给请来的心理医生 A 负责接待诸葛老师",所以在通信编程里,ServerSocket 并不负责具体的网络读写,ServerSocket 就只是负责接收客户端连接后,新启一个 socket 来和客户端进行沟通。这一点对所有模式的通信编程都是适用的。
在通信编程里,我们关注的其实也就是三个事情:连接(客户端连接服务器,服务器等待和接收连接)、读网络数据、写网络数据,所有模式的通信编程都是围绕着这三件事情进行的。服务端提供 IP 和监听端口,客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
我们后面将学习的 BIO 和 NIO 其实都是处理上面三件事,只是处理的方式不一样。
Java 原生网络编程 - BIO
BIO 基础
BIO,意为 Blocking I/O,即阻塞的 I/O。
BIO 基本上就是我们上面所说的生活场景的朴素实现。在 BIO 中类 ServerSocket 负责绑定 IP 地址,启动监听端口,等待客户连接;客户端 Socket 类的实例发起连接操作,ServerSocket 接受连接后产生一个新的服务端 socket 实例负责和客户端 socket 实例通过输入和输出流进行通信。
BIO 的阻塞,主要体现在两个地方:
- 若一个服务器启动就绪,那么主线程就一直在等待着客户端的连接,这个等待过程中主线程就一直在阻塞。
- 在连接建立之后,在读取到 socket 信息之前,线程也是一直在等待,一直处于阻塞的状态下的。
这一点可以通过 cn.tuling.bio 下的 ServerSingle.java 服务端程序看出,启动该程序后,启动一个 Client 程序实例,并让这个 Client 阻塞住,位置就在向服务器输出具体请求之前,再启动一个新的 Client 程序实例,会发现尽管新的 Client实例连接上了服务器,但是 ServerSingle 服务端程序仿佛无感知一样?为何,因为执行的主线程被阻塞了一直在等待第一个 Client 实例发送消息过来。
所以在 BIO 通信里,我们往往会在服务器的实现上结合线程来处理连接以及和客户端的通信。
传统 BIO 通信模型
传统 BIO 通信模型:采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答模型,同时数据的读取写入也必须阻塞在一个线程内等待其完成。代码可见 cn.tuling.bio.Server。
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈 1:1 的正比关系,Java 中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死-掉-了。
为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现 1 个或多个线程处理 N 个客户端的模型(但是底层还是使用的同步阻塞 I/O),通常被称为"伪异步 I/O 模型"。
我们知道,如果使用 CachedThreadPool 线程池(不限制线程数量),其实除了能自动帮我们管理线程(复用),看起来也就像是 1:1 的客户端:线程数模型,而使用 FixedThreadPool 我们就有效的控制了线程的最大数量,保证了系统有限的资源的控制,实现了 N:M 的伪异步 I/O 模型。代码可见 cn.tuling.bio.ServerPool。
但是,正因为限制了线程数量,如果发生读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。
附录:BIO 实战-手写 RPC 框架
因为后面的 Dubbo课程中,周瑜老师会带大家手写实现一个 RPC 框架,所以本节内容不会讲述,从笔记到代码都供大家自行学习和参考。
为什么要有 RPC?
我们最开始开发的时候,一个应用一台机器,将所有功能都写在一起,比如说比较常见的电商场景,服务之间的调用就是我们最熟悉的普通本地方法调用。
随着我们业务的发展,我们需要提示性能了,我们会怎么做?将不同的业务功能放到线程里来实现异步和提升性能,但本质上还是本地方法调用。
但是业务越来越复杂,业务量越来越大,单个应用或者一台机器的资源是肯定背负不起的,这个时候
,我们会怎么做?将核心业务抽取出来,作为独立的服务,放到其他服务器上或者形成集群。这个时候就会请出 RPC,系统变为分布式的架构。
为什么说千万级流量分布式、微服务架构必备的 RPC 框架?和 LocalCall 的代码进行比较,因为引入 rpc 框架对我们现有的代码影响最小,同时又可以帮我们实现架构上的扩展。
现在的开源 rpc 框架,有什么?dubbo,grpc 等等
当服务越来越多,各种 rpc 之间的调用会越来越复杂,这个时候我们会引入中间件,比如说 MQ、缓存,同时架构上整体往微服务去迁移,引入了各种比如容器技术 docker,DevOps 等等。最终会变为如图所示来应付千万级流量,但是不管怎样,rpc 总是会占有一席之地。
什么是 RPC?
RPC(Remote Procedure Call ------远程过程调用),它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络的技术。
一次完整的 RPC 同步调用流程:
- 服务消费方(client)以本地调用方式调用客户端存根;
- 什么叫客户端存根?就是远程方法在本地的模拟对象,一样的也有方法名,也有方法参数,client stub 接收到调用后负责将方法名、方法的参数等包装,并将包装后的信息通过网络发送到服务端;
- 服务端收到消息后,交给代理存根在服务器的部分后进行解码为实际的方法名和参数;
- server stub 根据解码结果调用服务器上本地的实际服务;
- 本地服务执行并将结果返回给 server stub;
- server stub 将返回结果打包成消息并发送至消费方;
- client stub 接收到消息,并进行解码;
- 服务消费方得到最终结果。
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 语言的反射机制。
反射机制能做什么
反射机制主要提供了以下功能:
- 在运行时判断任意一个对象所属的类;
- 在运行时构造任意一个类的对象;
- 在运行时判断任意一个类所具有的成员变量和方法;
- 在运行时调用任意一个对象的方法;
- 生成动态代理。
成型代码
参见工程 ketang-tl-rpc,其中
- rpc-client 包含了 rpc 框架客户端的使用范例
- rpc-reg 包含了 rpc 框架的远程注册中心的实现代码
- rpc-server-sms 包含了 rpc 框架服务端的使用范例 sms 服务
- rpc-server-stock 包含了 rpc 框架服务端的使用范例 stock 服务
- rpc-netty-client 包含了基于 Netty 通信框架的 rpc 实现的客户端
- rpc-netty-server 包含了基于 Netty 通信框架的 rpc 实现的服务端,采用了本地注册中心模式
原生 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 号房间空闲了也是事件,但是路人甲不感兴趣。
大堂经理不仅仅服务路人甲这个人,他还可以同时服务路人乙、丙........,每个人所感兴趣的事件是不一样的,大堂经理会根据每个人感兴趣的事件通知对应的每个人。
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 对象,并提供了一组方法,用来方便的访问该块内存。
实现代码
-
Selector对象是通过调用静态工厂方法 open()来实例化的,如下:
Selector Selector=Selector.open();
-
要实现 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();
-
在实际运行中,我们通过 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会再次将其放入已选择键集中。
具体与 NIO编程相关的代码参见模块 nio 下包 cn.tuling.nio.nio。从服务端的代码我们可以看到,我们仅用了一个线程就处理了多个客户端的的连接。
重要概念 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;但如果是写密集型的任务,比如文件下载等,可以注册该操作,在写缓冲区有空闲空间时发起写操作,文件数据有的放矢,避免浪费 CPU。 |
OP_CONNECT | TCP 客户端连接成功或失败时就绪。比如 TCP 三次握手成功建立连接或连接失败等情况。 |
OP_ACCEPT | TCP 服务器监听通道有新客户端连接请求时就绪。 |
详细的 API
public abstract class SelectionKey
extends Object
{
public static final int OP_READ = 1 << 0; // 0001
public static final int OP_WRITE = 1 << 2; // 0100
public static final int OP_CONNECT = 1 << 3; // 1000
public static final int OP_ACCEPT = 1 << 4; // 00010000
// 取消 selectionKey
public abstract void cancel();
// 获取关联的 selector
public abstract Selector selector();
// 获取注册的 channel
public abstract SelectableChannel channel();
// 判断 key 是否有效
public abstract boolean isValid();
// 获取注册事件,返回 interestSet
public abstract int interestOps();
// 设置注册事件,更新 interestSet
public abstract SelectionKey interestOps(int ops);
// 获取已经准备就绪的事件,返回 readySet
public abstract int readyOps();
// 判断是否读事件已经就绪
public final boolean isReadable() {
return (readyOps() & OP_READ) != 0;
}
// 判断是否写事件已经就绪
public final boolean isWritable() {
return (readyOps() & OP_WRITE) != 0;
}
// 判断连接是否已经就绪
public final boolean isConnectable() {
return (readyOps() & OP_CONNECT) != 0;
}
// 判断是否有新连接
public final boolean isAcceptable() {
return (readyOps() & OP_ACCEPT) != 0;
}
}
NIO 之 Reactor 模式
Reactor 模式是 Netty 框架等的核心,也是 Netty 高性能网络编程的核心。
单线程 Reactor 模型
- Acceptor 线程:
- 负责监听客户端连接请求
- 处理所有 I/O 操作
-
缺点:当请求高并发时,一个线程需要处理大量 I/O 操作,负担太重
-
代码可见 cn.tuling.nio.nioadvance 单线程的 Server代码
多线程主从 Reactor 模型
主从 Reactor 模型,有一个 mainReactor 和多个 subReactor
- mainReactor 负责接收客户端连接请求,然后将 SocketChannel 传递给某一个 subReactor
- subReactor 负责与客户端通信,处理 I/O 操作
优点:将连接请求和 I/O 操作分开,互不影响,整体效率更高
多线程主从 Reactor 模型代码可见 cn.tuling.nio.nioadvance 多线程 Server 代码
零拷贝
什么是零拷贝?
Zero Copy 意为"零拷贝",在计算机操作系统中是指 CPU 不需要调用数据直接从一个地址复制到另一个地址,避免了内核态与用户态之间频繁的数据拷贝。
为什么要使用零拷贝?
因为内核缓冲区和用户缓冲区的频繁数据拷贝将耗费大量 CPU 资源。在进行大量数据传输时,采用零拷贝技术可以显著提升系统效率。
零拷贝技术
目前,常见的零拷贝技术主要有 mmap 和 sendfile。
mmap
通过 mmap 实现零拷贝的过程为:
- 操作系统将文件映射到进程地址空间,避免用户态与内核态的数据拷贝;
- 应用程序通过虚拟内存访问文件内容,进行读写操作。
sendfile
通过 sendfile 实现零拷贝的过程为:
- 内核通过 DMA 直接将数据从磁盘读取到内核空间;
- 内核再将数据从内核空间复制到网卡,发送到网络。
Java 中的零拷贝
Java NIO 提供了 mmap 和 sendfile 的支持,实现零拷贝。Netty 框架也大量采用零拷贝技术来提高性能。
- MappedByteBuffer:Java NIO 提供的内存映射文件,通过 FileChannel.map() 方法创建,避免了用户态和内核态的数据拷贝。
- FileChannel.transferTo() 和 transferFrom():Java NIO 提供的基于 sendfile 的文件传输方法,实现了零拷贝。
直接内存
什么是直接内存?
直接内存(Direct Memory)是在堆外分配的内存,不受 Java 垃圾回收机制(GC)管理。
为什么使用直接内存?
使用直接内存可以避免内核态与用户态之间的数据拷贝,提高 I/O 操作的性能。特别适用于网络编程和高性能 I/O 场景。
Java 中的直接内存
Java NIO 提供了直接内存的支持,通过 ByteBuffer.allocateDirect() 方法分配直接内存。Netty 框架中也大量使用直接内存来提高性能。
总结
通过以上内容,我们可以详细了解计算机网络通信中的 BIO 和 NIO 编程模型,以及直接内存和零拷贝技术。希望这些知识点能够帮助你更好地理解和掌握网络通信编程。