Java高级篇之Socket:从建立连接到完整收发一条消息

上一篇通过反射把类信息与对象操作串了起来。现在进入Java学习路径的高级篇,从最基础的网络通信开始:让一个程序发送消息,让另一个程序接收并回复。

第一次写Socket程序,连接成功往往很快,真正容易卡住的是后面几个问题:为什么一次读取没有拿到完整消息?为什么发送方已经写完,接收方还在等?中文长度应该按字符算,还是按字节算?

本文用一个可直接运行的请求---响应程序,把连接、消息边界、编码、超时和资源关闭放在同一条流程里讲清楚。示例使用JDK21。

一、Socket负责通信端点,ServerSocket负责等待连接

IP地址 帮助找到主机,端口帮助找到主机上的服务。客户端连接某个地址和端口,服务端先绑定并监听这个地址和端口。

Java中最常用的两个入口是:

类型 职责 关键操作
ServerSocket 监听新连接 bind、accept
Socket 在一条连接上收发数据 connect、getInputStream、getOutputStream

accept()返回一个新的Socket,代表这次被接受的连接。监听Socket继续承担接待新连接的职责,返回的Socket负责和这个客户端通信。两者的生命周期应分别管理。

本文在一台机器上演示,所以绑定回环地址 ;端口传入0,让操作系统分配空闲端口,再通过getLocalPort()交给客户端。这样不需要提前挑选固定端口,也不对外网开放服务。跨机器通信时,服务端需要绑定可访问的网络接口,客户端使用对应的地址和端口。

二、先约定消息边界,再写收发代码

TCP提供有序字节流。应用写入的两条消息,接收方可能一次读到它们的一部分,也可能在一次读取中拿到多条消息的数据。一次write调用与一次read调用没有一一对应关系。

例如发送方依次写入"订单A"和"订单B"。如果没有协议,接收方看到一串字节时就无法确定哪一段属于第一条订单。

常见的边界方案有三种:

方案 接收方式 适用场景与约束
分隔符 读到换行等结束符 文本协议容易调试,正文中的分隔符需要转义或禁止
固定长度 每次收齐约定字节数 简单,但可变长度内容需要额外设计
长度前缀 先读长度,再读指定长度正文 适合变长消息,必须校验长度上限

这里选择4字节长度前缀+UTF-8正文 。长度记录编码后的字节数,不记录String.length():中文字符经过UTF-8编码后通常占多个字节,字符串长度不能直接当作网络负载长度。

发送端先把文本编码为字节数组,再写长度和正文;接收端先读4字节整数,确认大小合法,再收齐正文。这就把TCP的字节流变成应用能够处理的消息序列。

三、完整示例:在同一条连接上收发两条消息

保存为SocketDemo.java,执行java SocketDemo.java即可运行。程序在后台线程接受连接,主线程作为客户端;服务端接受一个连接并处理两条消息,随后结束。

java 复制代码
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;

public class SocketDemo {
    static final int MAX_BYTES = 4096;

    static void writeFrame(DataOutputStream out, String text) throws IOException {
        byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
        if (bytes.length > MAX_BYTES) throw new IOException("frame too large");
        out.writeInt(bytes.length);
        out.write(bytes);
        out.flush();
    }

    static String readFrame(DataInputStream in) throws IOException {
        int length = in.readInt();
        if (length < 0 || length > MAX_BYTES) throw new IOException("invalid length");
        byte[] bytes = new byte[length];
        in.readFully(bytes);
        return new String(bytes, StandardCharsets.UTF_8);
    }

    public static void main(String[] args) throws Exception {
        try (ServerSocket listener = new ServerSocket()) {
            listener.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
            listener.setSoTimeout(5000);
            try (ExecutorService worker = Executors.newSingleThreadExecutor()) {
                Future<Integer> handled = worker.submit(() -> {
                    try (Socket peer = listener.accept()) {
                        peer.setSoTimeout(3000);
                        DataInputStream in = new DataInputStream(peer.getInputStream());
                        DataOutputStream out = new DataOutputStream(peer.getOutputStream());
                        for (int i = 0; i < 2; i++) {
                            String request = readFrame(in);
                            writeFrame(out, "收到:" + request);
                        }
                        return 2;
                    }
                });
                try (Socket client = new Socket()) {
                    client.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(),
                            listener.getLocalPort()), 3000);
                    client.setSoTimeout(3000);
                    DataOutputStream out = new DataOutputStream(client.getOutputStream());
                    DataInputStream in = new DataInputStream(client.getInputStream());
                    for (String message : new String[]{"你好,Socket", "第二条消息"}) {
                        writeFrame(out, message);
                        System.out.println(readFrame(in));
                    }
                }
                System.out.println("处理消息数=" + handled.get());
            }
        }
    }
}

本地运行输出:

text 复制代码
收到:你好,Socket
收到:第二条消息
处理消息数=2

这里有几个刻意保留的设计。

