在 TCP Socket 编程中,我们通常不直接操作“数据包”(Packet)。

- 数据包 是一个网络层的概念,它包含了源/目的 IP 地址、端口号等完整的路由信息,是 IP 协议(如 UDP 数据报)处理的基本单位。
- TCP Socket 工作在传输层,它提供的是面向连接的、可靠的、基于字节流的通信,它不关心你发送的数据的“边界”,只负责将你写入的字节流按顺序、完整地发送到对方,并接收对方发来的字节流。
在 Java TCP Socket 编程中,我们处理的是数据流,而不是数据包,但理解 TCP 数据段的结构和 Java 如何将你的数据打包成 TCP 段,对于编写健壮的 Socket 程序至关重要。
下面我将从以下几个方面展开:
- Java Socket 的核心类与字节流
- TCP 如何将你的数据打包成“数据段”
- 如何发送和接收结构化的数据(解决粘包/拆包问题)
- 一个完整的、可运行的 TCP 通信示例
- UDP 数据报编程简介(真正的“数据包”)
Java Socket 的核心类与字节流
Java 使用 java.net 包中的类来处理网络通信,核心类有:
Socket: 代表一个客户端套接字,它用于发起连接。ServerSocket: 代表一个服务器端套接字,它用于监听并接受客户端的连接。InputStream/OutputStream: 通过Socket对象获取的输入/输出流,它们是字节流,是数据传输的通道。
关键点:getInputStream() 和 getOutputStream() 返回的是字节流。

