资讯详情 Java Socket聊天室实战:多线程通信与MySQL持久化完整指南
📅 2026/10/21 11:56:19
简介基于Java的迷你聊天室项目源码适合Java初学者、网络编程学习者及课程设计参考。项目以SOCKET通信为底层通道结合多线程处理并发连接并使用数据库完成用户注册、登录与聊天记录存储覆盖聊天室开发的完整流程。压缩包共46个文件其中7个Java源码与20个class文件可直接对照阅读底层实现另有1个db数据库文件、3个txt说明文档及多张图片图标整体仅199KB结构精简。已有193人学习下载。通过该项目可掌握Socket连接建立、多线程消息广播、JDBC数据交互等关键代码并借助说明文档快速运行调试适合作为网络编程综合实践的入门模板。1. 基于Java Socket的网络聊天室多线程通信与数据库持久化的实战项目一个局域网聊天室的小需求让我把Java Socket、多线程和数据库串了一遍。朋友要我给公司内部做个简单聊天工具我当时没犹豫就选了原生Socket服务端监听一个端口就能接多客户端每个连接分一条线程去处理消息实时广播出去聊天记录统一写进MySQL。这个项目功能不算花哨但把java网络通信里最核心的阻塞IO、线程模型、连接管理全用上了特别适合刚学完Java基础、想搞懂网络编程到底在做什么的人。你读完能明白服务端为什么必须多线程客户端为什么至少拆两条线程以及怎样把一条消息安全地落进数据库。2. 系统总体设计从Socket链路到数据库表结构2.1 总体架构C/S模式与三个模块的职责划分整个项目就是经典的C/S模式客户端、服务端、数据库三层各管各的。客户端负责发起连接、采集用户输入、展示服务端转发回来的消息服务端负责监听端口、接受客户端连接、读取消息做逻辑转发、调DAO层写库数据库只干一件事持久化用户信息和聊天记录。三层之间没有复杂的中间件这保证了项目的最小可运行也方便后续加功能。职责切分上我习惯把代码拆成server、client、db三个包。server里再拆成三个类ChatServer只做端口监听和连接接入ClientHandler负责单个连接的生命周期管理MessageDispatcher负责消息往哪转发。db包里放DBUtil和MessageDao这样服务端代码不会到处都是JDBC样板代码。很多新手喜欢把数据库操作直接写在客户端处理线程里一旦出现Bug日志都分不清是谁的问题。2.2 通信协议用“竖线分隔符”定义消息格式不少同学一上来就想着上JSON这个体量真没必要。我用的是最朴素的文本协议每行一条消息字段顺序固定用竖线分隔例如LOGIN|zhangsan|all|hello|2025-06-01 12:00:00 MSG|zhangsan|all|大家好|2025-06-01 12:01:00 MSG|zhangsan|lisi|晚上一起吃饭|2025-06-01 12:02:00 LOGOUT|zhangsan|all|bye|2025-06-01 12:03:00第一段是消息类型第二段是来源用户第三段是目标用户广播填all私聊填对方用户名第四段是消息内容第五段是时间戳。服务端拿到一行消息后直接split(\\|)就能解析不引第三方库出问题也能把整行日志拉出来追踪。缺点也明显消息内容里不能出现竖线我就在客户端发送前统一做了替换把半角竖线换成全角竖线服务端回复时再替换回来。2.3 多线程模型线程池管理客户端连接如果只用一个main线程去处理客户端连接第一个客户端的readLine()就会把主线程阻塞住后面的客户端全卡在accept队列里。所以必须在accept到Socket后为它分配一条独立的线程来处理这个连接。我用的线程池是Executors.newCachedThreadPool()原因是聊天室连接数量不稳定但单个连接空闲时线程可以回收相比固定大小线程池更省资源。在线用户列表我用ConcurrentHashMapString, Socket保存。这里有个坑如果图省事用普通HashMap当两个客户端同时登录或同时掉线时读写可能互相覆盖轻则某客户端收不到消息重则死循环CPU飙满。ConcurrentHashMap在竞态环境下能保证读到的不是半初始化数据代价是迭代时弱一致不过这个场景完全够用。为了不让消息积压在某个慢客户端上我给每个Socket的输出流都加了synchronized锁。后面避坑章节会详细讲这里先记住一个原则多个线程可以同时读不同的Socket但写同一个Socket时必须串行化否则字符串会交错成乱码。2.4 数据库表设计用户表与消息表数据库用MySQL 8.0字符集统一utf8mb4不然客户端发个emoji表情直接存不进去。两张表就够了用户表存用户名和密码消息表存每一条聊天记录。建表SQL如下CREATE DATABASE IF NOT EXISTS chatroom DEFAULT CHARSET utf8mb4; CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password CHAR(64) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE messages ( id INT PRIMARY KEY AUTO_INCREMENT, from_user VARCHAR(32) NOT NULL, to_user VARCHAR(32) NOT NULL DEFAULT all, content TEXT NOT NULL, send_time DATETIME NOT NULL );消息表的to_user字段很重要广播消息记all私聊消息记对方用户名。这样后面查“某两个人之间聊过什么”只需要一条带OR的SQL不用再拆一张私聊表。users.username加了唯一索引登录时能快速判断重名也避免同一用户名被两个连接同时占用的数据错乱。2.5 项目目录结构从源码到资源文件的布局最终工程结构长这样chatroom/ ├── pom.xml ├── sql/init.sql └── src/main/java/com/chat/ ├── server/ │ ├── ChatServer.java │ ├── ClientHandler.java │ └── MessageDispatcher.java ├── client/ │ ├── ChatClient.java │ └── ReceiveThread.java └── db/ ├── DBUtil.java └── MessageDao.javapom.xml里只放一个MySQL驱动依赖sql目录放初始化脚本server和client完全不互相引用db层被server层单向调用。以后要加心跳、文件传输、离线消息往对应包里加类就行不用改整体结构。3. 核心模块拆解一个消息从发送到落库的完整链路3.1 服务端启动与接入ServerSocket.accept() 与连接循环服务端主类写起来很简洁核心逻辑就是accept()循环加线程池public class ChatServer { private static final int PORT 8888; private static final ConcurrentHashMapString, Socket onlineUsers new ConcurrentHashMap(); private static final ExecutorService pool Executors.newCachedThreadPool(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(服务端已启动监听端口 PORT); while (true) { Socket socket serverSocket.accept(); System.out.println(新连接 socket.getRemoteSocketAddress()); pool.execute(new ClientHandler(socket, onlineUsers)); } } }accept()是阻塞方法每次有客户端连上来才返回一个已建立连接的Socket然后把后续处理抛给线程池里的线程主循环立刻回去继续等待下一个连接。这样服务端可以同时处理成千上万个连接但每个连接独自占用一条线程。PORT可以自己改要注意1024以下的端口在某些系统上需要管理员权限否则会报Permission denied。3.2 客户端接入处理ClientHandler 与用户上线ClientHandler是单个连接的全权代理负责登录校验、读消息、转发、退出清理。它接收Socket和共享的onlineUsers Maprun方法启动后先读取第一行登录协议public class ClientHandler implements Runnable { private final Socket socket; private final ConcurrentHashMapString, Socket onlineUsers; private BufferedReader in; private PrintWriter out; private String username; public ClientHandler(Socket socket, ConcurrentHashMapString, Socket onlineUsers) { this.socket socket; this.onlineUsers onlineUsers; } Override public void run() { try { in new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); String line in.readLine(); if (line null || !line.startsWith(LOGIN|)) { socket.close(); return; } String[] parts line.split(\\|); username parts[1]; if (onlineUsers.containsKey(username)) { out.println(ERROR|用户名已存在); socket.close(); return; } onlineUsers.put(username, socket); System.out.println(username 上线当前在线 onlineUsers.size()); while ((line in.readLine()) ! null) { System.out.println(收到来自 username 的消息 line); MessageDispatcher.dispatch(line, onlineUsers, username); } } catch (IOException e) { e.printStackTrace(); } finally { if (username ! null) { onlineUsers.remove(username); } try { socket.close(); } catch (IOException ignored) {} } } }注意两个细节第一PrintWriter构造函数的第二个参数autoFlush必须设为true不然println()的字符串不会立刻发出去而是攒在缓冲区里客户端半天收不到消息。第二finally里一定要把用户名从map中移除否则客户端直接强退时服务端永远不知道它走了别人再登录同一个用户名会一直被提示“已存在”。3.3 消息转发MessageDispatcher 的分发逻辑MessageDispatcher做的事情很纯粹根据协议类型把消息转发给目标客户端同时落库。由于多个线程都会调用这个方法所以内部对每个Socket的输出流做了串行化public class MessageDispatcher { public static void dispatch(String message, ConcurrentHashMapString, Socket onlineUsers, String from) throws IOException { String[] parts message.split(\\|); String type parts[0]; String to parts[2]; if (LOGOUT.equals(type)) { return; } if (all.equals(to)) { for (Socket s : onlineUsers.values()) { PrintWriter writer new PrintWriter(new OutputStreamWriter(s.getOutputStream(), UTF-8), true); writer.println(message); } } else { Socket target onlineUsers.get(to); if (target ! null) { PrintWriter writer new PrintWriter(new OutputStreamWriter(target.getOutputStream(), UTF-8), true); writer.println(message); } else { MessageDao.saveOfflineMessage(from, to, parts[3]); } } MessageDao.saveMessage(from, to, parts[3]); } }这里我为了演示逻辑简洁每次转发都临时new一个PrintWriter连接少时没毛病一旦并发上来就是性能瓶颈。更好的做法是在ClientHandler里把out存成成员变量dispatch时通过Socket反查对应的writer。这个优化思路我留到二次开发章节展开。LOGOUT类型直接返回不落库也不用转发因为退出动作只需要清理在线状态。3.4 消息持久化使用 PreparedStatement 写入 MySQL落库逻辑单独抽在MessageDao里用PreparedStatement防止拼接SQL导致注入public class MessageDao { public static void saveMessage(String from, String to, String content) { String sql INSERT INTO messages(from_user, to_user, content, send_time) VALUES (?, ?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, from); ps.setString(2, to); ps.setString(3, content); ps.setTimestamp(4, new Timestamp(System.currentTimeMillis())); ps.executeUpdate(); } catch (Exception e) { e.printStackTrace(); } } }try-with-resources会保证连接、PreparedStatement都自动关闭但连接底层如果每次都是新建物理连接聊天室消息一多就会把MySQL连接数打满。第5章我们会把这个实现换成连接池版本。saveOfflineMessage实现类似只是写入的目标表是offline_messages在离线消息模块里再细说。3.5 客户端实现发送线程与接收线程分离客户端如果只有一个线程必趴在System.in读键盘服务端广播的消息全堵在缓冲区里没人看。所以必须拆成两个线程主线程读控制台并发送接收线程阻塞读Socket并打印public class ChatClient { public static void main(String[] args) throws IOException { Socket socket new Socket(127.0.0.1, 8888); new Thread(new ReceiveThread(socket)).start(); BufferedReader console new BufferedReader(new InputStreamReader(System.in, UTF-8)); PrintWriter out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); String line; while ((line console.readLine()) ! null) { out.println(line); } } }接收线程长这样public class ReceiveThread implements Runnable { private final Socket socket; public ReceiveThread(Socket socket) { this.socket socket; } Override public void run() { try (BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8))) { String msg; while ((msg in.readLine()) ! null) { System.out.println(msg); } } catch (IOException e) { e.printStackTrace(); } } }主线程每输入一行就是一条协议消息接收线程只管把服务端吐出来的内容原样打印。两个线程之间没有共享变量所以不需要加锁。这也是Socket客户端最标准的写法。4. 项目运行与调试从源码到可运行jar包4.1 环境准备JDK 8、MySQL 5.7、Maven 与 IDE基础环境就三样JDK 8或更高、MySQL 5.7或8.0、Maven 3.6。如果不想用Maven直接建一个普通Java工程把源码粘贴进去再手动引入mysql-connector-java的jar包也能跑。pom.xml里的核心依赖只有一个dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency注意驱动版本要跟MySQL版本匹配。MySQL 8.0配8.x驱动没问题如果用的是MySQL 5.7建议换成5.1.49否则可能报Public Key Retrieval is not allowed这种连接错误。4.2 初始化数据库执行建库建表脚本sql/init.sql里放了全套建库建表语句命令行执行mysql -uroot -p sql/init.sql执行后检查一下mysql -uroot -p chatroom -e show tables;看到users和messages两张表就说明建表成功。如果之前跑过旧版脚本建议先DROP DATABASE chatroom再重新导入避免字段冲突。4.3 修改数据库连接配置DBUtil里目前是硬编码连接信息拿到项目后第一步必改这里是自己的MySQL账号密码public class DBUtil { private static final String URL jdbc:mysql://127.0.0.1:3306/chatroom?useUnicodetruecharacterEncodingutf8mb4; private static final String USER root; private static final String PASSWORD 123456; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }如果连接失败先ping 127.0.0.1确认数据库活着再检查URL里chatroom库名是否拼错。很多翻车都是这里多了个空格或者密码没改。4.4 启动项目先启动服务端再启动多个客户端用Maven打包并启动服务端mvn clean package -DskipTests java -cp target/chatroom-1.0-SNAPSHOT.jar com.chat.server.ChatServer看到“服务端已启动监听端口 8888”就说明监听成功。再另外开两个终端分别启动客户端java -cp target/chatroom-1.0-SNAPSHOT.jar com.chat.client.ChatClient客户端启动后不会立刻连上不是的new Socket(127.0.0.1, 8888)会在构造时建立TCP连接。连接成功后先在客户端输入一行登录协议LOGIN|zhangsan|all|hi|2025-06-01 12:00:00。再在另一个客户端登录LOGIN|lisi|all|hi|2025-06-01 12:00:01然后发一条MSG|zhangsan|all|大家好|2025-06-01 12:00:02两个客户端都应该能收到。4.5 调试与验证查看日志和消息记录服务端在连接、上线、收到消息三个节点都打了日志这是最直接的观察窗口。验证分三步第一服务端控制台有没有“新连接”第二客户端能不能收到广播消息第三查数据库里的messages表SELECT * FROM chatroom.messages ORDER BY id DESC LIMIT 10;如果客户端收不到消息首先ping服务端IP再看端口通不通telnet 127.0.0.1 8888。端口不通就回到防火墙规则端口通但不收发数据多半是编码或协议解析问题。这一步排查完运行链路基本就通了。5. 避坑手册Socket编程的常见翻车点与排查思路5.1 客户端连不上服务器Connection refused现象客户端刚启动就抛java.net.ConnectException: Connection refused服务端控制台也没任何反应。原因绝大多数情况是服务端没启动成功或者监听端口被占用后进程直接退出。还有一种是服务端绑定了127.0.0.1客户端却拿局域网IP去连。解决先确认服务端进程在不在ps -ef | grep ChatServer再用netstat -an | grep 8888看端口有没有LISTEN状态最后用telnet 127.0.0.1 8888实测。如果本机能连、远程不能连那问题出在防火墙或服务端绑定地址我一般直接把ServerSocket的IP参数设为空让它绑定到所有网卡。5.2 客户端异常掉线服务端线程一直挂着现象客户端直接强杀进程或拔网线服务端控制台没有任何异常在线用户列表也不减少线程越积越多最后服务器卡死。原因TCP协议在物理断开时不会主动通知对端服务端线程阻塞在readLine()上永远等不到数据。解决给Socket设置读超时并配合心跳机制。我在服务端执行socket.setSoTimeout(60000)客户端每30秒发一条PING|username|all|heartbeat|时间服务端在socketTimeoutException时检查上次心跳时间超过90秒没收到就关闭连接。这里要特别注意SocketTimeoutException不是普通IO错误不能在catch里直接打印栈后就继续循环否则会进入死循环日志轰炸。5.3 中文消息乱码现象客户端发“你好”服务端收到????或者另一个客户端显示一堆乱码。原因字节流变字符流时没指定编码Java默认按操作系统的字符集解码。Windows默认GBK而我的协议和数据库都按UTF-8处理两边编码对不上就出现乱码。解决所有从Socket拿输入流的地方都写成new InputStreamReader(socket.getInputStream(), UTF-8)输出流同理。数据库连接URL里也带characterEncodingutf8mb4。只要有一个地方落下了整条链路的编码就不可信。从那以后我每个Socket项目都会在启动时打印默认字符集防止IDE把全局编码改掉以后查半天。5.4 广播消息丢失偶发只发给了部分用户现象多个客户端同时在线某个客户端发一条广播有的客户端收到有的客户端一直收不到。原因messageDispatcher里每次转发都新建PrintWriter多个线程并发写同一个Socket的流时会产生半包或交错甚至报SocketException: Broken pipe。解决把每个客户端连接对应的PrintWriter保存下来在ClientHandler里定义一个成员变量所有发送都通过这个writer并外加synchronized锁public void sendMessage(String msg) { synchronized (out) { out.println(msg); } }这样脏写问题就消失了。锁粒度控制在单个Socket上不会阻塞其他客户端之间的通信。5.5 数据库连接池耗尽现象运行一段时间后服务端控制台报Too many connections消息保存越来越慢最后MySQL服务器拒绝所有新连接。原因DBUtil每次getConnection()都是新建物理连接用完代码里虽然关了但连接池没有复用高并发下瞬间创建上百个连接。解决引入HikariCP把连接管理交给连接池。改造后的DBUtil核心代码private static HikariDataSource dataSource; static { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://127.0.0.1:3306/chatroom?useUnicodetruecharacterEncodingutf8mb4); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(10); config.setIdleTimeout(30000); dataSource new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); }最大连接数设为10聊天室这个并发量完全够用连接用完归还不会再出现爆库。6. 二次开发私聊、离线消息与心跳机制扩展6.1 从广播到私聊扩展消息类型现在的协议已经预留了to字段所以私聊做起来非常顺客户端的输入格式里填对方用户名服务端在MessageDispatcher里判断to不是all就走私聊分支直接查onlineUsers定向发送。发送前记得同步加锁避免两个线程同时写同一个客户端Socket。历史记录查询可以用一条SQL搞定SELECT * FROM messages WHERE (from_user? AND to_user?) OR (from_user? AND to_user?) ORDER BY send_time;6.2 离线消息给未上线的用户补发服务端发现目标不在线时已经调用了saveOfflineMessage需要补一张offline_messages表。用户登录成功后查一次ListOfflineMessage offlineList dao.findOffline(username); for (OfflineMessage m : offlineList) { sendMessage(MSG| m.getFromUser() | username | m.getContent() | m.getSendTime()); dao.deleteOffline(m.getId()); }注意要先把列表查出来再逐条发送不要在遍历的时候执行删除否则会漏掉部分消息。6.3 心跳机制服务端定时清理僵尸连接在ClientHandler里维护一个lastHeartbeat字段收到任何消息都刷新它。服务端单独起一个清理线程每30秒扫描所有在线用户的最后心跳时间超过90秒的直接关闭Socket。清理时不能直接ConcurrentHashMap.remove要先收集要移除的用户名列表再统一处理否则会触发并发修改异常。这个项目从无到有踩了不少坑。最初我为了省事一个客户端一个线程每次读写都现new流结果并发一高就崩。后来强制规定所有Socket I/O统一UTF-8转发统一加锁数据库统一走连接池才从玄学变稳定。从那以后我每次写Socket项目都会先在本地模拟断线重连和并发广播希望帮到你。本文还有配套的精品资源点击获取