概述
在现代网络编程的广阔天地中,Java Socket API
为我们提供了强大的工具,以实现客户端与服务器之间的高效通信。本文将带您深入Linux
系统的内核,揭示Java Socket
编程背后的系统调用机制,并详细解读TCP
连接状态的每一个关键阶段。通过实际的代码示例和系统调用的深入分析,我们将一探究竟,理解在Linux
环境下,Java Socket
是如何完成其魔法般的通信任务的。
命令介绍
在深入Java Socket
的内部工作之前,让我们先来熟悉几个Linux
系统调用和网络命令,这些工具将帮助我们监视和分析网络活动。
- lsof -p [pid] :这个强大的命令使我们能够窥视进程的文件描述符,揭示系统资源的分配情况。
- netstat -natp :提供了一个全面的视图,展示当前
TCP/IP
网络连接的状态,以及相关的协议统计信息。- -n:以数字形式显示地址和端口号。
- -a:显示所有连接和监听端口。
- -t :显示
TCP
传输协议的连线状况。 - -p :显示使用
Socket
的程序识别码和程序名称。
- strace -ff -o :作为系统调用的侦探,它能够跟踪进程的每一个系统调用和信号,为我们提供详尽的执行细节。
- -ff:输出所有进程的跟踪结果到相应的文件中。
- -o [filename] :将输出写入指定文件。
- -p [pid] :跟踪指定进程。
- tcpdump -S -nn -i eth0 port 9090 :这个网络包分析工具允许我们根据定义捕获网络上的数据包,为我们提供了深入网络层面的洞察力。
- -S :列出
TCP
关联数的绝对数值。 - -n:不解析主机的网络地址。
- -i [interface] :指定网络截面。
- port [port] :指定端口。
- -S :列出
TCP连接常见状态
TCP连接的状态管理是确保数据可靠传输的核心。以下是TCP连接在其生命周期中可能经历的状态,每个状态都代表了连接的一个特定阶段:
- LISTEN:服务器在此状态下等待接受来自远程TCP端口的连接请求。
- SYN-SENT:服务器已发送连接请求,等待远程TCP的确认。
- SYN-RECEIVED:服务器已接收并确认了连接请求,等待最终的确认。
- ESTABLISHED:连接已成功建立,数据传输可以开始。
- FIN-WAIT-1 & FIN-WAIT-2:服务器准备关闭连接,正在发送结束信号。
- CLOSE-WAIT:服务器已接收到关闭请求,等待应用程序关闭。
- CLOSING:双方均已发出关闭请求,等待最终确认。
- LAST-ACK:服务器已发送关闭确认,等待远程TCP的最终确认。
- TIME-WAIT:服务器已关闭连接,等待足够的时间以确保对方收到最终确认。
- CLOSED:连接已完全关闭,所有资源已释放。
Java Socket编程:实战演练
接下来,我们将通过一系列精心设计的Java
代码示例,展示如何在Linux
环境下使用Java Socket API
实现服务器和客户端之间的通信。
服务端的构建与监听
我们的服务器端程序首先创建一个ServerSocket
实例,绑定到指定的端口,并开始监听客户端的连接请求。通过循环和多线程的策略,服务器能够同时处理多个客户端的连接。
客户端的连接与通信
客户端程序则简单直接,它建立到服务器的连接,并发送消息。通过这个简单的行为,我们展示了客户端如何与服务器进行交互。
服务端代码
java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerSocketBIO {
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(9090);
System.out.println("--server start--");
while (true) {
//在accept之前,先阻塞住直到按下任意键,方便分析调用流程
System.in.read();
Socket client = serverSocket.accept();
System.out.println("---accept client ---");
new Thread(() -> {
InputStream inputStream;
try {
inputStream = client.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
while (true) {
System.out.println("*wait client send data...*");
String readLine = bufferedReader.readLine();
if (readLine.equals("quit")) {
System.out.println("---client down...---");
bufferedReader.close();
inputStream.close();
client.close();
break;
} else {
System.out.println("recv client data:" + readLine);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端代码
java
import java.io.*;
import java.net.Socket;
public class SocketCli {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("10.0.0.101", 9090);
System.out.println("client start...");
OutputStream outputStream = socket.getOutputStream();
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream));
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
String readLine = reader.readLine();
if (readLine.equals("quit")) {
bufferedWriter.write(readLine);
bufferedWriter.newLine();
bufferedWriter.flush();
System.exit(-1);
} else {
bufferedWriter.write(readLine);
bufferedWriter.newLine();
bufferedWriter.flush();
}
}
}
}
系统调用追踪与分析
接下来我们通过启动tcpdump
捕获数据包,然后观察TCP
三次握手的过程。同时,利用strace
工具,我们追踪了服务端程序的系统调用,从而揭示了Java Socket API
背后的Linux
系统调用细节。
开启tcpdump
抓取数据包
1. 启动服务端
查看strace输出的out文件,分析如下:
- 服务端创建ServerSocket时,会通过调用socket函数完成,并得到一个FD=5。
- 通过bind函数,将5绑定到9090端口上。
- 通过listen函数,开启监听。
- 通过write打印输出。
- 最终阻塞在read函数中,等待任意键输入,阻塞在这行代码System.in.read()。
2. 状态为LISTEN
查看为服务端分配的文件描述符,FD5
对应一个TCP
连接,并且状态为LISTEN
。
3. 启动客户端
4. 完成三次握手
注意此时服务端并没有通过accept
接收客户端的请求,但是在TCP
层面双方已经完成了三次握手(意味着已经可以进行数据传输了)。
5. 服务端状态为ESTABLISHED
服务端建立了连接,因为没有accept
,所以也没分配PID
,但是状态已经为ESTABLISHED
了。
6. 客户端状态也为ESTABLISHED
再去查询客户端,客户端也建立连接,并分配了PID
,状态为ESTABLISHED
。
7. 服务端accept
服务端键入任意键,执行serverSocket.accept()
代码。
accept
函数对应的系统调用过程大致如下:
- 通过阻塞式函数
poll
,等待FD=5
的文件描述符就绪,如果没有客户端请求到来,则会一直阻塞在这个方法上(也就是serverSocket.accept()
,这也是一个阻塞点)。- 调用
accept
函数,创建一个与10.0.0.101
建立连接的socket
,并返回一个引用这个socket
新的FD=6
。
accept
之后,代码中是直接创建了一个新的线程处理,所以当java中调用new Thread
时,实际上在linux中通过clone
这个函数完成的,并返回了新的线程pid -> 5667
。- 之后继续阻塞在
read
函数(也就是回到了代码中的System.in.read()
),等待任意键输入。
- 此时再来看服务端打开的文件信息时,多了一条
FD=6
的TCP信息。
8. 完成PID分配
之前未分配PID
,现在也已经完成了分配。
再来查看strace
跟踪到的5667
这个新创建出来的线程,阻塞在了recvfrom
函数中,也是bufferedReader.readLine()
这行代码,等待socket
中的消息到来。
9. 客户端发送数据
现在让客户端发送一点数据
服务端可以正常接收
5667
线程接收到输出后,继续等待新的数据到来。
10. 启动多个客户端
现在让我们再启动一个客户端,服务端正常接收连接。
服务端依然通过clone
函数,创建一个新的线程, 并且pid -> 5674
。
服务端进程中又多了一条FD=7
的TCP信息。
正常建立了连接。
新的线程同样等待数据到来。
11. 客户端下线
最后我们让一个客户端下线,观察netstat
,此时下线的客户端与服务端的连接状态为TIME_WAIT
。
分配的FD=6
也没了。
总结
当服务端创建一个socket
并绑定到9090
端口上时,系统会为服务端进程分配一个FD
并专门用来监听客户端的请求,当有客户端连接时,即使服务端没有accept
,也会完成三次握手并建立连接,只不过对于服务端来说此时建立的连接并没有分配到某个具体的PID
上,一旦服务端调用accept
接收客户端的连接后,就会创建一个新的FD
,专门用来处理服务端与客户端数据的交互。
通过演示我们也能看到在传统BIO
模式下,服务端的accept
和read
都会导致线程阻塞,所以我们让主线程专门用来监听客户端的请求,把监听到的请求全部交给一个新的线程去处理,这样实现了一个服务端能同时接收多个客户端的需求,但是你始终不能无限的创建线程,它始终会有瓶颈,所以之后也就出现了NIO
,多路复用等IO
模型,在这些模型下可以完成一个线程同时处理多个客户端的请求。
至此也就完成了最简单的BIO
模式下的系统调用分析,大家可以参考文章,自己进行实验。
感谢:点赞、收藏、评论