这意味着你写入 OutputStream 的任何数据(无论是字符串、图片还是对象)最终都会被转换成字节序列,同样,从 InputStream 读取的也是一连串的字节,你需要知道如何正确地解析它们。
TCP 如何将你的数据打包成“数据段”
当你通过 socket.getOutputStream().write(byte[] data) 发送数据时,TCP 协议栈会做以下事情:
-
分段:TCP 不会立刻把所有数据都发送出去,它会将你的字节流分割成一个或多个TCP 段,分段的大小受多种因素影响,如:
- MSS (Maximum Segment Size):网络路径中允许的最大 TCP 段大小,通常与以太网的 MTU (1500字节) 有关。
- 发送方的拥塞窗口大小:这是 TCP 流量控制的核心,防止发送方数据过快导致网络拥塞。
- 接收方的接收窗口大小:接收方通过
ACK报文告诉发送方自己还能接收多少数据。
-
添加 TCP 头部:每个 TCP 段都会被加上一个 TCP 头部(通常是 20 字节),包含了:
(图片来源网络,侵删)- 源端口号和目的端口号
- 序列号:确保数据按顺序重组。
- 确认号:用于确认已收到的数据。
- 其他控制位(如 SYN, ACK, FIN 等)和校验和。
-
封装成 IP 数据包:TCP 段会被进一步封装成一个 IP 数据包,加上 IP 头部(20 字节),包含源/目的 IP 地址。
你调用一次 write(),底层可能会产生 0 个、1 个或多个 TCP 段(即 IP 数据包),这就是为什么你不能假设 write() 一次发送的数据,对方就能通过 read() 一次完整地读出来。
如何发送和接收结构化的数据(解决粘包/拆包问题)
这是 TCP Socket 编程中最核心、最常见的问题,由于 TCP 是字节流协议,接收方 read() 时可能会遇到以下情况:
- 粘包:发送方连续发送了两个数据包,但接收方在一次
read()中读取到了两个包的数据。 - 拆包:发送方发送的一个大数据包,被 TCP 拆分成多个段,接收方需要多次
read()才能将其完整读取。
解决方案:定义一个应用层协议,让接收方知道每个数据包的边界,常见的方法有:
固定长度
- 发送:每个数据包都固定为
N个字节,如果数据不足N字节,用空格或特定字符填充。 - 接收:每次循环读取
N个字节。 - 缺点:浪费空间,不灵活。
特殊分隔符
- 发送:在每个数据包末尾加上一个特殊且不会在数据中出现的分隔符(如
\r\n,\0)。 - 接收:循环读取字节,直到读到分隔符,将之前的数据作为一个完整包处理。
- 缺点:如果数据中本身包含分隔符,需要进行转义,增加了复杂性。
长度前缀(最常用、最推荐)
- 发送:
- 先计算要发送数据的字节数。
- 将这个字节数(用
int类型,占 4 字节)作为头部发送出去。 - 再发送真正的数据内容。
- 接收:
- 先读取 4 个字节,解析出数据内容的长度
dataLength。 - 然后循环读取,直到读取到
dataLength个字节,这就是一个完整的数据包。 - 重复以上步骤。
- 先读取 4 个字节,解析出数据内容的长度
Java 实现长度前缀示例:
假设我们要发送一个字符串。
发送端代码片段:
import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class Sender {
public static void main(String[] args) throws Exception {
try (Socket socket = new Socket("localhost", 8888);
OutputStream os = socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os)) {
String message1 = "Hello, this is the first message.";
String message2 = "This is a slightly longer second message.";
// --- 发送第一个消息 ---
byte[] data1 = message1.getBytes(StandardCharsets.UTF_8);
// 1. 先发送数据长度 (int, 4 bytes)
dos.writeInt(data1.length);
// 2. 再发送数据内容
dos.write(data1);
// --- 发送第二个消息 ---
byte[] data2 = message2.getBytes(StandardCharsets.UTF_8);
dos.writeInt(data2.length);
dos.write(data2);
}
}
}
接收端代码片段:
import java.io.DataInputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class Receiver {
public static void main(String[] args) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(8888)) {
System.out.println("Server is listening on port 8888...");
try (Socket socket = serverSocket.accept();
InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is)) {
while (true) {
try {
// 1. 先读取数据长度 (int, 4 bytes)
int length = dis.readInt();
if (length < 0) {
// 可能是客户端正常关闭,处理错误等
break;
}
// 2. 根据长度创建一个字节数组
byte[] receivedData = new byte[length];
// 3. 循环读取,直到读取满 length 个字节
// dis.read() 一次可能读不完,需要循环
int totalRead = 0;
while (totalRead < length) {
int read = dis.read(receivedData, totalRead, length - totalRead);
if (read == -1) {
// 连接已关闭
break;
}
totalRead += read;
}
if (totalRead == length) {
// 成功读取一个完整的数据包
String message = new String(receivedData, StandardCharsets.UTF_8);
System.out.println("Received message: " + message);
} else {
System.out.println("Connection closed before receiving full packet.");
break;
}
} catch (Exception e) {
e.printStackTrace();
break;
}
}
}
}
}
}
一个完整的、可运行的 TCP 通信示例
这个例子将服务器和客户端放在一个文件中,并实现了基于长度前缀的字符串通信。
TCPServerClient.java
import java.io.*;
import java.net.*;
public class TCPServerClient {
// 服务器端
static class Server {
public static void start(int port) {
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("Server is listening on port " + port);
Socket socket = serverSocket.accept();
System.out.println("Client connected: " + socket.getInetAddress());
try (InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is)) {
while (true) {
try {
// 1. 读取消息长度
int length = dis.readInt();
if (length == -1) {
System.out.println("Client is closing the connection...");
break;
}
// 2. 根据长度读取消息内容
byte[] data = new byte[length];
int bytesRead = 0;
while (bytesRead < length) {
int read = dis.read(data, bytesRead, length - bytesRead);
if (read == -1) {
throw new IOException("Connection lost while reading data.");
}
bytesRead += read;
}
String message = new String(data, "UTF-8");
System.out.println("Received from client: " + message);
// 简单回显
sendResponse(socket, "Server echoes: " + message);
} catch (EOFException e) {
System.out.println("Client closed the connection.");
break;
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 客户端
static class Client {
public static void connect(String host, int port) {
try (Socket socket = new Socket(host, port);
OutputStream os = socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in))) {
System.out.println("Connected to server.");
String userInput;
while (true) {
System.out.print("Enter message (or 'exit' to quit): ");
userInput = consoleReader.readLine();
if ("exit".equalsIgnoreCase(userInput)) {
// 发送一个特殊信号或直接关闭
break;
}
byte[] data = userInput.getBytes("UTF-8");
// 1. 发送消息长度
dos.writeInt(data.length);
// 2. 发送消息内容
dos.write(data);
dos.flush(); // 确保数据被发送
// 等待服务器响应
receiveResponse(socket);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 辅助方法:发送响应
private static void sendResponse(Socket socket, String response) throws IOException {
try (OutputStream os = socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os)) {
byte[] data = response.getBytes("UTF-8");
dos.writeInt(data.length);
dos.write(data);
dos.flush();
}
}
// 辅助方法:接收响应
private static void receiveResponse(Socket socket) throws IOException {
try (InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is)) {
int length = dis.readInt();
byte[] data = new byte[length];
dis.readFully(data); // readFully 会一直读取直到填满数组
String message = new String(data, "UTF-8");
System.out.println("Response from server: " + message);
}
}
public static void main(String[] args) {
// 在新线程中启动服务器
new Thread(() -> Server.start(8888)).start();
// 等待服务器启动
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 在主线程中启动客户端
Client.connect("localhost", 8888);
}
}
UDP 数据报编程简介(真正的“数据包”)
如果你需要的是真正的“数据包”模型(无连接、不可靠、有边界),那么应该使用 UDP。
Java 中使用 DatagramSocket 和 DatagramPacket。
DatagramSocket: 发送和接收数据报的套接字。DatagramPacket: 数据包的容器,包含了要发送的数据、目标地址和端口,或接收到的数据和源地址。
UDP 示例代码片段:
发送方:
import java.net.*;
public class UDPSender {
public static void main(String[] args) throws Exception {
DatagramSocket socket = new DatagramSocket();
String message = "Hello from UDP!";
byte[] data = message.getBytes();
// 创建数据包,指定目标地址和端口
InetAddress address = InetAddress.getByName("localhost");
DatagramPacket packet = new DatagramPacket(data, data.length, address, 9876);
// 发送数据包
socket.send(packet);
System.out.println("Sent: " + message);
socket.close();
}
}
接收方:
import java.net.*;
public class UDPReceiver {
public static void main(String[] args) throws Exception {
DatagramSocket socket = new DatagramSocket(9876);
byte[] buffer = new byte[1024];
System.out.println("UDP Server is listening on port 9876...");
while (true) {
// 创建一个空的数据包用于接收
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
// 接收数据包,此方法会阻塞直到收到数据
socket.receive(packet);
// 从数据包中提取数据
String receivedMessage = new String(packet.getData(), 0, packet.getLength());
System.out.println("Received from " + packet.getAddress() + ": " + receivedMessage);
}
// socket.close(); // 通常不会关闭
}
}
| 特性 | TCP (Socket) | UDP (DatagramPacket) |
|---|---|---|
| 连接 | 面向连接 | 无连接 |
| 可靠性 | 可靠(保证顺序、无丢失) | 不可靠(可能丢失、乱序) |
| 数据单位 | 字节流 | 数据报(数据包) |
| 边界 | 无边界 | 有明确边界 |
| 效率 | 较低(有连接、确认、重传) | 较高(无连接开销) |
| 应用场景 | 文件传输、网页浏览、邮件等要求可靠性的场景 | 视频会议、在线游戏、DNS查询等要求速度、能容忍少量丢包的场景 |
对于 Java Socket 数据包,核心思想是:在 TCP 字节流的模型上,通过定义应用层协议(如长度前缀)来模拟“数据包”的边界,从而实现可靠的结构化数据传输。
