我是有点怕网络编程的,总有点【谈网色变】的感觉。为了让自己不再【谈网色变】,所以我想过系统学习一下,然后再做个笔记这样,加深一下理解。但是真要系统学习,其实还是要花费不少时间的,所以这里也只是简单的,尽可能地覆盖一下,梳理一些我认为比较迫切需要了解的网络编程相关的知识。
其实单单网络编程跟TCP/IP协议族就可以拿来写一个独立的笔记了,但是为了让知识点更耦合一点,我还是决定写在一起。当然这样也有坏处,那就是知识点比较密集,然后导致笔记篇幅偏长,所以阅读的朋友给点耐心。
另外,为了让知识分层更加清晰一点,基础网络编程跟TCP/IP那一块我还是放在【前置知识】里面。
OSI七层网络模型:
我相信大家对OSI七层模型都不陌生,OSI全称:开放系统互连参考模型,是国际标准化组织(ISO)和国际电报电话咨询委员会(CCITT)联合制定的开放系统互连参考模型,为开放式互连信息系统提供了一种功能结构的框架。其目的是为异种计算机互连提供一个共同的基础和标准框架,并为保持相关标准的一致性和兼容性提供共同的参考。这里所说的开放系统,实质上指的是遵循 OSI 参考模型和相关协议能够实现互连的具有各种应用目的的计算机系统。(PS:这是一个标准的,参考模型)
它的整体模型图如下:
面试中可能比较常考这一点,建议死记硬背。个人背诵方式是:记每一层首字。即:应表会传网数物、应表会传网数物、应表会传网数物。刚开始有点拗口,多念几遍就记住了。
TCP/IP模型:
严格来说,OSI模型是一种比较复杂且学术化的【参考】模型,实际上我们说的互联网中实际使用的是TCP/IP模型(这是由工程师定义的。这个也是我们后面要着重讲的内容)。
TCP/IP模型在网上又叫五层模型。它跟7层网络模型其实差不多,就是把7层的前3层合在一起表示了。模型图如下:
PS:网上也有一些人把最后2层【数据链路层】和【物理层】合在一起叫做【网络接口层】,最后结论为:TCP/IP为4层网络模型。大家知道有这个东西就好
小总结:
其实无论什么模型,每一层的定义都是建立在低一层提供的服务上的,并且为高一层提供服务。大致来说,我们可以这么映射这个网络模型:
物理层 <—对应—> 网卡、路由器、交换机
数据链路层 <—对应—> 网卡驱动
网络层、传输层 <—对应—> 这一部分已经被操作系统封装了
应用层 <—对应—> 一些通用的网络应用程序,或者自己编写的应用程序
另外,我相信通过对比大家也看到了,OSI七层模型跟TCP/IP五层模型没啥特别大区别的,记住其中一个模型就差不多了。而且,在编程过程中,其实我们通常只需要关注【应用层】就好了,下面几层的封装,其实操作系统早就帮我们封装好了。它就是:Socket
。
TCP/IP协议族,我希望大家将他拆分成三个部分来理解。
TCP/IP
:Transmission Control Protocol/Internet Protocol 的简写,中译名为【传输控制协议/因特网互联协议】,是 Internet 最基本的协议、Internet 国际互联网络的基础。由网络层的 IP 协议和传输层的 TCP 协议组成。协议采用了 5 层的层级结构。然而在很多情况下,它是利用 IP 进行通信时所必须用到的协议群的统称也就是说,它其实是个协议家族,由很多个协议组成,并且是在不同的层,是互联网的基础通信架构下图是TCP/IP模型,以及对应的常见协议族:
横向对比图:
OSI参考模型 | TCP/IP模型 | TCP/IP协议族 |
应用层 | 应用层 | DNS、HTTP、FTP、Mysql、WebSocket |
表示层 | ||
会话层 | ||
传输层 | 传输层 | TCP、UDP |
网络层 | 网络层 | IP、ICMP、RIP、IGMP |
数据链路层 | 数据链路层 | ARP、RARP、IEEE802.3、CSMA/CD |
物理层 | 物理层 | FE自协商、Manchester、MLT-3、4A、PAM5 |
每个分层中,都会对所发送的数据附加一个首部,在这个首部中包含了该层必要的信息,如发送的目标地址以及协议相关信息(或者我们可以理解为,数据再每个分层流转的时候都会包裹一层,根据协议标准附带上当前层的一些关键数据),这就是编程语言角度【协议】的本质。通常,为协议提供的信息为包首部,所要发送的内容为数据。在下一层的角度看,从上一层收到的包全部都被认为是本层的数据。
网络中传输的数据包由两部分组成:【协议首部】+【上一层传过来的数据】。协议首部结构由协议的具体规范详细定义。在数据包的首部,明确标明了协议应该如何读取数据。反过来说,看到首部,也就能够了解该协议必要的信息以及所要处理的数据。
举个简单的例子。我们用用户 A 发送,用户 B 接受来说说明:
1)用户A使用某应用程序在【应用层】处理。首先,应用程序会根据自定义编码处理产生的消息(比如最简单的json序列化),然后交给下面的【传输层】TCP协议处理
2)用户A的【传输层】TCP处理。TCP根据应用的指示,负责建立连接、发送数据以及断开连接。TCP 提供将应用层发来的数据顺利发送至对端的可靠传输。为了实现这一功能,需要将【应用层数据】封装为【报文段(segment)】并附加一个 TCP 首部然后交给下面的【网络层】IP协议处理
3)用户A的【网络层】IP模块的处理。IP 将 TCP 传过来的 TCP 首部和 TCP 数据合起来当做自己的数据,并在 TCP 首部的前端加上自己的 IP 首部生成 【IP 数据报(datagram)】然后交给下面的【数据链路层】
4)用户A【数据链路层】处理。从 IP 传过来的 IP 包对于数据链路层来说就是数据。给这些数据附加上数据链路层首部封装为链路层帧(frame),生成的链路层【帧(frame)】将通过物理层传输给接收端
5)用户B【数据链路层】的处理。用户 B 主机收到链路层帧(frame)后,首先从链路层帧(frame)首部找到 MAC 地址判断
是否为发送给自己的包,若不是则丢弃数据;如果是发送给自己的包,则从以太网包首部中的类型确定数据类型,再传给相应的模块,如IP、ARP 等。这里的例子则是 IP
6)用户B【网络层】IP 模块的处理。IP 模块接收到 数据后也做类似的处理。从包首部中判断此 IP 地址是否与自己的 IP 地址匹配,如果匹配则根据首部的协议类型将数据发送给对应的模块,如 TCP、UDP。这里的例子则是 TCP
7)用户B【传输层】TCP 模块的处理。在 TCP 模块中,首先会计算一下校验和,判断数据是否被破坏。然后检查是否在按照序号接收数据(请记住这个步骤,后面推断三次握手要用到)。最后检查端口号,确定具体的应用程序。数据被完整地接收以后,会传给由端口号识别的应用程序
8)用户B【应用层】应用程序的处理。接收端应用程序会直接接收发送端发送的数据。通过解析数据,展示相应的内容。
PS:上面这个过程有一个【隐含的知识点】需要大家记忆一下。那就是,数据在各层的【名称】。
PS:上面这个过程有一个【隐含的知识点】需要大家记忆一下。那就是,数据在各层的【名称】。
PS:上面这个过程有一个【隐含的知识点】需要大家记忆一下。那就是,数据在各层的【名称】。
- 在应用层:数据被称为【报文/消息】
- 在传输层:数据被称为【报文段】
- 在网络层:数据被称为【数据报】
- 在数据链路层和物理层:【数据帧】
在我们网络传输中,有两个概念我们会经常接触,就是所谓的地址跟端口号。地址地址,我估计大部分人想到的是IP地址,却忽略了Mac地址。可是有人知道,为什么计算机里面要设计【Mac地址 + IP地址 + 端口号】吗?
我们知道,IP地址通常是用来唯一标识一台机器的,但我们同样应该知道,不同内网中IP地址是可以重复的,那我怎么知道这个IP地址就是这台机器呢?IP地址又是如何而来的呢?对的,就是Mac地址。
Mac地址
MAC 地址全称叫做媒体访问控制地址,也称为局域网地址(LAN Address),MAC 位址,以太网地址(Ethernet Address)或物理地址(Physical Address),它是由网络设备制造商在生产时就写在硬件内部的(但是这不意味着就不能更改,只是比较麻烦,一般人也不会去改)。它作用于【数据链路层】上
MAC 地址共 48 位(6 个字节)。前 24 位由 IEEE(电气和电子工程师协会)决定如何分配,后 24 位由实际生产该网络设备的厂商自行制定。例如:FF:FF:FF:FF:FF:FF 或 FF-FF-FF-FF-FF-FF
比如,下面是我的电脑Mac地址信息:打开cmd,输入:ipconfig
IP地址
IP地址是啥不用过多介绍了。IP 地址分为:IPv4 和 IPv6,我们通常说的是IPv4。IP 地址是由 32 位的二进制数组成,它们通常被分为 4 个【8 位二进制数】,我们可以把它理解为 4 个字节,格式表示为:A.B.C.D
。其中,A,B,C,D 这四个英文字母表示为 0-255(2^8 - 1)的十进制的整数。例如:192.168.1.1。它作用于【网络层】上
那IP地址跟Mac地址有什么区别和联系呢?
1)对于网络中的一些设备,路由器或者是 PC 及而言, IP 地址的设计是出于拓扑设计出来的,只要在不重复 IP 地址的情况下,它是可以随意更改的;而 MAC 地址是根据生产厂商烧录好的,它一般不能改动的,一般来说,当一台 PC 机的网卡坏了之后,更换了网卡之后 MAC 地址就会变了
2)在前面的介绍里面,它们最明显的区别就是长度不同, IP 地址的长度为 32 位,而Mac地址为 48 位
3)它们的寻址协议层不同。IP 地址应用于 OSI 模型的【网络层】,而Mac地址应用在OSI的【数据链路层】。 数据链路层协议可以使数据从一个节点传递到相同链路的另一个节点上(通过 MAC 地址),而网络层协议使数据可以从一个网络传递到另一个网络上( ARP 根据目的 IP 地址,找到中间节点的 MAC 地址,通过中间节点传送,从而最终到达目的网络)
4)分配依据不同。 IP 地址的分配是基于我们自身定义的网络拓扑, MAC 地址的分配是基于制造商
端口号
端口号的存在不难理解,毕竟无论是Mac地址还是IP地址都只能标识一台机器,那怎么标识机器上特定应用程序呢?就是端口号,所以端口号又可以称为:程序地址。它作用于【传输层】上
一台计算机上同时可以运行多个程序。传输层协议正是利用这些端口号识别本机中正在进行通信的应用程序,并准确地将数据传输。
1)TCP/IP每一层都有自己的协议,甚至有多种
2)协议是一种规范、约束。用Java WebMVC来说,这种规范就是:前后端约定了,每一次交互必传手机号一样
3)TCP/IP每个分层中,都会对所发送的数据附加一个首部,在这个首部中包含了该层必要的信息(其实就是【协议】的体现)
4)在下一层的角度看,从上一层收到的包全部都被认为是本层的数据
Q1:为什么端口号只有65535个?
答:因为在TCP、UDP协议报文的定义中,会分别有16位二进制(合计32位4字节)长度来存储元端口号和目的端口号,所以端口个数只能是2^16=65536个。通常0号端口用来表示所有端口,所以实际可以用的端口号有65535个。(详细的TCP、UPD头部结构体定义,见下面的笔记内容【TCP头部结构体】)
Q2:客户端的端口号是多少,如何确定?
答:是的,我们通过客户端连接服务端的时候,也需要端口号的,这一点很容易被大家忽略。然后,客户端的端口号虽然可以指定,但是通常不指定,因为没什么必要。所以,都是由操作系统给自动分配的。并且,分配的范围通常都是大于10000(0-1023通常被知名服务占用了,比如FTP端口是21,HTTP是80)
在讲述TCP/UDP特性之前,我们先给大家声明一个概念。那就是网络通信中的客户端和服务端。声明如下:
【你】向【我】发起连接,肯定是因为想从【我】这里获得什么,所以【你】是客户端,【我】是服务端(服务提供方),这很合理。其实这才是广义上的客户端、服务端定义。请大家不要狭隘地跟Web开发中的客户端、服务端混淆。
TCP(Transmission Control Protocol)是面向连接的【全双工】通信协议,通过三次握手建立连接,然后才能开始数据的读写,通讯完成时要拆除连接,由于 TCP 是面向连接的所以只能用于端到端的通讯。
我们知道,TCP相对于UDP最大的特点,就是【可靠性】。那么为了实现【可靠性】,TCP做了什么呢?整体来说大概是2点:【超时重传】和【消息应答确认机制】。那大家有思考过,这两点是如何被实现的吗?其实这个实现方式也并不是特别重要,不过说到这个东西,我突然间意识到一些东西。言归正传。
全双工:即允许数据在两个方向上传输
半双工:虽然数据也能在两个方向上传输,但是一次只能一个方向流动
单工:只允许数据从一个方向上传输
超时重传
超时重传机制中最最重要的就是重传超时(RTO,Retransmission TimeOut)的时间选择(如何确定),由于网络存在波动,所以,用一个固定的时间来测算是一种不经济的方式,TCP通过引入一个叫做RTT(round-trip time)的定义,根据最近的发包、收包时间动态测算、修改重试时间
消息应答机制
顾名思义,就是当收到消息后,给消息一个应答,告诉对方:我收到了。而对于没收到消息应答的一方来说,一段时间后没收到应答就【超时重传】。那在复杂的网络通信中,我可能每秒钟都有好多条消息交互,我怎么知道这一条答复,是对应哪一条请求呢?对的,序号!这个序号也将在TCP三次握手中体现出来(现在回过头来看,RocketMQ的事务可能就是参照这个设计的。当然可能也不是,毕竟这个设计思路很多地方都有用过)
TCP 提供面向有连接的通信传输。【面向有连接】是指在数据通信开始之前先做好两端之间的准备工作。
所谓三次握手是指建立一个 TCP 连接时需要客户端和服务器端总共发送三个包以确认连接的建立。在 socket 编程中,这一过程由客户端执行 connect 来触发,所以网络通信中,发起连接的一方我们称为客户端,接收连接的一方我们称之为服务端。
下面是三次握手的流程图:
流程解读:
1)第一次握手:客户端将请求报文标志位 SYN 置为 1(告诉服务端,此时是在同步),请求报文的 seq(Sequence Number)字段中填入一个随机值 J(表示此时是同步到第几条消息,这个很重要很重要很重要),并将该数据包发送给服务器端,等待服务端的应答确认,紧接着客户端进入SYN_SENT
状态(SYN即同步的意思)
2)第二次握手:服务器端收到数据包后由请求报文标志位SYN=1
知道客户端请求建立连接,服务器端将应答报文标志位 SYN 和 ACK 都置为1(ACK=1
表示此条应答是针对客户端同步的应答;SYN=1
则表示我也想跟你请求同步了【毕竟全双工啊同学们】,双方都交换seq
),应答报文的 ack(Acknowledgment Number)字段中填入 ack=J+1,应答报文的 seq 中填入一个随机值 K(同上面的J一样道理),并将该数据包发送给客户端以确认连接请求,服务器端进入 SYN_RCVD 状态
3)第三次握手:客户端收到应答报文后,检查 ack 是否为 J+1,ACK 是否为 1,如果正确则将第三个报文标志位 ACK 置为 1,ack=K+1,并将该数据包发送给服务器端,服务器端检查 ack 是否为 K+1,ACK 是否为 1,如果正确则连接建立成功,客户端和服务器端进入ESTABLISHED 状态,完成三次握手,随后客户端与服务器端之间可以开始传输数据了
TCP三次握手大家都听过,但是根据我的经验,大部分人都【谈网色变】,甚至搞不清楚三次握手传的参数是什么意思。所以这边再来个【小总结】,希望大家别看了流程就算了。如下:
SYN=1
表示我要跟你同步了Q1:为什么要同步?同步什么?
答:主要是同步序号seq,为什么要这个同步这个?你看看【消息应答机制】提到的,在复杂的网络通信中,如果没有序号,如何判断,我这条应答对应的是你这条消息??没有序号,我怎么知道,哪条消息已经接收到了,哪些没有收到,哪些消息又没有重复发送?
Q2:为什么三次握手而不是四次握手,甚至更多次?
答:因为三次就够了,已经完成原始数据同步了,后面再来就没意义了。
socket
对象其实这个相对来说没那么重要,就算是面试也很少考这个,但终归还是要了解一下的。
四次挥手即终止 TCP 连接,就是指断开一个 TCP 连接时,需要客户端和服务端总共发送 4 个包以确认连接的断开。在 socket 编程中,这一过程由客户端或服务端任一方执行 close来触发。
跟握手一样,由于TCP是全双工的,因此,每个方向都必须要单独进行关闭。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭。(连接都是双方都请求连接,关闭当然是两边都要关闭)
流程图如下:
我相信你们如果完整看过了【TCP3次握手】的话,这边的流程图难不倒你。FIN
即finish
嘛。老样子,注意状态变化哦。这里可能比较男的地方就是最后一次【挥手】的时候,为什么要进入TIME-WAIT
状态了。哎呀,都是因为【全双工】啊,等待一下,确保那边也关了比较好,另外也确保迟到的消息报文,能被当前应用程序正确的识别并且丢弃。
Q1:【被当前应用程序正确的识别并且丢弃】怎么理解?
答:我们前面介绍端口的时候说过,客户端端口号可以不指定,由系统自动分配的,所以很可能你刚关闭,就被新的其他应用程序客户端占取了这个端口,结果就是:如果没有这个TIME-WAIT状态,就恰好再关闭之前有来自其他端的发送给上一个占用该端口的应用程序。啊,现在这个应用程序就懵了,咋回事啊这条消息?完全“看不懂”啥意思啊!!
为什么要介绍这个结构体?
首先,经过了【2.2 TCP/IP网络传输中的数据】的描述,你应该知道,在网络通信中,每个分层都会裹上一层属于自己的数据(各种协议规定的),这就是编程语言角度【网络协议】的本质。
所以,TCP在传输层中给出的【协议】即如下所示:
这个图是不是很难看懂?给大家一段C语言伪代码,一般人都看得懂啥意思。
// TCP头(首)部信息
struct TCPHead
{
int32 port; // 端口号,4字节
int32 seq; // 序号,4字节
int32 ack; // 确认号,4字节
int32 offset; // 偏移数据,4字节
int32 crc; // 校验,4字节
byte[] data; // 协议数据,边长
}
这里给出这该结构体的定义跟模型图,也是为了方便大家去放飞思想,想象一下其他各层的结构体,以及,又会裹上什么信息呢。
再然后,通过一个简单的面试问题,把之前的知识整合起来
Q1:一台主机上只能保持最多 65535 个 TCP 连接,对吗?
答:肯定是不对的。首先,出于【传输层】的TCP协议,肯定是包含了【网络层】IP层数据的(如果你网络,赶紧回去看看【2.2】)。的虽然我没有贴出来【网络层】IP协议的头部数据,但是你多多少少会知道,肯定需要【源IP地址】跟【目标IP】地址才对。
所以,定义一个【唯一TCP连接】的要素如下:【源 IP 地址】+【目标 IP 地址】+【协议号(协议类型)】+【源端口号】+【目标端口号】
大伙看看上图,不难看出,当【源IP地址】跟【源端口号】变化时,哪怕【目标IP地址】跟【目标端口号】,【协议类型(TCP固定为TCP,UDP固定为UPD)】固定,TCP连接数量也会由于前两者的变化而变化。
UDP(User Datagram Protocol),中文名是用户数据报协议。是把数据直接发出去,而不管对方是不是在接收,也不管对方是否能接收,也不需要接收方确认,属于不可靠的传输,可能会出现丢包现象,实际应用中要求程序员自己选择、比较使用。
UDP还有2个特性,称为:单播和广播。
单播和广播
单播的传输模式,定义为发送消息给一个由唯一的地址所标识的单一的网络目的地。面向连接的协议和无连接协议都支持这种模式。由于通讯不需要连接,所以可以实现广播发送,所谓广播——传输到网络(或者子网)上的所有主机。
我相信大部分初次接触UDP的朋友可能会觉得,UDP这么不可靠,使用应该不多吧?哈,相反,UDP因为没那么多复杂的机制,反而深受大佬们喜爱,甚至可以说,很多大佬们会优先选择UDP,除非迫不得已,真的需要【可靠性】的支持,才使用TCP。
OK,【前置知识】中关于网络编程的一些基础知识终于是捋完了。怎么说呢,因为我也没看过更专业、更全的【网络编程基础】相关资料书记,所以,我也不好说我说的是否都是对的,更遑论是否全面。我只是根据,我一直以来学习网络编程以来遇到的问题。不过话说回来,我遇到的这些问题有时候会受限于个人的眼界与技术功底。
共勉
正文开始
我在前置知识中稍微提过一嘴,由于Socket
的存在,直接帮我们封装了TCP/IP模型中的后4层,我们只需要关注【应用层】就好了。甚至【应用层】也有很多成熟的产品了,我们往往只需要关注如何写业务,以及如何把它们应用到我们的代码中。言归正传。
Socket
是应用层与 TCP/IP 协议族通信的中间软件抽象层,它是一组接口,一般由操作系统提供。从设计模式的角度看,Socket
其实就是一个门面模式,它把复杂的 TCP/IP 协议处理和通信缓存管理等等都隐藏在 Socket 接口后面,对用户来说,使用一组简单的接口就能进行网络应用编程,让Socket 去组织数据,以符合指定的协议。主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接。
给大家一个门面模式渲染图感受一下Socket:
改成门面模式后:(不要怀疑下面这个是否合理,如果你去过高端大气上档次的男科医院,就知道什么是VIP服务了)
客户端连接上一个服务端(connect),就会在客户端中产生一个 socket 接口实例,服务端每接受一个客户端连接,就会产生一个 socket 接口实例和客户端的 socket 进行通信,有多个客户端连接自然就有多个 socket 接口实例。它们的通信模型大概如下:
短连接
短连接的大概流程是这样的:连接->传输数据->关闭连接
。典型的如:HTTP。
传统 HTTP 是无状态的,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,但任务结束就断开。也可以这样说:短连接是指 SOCKET 连接发送数据接收完数据后马上断开连接的一种连接。
长连接
长连接的大概流程是这样的:连接->传输数据->保持连接 -> 传输数据-> 。。。 ->关闭连接
。典型的如:Mysql连接协议。也可以这样说:长连接指建立 SOCKET 连接后不管是否使用都保持连接的一种连接。
Q:什么时候用长连接,短连接?
答:长连接多用于操作频繁,点对点的通讯,毕竟TCP连接需要三次握手,反复握手是一个很重型的操作。而且从代码角度来说,反复创建Socket对象也不是一个经济的方式。而像 WEB 网站的 HTTP服务按照 HTTP协议规范早期一般都用短链接,毕竟想WEB应用通常都不会频繁交互,或者说每次交互都是短暂的操作。不过值得注意的是,现在的 HTTP协议,Http1.1,尤其是 Http2、Http3 已经开始向长连接演化。
我们首先来看一个生活中的场景。网络编程可以类比成生活中:心理咨询中心场景。
这里就类比于:网络编程中,我们要先在服务端声明一个ServerSocket,绑定指定IP地址跟端口
这就类比于:网络编程中,ServerSocket只关注于网络连接事件
4、5就类比于:网络编程中,ServerSocket每接收一个连接事件后,就新建一个Socket与它们交互
还算简单吧。这里有一个小结论要跟大家说下:
整体来说,在网络通信编程中,我们只需要关注三件事:
(PS:网络编程范式)
(PS:网络编程范式)
(PS:网络编程范式)
1)连接事件。客户端连接服务器,服务器等待和连接
2)读网络数据
3)写网络数据
无论是后面学的BIO也好,还是NIO,又或者什么Netty网络编程,所有模式的通信编程都是围绕着这三件事情进行的。服务端提供 IP 和监听端口,客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
BIO,意为 Blocking I/O,即:阻塞的 I/O。
BIO 基本上就是我们上面所说的生活场景的直接实现。在 BIO 中类 ServerSocket 负责绑定 IP 地址,启动监听端口,等待客户连接;客户端 Socket 类的实例发起连接操作,ServerSocket接受连接后产生一个新的服务端 socket 实例负责和客户端 socket 实例通过输入和输出流进行通信。
那么,BIO的阻塞体现在哪里呢?我们来看一段简单的BIO代码。
/**
* 服务端BIO代码
*
* @author shen·buffet
* @date 2023-10-17
* @slogan 听天命,尽人事
*/
public class TestServer {
final static int PORT = 8585;
public static void main(String[] args) throws IOException {
// 第一步:创建一个ServerSocket
ServerSocket serverSocket = createServerSocket();
System.out.println("第一步完成。 服务已启动");
// 第二步:监听连接事件
handleConnect(serverSocket);
}
/**
* 创建一个ServerSocket
*/
public static ServerSocket createServerSocket() throws IOException {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(PORT));
return serverSocket;
}
/**
* ServerSocket上的事件处理
*/
public static void handleConnect(ServerSocket serverSocket) throws IOException {
int connectCount = 0;
try {
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("连接事件来了。 当前连接数目=" + (++connectCount));
// 第三步:读写网络事件
handleClientReadWrite(clientSocket);
}
} finally {
serverSocket.close();
}
}
/**
* Client上的读写事件
*/
public static void handleClientReadWrite(Socket clientSocket) throws IOException {
// 实例化与客户端通信的输入输出流
ObjectInputStream objectInputStream = new ObjectInputStream(clientSocket.getInputStream());
ObjectOutputStream objectOutputStream = new ObjectOutputStream(clientSocket.getOutputStream());
// 接收客户端的输出,也就是服务器的输入
String userName = objectInputStream.readUTF();
System.out.println("客户端读事件来了,消息=" + userName);
// 服务器的输出,也就是客户端的输入
objectOutputStream.writeUTF("hello," + userName);
objectOutputStream.flush();
}
}
/**
* 客户端BIO代码
*
* @author shen·buffet
* @date 2023-10-17
* @slogan 听天命,尽人事
*/
public class TestClient {
public static void main(String[] args) throws IOException {
//客户端启动必备
Socket socket = null;
// 实例化与服务端通信的输入输出流
ObjectOutputStream output = null;
ObjectInputStream input = null;
// 服务器的通信地址
InetSocketAddress addr = new InetSocketAddress("127.0.0.1", 8585);
try {
socket = new Socket();
socket.connect(addr);
System.out.println("连接服务器成功!!");
output = new ObjectOutputStream(socket.getOutputStream());
input = new ObjectInputStream(socket.getInputStream());
System.out.println("客户端准备发送数据....");
// 向服务器输出请求
output.writeUTF("ZhangShen");
output.flush();
//接收服务器的输出
System.out.println("服务端答复数据来了....");
System.out.println(input.readUTF());
} finally {
if (socket != null) {
socket.close();
}
if (output != null) {
output.close();
}
if (input != null) {
input.close();
}
}
}
}
其实这个阻塞,就体现在上面代码中的2点:TestServer#handleConnect
下的serverSocket.accept()
服务端等待客户端连接,以及TestServer#handleClientReadWrite
下的objectInputStream.readUTF()
等待客户端socket发送消息。(大家伙可以从方法点进去,看看方法头上的注释就知道了。通常这种优秀的代码,会在方法头上写清楚注释:这个方法会阻塞)
除了阻塞以外,大家发现没有,这个代码一条线程只能处理一个TCP连接请求,这显然是无法忍受的。所以,BIO在演进的过程中,为了,结合了多线程来实现同时处理多个TCP连接的目的。
如果想要使用我上面的代码实现模拟多条TCP连接失败的场景,需要DEBUG住客户端发送消息那一块,反正就是不要让它进入
finally
块关闭socket
,不然连接都关了,服务端也要申请关闭(TCP全双工特性,还记得吧)。
然后,IDEA用户的话,此时再新增一个Application启动,如下图所示:(整体操作步骤应该不用我手把手再教学了吧…)
话不多说,先上个代码,这里主要修改了TestServer
类的handleConnect
方法:
/**
* ServerSocket上的事件处理
*/
public static void handleConnect(ServerSocket serverSocket) throws IOException {
int connectCount = 0;
try {
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("连接事件来了。 当前连接数目=" + (++connectCount));
// 第三步:读写网络事件
new Thread(()->{
try {
handleClientReadWrite(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
}).start();
}
} finally {
serverSocket.close();
}
}
就是在调用handleClientReadWrite
的时候,新增一个线程。
好了,如果看过我前面【并发专题】笔记的朋友,或者本身就了解多线程的朋友肯定知道,这么干是有性能瓶颈的。瓶颈造成的原因如下:
1:1
关系所以这个方案肯定不行的。先上个该方案的模型图让大家理解一下:
那不行,还有高级一点的方案吗?有的,用线程池咯,试试看
先上代码。这里主要修改了TestServer
类的handleConnect
方法,并且新增了一个线程池:
final static ExecutorService executorService;
static {
int cores = Runtime.getRuntime().availableProcessors();
executorService = Executors.newFixedThreadPool(cores);
}
/**
* ServerSocket上的事件处理
*/
public static void handleConnect(ServerSocket serverSocket) throws IOException {
int connectCount = 0;
try {
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("连接事件来了。 当前连接数目=" + (++connectCount));
// 第三步:读写网络事件
executorService.submit(() -> {
try {
handleClientReadWrite(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
} finally {
serverSocket.close();
}
}
虽然我这里的线程池用的是newFixedThreadPool
,不过你们也可以根据需要选择其他API。但是,无论你选择何种线程池API,这始终是一个BIO,并且它的性能受限于线程池。先来个模型图帮助大家理解:
一定要看看
一定要看看
一定要看看
BIO小结一:
大家也看到了,BIO使用还是有多种限制的,哪怕是上了多线程也一样。事实上,我相信大家也看得出来,真正让BIO受限的,其实是BIO本身。而且我们开过天眼的啊,知道有NIO这个东西,所以,关于BIO性能瓶颈的解决方案,我们也能预知道。
BIO小结二:
严格来说这里不是BIO小结,而是:Java Socket网络编程小结。
经过了【前置知识】的系统整合,我终于把OSI,TCP/IP网络模型与Java Socket网络编程结合起来理解了。以前我看BIO、NIO、甚至Netty等网络编程的时候,个人遇到的最大的两个问题是:
当然【谈流色变】这个病我还没去治,现阶段也只是解决了部分疑问。
第一个问题,多个Socket是怎么回事。我在一开始介绍Socket的时候就说过,它是对网络模型后面4层的封装,使得我们无需关心IP、端口是如何被封装的。另外,还记得一条TCP是如何标识唯一的吗?(我在【前置知识 3.1中讲了】)也正是由于这个原因,所以为了维护、标识每个客户端的唯一TCP,使用一个Socket去保存。
BIO小结三:
虽然我们一直说BIO有性能瓶颈,话语中看似有不喜的意思,但并不代表它就没用。因为它使用简单的特性,像Zookeeper等分布式中间件在集群通信方案中,都使采用了BIO。
NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 BIO 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO 被称为 no-blocking io(非阻塞IO),也有人叫new io(新IO)。这无关紧要,只是一个称呼。
NIO跟BIO相比,有2个比较显著的区别:
BIO是如何阻塞的,在哪里阻塞,我前面已经提过了。NIO则是因为把这些阻塞操作变成了非阻塞,在没有连接或者读取事件过来的时候,去做其他事情。比如:接收新的客户端的连接,或者读取事件。就是这样,达到了【一条线程管理多个客户端的连接、输入输出】
这算是NIO与BIO最大的区别。
BIO是面向流的,怎么理解呢?BIO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区;
NIO是面向缓冲区的,NIO把数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
Java的NIO并非本来就这么强大的,在没有引入多路复用之前,他也仅仅只是非阻塞版本的BIO而已。代码示例如下:
public class NioServer {
// 保存客户端连接
static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws IOException, InterruptedException {
// 创建NIO ServerSocketChannel,与BIO的serverSocket类似
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
serverSocket.configureBlocking(false);
System.out.println("服务启动成功");
while (true) {
// 非阻塞模式accept方法不会阻塞,否则会阻塞
// NIO的非阻塞是由操作系统内部实现的,底层调用了linux内核的accept函数
SocketChannel socketChannel = serverSocket.accept();
if (socketChannel != null) { // 如果有客户端进行连接
System.out.println("连接成功");
// 设置SocketChannel为非阻塞
socketChannel.configureBlocking(false);
// 保存客户端连接在List中
channelList.add(socketChannel);
}
// 遍历连接进行数据读取
Iterator<SocketChannel> iterator = channelList.iterator();
while (iterator.hasNext()) {
SocketChannel sc = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
// 非阻塞模式read方法不会阻塞,否则会阻塞
int len = sc.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array()));
} else if (len == -1) { // 如果客户端断开,把socket从集合中去掉
iterator.remove();
System.out.println("客户端断开连接");
}
}
}
}
}
模型图就不画了。相比于BIO,仅仅只是非阻塞而已,然后呢,相比于BIO的【一条线程管理一条连接】,它实现了【一条线程管理多个连接】。不过,我想细心的朋友已经看见了,非阻塞 + 【while(true)】的组合,那这样,CPU使用率不是非常高?对的!这种方案,CPU使用率很高,而且每次都轮询所有的【与客户端连接的socket
】,就导致了大量无效的遍历。
不过好在啊,JAVA的NIO实现引入了多路复用器这个玩意。
Q:什么是多路复用?
答:多路复用,即:【多路】+【复用】。【多路】指的是:多个TCP连接;【复用】指的是:复用一个或者多个线程。体现在IO中(完整的概念:IO多路复用)的意思就是,一条线程管理多个TCP连接的状态。只有当 socket 真正有读写事件时,才真正调用实际的 IO 读写操作。因为在多路复用 IO 模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket 读写事件进行时,才会使用 IO 资源,所以它大大减少了资源占用。(至于怎么实现的IO多路复用,这个是Linux内核的行为,感兴趣自己去研究)
先看看JavaNIO引入多路复用器之后的代码:
public class NioSelectorServer {
public static void main(String[] args) throws IOException, InterruptedException {
// 创建NIO ServerSocketChannel
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
serverSocket.configureBlocking(false);
// 打开Selector处理Channel,即创建epoll
Selector selector = Selector.open();
// 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务启动成功");
while (true) {
// 阻塞等待需要处理的事件发生
selector.select();
// 获取selector中注册的全部事件的 SelectionKey 实例
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
// 遍历SelectionKey对事件进行处理
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 如果是OP_ACCEPT事件,则进行连接获取和事件注册
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(false);
// 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("客户端连接成功");
} else if (key.isReadable()) { // 如果是OP_READ事件,则进行读取和打印
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
int len = socketChannel.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array()));
} else if (len == -1) { // 如果客户端断开连接,关闭Socket
System.out.println("客户端断开连接");
socketChannel.close();
}
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}
在这个代码中,其实有三个比较核心的东西,也是与上一版的NIO区别所在,他们就是NIO的三大核心组件:
Channel(通道)
:通道,是一个【应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操
作系统)】。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写Selector(多路复用器)
:选择器,它允许一个单独的线程来监视多个输入通道,你可以注册多个通道(就是上面的Channel)使用一个选择器(Selectors),然后使用一个单独的线程来操作这个选择器,进而“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道(应用程序将向 Selector 对象注册需要它关注的 Channel,以及具体的某一个 Channel 会对哪些 IO 事件感兴趣。Selector 中也会维护一个“已经注册的 Channel”的容器)Buffter(缓冲区)
:我们前面说过 JDK NIO 是面向缓冲的,Buffer 就是这个缓冲,用于和 NIO 通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。一个通道对应一个缓冲区
引入多路复用器之后的Java NIO模型如下:
(PS:我估计比较较真的同学对上面的代码中的selector
、channel
、selectionKey
会有点疑惑,这个写法上确实比较抽象。大家点开Selector
跟SelectionKey
就知道它们的关系了。我对这个设计思想也不是很熟悉,就不说了。简单来说就是:你中有我,我中有你)
NIO多路复用的代码如上,流程我就不多说了,基本上都写了注释。但还是要提一嘴,这个方案比较核心的代码在于如下:
// 创建多路复用器
Selector.open()
// 将channel注册到多路复用器上,并设置感兴趣的事件
socketChannel.register(selector, SelectionKey.OP_READ)
// 阻塞等待需要处理的事件发生
selector.select()
所以我是希望大伙可以稍微去看一下这个源码的,也帮助大家理解selector
、channel
、selectionKey
之间的关系。代码流程图如下:(下图是基于Linux操作系统的实现,Windows实现大体相似,但是实现类多少有点区别。因为Windows下没有epoll函数,只有select函数)
整体来说:JavaNIO整个调用流程就是调用了【操作系统的内核函数】来创建Socket,获取到Socket的文件描述符,再创建一个Selector对象,对应操作系统的【Epoll描述符】,将获取到的Socket连接的文件描述符的事件绑定到Selector对应的Epoll文件描述符上,进行事件的异步通知,这样就实现了使用一条线程,并且不需要太多的无效的遍历,将事件处理交给了操作系统内核(操作系统中断程序DMA来实现的),大大提高了效率。
顺带提一嘴。上面说的Linux内核函数,其实就是大名鼎鼎的epoll
函数(可曾听闻epoll
模型?)。
Reactor反应器模式是一种基于【事件驱动】的设计模式。通过将客户端请求提交到一个或者多个服务处理程序(一个非阻塞的线程),服务处理程序使用多路分配策略,派发这些请求至相关的工作线程进行处理。
在Reactor模式中,定义了如下3个角色:
它的设计模型有多种,如:单线程Reactor模型、单Reactor线程多工作线程模型、多线程主从Reactor模型。
单线程Reactor模型,如下所示:
它的业务处理流程如下:
注意,Reactor 的单线程模式的单线程主要是针对于 I/O 操作而言,也就是所有的 I/O 的accept()、read()、write()以及 connect()操作都在一个线程上完成的。
但在目前的单线程 Reactor 模式中,不仅 I/O 操作在该 Reactor 线程上,连非 I/O 的业务操作也在该线程上进行处理了,这可能会大大延迟 I/O 请求的响应。所以我们应该将非 I/O的业务逻辑操作从 Reactor 线程上卸载,以此来加速 Reactor 线程对 I/O 请求的响应。
单Reactor线程多工作线程模型,如下所示:
与单线程 Reactor 模式不同的是,添加了一个工作者线程池,并将非 I/O 操作从 Reactor线程中移出转交给工作者线程池来执行。这样能够提高 Reactor 线程的 I/O 响应,不至于因为一些耗时的业务逻辑而延迟对后面 I/O 请求的处理。
使用线程池的优势:
改进的版本中,所以的 I/O操作依旧由一个 Reactor 来完成,包括 I/O 的 accept()、read()、write()以及 connect()操作。对于一些小容量应用场景,可以使用单Reactor线程模型。但是对于高负载、大并发或大数据量的应用场景却不合适,主要原因如下:
所以,多Reactor的主从模型出现了
Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector、线程和分发的事件循环逻辑。
mainReactor 可以只有一个,但 subReactor 一般会有多个。mainReactor 线程主要负责接收客户端的连接请求,然后将接收到的 SocketChannel 传递给 subReactor,由 subReactor 来完成和客户端的通信。它的流程如下:
注意,所有的 I/O 操作(包括,I/O 的 accept()、read()、write()以及 connect()操作)依旧还是在 Reactor 线程(mainReactor 线程 或 subReactor 线程)中完成的。Thread Pool(线程池)仅用来处理非 I/O 操作的逻辑。
多 Reactor 线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个Reactor 线程来完成。mainReactor 完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给 subReactor 线程来完成与客户端的通信,这样一来就不会因为 read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多 Reactor线程模式在海量的客户端并发请求的情况下,还可以通过实现 subReactor 线程池来将海量的连接分发给多个 subReactor 线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。
关于epoll
,虽然这个属于拓展,但是因为知乎上看到了一个硬核大佬写的文章,从硬件层面开始给大家推演epoll
的演进变化,实在是太牛逼了,而且讲得通俗易懂。如果你又恰好完整看了我的【前置知识】,个人感觉阅读起来不会很困难(当然硬件知识直接跳过),所以我还是比较建议大伙可以学习学习的。文章传送门:《如果这篇文章说不清epoll的本质,那就过来掐死我吧》
感谢知乎大佬【作者:罗培羽】的文章《如果这篇文章说不清epoll的本质,那就过来掐死我吧》