ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

手写Java网络聊天室:Socket多线程与数据库实战全解析

手写Java网络聊天室:Socket多线程与数据库实战全解析 简介这是一个基于Java技术的迷你聊天室项目适用于需要学习网络编程、并发处理和数据库应用的开发者。项目以Socket通信为底层结合多线程机制处理多用户并发并通过数据库完成账号注册、登录和聊天记录存储覆盖了从客户端到服务端的完整交互链路。压缩包共46个文件约199KB包含20个class字节码、7个java源码、10张jpg图片、2个gif动图、3个txt说明文档及1个db数据库文件其中源码和说明文档便于直接阅读与二次开发数据库文件可用于快速验证功能。包内还附带使用说明和项目工程文件适合课程设计或毕业设计作为参考。目前已有193人学习下载对于想快速上手Java网络通信的初学者来说是一份轻量且完整的实践范例。 自己动手写过一遍 Java 网络聊天室之后再回头看那些长篇大论的教程感觉很多东西讲得太理想化了。这项目听着像大学课设标配但真正做起来Socket 通信、多线程、数据库这三样东西搅在一起的时候坑比想象中多得多。我最初照着网上示例抄了一遍跑起来确实能聊结果客户端一多就乱套消息串台、服务端假死、数据库疯狂重连全都遇到过。这篇文章把我从零实现到逐步完善的完整过程整理出来包括线程模型怎么选、数据库表怎么设计、以及那些只有跑起来才看得见的隐藏问题给准备做类似项目或者在准备 Java 面试的朋友当个参考。1. 为什么我建议你亲手写一个 Java 网络聊天室先说一个可能有点反常识的结论这个项目最大的价值不是“聊天室”本身而是它把 Java 后端开发的三块硬骨头——网络编程、并发控制、数据持久化——全部压进了一个足够小、足够直观的场景里。相比单纯刷题背八股亲手把一个 Socket 服务从“能连上”改到“能扛住几十个并发客户端”对整个技术栈的理解完全不是一个量级。我见过不少人的课设思路是去找现成的框架比如用 Netty 或者 WebSocket 封装库几行代码就把服务端拉起来了。这种做法不能说错但如果你现在正处于学习阶段或者准备面试我强烈建议先用原生 Java Socket 走一遍。原因很直接框架帮你屏蔽掉的恰恰是面试官最爱问的底层细节。原生 ServerSocket 的 accept、输入输出流的阻塞特性、多线程对共享资源的竞争——这些只有亲手写过才会真正理解为什么 Netty 要用 Reactor 模型为什么 ConcurrentHashMap 在某些场景下还不够。这个项目适合三类人刚学完 Java 基础想找一个能串起线程、IO、集合、JDBC 的综合练习项目的学生在准备 Java 后端面试需要把多线程和网络编程从“会背概念”提升到“能讲清楚应用场景”的求职者单纯好奇消息是怎么从一台电脑跑到另一台电脑、服务端又是怎么同时伺候一堆客户端的爱好者。从难度上看一个基础版聊天室大概分为三层递进目标版本核心功能涉及技术点工作量V1.0单客户端连接、服务端回显ServerSocket、IO流半天V2.0多客户端互聊、消息广播多线程、线程池、消息转发2-3天V3.0用户注册、聊天记录持久化JDBC、数据库设计、登录鉴权3-4天我能给出的最实在的建议是直接瞄准 V3.0 去做但每一步都先把底层的运行机制搞明白再往上加东西。框架技术迭代太快Socket 通信和并发的底层逻辑十年都没变过这才是这个项目真正值钱的地方。2. Socket 通信的底层逻辑连接、收发消息与关闭2.1 从“打电话”理解 Socket 的本质Socket 翻译成中文叫“套接字”这个名字第一次听确实有点抽象。我的理解方式是把它看成一个双向的“电话线”——服务端开机等着来电客户端拨号过去接通之后两边各自拿住听筒你一句我一句。Java 里的 ServerSocket 和 Socket 这两个类本质就是对这条电话线的封装。服务端启动的核心代码非常简洁ServerSocket serverSocket new ServerSocket(8888); while (true) { Socket socket serverSocket.accept(); // 每接到一个连接就交给一个新线程去处理 new Thread(new ClientHandler(socket)).start(); }这里关键的是accept()方法的阻塞特性。这个方法会一直卡在那里直到有一个客户端发起连接请求它会返回一个新的 Socket 对象专门用来跟这个客户端通信。原生的 ServerSocket 本身并不负责数据收发它只是“总机接线员”真正的对话发生在 accept 返回的那个 Socket 上。流的方向也要理清楚。Socket 提供了两个流getInputStream()用来读客户端发过来的数据getOutputStream()用来给客户端写数据。这两个流都是阻塞式的尤其是输入流。当调用read()方法读数据时如果对方没发消息线程会一直停在那里等这正是后面需要引入多线程的根本原因——一个线程读数据会阻塞就没法同时干别的了。2.2 消息格式为什么必须定义一个“协议”最开始我把通信内容直接写成字符串客户端用BufferedWriter.write(msg)发出去服务端用BufferedReader.readLine()读出来看着没问题。但测试的时候发现一个现象如果客户端消息里没有换行符服务端就一直在那儿堵着不返回。原因在于readLine()会一直读到换行符才认为一条消息结束了。这就是所谓的“粘包半包”问题的雏形——你需要跟对端约定好一条消息到底怎么才算结束。最简单可靠的做法是自定义一个消息协议。对于聊天室场景我采用了“消息头 消息体”的轻量设计每条消息以 JSON 格式组织里面带上消息类型、发送人、目标、内容、时间戳消息结尾统一加一个换行符\n作为消息结束标志发送端写完数据之后必须手动 flush 一下输出流否则数据会滞留在缓冲区里发不出去。{type:chat,from:alice,to:all,content:大家好,ts:1737100800000}关掉 flush 重试一次你就会发现消息经常是攒了一批才发出去对方收到的内容总是“慢半拍”。这也是网络编程新手最容易忽略的细节——缓冲区的存在让 write 操作并不等于真正发送。2.3 连接管理谁负责关闭连接连接关闭比新建连接更容易出问题。Java 的 Socket 在关闭时输入输出流会自动跟着关闭但如果你在多个线程里分别持有读写流关闭顺序就很讲究。我踩过的典型坑是这样客户端正常退出时调用socket.close()服务端的readLine()会返回 null 或抛出 SocketException这本身是正常的“对方断开”信号。但如果服务端在同一个 Socket 上还有一条写线程这时候再去往输出流里写数据就会抛 IOException。处理方式是在客户端优雅退出时先发一条{type:bye}消息等服务端确认回执之后再 close这样双方都有一个明确的退出握手过程。3. 多线程并发模型从“来一个客户端开一条线程”说起3.1 为什么单线程跑不起来服务端如果只在主循环里用同步方式处理客户端消息逻辑会变成这样先跟第一个客户端聊完才能接第二个客户端的请求。这完全背离了聊天室的意义。根源就在前面提到的read()阻塞——你永远不知道客户端什么时候会发消息但如果不去读又没法收到消息。所以必须引入多线程。核心思路是主线程只负责 accept 新连接每接进来一个客户端就分配一条独立的工作线程由这条线程专门处理该客户端的读写。这样各个客户端之间互不阻塞A 在打字的时候B 的消息依然能正常收发。用代码表示就是ExecutorService threadPool Executors.newCachedThreadPool(); while (true) { Socket socket serverSocket.accept(); threadPool.submit(new ClientHandler(socket)); }3.2 线程池和手撸线程的区别V1.0 我用的是new Thread(handler).start()V2.0 才换成线程池。差别在客户端数量上来之后特别明显——如果每秒有 100 个客户端断开重连裸线程方式会瞬间创建 100 个线程再销毁线程的创建和销毁本身就有开销高并发下 GC 压力和上下文切换成本都会显著上升。线程池里的核心线程可以复用等于把“现招现裁”改成了“固定团队接活”。参数建议这样设核心线程数Runtime.getRuntime().availableProcessors()大概等于机器核数最大线程数设置为核心线程数的 2-4 倍留点余量应对突发连接队列用LinkedBlockingQueue作为等待队列避免无限创建线程把内存打爆拒绝策略CallerRunsPolicy让提交任务的线程自己去跑不至于直接丢弃任务。3.3 共享资源竞争ConcurrentHashMap 和 CopyOnWriteArrayList多客户端连上来之后必然涉及一个问题——一个客户端发消息怎么广播给其他所有客户端这就要维护一个“在线客户端集合”。我最开始用的是HashMapSocket, String然后就出问题了多个线程同时往里面 put、遍历发送广播时偶尔会出现并发修改异常。这是因为 HashMap 在并发写入时内部数组可能正在扩容两个线程同时操作同一个桶位数据结构就乱了。换成ConcurrentHashMap之后问题立刻消失。它的锁分段JDK8 之后是 CAS synchronized 锁桶设计让并发读写的冲突概率大大降低。所以这个场景我的结论是所有被多条线程同时访问的容器一律用并发容器别想着省事。广播消息的时候还有一个小优化。如果用普通的 for 循环遍历 map 去发送当某个客户端的连接已经断开但还没移除时写流会抛异常导致整个广播线程中断。正确的做法是在广播循环里捕获每个客户端的异常一旦遇到 IO 异常就把这个客户端从集合中移除就像这样for (String clientId : onlineClients.keySet()) { try { onlineClients.get(clientId).getOutputStream().write(msgBytes); } catch (IOException e) { onlineClients.remove(clientId); // 通知其他客户端该用户已离线 } }4. 数据库设计聊天记录和用户信息的落地方案4.1 用户表与消息表应该长什么样聊天室不能只活在内存里用户注册信息、历史聊天记录都需要持久化。数据库我选用 MySQLJDBC 连接用标准的DriverManager还是连接池直接上结论除非你的项目要求零依赖否则尽量用 HikariCP 连接池。用裸 JDBC 写代码每来一条消息就建立一次数据库连接、用完再关掉在聊天这种高频读写场景下数据库很快就扛不住。用户表设计得很常规CREATE TABLE chat_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );密码那一列我强调一点——千万不要存明文。虽然聊天室课设看起来没啥风险但这是考察你工程素养的机会。可以用MD5加盐或者SHA-256更规范的做法是直接用BCrypt。面试时能主动提到密码加密这个加分项比你在项目描述里写“使用了 Spring Boot”有用得多。消息表是核心CREATE TABLE chat_message ( id INT PRIMARY KEY AUTO_INCREMENT, sender_id INT NOT NULL, target_type ENUM(all,single,group) DEFAULT all, target_id INT, content TEXT, sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_sent_at (sent_at) );target_type字段给未来扩展留下了余地。初期只做广播target_type全是all后面想加私聊只需要让target_type变成single并且target_id指向私聊对象群聊同理。一张表撑起三种聊天模式这就是设计时多想一步的价值。4.2 什么时候写数据库同步写入还是异步落库聊天消息有个特点实时性要求高但对强一致性的要求没这么高。如果每条消息都在收到的那一刻同步执行 INSERT网络往返 磁盘 IO 会拖慢消息转发速度用户能明显感觉到“发一条消息要顿一下”。我的方案是异步落库。具体思路是服务端收到消息后先把消息发送给所有在线客户端完成“实时”部分同时把这条消息放进一个内存队列比如BlockingQueueChatMessage由一个单独的落库线程消费这个队列批量写入数据库。这样发送动作和数据库写入动作解耦即使用户发消息时数据库短时间卡顿聊天也能继续。队列容量的设置也有讲究。无界队列默认LinkedBlockingQueue构造方式在消息量大的时候可能堆积大量内存存在 OOM 风险。给队列设置上限比较稳妥比如 10000 条队列满了之后再做降级处理——要么丢弃最老的记录要么临时改成同步写入。生产环境怎么做这里不展开但你要在项目文档里写清楚这个取舍能让面试官看出你思考过“削峰填谷”的问题。4.3 登录鉴权Redis 还是数据库查询V3.0 加用户系统后登录鉴权不可回避。最简单的方式是每次发消息都从数据库查一次用户信息验证身份。但聊天场景下每条消息都查一次库数据库压力会成倍增长。用 Redis 缓存在线状态是常见方案用户登录成功后把 token 作为 key、用户 ID 作为 value写进 Redis 并设置过期时间。每次发消息时先查 Redis 确认 token 有效再决定是否转发。如果你不想引入 Redis也可以用ConcurrentHashMap做内存版 token 管理功能上差不多只是服务端重启之后所有在线状态会丢失需要客户端重新登录。5. 核心代码拆解服务端、客户端与消息广播5.1 服务端整体骨架服务端代码比较完整的结构是这样的public class ChatServer { private final ConcurrentHashMapString, PrintWriter clients new ConcurrentHashMap(); private final ExecutorService pool Executors.newCachedThreadPool(); private final BlockingQueueChatMessage msgQueue new LinkedBlockingQueue(10000); public void start(int port) throws IOException { // 启动落库线程 new Thread(new MessagePersistenceTask(msgQueue)).start(); try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println(Chat server started on port port); while (true) { Socket socket serverSocket.accept(); pool.submit(new ClientHandler(socket, this)); } } } public void broadcast(ChatMessage message) { byte[] data message.toJsonBytes(); for (PrintWriter writer : clients.values()) { writer.println(new String(data, StandardCharsets.UTF_8)); writer.flush(); } msgQueue.offer(message); // 异步落库 } }5.2 ClientHandler每个客户端一个线程ClientHandler 是处理单个客户端所有交互的核心它做的事情很简单——从输入流里一行一行读消息解析后交给服务端做广播或私聊处理。public class ClientHandler implements Runnable { private final Socket socket; private final ChatServer server; private BufferedReader reader; private PrintWriter writer; private String username; public ClientHandler(Socket socket, ChatServer server) { this.socket socket; this.server server; } Override public void run() { try { reader new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); writer new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); // 1. 登录握手 String loginLine reader.readLine(); ChatMessage loginMsg ChatMessage.fromJson(loginLine); this.username loginMsg.getFrom(); // 2. 注册到在线列表 server.registerClient(username, writer); server.broadcast(new ChatMessage(system, all, username 加入了聊天室)); // 3. 循环读取消息 String line; while ((line reader.readLine()) ! null) { ChatMessage msg ChatMessage.fromJson(line); if (bye.equals(msg.getType())) { break; } server.broadcast(msg); } } catch (IOException e) { System.out.println(Client username disconnected); } finally { cleanup(); } } private void cleanup() { server.removeClient(username); server.broadcast(new ChatMessage(system, all, username 离开了聊天室)); try { socket.close(); } catch (IOException ignored) {} } }PrintWriter我特意用了autoFlushtrue的构造方式这样每次println之后会自动调用 flush不用手动刷。如果autoFlushfalse记得在每个 write 之后手动 flush否则数据会堆在缓冲区里一直不出去。5.3 客户端其实没什么高深的客户端的实现反而简单但有一个点容易出错——读和写必须分线程。如果主线程一边等用户在控制台输入一边等服务端消息无论你先把哪边阻塞住另一边就收不到数据。所以客户端也要开一个线程专门负责接收服务端消息主线程留给用户输入。public class ChatClient { public static void main(String[] args) throws Exception { Socket socket new Socket(127.0.0.1, 8888); BufferedReader serverReader new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter sender new PrintWriter(socket.getOutputStream(), true); // 接收线程持续读服务端广播 new Thread(() - { String line; try { while ((line serverReader.readLine()) ! null) { System.out.println(line); } } catch (IOException e) { System.out.println(连接已断开); } }).start(); // 主线程读控制台输入并发送 BufferedReader consoleReader new BufferedReader(new InputStreamReader(System.in)); String input; while ((input consoleReader.readLine()) ! null) { sender.println(input); } socket.close(); } }这里注意编码统一。服务端和客户端都指定了UTF-8防止中文乱码。如果你在 Windows 控制台跑默认编码可能是 GBK不统一的话「你好」会变成「浣犲ソ」。6. 实测中踩过的坑粘包、并发竞争与连接关闭6.1 消息里带换行导致的协议错乱有一次我测试发一条内容包含换行的长消息结果对端只收到了第一行。排查之后发现是我用的readLine()按换行符切分消息消息内容里一旦出现\n就会误当成一条新消息的结束标志。解决方案是把消息正文做一次 Base64 编码或者干脆在消息协议里规定正文不允许包含换行如果要发多行内容用\n之类的转义占位符代替真实换行。虽然限制了消息格式但换来的是协议简单可靠。6.2 广播时 ConcurrentModificationException每次广播都会遍历在线客户端集合而客户端登录、退出时又会修改这个集合遍历过程中集合被修改就会触发并发修改异常。我一开始以为换成ConcurrentHashMap就万事大吉了——遍历时它确实不抛异常但不代表结果是对的可能这次广播漏掉了刚加入的客户端也可能把刚退出的客户端也发了一遍。最终解决办法是双保险广播的时候不直接遍历真实的连接集合而是先new ArrayList(clients.values())快照一份再遍历同时配合异常捕获移除失效连接。快照方式在客户端数量几百的规模下完全够用。6.3 客户端断开后服务端线程泄漏另一个隐蔽的问题是用户直接关了聊天窗口没有发bye消息服务端readLine()会抛SocketException或返回 null。如果我没有在finally块里做清理这条客户端的线程和 Socket 句柄就永远不会释放连接集合里也会一直留着这个死连接。时间一长服务端资源被占满新的客户端连接不上。处理好清理逻辑之后我还加了一个心跳机制兜底。客户端每 30 秒发一个{type:ping}服务端记录每个客户端最近一次心跳时间每条独立线程每 60 秒扫描一次发现超过 90 秒没有心跳的客户端就强制踢掉同时回收资源。这也是为什么说聊天室不是一个“写一个死循环 readLine 就完事”的小玩具——真正稳定运行的版本要考虑的事情还不少。6.4 数据库连接耗尽这个问题在给聊天记录加历史查询功能时出现的。查询接口用裸 JDBC 实现每来一个请求就DriverManager.getConnection()测试时短时间开了 200 个并发请求MySQL 直接报Too many connections。换上 HikariCP 连接池之后核心参数这么配maximumPoolSize10跟 MySQL 默认 max_connections 保持合理比例minimumIdle5保留最少空闲连接避免频繁建连connectionTimeout3000 毫秒拿不到连接直接失败不无限等待。这样配置之后连接变成了资源池里的重复利用对象而不是用完即弃的一次性资源。7. 从课设到工业级这个项目的扩展方向与面试切入点7.1 三个值得做的升级方向做完 V3.0 之后我觉得这个项目还能继续往上走但重点不再是“能用”而是“能扛住更大的规模”。第一是引入 Netty 替换原生 Socket。不要觉得这是推翻重来其实你在原生 Socket 阶段理解的阻塞 IO 模型恰恰是读懂 Netty 非阻塞模型的最佳铺垫。Netty 就解决一个问题——用少量线程服务海量连接适合高并发场景下的长连接应用。第二是增加离线消息拉取功能。现在新用户登录后看不到历史消息体验不完整。实现方案也不复杂登录时从chat_message表里查最近 N 条记录推给客户端或按时间范围查询。这个功能会把服务端、数据库、前端展示三个环节完整串起来。第三是把数据库换成消息队列来做削峰。如果消息量真的很大异步落库用的BlockingQueue可以替换成 RocketMQ 或 Kafka保证消息不丢失、可回溯。别觉得一个聊天室上 MQ 是杀鸡用牛刀关键是理解这个思想实时链路和持久化链路通过消息中间件解耦。7.2 面试官会围绕这个项目问什么做完项目去面试被问到的概率最高的问题我列一下accept()是阻塞的吗阻塞发生在哪个层面多客户端连接时服务端如何区分消息是谁发的多个线程同时往不同客户端的 Socket 写数据会有并发问题吗在线用户列表用什么数据结构存的为什么不用 HashMap客户端掉线后服务端怎么感知心跳超时怎么实现聊天记录保存失败了怎么办消息会丢吗如果把聊天室用户量从几百提升到几万瓶颈在哪里数据库消息表的数据越来越多查询越来越慢怎么优化这些问题如果你是用框架快速搭出来的项目大概率答不深但亲手调过线程池、跟粘包搏斗过、亲眼看过连接池被打爆的报错日志之后每个问题都能讲出一段真实的排错经历。我的建议是做完之后别急着删代码先试着回答上面这些问题答不上来的地方重新打开项目去定位对应代码。这个过程比项目本身更能提升你对 Java 网络编程的整体理解。最后分享一个我后来一直保留的习惯无论做任何涉及网络通信的小项目我都会先定义一个明确的消息协议、画一张线程模型图、再写第一行代码。顺序错了后面填坑的时间绝对能成倍增长。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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