1. 从阻塞到非阻塞Socket编程的演进逻辑很多做Android开发的朋友写网络请求第一反应就是OkHttp、Retrofit再往下一点知道HttpURLConnection但一旦聊到Socket和NIO不少人就开始犯怵。我刚开始接触这块的时候也一样觉得NIO这三个字母自带一种“生人勿近”的气场。但后来做IoT设备通信、做长连接推送、做自定义协议解析绕来绕去还是得回到Socket和NIO上来。这篇文章就把我在实际项目里摸爬滚打出来的经验整理一下聊聊Android环境下Socket编程怎么从最基础的阻塞模式一步步演进到NIO以及每一步为什么要这么走。先说清楚这篇文章适合谁看。如果你已经会写基本的Android网络请求但对TCP长连接、自定义协议、高并发连接管理这些场景还比较模糊那这篇内容就是给你准备的。如果你已经在用Netty或者Mina这类框架但想搞清楚底层到底发生了什么那也可以顺着这个思路往下看。我会尽量用生活化的类比来解释同时给出可以直接参考的代码结构和参数配置。Socket本质上就是两台机器之间的一条“管道”数据从一端流进去从另一端流出来。这个比喻虽然粗糙但足够帮你理解后面所有的优化手段。阻塞式Socket就像一条单车道一辆车不走后面的车全堵着NIO就像给这条路装了红绿灯和多个车道哪条道有空就走哪条。理解了这个核心差异后面的Selector、Channel、Buffer就都好说了。2. 阻塞式Socket在Android中的基本用法与局限2.1 最基础的TCP客户端连接先看一段最朴素的阻塞式Socket代码这是所有后续优化的起点// 阻塞式Socket客户端基本结构 Socket socket new Socket(); socket.connect(new InetSocketAddress(10.0.0.1, 8080), 5000); socket.setSoTimeout(3000); OutputStream out socket.getOutputStream(); InputStream in socket.getInputStream(); // 发送数据 byte[] request buildRequest(); out.write(request); out.flush(); // 读取响应 byte[] buffer new byte[1024]; int len in.read(buffer);这段代码在简单场景下完全够用比如你只是偶尔发一条指令、收一条响应连接数不超过个位数那阻塞式Socket是最省心的选择。但问题在于in.read(buffer)这行代码会一直卡在那里直到有数据可读或者连接断开。如果你在主线程调用直接ANR如果你在子线程调用那这个子线程就被完全占住了什么也干不了。我见过不少项目为了同时处理多个连接直接开一堆线程每个线程管一个Socket。连接数少的时候还行一旦到了几十上百个连接线程切换的开销和内存占用就会变得非常可观。每个线程默认栈大小在Android上是1MB左右100个线程就是100MB这还没算上下文切换的CPU开销。2.2 阻塞模式下的典型问题场景在实际项目中阻塞式Socket最容易踩的坑有这么几个第一个坑是连接超时和读取超时混淆。connect的超时和setSoTimeout设置的读取超时是两回事。连接超时控制的是三次握手阶段读取超时控制的是连接建立之后等待数据的时间。很多人只设了一个结果要么连接阶段卡死要么读取阶段无限等待。第二个坑是半包和粘包。TCP是流式协议没有消息边界的概念。你发了两条100字节的消息对面可能一次收到200字节也可能分两次各收100字节甚至可能第一次收50字节第二次收150字节。阻塞式Socket下如果不自己处理消息边界解析出来的数据就是错的。第三个坑是连接断开检测不及时。阻塞模式下如果对面异常断开你的read可能长时间阻塞而不返回直到TCP keepalive超时或者你主动发数据触发RST。这在长连接场景下是致命的你以为连接还在实际上早就断了。注意Android系统对后台应用的网络访问有限制长时间保持Socket连接需要考虑Doze模式和App Standby的影响。建议结合Foreground Service或者WorkManager来管理长连接的生命周期。2.3 多线程阻塞模型的资源账假设你要做一个即时通讯的Demo需要同时维持50个TCP连接。用阻塞式Socket加线程池的方案大概是这样ExecutorService pool Executors.newFixedThreadPool(50); for (SocketAddress addr : addressList) { pool.execute(() - { Socket socket new Socket(); socket.connect(addr, 5000); // 处理读写循环 while (running) { int len in.read(buffer); // 处理数据 } }); }这个方案能跑但资源账算下来并不好看。50个线程每个线程栈1MB光栈内存就是50MB。再加上每个Socket的发送缓冲区和接收缓冲区默认大小在Android上通常是8KB到64KB不等50个连接又是几MB。线程上下文切换在50个线程同时活跃的时候CPU会有明显的额外开销。如果连接数再往上走比如到200、500这个模型基本就撑不住了。这就是NIO要解决的核心问题用更少的线程管理更多的连接。3. NIO三大核心组件拆解Channel、Buffer、Selector3.1 Channel双向数据通道NIO里的Channel和传统Socket最大的区别在于它是双向的。传统Socket你要么用InputStream读要么用OutputStream写得开两个流。Channel既可以读也可以写一个通道就够了。Android上常用的Channel实现有Channel类型对应传统Socket用途SocketChannelSocketTCP客户端连接ServerSocketChannelServerSocketTCP服务端监听DatagramChannelDatagramSocketUDP通信SocketChannel可以设置为非阻塞模式这是NIO的基础SocketChannel channel SocketChannel.open(); channel.configureBlocking(false); channel.connect(new InetSocketAddress(10.0.0.1, 8080));设置为非阻塞之后connect方法会立即返回不管连接有没有建立成功。你需要通过finishConnect()来检查连接是否完成或者注册到Selector上等待连接就绪事件。3.2 Buffer数据搬运的容器Buffer是NIO里数据读写的中间站。所有数据从Channel读出来先放到Buffer写数据也是先写到Buffer再通过Channel发出去。理解Buffer的三个核心属性就理解了一切capacity缓冲区总容量创建时确定不可变position当前读写位置每读一个字节或写一个字节就往后移一位limit读写边界写模式下等于capacity读模式下等于之前写入的数据量flip()方法是最容易搞混的地方。写数据的时候position在往后走要读数据之前必须调用flip()它会把limit设为当前position然后把position归零。这样读的时候就从0读到limit正好是刚才写入的数据范围。// Buffer读写切换的标准流程 ByteBuffer buffer ByteBuffer.allocate(1024); // 写模式 buffer.put(data); // position从0移动到data.length buffer.flip(); // limitdata.length, position0 // 读模式 while (buffer.hasRemaining()) { byte b buffer.get(); // position逐步移动到limit } buffer.clear(); // position0, limitcapacity准备下次写clear()和compact()的区别也值得注意。clear()是彻底重置假设数据都读完了。compact()是把没读完的数据移到缓冲区头部然后position移到这些数据后面准备继续写。半包处理的时候compact()特别有用。3.3 Selector多路复用的核心Selector是NIO的灵魂。一个Selector可以管理多个Channel通过事件驱动的方式告诉你哪些Channel有数据可读、哪些可以写、哪些连接就绪了。这样你用一个线程就能管理成百上千个连接。Selector的工作流程大致是这样的Selector selector Selector.open(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_READ); while (running) { int readyCount selector.select(); // 阻塞直到有事件就绪 if (readyCount 0) continue; SetSelectionKey keys selector.selectedKeys(); IteratorSelectionKey it keys.iterator(); while (it.hasNext()) { SelectionKey key it.next(); if (key.isReadable()) { // 处理读事件 } else if (key.isWritable()) { // 处理写事件 } else if (key.isAcceptable()) { // 处理新连接 } it.remove(); // 必须手动移除否则下次还会处理 } }selector.select()是阻塞的但阻塞的是Selector本身不是某个具体的Channel。当任何一个注册的Channel有事件就绪select就会返回。这就是“用一个线程管理多个连接”的实现原理。提示selectedKeys()返回的Set需要手动移除已处理的key否则下次select返回时这些key还在集合里会导致重复处理。这是新手最容易犯的错误之一。4. Android上NIO的实操落地与性能调优4.1 一个可运行的NIO客户端Demo下面这个Demo展示了完整的NIO客户端连接、发送、接收流程。我把它拆成了几个关键步骤方便你直接参考public class NioClient { private Selector selector; private SocketChannel channel; private ByteBuffer readBuffer ByteBuffer.allocate(4096); private ByteBuffer writeBuffer ByteBuffer.allocate(4096); public void connect(String host, int port) throws IOException { selector Selector.open(); channel SocketChannel.open(); channel.configureBlocking(false); channel.connect(new InetSocketAddress(host, port)); channel.register(selector, SelectionKey.OP_CONNECT); } public void run() throws IOException { while (true) { selector.select(); SetSelectionKey keys selector.selectedKeys(); IteratorSelectionKey it keys.iterator(); while (it.hasNext()) { SelectionKey key it.next(); it.remove(); if (key.isConnectable()) { handleConnect(key); } else if (key.isReadable()) { handleRead(key); } else if (key.isWritable()) { handleWrite(key); } } } } private void handleConnect(SelectionKey key) throws IOException { SocketChannel sc (SocketChannel) key.channel(); if (sc.finishConnect()) { sc.register(selector, SelectionKey.OP_READ); // 连接建立后可以发送登录包等 } } private void handleRead(SelectionKey key) throws IOException { SocketChannel sc (SocketChannel) key.channel(); readBuffer.clear(); int len sc.read(readBuffer); if (len -1) { sc.close(); key.cancel(); return; } readBuffer.flip(); byte[] data new byte[readBuffer.remaining()]; readBuffer.get(data); // 处理接收到的数据注意处理半包 } private void handleWrite(SelectionKey key) throws IOException { SocketChannel sc (SocketChannel) key.channel(); writeBuffer.flip(); sc.write(writeBuffer); if (writeBuffer.hasRemaining()) { // 还没写完继续关注写事件 writeBuffer.compact(); } else { writeBuffer.clear(); sc.register(selector, SelectionKey.OP_READ); } } }这段代码里有个关键点写事件的处理。当你调用sc.write(writeBuffer)时并不保证一次能把Buffer里的数据全部发出去。如果发送缓冲区满了write返回的字节数会小于Buffer里剩余的数据量。这时候你需要继续关注OP_WRITE事件等下次可写时接着发。很多Demo代码忽略了这一点在数据量大的时候就会出现数据丢失。4.2 半包粘包的处理策略NIO下处理半包粘包比阻塞模式更复杂因为数据是一块一块到达的。常见的处理策略有三种定长消息每条消息固定长度比如每条200字节。接收方每次读满200字节就解析一条。这种方式简单但浪费带宽适合消息长度固定的场景。长度字段前缀消息头里用4个字节表示消息体长度。接收方先读4字节解析出长度N然后继续读N字节。这种方式最常用灵活且高效。分隔符用特殊字符比如\r\n作为消息结束标志。HTTP的请求头就是这么干的。缺点是如果消息体里本身包含分隔符需要转义处理。实际项目中我一般用长度字段前缀的方案代码结构大概是这样// 半包处理的状态机 private int expectedLength -1; private ByteArrayOutputStream messageBuffer new ByteArrayOutputStream(); private void processData(ByteBuffer buffer) { while (buffer.hasRemaining()) { if (expectedLength -1) { if (buffer.remaining() 4) return; // 长度字段都不够等下次 expectedLength buffer.getInt(); } int readable Math.min(buffer.remaining(), expectedLength - messageBuffer.size()); byte[] chunk new byte[readable]; buffer.get(chunk); messageBuffer.write(chunk, 0, readable); if (messageBuffer.size() expectedLength) { // 一条完整消息 byte[] fullMessage messageBuffer.toByteArray(); handleMessage(fullMessage); messageBuffer.reset(); expectedLength -1; } } }这个状态机可以正确处理任意切分方式的数据到达不管是半包还是粘包都能正确解析。4.3 Selector空轮询问题与应对在Android上使用NIO有一个坑不得不提Selector的空轮询。在某些系统版本和特定条件下selector.select()可能会在没有事件就绪的情况下立即返回导致while循环疯狂空转CPU直接飙到100%。这个问题在服务端Linux环境下比较少见但在Android上确实存在。应对方案有几种方案一计数重建Selector。记录连续空轮询的次数超过阈值比如512次就重建Selector把原来注册的Channel重新注册到新的Selector上。int emptyCount 0; while (running) { int ready selector.select(1000); // 设置超时避免无限阻塞 if (ready 0) { emptyCount; if (emptyCount 512) { rebuildSelector(); emptyCount 0; } continue; } emptyCount 0; // 处理事件 }方案二设置select超时。给select()传一个超时时间比如1000毫秒。这样即使空轮询也不会疯狂循环最多每秒空转一次。这个方案简单但治标不治本CPU还是会有一点额外开销。方案三用成熟的网络框架。Netty内部已经处理了空轮询问题如果项目允许引入第三方库直接用Netty是最省心的。但如果你需要控制包体积或者想深入理解原理自己处理也是完全可以的。实操心得我在一个IoT项目里用NIO管理200多个设备连接Selector空轮询问题在某个品牌的设备上特别明显。后来用了计数重建的方案阈值设为256运行了半年没有再出现CPU异常。重建Selector的时候要注意把原来Channel的attachment也迁移过去否则会丢状态。5. 常见问题排查与避坑指南5.1 NIO使用中的典型异常与解决异常现象可能原因排查方向解决方案select()返回0但CPU高空轮询检查系统版本和Selector实现计数重建Selector或设超时数据读取不完整半包未处理检查消息边界逻辑引入长度字段或状态机写数据丢失未处理OP_WRITE检查write返回值注册OP_WRITE事件继续写连接频繁断开心跳缺失检查是否有keepalive机制加入应用层心跳包BufferOverflowExceptionBuffer容量不足检查消息最大长度扩容Buffer或分片处理CancelledKeyExceptionKey已取消仍被使用检查key.isValid()使用前先判断有效性5.2 心跳与断线重连的设计要点NIO本身不提供心跳机制需要应用层自己实现。心跳的设计要考虑几个参数心跳间隔太短浪费电量和流量太长断线检测不及时。移动端一般30秒到60秒比较合适。如果对实时性要求高可以缩短到15秒。心跳超时一般设为心跳间隔的2到3倍。比如30秒发一次心跳超时设为90秒。连续3次心跳无响应就判定断线。重连策略不要固定间隔重连建议用指数退避。第一次1秒后重连第二次2秒第三次4秒最大不超过30秒。这样既能快速恢复又不会在服务端故障时造成惊群。// 指数退避重连的简单实现 private long reconnectDelay 1000; private static final long MAX_DELAY 30000; private void scheduleReconnect() { handler.postDelayed(() - { if (connect()) { reconnectDelay 1000; // 重置 } else { reconnectDelay Math.min(reconnectDelay * 2, MAX_DELAY); scheduleReconnect(); } }, reconnectDelay); }5.3 Android特有的网络限制与适配Android和标准Java环境有几个重要差异写NIO的时候必须考虑网络权限INTERNET权限是必须的这个不用多说。但Android 9之后默认禁止明文HTTP流量如果你的Socket通信不是TLS加密的需要在manifest里配置usesCleartextTraffic或者用Network Security Config。后台限制从Android 8.0开始后台应用的网络访问受到严格限制。如果你的长连接需要在后台保持必须用Foreground Service并且通知栏要有常驻通知。否则系统会在应用进入后台后一段时间内切断网络。Doze模式设备进入Doze模式后网络访问会被暂停心跳包发不出去。解决方案是使用setExactAndAllowWhileIdle或者setAlarmClock来安排心跳或者申请忽略电池优化。网络切换移动设备经常在WiFi和蜂窝网络之间切换IP地址会变原来的Socket连接会失效。需要监听ConnectivityManager的网络变化广播在网络切换时主动断开重连。注意在Android上做长连接建议把连接管理封装成一个Service用Binder或者LiveData对外暴露状态。这样Activity销毁重建时连接不受影响也方便统一处理网络变化和生命周期。6. 从NIO到Netty什么时候该用框架6.1 手写NIO的适用边界自己写NIO不是不可以但要清楚它的边界在哪里。如果你的场景满足以下条件手写NIO是合理的连接数在几百以内协议相对简单团队对NIO有足够的理解能处理各种边界情况包体积有严格限制不想引入额外依赖需要深度定制框架反而碍手碍脚但如果你的场景是下面这样建议直接上Netty连接数上千甚至上万协议复杂需要编解码、心跳、重连、流量控制等完整功能团队对NIO经验不足自己写容易出bug需要跨平台服务端和客户端用同一套协议栈Netty在Android上是可以用的但要注意裁剪。完整的Netty jar包大概2MB多对于大部分App来说可以接受。如果嫌大可以用ProGuard做裁剪或者只引入需要的模块。6.2 手写NIO到Netty的迁移思路如果你已经有一套手写的NIO代码想迁移到Netty核心思路是ChannelHandler替换原来的事件处理逻辑。原来在handleRead里做的事情现在放到ChannelInboundHandlerAdapter.channelRead里。原来在handleWrite里做的事情放到channelWrite或者writeAndFlush的回调里。ByteBuffer替换成ByteBuf。Netty的ByteBuf比NIO的ByteBuffer好用很多支持动态扩容、读写指针分离、引用计数。半包处理可以用LengthFieldBasedFrameDecoder几行配置就能搞定。Selector替换成EventLoopGroup。Netty的EventLoop本质上就是封装好的Selector循环但处理了空轮询、任务调度、线程模型等细节。你不需要再手动管理Selector的生命周期。// Netty客户端的核心配置 EventLoopGroup group new NioEventLoopGroup(); Bootstrap bootstrap new Bootstrap(); bootstrap.group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new LengthFieldBasedFrameDecoder(65536, 0, 4, 0, 4)) .addLast(new IdleStateHandler(0, 30, 0)) .addLast(new MyBusinessHandler()); } });这段配置里LengthFieldBasedFrameDecoder自动处理了半包粘包IdleStateHandler自动处理了心跳超时。原来手写NIO需要几百行代码才能搞定的东西Netty几行配置就解决了。6.3 性能对比与选型建议我在一个模拟项目里做过简单的对比测试场景是维持500个TCP连接每秒收发1000条消息消息平均大小200字节指标手写NIONettyCPU占用12%-18%8%-12%内存占用约45MB约60MB代码行数约800行约200行开发耗时3-5天1-2天稳定性需要大量测试经过验证手写NIO在内存上有一点优势因为Netty本身有一些额外的对象开销。但CPU方面Netty反而更好因为它的内存池和零拷贝优化做得更到位。代码量和开发效率的差距就更明显了。我的建议是学习阶段手写NIO理解底层原理生产环境用Netty把精力放在业务逻辑上。两者并不矛盾理解了NIO再看Netty的源码很多设计决策就一目了然了。最后分享一个我在实际项目中总结的小技巧不管用NIO还是Netty连接的状态管理一定要做好。我习惯给每个连接维护一个状态机包含CONNECTING、CONNECTED、READING、WRITING、CLOSING、CLOSED这几个状态。所有对连接的操作都先检查当前状态避免在错误的状态下执行操作。这个习惯帮我避免了很多诡异的bug比如在连接还没建立完成时就发送数据或者在连接已经关闭后还尝试写入。状态机的代码不复杂但带来的稳定性提升非常明显。