《Java 网络编程基础》3.1 Socket 与 ServerSocket 基本用法

从 linkd 的最小阻塞 Echo 入口出发,拆开 ServerSocket 的 bind、listen、accept 与 Socket 的读写阻塞点,用本机实测说明 backlog、SO_REUSEADDR 与 TIME_WAIT 重绑行为,给出可停止的 Echo 服务器、半包边界和 netstat 诊断命令。

本节目标:能写一个可停止的阻塞 Echo 服务器,并说清 accept、read、write 各自阻塞在哪里。
适用版本:JDK 25(Temurin 25.0.5+8);java.net.ServerSocket、java.net.Socket。

承接的问题

2.3 解决了「地址怎么表示」:InetAddress 拿到 IP,端口是一个 0–65535 的整数,NetworkInterface 能列出本机网卡。但地址本身不会接收连接,必须把它绑定到一个监听套接字上。

linkd 的第一版入口就停在这个缺口上。LinkdMain 能读取启动参数里的端口,能打印本机地址,却没有任何东西在 9090 上等待连接。客户端执行 nc 127.0.0.1 9090 会立刻得到 Connection refused,因为内核在这个端口上找不到监听者。

这一节先把最小的阻塞 Echo 跑起来:ServerSocket 负责 bind、listen、accept,Socket 负责读写字节。它不处理半包,也不处理超时,这两件事分别留给第 4 章和 3.2。目标不是写出生产服务器,而是把每个阻塞点看清楚,后面换 NIO 时才知道自己替换掉了什么。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:ServerSocket 的 bind、listen 与 accept

new ServerSocket(port) 一行就完成了 bind 和 listen,但它不给你在 bind 之前设置选项的机会。要控制 SO_REUSEADDR、backlog 和绑定地址,用无参构造加 bind:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;

public class ServerSocketBindDemo {
    public static void main(String[] args) throws IOException {
        try (ServerSocket server = new ServerSocket()) {
            server.setReuseAddress(true);
            server.bind(new InetSocketAddress("127.0.0.1", 9091), 128);
            System.out.println("listening=" + server.getLocalSocketAddress());
            System.out.println("reuseAddress=" + server.getReuseAddress()
                    + " receiveBuffer=" + server.getReceiveBufferSize());
        }
    }
}

本机输出:

listening=/127.0.0.1:9091
reuseAddress=true receiveBuffer=131072

三个参数各有边界:

  • 地址:127.0.0.1 只接受本机连接;0.0.0.0 或 new InetSocketAddress(port) 绑定所有网卡。开发机图省事写后者,等于把端口暴露到局域网。linkd 默认绑 loopback,需要对外时由配置显式覆盖。
  • backlog:bind 的第二个参数是「等待 accept 的连接队列长度」的请求值,不是并发连接总数,也不是硬上限。队列具体是否包含尚未完成握手的半连接,由内核实现决定。本机 sysctl kern.ipc.somaxconn 是 128,内核会按自己的上限截断。把 backlog 写成 10000 不会让内核真的排 10000 个。
  • reuseAddress:必须在 bind 之前设置,bind 之后再调用无效。本机 JDK 25 上 new ServerSocket() 的默认值就是 true,但这是平台相关行为,代码里显式写出来是给读代码的人看的,不是冗余。

accept() 是第一个真正的阻塞点。它从队列里取出一个已完成握手的连接,返回一个新的 Socket;原来的 ServerSocket 继续监听。返回的 Socket 有独立的文件描述符和独立的输入输出流,和监听套接字不是同一个东西。

try (Socket connection = server.accept()) {
    System.out.println("accepted=" + connection.getRemoteSocketAddress()
            + " local=" + connection.getLocalSocketAddress());
}

本机输出:

accepted=/127.0.0.1:59650 local=/127.0.0.1:9091

远端地址是客户端的临时端口,本地地址是监听地址。一个 ServerSocket 可以先后 accept 成千上万个连接,但同一时刻只处理一个,除非把处理逻辑交给别的线程。阻塞模式下,accept 返回之前,调用线程什么也做不了。

