- 序列化:在客户端,将 Java 对象转换成一个字节流。
- 传输:通过
Socket将这个字节流发送到服务器。 - 反序列化:在服务器端,接收字节流并将其重新还原成原来的 Java 对象。
核心概念
a. Java 序列化
为了让一个 Java 对象可以被序列化,它的类必须实现 java.io.Serializable 接口,这个接口是一个标记接口,它本身没有任何方法,只是告诉 JVM 这个类的对象可以被序列化。

重要注意事项:
serialVersionUID:强烈建议为每个可序列化的类显式声明一个serialVersionUID,这是一个唯一的标识符,用于在序列化和反序列化时验证发送方和接收方的对象类是否一致,如果不一致,会抛出InvalidClassException,如果不显式声明,JVM 会根据类的结构自动生成一个,但任何对类的微小修改(如添加一个字段)都会导致这个 ID 改变,从而破坏兼容性。- 可序列化的字段:只有非
static和非transient的字段会被序列化。static字段属于类,不属于对象实例;transient字段被标记为“瞬态”,不参与序列化。
b. Socket 通信
Socket 通信是基于 TCP/IP 的,它提供了一个双向的通信通道,我们需要两个 Socket:
- 服务器端:使用
ServerSocket在指定端口监听客户端的连接请求,一旦有客户端连接,就会返回一个Socket对象,用于与该客户端进行后续通信。 - 客户端:使用服务器的 IP 地址和端口号创建一个
Socket对象,尝试连接到服务器。
实现步骤
我们将创建一个完整的例子,包括:
- 一个可序列化的
User类。 - 一个服务器端程序,接收并反序列化
User对象。 - 一个客户端程序,创建
User对象并序列化发送。
第 1 步:创建可序列化的对象类 (User.java)
import java.io.Serializable;
// 1. 实现 Serializable 接口
public class User implements Serializable {
// 2. 声明 serialVersionUID 以确保版本兼容性
private static final long serialVersionUID = 1L;
private String username;
private transient String password; // transient 字段不会被序列化
private int age;
public User(String username, String password, int age) {
this.username = username;
this.password = password;
this.age = age;
}
@Override
public String toString() {
return "User{" +
"username='" + username + '\'' +
// password 字段在反序列化后会是 null,因为它没有被传输
", password='" + password + '\'' +
", age=" + age +
'}';
}
}
第 2 步:创建服务器端程序 (Server.java)
服务器端的工作流程是:

