Java - 网络与 IO 篇

9.0 开篇:后端为什么绕不开网络和 IO

一起复盘一下:请求从浏览器到你的 Controller,要经过这些关卡——

DNS 找 IP → TCP 三次握手 → TLS 握手 → HTTP 解析
→ 内核把数据放进 socket 缓冲区 → 你的线程把数据读出来
→ 业务处理 → 写回 socket → 四次挥手

JVM 管的是”处理”,网络与 IO 管的是”进出”。很多线上问题(接口慢、连接数打满、CPU 不高但吞吐上不去)根子都在这一段。

学习地图:

分层(数据怎么封装) → TCP(怎么保证可靠) → HTTP(应用怎么说话)
→ IO 模型(内核怎么把数据交给你)→ 零拷贝(怎么少搬几次)
→ Netty(怎么把上面这些工程化)→ 排错(线上怎么查)

站内还有两篇更深入的专题,本文是面试速查版:


9.1 网络分层:寄一个快递

四层模型

层协议负责快递类比
应用层HTTP、DNS、SSH、MQTT业务语义你写的信的内容
传输层TCP、UDP、QUIC端到端、可靠/不可靠快递单:收件人、电话、签收
网络层IP、ICMP寻址、路由分拣中心:决定走哪条线路
链路层Ethernet、ARP、Wi-Fi相邻节点传输运输车、飞机

封装:数据从应用层往下走,每层加一个头(就像每层快递套一个袋子),到对端再一层层拆开。这个动作叫封装与解封装。

[ Ethernet 头 ][ IP 头 ][ TCP 头 ][ HTTP 数据 ][ Ethernet 尾 ]

MTU 与 MSS

  • MTU(Maximum Transmission Unit):链路层一帧最多 1500 字节(以太网)
  • MSS(Maximum Segment Size):TCP 一个报文段最多多少数据 = MTU - IP 头 20 - TCP 头 20 = 1460 字节

超过 MTU 会被分片,任何一片丢了整个包都要重传,效率很差。所以 TCP 会按 MSS 切分,尽量不分片。


9.2 TCP 三次握手与四次挥手

三次握手:确认”双方都能说、都能听”

类比:打电话。 你:“喂,能听到吗?“(SYN) 对方:“能听到,你能听到我吗?“(SYN + ACK) 你:“能听到。“(ACK)

少一次会怎样?如果你只说了”喂”就挂断,对方不知道你能否听到他——双向通道只验证了一半。

客户端                          服务端
  | -------- SYN (seq=x) -------> |   SYN_SENT → SYN_RCVD
  | <--- SYN+ACK (seq=y,ack=x+1) -|
  | -------- ACK (ack=y+1) -----> |   ESTABLISHED
  | ======== 开始传数据 ========== |
为什么是三次解释
确认双方收发能力两次只能确认客户端→服务端,服务端→客户端还没验证
同步初始序列号双方都要告诉对方自己的起始序号
防止历史重复连接老的 SYN 迟到,若只两次握手,服务端会白白建立一个无效连接

SYN 洪泛攻击:攻击者只发 SYN 不回 ACK,服务端半连接队列被占满。

防御说明
SYN Cookie不保存半连接状态,把信息编码进序列号;net.ipv4.tcp_syncookies=1
增大半连接队列tcp_max_syn_backlog
缩短 SYN+ACK 重试tcp_synack_retries

注意区分两个队列:半连接队列(收到 SYN 未完成握手)和全连接队列(已完成握手、等待应用 accept)。全连接队列满了,新连接会被拒绝或丢弃,对应参数 somaxconn 和 listen() 的 backlog。

四次挥手:为什么比握手多一次

TCP 是全双工的,两个方向要分别关闭。收到对方的 FIN 只代表”他不再发数据了”,你还可以继续发——这叫半关闭。

客户端                          服务端
  | -------- FIN (seq=u) --------> |   FIN_WAIT_1 → CLOSE_WAIT
  | <---------- ACK --------------|   (我收到了,但可能还有数据要发)
  | <--------- FIN (seq=v) -------|   FIN_WAIT_2 → LAST_ACK
  | ---------- ACK --------------> |   TIME_WAIT → CLOSED