核心机制二:Socket 的输入输出流与阻塞点

Socket 没有 read 和 write 方法,读写都走 getInputStream() 和 getOutputStream()。输入流是 InputStream,输出流是 OutputStream,语义和文件流一样,只是数据来自内核的接收缓冲区、写到内核的发送缓冲区。

先看一个最小的 Echo 处理:

package com.plume.linkd.app;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public final class SocketEchoHandler {
    public static void handle(Socket socket) throws IOException {
        socket.setTcpNoDelay(true);
        InputStream in = socket.getInputStream();
        OutputStream out = socket.getOutputStream();
        byte[] buffer = new byte[1024];
        int n;
        while ((n = in.read(buffer)) != -1) {
            out.write("echo: ".getBytes(StandardCharsets.UTF_8));
            out.write(buffer, 0, n);
            out.flush();
        }
    }
}

这段代码有四个必须理解的细节:

  1. read(byte[]) 返回的是本次读到的字节数,不是消息长度。 客户端一次写 5 个字节,服务端可能一次读到 5 个,也可能分两次读到 2 和 3。循环必须写到 -1 为止,-1 才表示对端关了输出方向(发送了 FIN)。返回 0 只在传入长度为 0 的数组时出现,不能拿它当 EOF。
  2. read 在没有数据时阻塞。 它阻塞到至少有一个字节可读、对端关闭、或者设置了读超时。3.2 会专门处理最后一种。
  3. write 返回不代表对端收到。 它只表示数据被复制进了内核发送缓冲区。缓冲区满时 write 会阻塞,这一点在 3.2 用线程栈证明。
  4. flush() 在 SocketOutputStream 上是空操作。 套接字流本身不带用户态缓冲;只有套了 BufferedOutputStream 才需要 flush。上面的代码保留 flush 是为了让意图明确,去掉不影响结果。

跑一次完整往返:

try (Socket client = new Socket()) {
    client.connect(new InetSocketAddress("127.0.0.1", 9091), 2000);
    client.getOutputStream().write("ping\n".getBytes(StandardCharsets.UTF_8));
    byte[] buffer = new byte[64];
    int n = client.getInputStream().read(buffer);
    System.out.println(new String(buffer, 0, n, StandardCharsets.UTF_8).trim());
}

本机输出:

echo: ping

这里客户端只读了一次就假定拿到了完整响应。Echo 服务器把 echo: 和 ping\n 分两次 write,内核可能合并成一个段,也可能分成两个段;客户端一次 read 可能只拿到 echo: pi。示例能跑通不代表协议正确,第 4 章会用长度域和分隔符把消息边界定下来。

核心机制三:backlog、SO_REUSEADDR 与 TIME_WAIT

backlog 和 SO_REUSEADDR 经常被混在一起讲,其实解决的是两个阶段的问题。

backlog 管的是「连接已经建立、还没被 accept」的排队长度。队列满了之后,新连接能不能挤进来由内核决定:有的实现直接拒绝,有的丢掉 SYN 让客户端重传。Java 层看不到这个队列,只能通过 bind 的参数给内核一个建议值。压测时看到连接建立慢、客户端超时,但服务端 CPU 不高,要怀疑 accept 循环太慢导致队列堆积,而不是先调大 backlog。

SO_REUSEADDR 管的是「监听套接字能不能 bind 一个仍被旧连接占用的本地端口」。最典型的场景是服务重启:旧进程退出后,已建立的连接会在服务端留下 TIME_WAIT,默认持续 2MSL。新进程如果直接 bind 同一端口,可能得到 Address already in use。

本机做一个对照实验。服务端先关连接,让 9093 端口进入 TIME_WAIT,然后分别用 reuseAddress=false 和 true 重新 bind:

ServerSocket first = new ServerSocket();
first.setReuseAddress(true);
first.bind(new InetSocketAddress("127.0.0.1", 9093), 50);

