精通Java Socket编程:深入剖析系统调用与TCP状态管理

概述

在现代网络编程的广阔天地中,Java Socket API 为我们提供了强大的工具,以实现客户端与服务器之间的高效通信。本文将带您深入Linux系统的内核,揭示Java Socket编程背后的系统调用机制,并详细解读TCP连接状态的每一个关键阶段。通过实际的代码示例和系统调用的深入分析,我们将一探究竟,理解在Linux环境下,Java Socket是如何完成其魔法般的通信任务的。

命令介绍

在深入Java Socket的内部工作之前,让我们先来熟悉几个Linux系统调用和网络命令,这些工具将帮助我们监视和分析网络活动。

  1. lsof -p [pid] :这个强大的命令使我们能够窥视进程的文件描述符,揭示系统资源的分配情况。
  2. netstat -natp :提供了一个全面的视图,展示当前TCP/IP网络连接的状态,以及相关的协议统计信息。
    • -n:以数字形式显示地址和端口号。
    • -a:显示所有连接和监听端口。
    • -t :显示TCP传输协议的连线状况。
    • -p :显示使用Socket的程序识别码和程序名称。
  3. strace -ff -o :作为系统调用的侦探,它能够跟踪进程的每一个系统调用和信号,为我们提供详尽的执行细节。
    • -ff:输出所有进程的跟踪结果到相应的文件中。
    • -o [filename] :将输出写入指定文件。
    • -p [pid] :跟踪指定进程。
  4. tcpdump -S -nn -i eth0 port 9090 :这个网络包分析工具允许我们根据定义捕获网络上的数据包,为我们提供了深入网络层面的洞察力。
    • -S :列出TCP关联数的绝对数值。
    • -n:不解析主机的网络地址。
    • -i [interface] :指定网络截面。
    • port [port] :指定端口。

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文件,分析如下:

  1. 服务端创建ServerSocket时,会通过调用socket函数完成,并得到一个FD=5。
  2. 通过bind函数,将5绑定到9090端口上。
  3. 通过listen函数,开启监听。
  4. 通过write打印输出。
  5. 最终阻塞在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函数对应的系统调用过程大致如下:

  1. 通过阻塞式函数poll,等待FD=5的文件描述符就绪,如果没有客户端请求到来,则会一直阻塞在这个方法上(也就是serverSocket.accept(),这也是一个阻塞点)。
  2. 调用accept函数,创建一个与10.0.0.101建立连接的socket,并返回一个引用这个socket新的FD=6
  3. accept之后,代码中是直接创建了一个新的线程处理,所以当java中调用new Thread时,实际上在linux中通过clone这个函数完成的,并返回了新的线程pid -> 5667
  4. 之后继续阻塞在read函数(也就是回到了代码中的System.in.read()),等待任意键输入。
  5. 此时再来看服务端打开的文件信息时,多了一条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模式下,服务端的acceptread都会导致线程阻塞,所以我们让主线程专门用来监听客户端的请求,把监听到的请求全部交给一个新的线程去处理,这样实现了一个服务端能同时接收多个客户端的需求,但是你始终不能无限的创建线程,它始终会有瓶颈,所以之后也就出现了NIO,多路复用等IO模型,在这些模型下可以完成一个线程同时处理多个客户端的请求。

至此也就完成了最简单的BIO模式下的系统调用分析,大家可以参考文章,自己进行实验。

感谢:点赞、收藏、评论

相关推荐
世俗ˊ10 分钟前
Spring Boot 的 WebClient 实践教程
java·spring boot·后端
lzb_kkk11 分钟前
【JavaEE】JVM
java·jvm·java-ee
夏子曦13 分钟前
java——Spring MVC的工作流程
java
暮志未晚Webgl21 分钟前
111. UE5 GAS RPG 实现角色技能和场景状态保存到存档
android·java·ue5
且听风吟72044 分钟前
JS综合解决方案5——模块化和构建工具
javascript·面试
摇滚侠1 小时前
javax.xml.ws.soap.SOAPFaultException: ZONE_OFFSET
xml·java·开发语言
yours_Gabriel1 小时前
【微服务】Nacos
java·微服务·架构
海绵波波1071 小时前
集群聊天服务器面试问题
运维·服务器·面试
户伟伟1 小时前
缓存方案分享
java·redis·缓存
脸红ฅฅ*的思春期1 小时前
Java安全—原生反序列化&重写方法&链条分析&触发类
java·安全·序列化·反序列化