状态出现在哪含义
FIN_WAIT_1/2主动关闭方已发 FIN,等对方关
CLOSE_WAIT被动关闭方收到 FIN,但应用还没 close()
LAST_ACK被动关闭方已发 FIN,等最后 ACK
TIME_WAIT主动关闭方等 2MSL 后彻底关闭

TIME_WAIT 为什么要等 2MSL

  1. 保证最后一个 ACK 能到对端:ACK 丢了,对方会重发 FIN,还能再回一次
  2. 让旧连接的残留报文在网络中消散,不会串到新连接里

Linux 上 MSL = 30 秒,所以 TIME_WAIT 持续 60 秒。

TIME_WAIT 太多怎么办:

net.ipv4.tcp_tw_reuse = 1        # 允许复用 TIME_WAIT 连接(仅客户端/出站连接)
net.ipv4.tcp_max_tw_buckets      # 限制数量,超了直接清
net.ipv4.ip_local_port_range     # 扩大可用端口范围

⚠️ tcp_tw_recycle 在 Linux 4.12 已移除,网上老文章还在教,别用。它会让 NAT 后面的客户端连接异常。 更好的做法是让客户端主动关闭短连接、用连接池、或者用长连接。

CLOSE_WAIT 太多 = 代码有 bug

CLOSE_WAIT 表示对方已经关了,你的程序还没调 close()。

常见原因:连接泄漏(忘记 finally 关闭)、连接池借出未归还、下游超时后没清理、线程卡死无法执行到关闭逻辑。 看到服务器上大量 CLOSE_WAIT,不要调内核参数,去查代码。


9.3 可靠传输、拥塞控制与粘包

TCP 怎么保证不丢不重不乱

机制作用类比
序号 + 确认(ACK)每个字节编号,收到就确认快递单号 + 签收
超时重传一段时间没确认就重发没签收就补发
快速重传收到 3 个重复 ACK,立刻重传,不等超时收件人说”前一件没到”,马上补
校验和检测数据损坏验货
滑动窗口一次可以发多少,流量控制收件人一次能收多少
拥塞控制网络堵了就降速高速堵车就减速

拥塞控制四阶段

慢启动     :窗口指数增长(1 → 2 → 4 → 8 …),直到阈值
拥塞避免   :过了阈值,窗口线性增长(每次 +1)
快重传     :收到 3 个重复 ACK,立即重传丢失段
快恢复     :不回到慢启动,窗口减半后继续线性增长

算法现状(2026):

算法特点现状
CUBIC基于丢包,Linux 默认绝大多数服务器
BBR基于带宽和 RTT 建模,不靠丢包Google 主推,跨境/高丢包链路效果好
BBRv3改进公平性新系统逐步采用

面试答法:默认 CUBIC,高丢包长肥管道(LFN)场景考虑 BBR。切换用 net.ipv4.tcp_congestion_control=bbr。

粘包与拆包

先说结论:TCP 是字节流,没有”消息边界”这个概念。 所谓粘包,不是 TCP 的 bug,是你把字节流当成了消息队列。

发送方发了三条消息: [A][B][C]
接收方可能收到:    [AB][C]  或  [A][BC]  或  [ABC]
解决方案做法适用
固定长度每条消息补齐到固定长度简单、浪费空间
分隔符用 \r\n 等特殊字符分隔文本协议(HTTP、Redis)
长度前缀先读固定长度头,里面写着正文多长最常用,二进制协议
自描述格式Protobuf 等自带长度信息RPC
// Netty:长度字段解码器
pipeline.addLast(new LengthFieldBasedFrameDecoder(
    1024 * 1024,  // 最大帧长
    0,            // 长度字段偏移
    4,            // 长度字段本身占 4 字节
    0,            // 长度调整值
    4             // 跳过 4 字节长度字段
));

UDP 不存在粘包:它保留消息边界,一次 send 对应一次 receive。代价是可能丢包、乱序、重复。

Nagle 算法与 40ms 延迟

