简介:基于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(),原因是聊天室连接数量不稳定,但单个连接空闲时线程可以回收,相比固定大小线程池更省资源。
在线用户列表我用ConcurrentHashMap<String, 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 ConcurrentHashMap<String, 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 Map,run方法启动后先读取第一行登录协议:
public class ClientHandler implements Runnable { private final Socket socket; private final ConcurrentHashMap<String, Socket> onlineUsers; private BufferedReader in; private PrintWriter out; private String username; public ClientHandler(Socket socket, ConcurrentHashMap<String, 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, ConcurrentHashMap<String, 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> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.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?useUnicode=true&characterEncoding=utf8mb4"; 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里也带characterEncoding=utf8mb4。只要有一个地方落下了,整条链路的编码就不可信。从那以后,我每个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?useUnicode=true&characterEncoding=utf8mb4"); 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表。用户登录成功后查一次:
List<OfflineMessage> 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项目都会先在本地模拟断线重连和并发广播,希望帮到你。
本文还有配套的精品资源,点击获取