
简介这是一套面向Java初学者与进阶开发者的仿QQ即时通讯项目源码适合用于课程设计、毕业设计或网络编程练手。项目围绕登录、好友管理、私聊、群聊、分组管理等核心模块展开综合运用Swing界面、Socket网络通信、多线程、序列化、JDBC数据库操作与事件驱动编程等技术帮助读者理解即时通讯软件的完整实现思路。压缩包共85个文件约1MB包含10个java源文件、20个class编译文件、1个jar依赖包、1个sql建库脚本以及27个gif和23个jpg界面素材覆盖服务端、dao、entity、util、gui等分层目录结构清晰便于按模块阅读。目前已有195人学习下载。通过研读源码读者可掌握客户端与服务端的数据交互流程、聊天窗口的线程处理方式、好友与群组的数据结构设计以及基于MySQL的用户信息存储方案是理解Java网络编程与GUI整合的实用参考案例。1. 从 Java-QQ.zip 说起一个仿 QQ 的私聊群聊系统到底该怎么落地很多人第一次看到「Java-QQ.zip」这类命名脑子里浮现的是「下载下来跑一下就能用」的成品。实际情况是这类仿 QQ 的 Java 项目核心价值不在界面像不像而在于它把即时通讯里最基础的两条链路——私聊和群聊——用 Java 网络编程完整走了一遍。私聊考验的是点对点消息路由和会话状态维护群聊考验的是消息广播、成员管理和并发写入。这两件事做通了后面加文件传输、离线消息、消息漫游才有地基。适合谁看适合已经会 Java 基础语法、想找一个能跑通 Socket 或 Netty 的实战项目练手的人也适合面试前想拿一个「能讲清楚消息怎么从 A 到 B」的项目撑场面的 Java 开发工程师。下面按「先跑通最小链路再补并发和持久化最后排坑」的顺序拆。2. 仿 QQ 私聊与群聊的最小可运行链路从 Socket 到消息路由2.1 为什么先用阻塞 IO 把私聊跑通而不是直接上 Netty仿 QQ 这类项目最容易翻车的地方是一上来就引入 Netty、Protobuf、Redis 一整套结果环境没配好连「两个客户端能互相发一句话」都没验证过。我一般会先用ServerSocket 多线程把私聊链路跑通确认消息能按userId准确投递再考虑换框架。阻塞 IO 的模型很直白服务端为每个连接开一个线程线程里读消息解析出目标用户 ID从在线用户表里找到对方的输出流写回去。这个模型在几十个并发连接下完全够用而且出问题时你能一眼看出是读阻塞还是写阻塞。最小私聊服务端代码结构如下关键在onlineUsers这个ConcurrentHashMap它决定了消息能不能找到人// 服务端维护在线用户与输出流的映射 public class ChatServer { // key 是用户 IDvalue 是该用户连接的输出流 private static final MapString, PrintWriter onlineUsers new ConcurrentHashMap(); public static void main(String[] args) throws IOException { ServerSocket server new ServerSocket(8888); System.out.println(仿QQ服务端启动端口 8888); while (true) { Socket socket server.accept(); new Thread(new ClientHandler(socket)).start(); } } // 每个连接一个处理线程 static class ClientHandler implements Runnable { private Socket socket; private String userId; private PrintWriter out; ClientHandler(Socket socket) { this.socket socket; } public void run() { try { BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); // 第一条消息约定为登录LOGIN|userId String login in.readLine(); if (login ! null login.startsWith(LOGIN|)) { userId login.split(\\|)[1]; onlineUsers.put(userId, out); out.println(LOGIN_OK); } String msg; while ((msg in.readLine()) ! null) { // 私聊格式PRIVATE|targetId|content if (msg.startsWith(PRIVATE|)) { String[] parts msg.split(\\|, 3); PrintWriter target onlineUsers.get(parts[1]); if (target ! null) { target.println(FROM| userId | parts[2]); } else { out.println(ERROR|用户不在线); } } } } catch (IOException e) { e.printStackTrace(); } finally { if (userId ! null) onlineUsers.remove(userId); try { socket.close(); } catch (IOException ignored) {} } } } }这段代码的逻辑说明onlineUsers用ConcurrentHashMap是因为多个线程会同时读写普通HashMap在并发put时可能丢数据甚至死循环。登录消息用LOGIN|userId作为第一条协议是为了在连接建立后立刻绑定身份后续消息才能路由。私聊消息用PRIVATE|targetId|content三段式split时限制为 3 段防止消息内容里本身带|被切碎。参数上端口 8888 可以改但客户端要同步编码统一用 UTF-8否则中文会变问号这是血泪经验。客户端侧只需要两个线程一个负责读服务端推送一个负责发消息。读线程必须独立否则你发消息时收不到别人发来的消息。// 客户端读线程与写线程分离 public class ChatClient { public static void main(String[] args) throws IOException { Socket socket new Socket(127.0.0.1, 8888); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), UTF-8), true); // 登录 out.println(LOGIN|userA); // 读线程持续接收服务端推送 new Thread(() - { try { String line; while ((line in.readLine()) ! null) { System.out.println(收到: line); } } catch (IOException e) { e.printStackTrace(); } }).start(); // 主线程从控制台读输入并发送 BufferedReader console new BufferedReader(new InputStreamReader(System.in)); String input; while ((input console.readLine()) ! null) { out.println(input); // 输入 PRIVATE|userB|你好 } } }逻辑说明读线程用 lambda 起一个独立线程避免主线程阻塞在readLine上导致无法发送。发送格式由控制台直接输入方便调试。参数上PrintWriter的第二个参数true表示自动 flush少了它消息会卡在缓冲区里发不出去这是新手最常见的翻车点。2.2 群聊广播从遍历在线表到群成员快照私聊跑通后群聊的本质是「一次写入多次读出」。最朴素的做法是遍历onlineUsers给每个人发一遍但这样有两个问题一是群成员不应该等于所有在线用户二是遍历时如果有人掉线会抛异常。正确做法是维护一个groupId - SetuserId的群成员表发群消息时先取成员快照再逐个投递。// 群聊群成员表与广播 private static final MapString, SetString groupMembers new ConcurrentHashMap(); // 创建群CREATE_GROUP|groupId|userA,userB,userC if (msg.startsWith(CREATE_GROUP|)) { String[] parts msg.split(\\|, 3); SetString members ConcurrentHashMap.newKeySet(); for (String u : parts[2].split(,)) members.add(u); groupMembers.put(parts[1], members); out.println(GROUP_OK| parts[1]); } // 群聊消息GROUP|groupId|content if (msg.startsWith(GROUP|)) { String[] parts msg.split(\\|, 3); SetString members groupMembers.get(parts[1]); if (members ! null) { // 取快照再遍历避免遍历时集合被修改 for (String member : new HashSet(members)) { PrintWriter target onlineUsers.get(member); if (target ! null) { target.println(GROUP_MSG| parts[1] | userId | parts[2]); } } } }逻辑说明ConcurrentHashMap.newKeySet()创建的是线程安全的 Set适合并发增删成员。广播前用new HashSet(members)做快照是因为遍历过程中如果有成员加入或退出直接遍历原集合会抛ConcurrentModificationException。参数上群 ID 由客户端生成服务端只做存储和转发这样服务端逻辑更轻。群消息格式里带上groupId和发送者userId客户端才能区分是哪个群、谁发的。3. 把仿 QQ 项目从「能跑」推到「能讲」并发、持久化与协议设计3.1 消息协议怎么定才能让私聊和群聊不打架协议是仿 QQ 项目里最容易被忽视、又最影响扩展性的部分。我见过不少项目用纯文本加空格分隔结果消息内容里有空格就解析错位。比较稳的做法是用竖线分隔并且把「消息类型」放在第一位。私聊、群聊、登录、心跳、文件传输各占一个前缀服务端用startsWith判断类型再按固定段数split。消息类型格式段数说明登录LOGIN|userId2连接后第一条绑定身份私聊PRIVATE|targetId|content3content 可含竖线限制段数为 3群聊GROUP|groupId|content3服务端查群成员后广播建群CREATE_GROUP|groupId|userA,userB3成员用逗号分隔心跳PING1客户端定时发服务端回 PONG这个协议的好处是解析逻辑集中新增消息类型只需加一个分支。注意split的第二个参数一定要写私聊和群聊都写 3这样 content 里的竖线不会被切碎。如果写成split(\\|)用户发一句「a|b|c」就会变成多段解析直接错位。3.2 用线程池替代裸线程避免连接数一多就崩最小版本里每个连接new Thread几十个连接没问题上百个就开始吃内存因为每个线程默认栈大小 1MB。改成线程池后线程复用内存可控。但要注意线程池的队列不能是无界的否则任务堆积到 OOM。// 用固定大小线程池处理连接 private static final ExecutorService pool new ThreadPoolExecutor( 8, // 核心线程数 32, // 最大线程数 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(200), // 有界队列防止 OOM new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行 ); // accept 后提交任务 Socket socket server.accept(); pool.execute(new ClientHandler(socket));逻辑说明核心线程 8 个最大 32 个队列 200这个配置在单机测试环境足够。CallerRunsPolicy表示队列满时让 accept 线程自己执行任务相当于反压避免任务被丢弃。参数上核心线程数可以按 CPU 核数调整但仿 QQ 这种 IO 密集型场景线程数可以比核数多一些。如果连接数长期超过 32说明该考虑 NIO 或 Netty 了但在那之前先把线程池跑稳。3.3 消息持久化什么时候写库写什么表私聊和群聊消息如果只存在内存里服务端一重启就全没了面试时被问「离线消息怎么做」也答不上来。常见做法是用 MySQL 存消息两张表private_msg和group_msg。字段包括id、from_user、to_user或group_id、content、send_time。写入时机是服务端转发成功后异步落库不要阻塞转发链路。-- 私聊消息表 CREATE TABLE private_msg ( id BIGINT AUTO_INCREMENT PRIMARY KEY, from_user VARCHAR(32) NOT NULL, to_user VARCHAR(32) NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_to_user_time (to_user, send_time) ); -- 群聊消息表 CREATE TABLE group_msg ( id BIGINT AUTO_INCREMENT PRIMARY KEY, group_id VARCHAR(32) NOT NULL, from_user VARCHAR(32) NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_group_time (group_id, send_time) );逻辑说明idx_to_user_time索引是为了查「某人最近的私聊消息」按接收者和时间排序。群聊同理。写入用异步线程或消息队列避免数据库慢查询拖垮聊天。参数上content用TEXT而不是VARCHAR(255)因为聊天消息可能很长。send_time用数据库默认时间减少应用层传参。4. 仿 QQ 项目避坑排查那些让私聊群聊集体翻车的细节4.1 现象客户端发消息没反应服务端也没报错原因PrintWriter没有自动 flush消息卡在缓冲区。或者客户端读线程没有独立启动主线程阻塞在readLine上。解决PrintWriter构造时第二个参数传true读线程用new Thread独立跑。检查时可以在服务端readLine后加一行System.out.println(收到: msg)确认消息到底有没有到。4.2 现象群聊消息发出去部分成员收不到原因广播时遍历onlineUsers用的是keySet直接遍历遍历过程中有人下线触发remove导致ConcurrentModificationException异常被吞掉后部分成员没收到。解决广播前先new HashSet(members)做快照再遍历快照。另外成员不在线时不要抛异常静默跳过即可离线消息靠数据库补。4.3 现象中文消息变成问号或乱码原因InputStreamReader和OutputStreamWriter没有指定 UTF-8用了平台默认编码。Windows 默认 GBKLinux 默认 UTF-8跨平台就乱。解决所有流构造时显式传UTF-8包括服务端和客户端。如果已经乱码检查split后的 content 是否被二次编码。4.4 现象服务端跑一段时间后内存暴涨原因onlineUsers里下线用户没有移除或者线程池队列无界导致任务堆积。解决在finally块里onlineUsers.remove(userId)线程池用有界队列加CallerRunsPolicy。另外客户端异常断开时服务端readLine会返回null循环退出后必须清理资源。4.5 现象私聊消息发给了错误的人原因split没有限制段数消息内容里带竖线导致parts[1]不是目标用户 ID。解决split(\\|, 3)并且约定消息内容里不允许出现竖线或者在发送前对 content 做转义。更稳的做法是用 JSON 作为消息体但那样解析成本高仿 QQ 项目里竖线分隔够用。5. 仿 QQ 项目的进阶验证用压力测试和离线消息确认它真的能用最小链路跑通、避坑做完之后怎么确认这个仿 QQ 项目不是「玩具」我一般做两件事一是用脚本模拟多用户并发私聊和群聊看服务端会不会崩二是把离线消息补上验证「A 给 B 发消息B 不在线B 上线后能收到」这条链路。压力测试不用复杂工具用 Java 起 50 个客户端线程每个线程登录不同用户循环发 100 条私聊消息给随机目标统计服务端响应时间和错误数。重点看两个指标有没有ConcurrentModificationException以及内存是否持续增长。如果 50 个连接跑 5000 条消息后内存稳定说明线程池和在线表管理没问题。离线消息的验证更关键。做法是私聊消息转发时如果目标不在线不丢弃而是写入private_msg表并标记delivered0。用户登录时服务端查to_user当前用户 AND delivered0的记录推送给客户端然后更新delivered1。// 登录时拉取离线消息 if (login.startsWith(LOGIN|)) { userId login.split(\\|)[1]; onlineUsers.put(userId, out); out.println(LOGIN_OK); // 查离线私聊消息 String sql SELECT from_user, content FROM private_msg WHERE to_user? AND delivered0 ORDER BY send_time; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, userId); ResultSet rs ps.executeQuery(); while (rs.next()) { out.println(OFFLINE| rs.getString(from_user) | rs.getString(content)); } } // 更新已投递标记 try (PreparedStatement ps conn.prepareStatement( UPDATE private_msg SET delivered1 WHERE to_user? AND delivered0)) { ps.setString(1, userId); ps.executeUpdate(); } }逻辑说明登录成功后先推离线消息再更新标记顺序不能反否则消息还没发出去标记就改了用户永远收不到。参数上delivered字段默认 0转发成功且对方在线时直接置 1不在线时保持 0。这个方案在单机 MySQL 下完全够用如果消息量大可以把离线消息放 Redis 列表登录时LRANGE拉取。最后说一个我自己的习惯每次改完协议或线程模型先跑一遍「两个客户端私聊 三个客户端群聊 一个客户端离线再上线」的固定用例确认这三条链路都通再去做其他功能。仿 QQ 项目不怕功能少怕的是基础链路不稳。希望帮到你。本文还有配套的精品资源点击获取