Java Socket 编程:网络抽象的起点
核心问题: 你线上报过
Too many open files,调过ulimit -n 65535,配过连接池的maxConnections——但你有没有想过,fd 到底是什么?一个new Socket()在内核里到底分配了什么?read()卡住的时候,线程去哪了?一台机器到底能撑多少连接?本章从 OS 内核视角出发,把 Socket 从"一个 Java 对象"拆回它的本质:一个通过 fd 访问的内核通信端点、两块内核缓冲区、两对队列。
1. Socket 的本质:OS 如何抽象网络通信
1.1 从网卡到进程:数据的旅程
当一台机器的网线收到一个 TCP 包,数据要经过层层处理才能到达应用程序:
关键一步在传输层:内核根据报文的目标 IP + 目标端口(以及源 IP + 源端口)找到对应的 Socket,把数据塞进它的接收缓冲区。应用程序调用 read() 时,读的就是这个缓冲区——它不需要知道网卡型号、TCP 校验和、路由表,内核把这些全部处理好了。
Socket 的价值就在这里:它把复杂的网络协议栈封装成了一个"读写缓冲区"。对应用程序而言,网络通信和读写文件在接口层面几乎没有区别。
1.2 Socket 通过文件描述符访问
在 Unix/Linux 中,应用程序通过文件描述符(File Descriptor, fd) 访问 Socket。操作系统把一切 I/O 资源都抽象为 fd——普通文件、管道、设备、网络连接,对应用来说都是一个 int 数字。fd 是应用访问 Socket 的入口,不是 Socket 本身。
创建一个 Socket 时,内核做的事情:
- 在内核中建立 Socket 相关的通信对象(含协议栈状态)
- 在进程的文件描述符表中分配一个空位,让它关联到这个 Socket
- 返回这个 fd 的编号给应用
后续所有操作——read、write、close——都通过这个 fd 编号进行。这就是为什么 socket() 系统调用的返回值是一个 int,而不是一个"连接对象"。
Java 层面的映射:Java 的
Socket和ServerSocket对象内部持有一个 OS fd,socket.close()最终调用的就是 OS 的close(fd)。如果 Java 对象被 GC 回收但没有显式close(),fd 的释放时机并不可靠——不同 JDK 版本的底层清理实现不同,finalize()在 JDK 9 已被标记废弃,不能依赖 GC 或Cleaner来及时释放连接。这就是为什么必须用 try-with-resources 显式关闭 Socket。
1.3 五元组与连接标识
一个 TCP 连接由五元组唯一标识:
| 字段 | 含义 | 示例 |
|---|---|---|
| 源 IP | 发送方的 IP 地址 | 10.0.0.5 |
| 源端口 | 发送方的临时端口 | 43210 |
| 目标 IP | 接收方的 IP 地址 | 192.168.1.1 |
| 目标端口 | 接收方的监听端口 | 8080 |
| 协议 | TCP 或 UDP | TCP |
五元组相同的两个包属于同一条连接,五元组不同则属于不同连接。
服务端一个监听端口能接受多少连接?
很多初学者以为"一个端口只能一个连接",这是误解。服务端监听 8080 端口后,每 accept() 一个新连接,内核就创建一个新的 Socket(新的 fd),这个 Socket 的五元组中目标 IP:端口相同(都是 192.168.1.1:8080),但源 IP:端口不同。只要来源不同,就是不同的连接。
Server: listen(:8080)
Client A (10.0.0.5:43210) ──连接──► Server:8080 → accept() → fd=4
Client B (10.0.0.5:43211) ──连接──► Server:8080 → accept() → fd=5
Client C (10.0.0.6:51782) ──连接──► Server:8080 → accept() → fd=6三个连接共享同一个监听端口,但五元组各不相同。
一个监听端口能承载多少连接?
服务端可以在同一个本地 IP 和端口上接受大量 TCP 连接:每条已建立连接由本地地址、本地端口、远端地址、远端端口和协议共同区分,不需要独占监听端口。
容量上限不能只按端口数量相乘来估算。客户端固定本地 IP 连接同一个服务端地址时,可用源端口空间(/proc/sys/net/ipv4/ip_local_port_range,默认 32768~60999)会限制同时存在的连接组合;服务端则更早受 fd 上限、内核内存、线程模型和 accept() 处理速度的限制。多个本地 IP 能增加可用的地址组合,但不会消除内存、fd 和应用处理能力的限制。
对服务端而言,fd、内核内存和应用处理能力通常是优先检查的容量限制;对客户端和经过 NAT 的连接路径,临时端口与端口映射容量也可能成为瓶颈。评估容量应看实际连接数、fd 使用量、内存占用和连接状态,而不是用一个端口乘法推算整机承载能力。
1.4 Socket 的两种类型
| 类型 | 协议 | 特点 | 典型场景 |
|---|---|---|---|
| Stream Socket | TCP | 面向连接、可靠、有序、字节流 | HTTP、数据库连接、RPC |
| Datagram Socket | UDP | 无连接、不可靠、低延迟、数据报 | DNS、视频流、游戏状态同步 |
本书以 TCP Stream Socket 为主线,因为 Java 企业级开发中绝大多数网络通信基于 TCP。
2. Socket 系统调用与 Java 映射
Socket 编程的本质就是按顺序调用一组系统调用。每一步都对应一个 OS 内核操作,Java 对这些操作做了面向对象封装。
2.1 socket():创建端点
// OS 层
int fd = socket(AF_INET, SOCK_STREAM, 0);内核分配一个 struct socket 对象,绑定到进程的 fd 表中。此时还没有连接,只是一个"插座"。
// Java 层
ServerSocket serverSocket = new ServerSocket(); // 内部调用 socket()
Socket clientSocket = new Socket(); // 内部调用 socket()Java 的 new ServerSocket() 在构造时会创建底层 Socket 端点并拿到一个 fd。这里把它与传统 Linux 阻塞式 Socket 的 socket() 系统调用对应,是一种概念映射,而非所有 JDK 实现都必须遵守的逐行调用关系。另外,new Socket() 创建的是未连接的 Socket,new ServerSocket() 创建的是未绑定的监听端点,此时还没有开始监听。
2.2 bind() + listen():绑定端口、开始监听
// OS 层
struct sockaddr_in addr = { .sin_port = htons(8080), .sin_addr.s_addr = INADDR_ANY };
bind(fd, (struct sockaddr*)&addr, sizeof(addr)); // 绑定 IP:Port
listen(fd, 128); // 开始监听,backlog=128bind() 把 Socket 和一个 IP:Port 绑定。绑定后,操作系统知道"目标端口是 8080 的 TCP 包应该送给这个 Socket"。
listen() 把 Socket 从"主动连接"模式切换为"被动监听"模式。在 Linux TCP 实现中,监听 Socket 关联着两个队列——半连接队列(SYN queue)和全连接队列(accept queue),前者跟踪尚未完成三次握手的连接请求,后者保存已完成握手、等待 accept() 的连接。backlog 参数影响全连接队列的容量,但实际上限还受 net.core.somaxconn 等内核参数影响。
// Java 层
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(8080), 128); // bind() + listen()
// 或者一行搞定:
ServerSocket serverSocket = new ServerSocket(8080); // 内部自动 bind + listen,backlog 默认 502.3 accept():从全连接队列取出连接
// OS 层(阻塞)
int connFd = accept(fd, NULL, NULL);accept() 从全连接队列中取出一个已完成三次握手的连接,为它创建一个新的 fd。原来的监听 fd 继续监听,不受影响。
listen fd (fd=3, port 8080)
│
│ accept()
│
▼
conn fd (fd=4, 10.0.0.5:43210 → 192.168.1.1:8080) ← 新的 fd,独立的连接
conn fd (fd=5, 10.0.0.6:51782 → 192.168.1.1:8080) ← 又一个// Java 层
Socket client = serverSocket.accept(); // 阻塞,直到有新连接
// client 内部持有一个新的 fd
accept()返回的是一个新的 Socket,和原来的ServerSocket完全独立。ServerSocket只负责监听,不负责数据传输。数据传输由accept()返回的Socket完成。
2.4 connect():客户端发起三次握手
// OS 层
struct sockaddr_in serverAddr = { .sin_port = htons(8080), .sin_addr.s_addr = inet_addr("192.168.1.1") };
connect(fd, (struct sockaddr*)&serverAddr, sizeof(serverAddr));connect() 触发 TCP 三次握手。握手完成后,客户端的 Socket 进入 ESTABLISHED 状态,可以开始读写。
// Java 层
Socket socket = new Socket("192.168.1.1", 8080); // 内部调用 socket() + connect()2.5 read() / write():数据在内核缓冲区的流转
连接建立后,数据的读写路径:
发送方: 应用 write(buf) → 用户缓冲区 → 内核发送缓冲区 → TCP 分段 → 网卡发出
接收方: 网卡收到 → 内核接收缓冲区 → 应用 read(buf) → 用户缓冲区关键理解:write() 不等于"数据已发出",read() 不等于"数据来自网络"。write() 只是把数据从用户空间拷贝到内核的发送缓冲区,真正的发送由内核的 TCP 协议栈异步完成。read() 只是从内核的接收缓冲区拷贝数据到用户空间。
// Java 层
OutputStream out = socket.getOutputStream();
out.write("hello".getBytes(StandardCharsets.UTF_8)); // 交给底层 Socket 输出实现,进入内核发送缓冲区
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];
int len = in.read(buf); // 从内核接收缓冲区读取2.6 close():四次挥手与 fd 释放
// OS 层
close(fd);close() 做两件事:
- TCP 层:关闭本端 Socket,正常关闭通常通过 TCP 终止过程通知对端;但具体报文和状态迁移取决于连接状态、未发送数据以及
SO_LINGER等配置,不能把每次close()都等同于必然完成标准的四次挥手 - OS 层:释放 fd 编号,回收内核中的 Socket 对象
// Java 层
socket.close(); // 内部调用 close(fd)同一个服务跑到线上,偶尔看
netstat -ant | grep 8080会发现十几个CLOSE_WAIT状态的连接越积越多。CLOSE_WAIT的意思是"对端发了 FIN,但本端还没调close()"——大概率是你代码里某个异常分支没走到socket.close(),或者连接池回收逻辑有漏,fd 一直被占着。不处理的话,CLOSE_WAIT 会一直积压到 fd 耗尽,新连接再也建不起来。
close()vsshutdown():close()同时关闭读和写两个方向。shutdown()可以只关闭一个方向(shutdownOutput()关写,shutdownInput()关读),另一个方向继续使用。典型场景:客户端发完请求后shutdownOutput(),告诉服务端"我发完了",但仍继续读取响应。
完整的系统调用链总结:
服务端 客户端
────── ──────
socket() → fd socket() → fd
bind(:8080) connect(192.168.1.1:8080)
listen(128) │ 三次握手
│ │
accept() → connFd ◄───────────────┘
│ │
read(connFd) ◄──── write(fd) ────►│
write(connFd) ────► read(fd) ────►│
│ │
close(connFd) ◄── 四次挥手 ──── close(fd)3. 内核视角:Socket 背后的数据结构
3.1 发送缓冲区与接收缓冲区
每个 TCP Socket 在内核中有两块缓冲区:
┌────────────────────────────────────────────────┐
│ 进程用户空间 │
│ │
│ write(buf) ──► 用户数据 │
│ │ │
└───────────────────┼────────────────────────────┘
│ 拷贝(CPU 参与)
┌───────────────────▼────────────────────────────┐
│ 内核空间 │
│ │
│ ┌──────────────────────────┐ │
│ │ 发送缓冲区(sndbuf) │ → TCP 协议栈 → 网卡 │
│ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ 接收缓冲区(rcvbuf) │ ← 网卡 ← TCP 协议栈│
│ └──────────────────────────┘ │
│ │ │
└───────────────────┼────────────────────────────┘
│ 拷贝(CPU 参与)
┌───────────────────▼────────────────────────────┐
│ read(buf) ◀── 用户数据 │
└────────────────────────────────────────────────┘缓冲区大小由 Socket 选项 SO_SNDBUF 和 SO_RCVBUF 控制;Java 设置方法见 §4.2,参数取舍见 TCP 性能参数与 Java 实践。默认值因 OS 而异,Linux 的实际值受 tcp_rmem / tcp_wmem 等内核参数和自动调优机制影响,应用通过 Socket 选项请求的值不一定等于内核最终采用的容量。
发送缓冲区满会怎样? write() 可能阻塞,直到内核发出一些数据腾出空间。这就是"写阻塞"——它可能源于发送缓冲区背压,也可能受对端接收窗口、网络传输和对端处理速度影响。
接收缓冲区空会怎样? read() 可能阻塞,直到有数据到达。这就是"读阻塞"——但阻塞不是唯一结果:对端关闭输出后,read() 会返回 -1 表示流结束;连接被重置或发生 I/O 错误时,则会抛出异常。
3.2 全连接队列与半连接队列
活动期间,你发现新连接全部超时,但服务端 CPU 和内存都正常。 同事怀疑是网络设备问题,你用
ss -ltn | grep 8080看了一眼——Recv-Q已经接近甚至超过了Send-Q。已经完成握手、等待accept()的连接积压了,应用接受连接的速度可能跟不上到达速度。
listen() 之后,内核为这个监听 Socket 维护两个队列:
全连接队列满(accept queue full)时:
- 默认行为(
tcp_abort_on_overflow=0):内核丢弃新的 ACK,客户端以为连接成功了,服务端却不知道——客户端发数据会超时重传,最终可能收到 RST - 设置
tcp_abort_on_overflow=1:内核直接发 RST,客户端立刻收到Connection reset
如何判断队列溢出?
# 查看监听端口的队列状态
$ ss -ltn | grep 8080
State Recv-Q Send-Q Local Address:Port
LISTEN 129 128 0.0.0.0:8080
# ↑ ↑
# 当前排队数 backlog 上限
# Recv-Q 持续接近或达到 Send-Q 时,需结合溢出计数器确认是否溢出排查时关注的内核计数器:
$ netstat -s | grep "listen"
12345 times the listen queue of a socket overflowed这个数字持续增长,说明应用的 accept() 速度跟不上连接到达速度——要么加快 accept(多线程 accept),要么增大 backlog。
3.3 阻塞的本质:线程在内核的哪里等
当应用调用 read() 但接收缓冲区为空时,线程到底发生了什么?
线程调用 read(fd, buf, len)
│
▼
内核检查接收缓冲区 → 空
│
▼
内核任务状态: RUNNING → TASK_INTERRUPTIBLE(睡眠)
线程从 CPU 运行队列中移除
线程被挂到 Socket 的"等待队列"上
│
│ ... 数据到达 ...
│
▼
内核中断处理 → 数据写入接收缓冲区 → 唤醒等待队列上的线程
内核任务状态: TASK_INTERRUPTIBLE → RUNNING
线程从 read() 处返回阻塞不是"线程在忙等/自旋",而是线程被操作系统挂起了——它不占 CPU 时间片,不消耗 CPU 资源。代价是占用线程相关的内存(线程栈、线程对象、内核调度结构)和调度资源。
注意这里的 TASK_INTERRUPTIBLE 是 Linux 内核的任务状态,与 Java 的 Thread.State(如 RUNNABLE、WAITING)是不同层次的概念,不能直接画等号。线程栈大小也取决于 JVM 配置和平台,不能把 1MB 当作固定值。
这就是 BIO 的核心代价:线程不消耗 CPU,但消耗内存和调度资源。一个阻塞在 read() 上的线程,CPU 利用率为 0,但内存和 fd 一直被占着。
3.4 一台机器能承载多少 Socket
这是一个工程问题,瓶颈在多个层次:
| 层次 | 限制 | 默认值 | 调整方式 |
|---|---|---|---|
| fd 上限(单进程) | 每个 Socket 占一个 fd | 1024 | ulimit -n 65535 |
| fd 上限(系统级) | 所有进程的 fd 总和 | ~100 万 | /proc/sys/fs/file-max |
| 端口范围(客户端) | 临时端口数量 | 32768~60999(~28000) | /proc/sys/net/ipv4/ip_local_port_range |
| 内核内存 | 每个 Socket 约 3~10KB | — | 取决于缓冲区配置 |
| 全连接队列 | listen backlog | 128(取 min(backlog, somaxconn)) | listen(backlog) + /proc/sys/net/core/somaxconn |
| 半连接队列 | SYN queue 大小 | 256~1024 | /proc/sys/net/ipv4/tcp_max_syn_backlog |
实际瓶颈通常在 fd 和内存:
一台 16GB 内存的服务器:
fd 上限设为 65535 → 理论最多 65535 个 Socket
每个 Socket 内核开销 5KB → 65535 × 5KB ≈ 320MB(可接受)
每个连接的业务线程栈 1MB → 65535 × 1MB ≈ 64GB(BIO 模型下不可能)
如果改用 NIO 的事件驱动模型:
一个线程通过 Selector 等待多个连接的就绪事件,不必为每个连接阻塞一个线程
内存开销 ≈ 320MB(Socket 内核对象) + 少量线程栈 → 可行当连接数很大、连接大部分时间都在等待 I/O 时,一连接一线程的 BIO 模型会先被线程栈和调度资源限制住。NIO 用 Selector 复用线程等待多个连接的 I/O 事件,能降低这部分成本——但不是所有高并发场景的唯一选择,JDK 21 的虚拟线程也能降低大量阻塞式任务的线程管理成本。是否用 NIO,应结合实际连接负载、业务处理方式和资源瓶颈判断。
4. 在 Java 中设置 Socket 选项
Socket 选项最终由 setsockopt() 或等价的系统调用设置。Java 的阻塞式 API 使用专用方法,NIO Channel 使用 setOption()。选项含义、调参边界与验证方法见 TCP 性能参数与 Java 实践,本节只说明 Java API 的对应关系和设置时机。
4.1 先确定设置时机
SO_REUSEADDR、SO_REUSEPORT应在bind()前设置;绑定后再修改通常不会生效。SO_RCVBUF、SO_SNDBUF应在绑定或连接前设置,设置值只是建议,实际值可能被系统限制或调整。TCP_NODELAY和SO_KEEPALIVE属于已建立的 TCP 连接,可在创建 Socket 后、连接前设置,也可在连接后设置,具体取决于 API 和平台实现。- 使用 Channel 时,先通过
supportedOptions()确认当前实现和协议支持该选项,再调用setOption();SO_REUSEPORT的可支持性存在操作系统差异,不能当作所有平台统一语义。
4.2 Java API 对应关系
| 选项 | ServerSocket | Socket | Channel |
|---|---|---|---|
SO_REUSEADDR | setReuseAddress(true) | setReuseAddress(true) | setOption(SO_REUSEADDR, true) |
SO_REUSEPORT | — | — | setOption(SO_REUSEPORT, true) |
TCP_NODELAY | — | setTcpNoDelay(true) | setOption(TCP_NODELAY, true) |
SO_KEEPALIVE | — | setKeepAlive(true) | setOption(SO_KEEPALIVE, true) |
SO_RCVBUF | setReceiveBufferSize(n) | setReceiveBufferSize(n) | setOption(SO_RCVBUF, n) |
SO_SNDBUF | — | setSendBufferSize(n) | setOption(SO_SNDBUF, n) |
ServerSocket serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.setReceiveBufferSize(256 * 1024);
serverSocket.bind(new InetSocketAddress(8080), 1024);
Socket socket = new Socket("example.com", 8080);
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);ServerSocket 不提供 TCP_NODELAY 和 SO_KEEPALIVE 专用方法,因为这两项属于已建立的连接,而不是监听端点。是否允许在连接后修改、以及操作系统如何执行设置,仍需以目标 JDK、协议类型和 supportedOptions() 结果为准。
5. 动手:用 Java Socket 跑通一个 Echo
前面四节讲的是 Socket 的"是什么"和"怎么工作"。这一节用最小的代码示例把理论变成可运行的程序。
5.1 Echo Server
import java.io.*;
import java.net.*;
import java.util.concurrent.*;
public class EchoServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
ExecutorService pool = new ThreadPoolExecutor(
100, 100, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500),
new ThreadPoolExecutor.AbortPolicy());
System.out.println("Echo Server started on port 8080");
while (true) {
Socket client = serverSocket.accept(); // 阻塞等待连接
try {
pool.execute(() -> {
try (client) {
InputStream in = client.getInputStream();
OutputStream out = client.getOutputStream();
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) != -1) { // 阻塞读取
out.write(buf, 0, len); // Echo 回写
out.flush();
}
} catch (IOException e) {
// 客户端断开
}
});
} catch (RejectedExecutionException e) {
client.close(); // 队列已满,拒绝该连接,避免任务无限积压
}
}
}
}5.2 Echo Client
import java.io.*;
import java.net.*;
public class EchoClient {
public static void main(String[] args) throws IOException {
try (Socket socket = new Socket("localhost", 8080)) {
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream();
out.write("hello\n".getBytes(StandardCharsets.UTF_8));
out.flush();
byte[] buf = new byte[1024];
int len = in.read(buf);
if (len != -1) {
System.out.println("Server replied: "
+ new String(buf, 0, len, StandardCharsets.UTF_8));
}
}
}
}5.3 代码剖析
out.flush() 在这里是否必要?
直接使用 socket.getOutputStream() 返回的输出流时,write() 会把数据交给底层 Socket 输出实现,并不存在一个默认的独立应用层缓冲区,因此这里的 flush() 实际没有额外作用。只有当你显式包了一层 BufferedOutputStream 时,write() 才可能先把数据留在 Java 缓冲区,此时 flush() 才有意义,它把缓冲区数据交给底层输出流。
无论是否调用 flush(),都不能据此断定对端已经收到数据:TCP 传输、对端读取和业务处理是三个不同阶段。
为什么要用有界线程池而不是 CachedThreadPool?
CachedThreadPool 无上限,连接暴涨时会创建过多线程。上面的 ThreadPoolExecutor 限制了并发线程数和等待队列长度,超出队列容量的任务会被拒绝而不是无限积压——这是保护服务端的基本手段。队列满时的拒绝策略需要显式处理:要么关闭刚接入的连接快速失败,要么记录拒绝计数,不能只靠"任务在队列里等着"来兜底。
单次 read() 读到的是完整的消息吗?
不是。TCP 是字节流,不保留应用层消息边界。客户端发 hello\nworld\n,服务端一次 read() 可能只读到 hello\n,也可能一次读到两行,甚至只读到半个字符(多字节 UTF-8 字符被截断时,对这一段单独解码会产生替换字符)。上面的示例只演示"读一次拿到多少字节",不代表读完一条完整业务消息。真实协议需要定义消息边界——固定长度、长度字段或分隔符——并按约定循环读取、解析。
阻塞 read() 会一直等下去吗?
不会,除非你希望它一直等。socket.setSoTimeout(30000) 可以给阻塞读设置超时,超时会抛 SocketTimeoutException,让工作线程有机会退出,而不是被一个"连上后什么都不发"的空闲连接永久占用。它只约束读操作,不约束 accept() 的等待,也不约束写操作;是否设置、设多大,应由业务对空闲连接的处理策略决定,30 秒只是示例,不是通用生产值。
5.4 一连接一线程的局限
上面的 Echo Server 是经典的 BIO 模型:每个连接占一个线程。线程大部分时间阻塞在 read() 上,不消耗 CPU,但消耗内存和调度资源。
1000 个连接 → 1000 个线程 → ~1GB 栈内存 → 勉强可行
10000 个连接 → 10000 个线程 → ~10GB 栈内存 → 不可行这个局限不是 Socket 的问题,而是 BIO 线程模型的问题。解决方案之一是 NIO——用一个线程通过 Selector 监听多个 Channel 的事件,线程只在"有数据可读"时才被唤醒,不需要为每个连接阻塞一个线程;JDK 21 的虚拟线程也能缓解大量阻塞式任务的线程成本。NIO 是下一章的内容。
6. 本章小结
| 概念 | 要点 |
|---|---|
| Socket 的本质 | OS 提供的网络编程抽象,通过 fd 访问,由协议栈支撑 |
| 五元组 | {源IP, 源端口, 目标IP, 目标端口, 协议} 唯一标识一条连接 |
| 一个端口多条连接 | 服务端一个监听端口可以 accept 出成千上万条连接 |
| 系统调用链 | socket() → bind() → listen() → accept() → read()/write() → close() |
| 内核缓冲区 | 每个 Socket 有收发两块缓冲区,read/write 操作的是缓冲区而非网络 |
| 全连接队列 | accept queue 溢出时连接被丢弃,需关注 ss -ltn 中的 Recv-Q |
| fd 限制 | 单进程默认 1024,高并发需调 ulimit -n |
| Socket 选项 | Java API 提供设置入口;参数含义和调参边界见 TCP 性能参数章节 |
| BIO 的局限 | 一连接一线程,内存和调度资源扛不住;NIO 或虚拟线程可缓解 |