Java网络聊天室实战:Socket长连接、多线程并发与MySQL落库

发布时间:2026/10/21 2:53:15
Java网络聊天室实战:Socket长连接、多线程并发与MySQL落库 简介一份基于Java实现的网络聊天室完整项目源码面向正在学习网络编程、多线程与数据库应用的开发者重点解决从零搭建实时多人聊天系统的实践问题。资源包共46个文件包含7个java源码、20个class编译文件以及界面截图、使用说明、数据库文件等辅助材料整体体积仅199KB结构清晰便于直接导入Eclipse等IDE阅读、调试和二次开发。项目完整覆盖Socket通信、多线程并发、数据库存储三大核心客户端通过Socket与服务器收发消息服务器为每个连接创建独立线程实现多用户同时在线聊天并利用MySQL或类似数据库完成用户注册、登录验证与聊天记录持久化。附带的使用说明和界面素材可帮助理解客户端/服务器架构、线程同步、JDBC操作等关键点适合作为课程设计或毕业设计的参考范本。已有193人学习下载对于希望掌握TCP/IP通信、并发编程及数据持久化的Java学习者具有直接的实战借鉴价值。1. Java 网络聊天室Socket、多线程与数据库三件套串成一个能跑的项目很多人上手 java 网络聊天室以为把 Socket 收发调通就完事了真正连起来才发现Socket 只是那条数据通道多线程决定多少人能同时在线说话数据库负责账号校验和聊天记录回放三块接不拢程序就停留在「能连 TCP」的阶段离「能用的聊天室」还差一大截。这份资源把这条链路完整串过一遍服务端多线程接收客户端连接、自定义消息协议解决 TCP 粘包、JDBC 接入 MySQL 做登录鉴权和历史消息查询。适合刚学完 Java 集合与 IO、想用「网络 并发 数据库」练手的开发者也适合直接拿来做课程设计或面试项目。按步骤搭完你能跑通注册、登录、群聊、私聊、历史记录查询的完整聊天室而不是一个只能回显的 demo。2. 技术选型与整体架构为什么是 C/S 和长连接而不是轮询2.1 架构选型聊天室天然适合 C/S 和长连接聊天室的需求点其实很明确消息要低延迟推给别人、客户端和服务端之间保持一条稳定的通道、服务端要实时知道谁在线谁离线。这三个诉求决定了架构选型的方向——要么选 Socket 长连接要么用 HTTP 轮询甚至 WebSocket。轮询的问题在于它是「拉」而不是「推」。客户端每隔几秒发一次请求问服务端「有没有新消息」服务端就算没有也得回一次空响应。连接数一多服务端大量时间在处理空请求轮询间隔设短了服务器扛不住设长了消息延迟又明显。聊天室这种以推送为主的场景轮询属于典型的反向优化。反过来看 Socket 长连接客户端和服务端建好 TCP 连接后双方随时可以写数据。消息广播的路径是「有人发消息 → 服务端收到 → 遍历在线连接 → 写出去」一次握手后续都是直接推送没有请求响应的开销。代价是服务端要多管理连接状态断线检测、线程资源、读写并发都要自己操心——这也是这份资源里花大量篇幅在讲的部分。两种方案的差异可以用一张表收拢指标Socket 长连接HTTP 轮询实时性消息到达即推送取决于轮询间隔服务端压力连接状态维护成本大量空请求占满线程消息延迟毫秒级秒级实现复杂度高并发/断线/协议低适合场景聊天室/实时推送低频数据刷新这个资源最终采用 C/S 结构模块划分大致如下客户端负责登录注册交互、Socket 连接、消息收发的封装和聊天界面展示服务端负责接受新连接、为每个连接分配处理任务、消息解析、按类型路由到目标客户端同时负责读写数据库数据库存用户信息和聊天记录支撑登录鉴权和历史查询。三层的责任边界非常清楚。服务端是重头戏也是踩坑最密集的地方。表个态如果你只会写客户端连着玩那是 Socket 练习把服务端的并发和落库都打通这才叫网络聊天室。2.2 自定义消息协议为什么是「4 字节长度头 JSON 包体」很多初学者会直接用一行字符串当消息比如zhangsan|hello然后服务端用readUTF去读。这种写法在「一个客户端连着聊几句」的场景下碰巧能用但只要消息变长、或者两条消息连续发送马上就出问题。原因在 TCP 的流式特性上TCP 把数据看成一段连续的字节流它不保证你一次write的数据会完整地到达对端一次read也不保证两次write的数据在对端是被分两次读到的。这就是所谓「粘包/拆包」。要想知道一条消息在哪里结束只能自己在协议层定义边界。资源里采用的方案是「4 字节长度头 JSON 包体」一条消息的格式如下字段字节数说明包体长度4用 int 记录包体字节数包体包体长度JSON 序列化后的消息内容UTF-8 编码客户端发消息时先把消息对象序列化成 JSON 字节再在前面写 4 字节长度服务端先读满 4 字节得到长度再按长度读满包体。边界明确了粘包问题才有解。消息对象本身长这样public class ChatMessage { private String type; // LOGIN / REGISTER / CHAT / PRIVATE / SYSTEM / HISTORY private String from; // 发送者用户名 private String to; // 私聊目标群聊时为 null private String content; // 消息内容登录场景下放密码 private long timestamp; // 发送时间 // Jackson 需要无参构造和 getter/setter这里省略 }type字段承担的是路由职责。服务端收到一条消息后先读type再决定是查数据库验登录、广播给所有人、还是定向发给某个用户。用 JSON 而不是 Java 原生对象序列化有两个原因一是 JSON 在任何语言里都能解析客户端以后想换成 C# 或前端页面都不用改服务端二是 JSON 字段和数据库字段天然对应落库的时候直接映射调试时也能直接看到报文不用反序列化。2.3 数据库表结构用户表、聊天记录表的最小设计聊天室涉及的数据量不大但有两类数据必须存用户和聊天记录。资源的建表语句大致如下CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE chat_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, msg_type VARCHAR(20) NOT NULL, sender VARCHAR(50) NOT NULL, receiver VARCHAR(50), content TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_create_time (create_time) );username加了唯一约束注册时可以直接通过INSERT是否报重复键来判断用户名是否被占用不用先SELECT再INSERT省一次查询。chat_records的msg_type区分群聊和私聊receiver在群聊时留空这样私聊记录也能按对话双方查出来。密码字段用的是VARCHAR(64)正好放下一个 SHA-256 的十六进制字符串。资源里没有明文存密码——这点很重要哪怕是练手项目明文密码也容易在答辩或演示时被问到。存哈希而不是原文坏处只是登录时要对输入做同样处理后比对成本很低但能少很多解释。关于连接数据库的方式JDBC 原生连接在项目里只用于演示实际运行要换连接池。这块我在第 4 章展开先把表结构立住。表结构立住了后面的 DAO 层怎么写都不会跑偏。3. 通信层落地Socket 长连接、自定义协议与消息分发路由3.1 服务端骨架accept 循环与客户端线程分离服务端的主循环是一个经典结构accept()阻塞等待新连接每来一个连接就交给一个独立的处理单元去跑。public class ChatServer { private static final int PORT 8888; // 用 CopyOnWriteArrayList 保存在线客户端遍历和增删不会互相干扰 private final ListClientHandler clients new CopyOnWriteArrayList(); public void start() { try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(服务端启动监听端口: PORT); while (true) { Socket socket serverSocket.accept(); // 阻塞直到有客户端连入 ClientHandler handler new ClientHandler(socket, this); clients.add(handler); handler.start(); // 每个客户端一个独立线程 } } catch (IOException e) { e.printStackTrace(); } } }逻辑说明accept()是阻塞方法服务端停在这里直到某个客户端发起 TCP 连接才返回一个代表该连接的Socket。ClientHandler承担后续所有业务——读消息、解析、路由——它继承Thread或者实现Runnable都可以。clients列表用CopyOnWriteArrayList而不是ArrayList是因为同一时刻可能有多个线程在遍历列表做广播另一个线程在新增或移除连接普通ArrayList在「遍历中增删」时会抛ConcurrentModificationException这个细节在第 5 章还会再提。参数说明PORT是服务端监听端口资源默认 8888你自己跑的时候注意两点不要用 1-1024 的保留端口也不要和本机已占用端口冲突。检查端口是否被占用的方式是netstat -ano | findstr 8888Windows或lsof -i:8888Linux/macOS。另外handler.start()单独起线程是「每连接一线程」的初版模型它直白但耐压能力弱第 4 章我会改成线程池版本。3.2 读写封装readUTF 的坑以及 readInt readFully 的正确姿势DataInputStream.readUTF()有个坑它最多只能读 64KB 字节超过就抛UTFDataFormatException。聊天消息大多数时候不会超过 64KB但这种「靠运气工作」的代码我向来不推荐。正确做法是严格按协议来先读 4 字节长度再按长度把包体读满。public void writeMessage(Socket socket, ChatMessage msg) throws IOException { // 先把消息对象转成 JSON 字节数组再在头部写入长度 byte[] body objectMapper.writeValueAsBytes(msg); DataOutputStream out new DataOutputStream(socket.getOutputStream()); out.writeInt(body.length); // 包体字节数4 字节 out.write(body); // 包体内容 out.flush(); } public ChatMessage readMessage(Socket socket) throws IOException { DataInputStream in new DataInputStream(socket.getInputStream()); int len in.readInt(); // 先读长度 byte[] body new byte[len]; in.readFully(body); // 读满 len 个字节才返回 return objectMapper.readValue(body, ChatMessage.class); }逻辑说明写入侧先writeInt再write读出侧先readInt再readFully收发双方对「消息边界」的理解一致粘包问题就解掉了。readFully和普通read(byte[])的区别是普通read可能只读到一半就返回了比如网络慢的时候 1000 字节只到了 300read就会带着 300 字节返回readFully会阻塞到读满指定长度才继续。包体读不完整就没有解析意义所以必须readFully。参数说明objectMapper是 Jackson 实例它在整个项目里应该只有一个。创建ObjectMapper的开销不小而且它是线程安全的所以放在服务端启动时初始化一次所有线程共用。另外DataOutputStream是带缓冲的写完记得flush()否则数据可能留在进程内存里对端要等缓冲区满了才能收到——这个小细节能解释很多「为什么消息半天才出来」的怪问题。3.3 消息分发路由群聊广播、私聊定向与在线状态维护服务端读完一条消息后按type走不同的路由。群聊广播是遍历所有在线客户端逐个发送私聊是找到目标用户那一个连接发送系统消息上线、下线、登录结果走单独通道。public void broadcast(ChatMessage msg, ClientHandler exclude) { for (ClientHandler handler : clients) { if (handler ! exclude handler.isLoggedIn()) { handler.sendMessage(msg); } } } public void sendPrivate(ChatMessage msg) { for (ClientHandler handler : clients) { if (handler.getUsername() ! null handler.getUsername().equals(msg.getTo())) { handler.sendMessage(msg); return; } } }逻辑说明broadcast里传入exclude参数是为了「不回发给消息发送者本人」。消息已经由发消息的客户端自己显示在界面上如果服务端再把它灌回发送者UI 上就会出现两条一模一样的消息这是聊天室常见的重复消息问题的根源之一。sendPrivate用getUsername().equals(msg.getTo())匹配目标连接找到就发送并停止遍历找不到则说明目标不在线服务端要回一条SYSTEM类型的消息告诉发送者「对方不在线」。参数说明这两个方法都没加锁因为它们依赖CopyOnWriteArrayList的安全遍历特性。isLoggedIn()是一个 boolean 标志客户端连接建立后还没登录成功时群聊广播要跳过它否则未认证的连接会收到不该看的消息这在资源里属于安全边界设计别为了省事省略。3.4 在线用户列表上线与下线的状态同步除了纯文本消息客户端顶部的在线用户列表也由服务端维护。资源里在用户上线和下线时广播一条SYSTEM类型的消息内容是一个 JSON 数组列出当前所有在线用户名客户端收到后刷新列表。这个机制同时解决了「新用户登录后怎么知道谁在线」和「其他客户端怎么感知某用户下线」两个问题。注意点这类系统消息也只推给已登录的客户端代码里isLoggedIn()的判断在broadcast和pushOnlineList里都要做漏掉一个就会出现「未登录的连接收到全量用户名」的安全问题。我一般会把「获取在线列表」抽成一个独立方法在登录成功、上线、下线三个时机各调用一次保持状态一致性。4. 并发与存储线程池参数、连接池与聊天记录落库4.1 线程模型从每连接一线程到可控的线程池第 3 章的handler.start()每来一个客户端就 new 一个线程那是一种直白但危险的写法。连接数少的时候没问题100 个客户端就是 100 个线程系统还能扛到 1000 个在线线程数量跟着飙上去每个线程默认栈就有 1MB光线程内存就吃掉 1GB加上 CPU 上下文切换开销服务端会先把自己拖垮。资源在完整版里把线程创建换成了ThreadPoolExecutor核心代码ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // corePoolSize 核心线程数 32, // maximumPoolSize 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程闲置回收时间 new ArrayBlockingQueue(200), // 等待队列最多排队 200 个任务 Executors.defaultThreadFactory(), new ThreadPoolExecutor.DiscardPolicy() // 队列满时丢弃新任务 );逻辑说明线程池的执行顺序是——核心线程处理常规连接核心线程全忙时新任务进队列排队队列满了才创建非核心线程连非核心线程都满就直接走拒绝策略。聊天室这种长连接场景每个连接占用一个线程长时间不释放所以maximumPoolSize相当于在线人数的硬上限超过就拒之门外。DiscardPolicy在很多场景下显得激进它静默丢弃任务但用在聊天室上反而合适连接太多时宁可拒绝新连接也不让已有连接卡死。参数说明这套参数是资源默认值你可以按机器调。核心线程设 8、最大线程数设 32适合 2-4 核的小服务器跑在 16 核机器上可以往 16/64 方向调。ArrayBlockingQueue的容量决定「突然涌入 300 个连接」时有多少个能排队等待200 是一个保守值。注意不要用Executors.newCachedThreadPool()它的线程数是无限的看着省事等于把「每连接一线程」的隐患换了个马甲搬回来。4.2 数据库连接池为什么不能每条消息新建 Connection聊天记录落库需要访问数据库但用 JDBC 原生DriverManager.getConnection()每条消息建一次连接是最典型的翻车写法。MySQL 建立连接要走 TCP 握手、认证、权限校验一次完整连接的开销接近一次简单 SQL 查询。消息量一大连接时间比业务处理时间还长数据库端的连接数也会被瞬间打满。所以资源里换上了 HikariCP一个小而快的连接池。HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/chat ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8); config.setUsername(root); config.setPassword(your_password); config.setMaximumPoolSize(10); // 池中最大连接数 config.setMinimumIdle(2); // 最小空闲连接数 config.setConnectionTimeout(30000); // 取连接超时时间单位毫秒 HikariDataSource dataSource new HikariDataSource(config);逻辑说明连接池做的事其实很简单——把连接对象放到池里复用。消息要写库时dataSource.getConnection()从池里拿一个空闲连接用完close()归还而不是真的断开。池的maximumPoolSize是硬上限它和前面服务端的最大线程数要配合考虑如果服务端 32 个线程同时写库而连接池只有 5 个连接那每个线程取连接时都可能排队等 30 秒然后超时。参数说明JDBC URL 里的serverTimezone在 MySQL 8 里是必填参数不填报时区异常characterEncodingutf8保证中文不乱码useSSLfalse是本地开发环境的标准配置。注意username和password要换成本地数据库的实际账号。connectionTimeout设为 30000 毫秒意味着池子没连接时最多等 30 秒超过就抛异常。这个配置项在高峰期排查问题时会给你明确的报错线索值得保留。4.3 登录鉴权与消息落库DAO 层的读写职责有了连接池DAO 层就简单了。登录的本质是拿客户端传过来的用户名和哈希密码查users表聊天记录的本质是往chat_records表插一行。资源里用PreparedStatement而不是字符串拼接 SQL这是硬要求。public boolean checkLogin(String username, String hashedPassword) { String sql SELECT id FROM users WHERE username ? AND password ?; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, hashedPassword); try (ResultSet rs ps.executeQuery()) { return rs.next(); } } catch (SQLException e) { e.printStackTrace(); return false; } }逻辑说明PreparedStatement参数化查询的意义不只是代码美观而是防 SQL 注入。如果用字符串拼接用户名是admin OR 11时SQL 就变成了WHERE username admin OR 11直接绕过密码校验。参数化后用户输入永远是「值」而不是「SQL 片段」。try-with-resources确保连接和语句用完自动关闭连接归还池子语句释放内存。参数说明密码传进来时已经是哈希值不是明文。你可以用MessageDigest算 SHA-256或者用更推荐的做法——SHA-256 加盐注册时生成随机盐存salt:hash两段登录时用同一盐重算再比对。这个改动只涉及注册和登录两个方法成本很低却能把「密码防反查」这件事讲清楚。这也是在很多课程设计答辩里能被追问的点。消息落库的方法基本同构只是 SQL 换成INSERT。值得注意的一点是群聊广播是高频操作每条都同步写库会让广播带着数据库延迟走完。资源里的做法是聊天消息先广播同时用一个单线程的ExecutorService异步把记录写进chat_records这样广播速度不依赖数据库。代价是极端情况下可能有几条记录缓冲在内存里没落盘这个取舍在聊天室场景是可接受的。5. 避坑记录粘包、并发修改、线程泄漏与连接耗尽的高发现场5.1 消息错乱粘包、半包与并发写同一个流现象客户端连发两条消息服务端收到的 JSON 被拼在一起解析直接报错或者一条消息只读了一半JSON 截断。有时候连着发三条第三条和第一条混在一个包体里。重启服务端后可能一切正常过一会儿又随机出现看起来像玄学其实全是协议问题。原因TCP 是字节流没有消息边界。如果收发两端没有严格按「长度头 包体」来读写或者写入侧没flush()、读取侧用了单次read()而不是readFully()粘包和半包就会轮番出现。另外如果服务端有多个线程在往同一个Socket输出流写数据两条消息的字节可能穿插在一起对端读出来的长度和内容完全对不上。解决写入统一走writeMessage()读取统一走readMessage()全局只允许这两个方法碰 Socket 流。多线程写同一个 socket 时给输出流加synchronized或者在ClientHandler内部用一个独立的发送线程把要发的内容放进队列逐条写出。我一般会让所有发送走一个串行队列排队发送乱序就自然消失了。5.2 客户端断开SocketException 与线程泄漏现象客户端直接关掉窗口或断网服务端控制台抛SocketException: Connection reset或EOFException报错线程一直在日志里刷屏clients列表里还保留着这个失效连接在线人数统计越来越不准。原因readMessage()在客户端断开时会立即抛异常但服务端的处理线程没有try-catch-finally异常只是打日志线程和连接对象都没被清理。异常循环出现的本质是同一个失效连接在被重复读取而且连接不在列表里移除后续广播还会往这个死连接上写数据白白抛异常。解决在ClientHandler的run()里用try-finallyfinally中把当前 handler 从clients列表移除、关闭 Socket、把在线状态标记复位。catch捕获SocketException后不要紧张把它看作一次正常下线即可。我一般在写服务端时会强制检查一遍所有读取循环是否都有finally把线程资源收干净。5.3 ConcurrentModificationException广播时移除客户端的冲突现象服务端在broadcast()遍历clients的同时另一个线程因为客户端断线调用了clients.remove(handler)控制台抛出ConcurrentModificationException广播中断其他在线用户的消息也收不到了。原因ArrayList的迭代器在检测到列表被结构性修改时会快速失败抛异常。聊天室的广播是高频遍历断线是随机事件这两件事几乎必然在某时刻并发。解决把clients声明为CopyOnWriteArrayList它的迭代器基于快照遍历时增删不会抛异常。代价是写操作开销高但聊天室的写操作只有上线和下线比读操作少几个数量级这个代价可以接受。如果不想换集合类型就在broadcast里对列表加锁但锁的范围要包住整个遍历否则没有意义。5.4 数据库连接耗尽Too many connections现象运行一段时间后消息发送开始变慢随后服务端抛Cannot create PoolableConnectionFactory或Too many connections聊天室整体不可用所有在线用户的消息都发不出去。原因最常见的有两种一是没接连接池每条消息DriverManager.getConnection()建新连接且没有及时close()连接泄漏二是连接池maximumPoolSize远小于服务端线程数线程取连接超时异常处理里连接未归还。第二种更隐蔽问题出在取连接异常时conn为null后面的close()没执行连接被永久占用。解决统一走 HikariCPgetConnection()后放到try-with-resources里强制归还。maximumPoolSize要参考服务端线程池上限线程数 32、连接池 10 是一个正常配比如果单条 SQL 很慢可以考虑降到 1:432 线程配 8 连接让线程在连接上排队而不是抢占。排查时先执行SHOW PROCESSLIST看哪些连接处于Sleep状态且长期不释放那基本就是泄漏现场。6. 进阶验证多客户端模拟压测与三项落地检查6.1 一键拉起 50 个模拟客户端聊天室写完不能只靠两个窗口手动互发几条消息就宣布完工。我惯用的验证方式是批量拉起模拟客户端自动完成注册、登录、群聊然后检查服务端有没有异常线程残留。资源里附带的压测脚本思路如下#!/bin/bash for i in $(seq 1 50); do java -cp chat-client.jar ChatClientMock user_$i /dev/null done sleep 10 echo 在线数检查通过: 50压测结束后从数据库侧核对落库情况mysql -uroot -p chat -e SELECT COUNT(*) AS total FROM chat_records;ChatClientMock是资源里的一个小工具类它模拟客户端行为但不带界面每 2 秒发一条群聊消息并发 50 个约等于每分钟产生 1500 条广播对服务端线程池和连接池的压力已经能看出问题。6.2 三项落地检查压测跑完后我会按这个顺序确认在线管理50 个模拟客户端是否全部登录成功用系统消息广播验证在线数消息不丢每个客户端统计自己收到的群聊消息条数应该等于 49 条自己发的被排除多一条或少一条都是路由逻辑有问题落库正确chat_records表里群聊记录数量等于 50 个客户端各发 100 条的总和私聊记录能按双方用户名查出来这一套走下来能覆盖八成的高发问题剩下的就是无语的玄学时刻——比如防火墙拦了端口、本机 hosts 写错、MySQL 大小写敏感导致表名对不上这些我会直接在日志里找线索不跟现象死磕。从那以后我每次写完网络层都强制走一遍「多客户端同时断连 群聊广播 SQL 计数核对」的组合测试这套流程帮我拦下了不少上线前差点翻车的问题。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询