ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java TCP网络通信聊天室:从三次握手到多人在线,一套能跑通的课程设计

Java TCP网络通信聊天室:从三次握手到多人在线,一套能跑通的课程设计 简介这是一套面向Java网络编程初学者与课程设计学习者的TCP聊天室完整项目资料围绕客户端与服务器端实时通信场景帮助读者理解面向连接、可靠传输的TCP协议原理及多线程并发处理思路。压缩包共15个文件约7.19MB包含java源码、class编译文件、properties配置、classpath与project工程文件以及mp4演示视频和doc报告论文覆盖从代码实现到运行演示再到理论分析的完整链路。源码中服务器端以Server类启动监听、ConnectionHandler处理多客户端连接客户端通过Socket与输入输出流收发消息报告论文约6000字深入讨论TCP三次握手、序列号确认、流量与拥塞控制、异常处理等关键点。目前已有1186人学习适合需要动手实践网络通信与并发编程、完成聊天系统课程设计或毕业设计的读者参考。1. Java TCP 网络通信聊天室从三次握手到多人在线一套能跑通的课程设计很多人做 Java 课程设计时第一反应是去搜「java 课程设计案例源码」结果下回来一堆跑不起来的压缩包。这个标题里的「Java TCP 网络通信聊天室」本质上是让你用 Socket 编程实现一个多客户端实时消息转发的系统配套源码、演示视频和 6000 字报告论文。它解决的核心问题是如何让多个客户端通过 TCP 连接同时连到服务端并且一条消息能被所有在线用户看到。适合正在做 Java 课程设计、想搞懂 TCP 连接和网络通信底层逻辑的在校生也适合工作后想补网络编程基础的开发者。TCP 三次握手不是背八股而是你调试连接失败时真正要盯的东西。2. 聊天室的 TCP 骨架Socket、线程模型与消息协议怎么定2.1 为什么选 TCP 而不是 UDP做聊天室第一个决策就是传输层协议选 TCP 还是 UDP。热搜里「tcp和udp的区别」被反复搜但落到聊天室场景结论很明确选 TCP。原因是聊天消息不能丢——用户 A 发的「你好」用户 B 必须收到丢一条就是 bug。UDP 不保证送达、不保证顺序虽然延迟低但聊天室对实时性的要求没到游戏那种毫秒级可靠性优先。TCP 的连接管理靠三次握手建立、四次挥手断开。你在代码里调用new Socket(host, port)时底层就在做三次握手调用socket.close()时底层走四次挥手。理解这一点后面排查「连接被拒绝」「连接超时」就有方向了。服务端的核心结构是一个ServerSocket监听端口主线程阻塞在accept()上等客户端连接每来一个客户端就分配一个独立线程处理它的读写。这就是经典的「一连接一线程」模型。客户端数量少课程设计通常几十个完全够用不需要上 NIO。2.2 服务端最小可运行骨架先看服务端。下面这段代码是聊天室服务端的核心骨架能接受多个客户端连接并把消息广播出去。import java.io.*; import java.net.*; import java.util.concurrent.*; public class ChatServer { // 监听端口课程设计常用 8888 或 9999 private static final int PORT 8888; // 线程安全的在线客户端集合ConcurrentHashMap 防止并发修改异常 private static final MapString, PrintWriter clients new ConcurrentHashMap(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(聊天室服务端已启动监听端口 PORT); // 线程池每个客户端一个线程避免无限创建线程 ExecutorService pool Executors.newCachedThreadPool(); while (true) { // 阻塞等待客户端连接底层完成 TCP 三次握手 Socket socket serverSocket.accept(); System.out.println(新客户端接入 socket.getRemoteSocketAddress()); pool.execute(new ClientHandler(socket)); } } // 每个客户端连接对应一个 Handler static class ClientHandler implements Runnable { private Socket socket; private String userName; private PrintWriter out; ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try { BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); // 第一条消息约定为用户名 out.println(请输入你的昵称); userName in.readLine(); if (userName null || userName.trim().isEmpty()) { socket.close(); return; } clients.put(userName, out); broadcast(【系统】 userName 加入了聊天室, null); String msg; // 循环读取该客户端发来的每条消息 while ((msg in.readLine()) ! null) { if (msg.equalsIgnoreCase(bye)) break; broadcast([ userName ] msg, userName); } } catch (IOException e) { System.out.println(客户端异常断开 e.getMessage()); } finally { // 清理移除客户端并通知其他人 if (userName ! null) { clients.remove(userName); broadcast(【系统】 userName 离开了聊天室, null); } try { socket.close(); } catch (IOException ignored) {} } } } // 广播消息给所有在线客户端except 为 null 时发给所有人 static void broadcast(String message, String except) { for (Map.EntryString, PrintWriter entry : clients.entrySet()) { if (except ! null entry.getKey().equals(except)) continue; entry.getValue().println(message); } } }逻辑说明ServerSocket绑定端口后进入死循环accept()每返回一个Socket就代表一个客户端完成了三次握手。用ConcurrentHashMap存用户名到输出流的映射是因为多个线程会同时读写这个集合普通HashMap会抛ConcurrentModificationException。PrintWriter构造时第二个参数传true开启自动刷新否则消息会卡在缓冲区里发不出去——这是新手最常见的翻车点。参数说明端口选 8888 是因为 1024 以下需要管理员权限8888 在开发机上不会冲突。newCachedThreadPool()适合连接数波动大的场景如果要做压力测试可以换成newFixedThreadPool(50)观察线程耗尽的表现。2.3 客户端连接与消息收发客户端比服务端简单两个线程主线程负责读用户键盘输入并发送另一个线程负责接收服务端广播的消息。import java.io.*; import java.net.*; public class ChatClient { private static final String HOST 127.0.0.1; private static final int PORT 8888; public static void main(String[] args) throws IOException { Socket socket new Socket(HOST, PORT); // 触发 TCP 三次握手 System.out.println(已连接到聊天室服务器); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); BufferedReader keyboard new BufferedReader(new InputStreamReader(System.in)); // 接收线程持续打印服务端推送的消息 new Thread(() - { try { String line; while ((line in.readLine()) ! null) { System.out.println(line); } } catch (IOException e) { System.out.println(与服务端断开连接); } }).start(); // 主线程读键盘输入并发送 String input; while ((input keyboard.readLine()) ! null) { out.println(input); if (input.equalsIgnoreCase(bye)) break; } socket.close(); } }逻辑说明客户端必须开两个线程否则读键盘时会阻塞接收导致看不到别人发的消息。接收线程用 lambda 创建in.readLine()阻塞等待服务端推送一旦返回 null 说明服务端关闭了连接。发送端用println而不是print因为服务端用readLine()按行读取没有换行符会一直阻塞。参数说明HOST在本机测试时用127.0.0.1局域网内多机测试要改成服务端的实际 IP。PORT必须和服务端一致不一致会抛Connection refused。3. 消息协议与并发安全让聊天室不丢消息、不乱序3.1 自定义消息格式的必要性上面用的是纯文本行协议简单但有个隐患如果用户发的消息里包含换行符服务端readLine()会把它拆成两条。课程设计里通常约定「一行一条消息」但更稳妥的做法是加一个简单的消息头。常见做法是定义类型|发送者|内容这样的格式比如MSG|张三|你好、JOIN|李四|、LEAVE|王五|。服务端解析第一个字段决定怎么处理。这样扩展性好后面加私聊、加文件传输都不用改协议结构。// 消息封装类 public class Message { public static final String TYPE_MSG MSG; public static final String TYPE_JOIN JOIN; public static final String TYPE_LEAVE LEAVE; private String type; private String sender; private String content; public Message(String type, String sender, String content) { this.type type; this.sender sender; this.content content; } // 序列化为一行文本用 | 分隔 public String serialize() { return type | sender | content; } // 从一行文本反序列化 public static Message parse(String line) { String[] parts line.split(\\|, 3); if (parts.length 3) return null; return new Message(parts[0], parts[1], parts[2]); } // getter 省略 }逻辑说明split(\\|, 3)的第二个参数限制最多切 3 段这样消息内容里即使有|也不会被切碎。序列化和反序列化必须成对使用服务端和客户端用同一套Message类避免格式理解不一致。参数说明分隔符选|是因为它在聊天内容里出现频率低。如果担心用户输入|可以换成\u0001这种不可见字符但调试时不直观课程设计用|够了。3.2 并发写入的坑与同步策略多个线程同时往同一个PrintWriter写会出问题吗PrintWriter内部方法加了synchronized单次println是原子的。但如果你要发一条消息分多次print中间可能被其他线程插入导致消息交错。血泪经验广播方法里不要先print前缀再print内容要拼成一个字符串一次println。上面broadcast方法就是这么做的。另一个坑是客户端断开时的清理。如果客户端直接关窗口服务端readLine()会抛SocketException必须在finally里移除该客户端否则clients集合里会残留死连接广播时往已关闭的流写数据虽然不报错但浪费资源。// 广播时捕获单个客户端的写入异常避免一个坏连接影响所有人 static void broadcast(String message, String except) { for (Map.EntryString, PrintWriter entry : clients.entrySet()) { if (except ! null entry.getKey().equals(except)) continue; PrintWriter writer entry.getValue(); if (writer.checkError()) { // 该连接已失效移除 clients.remove(entry.getKey()); continue; } writer.println(message); } }逻辑说明checkError()返回 true 表示流上发生过异常此时直接移除该客户端。注意不能在遍历entrySet时直接remove上面的写法在ConcurrentHashMap下是安全的但如果是HashMap会抛异常。参数说明这段代码没有额外参数关键是理解「广播要容错」——一个客户端网络断了不能让整个聊天室崩掉。4. 避坑与排查连接失败、中文乱码、消息丢失怎么定位4.1 连接被拒绝Connection refused现象客户端启动后立刻抛java.net.ConnectException: Connection refused。原因服务端没启动或者端口不对或者服务端绑定的 IP 和客户端连接的不是同一个。常见于客户端写127.0.0.1但服务端跑在另一台机器上。解决先确认服务端进程在跑用netstat -an | grep 8888Linux/Mac或netstat -an | findstr 8888Windows看端口是否处于 LISTENING 状态。局域网测试时客户端HOST要填服务端的真实 IP服务端ServerSocket构造时不指定 IP 默认绑定所有网卡不用改。4.2 中文乱码现象发「你好」收到「ä½ å¥½」。原因InputStreamReader和OutputStreamWriter没指定字符集用了系统默认编码。Windows 默认 GBKLinux 默认 UTF-8跨平台就乱。解决两端都显式指定UTF-8就是上面代码里写的那样。注意PrintWriter的构造链要一路传下去漏一个环节就前功尽弃。4.3 消息发出去但对方收不到现象客户端 A 发了消息服务端日志显示收到了但客户端 B 没打印。原因PrintWriter没开自动刷新消息卡在缓冲区。或者客户端 B 的接收线程没启动主线程在读键盘。解决new PrintWriter(outputStream, true)第二个参数必须是true。接收线程必须在发送之前就start()顺序反了会丢消息。4.4 客户端关闭后服务端报错刷屏现象关掉一个客户端服务端控制台疯狂打印异常堆栈。原因readLine()在连接断开时抛SocketException如果没在run()方法里捕获线程池会打印堆栈。解决run()方法里用try-catch(IOException)包住整个循环捕获后正常走finally清理逻辑不要往外抛。4.5 端口被占用Address already in use现象服务端启动报BindException: Address already in use。原因上次的服务端进程没退干净端口还被占着。Java 的ServerSocket默认不立即释放端口需要等 TIME_WAIT 状态结束。解决换一个端口或者在ServerSocket构造后调用serverSocket.setReuseAddress(true)。开发阶段最省事的办法是换个端口号比如 8889。5. 从能跑到好用私聊、在线列表与压力测试的进阶技巧课程设计只要求群聊的话上面代码已经够了。但报告论文想拿高分通常要加几个功能点。私聊的实现思路是消息格式加一个TO字段服务端解析后只往目标用户的PrintWriter写。在线列表则是服务端收到LIST请求时把clients.keySet()拼成字符串发回去。压力测试是很多人忽略的一环。你可以写一个简单的测试客户端循环创建 100 个连接每个连接每秒发一条消息观察服务端内存和 CPU。用newCachedThreadPool时 100 个连接就是 100 个线程现代机器扛得住但如果连接数上千就要考虑 NIO 或者 Netty 了。课程设计里在报告里写一句「当前模型适用于中小规模并发连接数超过 500 建议改用 NIO」就是加分项。// 简易压力测试创建 N 个客户端连接并持续发送消息 public class StressTest { public static void main(String[] args) throws Exception { int clientCount 100; for (int i 0; i clientCount; i) { final int id i; new Thread(() - { try { Socket s new Socket(127.0.0.1, 8888); PrintWriter out new PrintWriter( new OutputStreamWriter(s.getOutputStream(), UTF-8), true); out.println(testUser id); // 先发用户名 while (true) { out.println(压力测试消息 from id); Thread.sleep(1000); } } catch (Exception e) { System.out.println(客户端 id 异常 e.getMessage()); } }).start(); } } }逻辑说明每个线程模拟一个独立客户端先发用户名完成注册然后每秒发一条消息。跑起来后观察服务端控制台输出是否正常以及任务管理器的内存占用。如果服务端开始卡顿或者抛OutOfMemoryError说明线程模型到了瓶颈。参数说明clientCount从 100 开始试逐步加到 500、1000记录服务端响应时间的变化。Thread.sleep(1000)控制发送频率改成 100 就是每秒 10 条用来测吞吐上限。最后说一个我自己的习惯每次改完服务端代码先开两个客户端互发消息验证基本功能再开压力测试跑 5 分钟看稳定性。这个顺序能帮你快速定位是逻辑 bug 还是并发问题。做网络编程日志比调试器好用在accept、broadcast、finally三个位置各加一行System.out.println大部分问题都能一眼看出来。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进