简介面向Java毕业设计选题“JAVA网络通信系统的研究与开发”这份资源以rar压缩包形式提供完整覆盖论文、源代码与开题报告。内容从网络通信在信息社会中的作用切入梳理了课题的研究意义、国内外研究现状与发展趋势并结合QQ、ICQ等主流聊天工具对比了点对点通信与基于Socket的集中式聊天系统的实现差异。读者可借助其中Java源码理解Socket编程、客户端/服务器交互等关键知识点快速搭建一个简单的网络聊天系统原型。压缩包整体约540KB主要文件类型为Word论文文档、Java源码文件和开题报告结构清晰便于分类阅读与二次修改。目前已有73人学习下载适合正在准备Java方向毕业设计或需要完整课题参考的高校学生。通过这份资料既能获得从开题、设计到论文撰写的全流程文档支撑也能拿到可调试的Java通信代码对完成网络通信类课程设计或毕业设计十分有帮助。1. 为什么毕业设计都喜欢选 Java 网络通信系统以及你该怎么看待它网络通信系统这个题在 Java 毕业设计里的出场率一直很高因为它既不像纯 CRUD 管理系统那样一眼望到底也不像编译器、操作系统那样容易让大部分人卡在数学和底层细节上。它有一个非常标准的落地路径从 Socket 编程开始理解数据怎么在两端之间流动再一步步引入并发模型、协议设计、序列化最后做出一个带客户端和服务端的整体项目。这个路径恰好覆盖了学校考察的「研究」和「开发」两个环节研究的是网络编程模型和 Java 并发工具开发的是可运行的通信系统源代码。但同时很多人在这个题目上翻车不是因为网络太复杂而是因为把大量时间花在了背八股文上——NIO 和 BIO 的区别、select 和 epoll 的区别背得滚瓜烂熟结果真要动手写一个能扛住几百个连接的通信系统时连 TCP 粘包怎么处理都不知道。本文不会带你回顾那些面试填空题而是给出一条我自己做这类系统时实际走的路线先用最朴素的 Socket 把链路跑通再引入线程池解决并发接着用定长字节或者长度字段解决粘包问题最后再说说为什么 Netty 值得放进你的系统里——但不是一个劲儿地崇拜框架。这篇内容适合三类人正在准备 Java 网络方向毕业设计的学生工作中突然要接手一个老通信服务、需要快速理清思路的开发者以及想从「会写接口」跳到「能设计通信协议」的进阶者。下面所有代码都是可以直接复制到本地跑的最小实现但更重要的是我会说明每一步为什么这么做以及最常踩的坑在哪里。2. 网络通信系统的核心模型从阻塞式 BIO 到高并发 NIO 的选型判断2.1 同步、异步、阻塞、非阻塞这四个词到底在说什么Java 网络通信系统里的一切都围绕这四个度展开。同步和异步描述的是调用方在发出一个操作之后是否需要等待这个操作的结果阻塞和非阻塞描述的是线程在等待结果时是否主动让出 CPU。组合起来就是四种模型而 Java 里最常见的落点就两个BIO 是同步阻塞NIO 可以做到同步非阻塞再配上一个 Reactor 线程模型就能用少量线程处理大量连接。一个常见误解是把非阻塞当成异步。以SocketChannel.read()为例非阻塞模式下这个调用会立刻返回返回 0 表示暂时没有数据但这仍然是同步的因为你读到的数据是直接从内核缓冲区拷到用户缓冲区里的。真正异步的是AIO那种使用回调或Future的方式数据就绪后系统主动通知你。在 JDK 1.4 引入 NIO 之后很长一段时间里 AIO 在 Windows 和 Linux 上的实现策略不一致性能优势也不明显所以生产环境里大家更愿意用 NIO Reactor而不是直接依赖 AIO。2.2 为什么绝大多数系统不需要一上来就选 Netty先看一张极简的选型对比表这张表决定了你做毕业设计时的技术路线模型线程模型并发连接上限编码复杂度典型场景传统 SocketBIO每个连接一个线程数百级线程多了上下文切换严重低内网小工具、简单文件传输原生 NIO多路复用一个线程或少量线程管理多条连接数万级高协议处理容易出错网关、长连接服务Netty主从 Reactor 多线程数十万级取决于内存和句柄中API 封装度高通信系统、IM、RPC 框架底层AIOJDK NIO.2回调/异步与 NIO 接近中高对异步实现有特殊要求的项目如果毕业设计的题目只是「研究与开发」没有要求你必须扛住多大并发我会建议你把主力放在原生 Socket 线程池上把这个吃透然后花三四天时间用 Netty 重写一遍同一个通信协议对比两种实现之间的差距。这样你的「研究」部分有真实素材不是抄书「开发」部分又拿得出能跑的源代码。一上来就 Netty 的问题在于框架帮你隐藏了太多东西一旦某个连接异常断开你很难说出底层发生了什么。2.3 动手跑一个最小化的 NIO 非阻塞 TCP 服务端下面这段代码展示了 NIO 最核心的注册事件、轮询事件、读写三个阶段。它不完整但能让你清晰看到非阻塞网络通信系统的骨架import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; public class NioTcpServer { public static void main(String[] args) throws Exception { // 1. 打开服务端通道绑定端口并设置为非阻塞 ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); // 2. 打开 Selector并把服务端通道注册到 Selector 上只关心“接受新连接”事件 Selector selector Selector.open(); serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println(NIO Server started on port 8080); while (true) { // 3. 阻塞等待事件发生每次最多阻塞 1 秒 selector.select(1000); IteratorSelectionKey keyIterator selector.selectedKeys().iterator(); while (keyIterator.hasNext()) { SelectionKey key keyIterator.next(); keyIterator.remove(); // 必须手动移除否则会重复处理 if (key.isAcceptable()) { // 4. 接受新连接并把新连接也注册到同一个 Selector 上关心“可读”事件 SocketChannel client serverChannel.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); System.out.println(New connection from client.getRemoteAddress()); } else if (key.isReadable()) { // 5. 读取客户端数据非阻塞模式下 read() 可能只读到部分数据 SocketChannel client (SocketChannel) key.channel(); ByteBuffer buffer ByteBuffer.allocate(1024); int bytesRead client.read(buffer); if (bytesRead -1) { client.close(); continue; } String message new String(buffer.array(), 0, bytesRead, UTF-8); System.out.println(Received: message); // 6. 回写数据。注意这里没有处理写一半的情况完整版需要注册 OP_WRITE client.write(ByteBuffer.wrap((echo: message).getBytes(UTF-8))); } } } } }这段代码有四个地方需要重点说明。第一步configureBlocking(false)是入口开关不设置它后面的register会直接抛 IllegalBlockingModeException。第二步OP_ACCEPT和第四步的OP_READ是两个不同层级的事件服务端关注建立连接客户端连接关注数据到达。第三步select(1000)传入毫秒表示超时如果不传就会一直阻塞这在某些场景下会导致线程无法被中断。第五步读取数据时ByteBuffer固定分配了 1024 字节这意味着一次read很可能读到的不是完整一条消息这正是后面要解决的 TCP 粘包问题。最后说一个很多人忽略的细节第六步里直接调用channel.write()是危险的。非阻塞模式下如果因为发送缓冲区满而只写出去一半剩下的数据就丢了。正确做法是进入非阻塞写的循环或者把剩余字节注册到OP_WRITE事件里在write可写时继续发送。完整代码里一定会出现一个挂起的写队列这是原生 NIO 写起来最烦的地方也是 Netty 帮你简化掉的典型逻辑。3. 基于 Socket 实现一个可复现的通信系统协议设计、线程池与拆包粘包3.1 通信协议不是只在文档里定义的东西它要写进代码里「系统研究与开发」里最容易糊弄过去的就是协议设计。很多人的通信系统写出来就是一行readLine()认为只要收发字符串就叫网络通信。实际工作中协议至少要回答四个问题消息从哪里开始、到哪里结束、数据是什么格式、对端如何处理半包和粘包。最简单的做法是用定长字节比如规定每个消息固定 100 字节读满 100 字节就算一条完整消息。这种方式实现简单但体操难做——实际消息不一定正好那么长短了要补位长了要截断。更通用的是「长度字段 消息体」即把int类型的数据长度放在消息最前面接收方先读 4 个字节得到长度再按长度读取消息体。用 Java 8/11 的流式读取这个过程可以写得非常直观。下面的服务端代码同时承担了协议解码和业务处理两个角色但学的时候脑子里要分清这两件事import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.*; public class TcpServerWithThreadPool { private static final int PORT 9000; // 线程池参数核心线程数、最大线程数、空闲存活时间、工作队列大小 private static final ThreadPoolExecutor POOL new ThreadPoolExecutor( 4, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadPoolExecutor.CallerRunsPolicy() ); public static void main(String[] args) throws IOException { ServerSocket server new ServerSocket(PORT); System.out.println([Server] listening on PORT); while (true) { Socket socket server.accept(); // 注意BIO 下 accept() 是阻塞的但拿到 socket 后不阻塞主线程 POOL.submit(() - handleClient(socket)); } } private static void handleClient(Socket socket) { try (DataInputStream in new DataInputStream(socket.getInputStream()); DataOutputStream out new DataOutputStream(socket.getOutputStream())) { while (true) { // 1. 先读 4 字节长度这就是协议里的“长度字段” int length in.readInt(); if (length 0 || length 8192) { break; // 协议保护非法长度直接断开 } // 2. 按长度读取完整消息体 byte[] body new byte[length]; in.readFully(body); String message new String(body, UTF-8); System.out.println([Server] receive: message); // 3. 回复客户端回复内容也遵循同一个协议 byte[] response ok.getBytes(UTF-8); out.writeInt(response.length); out.write(response); out.flush(); } } catch (EOFException e) { System.out.println([Server] client closed connection); } catch (IOException e) { System.err.println([Server] error: e.getMessage()); } finally { // 无论何种原因退出都关闭连接 try { socket.close(); } catch (IOException ignored) {} } } }3.2 线程池参数怎么设为什么 CallerRunsPolicy 更安全上面代码里线程池是整段的关键。corePoolSize4表示即使没有任务也至少维持 4 个空闲线程用于接受连接后的处理maximumPoolSize16是峰值情况下最多启动的线程数。这里有一个非常容易踩的坑JVM 默认的线程池Executors.newCachedThreadPool的最大线程数是Integer.MAX_VALUE在通信系统里一旦连接短时间暴涨线程数会跟着疯涨最终 OOM。自定义 ThreadPoolExecutor 才是生产环境该有的做法。工作队列选了ArrayBlockingQueue(1000)是因为在这种通信场景下队列的作用是「缓冲来不及处理的连接」而不是「无限堆积」。如果队列无界极端情况下内存会被积压的任务撑爆。CallerRunsPolicy的作用是当线程池和队列都满时新任务不会丢弃而是让提交任务的调用线程——也就是当前正在accept()的主线程——自己来执行这个任务。这个策略在通信系统里很实用它把压力传导回入口主线程暂时不能accept新连接等于天然做了一层层背压。接收数据使用的是DataInputStream。readFully这个方法必须重点讲它不是读一次就行而是会循环读取直到填满body数组数据不够时线程就阻塞在那里。这正好解决了半包问题——底层 TCP 可能分两次把一条消息传过来但readFully保证凑齐一个完整消息才返回。而长度字段本身解决了「一条消息到底读到哪结束」的问题。这两者合起来就是拆包粘包在工程上的标准解法。3.3 客户端的写法与连接复用、超时设置服务端有了客户端就是镜像地把协议写一遍但要注意三点差异import java.io.*; import java.net.Socket; public class TcpClient { public static void main(String[] args) throws Exception { // 连接超时设为 3 秒而不是用默认的无限等待 Socket socket new Socket(); socket.connect(new java.net.InetSocketAddress(127.0.0.1, 9000), 3000); socket.setSoTimeout(5000); // 读超时 5 秒防止对端不回复导致线程卡死 socket.setTcpNoDelay(true); // 关闭 Nagle 算法低时延场景建议开启 DataOutputStream out new DataOutputStream(socket.getOutputStream()); DataInputStream in new DataInputStream(socket.getInputStream()); for (int i 0; i 10; i) { String msg message- i; byte[] sent msg.getBytes(UTF-8); out.writeInt(sent.length); out.write(sent); out.flush(); int len in.readInt(); byte[] resp new byte[len]; in.readFully(resp); System.out.println(Response: new String(resp, UTF-8)); Thread.sleep(500); } socket.close(); } }第一点是setSoTimeout。这个值控制的是输入流读取时最多阻塞多久超过就抛SocketTimeoutException。不加它如果服务端出了 bug 一直不回复客户端线程会永远挂住。第二点是setTcpNoDelay。Nagle 算法会把小包合并成大包发送这在批量传输时是好事但在像请求-响应这样的短会话里会引入额外的 40-200ms 延迟做交互式通信系统时建议关掉。第三点是连接复用在这个例子里一个客户端连接连续发了 10 条消息每条消息独立成帧但底层复用同一条 TCP 连接。现实中很多新手做通信系统喜欢每条消息都new Socket这样既慢又会把系统复杂度全部推到连接管理上。当你把服务端和客户端各自跑起来可以先用netstat -an | grep 9000查看连接状态服务端那侧会看到一条ESTABLISHED连接断开后变成TIME_WAIT。这就是一个最小可复现的 Java 网络通信系统了——它已经包含了协议、并发、编解码三个核心组件。4. 用 Netty 重新实现同一套协议理解 Reactor 模型的价值与代价4.1 Netty 到底比原生 NIO 强在哪强在什么时候很多人把 Netty 当成一个「高性能框架」来背特点实际它的优势可以归结为三条一是把 NIO 里容易写错的地方全部封装好了特别是非阻塞写入和缓冲区的生命周期管理二是提供了流水线式的编码解码器把协议处理拆成一个个独立的ChannelHandler改一处不影响其他逻辑三是内置了线程池与事件循环让你不需要自己维护 Selector 和线程的对应关系。但代价是如果你连 BIO 和 NIO 的程序都没写过直接看 Netty 的ChannelPipeline和ByteBuf概念很容易被绕晕。我的建议是做完第 3 章的原生 Socket 版本之后把那个协议的编解码逻辑原封不动地搬到 Netty 里对比一下代码量的变化和排错感受。下面这段代码就是一个可以直接运行的 echo 服务器用到了 Netty 的LengthFieldBasedFrameDecoder来处理长度字段协议import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.*; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.LengthFieldBasedFrameDecoder; import io.netty.handler.codec.LengthFieldPrepender; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import java.nio.charset.StandardCharsets; public class NettyServer { public static void main(String[] args) throws Exception { // bossGroup 负责接受新连接workerGroup 负责处理已有连接的读写 EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap b new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ChannelPipeline p ch.pipeline(); // 解码器前 4 字节是消息长度最大值 8192 p.addLast(new LengthFieldBasedFrameDecoder(8192, 0, 4, 0, 4)); // 编码器写出的每条消息前自动加上 4 字节长度 p.addLast(new LengthFieldPrepender(4)); p.addLast(new StringDecoder(StandardCharsets.UTF_8)); p.addLast(new StringEncoder(StandardCharsets.UTF_8)); p.addLast(new SimpleChannelInboundHandlerString() { Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { System.out.println(receive: msg); ctx.writeAndFlush(ok: msg); } }); } }) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.TCP_NODELAY, true); ChannelFuture f b.bind(9001).sync(); System.out.println(Netty server started on 9001); f.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }对比原生版本最直观的变化是LengthFieldBasedFrameDecoder一行配置就解决了粘包拆包。要注意它的四个参数8192 是单条消息的最大帧长度超过会抛出异常第二个 0 是长度字段在字节流中的偏移量表示消息一开始就是长度字段第三个 4 是长度字段占据的字节数第四个 0 是长度调整值因为我们的消息体不包含长度字段本身所以是 0最后一个 4 是解码后需要跳过的字节数即去掉前 4 个长度字节。如果长度调整值设置错比如有些协议长度字段包含了自己那 4 字节这里就要写成 -4 或 4需要根据实际协议去算。NioEventLoopGroup(1)和NioEventLoopGroup()对应主从 Reactor 的两个线程组boss 组只做 accept1 个线程足够因为 accept 本身就是个小操作worker 组默认线程数是 CPU 核数的两倍负责连接上的全部 IO 和业务。这个配置在绝大多数场景下都不需要动。如果你看到网上有些人把 bossGroup 调成很大的数值那是对这个模型理解有偏差。4.2 为什么说 Netty 里的 Handler 执行顺序值得用 debug 去验证上面的 pipeline 顺序是LengthFieldBasedFrameDecoder→LengthFieldPrepender→StringDecoder→ 自定义 handler。入站消息先进LengthFieldBasedFrameDecoder解码成完整帧后再交给StringDecoder转成字符串出站消息反着走先编码字符串再自动加长度头。这个顺序一旦写错程序不会直接报错但会得到一堆乱码或者莫名其妙的 decode 异常。我自己在调试这类 Netty 通信系统时最常用的手段不是在 handler 里打日志而是把NettyLoggingHandler加在最前面它会把每次读写的内容以 hex 形式打印出来。比如发送一个英文hello你会看到[id: 0x...] READ: 4B 68 65 6C 6C 6F前面 4 个字节正好是 5hello 的长度后面是 5 个字节的 ASCII 码。如果没有这一层你很难判断是协议编码问题还是数据被截断了。另外要注意一个反直觉点SimpleChannelInboundHandler中的channelRead0只对入站消息生效它的名字带Simple但并不会自动释放ByteBuf以外的资源。当你从StringDecoder拿到一个字符串时这个字符串已经是普通对象不需要你释放但如果你直接处理ByteBuf就必须手动release()。ReferenceCountUtil.release(msg)是常见动作Netty 4.x 里不释放的话内存会缓慢上涨最终 OOM。这是初学者排查一个通讯程序跑几个小时就内存飘高时最先要查的地方。4.3 心跳机制一个通信系统从「能通」到「可靠」的分水岭TCP 连接在拔掉网线、对端断电、进程崩溃时不会立刻通知对端。你要靠自己发现一个连接是否已经死了。最常见做法是每隔一段时间发送一个心跳包对端收到后回复超时没回复就判定连接失效。在 Netty 里内置了IdleStateHandler用法是在 pipeline 里加一行p.addLast(new IdleStateHandler(5, 10, 0, TimeUnit.SECONDS));这三个参数依次是5 秒内没有读事件就触发读空闲事件10 秒内没有写事件触发写空闲事件第三个 0 是「不关心」对端是否发送读写我们这里不需要设置。然后在自定义 handler 里重写userEventTriggered方法Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { // 读空闲 5 秒说明对端没有发心跳关闭该连接 ctx.close(); } else if (event.state() IdleState.WRITER_IDLE) { // 写空闲 10 秒主动发一个只有 1 字节的 ping 包 ctx.writeAndFlush(ping); } } else { super.userEventTriggered(ctx, evt); } }这里的设计很考究的是时间梯度写空闲心跳发送周期应该比读空闲的超时时间短一半左右。比如你每 10 秒发一次心跳对端 5 秒没读到你任何数据就关掉那是合理的反过来如果你 5 秒发一次却允许对端 10 秒不回复——那这个超时就没有保护意义了。生产里我会把读空闲设成 60 秒写空闲设成 20 秒间隔四倍以上防止因为 GC 卡顿或网络抖动导致误杀正常连接。5. 排错与验证从只能本地跑通到敢说自己完成了一个网络通信系统5.1 三个最值得提前演练的异常场景通信系统里最怕的不是代码报错而是程序不报错但是逻辑不对。下面三个场景建议在你自己的系统上一一故意制造出来然后观察行为是否符合预期。这比背任何八股文都有用。第一个是客户端发送半条消息后关闭连接。比如发送方只写了长度字段00000005然后不写 body 就 close。服务端的readFully会抛EOFException你的 catch 要能捕获并正确关闭连接。很多初版代码在这里会直接打印EOFException后继续循环结果造成连接泄露。第二个是服务端线程池满了。设置 1000 个队列 16 最大线程然后并发开启 2000 个连接只连不发数据你会看到accept依然在接受连接但提交到线程池的任务开始排队如果继续加压最终触发CallerRunsPolicy主线程卡在处理上。这个现象本身是设计预期的但如果你没有看到它说明你根本没有压到边界代码里隐藏的问题会一直埋在深处。第三个是粘包混发客户端一次发送两条消息的字节即len1 body1 len2 body2全部写进一次write服务端应该能准确拆出两条消息并分别处理。每次演练都去看服务端的日志直到你能从日志分辨出「半包」「粘包」「连接已关闭」对应的是哪一行输出。5.2 用 tcpdump 和 netstat 验证数据是否真的按协议在传输代码写完了验证环节不能只靠自己的程序打印。我会在服务器上跑一个tcpdump来抓实际网络包。注意这个命令需要在 Linux 或 macOS 上执行Windows 可以用 Wireshark 代替sudo tcpdump -i lo port 9000 -XX-i lo表示抓回环接口因为客户端和服务端都在本机port 9000过滤端口-XX同时打印 hex 和 ASCII 数据。如果发送的是0000000568656C6C6F在抓包里应该能看到完整的这 9 个字节。如果只看到00000005和68656C6C分在两个 TCP segment 里那也是在合法范围内的——TCP 是字节流协议它不保证一次 write 对应一次 readreadFully和 Netty 的 decoder 就是用来在应用层重新划分边界的。看到这种分包反而说明你的协议正确处理了半包。再配合netstat -s查看协议层的统计比如重传次数和丢包数如果数值异常增长说明网络环境不稳定或者代码里有大量小包导致 Nagle 和延迟 ACK 互相作用。这时你可以用sysctl net.ipv4.tcp_tw_reuse查看状态但我主张不要为了消除TIME_WAIT去盲目改内核参数理解这些状态的含义比临时调大端口范围更重要。5.3 压测前的两个必要检查句柄数和 JVM 内存参数压测是验证通信系统最直接的方式但压测前先做两个检查。第一是用ulimit -n查看文件描述符上限默认可能是 1024这意味着你的系统最多只能同时维持 1024 个 TCP 连接压到一千多就报Too many open files。临时调成 65535ulimit -n 65535如果要在生产环境永久生效需要改/etc/security/limits.conf这块需要 root 权限毕设环境里临时设置就够了。第二个检查是 JVM 堆内存。Netty 的堆外内存由-XX:MaxDirectMemorySize控制默认等于堆上限。如果你的系统要处理大量数据建议显式设置java -Xms512m -Xmx1g -XX:MaxDirectMemorySize1g -jar your-server.jar-Xms和-Xmx分别是最小和最大堆MaxDirectMemorySize限制直接内存的总量。Netty 的ByteBuf有两种分配方式堆内和堆外堆外直接内存减少了内核缓冲区到 JVM 堆的拷贝但用完后必须归还 Netty 的池否则系统内存会持续增长。你在压测观察top里的RES时应该能看到运行一段时间后保持稳定而不是一路飙高——如果一直涨先检查是不是有 handler 忘了释放ByteBuf。5.4 一个绕不开的验证技巧写一个自动回包的压力测试客户端手写压测客户端最大的好处是你能完全控制发包速度和内容。下面是一个简化版的压测代码用ExecutorService并发模拟多个客户端每个客户端连续发送 1000 条消息并验证响应是否到达import java.io.*; import java.net.Socket; import java.util.concurrent.*; public class StressClient { public static void main(String[] args) throws Exception { int clients 200; int messagesPerClient 1000; ExecutorService pool Executors.newFixedThreadPool(clients); long start System.currentTimeMillis(); for (int i 0; i clients; i) { final int clientNum i; pool.submit(() - { try (Socket socket new Socket(127.0.0.1, 9000)) { DataOutputStream out new DataOutputStream(socket.getOutputStream()); DataInputStream in new DataInputStream(socket.getInputStream()); for (int j 0; j messagesPerClient; j) { String msg client- clientNum - j; out.writeInt(msg.getBytes(UTF-8).length); out.write(msg.getBytes(UTF-8)); out.flush(); int len in.readInt(); byte[] resp new byte[len]; in.readFully(resp); // 简单校验响应内容里必须包含“ok” if (!new String(resp, UTF-8).startsWith(ok)) { System.err.println(bad resp); } } } catch (IOException e) { System.err.println(err: e.getMessage()); } }); } pool.shutdown(); pool.awaitTermination(60, TimeUnit.SECONDS); long cost System.currentTimeMillis() - start; System.out.println(Done in cost ms); } }跑这个压测时服务端的 CPU 占用会随着并发连接数上升但如果你在服务端加入了CallerRunsPolicy并且连接数超过极限你会在压测日志里看到部分连接直接失败。这时候要回头调线程池参数——把maximumPoolSize调大可能有用但更本质的是看每条消息的平均处理耗时。如果处理耗时超过 1ms200 个客户端 × 1000 条消息需要 200 秒才能跑完那就说明瓶颈不在连接数而在业务逻辑里比如频繁打印日志、字符串序列化、锁竞争。用一个AtomicLong统计消息总数在结束时打印算 QPS比猜测要有意义得多。5.5 从「能跑」到「能展示」给系统加一个可观测的状态输出毕业设计答辩或者团队交接时一个只有黑窗口日志的系统很难说明白。我推荐在你的系统里加一个轻量级的状态接口不需要引入 Spring Boot可以直接用HttpServer在另一个端口暴露统计数据import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; import java.util.concurrent.atomic.AtomicLong; public class StatusServer { public static AtomicLong totalMessages new AtomicLong(0); public static AtomicLong activeConnections new AtomicLong(0); public static void start(int port) throws Exception { HttpServer server HttpServer.create(new InetSocketAddress(port), 0); server.createContext(/status, exchange - { String json String.format({\totalMessages\:%d,\activeConnections\:%d,\heapMb\:%d}, totalMessages.get(), activeConnections.get(), Runtime.getRuntime().totalMemory() / 1024 / 1024); byte[] bytes json.getBytes(UTF-8); exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); }); server.start(); } }然后在你的 handler 里每次收到消息调用StatusServer.totalMessages.incrementAndGet()连接建立和关闭时分别incrementAndGet和decrementAndGet。浏览器访问http://127.0.0.1:9100/status就能实时看到吞吐和在线数。这个设计不仅是为了展示它本身就是一个简单的运维观测点——当系统异常时activeConnections会异常升高或者归零totalMessages增速变慢。把观测做进系统里才算是把「研究与开发」落到了实践层面。本文还有配套的精品资源点击获取
