上一篇通过反射把类信息与对象操作串了起来。现在进入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请求、状态码判断和响应读取。
👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