3. TCP服务端编程基础

TCP 服务端编程是 Java 网络编程的核心入口。客户端与服务端之间要完成一次可靠通信,服务端必须先“占住”一个端口,然后等待连接到来,再通过连接上的字节流收发数据。Java 把这一过程封装为 ServerSocket 与 Socket 两个类:前者负责监听和接受连接,后者代表一条已经建立的双向 TCP 连接。理解这两者的分工,以及阻塞式模型的行为特征,是后续学习多线程、NIO、Netty 的基础。

一、ServerSocket:监听端口与接受连接

ServerSocket 的职责非常明确:绑定本机某个端口,进入监听状态,等待客户端发起三次握手。创建方式通常有两种:

// 方式一:创建时直接绑定端口,内部会调用 bind
ServerSocket server = new ServerSocket(9000);

// 方式二:先创建再绑定,便于设置 backlog 等参数
ServerSocket server = new ServerSocket();
server.bind(new InetSocketAddress("0.0.0.0", 9000), 50);

其中 9000 是监听端口,0.0.0.0 表示监听本机所有网卡地址;若写成 127.0.0.1,则只有本机进程能连接。backlog 参数(示例为 50)表示内核已完成三次握手、但应用层尚未 accept 的连接队列长度上限。它并不是“最大连接数”,而是“等待被取走的连接数”。当队列满时,新的握手请求可能被拒绝或丢弃,客户端表现为连接超时或 Connection refused。

调用 server.accept() 会从已完成连接的队列中取出一个连接,返回一个新的 Socket 对象。这个 Socket 与客户端那个 Socket 构成一对,双方通过它读写数据。需要注意:accept() 返回的 Socket 使用的是服务端本地端口 9000 与客户端临时端口组成的四元组,因此同一个监听端口可以同时服务多个客户端。

二、Socket 输入输出流:字节流的读写

拿到 Socket 后,通信通过流完成。socket.getInputStream() 读取对端发来的字节,socket.getOutputStream() 向对端写出字节。TCP 是字节流协议,没有消息边界,因此“一次 write”不等于“一次 read”。例如客户端连续写入 "hello" 和 "world",服务端可能一次读到 "helloworld",也可能分两次读到 "hel" 与 "loworld"。要正确解析,必须约定应用层协议,例如“一行一条消息”或“长度前缀 + 内容”。

Socket socket = server.accept();
BufferedReader in = new BufferedReader(
        new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(
        new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);

String line = in.readLine();      // 按行读取,依赖对端发送换行符
out.println("echo: " + line);     // autoFlush=true 时 println 会立即写出

示例中 readLine() 会一直阻塞,直到读到换行符、流结束或异常。若客户端只发 "hi" 而不发换行,服务端就会一直等待。这是初学者最常见的“卡住”原因之一。写数据时,PrintWriter 的 autoFlush 只对 println、printf 等方法生效,直接 write 不一定立即刷出,必要时显式调用 flush()。

三、阻塞式通信模型

默认情况下,accept()、read()、write() 都是阻塞的:没有连接时 accept 挂起;没有数据时 read 挂起;发送缓冲区满时 write 也可能挂起。这种“一个线程从头到尾处理一条连接”的模型就是阻塞式(BIO)模型。它的优点是代码直观、调试简单;缺点是单线程只能串行处理一个客户端,第二个客户端必须等第一个处理完才能被 accept。

下面这张图展示了服务端从监听、接受连接到流读写的完整信号与数据路径,并标出了阻塞点:

TCP 服务端阻塞式通信路径 客户端 Socket connect(host,9000) 发送 "hello\n" SYN / ACK ServerSocket bind(0.0.0.0:9000) listen(backlog=50) accept() 阻塞等待 已完成连接队列 (等待 accept 取走) 连接 Socket getInputStream() getOutputStream() read() 阻塞等待数据 accept 返回 字节流:无消息边界 需按行 / 长度前缀解析 阻塞点与后果 accept():无新连接时线程挂起,无法处理其他客户端 read():对端未发数据或未发换行时挂起,readLine 会一直等待 write():发送缓冲区满时挂起,需关注对端读取速度

四、简单多客户端处理:一连接一线程

要让服务端同时服务多个客户端,最直接的做法是:主线程只负责 accept,每接受一个连接就交给一个新线程处理。这样 accept 不会被某个客户端的读写拖住,多个客户端可以并行通信。

public class EchoServer {
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(9000);
        System.out.println("listening on 9000");
        while (true) {
            Socket socket = server.accept();          // 主线程阻塞在此
            new Thread(() -> handle(socket)).start(); // 每个连接一个线程
        }
    }

    static void handle(Socket socket) {
        try (socket;
             BufferedReader in = new BufferedReader(
                 new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
             PrintWriter out = new PrintWriter(
                 new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) {
            String line;
            while ((line = in.readLine()) != null) {  // 对端关闭时返回 null
                out.println("echo: " + line);
            }
        } catch (IOException e) {
            System.out.println("client error: " + e.getMessage());
        }
    }
}

这段代码有几个关键判断点。第一,in.readLine() 返回 null 表示对端正常关闭了输出流,此时应结束循环并释放连接。第二,try-with-resources 中的 socket 会自动关闭,关闭 Socket 会同时关闭其输入输出流。第三,如果客户端异常断开,readLine() 可能抛出 SocketException,需要捕获并记录,避免线程因未捕获异常而静默退出。

可以用 telnet 127.0.0.1 9000 或 nc 127.0.0.1 9000 做手工验证:输入一行回车,应看到 echo: ... 返回;再开一个终端连接同一端口,两个连接应能同时得到响应。若第二个连接一直无响应,通常说明服务端仍在单线程串行处理,或者第一个连接的 readLine 一直阻塞导致主线程无法回到 accept。

常见排障顺序:① 端口是否被占用(BindException: Address already in use);② 是否忘记 flush 或 println 导致客户端收不到数据;③ 是否用 readLine 但客户端没发换行;④ 是否在单线程里处理连接,导致后续客户端排队;⑤ 是否忘记关闭 Socket,造成文件描述符泄漏。

五、BIO 的边界与演进方向

“一连接一线程”在连接数几百以内、逻辑简单时足够用,但线程栈默认约 1MB,上万连接会带来巨大内存与上下文切换开销;同时大量线程阻塞在 read 上,CPU 利用率低。因此后续会引入线程池限制并发线程数、设置 soTimeout 避免永久阻塞、使用 NIO 的 Selector 以少量线程管理大量连接。但在学习这些之前,先把 ServerSocket、accept、流读写和阻塞行为这四件事弄清楚,后面的模型才有对照的基准。

本节最小可运行闭环:服务端 new ServerSocket(9000) → 循环 accept() → 每个连接交给线程 → readLine() 读一行 → println 回显 → 对端关闭时 readLine 返回 null → 连接关闭。用两个终端同时连接验证并发,用 ss -ltnp | grep 9000 或 netstat -ano | findstr 9000 确认监听状态。