尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java网络编程从Socket到Netty:高并发实战指南

Java网络编程从Socket到Netty:高并发实战指南 很多刚开始学Java的同学拿到“网络编程”这四个字就发怵。一来觉得概念太抽象什么BIO、NIO、Netty、序列化一个比一个听着高大上二来在IDE里写个ServerSocket倒是能跑通可一旦放到真实环境里性能上不去、连接一多就崩、数据还经常对不上完全不知道从哪下手。其实Java网络编程本质就是解决两件事怎么把数据从一台机器安全可靠地送到另一台机器以及怎么高效处理成千上万的并发连接。搞懂这两个底层逻辑你再看面试题、看框架源码思路会顺畅很多。这篇文章我从实际开发的角度把Java网络编程从最基本的Socket讲到生产级Netty应用每个阶段配代码、配踩坑经验、配面试常考问题适合正在学Java基础、准备面试、或者想上手Netty的朋友。没有废话全是实操里用得到的东西。1. 入门之前先搞懂网络编程到底在解决什么问题1.1 网络编程的应用场景与学习价值想明白网络编程解决什么问题看几个日常场景就够了。你登录一个网站浏览器和Web服务器之间就是HTTP协议通信底层是TCP连接你刷短视频视频数据从服务器传到手机走的是TCP加流媒体协议你玩手游角色移动的指令通过UDP或TCP实时上报到服务器再广播给同场的其他玩家。甚至你写的后端服务调用另一个服务的接口用Feign、Dubbo或者RestTemplate底层全都离不开网络通信。Java后端岗位的日常说白了有一大半时间在处理这类通信问题。我见过太多简历上写着“熟悉Java基础”的候选人一问网络编程只知道Socket能写聊天室再往下问TCP粘包怎么解决、高并发下IO模型怎么选、Netty的EventLoop到底怎么回事就支支吾吾了。所以无论是应付面试还是应付真实项目网络编程都是必须跨过去的一道坎。学习网络编程最忌讳只背概念不写代码。你光知道“TCP三次握手四次挥手”没有意义要用代码亲手跑一遍客户端和服务端的交互再亲眼看到连接建立、数据收发、异常断开的整个过程这些抽象概念才会真正长在你脑子里。1.2 三类基础协议TCP、UDP、HTTP网络编程绕不开协议。Java程序员最常接触的有三个它们的定位完全不同。**TCP传输控制协议**是面向连接的、可靠的、基于字节流的传输协议。什么叫可靠数据发了接收方必须确认收到如果中间丢了发送方会重传保证接收方拿到的数据完整有序。代价是效率相对低、连接建立有开销。大多数应用层协议都构建在TCP之上比如HTTP、MySQL协议、Redis协议。**UDP用户数据报协议**则完全相反无连接、不保证可靠交付数据报发出去就不管了省去了确认、重传、排序这些机制延迟低、开销小适合能容忍少量丢包但对实时性要求高的场景比如视频通话、语音传输、游戏里某些实时指令。HTTP属于应用层协议底层依赖TCP定义的是客户端和服务端之间请求响应的格式。你可能听过HTTP/1.1、HTTP/2、HTTP/3这些版本差异主要体现在连接复用、传输效率、队头阻塞这些方面但对TCP层的开发来说你要处理的问题始终是同一个高效稳定地传输字节流。给个直觉类比TCP像顺丰快递运单号、签收、损坏理赔全程可追踪UDP像自己骑电动车送文件快是真快但中途掉了你也不知道。开发中根据业务容忍度去选没有绝对的好坏只有合不合适。2. 从零写一个Socket通信程序BIO入门2.1 基于TCP的Socket通信模型Java里最早提供的网络编程模型是BIOBlocking IO阻塞式IO核心类就是ServerSocket和Socket。// 服务端 ServerSocket server new ServerSocket(8080); System.out.println(服务端启动等待客户端连接...); Socket socket server.accept(); // 阻塞直到有客户端连上来 InputStream in socket.getInputStream(); byte[] buffer new byte[1024]; int len in.read(buffer); // 阻塞直到读到数据 System.out.println(收到客户端消息 new String(buffer, 0, len)); socket.close(); server.close();// 客户端 Socket socket new Socket(127.0.0.1, 8080); // 连接服务端也是阻塞的 OutputStream out socket.getOutputStream(); out.write(Hello Server.getBytes()); out.flush(); socket.close(); run();这段代码每个人第一次跑通的时候都会很兴奋因为它直观展示了什么叫“两台机器之间传输数据”。但只要你稍微多想一步就会发现它有问题accept()阻塞、read()也阻塞服务端同一时刻只能处理一个客户端连接。给这个服务端加个循环也只能挨个处理客户端请求前一个处理完才轮到下一个。BIO的线程模型是“来一个连接开一个线程”代码大致长这样while (true) { Socket socket server.accept(); new Thread(() - handleRequest(socket)).start(); }这种写法在连接数少的时候没问题但连接一多线程数暴涨上下文切换开销巨大线程栈占内存系统很快就撑不住了。而且大多数线程都阻塞在read()上等着数据来CPU和内存都在空转。2.2 实操搭建一个多线程BIO服务端我建议你在学习阶段把上面的单线程例子扩展成多线程版本亲手跑一下这是理解后面NIO的前提。public class BioServer { private static ExecutorService threadPool new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy()); public static void main(String[] args) throws Exception { ServerSocket server new ServerSocket(8080); System.out.println(BIO Server 启动); while (true) { Socket socket server.accept(); threadPool.execute(() - 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) { System.out.println(收到: line); writer.println(echo: line); } } catch (Exception e) { e.printStackTrace(); } } }这里用线程池代替了无限创建新线程算是BIO的优化方案。但你注意一个问题线程池的线程数如果设成固定的当所有线程都被耗时的read()阻塞时新请求只能排队。而用BIO处理长连接比如聊天室每条连接至少占一个线程这根本是不可持续的。2.3 关键误区BIO慢在阻塞而不是线程很多人在面试的时候解释BIO的性能问题一开口就说“线程开销大”。这个说法不完全错但不够本质。BIO真正的痛点在于阻塞线程发起read()调用之后如果数据还没到线程就挂起等待在这段时间里线程什么都干不了整个线程的栈内存、CPU调度资源全被白白占着。所谓“连接少的时候没问题”是因为阻塞时CPU能立即切换去跑别的线程。可一旦连接数上万每个连接占个线程光线程栈内存就大几百MB再加上上下文切换的代价系统就废了。想通这一点你就会理解为什么后面要引入NIO核心思路就是让一个线程能同时照看大量连接只在真正有数据可读的时候才切换到对应连接上去处理。3. NIO实战同步非阻塞如何扛住高并发3.1 NIO的三大核心Buffer、Channel、SelectorNIONon-blocking IO是Java 1.4引入的另一套IO体系三大核心组件各司其职Buffer缓冲区数据读写都发生在Buffer上。注意它是面向缓冲区的不像BIO那样直接面向Stream。ByteBuffer是最常用的缓冲区读数据要flip()切换读写模式写数据要clear()或compact()清空或压缩剩余数据。这里有个高频考点flip()到底是干什么的其实很简单Buffer内部维护了position当前读写位置、limit可读写上限、capacity容量三个指针flip()把写模式切换到读模式把limit设为当前position再把position归零。Channel通道可以理解为连接的抽象SocketChannel对应TCP连接ServerSocketChannel对应服务端监听。和BIO的InputStream/OutputStream不同Channel是双向的既能读也能写。Selector多路复用器这是NIO的灵魂。一个Selector可以注册多个Channel然后调用select()方法去轮询哪些Channel有数据可读、哪些可以写数据。这个机制让一个线程同时管理成千上万条连接成为可能。Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT);通了三个组件的逻辑再看代码就很简单select()返回当前有事件的Channel集合遍历处理即可。3.2 手写一个NIO服务端代码拆解NIO服务端的标准写法网上很多我贴一个精简但结构清晰的版本public class NioServer { public static void main(String[] args) throws Exception { Selector selector Selector.open(); ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.socket().bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT); ByteBuffer buffer ByteBuffer.allocate(1024); while (true) { selector.select(); // 阻塞直到至少有一个通道有事件 IteratorSelectionKey keys selector.selectedKeys().iterator(); while (keys.hasNext()) { SelectionKey key keys.next(); keys.remove(); // 必须手动移除否则会重复处理 if (key.isAcceptable()) { SocketChannel client serverChannel.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel client (SocketChannel) key.channel(); buffer.clear(); int len client.read(buffer); if (len 0) { buffer.flip(); System.out.println(收到数据: new String(buffer.array(), 0, buffer.remaining())); // 实际开发中这里要根据业务去写回响应这里略去 } else if (len 0) { client.close(); // 对端关闭 } } } } } }这段代码的关键点有三个第一configureBlocking(false)把Channel设置为非阻塞模式这是注册到Selector的前提。第二selectedKeys()返回的事件集合需要在遍历时手动remove()否则下轮select()返回时之前的事件还在集合里会被重复消费。这是NIO新手最容易踩的坑。第三buffer.clear()和buffer.flip()必须配对使用clear()为读做准备flip()为写出/读取数据做准备顺序错了数据就会乱。NIO的这套模型把线程数压缩到了一个主线程所有连接的事件在这个线程上轮询。高性能的代价是代码复杂度上来了你需要自己管理缓冲区、自己处理半包粘包、自己维护“每个连接当前读到哪了”的状态。这也是后面Netty出现的原因。3.3 AIO需要学吗Java 7还引入了AIO异步非阻塞IO核心是回调读数据不需要你主动轮询操作系统读好了会回调你的方法。听起来比NIO更先进但实际生产中用得很少。Linux下AIO的底层实现不如NIO的epoll那么成熟而且Netty封装NIO之后使用复杂度已经低了很多性能也够用所以绝大多数项目压根不用AIO。面试里被问到知道它们三者的区别即可不需要花大力气去学AIO的代码。4. 生产级选择Netty框架深度实践4.1 为什么不用原生NIO而选Netty原生NIO能跑但离生产可用还有不小距离。你自己用NIO写一个服务端很快会遇到一堆“脏活”粘包拆包问题TCP是字节流没有消息边界你发一段消息对方可能收到半截也可能一次收到好几段黏在一起的消息必须自己设计协议、自己编解码。断线重连和心跳机制长连接场景下连接断了需要识别空闲了需要发心跳包保活这些逻辑都得自己写。IO线程与业务线程的分离真正开发中业务逻辑可能涉及数据库操作、调外部接口这些耗时操作不能放在IO线程上阻塞否则高并发性能一样被拖垮。异常处理、超时管理、连接池管理全是细节全都很容易出错。Netty把这些都封装好了它基于NIO提供了强大且易用的网络应用框架。业界主流的框架底层通信十有八九都是NettyDubbo、RocketMQ、Elasticsearch、Spring WebFlux底层的Reactor模型还有各种自研的RPC框架。学会了Netty你等于拿到了阅读大量中间件源码的钥匙。4.2 五分钟搭一个Netty服务端说再多不如跑一个DemoNetty的Hello World只需要几步public class NettyServer { public static void main(String[] args) { EventLoopGroup bossGroup new NioEventLoopGroup(1); // 接收连接 EventLoopGroup workerGroup new NioEventLoopGroup(); // 处理IO读写 try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new StringDecoder()) .addLast(new StringEncoder()) .addLast(new SimpleChannelInboundHandlerString() { Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { System.out.println(收到: msg); ctx.writeAndFlush(hello from server: msg); } }); } }) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true); ChannelFuture future bootstrap.bind(8080).sync(); future.channel().closeFuture().sync(); } catch (InterruptedException e) { e.printStackTrace(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }Java 8之后可以用lambda简化部分代码但核心逻辑不变。这段代码里有几个关键点你值得花时间吃透双EventLoopGroup模型bossGroup负责接受新连接workerGroup负责处理已建立连接上的IO读写。这个模型对应经典的Reactor多线程模型接受连接和处理IO分离能有效利用多核CPU。默认NioEventLoopGroup()创建的线程数是CPU核心数的两倍一般不用担心配置。ChannelPipeline流水线Netty把数据处理做成了流水线每个addLast添加的Handler相当于流水线上的一个工位。数据从一头进来经过Decoder解码、业务Handler处理、Encoder编码最后写出。想加日志、加鉴权、加限流往Pipeline里插一段就行这就是责任链模式的好处。SimpleChannelInboundHandler它自动帮你释放被处理过的ByteBuf避免内存泄漏。如果用单独的ChannelInboundHandlerAdapter你需要手动ReferenceCountUtil.release(msg)忘了释放就是OOM。实际项目中Handler通常拆成多个LoggingHandler打日志、IdleStateHandler做心跳检测、自定义Decoder解析字节流、业务Handler处理反序列化后的对象。这样每条Handler只负责一件事代码清晰也方便复用。4.3 Netty的线程模型EventLoop机制如果面试只问你一个Netty问题大概率就是EventLoop。EventLoop本质上是一个继承了Executor的线程负责处理注册到它上面的所有Channel的IO事件。一个EventLoop可以服务多个Channel但一个Channel只会注册到一个EventLoop上这就保证了一个Channel的所有事件都是同一个线程执行的不用加锁。这个设计很妙多线程并发处理不同Channel单线程串行处理同一个Channel。你的业务Handler不用担心数据竞争这是Netty并发模型的核心优势。实际开发中还有一类必须记住的坑不要在EventLoop线程里执行耗时操作。假设你在channelRead0里直接去查数据库查询耗时3秒那这个EventLoop上其他所有Channel都会被堵住雪崩就是这么发生的。解法是把耗时操作丢到业务线程池里再把结果通过ctx.writeAndFlush写回或者用Netty自带的事件执行器机制。5. 高频进阶话题粘包拆包、序列化与心跳5.1 粘包拆包概念与解决方案“粘包拆包”是网络编程里最常被问到的实际问题也是新手最容易答崩的题。TCP是字节流协议它不关心应用层的消息边界。发送方连续几次write的数据接收方可能一次性全部读到这就叫粘包。反过来一次write的数据分多次到达接收方一次只读到半截这就叫拆包半包。根本原因在于TCP底层会把小块数据合并成大块发送Nagle算法等也受接收缓冲区大小、网络延迟影响。解决粘包拆包的标准做法在应用层定义消息协议常见有四种固定长度每个消息都用固定长度的字节数不够就补位。优点是编解码简单缺点是对短消息浪费空间应用范围有限。分隔符消息末尾加特殊字符比如\n或自定义的\r\n。Netty提供了LineBasedFrameDecoder和DelimiterBasedFrameDecoder。消息头包含长度每个消息由“长度字段 消息体”组成接收方先读长度再按长度截取完整消息。这是最主流的方案Netty的LengthFieldBasedFrameDecoder干的就是这件事。自定义协议项目复杂后会在长度字段前后再增加魔数、版本号、消息类型等字段便于框架扩展和校验。很多RPC框架的协议都长这样。实操时直接使用Netty的LengthFieldBasedFrameDecoder最省心但你要搞清楚四个参数的含义maxFrameLength、lengthFieldOffset、lengthFieldLength、lengthAdjustment。我之前见过有同学把这几个参数配错一直收到半包数据又不知道问题出在哪。这几个参数的本质是告诉Netty“从哪个字节起读长度、长度占几个字节、算完长度后从哪里截取完整消息”建议你写个单元测试把完整包、半包、粘包三种情况都传进去跑一遍彻底理解。5.2 序列化方式怎么选JVM原生、JSON、Protobuf把对象转成字节流传输反序列化再还原对象这步叫序列化。选择序列化方案主要看几个维度跨语言支持、性能、体积、使用简便程度。JDK原生序列化只需实现Serializable接口零额外依赖。但它序列化后的字节流体积大、性能差还只能在Java之间用生产项目基本不用它做跨服务传输。JSON序列化通用性强、可读性好、跨语言友好Jackson、Gson、Fastjson都有广泛使用者。性能中规中矩体积比二进制大一些。适合HTTP接口、配置类数据也适合对性能要求没那么极端的内部RPC。ProtobufProtocol BuffersGoogle开源的二进制序列化协议体积最小、性能最高定义了结构化的.proto文件后自动生成Java类。代价是需要引入额外的编译插件学习成本高且语言无关的.proto描述文件维护要规范。在超高性能场景比如游戏服务端、大数据传输中它是主流选择。选型建议很简单公司内部项目如果性能敏感优先Protobuf如果追求通用方便JSON完全够用JDK序列化能不用就不用。5.3 心跳机制与断线重连长连接场景下非常常见的问题客户端和服务端之间的连接可能被防火墙强行切断、对端机器可能突然断电而这些情况TCP层自己无法在第一时间感知。为了保活和及时发现异常我们需要心跳机制。Netty里最省事的做法是用IdleStateHandler。服务端可以这么配ch.pipeline().addLast(new IdleStateHandler(10, 0, 0, TimeUnit.SECONDS));这个Handler会周期检查这个Channel是否闲置比如10秒内客户端没有发数据过来就触发IdleStateEvent事件。你在后面的Handler里重写userEventTriggered方法对IdleStateEvent做处理——通常是把该连接关闭。客户端则可以在固定间隔发送心跳包利用IdleStateHandler的“写空闲”机制来触发ch.pipeline().addLast(new IdleStateHandler(0, 5, 0, TimeUnit.SECONDS));客户端5秒没往外写数据就触发事件这时你发送一个心跳报文给服务端。服务端收到心跳报文自然知道这个连接还活着就会重置闲置计时。断线重连更好理解客户端启动时连不上或者连接被关闭就自动延时重试常见做法是在channelInactive里触发重连逻辑加上指数退避策略第一次等1秒、第二次等2秒、第三次等4秒避免连接风暴。6. 面试高频考点与实战排查技巧6.1 面试必问的几类问题用一个表格总结网络编程面试中最高频的考点方便你复习对照问题方向核心必答点加分项三次握手与四次挥手状态变化、为什么不能减少握手次数TIME_WAIT状态的意义和影响BIO/NIO/AIO区别阻塞与非阻塞、同步与异步的本质区别结合Linux epoll讲NIO实现select/poll/epoll对比事件驱动、O(n)与O(1)差异为什么epoll能支撑百万连接TCP粘包如何解决四种解决方式的原理自己在项目中怎么设计协议Netty线程模型EventLoop、boss/worker分工ChannelIdle状态如何感知零拷贝sendfile和mmap的区别Netty FileRegion的用法很多候选人把三次握手四次挥手背得滚瓜烂熟但一到追问“为什么挥手要四次”就卡壳。原因很简单TCP是全双工的每个方向的关闭都需要一对FIN和ACK所以至少四次。你理解了全双工这个概念比死背流程有用一百倍。6.2 实际开发中的排查思路项目上线后网络问题往往最难排查。我给你分享几个自己踩过的坑和排查思路。连接数上不去CPU却不高先怀疑文件描述符限制ulimit -n看看再怀疑服务端接收队列溢出调整ServerSocket的backlog或Netty的SO_BACKLOG参数。数据传输慢时延忽高忽低重点检查是否启用了Nagle算法TCP_NODELAY是否为false如果启用了且你的应用是小包频繁发送那就会累积延迟到确认才发下一个包。Netty里设置ChannelOption.TCP_NODELAY, true就能解决。客户端连接频繁断开排查网络连接是否处于半开状态检查心跳配置是否合理再就是检查服务端空闲超时处理逻辑是不是把正常连接误杀了。放开日志确认断开时收到的最后一个事件类型比瞎猜快得多。收到的数据总是少一段或多一段多半是粘包拆包没解决先确认解码器的长度字段设计是否正确再用单元测试模拟“一次写入两个包”“一次写半个包”来定位。线上排查基本手段就三样看日志、抓包、压测。抓包工具首选Wireshark或tcpdump重点看TCP握手过程是否异常、是否出现大量重传、RST包从哪来的。压测工具像wrk、JMeter把应用跑在满负荷下观察线程数、内存、GC表现很多问题只在压力下现出原形。6.3 动手做一个小项目网络编程最好的验证方式光看不练等于没学。我建议学完基础后自己做一个完整的小项目把知识串起来。这里有三个难度递进的选题简易聊天室入门级用Netty实现客户端和服务端的消息收发支持多人在线广播消息包含心跳检测。做完你就掌握了Handler流水线、消息编解码基础。文件传输服务进阶级服务端支持上传下载文件分块传输断点续传展示传输进度。这个项目练的是零拷贝、数据包拆分重组、内存控制。简易RPC框架高手级模仿Dubbo做一个基于Netty的远程调用框架自定义协议、序列化、负载均衡、服务注册发现都能涉及拉通之后你对整个RPC生态的理解会上一个台阶。每个项目控制在2到3周业余时间重点是做完不要追求完美。我在做第一个聊天室的时候也犯过低级错误忘记加StringDecoder导致收到一堆二进制乱码在Handler里做了耗时操作导致所有消息延迟飙升。这些错误踩过一遍比看十篇博客都有效。7. 从入门到精通的学习路线建议网上的Java学习路线图五花八门但网络编程这条线其实非常清晰按阶段推进就行第一阶段零到入门掌握Java基础之后花一周把Socket、TCP/UDP、HTTP搞明白跑通BIO和NIO的Demo重点理解阻塞、非阻塞、同步、异步这四个概念的区别。这个阶段别碰Netty先把底层模型的坑踩明白。第二阶段框架实战学Netty核心API掌握ServerBootstrap、EventLoopGroup、ChannelPipeline、Handler机制做个聊天室小项目。这个阶段要能画出Netty的线程模型图。第三阶段底层源码读Netty源码中NioEventLoop的run循环、ChannelPipeline的传播机制、ByteBuf的内存分配与引用计数。这个阶段难但有价值读完后你再看其他中间件源码会有一种“都是老朋友”的感觉。第四阶段综合应用把网络编程和实际系统结合起来比如手写一个RPC、做IM系统、做API网关。这个阶段是真正拉开开发者差距的地方拼的已经不只是API熟练度而是架构设计能力。我在带新人时发现一个规律学习速度最快的不是基础最好的而是动手最快、遇到问题愿意刨根问底的人。网络编程尤其如此它是一门实践学科很多坑自己踩一遍比听别人说十遍都管用。比如半包问题你看文章理解得再好不如写个测试用例亲眼看到LineBasedFrameDecoder正确拆包的过程来得深刻。最后说一点个人的体会Java网络编程的精髓不在于记住多少API而在于建立“数据如何在网络中流动”的直觉。一旦建立起这种直觉你看到任何网络框架底层都会觉得顺理成章甚至能自己预判它可能在哪些场景下出问题。我这几年从BIO踩坑踩到Netty源码最大的心得就是多写、多测、多复盘把每一层抽象都撕开看一眼你才算真的吃透了它。
返回列表