Nagle:小包先攒着,等收到 ACK 或攒够一个 MSS 再发——减少网络上的小包。

延迟 ACK:接收方不立刻回 ACK,等一会儿(通常 40ms)看能不能捎带数据。

两个”好人”凑一起就出事:发送方等 ACK 才发,接收方等数据才回 ACK,互相等,平白多出 40ms 延迟。

socket.setTcpNoDelay(true);   // 关闭 Nagle,实时性要求高的场景必开

游戏、IM、RPC 一般开 TCP_NODELAY;大文件传输可以保留 Nagle。

心跳:TCP keepalive 还是应用层心跳

TCP keepalive应用层心跳
默认时间2 小时(tcp_keepalive_time)自定义,通常 30s~60s
能否穿透代理不能(代理可能不转发)能
能否探测业务假死不能能(应用还能回包才算活)

生产环境用应用层心跳,TCP keepalive 只作为兜底。


9.4 HTTP 的演进:三次队头阻塞

HTTP/1.0 → 1.1

特性1.01.1
连接默认短连接默认长连接(Connection: keep-alive)
队头阻塞有有 + 管线化(实际很少用)
缓存简单Cache-Control、ETag、If-None-Match
分块传输❌✅ Transfer-Encoding: chunked
Host 头可选必需(虚拟主机)

队头阻塞:同一个连接上,前一个请求没响应完,后面的请求只能排队。

浏览器只能给同一域名开 6 个左右连接,于是大家发明了域名分片、雪碧图、资源合并——全是为了绕开这个问题。

HTTP/2

特性说明
二进制分帧把请求拆成 HEADERS / DATA 帧,不再是人类可读文本
多路复用一个 TCP 连接上并发多个 Stream,解决应用层队头阻塞
HPACK头部压缩,重复的 Header 只传索引
Stream 优先级声明资源重要性
服务端推送❌ 已被浏览器移除(Chrome 106 起)

但 HTTP/2 没解决 TCP 层的队头阻塞:所有 Stream 共用一条 TCP 连接,一旦丢包,TCP 要等重传,所有 Stream 一起卡住。这就是”第二次队头阻塞”。

HTTP/3 与 QUIC

特性说明
传输层跑在 UDP 上(QUIC)
队头阻塞流级别隔离,一个流丢包不影响其它流
握手传输层与 TLS 1.3 合并,1-RTT;恢复连接可 0-RTT
连接迁移手机从 Wi-Fi 切 4G,连接不断(用 Connection ID 而不是四元组)
加密默认全加密(包括头部)

代价:UDP 在部分企业网络被限速或封禁;服务端实现复杂度高。

HTTPS / TLS

核心思路:非对称加密换钥匙,对称加密传数据。

类比:先用保险箱(公钥)把一把只有双方知道的一次性钥匙送过去,之后就用这把钥匙快速通信。非对称安全但慢,对称快但需要先共享密钥。

TLS 1.2TLS 1.3
握手往返2-RTT1-RTT
恢复连接1-RTT0-RTT
密钥交换RSA / ECDHE只保留 ECDHE
前向保密可选强制
加密套件多精简到 5 个

前向保密:私钥以后泄露了,以前抓的包也解不开——因为每次会话的密钥是临时生成的。

常见状态码

码含义易混点
200 / 201 / 204成功 / 已创建 / 无内容204 不返回 body
301 / 302 / 307 / 308永久 / 临时重定向307/308 保持原方法,301/302 可能变 GET
304未修改走协商缓存,不返回 body
400 / 401 / 403 / 404请求错 / 未认证 / 无权限 / 不存在401 是”你是谁”,403 是”你不能”
409冲突幂等键重复、乐观锁失败
429限流配合 Retry-After
500 / 502 / 503 / 504服务端错 / 网关错 / 不可用 / 网关超时502 是网关连不上后端,504 是后端没按时回

长连接、SSE、WebSocket

轮询长轮询SSEWebSocket
方向客户端拉客户端拉服务端推双向
协议HTTPHTTPHTTP独立握手后升级
开销大中小小
适用简单状态兼容性要求高通知、日志流、AI 流式输出IM、游戏、协同编辑

