计算机网络
基础结构
OSI 7层模型:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层
应用层、表示层、会话层是应用程序维度的,包含应用程序特有的协议,例如http协议、https协议可以使用get、post等方法,而redis不能使用get、post只能使用redis特有的方法,比如keys *
传输层、网络层、数据链路层、物理层是底层运行环境实现的协议,是不同应用之间通用的
连接本质上是双方开辟内存空间适配数据格式
可以通过nc指令建立底层连接,然后执行应用程序的协议,例如:
nc www.baidu.com 80 # 建立底层连接
GET / HTTP/1.0 # 应用层通过HTTP协议进行访问
nc localhost 6379 # 建立与本地redis的连接
key gty # 应用层通过redis的协议操作
tcp协议 - 传输层通用协议
tcp和udp
在互联网中,两台计算机想要通信,必须要有一个通用的格式,就像寄信,最外层是信封,信封是有邮票,写着寄件人、收件人、邮编,里面是信纸(数据)。这个层层固定的格式就是网络协议,不通网络层次的网络协议负责内容不同,有的协议保证数据不丢失、不乱序、有的层处理网络异常等等,所有层一起联合起来一起保证通信的可靠与通畅
传输层有两种协议:tcp协议和udp协议,都是通用的
tcp协议是一种面向连接的,点对点通信协议,负责的职责是保证通信的可靠性,它工作的核心是建立这条逻辑连接。tcp协议规定了一些校验字段,通信双方以报文中的这些字段来进行可靠的通信,从而保证数据正确,不会乱序
tcp建立连接的过程 - 三次握手
三次握手即三次请求SYN/应答ACK报文交换:
第一次:客户端向服务器发送请求SYN,携带一个随机序列号seq=a,携带SYN=1
第二次:服务器收到请求后,回复应答ACK,携带seq=a+1,即SYN中序列号+1,同时发送自己的SYN,携带随机序列号seq=b,携带SYN=1
第三次:客户端收到服务器的请求,回复应答ACK,携带seq=b+1
这时,客户端回发ACK后,就开始发送数据了,服务端收到ACK后就开始开辟内存资源适配数据格式,即做好了接收数据的准备
这里如果客户端发送的ACK没有被服务器接收怎么办?
客户端依旧发送数据包,但服务端无法接收。
服务端没收到ACK,会重发第二次的SYN+ACK,客户端收到后重发ACK,知道建立连接
在上面三次握手的流程中,客户端实际上向服务器发送了两个连接请求,一次请求syn,一次应答ack,这两个请求分别存放在半连接队列net.ipv4.tcp_max_syn_backlog
中和全连接队列net.core.somaxconn
中
当net.ipv4.tcp_max_syn_backlog
设置过小,服务器就会丢弃过量的半连接,客户端长期无法建立连接,就会产生Connetion time out异常
当net.core.somaxconn
过小,服务器就会丢弃过量的全连接,客户端认为连接建立了,但是长期收不到服务器数据,就会产生Read time out异常
tcp的传输过程
在TCP连接建立后,数据发送方会将要发送的数据划分为较小的报文段,并为每个报文段分配一个序列号,序列号单调递增。数据接收方通过检查序列号来确定接收到的数据的顺序。TCP协议要求数据接收方必须按照序列号的顺序将数据重新组装成完整的数据流。
同时,TCP协议还使用确认机制来确保数据的完整性。数据接收方会发送确认报文段(ACK)给数据发送方,其中包含确认号(Acknowledgment Number),表示已成功接收到的数据的最高有效序列号。数据发送方会根据收到的确认号来确定哪些数据已经被成功接收,从而维护数据的有序性和完整性。
如果数据发送方没有收到确认报文段,或者接收方检测到数据包的丢失、损坏等情况,TCP协议会进行重传,确保数据的可靠传输。
tcp连接释放过程 - 四次挥手
连接释放是单方面的,因此相比于三次握手,服务端不是ACK和SYN一起发了,而是ACK之后,择机主动发SYN,同时SYN里面携带的是标志位:
步骤一:客户端向服务器发送请求SYN,携带一个随机序列号seq=a,携带FIN=1,进入FIN_WAIT_1状态
步骤二:服务器收到请求后,回复应答ACK,携带seq=a+1,即SYN中序列号+1,同时携带ACK=1,进入CLOSE_WAIT状态
步骤三:服务端接收到客户端SYN后不立即关闭,而是先继续处理未完成的数据传输,指导完成后,发送一个关闭请求SYN,携带seq=b,FIN=1,自己进入LAST_ACK状态
步骤四:客户端收到服务端的请求,应答ACK,携带seq=b+1,携带ACK=1,自己进入TIME_WAIT状态
除了正常关闭的四次挥手,还有可能出现异常连接关闭的场景,这时候客户端收到的就是RST标志位
RST标示复位、用来异常的关闭连接,发送RST包关闭连接时,不必等缓冲区的包都发出去,直接就丢弃缓冲区中的包,发送RST, 而接收端收到RST包后,也不必发送ACK包来确认。
发送RST包的场景包括:
建立连接的SYN到达某端口,但是该端口上没有正在 监听的服务。
TCP收到了一个根本不存在的连接上的分节。
请求超时。 使用setsockopt的SO_RCVTIMEO选项设置recv的超时时间。接收数据超时时,会发送RST包
子网掩码
子网掩码的作用是划分局域网的ip地址,指的是这个局域网下面共有多少可用ip
划分子网掩码的目的是让一个公网ip下面能有更多的节点接入,假设我获得了一个ip:192.168.1.1,如果我只有一个设备需要连接,那么这个设备独占192.168.1.1即可
如果我有10个设备需要连接,那么他们共同使用一个公网ip,那如果有从外部向内部访问的请求,或内部互相访问的请求,岂不是没有ip可用了,这时候就可以使用子网掩码了,只要有10个子ip标定这10个设备,外部请求到192.168.1.1的请求就会被路由器再次解析并路由到对应的设备上
假如我有一个子网掩码:10.38.184.0/23,我该如何算其子网的真实网段呢?
首先将10.38.184.0这个ip转为二进制:
00001100 00100110 10111000 00000000
然后明确23位掩码的意义:即从左到右,23位数是固定的
因此,将ip转化为00001100 00100110 1011100x xxxxxxxx
对于这个ip,其范围就是00001100 00100110 10111000 00000000 ~ 00001100 00100110 10111001 11111111
转回ip形式,也就是10.38.184.0 ~ 10.38.185.255了
即子网一共有512个ip可供分配
但是我们在路由器中配置子网掩码,为什么经常是255xxx的形式呢,我们可以针对上面的掩码作用做一下转换:
还记得23位掩码的作用是前23位数固定,如果想让一个二进制数做与运算结果不变,那么其算子的前23位就必须是1,即:
11111111 11111111 11111110 00000000
这样,/23的写法就变成了255.255.254.0,即我们在路由器中经常看到的子网掩码写法了
网络地址与广播地址
上面了解到子网掩码是为了划分子网的,而子网的作用是让这一堆设备共享一个公网ip
那么如果这些设备全都是平级的,请求这个公网ip,大家就都可以获取请求内容了,外部也不知道这个请求是发给谁的
因此在这一堆设备中,必然有至少一个设备是负责内部转发和与公网进行数据传输的,这个ip是固定的,也就是网络地址,是这个子网中的第一个子网ip,例如上面的10.38.184.0
同时,子网还承担着内部互相访问的能力,因此还得至少有一个设备负责内部广播,所有设备都可以接收到它的消息,这个ip也是固定的,也就是广播地址,是这个子网中的最后一个子网ip,例如上面的10.38.185.255
因此,一个/23位掩码下面,能分配的ip共512个,但实际上可用于子网设备的要减去2个,即510个
网络地址在一个网段中是唯一的,因此相同网段可以视为是相同的子网,而不同的网段则是不同的子网,之间不能互相通信,例如:
PC1的ip为192.168.0.3/26,PC2的IP为192.168.0.192/26,双方是否能互相通信?
对ip和掩码做与运算,得到PC1网段中的网络地址是:192.168.0.0,而PC2的网络地址是192.168.0.192,可知二者是不同的网段
但是有一个问题:会不会有两个网段具有相同的网络地址呢?答案是不会的,因为具有相同网络地址的网段,就是一个网段,例如PC3是192.168.0.4/26,其实就是跟PC1是一个网段
那就又有一个问题了,假如PC1和PC3被不同的组织持有呢?实际上这是不可能的,因为在公网环境中,根据互联网协议,任何公网IP段全球只能分配给一个组织,否则就会出现全球网络的崩溃(个人私网除外,私网是局域网,那随便分配)
IP地址的划分
上面提到,不同的网段会划分给不同的组织,不会重复,根据这一划分,IP地址也分成了几类:
A类:10.0.0.0 ~ 10.255.255.255,保留给政府机构
B类:172.16.0.0 ~ 172.31.255.255,分配给中等规模公司
C类:192.168.0.0 ~ 192.168.255.255,分配给有需要的任何人
D、E类则是网管专业配置地址和科研实验相关,使用率就比较低了
网关和路由器
前面也提到,同一网段内的设备可以互相通信,但是不同网段内的设备就无法互相通信了
那么既然都在互联网上面,需要通信怎么办呢?答案是在这两个网段内分别配置网关
网关定义了边界路由,即ip地址的出口,可以理解成国家之间的交界往返地,中国 <-> 珠穆朗玛峰 <-> 尼泊尔
而将网络请求从一个地址发送到另一个地址的过程就叫路由,路由必须是相邻节点之间转发,每个路由节点都维护了路由表,网络请求将从网络拓扑中找到目标网关最近的线路,并且验证相邻节点和路由表不断转发
而家用路由器、光猫一般承担了网关的功能,还担任路由器的能力
一台电脑的默认网关是不可以随随便便指定的,必须正确指定,否则一台电脑就会将数据包发给不是网关的电脑,从而无法与其他网络的电脑通信
Socket - Java中的网络通信
Socket是什么
tcp协议规定了连接建立的流程和规范格式,Java将这一格式封装并提供了一套简化的api,也就是java.net包下的Socket
一个Socket就是一条TCP连接,Socket是对TCP/IP通信过程的一个抽象,它将TCP/IP里面复杂的通信逻辑进行 封装,对用户来说,只要通过一组简单的API就可以实现网络的连接和通信。
Socket虽然是网络通信的基础,但是现在已经有很多成熟的框架(netty)对其进行了封装,很少有项目直接手写Socket连接了
Socket客户端与服务端实现案例
服务端
public class SocketServer {
public void processMessage() throws IOException {
// 1. 服务器建立socket,绑定端口
ServerSocket serverSocket = new ServerSocket(8888);
serverSocket.setSoTimeout(1000);
// 2. 等待客户端建立连接
Socket socket = serverSocket.accept();
System.out.println("client connected: " + socket.getInetAddress().getHostAddress());
// 3. 获取输入输出流
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
// 4. 接收并处理数据
String clientMessage = reader.readLine();
System.out.println("Received message: " + clientMessage);
// 5. 回发响应给客户端
String response = "OK, I'm Received!";
writer.println(response);
System.out.println("Sending response: " + response);
// 6. 关闭连接
socket.close();
serverSocket.close();
}
}
一个SocketServer实现了以下步骤:
建立ServerSocket绑定端口监听
执行
accept()
方法阻塞等待客户端创建连接当客户端创建了连接,就可以获取socket对象
通过socket对象获取输入流读取数据,通过socket对象获取输出流写入数据,完成回发
关闭连接
客户端
public class SocketClient {
public void sendMessage() throws IOException {
// 1. 与指定的ip、端口建立连接
Socket socket = new Socket("localhost", 8888);
// 2. 获取输入流和输出流
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
// 3. 发送数据给服务器
String message = "Hello Server";
writer.println(message);
// 4. 接收响应
String response = reader.readLine();
System.out.println("Received response: " + response);
// 5. 关闭连接
socket.close();
}
}
一个SocketClient实现了以下步骤:
与指定ip、port建立Socket连接,获取Socket对象
通过socket对象获取输入流读取数据,获取输出流写数据
关闭连接
测试
public class SocketTest {
public static void main(String[] args) throws IOException, InterruptedException {
SocketClient client = new SocketClient();
SocketServer server = new SocketServer();
// 线程1:启动客户端
new Thread(new Runnable() {
@Override
public void run() {
try {
client.sendMessage();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}).start();
// 线程2:启动服务端
new Thread(new Runnable() {
@Override
public void run() {
try {
server.processMessage();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}).start();
TimeUnit.SECONDS.sleep(5);
}
}
基于测试类,做了一些测试,可以得到以下结论:
client建立Socket连接的顺序可以在server监听之前,但这个时间并不是无限制的,如果client长期无法建立连接(注释线程2),会抛出异常
java.net.ConnectException: Connection refused: connect
如果是单线程场景,并且把server的启动放在client启动之前,server会阻塞后续流程执行,阻塞的来源是
Socket#accept()
方法,因此测试尽量使用多线程执行,这时client和server谁在前谁在后都无所谓了这里想让
accept()
方法不会永久性地阻塞,可以通过在服务器端设置超时时间:serverSocket.setSoTimeout()
使用多线程场景,启动server,但不让client建立连接,Socket#accept()方法会持续阻塞主线程,使main方法无法退出,因此可知:
accept是阻塞的,且accept的socket并不知道其数据包是否已经收完,很可能出现因为数据包没有收完,还需要阻塞在原地等待IO继续收数据包的情况,因此java后续推出了NIO,即非阻塞io,从而对Socket进行优化
另一个是一个端口在同一时间只能处理一个客户请求,这就对服务器性能产生了挑战,一种常见的方法是使用负载均衡和多线程技术。服务器可以通过负载均衡将连接分布到多个监听端口上,并使用多线程或线程池来处理连接和读写操作。这样可以提高并发处理能力和吞吐量,减轻单个端口的压力
Socket源码分析
ServerSocket#accept
Socket s = new Socket((SocketImpl) null);
implAccept(s);
--ServerSocket#implAccept--
// accept a connection
impl.accept(si);
构造一个空socket,并且调implAccept()
,持续阻塞监听,下层调用到SocketImpl#accept()
方法,这里默认选用的是NioSocketImpl
NioSocketImpl#accept
ReentrantLock acceptLock = readLock;
int timeout = this.timeout;
long remainingNanos = 0;
if (timeout > 0) {
remainingNanos = tryLock(acceptLock, timeout, MILLISECONDS);
if (remainingNanos <= 0) {
assert !acceptLock.isHeldByCurrentThread();
throw new SocketTimeoutException("Accept timed out");
}
} else {
acceptLock.lock();
}
这一部分是加锁防止并发占用一个连接,使用ReetrenLock实现
try {
if (remainingNanos > 0) {
// accept with timeout
configureNonBlocking(fd);
n = timedAccept(fd, newfd, isaa, remainingNanos);
} else {
// accept, no timeout
n = Net.accept(fd, newfd, isaa);
while (IOStatus.okayToRetry(n) && isOpen()) {
park(fd, Net.POLLIN);
n = Net.accept(fd, newfd, isaa);
}
}
} finally {
endAccept(n > 0);
assert IOStatus.check(n);
}
这部分开始接收数据,阻塞点实际上是在Net.accept()
方法
public static native int accept(FileDescriptor fd,
FileDescriptor newfd,
InetSocketAddress[] isaa)
throws IOException;
而这里底层实际上调用的是C的方法了
参考资料
《Java编程思想(第四版)》 Bruce Eckel 著 陈昊鹏 译
评论区