// 建立一条连接,服务端先 close,客户端随后 close,制造 TIME_WAIT
try (Socket client = new Socket("127.0.0.1", 9093);
     Socket accepted = first.accept()) {
    accepted.close();
    Thread.sleep(200);
    client.close();
}
first.close();
Thread.sleep(200);

try (ServerSocket second = new ServerSocket()) {
    second.setReuseAddress(false);
    second.bind(new InetSocketAddress("127.0.0.1", 9093), 50);
    System.out.println("rebind reuse=false: ok");
} catch (BindException e) {
    System.out.println("rebind reuse=false: " + e.getMessage());
}

try (ServerSocket third = new ServerSocket()) {
    third.setReuseAddress(true);
    third.bind(new InetSocketAddress("127.0.0.1", 9093), 50);
    System.out.println("rebind reuse=true: ok");
}

本机输出:

rebind reuse=false: Address already in use
rebind reuse=true: ok

TIME_WAIT 不是 bug,它是 TCP 防止旧连接的迟到报文被新连接误收的机制。SO_REUSEADDR 让新监听者可以复用这个端口,但不会让两个同时活跃的监听者共享同一端口;那是 SO_REUSEPORT 的职责,JDK 标准 API 不直接暴露,不在本节范围。

服务端跑起来后,用 macOS 的 netstat 确认监听状态:

netstat -anv -p tcp | grep '\.9094'

本机输出:

tcp4       0      0  127.0.0.1.9094         *.*                    LISTEN                 0            0  131072  131072             java:8171   00000 00000006 0000000002b853e3 00000000 00000800      1      0 000000

LISTEN 表示套接字在等连接;两列 0 是接收队列和发送队列的当前字节数;131072 是接收和发送缓冲区大小;最后一列是进程。macOS 的可视化连接工具和 Linux 不同,连接诊断就用 netstat、lsof 和 tcpdump,不要照抄 Linux 教程里的命令。

在 linkd 里落地

把上面的片段拼成 linkd 的最小入口,拆成两个类:BlockingEchoServer 管生命周期,SocketEchoHandler 管单连接读写。这个版本只用于第 3 章验证阻塞模型,第 6 章会被 Selector 版本替换。

package com.plume.linkd.app;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public final class BlockingEchoServer {

    private final int port;
    private final Set<Socket> connections = ConcurrentHashMap.newKeySet();
    private volatile boolean running;
    private ServerSocket server;

    public BlockingEchoServer(int port) {
        this.port = port;
    }

    public int start() throws IOException {
        server = new ServerSocket();
        server.setReuseAddress(true);
        server.bind(new InetSocketAddress("127.0.0.1", port), 128);
        running = true;
        Thread acceptor = new Thread(this::acceptLoop, "linkd-accept");
        acceptor.start();
        return server.getLocalPort();
    }

    private void acceptLoop() {
        while (running) {
            try {
                Socket socket = server.accept();
                connections.add(socket);
                Thread handler = new Thread(() -> {
                    try (socket) {
                        SocketEchoHandler.handle(socket);
                    } catch (IOException e) {
                        System.getLogger("linkd").log(System.Logger.Level.WARNING,
                                "connection failed: " + socket.getRemoteSocketAddress(), e);
                    } finally {
                        connections.remove(socket);
                    }
                }, "linkd-echo");
                handler.start();
            } catch (IOException e) {
                if (running) {
                    System.getLogger("linkd").log(System.Logger.Level.ERROR, "accept failed", e);
                }
            }
        }
    }

    public void stop() {
        running = false;
        try {
            if (server != null) {
                server.close();
            }
        } catch (IOException ignored) {
            // close 只用于唤醒 accept,失败不影响退出
        }
    }
}

start() 返回实际端口,是为了测试时能传 0 让内核分配临时端口。acceptLoop 在 server.close() 之后会从 accept 抛出 SocketException: Socket closed;因为 running 已经变成 false,这个异常被静默吞掉,这是正常的停止路径,不是故障。