SSE 就是 text/event-stream,浏览器原生 EventSource 支持,AI 应用的流式输出基本都用它。


9.5 IO 模型:点外卖的五种姿势

先记住一个前提:一次网络读取分两个阶段——

1. 等数据就绪:数据从网卡 → 内核缓冲区
2. 拷贝数据:内核缓冲区 → 用户空间(你的程序)

类比:点外卖。

  1. 等餐做好(数据到内核)
  2. 把餐拿到手(拷贝到用户空间)

五种模型就是”等餐”和”取餐”的不同方式。

五种模型对比

模型等数据拷贝类比Java
阻塞 IO(BIO)一直等自己搬站柜台前干等,做好再端走InputStream.read()
非阻塞 IO(NIO)反复问自己搬每隔一会儿问”好了吗”SocketChannel.configureBlocking(false)
IO 多路复用等叫号自己搬坐大厅等叫号屏Selector(epoll)
信号驱动 IO等信号自己搬做好后打电话叫你很少用
异步 IO(AIO)不用等别人送外卖送到桌上AsynchronousSocketChannel

⚠️ 最容易考的一句话:前四种都是同步 IO(数据拷贝阶段要自己参与),只有 AIO 是真正的异步。

select / poll / epoll

都是”IO 多路复用”的具体实现,区别在于怎么知道谁就绪了。

selectpollepoll
最大连接数1024(FD_SETSIZE)无上限无上限
数据结构位图链表红黑树 + 就绪链表
每次调用把整个 fd 集合从用户态拷到内核态同 select只注册一次,不用重复拷贝
就绪检查遍历所有 fd,O(n)O(n)回调触发,O(1)
触发方式LTLTLT / ET
适用连接数少连接数中等高并发(Nginx、Redis、Netty)

epoll 为什么快:

epoll_create  → 内核里建一棵红黑树 + 一个就绪链表
epoll_ctl     → 把 fd 挂到红黑树上,注册回调
epoll_wait    → 有事件时,回调把 fd 放进就绪链表,直接返回链表

类比:select/poll 是每次挨个敲门问”有人吗”;epoll 是在每家门口装了个门铃,有人回来按铃,前台只需要看铃响了几个。

LT vs ET:

水平触发 LT边沿触发 ET
通知时机只要缓冲区有数据就一直通知只在状态变化时通知一次
编程难度低高(必须一次读完,读到 EAGAIN)
默认✅需显式设置
风险效率略低没读完就再也不通知了

类比:LT 是”水没关就一直报警”;ET 是”水位第一次超过线时响一次”。ET 性能更好,但必须配合非阻塞 IO 循环读干净。

Java 三种 IO

BIONIOAIO
模型阻塞多路复用异步
线程一连接一线程一个 Selector 管多连接回调
编程难度低高中
典型Socket、ServerSocketChannel、SelectorAsynchronousSocketChannel
现状配合虚拟线程重新流行Netty 的基础Linux 上长期是 epoll 模拟,真正内核异步靠 io_uring

9.6 零拷贝:少搬几次家

普通 read() + write() 有多浪费

磁盘/网卡 → 内核缓冲区 → 用户缓冲区 → socket 缓冲区 → 网卡
              ①DMA          ②CPU          ③CPU        ④DMA

一次文件传输:4 次拷贝 + 4 次上下文切换,其中 2 次 CPU 拷贝完全没必要——数据只是路过用户空间。

类比:你要把仓库 A 的货搬到仓库 B,规定必须先搬到你家客厅再搬过去。零拷贝就是把这条规定取消。

三种优化

方案拷贝次数做法谁在用
mmap + write3 次用户空间映射内核缓冲区,省掉一次 CPU 拷贝RocketMQ(mmap 写 CommitLog)
sendfile2 次 + 2 次切换内核直接对接,数据不进用户空间Kafka(transferTo)
sendfile + DMA gather0 次 CPU 拷贝只传描述符,DMA 按描述符拼数据现代网卡 + Kafka
// Java 里的零拷贝
FileChannel in = new FileInputStream("a.txt").getChannel();
SocketChannel out = SocketChannel.open(addr);
in.transferTo(0, in.size(), out);   // 底层走 sendfile