- 创建
ServerSocket并绑定端口。 - 调用
accept()方法,阻塞等待客户端连接。 - 客户端连接后,获取输入流。
- 使用
ObjectInputStream包装输入流,用于读取对象。 - 读取对象(反序列化)。
- 处理对象并打印。
- 关闭连接。
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) {
int port = 12345;
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务器已启动,等待客户端连接...");
// 阻塞,等待客户端连接
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress());
// 获取输入流
InputStream inputStream = clientSocket.getInputStream();
// 使用 ObjectInputStream 来读取对象
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
// 读取对象(反序列化)
User receivedUser = (User) objectInputStream.readObject();
// 处理接收到的对象
System.out.println("服务器接收到对象: " + receivedUser);
System.out.println("注意,password 字段为 null,因为它被标记为 transient: " + receivedUser.getPassword());
// 关闭资源
objectInputStream.close();
clientSocket.close();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
第 3 步:创建客户端程序 (Client.java)
客户端的工作流程是:
- 创建
Socket连接到服务器的 IP 和端口。 - 获取输出流。
- 使用
ObjectOutputStream包装输出流,用于写入对象。 - 创建要发送的对象。
- 将对象写入输出流(序列化)。
- 刷新并关闭流。
import java.io.*;
import java.net.Socket;
public class Client {
public static void main(String[] args) {
String host = "127.0.0.1"; // 本地主机地址
int port = 12345;
try (Socket socket = new Socket(host, port)) {
System.out.println("已连接到服务器: " + socket.getInetAddress());
// 获取输出流
OutputStream outputStream = socket.getOutputStream();
// 使用 ObjectOutputStream 来写入对象
ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
// 创建要发送的对象
User userToSend = new User("zhangsan", "123456", 30);
System.out.println("客户端准备发送对象: " + userToSend);
// 写入对象(序列化)
objectOutputStream.writeObject(userToSend);
// 刷新流,确保数据被立即发送
objectOutputStream.flush();
System.out.println("对象已发送。");
// 关闭资源
objectOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
如何运行
-
编译:将
User.java,Server.java,Client.java放在同一个目录下,然后编译它们。javac User.java Server.java Client.java
-
运行:
-
首先,在终端运行服务器程序:
(图片来源网络,侵删)java Server
你会看到输出:
服务器已启动,等待客户端连接... -
然后,在另一个终端运行客户端程序:
java Client
你会看到客户端的输出:
已连接到服务器...和客户端准备发送对象...。 -
最后,回到服务器终端,你会看到服务器的输出:
客户端已连接: /127.0.0.1 服务器接收到对象: User{username='zhangsan', password='null', age=30} 注意,password 字段为 null,因为它被标记为 transient: null
-
重要注意事项和最佳实践
-
关闭资源:
Socket,InputStream,OutputStream,ObjectInputStream,ObjectOutputStream等都是需要关闭的资源,强烈建议使用try-with-resources语句(如上面的例子所示),这样可以自动关闭资源,避免资源泄漏。 -
serialVersionUID的作用:如果你修改了User类(增加一个String email字段),但没有更新serialVersionUID,然后尝试用新版本的客户端连接旧版本的服务器(或反之),反序列化时会抛出InvalidClassException,显式声明serialVersionUID可以让你控制类的版本兼容性。 -
安全性问题:
- 远程代码执行:标准的 Java 序列化机制存在严重的安全漏洞,攻击者可以构造恶意的字节流,导致服务器在反序列化时执行任意代码。在生产环境中,直接使用
ObjectInputStream和ObjectOutputStream是非常危险的。 - 替代方案:为了安全,强烈推荐使用更现代、更安全的序列化格式,JSON 或 Protocol Buffers (protobuf),它们不仅更安全,而且跨语言性更好,数据格式也更紧凑。
- 远程代码执行:标准的 Java 序列化机制存在严重的安全漏洞,攻击者可以构造恶意的字节流,导致服务器在反序列化时执行任意代码。在生产环境中,直接使用
-
发送多个对象:如果你想通过一个 Socket 连续发送多个对象,需要注意:
ObjectOutputStream在第一次写入对象时,会写入一个头信息(stream header),后续写入只需要追加对象数据。- 客户端必须确保所有对象都写入完毕后,再关闭
ObjectOutputStream,如果在写入过程中关闭,会导致服务器端的ObjectInputStream抛出StreamCorruptedException。 - 服务器端可以使用一个循环来持续读取
ObjectInputStream,直到遇到流结束的标记(客户端关闭连接)或特定的协议消息。
使用 JSON 作为更安全的替代方案
下面是一个使用流行的 Jackson 库进行 JSON 序列化的例子。
Maven 依赖 (pom.xml)
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version> <!-- 使用最新版本 -->
</dependency>
修改后的客户端 (ClientWithJson.java)
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.OutputStream;
import java.net.Socket;
public class ClientWithJson {
public static void main(String[] args) {
String host = "127.0.0.1";
int port = 12345;
ObjectMapper mapper = new ObjectMapper(); // Jackson 核心类
try (Socket socket = new Socket(host, port);
OutputStream outputStream = socket.getOutputStream()) {
User userToSend = new User("lisi", "654321", 25);
// 将对象序列化为 JSON 字节数组
byte[] jsonBytes = mapper.writeValueAsBytes(userToSend);
System.out.println("客户端发送 JSON: " + new String(jsonBytes));
// 发送 JSON 字节数组
outputStream.write(jsonBytes);
} catch (Exception e) {
e.printStackTrace();
}
}
}
修改后的服务器 (ServerWithJson.java)
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerWithJson {
public static void main(String[] args) {
int port = 12345;
ObjectMapper mapper = new ObjectMapper();
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("JSON 服务器已启动,等待客户端连接...");
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress());
try (InputStream inputStream = clientSocket.getInputStream()) {
// 读取所有字节
byte[] jsonBytes = inputStream.readAllBytes();
// 将 JSON 字节数组反序列化为 User 对象
User receivedUser = mapper.readValue(jsonBytes, User.class);
System.out.println("服务器接收到对象: " + receivedUser);
}
clientSocket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
这个 JSON 版本的例子更加安全和现代,是当前业界更推荐的做法。
