序列化与IO
开篇:数据如何跨越网络?
你在淘宝下了一个订单,订单对象在服务器 A 的 JVM 内存里。但处理发货的是服务器 B,它有自己的 JVM。一个 Java 对象怎么从 A 飞到 B?答案就是序列化——把内存中的对象变成一串字节,通过网络传过去,对面再还原成对象。
而数据在传输过程中怎么高效地读和写,就是 IO 体系要解决的问题。再加上 Java 让框架可插拔的 SPI 机制,这三块知识合在一起,构成了 Java 网络编程和框架设计的基础。
一、序列化与反序列化
1.1 什么是序列化?
把序列化想象成打包行李箱:你要出差,不可能把整个房间搬走,只能把需要的东西打包成一个箱子(字节数组),带到目的地后再拆开还原。
序列化的核心用途:
- 网络传输:RPC 调用时,方法参数和返回值需要序列化才能在网络上传输
- 持久化存储:把对象保存到文件或数据库
- 缓存:Redis 存的就是序列化后的字节数据
- 深拷贝:序列化再反序列化可以实现对象的深拷贝
1.2 Java 原生序列化的问题
Java 自带的序列化方式很简单——实现 Serializable 接口就行:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient String password; // transient 字段不会被序列化
}用 ObjectOutputStream 写出去,ObjectInputStream 读回来:
// 序列化:对象 → 文件
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("user.dat"))) {
oos.writeObject(user);
}
// 反序列化:文件 → 对象
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("user.dat"))) {
User user = (User) ois.readObject();
}看起来简单,但 Java 原生序列化有几个硬伤:
| 问题 | 说明 |
|---|---|
| 性能差 | 序列化后的字节数组体积大,速度慢 |
| 不能跨语言 | 序列化格式是 Java 专有的,Python/Go 读不了 |
| 安全漏洞 | 反序列化时会执行对象构造逻辑,可能被恶意利用 |
| 版本兼容麻烦 | 类结构变了,serialVersionUID 不一致就会反序列化失败 |
关于 serialVersionUID,这里多说几句。它的作用是在反序列化时验证"这个字节流和当前类的定义是不是同一个版本"。如果你不显式定义它,JVM 会根据类结构(字段、方法等)自动生成一个哈希值——一旦你改了类的任何字段,自动生成的值就变了,之前序列化的数据就全废了,会抛出 InvalidClassException。
所以强烈建议:手动定义 serialVersionUID,别让 JVM 自动生成。
// 推荐:手动定义
private static final long serialVersionUID = 1L;
// 不推荐:不定义,让 JVM 自动生成
// 类一改动,之前的序列化数据就读不回来了还有几个容易忽略的细节:
static字段不会被序列化(它属于类,不属于实例)transient字段不会被序列化(反序列化后为默认值:null/0/false)- 父类没实现
Serializable,父类的字段不会被序列化 - 实现
Externalizable接口可以自定义序列化哪些字段(更灵活但更麻烦)
1.3 现代序列化方案对比
实际项目中,Java 原生序列化用得很少,大家更倾向于用更快、更小、更安全的方案:
| 方案 | 格式 | 是否跨语言 | 体积 | 速度 | 适用场景 |
|---|---|---|---|---|---|
| Java 原生 | 二进制 | 否 | 大 | 慢 | 学习用,生产少见 |
| JSON (Jackson/Gson) | 文本 | 是 | 中 | 中 | HTTP API、配置文件 |
| Protobuf | 二进制 | 是 | 小 | 快 | gRPC、高性能 RPC |
| Hessian | 二进制 | 是 | 中 | 中 | Dubbo 默认协议 |
| Kryo | 二进制 | 否 | 小 | 极快 | 单语言高性能场景 |
选型建议:
- 对外 API 用 JSON(可读性好,方便调试)
- 内部 RPC 用 Protobuf 或 Hessian(性能好,体积小)
- 临时缓存/本地序列化 用 Kryo(最快,但不跨语言)
- Java 原生序列化 基本不用于生产
Fastjson 安全提醒:Fastjson 的 AutoType 功能允许 JSON 中通过
@type字段指定反序列化的目标类。攻击者可以构造恶意的@type值(如com.sun.rowset.JdbcRowSetImpl),通过 RMI 远程调用实现代码执行。这是一个经典的反序列化漏洞。如果使用 Fastjson,务必升级到最新版本并开启safeMode:ParserConfig.getGlobalInstance().setSafeMode(true)。
二、Java IO 体系
2.1 字节流与字符流
Java IO 的核心就像一根水管:数据像水一样,从一头流入,从另一头流出。而且水管是单向的——一根管子要么进水(读),要么出水(写),不能同时双向。
- 字节流处理原始二进制数据(图片、视频、任何文件都行)
- 字符流专门处理文本数据(自动处理字符编码,避免乱码)
一个简单的记忆法:看得懂的内容用字符流,看不懂的内容用字节流。
// 字节流:复制任意文件(图片、视频、压缩包都行)
try (InputStream in = new FileInputStream("photo.jpg");
OutputStream out = new FileOutputStream("copy.jpg")) {
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) != -1) {
out.write(buf, 0, len);
}
}
// 字符流:读写文本文件(自动处理编码)
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}Java IO 还有一个重要设计——装饰器模式。BufferedInputStream 包裹 FileInputStream 来加缓冲,InputStreamReader 包裹 InputStream 把字节转字符。像套娃一样一层层增强功能,每层只负责一件事:
// 经典套娃:字节流 → 字符流 → 带缓冲的字符流
BufferedReader br = new BufferedReader( // 第三层:加缓冲
new InputStreamReader( // 第二层:字节转字符
new FileInputStream("data.txt"), // 第一层:读文件
StandardCharsets.UTF_8 // 指定编码
)
);这个设计的好处是灵活组合。需要缓冲?套一层 Buffered。需要转编码?套一层 InputStreamReader。需要数据格式化?套一层 DataInputStream。每层职责单一,随意搭配。
IO 流的几个基本特性:
- 先进先出:先写入的数据先被读取
- 顺序读写:不能跳着读(
RandomAccessFile除外) - 单向流动:读和写要用两个不同的流
2.2 BIO vs NIO vs AIO
这三种 IO 模型的区别,用餐厅点餐来理解最直观:
BIO(Blocking IO,同步阻塞)—— 服务员一对一服务:
一个服务员盯一桌客人,客人不点菜服务员就干等着。客人多了就要雇一大堆服务员(线程),成本极高。
// BIO:一个连接一个线程,连接多了线程就爆了
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 阻塞等待连接
new Thread(() -> handle(socket)).start(); // 每个连接一个线程
}NIO(Non-blocking IO,同步非阻塞)—— 一个服务员巡视多桌:
服务员轮流问每桌"想好没",想好了就处理,没想好就去问下一桌。一个人能服务很多桌。核心组件是 Selector(选择器)、Channel(通道) 和 Buffer(缓冲区)。
// NIO:一个 Selector 管理多个 Channel
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞直到有事件就绪
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) { /* 处理新连接 */ }
if (key.isReadable()) { /* 处理读事件 */ }
}
}AIO(Asynchronous IO,异步非阻塞)—— 客人自己按铃:
客人想好了按桌上的铃,服务员听到铃声再过来。服务员不需要主动巡视,完全由事件驱动。
| 模型 | 线程模型 | 核心概念 | 适用场景 | 典型框架 |
|---|---|---|---|---|
| BIO | 一连接一线程 | Stream | 连接少、请求重 | Tomcat(早期) |
| NIO | 少量线程管理多连接 | Selector + Channel + Buffer | 高并发、短连接 | Netty, Tomcat NIO |
| AIO | 异步回调 | CompletionHandler | 高并发、长连接 | 理论上好,实际少用 |
实际生产中 NIO 用得最多,尤其是通过 Netty 框架。纯粹的 AIO 在 Linux 上的 epoll 实现其实还是同步的事件通知模型,并非真正的内核异步 IO,所以 Netty 选择了基于 NIO + epoll 的方案而不是 AIO。
2.3 零拷贝
传统的文件传输要经历 4 次数据拷贝和 4 次用户态/内核态切换:
数据在内核和用户空间之间来回搬运,第 2 步和第 3 步完全是浪费。
零拷贝(如 Linux 的 sendfile 系统调用)让数据不再经过用户空间,直接在内核中从读缓冲区转到网卡:
从 4 次拷贝减少到 2 次,CPU 几乎不参与数据搬运。
在 Java 中,零拷贝体现在两个 API:
1. FileChannel.transferTo() —— 底层调用 sendfile
// 零拷贝发送文件:数据不经过用户空间
FileChannel source = new FileInputStream("big_file.dat").getChannel();
SocketChannel target = SocketChannel.open(
new InetSocketAddress("host", 8080));
source.transferTo(0, source.size(), target);2. MappedByteBuffer(mmap) —— 将文件映射到内存
// 内存映射:把文件"映射"到内存,读写文件像操作数组
FileChannel channel = new RandomAccessFile("data.dat", "rw").getChannel();
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE, 0, channel.size());
buffer.putInt(0, 42); // 直接写,无需 read/write 系统调用Kafka 的高吞吐量有两大秘密武器:顺序写磁盘 + 零拷贝发送。消费消息时用 transferTo 直接把磁盘数据发到网卡,绕过用户空间,这是它每秒能处理百万级消息的关键。Nginx 的静态文件服务也大量使用了 sendfile。
三、SPI 机制
3.1 什么是 SPI?
SPI(Service Provider Interface)可以理解为一个插件系统。
先区分两个概念:API 是"我定义接口,我实现,你调用";SPI 是"我定义接口,你实现,我来加载"。就像 USB 接口——电脑厂商定义了 USB 标准(SPI 接口),各外设厂商生产不同的 USB 设备(实现类),插上就能用,不需要电脑厂商提前知道会有什么设备。
SPI 的工作方式分三步:
- 框架定义接口:声明一个接口作为扩展点
- 实现方注册:在 JAR 包的
META-INF/services/目录下创建一个以接口全限定名命名的文件,文件内容是实现类的全限定名(每行一个) - 框架加载:通过
ServiceLoader.load()自动发现并加载 classpath 下所有的实现
// 1. 定义接口
public interface PaymentService {
void pay(BigDecimal amount);
}
// 2. 提供实现
public class AlipayService implements PaymentService {
public void pay(BigDecimal amount) {
System.out.println("支付宝支付: " + amount);
}
}
public class WechatPayService implements PaymentService {
public void pay(BigDecimal amount) {
System.out.println("微信支付: " + amount);
}
}
// 3. 注册:META-INF/services/com.example.PaymentService
// com.example.AlipayService
// com.example.WechatPayService
// 4. 加载所有实现
ServiceLoader<PaymentService> loader = ServiceLoader.load(PaymentService.class);
for (PaymentService service : loader) {
service.pay(new BigDecimal("99.9"));
}
// 输出:支付宝支付: 99.9
// 微信支付: 99.93.2 SPI 在 JDBC 中的应用
JDBC 是 SPI 最经典的应用。以前你需要手动写 Class.forName("com.mysql.jdbc.Driver") 来加载驱动,从 JDBC 4.0(JDK 6)开始就不需要了。
当你把 MySQL 的 JAR 包放进 classpath,DriverManager 在初始化时通过 SPI 自动发现并加载 MySQL 驱动。换 PostgreSQL?换个 JAR 包就行,一行代码不用改。
这就是 SPI 的威力:面向接口编程 + 运行时动态加载 = 真正的可插拔架构。
Java 原生 SPI 也有不足:
- 全量加载:会一次性实例化所有实现类,即使你只需要其中一个
- 没有 IoC 能力:实现类不能自动注入依赖
- 没有排序和优先级:无法控制多个实现的加载顺序
所以很多框架做了增强:
- Dubbo SPI:支持按需加载(通过 key 指定),支持 IoC 和 AOP,支持默认实现
- Spring Boot:通过
spring.factories文件实现类似的自动发现机制(@EnableAutoConfiguration就是靠它)
四、常见面试题精选
Q1:Java 序列化和 JSON 序列化有什么区别?什么时候用哪个?
Java 原生序列化是二进制格式、Java 专用、支持完整的对象图(包括循环引用)。JSON 是文本格式、跨语言、可读性好但不支持复杂对象关系。对外 API 用 JSON,内部 RPC 追求性能用 Protobuf/Hessian,Java 原生序列化基本不用于生产。
Q2:transient 和 static 字段会被序列化吗?
都不会。transient 是专门用来标记"不需要序列化"的字段(如密码、临时计算结果)。static 字段属于类而非实例,序列化只保存实例状态。反序列化后,transient 字段为默认值(null/0/false),static 字段取当前 JVM 中的值。
Q3:BIO、NIO、AIO 分别适合什么场景?
BIO 适合连接少但处理重的场景(如传统 Web 应用);NIO 适合高并发短连接(如即时通讯、网关),Netty 就是基于 NIO;AIO 理论上适合高并发长连接,但 Linux 的 AIO 实现并非真正的异步,实际用得不多。
Q4:什么是零拷贝?Kafka 为什么这么快?
零拷贝减少了数据在内核空间和用户空间之间的拷贝次数。传统方式需要 4 次拷贝和 4 次上下文切换,零拷贝(sendfile)只需 2 次拷贝。Kafka 消费消息时用 FileChannel.transferTo() 直接把磁盘数据发到网卡,绕过用户空间,配合顺序写磁盘,这是它高吞吐的两大关键。
Q5:SPI 和 API 有什么区别?
API 是"提供方定义并实现,调用方使用";SPI 是"提供方定义接口,调用方实现"。SPI 是一种可插拔的扩展机制,典型应用是 JDBC 驱动加载、SLF4J 日志门面、Dubbo 扩展点。Java 通过 ServiceLoader + META-INF/services/ 目录实现 SPI 的自动发现和加载。
小结
序列化解决了"数据怎么传",IO 体系解决了"数据怎么读写",SPI 解决了"实现怎么可插拔"。这三块知识在日常 CRUD 中可能感知不强,但一旦你去读 Dubbo、Netty、Kafka 的源码,它们就是绕不过去的基础。理解了这些底层机制,你就能明白为什么 Netty 选了 NIO 而不是 AIO,为什么 Dubbo 不用 Java 原生序列化,为什么 JDBC 驱动不需要手动加载了。