1. 编码在协议边界处完成

writeFrame只把UTF-8字节数写进协议。readFrame收齐字节后才解码,不把一次底层读取当作一条完整中文字符串。UTF-8字符的字节可能跨越两次底层读取,先收齐完整消息再解码可以避开这种切分问题。

2. 长度值在申请数组之前检查

示例限制正文最多4096字节,并拒绝负长度。接收方不能相信对端给出的长度:先分配数组再判断大小,会让异常输入消耗大量内存。实际协议可以定义更大的上限,但上限需要与业务负载、并发量和内存预算一起确定。

DataOutputStream.writeInt和DataInputStream.readInt使用配套的整数表示。跨语言实现时也要约定4字节、大端序以及合法长度范围,不能只写一句"前面放一个整数"。

3. 收齐正文使用readFully

普通read允许返回少于请求数量的字节。readFully持续读取,直到指定数组填满;提前遇到流结束则抛出EOFException。因此它适合已经知道长度的帧正文。若对端只发一半就断开,程序会报错,不会把残缺消息当成成功结果。

4. flush与对端处理完成是两回事

flush()用于刷新输出流的缓冲。本例直接包装Socket输出流,保留刷新操作也便于以后添加缓冲层。但刷新不会返回"服务端业务已经完成"的保证。程序通过读取"收到:......"这个应用层响应确认服务端处理了该条请求。

示例逐条请求、逐条响应,所以无需请求ID。一旦改成多个请求同时在途,就应在协议中加入请求ID,让响应与请求准确对应。

四、程序卡住时,先确定卡在哪一种等待

网络程序中"超时"有不同含义,配置位置也不同。

等待位置 本例设置 控制的等待
客户端连接 connect(address, 3000) 建立连接
服务端接待 listener.setSoTimeout(5000) accept等待新连接
已连接Socket socket.setSoTimeout(3000) 输入流阻塞读取

Socket读取超时会抛出SocketTimeoutException;它本身不会自动关闭Socket。本例把异常交给外层退出,由try-with-resources关闭连接。SO_TIMEOUT也不限制输出流写入耗时,更不等于整次业务请求的总时限。相关API语义见Socket文档与ServerSocket文档。

尤其要留意一个现象:对端持续慢慢发送,每次底层读取都能拿到一点数据,整个帧仍可能耗时很久。对有严格总时限的业务,需要另外维护截止时间,将剩余预算传递到后续操作;不能把某一次读取超时当成全程预算。

另一个常见卡点是双方都在等待:客户端等待回复,服务端却把EOF当作请求结束标记,等客户端关闭输出。本文已经用长度前缀确定边界,因此无需等EOF。若协议选择EOF结束请求,可以用shutdownOutput()关闭本端发送方向,同时保留接收回复的能力;直接关闭输出流通常会连带关闭Socket,不适合这个目的。

五、从演示走向服务:哪些地方需要改

本例服务端只接受一个客户端,收两条消息后退出,便于把协议流程完整看清。长期运行的服务需要循环accept,并将连接交给执行器处理;同时限制活跃连接数量、每个连接的消息大小与处理时间。

不要把accept循环和耗时业务放在同一条执行路径上。否则一个客户端处理缓慢,后续客户端就要等待。可以使用线程池或虚拟线程承接连接任务,但数据库连接数、下游容量等资源仍然有限,需要独立限流。

资源关闭也要有归属:接入层管理监听Socket,连接处理代码管理已接受的Socket。连接退出后关闭Socket,其关联的输入输出流随之关闭。若要继续使用连接,不要在每读完一条消息后就关闭包装流。

最后,TCP连接成功只说明通信通道建立了。身份认证、传输加密、请求去重和业务重试属于更上层的设计。比如扣款请求超时后,客户端并不知道服务端是否已经执行,直接重发就可能产生重复操作;这时需要业务幂等键配合结果查询。

六、🧠 思维导图

