简介:本资源是一个基于Java实现的仿QQ即时通讯系统完整项目,面向Java初学者与GUI/网络编程学习者,聚焦于多线程聊天、Socket通信、Swing界面开发及基础数据库交互等核心实践能力训练。项目涵盖用户登录、好友管理、私聊与群聊、表情消息、状态同步等典型功能模块,代码结构清晰,含20个.class字节码文件、10个.java源文件、1个MySQL建表脚本(qq.sql)及1个JDBC驱动jar包,配合27个GIF表情资源与23张界面截图(如登录页、主面板、聊天窗口等),直观呈现UI设计逻辑;压缩包共85个文件,大小仅1MB,轻量易部署。目前已有196人学习下载,适合用于课程设计、毕业设计参考或Java综合实训项目复现——可直接运行调试,快速理解客户端-服务器通信机制、事件驱动响应流程与分层架构组织方式。
1. 这不是玩具 Demo:一个能跑通私聊+群聊+消息持久化的 Java 桌面 IM 实战项目
你手头这个Java-QQ.zip,不是网上泛滥的「Swing 做个登录框就叫仿 QQ」的半成品。它真正在本地跑起来后,能完成:用户登录 → 好友列表加载 → 点击私聊弹窗 → 发送文字/表情/图片 → 消息实时回显 → 群聊创建与加入 → 多人消息广播 → 所有聊天记录写入 MySQL 并在重启后自动恢复——整套链路闭环,且代码结构清晰、分层明确(server/dao/entity/util/gui),连图标资源(.gif/.jpg)都按功能归类好了。它不依赖 Spring Boot 或任何现代框架,纯 JDK 8 + JDBC + Socket + Swing 实现,适合想夯实 Java 网络编程、多线程协同、GUI 事件驱动和数据库 CRUD 四大硬核能力的中级开发者。如果你正被「学了 Java 却写不出完整项目」卡住,或者面试前急需一个能讲清技术选型、线程分工、消息序列化策略的实战案例,这个包就是你该拆的第一份「血肉级」源码——它没用 Netty,没上 Redis,但每行代码都在回答「为什么这里必须用 synchronized?」「为什么 DAO 层要单独抽 interface?」「为什么 GUI 更新必须走 EventQueue.invokeLater?」。
2. 从解压到启动:环境准备与核心模块定位
2.1 JDK 版本与 IDE 兼容性确认:别让 .classpath 拖垮你的第一次运行
这个项目.classpath文件里明确写着:
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.6"/>但实际运行时你会发现:JDK 1.6 启动会报Unsupported major.minor version 52.0。原因很实在——项目里混用了 JDK 7+ 编译的mysql-connector-java-5.1.15-bin.jar(该 jar 编译于 JDK 6,但部分 class 已升级)。
正确做法是:统一用 JDK 8u202 或更高版本(推荐 8u333),并手动修正.project中的 build path:
提示:Eclipse 导入时若提示「JRE System Library is not compatible」,右键项目 → Properties → Java Build Path → Libraries → Remove
JRE System Library→ Add Library → JRE System Library → Execution environment →JavaSE-1.8→ Finish。
验证是否成功:
java -version # 必须输出 1.8.x javac -version # 同步验证2.2 数据库初始化:qq.sql不是摆设,它定义了整个消息生命周期
项目根目录下的qq.sql是真实可用的建表脚本,不是示意代码。它包含 4 张核心表:
user:存储用户名、密码(明文,仅用于教学,生产需加盐哈希)friend:记录好友关系(user_id,friend_id,group_name)group_info:群基本信息(group_id,group_name,creator_id)message_record:最关键——所有私聊/群聊消息落库字段为(id, sender_id, receiver_id, group_id, msg_content, msg_type, send_time),其中receiver_id和group_id互斥(私聊填前者,群聊填后者),msg_type区分文本/表情/图片(值为 0/1/2)。
执行步骤:
-- 在 MySQL 5.7+ 创建数据库 CREATE DATABASE qq_im CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 导入脚本(注意路径) source /path/to/your/Java-QQ/qq.sql;注意:
qq.sql中message_record.send_time类型为DATETIME,但 Java 代码里用的是new Date(),需确保 JDBC URL 加上serverTimezone=GMT%2B8,否则时间戳会偏移 8 小时。修改dao/DBUtil.java中的连接字符串:private static final String URL = "jdbc:mysql://localhost:3306/qq_im?useSSL=false&serverTimezone=GMT%2B8&characterEncoding=utf8";
2.3 启动顺序与进程分工:Server 和 Client 必须严格分离
这个项目采用C/S 架构,非 P2P。启动流程不可颠倒:
- 先启动 Server 端:运行
src/server/ServerMain.java(主类),控制台输出服务器已启动,等待客户端连接...即成功; - 再启动 Client 端:运行
src/gui/LoginFrame.java(注意不是Main.java!项目无统一入口,GUI 启动点在登录窗); - Server 必须常驻:关闭 Server 后 Client 仍可操作 UI,但点击发送按钮会抛
java.net.SocketException: Connection reset—— 这是设计使然,不是 Bug。
关键逻辑在server/ServerThread.java:每个 Client 连接由独立线程处理,run()方法内循环读取ObjectInputStream,根据消息类型(MessageType.LOGIN/MessageType.PRIVATE_CHAT/MessageType.GROUP_CHAT)分发到对应 handler。这不是简单 echo server,而是带状态管理的真实服务端。
3. 私聊与群聊的消息流转:从点击按钮到数据库落盘的全链路解析
3.1 私聊消息:双线程 + 双队列 + GUI 安全线程更新
当你在好友列表双击张三,触发FriendListPanel.java的mouseClicked事件:
// FriendListPanel.java 第 127 行 private void openChatWindow(User user) { ChatWindow chatWindow = new ChatWindow(currentUser, user); // 创建新窗口 chatWindow.setVisible(true); chatWindow.startReceiveThread(); // 启动接收线程 }此时ChatWindow构造器中做了三件事:
- 初始化
JTextArea显示区(只读); - 绑定发送按钮
ActionListener,调用sendMessage(); - 关键:
startReceiveThread()启动一个Thread,持续监听ObjectInputStream(来自 Server 的响应流)。
sendMessage()流程:
- 获取输入框文本 → 封装为
Message对象(含senderId,receiverId,content,type=0); - 调用
clientSocket.getOutputStream().writeObject(msg)发送给 Server; - 立即本地追加到聊天框(
chatArea.append(...)),实现「发送即显示」;
Server 收到后,查receiverId对应的在线 socket,writeObject()推送消息。Client 接收线程捕获后,必须用SwingUtilities.invokeLater()更新 UI:
// ChatWindow.java 第 215 行 SwingUtilities.invokeLater(() -> { chatArea.append("[对方] " + msg.getContent() + "\n"); chatArea.setCaretPosition(chatArea.getDocument().getLength()); });为什么必须
invokeLater?因为 Swing 组件非线程安全,直接在接收线程调用append()会导致IllegalStateException或 UI 冻结。这是 Java GUI 开发的铁律,也是本项目唯一一处显式使用SwingUtilities的地方——它暴露了作者对线程模型的真实理解。
3.2 群聊消息:广播机制与群成员状态同步
群聊入口在MainFrame.java的「创建群聊」按钮:
// MainFrame.java 第 189 行 createGroupBtn.addActionListener(e -> { String groupName = JOptionPane.showInputDialog("请输入群名称:"); if (groupName != null && !groupName.trim().isEmpty()) { Message msg = new Message(); msg.setType(MessageType.CREATE_GROUP); msg.setSender(currentUser.getId()); msg.setContent(groupName); try { clientSocket.getOutputStream().writeObject(msg); } catch (IOException ex) { ex.printStackTrace(); } } });Server 端GroupHandler.java处理CREATE_GROUP:
- 插入
group_info表; - 返回
GroupInfo对象给创建者; - 不主动推送给其他人——群聊成员需手动「加入群聊」。
真正广播发生在GROUP_CHAT类型消息:
- Client 发送消息时,
Message.groupId非空,receiverId为 0; - Server 查询
group_member表(项目未显式建此表,但dao/GroupDao.java有getGroupMembers(groupId)方法,实际查friend表中group_name匹配的记录); - 遍历每个成员 socket,
writeObject()推送(注意:不是 multicast,是单播循环)。
关键细节:群消息在
message_record表中receiver_id=0,group_id=xxx,查询历史时WHERE group_id = ?。这比用receiver_id存群 ID 更规范,避免 ID 冲突。
3.3 消息持久化:DAO 层如何保证「发一条存一条」不丢不重
所有消息写库动作集中在dao/MessageDao.java:
public boolean saveMessage(Message message) { String sql = "INSERT INTO message_record (sender_id, receiver_id, group_id, msg_content, msg_type, send_time) VALUES (?, ?, ?, ?, ?, ?)"; return executeUpdate(sql, message.getSenderId(), message.getReceiverId(), message.getGroupId(), message.getContent(), message.getType(), new Timestamp(message.getSendTime().getTime())) > 0; }但注意:这个方法被调用的位置只有两处:
ServerThread.java处理完私聊/群聊消息后,messageDao.saveMessage(msg);LoginFrame.java登录成功后,loadHistory()加载历史消息(只读,不写)。
也就是说:消息只在 Server 端落库,Client 不写库。这是合理设计——避免多客户端并发写导致脏数据。Server 收到消息 → 校验 → 广播 → 落库,原子性由 JDBC 事务保障(虽然项目没显式开启 transaction,但单条 INSERT 默认 auto-commit)。
4. 避坑:那些让你调试到凌晨三点的真实问题与解法
4.1 现象:登录成功后好友列表为空,但数据库friend表明明有数据
原因:dao/FriendDao.java的loadFriends(int userId)方法中 SQL 拼接错误:
// 错误写法(原文本) String sql = "SELECT * FROM friend WHERE user_id = " + userId; // 没加引号!当userId=1时生成WHERE user_id = 1正确,但若user_id是字符串(如U001),则 SQL 变成WHERE user_id = U001,MySQL 报错Unknown column 'U001' in 'where clause',DAO 返回空集合。
解决:改为预编译参数:
String sql = "SELECT * FROM friend WHERE user_id = ?"; return executeQuery(sql, userId);4.2 现象:发送表情后对方看到乱码或空白,但文字消息正常
原因:表情资源路径硬编码在gui/ChatWindow.java:
// 第 342 行(错误) ImageIcon icon = new ImageIcon("images/" + fileName);而项目中表情文件名含中文(如消息记录.JPG),Windows 系统默认 GBK 编码读取路径失败。
解决:改用ClassLoader获取资源流:
URL imgUrl = getClass().getClassLoader().getResource("images/" + fileName); if (imgUrl != null) { ImageIcon icon = new ImageIcon(imgUrl); // ...后续处理 }4.3 现象:群聊消息发送后,部分成员收不到,重启 Server 才恢复
原因:ServerThread.java中群成员 socket 存储在ArrayList<Socket>,但未做线程安全保护。当 A 用户退出群聊(removeFromGroup)时,遍历 list 移除 socket,同时 B 用户正发消息遍历同一 list ——ConcurrentModificationException导致广播中断。
解决:将ArrayList替换为CopyOnWriteArrayList,并在GroupHandler.java中声明:
private static CopyOnWriteArrayList<Socket> groupSockets = new CopyOnWriteArrayList<>();血泪经验:
CopyOnWriteArrayList适合读多写少场景,群成员变动频率低,但消息广播高频,这是最优解。别用synchronized(list),会锁死整个广播流程。
4.4 现象:MySQL 连接频繁断开,日志报Communications link failure
原因:DBUtil.java的getConnection()每次都新建连接,且未关闭。项目中大量 DAO 方法调用后未显式close(),连接池耗尽。
解决:在DBUtil.java添加连接池(轻量级 HikariCP):
// 新增静态变量 private static HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/qq_im?..."); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); dataSource = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); }然后所有executeQuery/executeUpdate方法末尾加finally { conn.close(); }。
4.5 现象:双击好友头像无反应,控制台无报错
原因:FriendListPanel.java的MouseListener绑定在JPanel上,但JLabel(头像)覆盖了点击区域,事件被 JLabel 拦截。
解决:给每个头像 JLabel 设置setOpaque(false)和setContentAreaFilled(false),并添加addMouseListener到 JLabel 本身:
JLabel avatarLabel = new JLabel(new ImageIcon(avatarPath)); avatarLabel.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { openChatWindow(friendUser); } });5. 表情与图片消息:二进制传输的底层实现与边界处理
5.1 表情消息:不是 Base64 字符串,而是序列化后的 File 对象
项目中表情发送逻辑藏在ChatWindow.java的sendEmotion()方法:
private void sendEmotion(String emotionFileName) { try { File file = new File("images/" + emotionFileName); FileInputStream fis = new FileInputStream(file); byte[] data = new byte[(int) file.length()]; fis.read(data); fis.close(); Message msg = new Message(); msg.setType(MessageType.EMOTION); msg.setSenderId(currentUser.getId()); msg.setReceiverId(targetUser.getId()); msg.setContent(emotionFileName); // 仅传文件名! msg.setBinaryData(data); // 关键:二进制数据存这里 clientSocket.getOutputStream().writeObject(msg); } catch (Exception e) { e.printStackTrace(); } }重点:msg.setBinaryData(data)将字节数组存入Message对象的byte[] binaryData字段,而Message类实现了Serializable。这意味着:
- Server 收到
Message对象后,msg.getBinaryData()可直接获取字节数组; - 但 Server不保存二进制数据到数据库(
message_record.msg_content只存文件名),而是将binaryData透传给接收方; - 接收方
ChatWindow的receiveMessage()方法中,根据msg.getType() == MessageType.EMOTION,用msg.getBinaryData()创建ImageIcon并插入聊天框。
这种设计节省数据库空间(不用存 blob),但要求所有客户端
images/目录下必须有同名文件。生产环境应改为 CDN URL 或数据库 blob 存储。
5.2 图片消息:FileInputStream的陷阱与内存溢出防护
图片发送复用同一套逻辑,但FileInputStream读取大图(>5MB)时会 OOM。原代码无校验:
File file = new File("images/" + fileName); byte[] data = new byte[(int) file.length()]; // 危险! fis.read(data);改进方案:添加大小限制与分块读取:
long fileSize = file.length(); if (fileSize > 5 * 1024 * 1024) { // 5MB 限制 JOptionPane.showMessageDialog(this, "图片过大,请选择小于5MB的文件"); return; } ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int len; while ((len = fis.read(buffer)) != -1) { baos.write(buffer, 0, len); } byte[] data = baos.toByteArray();5.3 消息类型枚举:MessageType的扩展性设计与反序列化安全
entity/MessageType.java定义了 7 种类型:
| 类型 | 值 | 用途 |
|---|---|---|
LOGIN | 1 | 用户登录认证 |
LOGOUT | 2 | 用户登出通知 |
PRIVATE_CHAT | 3 | 私聊消息 |
GROUP_CHAT | 4 | 群聊消息 |
CREATE_GROUP | 5 | 创建群聊请求 |
EMOTION | 6 | 表情消息(含 binaryData) |
FILE_TRANSFER | 7 | 文件传输(预留,未实现) |
关键设计:Message类的readObject()方法中,defaultReadObject()后立即校验type是否在合法范围内:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); if (type < 1 || type > 7) { throw new InvalidClassException("Invalid message type: " + type); } }这是反序列化安全的最小实践。如果没有此校验,攻击者可构造恶意
type=999触发后续 switch-case 的default分支,造成未定义行为。Java 序列化漏洞频发,这种白名单校验是低成本高收益的防御。
6. 从「能跑」到「能讲」:用三个验证技巧把项目变成你的技术名片
6.1 验证消息一致性:用 Wireshark 抓包对比 Socket 流与数据库记录
很多开发者只测 UI,却不知消息是否真被 Server 处理。最硬核的验证方式是抓包:
- 启动 Server 和两个 Client(A/B);
- 在 A 的聊天窗发送「测试123」;
- 立即打开 Wireshark,过滤
tcp.port==8888(Server 默认端口); - 查看 TCP Stream,确认 A 发送的
ObjectStream中msg_content="测试123"; - 同时查 MySQL:
SELECT * FROM message_record WHERE msg_content LIKE '%测试123%';- 若 Wireshark 有、DB 没有 → Server 落库失败(检查
MessageDao.saveMessage()是否被调用); - 若 DB 有、Wireshark 没有 → Client 未真正发送(检查
clientSocket.getOutputStream().writeObject()是否执行); - 两者都有但内容不一致 → 序列化/反序列化出错(检查
Message类字段是否transient或static)。
- 若 Wireshark 有、DB 没有 → Server 落库失败(检查
我从那以后每次重构网络模块,都强制走一遍 Wireshark + DB 对照。它比断点调试更接近真相——因为你能看到字节流本身,而不是 JVM 解析后的对象。
6.2 验证线程安全性:用 jstack 分析 ServerThread 的锁竞争
当群聊人数 >50 时,Server 可能变慢。用jstack查看线程状态:
# 查找 Server 进程 PID jps -l | grep ServerMain # 导出线程栈 jstack <PID> > server_threads.txt重点关注ServerThread线程是否处于BLOCKED状态。常见瓶颈点:
GroupHandler.getGroupMembers()查询数据库未加索引 → 在friend.group_name字段建索引;MessageDao.saveMessage()的 JDBC 连接未池化 → 如前文所述引入 HikariCP;ServerThread.run()中synchronized(socket)块过长 → 将消息解析、业务处理、落库拆分为异步任务。
6.3 验证 GUI 响应性:用 VisualVM 监控 Event Dispatch Thread
Swing 卡顿往往因 EDT(Event Dispatch Thread)被阻塞。启动 VisualVM,连接 Client 进程:
- 切换到「Sampler」→ 「CPU」→ 「Record」;
- 在聊天窗疯狂点击发送按钮;
- 停止采样后,查看热点方法:若
ChatWindow.sendMessage()占比 >80%,说明业务逻辑(如 IO)在 EDT 中执行; - 修复:将
clientSocket.getOutputStream().writeObject(msg)移到新线程:
new Thread(() -> { try { clientSocket.getOutputStream().writeObject(msg); } catch (IOException e) { e.printStackTrace(); } }).start();从那以后我每次写 Swing 项目,都强制在
ActionListener里加一行System.out.println("EDT: " + EventQueue.isDispatchThread());。如果输出 false,立刻重构——因为 GUI 线程不是你的试验田,它是用户眼中的「世界是否卡住」的唯一标尺。
希望帮到你。
本文还有配套的精品资源,点击获取