Appearance
第40课:网络编程 - Socket
🎯 学习目标
- 理解 TCP/UDP 的区别与选择
- 掌握 Socket/ServerSocket 的 BIO 编程
- 实现客户端-服务器通信(含多线程服务器)
- 理解 BIO/NIO/AIO 三种 IO 模型
- 知道心跳机制与连接管理
📖 一、概念讲解:TCP/UDP
1. 传输层两协议
| 特性 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(确认/重传/排序) | 不可靠(尽最大努力) |
| 顺序 | 有序 | 无序 |
| 速度 | 慢(开销大) | 快 |
| 适用 | 文件/HTTP/邮件 | 视频/语音/DNS/游戏 |
TCP:可靠传输,适合要求不丢数据的场景(HTTP、文件传输、数据库连接)。 UDP:快但不保证,适合实时且容忍丢包的场景(视频流、游戏、DNS 查询)。
2. Socket 是什么
Socket 是应用层与传输层之间的编程接口——"IP + 端口"标识一个连接端点。
客户端 Socket ──连接──→ 服务器 ServerSocket
(IP:端口) (监听端口)Java 的 java.net 提供 Socket(TCP 客户端)、ServerSocket(TCP 服务端)、DatagramSocket(UDP)。
📖 二、BIO 编程(阻塞 IO)
1. 服务器端(ServerSocket)
java
try (ServerSocket server = new ServerSocket(8080)) {
System.out.println("服务器启动,监听 8080");
while (true) {
Socket client = server.accept(); // 阻塞,等待客户端连接
// 处理 client(读取请求、返回响应)
handle(client);
}
}accept() 阻塞直到有客户端连接。每个连接一个 Socket,通过其 InputStream/OutputStream 读写。
2. 客户端(Socket)
java
try (Socket socket = new Socket("127.0.0.1", 8080)) {
OutputStream out = socket.getOutputStream();
out.write("Hello Server".getBytes(StandardCharsets.UTF_8));
socket.shutdownOutput(); // 告诉服务器发送完毕
InputStream in = socket.getInputStream();
// 读服务器响应
}new Socket(host, port) 发起连接(三次握手)。
3. BIO 的局限
单线程服务器一次只能处理一个客户端(accept 后处理完才接下一个)。多客户端需多线程:每个连接开一个线程处理。
java
while (true) {
Socket client = server.accept();
new Thread(() -> handle(client)).start(); // 每连接一线程
}问题:连接数多时线程爆炸(每线程约1MB栈),C10K 问题(万连接)难应对。这就是 BIO 的局限,引出 NIO(多路复用)。
📖 三、多线程服务器
java
ExecutorService pool = Executors.newFixedThreadPool(100);
while (true) {
Socket client = server.accept();
pool.submit(() -> handle(client)); // 线程池处理
}用线程池控制线程数,避免无限制创建。但仍是"一连接一线程",万连接仍受限。
📖 四、BIO / NIO / AIO 对比
| 模型 | 全称 | 机制 | Java |
|---|---|---|---|
| BIO | Blocking IO | 阻塞,一连接一线程 | Socket/ServerSocket |
| NIO | Non-blocking IO | 多路复用(Selector),一线程管多连接 | Channel/Buffer/Selector |
| AIO | Asynchronous IO | 异步,回调通知 | AsynchronousSocketChannel(JDK7+,Linux 支持有限) |
- BIO:简单,连接少时够用。
- NIO:Selector 多路复用,一个线程管理多个连接,高并发首选(Netty 基于 NIO)。
- AIO:真正异步,Windows 实现好,Linux 仍用 epoll 模拟,Netty 弃用 AIO 用 NIO。
详见 22 课 NIO。
📖 五、心跳与连接管理
长连接(如 IM、推送)需心跳机制保活、检测断连:
- 定时发送心跳包(小数据),对方回复。
- 超时未收到心跳 → 判定断连 → 清理资源。
java
// 简单心跳:定时 ping
scheduler.scheduleAtFixedRate(() -> send("PING"), 0, 30, TimeUnit.SECONDS);连接管理:超时设置(connectTimeout/readTimeout)、连接池、重连机制。
⚠️ 六、常见陷阱
陷阱1:忘记 shutdownOutput/close
客户端写完不 shutdownOutput,服务器 read 会一直阻塞(以为还有数据)。用 try-with-resources 或 shutdownOutput 明确结束。
陷阱2:BIO 线程爆炸
每连接一线程,连接多时线程耗尽内存。高并发用 NIO/Netty。
陷阱3:端口冲突
端口被占用抛 BindException。选未被占用端口,或 SO_REUSEADDR。
陷阱4:编码问题
网络传输字节,文本需指定编码(UTF-8),别用平台默认。
陷阱5:不处理 IOException/中断
网络异常(连接断开、超时)需 try-catch,线程要响应 interrupt 优雅退出。
🆚 七、Java vs C 对比
| 特性 | C(BSD Socket) | Java |
|---|---|---|
| API | socket()/bind()/listen()/accept() | Socket/ServerSocket 封装 |
| 错误 | 返回值/errno | 异常 |
| 多路复用 | select/poll/epoll | Selector |
| 资源 | 手动 close | try-with-resources |
对 C 程序员:Java 的 Socket 封装了 C 的 BSD socket API,概念一一对应(accept/read/write)。Java 用异常替代 errno,用 try-with-resources 自动关闭。NIO Selector 对应 epoll。
💡 八、最佳实践
- try-with-resources 关闭 Socket/ServerSocket。
- 多线程服务器用线程池,限制线程数。
- 高并发用 NIO/Netty,别 BIO 硬扛。
- 指定编码,UTF-8。
- 设置超时(connect/read),避免永久阻塞。
- 长连接加心跳,检测断连。
- 协议设计:定长度/分隔符/长度前缀,解决 TCP 粘包半包。
📝 练习预告
完成 练习/Ex40_Socket.java 中的 6 道题:
- TCP 客户端-服务器(回显)
- 多线程服务器
- 客户端发送/接收
- 超时设置
- UDP 数据报
- 综合:简易 HTTP 服务器(解析请求)
完成后对比 答案/Sol40.java,查看逐行讲解与多解法。
📖 九、TCP 生命周期
TCP 是面向连接的协议,一个连接有建立、传输、关闭三个阶段。
建立连接:
text
客户端发送 SYN。
服务端返回 SYN + ACK。
客户端发送 ACK。
连接建立。关闭连接:
text
主动关闭方发送 FIN。
被动关闭方返回 ACK。
被动关闭方也发送 FIN。
主动关闭方返回 ACK。
连接关闭。Java Socket 中对应行为:
text
new Socket(host, port):发起连接。
server.accept():接受连接。
socket.getInputStream().read():读取数据,可能阻塞。
socket.getOutputStream().write():写出数据。
socket.shutdownOutput():关闭输出方向,告诉对方写完。
socket.close():关闭整个 socket。常见状态:
text
ESTABLISHED:连接已建立。
CLOSE_WAIT:对方关闭,本端还没关闭。
TIME_WAIT:主动关闭方等待旧包过期。如果服务端出现大量 CLOSE_WAIT,通常说明应用没有及时关闭 Socket。
📖 十、粘包与半包
TCP 是字节流协议,不保留消息边界。
问题表现:
text
发送方 write 两次,接收方可能 read 一次读到两条消息。
发送方 write 一次,接收方可能 read 多次才读完整。这不是 TCP bug,而是字节流协议的正常行为。
解决方案:
text
固定长度协议。
分隔符协议,例如每条消息以 \n 结束。
长度前缀协议,先发消息长度,再发消息内容。长度前缀示例:
java
public static void writeMessage(DataOutputStream out, String message) throws IOException {
byte[] bytes = message.getBytes(StandardCharsets.UTF_8);
out.writeInt(bytes.length);
out.write(bytes);
out.flush();
}
public static String readMessage(DataInputStream in) throws IOException {
int length = in.readInt();
byte[] bytes = in.readNBytes(length);
if (bytes.length != length) {
throw new EOFException("message not complete");
}
return new String(bytes, StandardCharsets.UTF_8);
}HTTP、Redis、MySQL、Kafka 等协议都必须定义消息边界,只是形式不同。
📖 十一、超时与关闭语义
网络编程必须设置超时。
常见超时:
text
连接超时:建立连接最多等待多久。
读取超时:等对方响应最多等待多久。
写入超时:发送数据最多等待多久,BIO Socket 不好直接控制。
空闲超时:长连接多久无数据关闭。
心跳超时:多久没收到心跳认为断线。Java BIO 设置:
java
Socket socket = new Socket();
socket.connect(new InetSocketAddress("127.0.0.1", 8080), 3000);
socket.setSoTimeout(5000);关闭相关方法:
text
shutdownOutput:只关闭输出方向,对方读到 EOF。
shutdownInput:只关闭输入方向。
close:关闭整个连接。常见错误:
text
客户端写完不 flush。
客户端写完不 shutdownOutput,服务端一直 read 阻塞。
异常时没有 close,导致 CLOSE_WAIT。
多个线程同时读写同一个 Socket,没有协议约束。🧪 十二、协议设计案例:简易命令协议
假设设计一个简单命令协议:
text
PING
ECHO hello
TIME
QUIT用换行作为分隔符:
java
try (
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))
) {
String line;
while ((line = reader.readLine()) != null) {
if (line.equals("PING")) {
writer.write("PONG\n");
} else if (line.startsWith("ECHO ")) {
writer.write(line.substring(5) + "\n");
} else if (line.equals("TIME")) {
writer.write(Instant.now().toString() + "\n");
} else if (line.equals("QUIT")) {
writer.write("BYE\n");
writer.flush();
break;
} else {
writer.write("ERR unknown command\n");
}
writer.flush();
}
}这个协议简单,但已经包含真实协议设计要素:
text
消息边界:换行。
命令类型:PING/ECHO/TIME/QUIT。
响应格式:一行文本。
错误处理:ERR。
连接关闭:QUIT。如果消息体可能包含换行,就不能继续使用这种协议,应改用长度前缀。
🛠 十三、生产排查清单
Socket 服务异常时,按下面方向排查:
text
端口是否监听。
防火墙或安全组是否放行。
服务端 accept 是否正常。
线程池是否耗尽。
连接数是否达到系统限制。
是否大量 CLOSE_WAIT。
是否大量 TIME_WAIT。
是否设置 read timeout。
协议边界是否正确。
客户端是否正确 flush 和 close。常用命令:
bash
netstat -anoWindows 下可以结合 PID 查看进程:
powershell
Get-Process -Id <pid>服务端指标:
text
当前连接数。
新建连接速率。
请求处理耗时。
线程池活跃线程数。
队列长度。
读写超时次数。
异常关闭次数。📊 十四、BIO/NIO/Netty 选型
| 场景 | 推荐 |
|---|---|
| 学习网络基础 | BIO Socket |
| 少量内部连接 | BIO + 线程池可接受 |
| 高并发长连接 | NIO / Netty |
| 自定义协议网关 | Netty |
| HTTP 客户端调用 | HttpClient / OkHttp |
| Spring Web 服务 | Spring MVC / WebFlux |
选择建议:
text
先理解 BIO,因为它最接近 Socket 本质。
生产高并发不要自己手写 NIO。
自定义 TCP 协议优先考虑 Netty。
简单 HTTP 不要用裸 Socket 写。🔍 十五、自测问题
text
为什么 TCP 会有粘包和半包?
read 返回 -1 表示什么?
shutdownOutput 和 close 有什么区别?
CLOSE_WAIT 大量出现通常说明什么?
BIO 为什么会有一连接一线程的问题?
长度前缀协议如何解决消息边界?
心跳包解决什么问题?
什么时候应该直接使用 Netty?✅ 十六、掌握标准
学完本课后,应能做到:
text
能写出一个可关闭的 TCP 回显服务。
能解释 TCP 和 UDP 的取舍。
能说明 BIO 阻塞点在哪里。
能用线程池改进一连接一线程的粗暴写法。
能设置连接超时和读取超时。
能解释并解决粘包半包。
能设计简单的文本协议或长度前缀协议。
能根据连接数和协议复杂度判断是否需要 Netty。网络编程的重点不是背 API,而是理解连接、阻塞、超时、协议边界和资源关闭。
🎓 下一步
- 第41课:网络编程-HTTP — HttpClient、RESTful 调用