stop() 只关闭监听套接字,已接受的连接还在 connections 里。3.3 会把「排空写队列、半关闭、关闭连接」补全;本节先保证 accept 能停下来。每接受一个连接就起一个平台线程,这个模型在连接数上千时会耗尽线程栈,第 6 章换 Selector 的动机就来自这里。

LinkdMain 只需要把端口和关闭钩子接上:

package com.plume.linkd.app;

public final class LinkdMain {
    public static void main(String[] args) throws Exception {
        int port = args.length > 0 ? Integer.parseInt(args[0]) : 9090;
        BlockingEchoServer server = new BlockingEchoServer(port);
        int bound = server.start();
        Runtime.getRuntime().addShutdownHook(
                new Thread(server::stop, "linkd-shutdown"));
        System.out.println("linkd listening on " + bound);
        Thread.sleep(Long.MAX_VALUE);
    }
}

Thread.sleep(Long.MAX_VALUE) 让主线程停住,进程不会因为 main 返回而进入退出流程。acceptor 和 handler 都是非守护线程,严格来说不睡也不会立刻退出,但显式停住让「服务器进程一直运行到收到信号」这件事写在代码里。3.3 会把这个 LinkdMain 的关闭钩子接到真正的排空逻辑上。

常见错误与边界

错误一:用无参 new ServerSocket(9090) 绑定所有网卡。 它等价于绑定 0.0.0.0,本机所有网络接口都能连进来。开发机的防火墙不一定拦住,云主机上就是直接暴露。linkd 明确写 127.0.0.1,需要对外时再通过配置改地址。

错误二:以为设置 running=false 就能让 accept 返回。 accept 阻塞在系统调用里,不会检查 Java 字段。不调用 server.close(),线程会一直挂着。关闭后 accept 抛 SocketException: Socket closed,这是预期路径。

错误三:把一次 read 当成一条消息。 阻塞套接字是字节流,没有消息边界。客户端写 100 字节,服务端可能读到 100、60、40,甚至先读到 1。Echo 能工作只是因为原样回写;一旦要解析协议,必须自己定帧。

错误四:用 read() == 0 判断连接关闭。 无参 read() 返回 0–255 的字节值或 -1;read(byte[]) 返回读取长度或 -1。只有 -1 是 EOF。把 0 当关闭会误判合法的 0x00 字节。

边界:backlog 不是并发上限。 它只影响握手完成后的排队长度。真正的并发上限由线程数、文件描述符上限和内存决定。ulimit -n 在 macOS 上默认不高,连接多了会先在 accept 或 Socket 创建处失败。

边界:SO_REUSEADDR 不解决端口冲突。 如果另一个进程正在 LISTEN 同一地址和端口,设置 reuse 也 bind 不上;它只处理 TIME_WAIT 这类残留状态。

小结

  • ServerSocket 的 bind(addr, backlog) 同时完成绑定和监听,accept() 返回一个新的 Socket,监听套接字继续工作。
  • backlog 是内核队列长度的建议值,本机 kern.ipc.somaxconn 为 128,调大 Java 参数不会突破内核上限。
  • SO_REUSEADDR 必须在 bind 前设置;本机实测关闭后 false 得到 Address already in use,true 可以立即重绑。
  • 阻塞 read 等到至少一个字节、EOF 或超时;write 只复制到内核缓冲区,满了会阻塞。
  • read(byte[]) 的返回值是本次字节数,-1 才是 EOF;消息边界要靠第 4 章的帧格式。
  • BlockingEchoServer 用 server.close() 唤醒 accept,每个连接一个平台线程,这个模型就是第 6 章要替换的对象。

Echo 能跑起来之后,下一个问题是「等多久」。客户端连不上、服务端迟迟不响应、发送缓冲区满导致写卡住,这三种情况在代码里对应完全不同的 API。下一节把它们分开测量。

阅读导航:上一节:2.3 InetAddress、端口与网络接口 · 下一节:3.2 连接、读、写超时 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发