1. Java后端为什么绕不开计算机网络
前阵子组里一个同学排查线上问题,日志里全是Connection reset by peer,他第一反应是代码里哪里没关流,翻了两个小时业务代码。后来把ss -s和tcpdump一跑,才发现是上游一个负载节点在连接空闲超过某个阈值后主动发 RST,问题根本不在他的业务逻辑里。这类场景在 Java 后端岗位上出现的频率高得离谱,接口超时、连接池耗尽、长连接掉线、网关 502、上传大文件卡住不动,往深里挖几乎全是计算机网络这门课的内容。
标题叫「Java知识点整理 4」,说明前面已经整理过 Java 基础、集合、并发这些内容。把计算机网络单独拎出来作为第四篇,逻辑很清楚:Java 在后端的绝大多数工作就是处理网络请求,你写的每一行 Controller 代码,背后都是一次或多次跨进程、跨机器的数据交换。不懂网络,你只能停留在「接口能跑通」的层面,一旦出问题就只能靠猜。这份整理面向的对象很明确:准备面试的同学需要一个能串起来的知识骨架,已经在写业务的中初级开发者需要一套能落地的排查手段,两者其实共用同一套底层认知。
我自己把这些年零散记的笔记重新捋了一遍,删掉了一堆只在考试里出现、工作五年也没碰过的边角料,留下了几条真正会被反复用到的主线:分层模型帮你定位问题出在哪一层;TCP 的连接生命周期解释了长连接为什么会莫名其妙断掉;粘包拆包几乎是所有自定义协议绕不开的第一道坎;HTTP 和 HTTPS 决定了你在网关、CDN、缓存上能不能省下真金白银;Java 的 BIO、NIO、AIO 三种模型则直接决定了你选 Tomcat 还是 Netty。这几条线拧在一起,才算是把「Java 程序员该懂的计算机网络」这件事讲完整。
下面按这个顺序展开,中间会穿插一些参数计算、命令输出和踩坑记录,都是我实际遇到过或者亲手验证过的,可以直接拿去对照。
2. 分层模型:先把整张地图画出来
2.1 四层与七层的对应关系,别在面试里背串了
分层模型这块最怕的就是死记硬背,背完考完就忘。我的建议是先记住一句话:每一层只解决一件事,并且只跟对端同一层「逻辑上」对话。OSI 七层是教学模型,TCP/IP 四层是工程模型,实际抓包的时候你看到的是四层模型的产物。
对应关系用一张表说清楚,比背十遍概念管用:
| TCP/IP 四层 | OSI 七层 | 承载的东西 | Java 程序员接触点 |
|---|---|---|---|
| 应用层 | 应用层、表示层、会话层 | HTTP、DNS、FTP、自定义协议 | Spring MVC、OkHttp、Netty 编解码器 |
| 传输层 | 传输层 | TCP、UDP | Socket、ServerSocket、DatagramSocket |
| 网络层 | 网络层 | IP、ICMP、ARP | 一般不直接碰,排查时用 ping、traceroute |
| 网络接口层 | 数据链路层、物理层 | 以太网帧、Wi-Fi | 网卡、交换机,通常交给运维 |
这张表的价值不在于背,而在于定位。当你遇到「接口返回慢」,先问自己:是应用层业务慢、传输层重传多、还是网络层路由绕远了?不同层的排查工具完全不一样。应用层看日志和链路追踪,传输层看ss -i里的重传计数,网络层用mtr看丢包点。很多人排查慢请求时,一上来就翻代码,其实ss -i一眼就能看出是不是网络在捣鬼。
再补一个经常被忽略的点:ARP 属于网络层还是链路层,不同教材说法不一。这种争议题面试官一般不会深究,你只要知道它的作用是「已知 IP 找 MAC」就够了。真正有价值的是理解封装过程,下面展开。
2.2 一次请求的封装与解封装,串起整个知识体系
假设你在浏览器里敲了一个地址,点下回车,数据从你的机器走到服务器再走回来,这个过程是整门课的主线,把主线理顺了,后面所有知识点都能挂上去。
应用层先按协议格式生成数据,比如 HTTP 请求的请求行、请求头、请求体。传输层在数据前面加上 TCP 头,里面最关键的是源端口、目的端口、序号、确认号、标志位,这些字段后面每一节都会用到。网络层再加 IP 头,写清楚源 IP 和目的 IP,同时决定这个包该从哪个网卡出去、下一跳是谁。链路层最后封成帧,加上源 MAC 和目的 MAC,发给网关。
对端收到之后反着来一遍:链路层剥掉帧头,网络层看 IP 头确认是给自己的,传输层看端口号交给对应的进程,应用层按协议解析出内容。整个过程有个关键认知:除了应用层的报文内容和端口号,中间设备基本不改其他字段。NAT 是个例外,它改 IP 和端口,这也是为什么你在家宽环境下抓包,看到的内网地址跟公网地址对不上,很多人第一次碰到会懵。
理解了这个过程,你就能自己回答一类问题:为什么改 hosts 文件能绕开 DNS?因为在应用层就把域名解析成了 IP。为什么公司内网访问外部接口会超时,但 ping 得通?因为 ping 走 ICMP 不走 TCP,前者能通过不代表后者能通过。这类问题看似零散,其实都是同一条主线上的分支。
2.3 MTU、MSS 与分片:大包为什么会卡住
这个点几乎每本教材都提,但真正在工作里用到的场景很多人说不出来。MTU 是链路层单帧能承载的最大数据量,以太网默认 1500 字节。减去 IP 头 20 字节和 TCP 头 20 字节,MSS 通常就是 1460 字节。
一旦应用层写出的数据超过 MSS,TCP 会自动分段,这本身没问题。怕的是 IP 层的分片:如果某个包被打了「不分片」标记(DF 位),路上遇到 MTU 更小的链路,路由器没法转发也没法分片,只能丢包并回一个 ICMP「需要分片」消息。如果这个 ICMP 被防火墙拦了,发送方永远不知道要调小分段,就会表现为「小请求一切正常,传大文件卡死在某个位置」。
注意:排查这类问题的典型特征是大包必挂、小包正常。用
ping -s 1472 -M do 目标IP可以手动验证路径 MTU,1472 是 1500 减去 20 字节 IP 头再减去 8 字节 ICMP 头。如果 1472 不通而 1400 通,基本可以锁定是 MTU 问题。
Java 侧对应的参数是-Djava.net.preferIPv4Stack之类吗?不是,这里要关注的是 TCP 的 MSS 协商和TCP_MAXSEG选项。多数情况下你不需要动它,但在容器网络、隧道网络里,MTU 往往被设成 1450 甚至更低,如果应用层不感知,就会出现上面那种症状。我在一个跨机房调用的项目里就遇到过,最后是把容器网络的 MTU 对齐到 1400 才彻底解决。
3. TCP 连接的一生:从握手到挥手再到状态机
3.1 三次握手为什么不能是两次
这个问题几乎每场面试都会问,但很多人答的是「防止历史连接」,这只是表层。真正的核心是双方都要确认对方的收发能力,并且要对初始序号达成一致。
第一次握手,客户端发 SYN 带自己的初始序号 seq=x,服务端收到后能确认:客户端的发送能力正常。第二次握手,服务端回 SYN+ACK,带自己的 seq=y 和 ack=x+1,客户端收到后能确认:我的发送正常、对方的收发都正常。第三次握手,客户端回 ACK ack=y+1,服务端收到后能确认:我的发送正常、对方的接收正常。至此双方才都确信四件事都成立。
如果只有两次,服务端无法确认自己发出的包客户端能不能收到。举个具体场景:客户端的一个旧 SYN 在网络里滞留了很久才到服务端,服务端回了 SYN+ACK 并直接建立连接开始分配资源,结果客户端根本不认这个连接,资源就白白占着。三次握手的第三次 ACK 本质上是一个「我确实收到了你的确认」的回执,它让服务端在分配完整资源前多了一次确认机会。
握手阶段的参数在 Java 侧也有对应。ServerSocket的 backlog 参数决定半连接队列和全连接队列的长度,Linux 上还受somaxconn和tcp_max_syn_backlog制约。当瞬时并发很高时,如果队列设小了,客户端会看到连接超时或直接被拒。这个坑在秒杀场景特别常见,调优时要把这三个值一起看,改一个没用。
3.2 四次挥手与 TIME_WAIT 的真正含义
挥手比握手多一次,原因是 TCP 是全双工的,两个方向要各自关闭。主动关闭方发 FIN,被动方回 ACK,此时主动方进入 FIN_WAIT_2,连接处于「半关闭」状态,被动方还能继续发数据。等被动方数据发完了,再发自己的 FIN,主动方回 ACK,然后进入 TIME_WAIT,等待 2MSL 后彻底关闭。
TIME_WAIT 存在的理由有两条,都很实在。第一,保证最后一个 ACK 能到达对端。如果这个 ACK 丢了,对端会重发 FIN,此时主动方还处在 TIME_WAIT,能重新回一个 ACK;如果连接已经彻底关闭,收到莫名其妙的 FIN 就只能回 RST,对端会认为出错。第二,让这条连接上的旧报文在网络中自然消亡,避免它们干扰后续复用相同四元组的新连接。
工作里更常见的问题是 TIME_WAIT 太多。表现是ss -tan state time-wait | wc -l数出来几万个,端口被占满,新连接建不上。很多人第一反应是调net.ipv4.tcp_tw_reuse,但更该先问一句:为什么会有这么多短连接?绝大多数情况下答案是把 HTTP 客户端设成了每次新建连接,或者数据库连接池配得太小导致频繁重建。从业务侧改成连接复用,问题自然消失,比改内核参数安全得多。
提示:
tcp_tw_recycle这个参数在新版内核里已经被移除,老资料里还经常见到,别照着抄。tcp_tw_reuse只对主动发起连接的场景生效,且需要开启时间戳,理解清楚再用。
3.3 状态机速查与 CLOSE_WAIT 堆积的排查
TCP 一共 11 个状态,全背下来意义不大,但有几个必须一眼认出来。我整理了一张对照表,排查时直接对着看:
| 状态 | 出现位置 | 正常还是异常 | 常见原因 |
|---|---|---|---|
| SYN_SENT | 主动连接方 | 短暂正常 | 长时间停留说明对端没回,可能被防火墙丢包 |
| SYN_RECV | 被动连接方 | 短暂正常 | 大量堆积可能是 SYN Flood |
| ESTABLISHED | 双方 | 正常 | 数量持续上涨要警惕连接泄漏 |
| FIN_WAIT_1 / 2 | 主动关闭方 | 短暂正常 | FIN_WAIT_2 长期不消失说明对端没发 FIN |
| CLOSE_WAIT | 被动关闭方 | 异常信号 | 应用层收到 FIN 但没调 close() |
| TIME_WAIT | 主动关闭方 | 正常 | 大量堆积说明短连接过多 |
| LAST_ACK | 被动关闭方 | 短暂正常 | 长时间停留说明最后的 ACK 没收到 |
其中 CLOSE_WAIT 最值得警惕,因为它几乎百分百是代码问题。含义很明确:内核已经收到了对端的 FIN 并回了 ACK,但应用层还没有调用close()关闭这个 socket。只要代码里有一条路径漏了关闭,这个连接就会一直挂在那里,直到进程重启。排查方法很直接,用lsof -p 进程号 | grep CLOSE_WAIT找到对应的 fd,再看代码里哪个分支没有在 finally 里关闭资源。
我见过最典型的一种写法是这样:
public String call(String url) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); InputStream in = conn.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(in)); String line = reader.readLine(); if (line == null) { return ""; // 这里直接返回了,流没关 } return line; }这个if分支就是 CLOSE_WAIT 的生产者。修复方式要么用 try-with-resources,要么在 finally 里统一关闭。用成熟的 HTTP 客户端也一样,连接池里的连接如果借出后没有正确归还,照样会出现同样的问题,只是表现形式变成连接池耗尽。
4. 可靠传输的几件套:序号、确认、窗口、重传
4.1 滑动窗口与流量控制,为什么不能一发了之
TCP 之所以可靠,靠的是一套组合拳:每个字节都有序号,接收方回确认,超时或收到重复确认就重传。但如果发送方发一个包就停下来等确认,那吞吐量会低到无法接受。滑动窗口就是用来解决这个效率问题的。
发送方维护一个窗口,窗口内的数据可以连续发出不必等待确认,收到确认后窗口向前滑动。窗口大小由接收方通过 TCP 头里的 window 字段通告,这就是流量控制:接收方根据自己的缓冲区剩余空间告诉发送方「你最多还能发多少」,避免发太快把对方撑爆。
这里有个容易被误解的点:窗口字段是 16 位,最大只能表示 65535 字节,在现代网络下太小了。所以有窗口缩放选项,在握手阶段协商一个移位因子,实际窗口大小等于字段值左移若干位。这也是为什么抓包时看到的窗口值有时小得离谱,不能直接当实际值用。
零窗口是个特殊状态:接收方缓冲区满了,通告窗口为 0,发送方停止发送,但会定期发窗口探测包询问什么时候能继续。如果你在抓包里看到大量零窗口和探测包,说明接收端处理太慢,瓶颈在应用层而不是网络。这种场景下加带宽没用,得去看消费线程是不是被什么阻塞了。
4.2 拥塞控制四个阶段的通俗理解
流量控制是端到端的,拥塞控制则是面向整个网络的。核心思路是发送方维护一个拥塞窗口 cwnd,实际发送窗口取 cwnd 和接收方通告窗口的较小值。cwnd 怎么变,就是四个阶段。
慢启动阶段,cwnd 从一个小值开始,每收到一个确认就翻倍,呈指数增长。名字叫「慢」是因为起点低,实际增长速度很快。当 cwnd 达到慢启动阈值 ssthresh 时进入拥塞避免,改成每轮加一,线性增长。一旦检测到丢包,根据信号类型分别处理:收到三个重复 ACK 说明网络还能通,只是个别包丢了,走快速重传加快速恢复,cwnd 减半继续;如果是超时,说明网络状况很糟,cwnd 直接降回 1 重新慢启动。
理解这套机制的实际意义在于:TCP 的吞吐量对丢包率极其敏感。在长距离、高延迟的链路上,哪怕只有百分之零点几的丢包,吞吐量也会掉得很难看。这也是为什么跨地域传输大量数据时,单纯加大带宽往往没效果,得靠多连接并行或者换用对丢包不那么敏感的传输方案。我在一个跨区域数据同步的项目里深有体会,单连接跑满只有理论值的十分之一,拆成八条连接后立刻改善。
4.3 Nagle 算法与延迟确认:两个老实人凑一起会出事
Nagle 算法的初衷是减少小包。规则是:如果还有未确认的数据在飞,就先攒着,等收到确认或者攒够一个 MSS 再发。延迟确认的思路是:收到数据先不回 ACK,等一小会儿,看能不能捎带在反向数据里一起回,省一个包。
问题在于这两个机制凑到一起会互相等待。发送方等 ACK 才发下一批,接收方等有数据才回 ACK,双方都在等对方先动,结果就是几十毫秒的额外延迟。这种现象在「写-写-读」模式的 RPC 里特别明显:客户端发一个小请求,服务端回一个小响应,两边都启用了优化,延迟就上去了。
解决办法是在客户端设置TCP_NODELAY,关掉 Nagle。Java 里对应的写法是:
Socket socket = new Socket(); socket.setTcpNoDelay(true); // 关闭 Nagle,小包立即发送 socket.setSoTimeout(3000); // 读超时,避免线程永久阻塞 socket.setKeepAlive(true); // 开启探活Netty 里的对应配置是ChannelOption.TCP_NODELAY,默认值在不同版本里不一样,我一般显式设成 true,避免版本升级带来的行为变化。至于延迟确认,它是接收端行为,可以通过调整内核参数缩短等待时间,但大多数场景下关掉 Nagle 就够了。
注意:不是所有场景都该关 Nagle。批量传输、日志上报这类场景,攒包反而是好事,关掉会增加小包数量,加重网络负担。判断标准是「延迟敏感还是吞吐敏感」,别一刀切。
5. 粘包拆包:Java 程序员最容易踩的第一个坑
5.1 现象与根因:TCP 是字节流,不是消息流
很多人第一次写自定义协议就栽在这里:明明发了三条消息,对端却收到一条半。原因在于 TCP 只保证字节有序到达,不保证你写一次就对应读一次。发送端可能把多次写入合并成一个包发出,接收端的缓冲区也可能把多个包一次性交给应用层。
这个特性不是缺陷,而是设计使然。TCP 要在效率和可靠性之间做平衡,如果强制按写入边界切分,头部开销会大到无法接受。所以协议设计者必须自己解决消息边界问题,这是应用层的责任,不是传输层的。
典型的错误写法是这样读:
byte[] buf = new byte[1024]; int len = in.read(buf); String msg = new String(buf, 0, len);只要一次没读全,或者一次读到了两条,解析就错了。而且这个错误有随机性,本地测试往往正常,上了生产网络状况一变就暴露,排查起来非常痛苦。
5.2 三种主流解决方案与取舍
固定长度。每条消息长度固定,读满 N 字节就是一条。实现最简单,缺点是灵活性差,短消息浪费带宽,长消息装不下。适合指令型协议,比如某些硬件通信。
分隔符。用特殊字符标记消息结束,比如换行符。Redis 的 RESP 协议、很多日志协议都是这个思路。缺点是要处理转义,如果消息内容本身可能包含分隔符,就得有一套转义规则,实现复杂度会上去。
长度字段。消息头里写明后续内容的长度,先读头再按长度读体。这是最通用的方案,也是绝大多数 RPC 框架的选择。缺点是头部的长度字段本身也要定长,且要防御恶意超长长度导致的 OOM。
Java 里手写一个基于长度字段的解码器,核心逻辑大概是这样:
public class LengthFieldDecoder { private ByteBuffer buffer = ByteBuffer.allocate(8192); public String decode(ByteArrayOutputStream in) { byte[] data = in.toByteArray(); buffer.put(data); buffer.flip(); // 头部 4 字节表示长度 while (buffer.remaining() >= 4) { buffer.mark(); int bodyLen = buffer.getInt(); if (bodyLen < 0 || bodyLen > 1024 * 1024) { throw new IllegalStateException("非法的消息长度: " + bodyLen); } if (buffer.remaining() < bodyLen) { buffer.reset(); // 数据不够,回退等下次 break; } byte[] body = new byte[bodyLen]; buffer.get(body); } buffer.compact(); // 把未处理的部分挪到前面 return null; } }这里有两个细节值得说。一是必须校验长度上限,不然对端发个超大长度就能把你的内存打爆,这是很常见的攻击面。二是compact()的调用时机,处理完所有完整消息后要把剩余半包数据保留下来,否则下次就读不到了。我在早期项目里就漏过这一步,现象是「偶尔丢一条消息」,查了整整两天。
5.3 Netty 里的现成方案与选型建议
自己写解码器容易在边界条件上出错,实际项目里我更倾向于用 Netty 提供的几个内置解码器:
| 解码器 | 适用场景 | 注意事项 |
|---|---|---|
FixedLengthFrameDecoder | 定长协议 | 长度必须严格固定 |
DelimiterBasedFrameDecoder | 分隔符协议 | 要设最大帧长,防止无分隔符导致内存膨胀 |
LengthFieldBasedFrameDecoder | 长度字段协议 | 参数多但最灵活,重点理解 offset 和 lengthAdjustment |
LengthFieldBasedFrameDecoder的参数是新手最大的门槛。假设协议是「4 字节魔数 + 4 字节长度 + 内容」,那lengthFieldOffset是 4,lengthFieldLength是 4,lengthAdjustment是 0,initialBytesToStrip取决于你想不想把头部一起交给业务。这几个值配错,表现出来就是解析全乱或者永远收不到完整帧。我的经验是先用一个最简单的两字段协议把参数跑通,再往上加字段,一次只改一个参数。
提示:无论用哪种解码器,都要设
maxFrameLength。不设的话,一个畸形包就能让内存一路涨上去,这类问题在生产环境很难复现也很难定位。
6. HTTP 与 HTTPS:业务代码每天都在打交道的那一层
6.1 报文结构、方法与状态码,别只记住 200 和 404
HTTP 报文分请求和响应两种。请求由请求行(方法、路径、版本)、请求头、空行、请求体组成;响应由状态行(版本、状态码、短语)、响应头、空行、响应体组成。结构本身不复杂,容易出错的是对语义的理解。
方法这块,GET 和 POST 的区别被问得最多,但很多答案停留在「参数位置不同」。更本质的区别在于语义:GET 是安全且幂等的,用于获取资源,可以被缓存、可以被浏览器预取;POST 不保证幂等,用于提交数据,默认不缓存。这个区别直接影响你的接口设计,比如重试策略:GET 可以放心重试,POST 就得靠幂等键来保证不重复。
状态码我整理了一张表,覆盖工作中实际会遇到的那些:
| 状态码 | 含义 | 常见诱因 |
|---|---|---|
| 200 / 201 / 204 | 成功 / 已创建 / 无内容 | 正常 |
| 301 / 302 | 永久 / 临时重定向 | 域名迁移、HTTPS 跳转 |
| 304 | 资源未修改 | 命中协商缓存 |
| 400 | 请求语法错误 | 参数格式不对 |
| 401 / 403 | 未认证 / 无权限 | 令牌失效、权限不足 |
| 404 | 资源不存在 | 路径错了,或路由没配 |
| 405 | 方法不允许 | 用了 POST 调 GET 接口 |
| 429 | 请求过多 | 触发限流 |
| 500 / 502 / 503 / 504 | 服务内部错误 / 网关错误 / 不可用 / 网关超时 | 后端挂了、网关连不上、过载、后端处理超时 |
这几个 5xx 里,502 和 504 的区分很关键。502 是网关根本没拿到有效响应,通常是后端进程挂了或者端口没监听;504 是网关等到了超时时间还没拿到响应,说明后端在跑但处理太慢。排查方向完全不同:前者去看进程和端口,后者去看慢查询和线程池。
6.2 缓存机制与几个容易记混的头部字段
HTTP 缓存分强缓存和协商缓存两类,这个区分是所有缓存优化的基础。
强缓存由Cache-Control的max-age或老的Expires控制,在有效期内浏览器直接用本地副本,连请求都不发。Expires是绝对时间,受客户端时钟影响;Cache-Control是相对秒数,更可靠。如果两个同时存在,后者优先。
协商缓存是强缓存失效后走的流程,浏览器带着If-None-Match或If-Modified-Since去问服务端,服务端判断没变就回 304,只回头部不回正文,省了正文的流量。ETag对应If-None-Match,Last-Modified对应If-Modified-Since。前者的精度更高,因为文件修改时间只能精确到秒,一秒内改两次就识别不出来,而 ETag 通常基于内容哈希。
几个容易搞混的头值得单独列一下:
Connection: keep-alive控制连接复用,HTTP/1.1 默认就是长连接,写成close才会每次新建。Transfer-Encoding: chunked表示分块传输,服务端不需要预先知道内容长度就能边生成边发,流式接口常用。Content-Length和chunked不能同时出现,同时出现会导致解析歧义,有些请求走私漏洞就是利用这个差异做出来的。Vary告诉缓存按哪个请求头区分副本,常见的是Vary: Accept-Encoding。
最后这条特别值得注意:如果网关缓存了带用户信息的接口但没设置Vary或者Cache-Control: private,就可能出现 A 用户看到 B 用户数据的严重事故。这类问题上线前一定要检查响应头。
6.3 HTTPS 握手:证书校验到底在校验什么
HTTPS 就是在 HTTP 和 TCP 之间加了一层 TLS,解决三个问题:内容加密、身份认证、完整性校验。握手过程被问得很多,我按自己的理解梳理一遍。
第一步,客户端发 ClientHello,带上自己支持的 TLS 版本、加密套件列表、一个随机数。第二步,服务端回 ServerHello,选定版本和套件,给出自己的随机数,同时把证书链发过来。第三步,客户端验证证书:检查是否由受信任的 CA 签发、域名是否匹配、是否在有效期内、有没有被吊销。验证通过后生成一个预主密钥,用证书里的公钥加密发给服务端。第四步,双方用两个随机数加预主密钥算出会话密钥,各自发一个 Finished 消息确认握手完成,之后就用会话密钥对称加密通信。
这里有三个点经常被误解。第一,非对称加密只用来交换密钥,不用于加密业务数据,因为它的性能比对称加密差好几个数量级,全程用它扛不住。第二,证书校验的核心是信任链,浏览器和 JDK 内置了一批根证书,逐级验证到根证书为止,所以自签证书一定会报错,除非手动导入信任库。第三,TLS 1.3 把握手压缩到了一次往返,客户端在第一个消息里就带上密钥交换信息,省掉了一个 RTT,对延迟敏感的场景收益明显。
Java 侧调 HTTPS 接口时最常见的问题是证书不受信,报PKIX path building failed。解决办法是把服务端证书导入 JDK 的 cacerts 信任库,或者给客户端配置自定义的SSLSocketFactory。前者影响全局,后者更可控。我一般倾向于后者,且只在明确需要的连接上启用,避免把整个 JVM 的信任策略放宽。
6.4 三个版本的演进,以及为什么 HTTP/2 没想象中那么普及
HTTP/1.1 的核心问题是队头阻塞:一个连接上同一时刻只能处理一个请求,前面的慢请求会卡住后面的。浏览器用六个并发连接缓解,但连接本身开销不小。
HTTP/2 引入了二进制分帧、多路复用、头部压缩、服务端推送。多路复用让同一连接上并行多个请求,真正解决了应用层的队头阻塞。但要注意,它在 TCP 层还是存在队头阻塞:一个 TCP 段丢了,后面所有流都得等重传。
HTTP/3 干脆换了传输层,基于 UDP 之上的 QUIC,每个流独立,丢包只影响自己。同时把 TLS 握手机制合并进来,建连更快,还能在 IP 变化时保持连接(移动网络切换场景很有价值)。
实际落地中 HTTP/2 的普及度不如预期,原因挺现实的:内网服务之间调用大多走 RPC 框架而不是 HTTP,外网侧很多场景被 CDN 和网关吸收掉了,业务代码感知不到。而 HTTP/3 对中间设备的要求更高,UDP 在很多企业网络里被限制得比较严。所以我的建议是知道原理、能说清各版本的差异就够了,不必强求在项目里用上。
7. Java 网络编程的三种模型与工程选型
7.1 BIO、NIO、AIO 到底差在哪
BIO 是阻塞式,一个连接一个线程,读写都会卡住线程。优点是模型简单,调试方便;缺点是无法支撑高并发,几千连接就要几千线程,光是上下文切换就吃满 CPU。早期 Tomcat 的默认实现就是它。
NIO 是基于多路复用,一个线程通过 Selector 同时监听多个 Channel,哪个就绪处理哪个。核心组件有三个:Channel 表示通道,Buffer 是读写的数据载体,Selector 负责事件监听。它的价值在于用少量线程处理大量连接,代价是编程模型复杂,要自己处理半包、空轮询、写缓冲区等问题。
AIO 是异步 IO,操作发起后立刻返回,完成时回调通知。理论上最省线程,但 Linux 上的实现一直不够成熟,实际性能提升有限,用的人很少。Netty 在某些版本尝试过 AIO 支持,后来也放弃了。
我用一张表把三者的差异摆清楚:
| 维度 | BIO | NIO | AIO |
|---|---|---|---|
| 线程模型 | 一连接一线程 | 少量线程 + 事件驱动 | 回调驱动 |
| 吞吐能力 | 低 | 高 | 理论高 |
| 编程复杂度 | 低 | 高 | 高 |
| 典型代表 | 传统 Tomcat | Netty、Tomcat NIO 模式 | 少量框架支持 |
| 适用场景 | 连接数少、逻辑简单 | 高并发网关、长连接服务 | 很少直接用 |
选型上我的经验是:除非你在写基础框架,否则不建议自己撸 NIO。裸写 Selector 要处理的边界情况非常多,空轮询导致 CPU 打满这类问题在 JDK 早期版本里还有 bug,得靠计数规避。直接上 Netty,它把这些问题都封了一层,同时保留了足够的可控性。
7.2 epoll 与 Selector 的关系,别在面试里说得太玄
Selector 是 Java 层的抽象,底层在不同操作系统上对应不同的实现。Linux 上用的是 epoll,Windows 上是 IOCP,macOS 是 kqueue。epoll 的优势在于它不需要每次调用都把整个 fd 集合从用户态拷到内核态,而是通过事件就绪列表返回,复杂度跟活跃连接数相关,而不是总连接数。
epoll 有三个关键操作:epoll_create创建实例,epoll_ctl注册或修改关注的 fd,epoll_wait等待事件。还有个经典问题:水平触发和边缘触发的区别。水平触发下只要缓冲区还有数据,每次epoll_wait都会通知;边缘触发只在状态变化时通知一次,必须一次把数据读干净,否则就丢了通知。Java 的 NIO 用的是水平触发,所以不用担心这个问题,但如果用 JNI 直接调 epoll,就得特别注意。
Netty 在 NIO 之上做了一层优化,比如用SO_REUSEPORT支持多线程绑定同一端口,让多个 event loop 分担 accept 压力;用内存池化的 ByteBuf 减少 GC;用对象池复用事件对象。这些优化不是概念层面的,而是实打实影响吞吐量的。
7.3 几个必须调对的参数与我的实际配置
网络编程的坑很多不在代码里,而在参数上。我把常用的几个列出来:
// 服务端 ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.option(ChannelOption.SO_BACKLOG, 1024) // 连接队列长度 .childOption(ChannelOption.SO_KEEPALIVE, true) // 开启保活探测 .childOption(ChannelOption.TCP_NODELAY, true) // 关闭 Nagle .childOption(ChannelOption.SO_RCVBUF, 128 * 1024) // 接收缓冲区 .childOption(ChannelOption.SO_SNDBUF, 128 * 1024) // 发送缓冲区 .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(32 * 1024, 64 * 1024)); // 写水位线SO_BACKLOG要和内核的somaxconn对齐,只改一边没意义。缓冲区大小不是越大越好,调太大会增加内存占用和延迟,一般设置在几十到几百 KB 之间,具体看你的消息大小分布。写水位线是用来做背压的,写缓冲超过高水位后isWritable变成 false,你可以据此暂停读取上游数据,避免内存无限增长。这几个参数我在压测时反复调过,水位线这一项对稳定性影响最大,设得太宽松,高峰期内存曲线会一路往上走。
8. 线上问题排查实录与速查表
8.1 我常用的几条命令和它们的输出怎么读
排查网络问题,工具用对了能省几小时。按使用频率排下来:
# 看整体连接状态分布 ss -s # 按状态过滤,看是否有异常堆积 ss -tan state close-wait | wc -l # 看单个连接的详细信息,包括重传、拥塞窗口 ss -tin dst 目标IP # 抓包,只抓某个端口,写到文件便于分析 tcpdump -i eth0 -nn port 8080 -w /tmp/cap.pcap # 看进程打开了哪些网络连接 lsof -i :8080 -n -Pss -ti的输出里有两项我特别关注:retrans表示重传次数,持续有重传说明链路质量差;cwnd是拥塞窗口,如果它一直很小,说明拥塞控制在压着速度。还有人会看rtt,往返时延突变往往意味着路由变化或对端过载。
抓包之后的分析,我一般用 Wireshark 看几个固定点:三次握手是否正常完成、有没有 RST、有没有大量重复 ACK、应用层数据的时间间隔。其中「重复 ACK」是快速重传的触发信号,看到三个连续的相同 ACK 就说明有包丢了,但链路还能通。
8.2 常见异常对照表,照着查基本不会跑偏
| 异常信息 | 大概率原因 | 排查动作 |
|---|---|---|
| Connection refused | 端口没监听、进程没起、防火墙拒绝 | ss -lntp看端口,telnet试连通性 |
| Connection reset by peer | 对端主动 RST,可能是超时关闭或协议不匹配 | 抓包看 RST 前发生了什么 |
| Connection timed out | SYN 没得到响应,或被中间设备丢包 | traceroute看路径,检查安全组 |
| SocketTimeoutException | 连接建立了但对端处理慢 | 查服务端线程池、慢查询 |
| No route to host | 路由不可达、ARP 失败 | 检查网络配置和目标存活 |
| Too many open files | fd 用尽 | ulimit -n,检查是否有连接泄漏 |
| Address already in use | 端口被占,或 TIME_WAIT 未释放 | 找占用进程,或开启端口复用 |
这张表里我用得最多的是前两条。Connection reset by peer尤其值得展开:它表示对端发了 RST,而 RST 的产生原因有好几种。可能是对端进程崩溃,可能是连接空闲太久被中间设备清理,也可能是应用层写了已经关闭的 socket。区分方法是抓包看 RST 的时机,如果是空闲很久之后突然来的,基本就是中间设备的空闲超时,这时候该做的是加心跳保活,而不是改重试逻辑。
8.3 一个完整的排查案例,从现象到根因
最后记录一个印象最深的案例,流程比较典型。某服务在高峰期偶发大量请求超时,监控上表现为 P99 突然飙到几秒,但 CPU 和内存都正常。
第一步看连接状态,ss -s显示 TIME_WAIT 数量在几万级别,明显偏高。第二步看应用配置,发现调用下游用的是每次新建连接的客户端,没有连接池。第三步抓包,确认每个请求都是一次完整的三次握手加四次挥手,握手耗时占了总耗时的一大半。第四步验证,把客户端换成带连接池的实现,TIME_WAIT 降到几百,P99 回到几十毫秒。
这个案例的启发是:很多「性能问题」本质上是配置问题。如果一上来就去优化业务代码或者加机器,方向就偏了。养成先看连接状态的习惯,能省下大量时间。
9. 最后分享几个我自己踩出来的经验
整理到这里,知识点基本覆盖完了。最后说几条不太容易从文档里看到的东西。
关于学习顺序,我建议不要按教材章节来。更高效的方式是围绕一次真实请求走一遍:从域名解析开始,到建立连接、发送数据、接收响应、关闭连接,每一步遇到不懂的概念再去补。这样学下来的知识是串在一条线上的,不容易忘,面试时也能顺着讲下来而不是蹦单词。
关于面试准备,很多人卡在「知道概念但说不清为什么」。一个自检方法是:随便挑一个知识点,试着用「如果不用它会发生什么」来解释。比如问为什么要三次握手,你就想两次握手会导致什么具体后果;问为什么要滑动窗口,你就想没有窗口会慢到什么程度。能把这个说清楚,基本就过关了。
关于实际工作,我的体会是排查能力比背诵能力重要得多。面试可以背,线上问题背不出来。平时可以刻意练一件事:每遇到一次网络相关的故障,把现象、排查路径、根因、修复方案完整记一笔,攒够十来个,你就有了自己的一套方法论。我到现在还保留着这个习惯,很多当初记下的现象,后来在别的项目里又遇到了,直接翻笔记就能定位。
还有一个容易被忽略的点是对超时时间的设置。几乎所有网络客户端都有默认超时,有些默认值长得离谱(比如某些库的连接超时默认是无限),一旦下游出问题,你的线程会全部卡住,雪崩就是这么来的。我的经验是三层超时都要显式设置:连接超时、读超时、整体调用超时,且后一层要比前一层大,形成梯度。这个细节看起来小,但它决定了系统在下游故障时是降级还是崩盘。
这个系列后面还打算整理操作系统和 JVM 相关的部分,网络这一块如果后面遇到新的问题,也会回头补充进来。