ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java网络编程进阶:从BIO到NIO的演进与线上问题排查

Java网络编程进阶:从BIO到NIO的演进与线上问题排查 Java网络编程这块很多人一开始接触都是从TCP的三次握手、四次挥手开始背然后写一个ServerSocket和Socket的Demo跑通了就放下了。但真正到了线上随便一个连接超时、内存飙升、线程池被打满的问题都能把人卡在原地半天。我在这个领域踩了不少坑也帮别人排查过不少问题从最传统的BIO到NIO再到基于Netty的框架一路用下来最大的感受是网络编程不是因为代码有多难写而是因为你手里没有一个完整的“视图”不知道数据从网线到Java对象的全过程里每一层在干什么。这篇文章我不打算按教科书顺序把TCP/IP协议栈从头捋一遍而是换个思路从一个Java开发者的实际工作流出发把网络编程拆成“设计认知—核心细节—手写实现—线上排查”四个板块。你如果正准备做IM、网关、RPC框架或者正在为Java面试里的网络编程问题发愁这篇文章应该能帮你把那些零散的知识点串成一条线。1. 先想清楚Java网络编程到底在解决什么事1.1 从“两个进程怎么聊天”说起网络编程的本质说破天就是“两个进程之间交换数据”。只不过这两个进程可能在同一台机器上也可能隔着几千公里。Java作为一门跨平台语言把操作系统提供的socket接口包装成了java.net.Socket、ServerSocket这些我们很熟悉的类看起来很简单——new一个Socket连上getInputStream/getOutputStream读写就完事了。但“简单”是假象。真正的复杂度藏在这几个地方连接怎么建立、数据怎么可靠地送到对端、怎么判断对端已经挂了、怎么处理大量并发连接、怎么避免读写互相干扰。这些问题的根源都在TCP/IP协议栈而Java只是把协议栈的能力暴露出来而已。所以你很少看到有人能靠死记硬背Socket的API成为网络编程高手关键还是理解这层抽象背后发生了什么。举一个最简单的例子你调用new Socket(host, port)的时候代码内部发生了什么很多人只知道“建立了TCP连接”但实际是操作系统帮你完成了DNS解析、路由选择、TCP三次握手最后才把那个已经就绪的连接文件描述符交给Java层。如果你能把这个过程在脑子里画出来后续很多问题都不会懵。1.2 Java网络编程的“三段式进化”BIO、NIO、AIOJava网络编程的API演进本质上就是跟“阻塞”作斗争的过程。BIOBlocking I/O最原始、最好理解的模式。一个客户端连接进来服务端就分配一个线程去处理这个线程一直阻塞在read或者write上直到数据读完或者写完。问题很明显线程的成本很高一个线程占1MB左右的内存默认线程栈大小而且线程多了以后CPU大量时间花在线程上下文切换上真正的业务处理时间被严重挤占。NIONon-blocking I/O / New I/OJDK 1.4引入。核心是Selector一个线程可以同时监听成千上万个连接的事件只有某个连接真的有数据可读或可写时才去处理它。这就像餐厅里不再是一个顾客配一个服务员而是一个服务员统一看一圈哪个桌举了手才过去接待。线程数量大幅下降支撑高并发的能力显著提升。AIOAsynchronous I/O / NIO.2JDK 1.7引入真正意义上的异步非阻塞发起读操作后立即返回由操作系统在数据就绪后回调。但这里有个很尴尬的现状Linux上的AIO实现是用epoll模拟的性能上并没有比NIO强出代差所以很多成熟框架比如Netty在Linux上默认用的仍然是NIO模型。我个人的看法是AIO你可以了解但千万别把它当成“一定要用”的技术。现实世界里Netty这种基于NIO的框架依然是主流AIO更多出现在Windows平台的某些场景里。1.3 选型什么时候该用哪种模型很多初学者有个误区觉得BIO是落后的东西面试里也总被问“BIO和NIO的区别”答完就以为BIO一无是处。实际不是这样的。如果你的应用是内部管理系统、低并发的工具类服务比如每秒几十个连接业务逻辑又不重BIO配上线程池完全够用。它的代码直观好调试出了问题好排查。如果你要写的是消息推送服务、API网关、RPC框架、游戏服务器这类高并发、长连接密集的场景必须走NIO方向自己基于Selector封装或者直接用Netty。如果你的服务是文件传输、磁盘I/O密集的场景那就要考虑AIO或者专门的异步文件通道因为瓶颈不在网络而在磁盘。我见过不少团队为了“显得技术先进”在低并发场景里强行上Netty结果代码复杂程度翻了好几倍线上出了问题还不好定位。工具是拿来解决问题的不是拿来炫技的。2. 核心细节拆解Socket背后的TCP细节与踩坑点2.1 三次握手发生在哪里backlog是个什么参数先记住一个结论accept()拿到的连接是已经完成了三次握手的连接。三次握手的过程在内核里完成Java代码根本感知不到。具体来说服务端调用ServerSocket.bind(port)后内核开始监听端口。客户端发来SYN包时内核自动回SYNACK此时连接进入半连接队列SYN队列。等到客户端的ACK到达连接才会被放到全连接队列Accept队列。应用层调用accept()只是从全连接队列里取出一个已经就绪的连接而已。这里有个很经典的坑ServerSocket构造方法里的backlog参数说的就是这个全连接队列的大小。如果队列满了新的连接请求会被内核直接拒绝表现就是客户端连不上或者连接超时。很多系统给出的“最大连接数”并不是你代码里能创建的Socket数而是这个队列加系统fd限制共同作用的结果。我遇到过一次线上事故服务端代码用默认backlog通常linux是50结果在流量高峰时大量客户端提示Connection refused。用netstat -s一看SYN to LISTEN sockets dropped这个计数器一直在涨才意识到是backlog太小。后来调大加上合理的服务端接受能力问题就消失了。2.2 阻塞、非阻塞到底是谁阻塞“阻塞”这个词很迷惑人因为大家总容易把它理解成代码层面的事。实际上阻塞指的是线程在等待某个操作完成时被挂起不消耗CPU的过程。在BIO里线程调用InputStream.read()后如果对端迟迟不发数据这个线程会一直挂在那里什么也干不了。在NIO里线程调用Selector.select()会阻塞在事件等待上但一旦有事件就绪这个同一个线程可以去处理无数个连接的事件。这不是魔法关键是它不会为一个连接独占一个线程。再往深一层我在面试里经常问自己一个问题NIO到底是同步还是异步答案是——NIO是同步非阻塞。数据从内核缓冲区拷贝到用户缓冲区这个过程仍然需要应用线程亲自“搬运”没有让操作系统帮你搬完再通知你。AIO才是异步数据拷贝由内核完成拷贝完了再回调应用。2.3 粘包半包为什么一定会遇到怎么破在TCP上做应用层通信粘包和半包属于逃不掉的宿命问题。粘包很好理解TCP是字节流协议它不关心你一次read要读多少字节也不保证你发送时的消息边界。你调用两次write对端一个read可能把两段数据都读走了反过来你发送一个大消息网络层可能把它拆成多个包客户端一次read只读到一半。解决思路本质上就三种固定长度每个消息定一个固定字节数比如1024字节不足补齐。简单粗暴但空间浪费严重。分隔符比如用\n或自定义分隔符读到分隔符视为一条完整消息。实现简单但消息体里不能天然包含分隔符。长度前缀每个消息前用固定字节数比如4字节表示消息体长度接收方先读长度再按长度读取消息体。这是目前最通用、推荐的做法。实际开发里我基本都是用长度前缀方案。如果你用NettyLengthFieldBasedFrameDecoder已经帮你封装好了如果你手写NIO或BIO这个逻辑最好做成一个单独的编解码工具类别散落在业务代码里。2.4 心跳、超时、断线重连不做就会踩的连环坑TCP连接不是永远可靠的。对端进程崩溃、网线断开、网络设备故障客户端可能根本收不到任何通知。因为你没发数据TCP的保活机制默认要等2小时才探测一次这在大多数业务场景下等于不存在。所以业务层必须自己设计心跳机制客户端每隔一段时间发送一个心跳包服务端如果超过N秒没有收到任意数据就判定连接死亡并主动关闭。服务端也可以定期清理不活跃连接避免僵尸连接占用fd。我踩过最惨的一次坑是当初写网关时没用心跳机制客户端设备掉线后服务端连接一直挂着。由于长时间没数据读写内核完全感知不到异常最后fd被占满新连接全都进不来。排查了半天才想起来用ss -ant看了一眼连接状态成片的ESTABLISHED连接一动不动才意识到是心跳缺失的问题。3. 实操从BIO到NIO手写一个可用的TCP通信3.1 第一个版本单线程BIO的Echo服务端先来一个最基础的版本目标是把流程跑通直观感受一下阻塞发生在哪里。public class BioEchoServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(9090); System.out.println(server started, port9090); while (true) { Socket socket serverSocket.accept(); System.out.println(accept client: socket.getRemoteSocketAddress()); BufferedReader reader new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter writer new PrintWriter(socket.getOutputStream(), true); String line; while ((line reader.readLine()) ! null) { System.out.println(receive: line); writer.println(echo: line); } socket.close(); } } }运行起来后你可以用telnet 127.0.0.1 9090或者nc 127.0.0.1 9090连上去随便输入一行字符服务端就会原样返回。这里注意因为这个版本是单线程的accept()返回第一个客户端后服务端就死死卡在reader.readLine()里第二个客户端即使TCP连接建立成功也永远不会被accept()处理表现为“连得上但没反应”。如果你想看得更清楚可以开两个终端同时连接输入数据后会发现只有第一个连接有反应。这就是BIO最让初学者困惑的地方连接是内核帮你处理好的但应用层没有能力去处理多个连接。3.2 线程池版本能撑住并发但别高兴太早最简单的优化思路是把每个连接交给一个独立线程处理public class BioThreadPoolServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(9090); ExecutorService pool Executors.newFixedThreadPool(50); while (true) { Socket socket serverSocket.accept(); pool.submit(() - handle(socket)); } } private static void handle(Socket socket) { try (BufferedReader reader new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter writer new PrintWriter(socket.getOutputStream(), true)) { String line; while ((line reader.readLine()) ! null) { writer.println(echo: line); } } catch (IOException e) { e.printStackTrace(); } } }这个版本能同时处理多个客户端了但问题也随之而来线程池大小是固定的如果同时有500个客户端在线其中450个连接都没有发数据那450个线程就全部阻塞在read上白白浪费资源。一旦同时活跃连接数超过线程池上限后面进来的连接就只能排队等待。你要么调大线程池线程多到一定规模后CPU都在做上下文切换要么换NIO。这个阶段我特别建议你做一个简单的压测用一段脚本开几百个连接输入数据后观察服务端CPU和内存。你会很直观地感受到“连接数多”和“活跃连接多”是两码事。3.3 NIO版本Selector事件循环是怎么转起来的NIO的核心不是Channel也不是Buffer而是Selector。它让一个线程通过事件驱动的方式管理大量连接。看一下最小骨架public class NioEchoServer { public static void main(String[] args) throws IOException { Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(9090)); serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT); ByteBuffer buffer ByteBuffer.allocate(1024); while (true) { selector.select(); IteratorSelectionKey iterator selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key iterator.next(); iterator.remove(); if (key.isAcceptable()) { SocketChannel channel serverChannel.accept(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel channel (SocketChannel) key.channel(); buffer.clear(); int read channel.read(buffer); if (read 0) { buffer.flip(); channel.write(buffer); } else if (read 0) { channel.close(); } } } } } }这段代码看起来短但包含了NIO编程最重要的几个点configureBlocking(false)必须设为非阻塞模式否则无法注册到Selector上。register把Channel和感兴趣的事件OP_ACCEPT、OP_READ、OP_WRITE绑定。selector.select()阻塞等待至少一个事件发生。一旦返回你就能通过selectedKeys()拿到所有就绪的事件。事件分发每次处理完一个key必须从集合里移除否则下次还会重复处理。手写NIO最大的痛点是状态管理一个连接的数据可能不是一次read就能读完的你需要为每个连接维护一个半包缓冲区写数据时不可能一次性写完你需要维护一个待写入队列。这些逻辑堆在一起代码很快就失控了。所以我在工作中很少直接手写完整NIO服务端更多是写一些测试工具或短小的代理程序。生产级的实现我会优先选择Netty。3.4 关键参数超时、缓冲区、TCP选项怎么设网络编程里最容易被忽略的就是参数设置。我整理一下最常见的几个参数作用我的经验值/建议SO_TIMEOUT设置read阻塞的最长时间超时抛SocketTimeoutException业务心跳间隔的2-3倍比如心跳30秒就设60秒TCP_NODELAY是否禁用Nagle算法。Nagle会把小包合并发送减少网络包数量但增加延迟交互类应用要true即setTcpNoDelay(true)SO_REUSEADDR端口释放后是否允许立即重用服务端必须设true否则重启经常报Address already in useSO_LINGER设置close时等待未发送数据的时间一般不用动用默认值backlog全连接队列大小根据业务压测结果调一般256起步接收/发送缓冲区setReceiveBufferSize/setSendBufferSize需要结合TCP窗口调别盲目设得很大其他参数我也稍微说下缓冲区不是越大越好太大会占用内存而且TCP窗口机制会自动协商我给普通业务消息场景一般设置8KB到32KB保持合理的上下游。在把服务端跑起来以后我强烈建议你用tcpdump或者Wireshark抓一次包亲眼看看三次握手的过程、数据的ACK确认。这个习惯帮我建立了很多对网络协议的感觉比纯粹看书有效得多。4. 线上问题排查实录网络编程典型故障与工具4.1 Connection reset、Connection refused、Broken pipe 都是怎么回事这三种报错我在群里见过不下百次其实它们各自表达的问题状态完全不同。Connection refused一般是目标端口根本没人监听或者内核全连接队列已满直接拒绝。排查方向是先确认服务端进程是否存活、端口是否被占用再看backlog和连接数指标。Connection reset对端发了RST包。常见原因是连接建立后服务端处理线程异常退出并关闭socket或者服务端往一个已经关闭的连接继续写数据对端内核收到数据但应用层无人在读直接回RST。Broken pipe更确切说是在已经关闭的socket上继续写数据。比如客户端断电服务端不知道还在调用write内核返回EPIPEJava层抛IOException: Broken pipe。这三类错误的排查我的建议是先顺序问三个问题服务端在吗连接还活着吗代码有没有在错误连接上继续IO大多数情况下答案都出在这三件事上。4.2 大量TIME_WAIT和CLOSE_WAIT意味着什么用netstat -ant | grep TIME_WAIT | wc -l数一下如果是几千甚至上万先别慌要看场景。TIME_WAIT是主动关闭连接的一方在等内核回收socket资源TCP协议为了保证旧连接的重复包不会串扰新连接主动关闭方要等待2MSLLinux上一般是60秒。如果大量TIME_WAIT出现在服务端说明服务端在频繁主动关闭连接。短连接场景下这是正常的但数量太大会占用端口和fd。可以用SO_REUSEADDR缓解或者在架构上改成长连接。如果大量CLOSE_WAIT出现在服务端那就不是内核的问题是代码的问题。CLOSE_WAIT表示对端发起了关闭但本端一直没有调用close()。我在排查这种问题时十有八九是程序里读到了read -1但没有正确释放socket资源或者关闭流程被try-catch吞掉了异常。CLOSE_WAIT堆积这个场景我有一招很管用的土办法用jstack把线程栈打出来和CLOSE_WAIT的连接数量做对照那些卡在某个不释放的read或write上的线程往往就是罪魁祸首。4.3 线程爆炸java.lang.OutOfMemoryError: unable to create new native thread这个报错在网络编程里太常见了。表面意思是无法创建新的本地线程实际原因通常是以下两者之一线程数量已经超过了操作系统ulimit -u的限制。JVM堆内存已经把物理内存吃光没有更多内存分配给线程栈。排查思路很简单先用ps -eLf | grep java | wc -l看看进程里线程数再用jstack pid看看线程都在干什么。如果90%的线程卡在java.net.SocketInputStream.socketRead0上那基本可以断定是BIO模型下连接数过多导致线程被socket read占满。这种情况的根治不是一味调大线程池上限而是考虑换模型。要么把连接改成NIO让一个线程管理大量空闲连接要么增加业务心跳和空闲连接清理把已经死掉的连接及时踢掉。4.4 网络排查的日常工具箱我把对付网络编程问题最常用的几条命令列一下都是Linux下直接能用的# 查看端口和连接状态 netstat -ant | grep 9090 # 更好用的socket状态查看工具能看到队列信息 ss -antl | grep 9090 # 查看进程打开的文件描述符数量和socket情况 lsof -p pid | grep TCP | wc -l # 查看网络协议栈统计信息重点看SYN drops netstat -s | grep -i drop # 实时抓包卡点的时候看一眼数据到底有没有发过来 tcpdump -i any port 9090 -nn -XX # 线程栈看java线程卡在哪个socket读写上 jstack pid我一直觉得排查网络问题最忌讳的是上来就改代码。先看网络层有没有问题再看连接状态最后才看应用逻辑。顺序搞反了很容易绕远路。5. 一些补充从Java面试“八股”到真正能用最后一个部分我想聊聊学习路径。我知道很多读者是在准备面试时搜到这篇文章的因为Java网络编程确实是面试里的高频区。BIO/NIO/AIO的区别、TCP三次握手、粘包半包、Netty的线程模型几乎每次面试都会翻牌子。但我的建议是不要只背结论要亲手把代码跑一遍。面试官问“BIO和NIO的区别”你背得再顺都只是八股。如果你能说清楚自己写过一次BIO单线程版卡住时的现象能说明白Selector的selectedKeys为什么需要手动移除这种理解深度自然就不一样了。关于学习顺序我的个人路径是这样的先跑通一个BIO的客户端和服务端用telnet和nc联调明白阻塞在哪。用tcpdump抓包看三次握手和四次挥手理解连接生命周期。手写线程池版BIO感受连接数和线程数的矛盾。手写NIO的Echo服务端理解Selector事件循环。引入Netty对比手写NIO和成熟框架的差距。设计一套带心跳和粘包拆包处理的完整通信协议。这套路径走下来你处理网络编程问题时会明显感觉有底数而不是靠猜和试。我自己带过的几个新人按这个顺序学基本上两周左右就能独立排查一个网络相关的线上问题。最后再分享一个小技巧写网络通信代码的时候务必把日志打全。连接建立、连接关闭、每一条消息的收发、每次异常抛出都要有清晰可见的日志。很多网络问题根本没法调试就是因为你不知道那个连接是什么时候断的、断之前发生了什么。有了完整日志80%的问题都能直接靠日志定位。
返回列表