注意:零拷贝不是”不拷贝”,而是不经过用户空间。DMA 拷贝和必要的协议头处理仍然存在。

Netty 的”零拷贝”

Netty 说的零拷贝是另一层意思:减少 JVM 堆内/堆外的数据复制。

手段说明
DirectByteBuf直接用堆外内存,避免堆内 ↔ 堆外复制
CompositeByteBuf多个 Buffer 逻辑合并,不实际拷贝
slice() / duplicate()共享同一块内存,只改视图
FileRegion底层 FileChannel.transferTo

9.7 Reactor 与 Netty

Reactor 三种模式

模式结构类比
单 Reactor 单线程一个线程负责接收 + 处理小饭馆:老板既点菜又炒菜
单 Reactor 多线程一个线程收连接,业务扔线程池老板点菜,厨师炒菜
主从 Reactor 多线程主 Reactor 只接连接,从 Reactor 处理 IO前台迎宾 + 多个服务员

Netty 用的是主从 Reactor:bossGroup 接收连接,workerGroup 处理读写。

Netty 核心组件

组件作用类比
EventLoop一个线程 + 一个 Selector,绑定多个 Channel一个服务员管几桌
Channel连接的抽象一张桌子
ChannelPipeline责任链,串起所有 Handler传菜流程
ByteBuf增强版 ByteBuffer,读写指针分离、可池化可重复使用的餐盘
Bootstrap启动器开店流程

Netty 为什么快

  1. 主从 Reactor + 多路复用:少量线程扛大量连接
  2. 池化 ByteBuf:减少 GC 和内存分配
  3. 堆外内存:减少一次复制
  4. 无锁串行化:一个 Channel 的所有事件都在同一个 EventLoop 线程里处理,天然无并发竞争
  5. 零拷贝:CompositeByteBuf、slice、FileRegion
  6. 可调背压:写缓冲高水位线(WRITE_BUFFER_WATER_MARK)自动切换 autoRead,防止内存被打爆
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(NioServerSocketChannel.class)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     protected void initChannel(SocketChannel ch) {
         ch.pipeline()
           .addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4, 0, 4))
           .addLast(new StringDecoder())
           .addLast(new BusinessHandler());
     }
 });

关键设计:一个连接的全部 IO 事件由同一个 EventLoop 线程串行处理,业务 Handler 里不要做阻塞操作(数据库、HTTP 调用),否则会卡住这个线程上的所有连接。耗时逻辑扔到业务线程池。

2026 年的新选择:虚拟线程

Netty 的复杂度来自”用少量线程扛大量连接”。JDK 21 的虚拟线程改变了这个前提:

// 每个请求一个虚拟线程,代码是阻塞写法
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    server.handle((req, res) -> executor.submit(() -> {
        var data = jdbc.query(sql);   // 阻塞调用,但会释放载体线程
        res.write(data);
    }));
}
Netty / 响应式虚拟线程
编程模型回调 / 链式 / 异步同步阻塞写法
调试难度高(栈被切碎)低
生态成熟新,JDBC 等阻塞驱动直接可用
适用超高并发、精细控制大多数业务服务

结论:新项目如果不是网关/推送这类超高连接数场景,虚拟线程 + 阻塞式代码往往是更划算的选择。Netty 仍然不可替代(协议定制、极致性能、海量长连接)。


9.8 线上排错手册

问题一:连接超时 vs 读超时

超时发生阶段常见原因
connect timeout三次握手网络不通、防火墙、后端没监听、SYN 队列满
read timeout握手成功后后端处理慢、线程池满、数据库慢、死锁
连接池获取超时从池里拿连接池太小、连接泄漏、下游变慢

排查顺序:先确认是哪一种超时,别一上来就调大超时时间——那只是把问题藏起来。

问题二:端口耗尽