#mermaid-svg-J1n2h1UxW1NomuWf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-J1n2h1UxW1NomuWf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-J1n2h1UxW1NomuWf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-J1n2h1UxW1NomuWf .error-icon{fill:#552222;}#mermaid-svg-J1n2h1UxW1NomuWf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-J1n2h1UxW1NomuWf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-J1n2h1UxW1NomuWf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-J1n2h1UxW1NomuWf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-J1n2h1UxW1NomuWf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-J1n2h1UxW1NomuWf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-J1n2h1UxW1NomuWf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-J1n2h1UxW1NomuWf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-J1n2h1UxW1NomuWf .marker.cross{stroke:#333333;}#mermaid-svg-J1n2h1UxW1NomuWf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-J1n2h1UxW1NomuWf p{margin:0;}#mermaid-svg-J1n2h1UxW1NomuWf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-J1n2h1UxW1NomuWf .cluster-label text{fill:#333;}#mermaid-svg-J1n2h1UxW1NomuWf .cluster-label span{color:#333;}#mermaid-svg-J1n2h1UxW1NomuWf .cluster-label span p{background-color:transparent;}#mermaid-svg-J1n2h1UxW1NomuWf .label text,#mermaid-svg-J1n2h1UxW1NomuWf span{fill:#333;color:#333;}#mermaid-svg-J1n2h1UxW1NomuWf .node rect,#mermaid-svg-J1n2h1UxW1NomuWf .node circle,#mermaid-svg-J1n2h1UxW1NomuWf .node ellipse,#mermaid-svg-J1n2h1UxW1NomuWf .node polygon,#mermaid-svg-J1n2h1UxW1NomuWf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J1n2h1UxW1NomuWf .rough-node .label text,#mermaid-svg-J1n2h1UxW1NomuWf .node .label text,#mermaid-svg-J1n2h1UxW1NomuWf .image-shape .label,#mermaid-svg-J1n2h1UxW1NomuWf .icon-shape .label{text-anchor:middle;}#mermaid-svg-J1n2h1UxW1NomuWf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-J1n2h1UxW1NomuWf .rough-node .label,#mermaid-svg-J1n2h1UxW1NomuWf .node .label,#mermaid-svg-J1n2h1UxW1NomuWf .image-shape .label,#mermaid-svg-J1n2h1UxW1NomuWf .icon-shape .label{text-align:center;}#mermaid-svg-J1n2h1UxW1NomuWf .node.clickable{cursor:pointer;}#mermaid-svg-J1n2h1UxW1NomuWf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-J1n2h1UxW1NomuWf .arrowheadPath{fill:#333333;}#mermaid-svg-J1n2h1UxW1NomuWf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-J1n2h1UxW1NomuWf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-J1n2h1UxW1NomuWf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J1n2h1UxW1NomuWf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-J1n2h1UxW1NomuWf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J1n2h1UxW1NomuWf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-J1n2h1UxW1NomuWf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-J1n2h1UxW1NomuWf .cluster text{fill:#333;}#mermaid-svg-J1n2h1UxW1NomuWf .cluster span{color:#333;}#mermaid-svg-J1n2h1UxW1NomuWf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-J1n2h1UxW1NomuWf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-J1n2h1UxW1NomuWf rect.text{fill:none;stroke-width:0;}#mermaid-svg-J1n2h1UxW1NomuWf .icon-shape,#mermaid-svg-J1n2h1UxW1NomuWf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J1n2h1UxW1NomuWf .icon-shape p,#mermaid-svg-J1n2h1UxW1NomuWf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-J1n2h1UxW1NomuWf .icon-shape .label rect,#mermaid-svg-J1n2h1UxW1NomuWf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J1n2h1UxW1NomuWf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-J1n2h1UxW1NomuWf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-J1n2h1UxW1NomuWf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Socket网络编程
建立连接
ServerSocket监听
Socket收发
消息协议
长度前缀
UTF-8字节
收齐与校验
等待与退出
连接和读取超时
关闭资源
服务化
并发与限流
应用层确认

七、总结

总结要点

连接与通信分工明确。 ServerSocket监听并接受连接,Socket在连接上收发数据。监听端与连接端分别管理,程序结构就清楚了。

协议决定消息边界。 TCP承载字节流,应用用长度、分隔符或固定尺寸识别消息。本文用长度前缀配合UTF-8,先检查长度,再收齐正文。

超时与资源管理决定故障时能否退出。 连接、接待和读取是不同等待点;完整业务还需要总预算、并发约束与明确的响应语义。

下一篇继续Java网络编程,介绍URL与HttpURLConnection,完成HTTP请求、状态码判断和响应读取。

👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊

相关推荐
凯瑟琳.奥古斯特1 小时前
OCR文字检测算法对比解析
开发语言·python·职场和发展
PD我是你的真爱粉1 小时前
成熟Agent系统设计深度研究报告
java·大数据·人工智能
Misnice1 小时前
Python 内置函数大全
开发语言·windows·python
美狐美颜SDK开放平台1 小时前
直播APP集成美颜SDK,如何梳理第三方组件的数据权限?
开发语言·人工智能·深度学习·音视频·第三方美颜sdk
怪我冷i1 小时前
Rust 核心语法与内存安全机制
开发语言·安全·rust
鶴哥只手遮天1 小时前
Spring AI Alibaba 深度技术系列-第 05 章 编译与执行引擎:CompiledGraph、GraphRunner与三段式 Executor
java·人工智能·开源
开开心心_Every2 小时前
免费压缩软件WinRAR无弹窗、广告干扰
java·开发语言·前端·spring·智能手机·word·winrar
计算机毕设定制辅导-无忧学长2 小时前
《基于微信小程序的个人智能刷题助手的设计与实现》
java·spring boot·mysql·微信小程序·uniapp·个人智能刷题助手系统
江沉晚呤时2 小时前
零依赖的代价与自由:我用 C# 自研了一套 Office 读写库
开发语言·c#