Skip to content

第40课:网络编程 - Socket

🎯 学习目标

  • 理解 TCP/UDP 的区别与选择
  • 掌握 Socket/ServerSocket 的 BIO 编程
  • 实现客户端-服务器通信(含多线程服务器)
  • 理解 BIO/NIO/AIO 三种 IO 模型
  • 知道心跳机制与连接管理

📖 一、概念讲解:TCP/UDP

1. 传输层两协议

特性TCPUDP
连接面向连接(三次握手)无连接
可靠性可靠(确认/重传/排序)不可靠(尽最大努力)
顺序有序无序
速度慢(开销大)
适用文件/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
BIOBlocking IO阻塞,一连接一线程Socket/ServerSocket
NIONon-blocking IO多路复用(Selector),一线程管多连接Channel/Buffer/Selector
AIOAsynchronous 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
APIsocket()/bind()/listen()/accept()Socket/ServerSocket 封装
错误返回值/errno异常
多路复用select/poll/epollSelector
资源手动 closetry-with-resources

对 C 程序员:Java 的 Socket 封装了 C 的 BSD socket API,概念一一对应(accept/read/write)。Java 用异常替代 errno,用 try-with-resources 自动关闭。NIO Selector 对应 epoll。


💡 八、最佳实践

  1. try-with-resources 关闭 Socket/ServerSocket。
  2. 多线程服务器用线程池,限制线程数。
  3. 高并发用 NIO/Netty,别 BIO 硬扛。
  4. 指定编码,UTF-8。
  5. 设置超时(connect/read),避免永久阻塞。
  6. 长连接加心跳,检测断连。
  7. 协议设计:定长度/分隔符/长度前缀,解决 TCP 粘包半包。

📝 练习预告

完成 练习/Ex40_Socket.java 中的 6 道题:

  1. TCP 客户端-服务器(回显)
  2. 多线程服务器
  3. 客户端发送/接收
  4. 超时设置
  5. UDP 数据报
  6. 综合:简易 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 -ano

Windows 下可以结合 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 调用