# 报错:Cannot assign requested address
ss -s                      # 看 timewait 数量
ss -tan state time-wait | wc -l
cat /proc/sys/net/ipv4/ip_local_port_range   # 默认 32768 60999,约 2.8 万个
  • 短连接 + 高 QPS → 本地端口被 TIME_WAIT 占满
  • 解决:用连接池/长连接、tcp_tw_reuse=1、扩大端口范围

问题三:文件句柄耗尽

ulimit -n                  # 进程可打开的文件数
lsof -p <pid> | wc -l      # 当前打开数
cat /proc/<pid>/limits

报错通常是 Too many open files。每个 socket 都是一个 fd,连接数 + 文件数 + 管道都要算进去。

问题四:accept 队列溢出

netstat -s | grep -i listen        # "listen queue of a socket overflowed"
ss -lnt                             # Send-Q 列是 backlog
cat /proc/sys/net/core/somaxconn
  • 应用 accept 太慢(比如在 accept 线程里做业务)
  • backlog 太小
  • 解决:加大 somaxconn、把业务处理移出 accept 线程

抓包三板斧

tcpdump -i eth0 -nn port 8080 -w /tmp/cap.pcap   # 抓包
tcpdump -i eth0 -nn -A port 8080                  # 直接看内容
# 用 Wireshark 分析:看三次握手、重传、RST、窗口大小
现象可能原因
大量 重传丢包、网卡/交换机问题、带宽打满
RST端口没监听、连接被中间设备重置、服务端 backlog 满
窗口为 0接收方处理不过来(应用读太慢)
握手很慢网络 RTT 高、SYN 队列满、DNS 慢

常用内核参数

# /etc/sysctl.conf
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10240 65000
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_fin_timeout = 30

9.9 面试速查

问题一句话答案
TCP 为什么三次握手确认双方收发能力 + 同步序列号 + 防止历史重复连接
为什么四次挥手全双工,两个方向要分别关闭,中间有半关闭状态
TIME_WAIT 为什么 2MSL保证最后 ACK 到达 + 让旧报文消散;Linux 上约 60 秒
大量 CLOSE_WAIT应用没调用 close,是代码 bug,不是内核参数问题
大量 TIME_WAIT短连接多、主动关闭方;用连接池或 tcp_tw_reuse
TCP 怎么保证可靠序号确认、超时重传、快速重传、校验和、滑动窗口、拥塞控制
粘包怎么解决TCP 没有消息边界;用固定长度、分隔符或长度前缀
Nagle 和延迟 ACK两者互相等待会产生约 40ms 延迟,实时场景开 TCP_NODELAY
HTTP/1.1 的队头阻塞同一连接上前一个请求未完成,后面的排队
HTTP/2 解决了吗解决应用层,但 TCP 丢包会让所有 Stream 一起阻塞
HTTP/3 怎么解决QUIC 跑在 UDP 上,流级隔离,1-RTT/0-RTT,支持连接迁移
TLS 1.3 改进1-RTT 握手、只保留 ECDHE、强制前向保密、0-RTT 恢复
502 和 504 区别502 网关连不上后端,504 后端没按时响应
五种 IO 模型阻塞、非阻塞、多路复用、信号驱动、异步
哪些是同步 IO前四种都是,只有 AIO 是异步
select / poll / epoll1024 上限 / 无上限但遍历 / 红黑树 + 就绪链表 + 回调
LT 和 ET水平触发一直通知,边沿触发只通知一次,ET 必须读干净
零拷贝是什么数据不经过用户空间;sendfile 2 次拷贝,DMA gather 0 次 CPU 拷贝
Netty 为什么快主从 Reactor、池化堆外内存、无锁串行化、零拷贝、背压
Netty 里能写阻塞代码吗不能,会卡住 EventLoop 上的所有连接
虚拟线程和 Netty 怎么选普通业务用虚拟线程更简单,超高并发/协议定制用 Netty
端口耗尽怎么查ss -s 看 timewait、ip_local_port_range,改用长连接

到这里,Java 面试笔记的骨架就补齐了:基础 → 集合 → 多线程 → JVM → Redis → MySQL → Spring → 微服务 → MQ → 网络与 IO。