JAVA网络编程

什么是 Socket?

Socket(套接字) 是网络通信的端点,不管是客户端还是服务端都需要这个端点,用于发送和接收数据。它封装了TCP/IP 协议栈,使得开发者无需关心底层网络细节,只需调用 API 即可实现网络通信。Socket 通信通常采用 C/S(客户端/服务器)模型,服务器端:监听某个端口,等待客户端连接。客户端:主动向服务器发起连接请求,建立通信通道。Socket 提供了一种端到端的通信机制,使得应用程序可以通过 IP 地址 + 端口号 的方式建立连接并传输数据。

在 Java 中,Socket 网络编程主要基于 TCP/IP 协议栈,建立连接的方式主要有两种:面向连接的 TCP 方式和无连接的 UDP 方式,其中 TCP 是最常用的连接方式。

TCP 是一种可靠的、面向连接的协议,其连接建立需要经过 "三次握手" 过程。Java 中通过Socket(客户端)和ServerSocket(服务器端)实现。

首先要建立起连接,然后象征性写个1进去,传输也是需要通过流,所以调用socket.getOutputStream()。

java 复制代码
public class Client {
    private Socket socket;

    public Client(){
        try {
            System.out.println("connecting");
            socket=new Socket("localhost",8088);
            //socket=new Socket("176.55.4.34",8088);
            System.out.println("finish");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start()
    {
        try {
            OutputStream os=socket.getOutputStream();
            os.write(1);
        } 
        catch (IOException e)
        {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Client client=new Client();
        client.start();
    }
}

在new一个Socket对象的时候,第一个参数给想要连接的ip地址,而想要连接到本地也不需要特地去查自己的网络ip是多少,只需要给个localhost即可;第二个参数表示服务器监听的端口号(范围是 0-65535),用于区分同一台服务器上的不同网络服务。端口号的作用是:当客户端连接到服务器的 IP 地址后,通过端口号找到服务器上对应的应用程序(如 Web 服务常用 80 端口,FTP 常用 21 端口,此处 8088 是自定义的服务端口)。

如果想让别人连接自己的服务器,仍需要得知自己的ip地址,按住win+R后回车,会弹出一个终端,在里面输入命令ipconfig回车,就可以看到本机的ip地址。

java 复制代码
public class Server {
    private ServerSocket ss;

    public Server()
    {
        try {
            System.out.println("正在启动服务端");
            ss=new ServerSocket(8088);
            System.out.println("启动服务端成功");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start()
    {
        try {
                System.out.println("等待客户端链接");
                Socket socket = ss.accept();
                System.out.println("一个客户端链接成功");
                InputStream is=socket.getInputStream();
                int x=is.read(bytes);
                System.out.println(x);
            }   catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Server server=new Server();
        server.start();
    }
}

这样在启动服务端后就会提示正在等待客户端连接,这时候再去启动客户端,就可以得到客户端输入的那个1了。

如果想要接收文本,根据流的传输特性,那就要将文本字符串转为字节流进行传输。但是这个字节流有到底有多少个字节,服务端是不知道的,所以应提前传一个长度,告诉服务端应该先创建这么大的一个字节数组。

java 复制代码
String s = sc.nextLine();
if("exit".equals(s))
    break;
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
os.write(bytes.length);
os.write(bytes);
java 复制代码
byte[] bytes = new byte[len];
is.read(bytes);
String message = new String(bytes, StandardCharsets.UTF_8);
System.out.println("客户端说:" + message);

但是想让它的功能更齐全,要让客户端发送消息,服务端收到后客户端可以继续发送信息,而不是发送了一次信息就结束了。

那就让客户端进入死循环,直到输入的是exit就退出。而服务端就只能根据流中是否还有内容来判断是否结束循环,如果读到了-1就说明读到了流的结尾。

java 复制代码
public class Client {
    private Socket socket;

    public Client()
    {
        try {
            System.out.println("connecting");
            socket=new Socket("localhost",8088);
            System.out.println("connect finish");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start()
    {
        try {
            Scanner scanner = new Scanner(System.in);
            OutputStream os=socket.getOutputStream();
            while(true)
            {
                String input=scanner.nextLine();
                if("exit".equals(input))
                    break;
                byte[] bytes =input.getBytes(StandardCharsets.UTF_8);
                os.write(bytes.length);
                os.write(bytes);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) {
        Client client = new Client();
        client.start();
    }
}
java 复制代码
public class Server {
    private ServerSocket ss;

    public Server()
    {
        try {
            System.out.println("启动服务器中");
            ss=new ServerSocket(8088);
            System.out.println("启动服务器成功");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start()
    {
        try {
            System.out.println("waiting connect");
            Socket socket = ss.accept();
            System.out.println("finished connect");

            InputStream is=socket.getInputStream();
            int len;
            while ((len= is.read())!=-1)
            {
                byte[] bytes = new byte[len];
                is.read(bytes);
                String s = new String(bytes, StandardCharsets.UTF_8);
                System.out.println("服务器说:"+s);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
        Server server = new Server();
        server.start();
    }
}

在socket网络编程中,为了能让程序能同时响应多个连接请求并处理数据,行处理多个客户端连接,消除 IO 阻塞导致的效率瓶颈,提高资源利用率和程序响应速度,采用前面的多线程处理。

在服务端的start里面创建线程,随后调用该线程的start,同时新定义一个ClientHandler类实现runnable接口,把想要服务端接收消息的这个工作放到这里面运行。

java 复制代码
public class Server {
    private ServerSocket serverSocket;

    public Server(){
        try {
            System.out.println("正在启动服务端...");
            serverSocket = new ServerSocket(8088);
            System.out.println("服务端启动成功!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start(){
        try {
            while(true) {
                System.out.println("等待客户端链接...");
                Socket socket = serverSocket.accept();
                System.out.println("一个客户端链接了!");
                //启动一个线程来处理该客户端交互
                ClientHandler handler = new ClientHandler(socket);
                Thread t = new Thread(handler);
                t.start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Server server = new Server();
        server.start();
    }
 private class ClientHandler implements Runnable{
        private Socket socket;

        public ClientHandler(Socket socket){
            this.socket = socket;
        }

        @Override
        public void run() {
            try {
                InputStream in = socket.getInputStream();
                int len;
                while((len = in.read())!=-1) {
                    byte[] data = new byte[len];
                    in.read(data);
                    String message = new String(data, StandardCharsets.UTF_8);
                    System.out.println("客户端说:" + message);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

三次握手和四次挥手

三次握手是 TCP 协议建立连接的核心过程,用于在客户端和服务器之间建立可靠的双向通信链路。它的本质是通过三次交互,让双方确认彼此的 "发送" 和 "接收" 能力均正常,从而为后续的数据传输奠定基础。

第一次握手:客户端向服务端发送一个带有 SYN(同步序列编号)标志的数据包,并生成一个随机的初始序列号(假设为 seq = x)。服务器状态从 "关闭状态" 进入 "同步收到状态(SYN-RCVD)。服务器确认 "客户端能发送,自己能接收"(因为收到了客户端的 SYN)。第一次握手失败是 TCP 连接建立阶段最常见的问题之一,本质是 "客户端的连接请求未被服务器正确接收或处理"。TCP 通过超时重传机制尽可能规避临时网络故障,若最终失败则向应用层反馈具体错误,确保连接建立的可靠性和透明度。

第二次握手:是从服务端返回一个带有 SYNACK(确认)标志的数据包给客户端。ACK = x + 1(表示已收到客户端的序列号 x,下次期望接收 x+1);服务器自己生成一个随机初始序列号 seq = y。客户端从 "同步发送状态(SYN-SENT)" 进入 "建立连接状态(ESTABLISHED)"。客户端确认 "服务器能发送,自己能接收"(因为收到了服务器的 SYN+ACK)。假如只有两次握手:服务器收到旧请求后会直接建立连接,但客户端早已放弃,导致服务器资源浪费。

第三次握手:客户端发送一个带有 ACK 标志的数据包给服务端,其中 ACK = y + 1(表示已收到服务器的序列号 y,下次期望接收 y+1)。服务器状态从 "SYN-RCVD" 进入 "ESTABLISHED",从此连接正式建立。服务器确认 "客户端能接收,自己能发送"(因为收到了客户端的 ACK)。

最终双方确认:双向通信的发送和接收通道均无问题。三次握手时,服务器必须收到客户端的第三次 ACK 才确认连接,而旧请求的第三次 ACK 不会出现(客户端已忽略),因此能过滤无效请求。

如果直接强制断开连接(如进程崩溃、网络突然中断、主动关闭 socket 但不发送 FIN 包等)会导致连接状态异常和资源泄漏,而编译器就直接报错了。不执行四次挥手直接断开,本质是破坏了 TCP 的 "有序释放" 机制,会导致未断开的一方因 "不知情" 而长期占用资源,甚至引发系统级问题(如服务器 FD 耗尽)。四次挥手的核心意义就是通过双向确认,确保双方都能 "优雅释放" 资源,避免此类问题。实际开发中,需通过保活机制、超时设置等手段,降低强制断开的影响。

第一次挥手 :客户端向服务端发送一个带有 FIN(结束)标志的数据包,序列号为 seq = u(表示客户端已完成数据发送,请求关闭连接)。客户端状态从 "ESTABLISHED(已连接)" 进入 "FIN-WAIT-1(等待结束 1)" 状态,等待服务器的确认。

长时间未收到 ACK,客户端会触发超时重传机制。服务器因未收到 FIN,会一直保持ESTABLISHED状态,占用资源(如文件描述符),成为 "僵尸连接"。若重传多次后仍无响应,客户端会判定连接异常,主动关闭连接,进入CLOSED状态。

第二次挥手 :服务器会返回一个带有 ACK(确认)标志的数据包,确认号为 ACK = u + 1,序列号为 seq = v(表示已收到客户端的 FIN 请求)。服务器状态从 "ESTABLISHED" 进入 "CLOSE-WAIT(关闭等待)" 状态,此时服务器仍可向客户端发送未完成的数据。客户端收到 ACK 后,从 "FIN-WAIT-1" 进入 "FIN-WAIT-2(等待结束 2)" 状态,等待服务器的 FIN 请求。

此时服务端可能还有数据要发送给客户端(比如未发送完的响应数据),所以不会立即发 FIN。服务端会继续处理应用程序的数据,直到这一块的所有数据发送完毕。

分块传输本质是在网络限制、资源效率、可靠性之间做平衡:通过将大数据拆分为小块,适配底层网络特性、降低资源消耗、配合 TCP 的控制机制,最终实现高效、稳定的传输。这也是为什么 Socket 编程中,通常会用循环读取 / 写入的方式(如每次读取 1024 字节)分块处理数据,而不是一次性传输。服务端应用程序调用 close()shutdown() 时,操作系统才会触发 FIN(第三次挥手)。

第三次挥手 :当服务器确认自己的数据已发送完毕,发送一个带有 FIN 标志的数据包,序列号为 seq = w,确认号仍为 ACK = u + 1。当服务器确认自己的数据已发送完毕,发送一个带有 FIN 标志的数据包,序列号为 seq = w,确认号仍为 ACK = u + 1。服务器状态从 "CLOSE-WAIT" 进入 "LAST-ACK(最后确认)" 状态,等待客户端的最终确认。

第四次挥手 :客户端返回一个带有 ACK 标志的数据包,确认号为 ACK = w + 1,序列号为 seq = u + 1。含义:"服务器,我收到你可以关闭连接的通知了,现在确认关闭。"客户端状态从 "FIN-WAIT-2" 进入 "TIME-WAIT(时间等待)" 状态,等待一段时间(通常为 2MSL,即两倍的最大报文段寿命,约 1-4 分钟)后关闭连接。服务器收到 ACK 后,从 "LAST-ACK" 进入 "CLOSED(已关闭)" 状态,连接正式关闭。

很多人可能会觉得第四次挥手显得有点多余,但这可能导致客户端端口被长期占用,短期内无法复用该端口建立新连接(尤其客户端频繁断开重连时,可能导致端口耗尽)。

四次挥手的问题核心是数据包丢失 / 延迟和状态同步失败。TCP 通过超时重传(确保关键包被接收)、TIME-WAIT 状态(避免旧数据干扰)、保活机制(检测死连接)等机制缓解这些问题,但仍需应用层配合(如合理设置超时、主动释放资源),才能确保连接 "优雅关闭"。

"三次握手过程中,如果第三次 ACK 丢失,服务器会怎么做?"

坑点:误以为服务器直接关闭,忽略重传机制。正确回答:服务器处于SYN_RECV状态,会启动重传计时器,每隔一段时间重传SYN+ACK(通常重传 3-5 次);若始终未收到 ACK,服务器会释放连接,不再等待。

"初始序列号(ISN)是固定的吗?为什么?"

坑点:认为 ISN 固定(如 1),但其实并不是固定为1,同时忽略安全风险。正确回答:ISN 不是固定的,而是随机生成的(由系统根据时间、随机数等动态计算)。若固定,攻击者可能猜测后续序列号,伪造报文攻击(如 TCP 会话劫持),随机 ISN 可降低风险。

"服务器出现大量 CLOSE_WAIT 状态,可能的原因是什么?"

坑点:归咎于网络问题,忽略应用层逻辑。正确回答:CLOSE_WAIT 是服务器收到 FIN 并回复 ACK 后,未发送自己的 FIN 时的状态,说明应用程序未调用 close () 关闭连接(如代码漏写、处理逻辑阻塞导致无法执行关闭)。需检查应用程序是否及时释放连接。