简介:这是一份面向Java初学者与进阶开发者的网络聊天室项目源码,围绕Socket通信、多线程处理与数据库管理三条主线展开,适合用于课程设计、毕业项目参考或网络编程专项练习。压缩包共46个文件,约199KB,以20个class编译文件、7个java源码、10张jpg界面截图及txt说明文档为主,另含数据库文件与工程配置,源码与运行产物齐全,便于直接导入IDE对照阅读。已有193人学习下载,可作为小型实战案例参考。项目覆盖用户注册登录验证、消息广播与在线状态跟踪、聊天记录持久化等模块,读者可从中梳理服务端为每个连接开辟线程、客户端监听消息并刷新界面的并发处理思路,理解密码加密存储与数据库接口调用的实现方式,并借鉴异常捕获与调试经验,快速搭建可运行的多人聊天原型,深化对Java网络编程整体流程的掌握。
1. 从一份 Java 聊天室源码说起:Socket 长连接到底怎么跑起来
很多人第一次接触网络编程,都是从“聊天室”这三个字开始的。它看起来简单——不就是一个人发消息、其他人收到吗?但真把一份基于 Java Socket 的聊天室源码拉下来跑,你会发现里面藏着网络编程最核心的几件事:TCP 长连接怎么维持、多线程怎么处理并发读写、消息怎么在服务端广播、用户状态怎么落库。这份资源就是一套完整的 Java 网络聊天室实现,技术栈覆盖 Socket 通信、多线程并发处理和数据库持久化,适合想从“看得懂 API”跨到“能跑起一个真实多人在线系统”的开发者。它解决的不是教学 demo 那种单客户端自娱自乐的问题,而是多个客户端同时在线、消息实时转发、用户信息可查的真实场景。下面我按自己拆包复现的顺序,把这份源码从启动到踩坑完整走一遍。
2. Socket 通信骨架:服务端监听与客户端接入的完整链路
2.1 为什么选 TCP 而不是 UDP
聊天室这类场景,消息的顺序和到达可靠性比实时性更重要。一条“我到了”发出去,对方必须收到,且不能出现后发先至的乱序。TCP 的面向连接、可靠传输、字节流有序这三点,正好对上聊天室的需求。UDP 虽然开销小,但丢包和乱序要应用层自己补,对一份拿来就能用的源码来说不划算。所以这份资源用ServerSocket做监听、Socket做连接,是合理选型。
服务端启动的核心逻辑是:绑定端口、进入 accept 循环、每接入一个客户端就交给独立线程处理。这里有个关键点——accept 本身是阻塞的,如果直接在 accept 之后做读写,第二个客户端就进不来。所以必须把每个 Socket 丢进线程池或新线程,这是多线程技术介入的第一个位置。
// 服务端启动骨架 public class ChatServer { private static final int PORT = 8888; // 用线程池管理客户端连接,避免无限制创建线程 private static final ExecutorService POOL = Executors.newFixedThreadPool(50); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("服务端已启动,监听端口: " + PORT); while (true) { // accept 阻塞等待新连接 Socket socket = serverSocket.accept(); // 每个连接交给线程池中的一个线程处理 POOL.execute(new ClientHandler(socket)); } } }这段代码里newFixedThreadPool(50)的 50 是并发上限,按你预期的在线人数调。端口 8888 要和客户端保持一致。accept()返回的 Socket 代表一条已建立的 TCP 连接,后续所有读写都围绕它进行。注意serverSocket不要放在循环里反复创建,否则端口会被占用报BindException。
2.2 客户端连接与消息收发的双线程模型
客户端这边有个新手最容易翻车的地方:如果用一个线程既负责读又负责写,读操作read()是阻塞的,界面就会卡死,用户点发送没反应。正确做法是读写分离——主线程负责发送,另起一个线程专门循环读取服务端推送的消息。
// 客户端连接与接收线程 public class ChatClient { private Socket socket; private BufferedReader reader; private PrintWriter writer; public void connect(String host, int port) throws IOException { socket = new Socket(host, port); reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); // 独立线程循环接收,避免阻塞主线程 new Thread(() -> { try { String line; while ((line = reader.readLine()) != null) { System.out.println("收到消息: " + line); } } catch (IOException e) { System.out.println("连接已断开"); } }).start(); } public void send(String msg) { writer.println(msg); // autoFlush 已开启,无需手动 flush } }PrintWriter构造时第二个参数true表示自动 flush,少了它消息会卡在缓冲区发不出去,这是很常见的“发了但对方收不到”的原因。字符集统一用 UTF-8,服务端和客户端必须一致,否则中文会变乱码。接收线程里readLine()返回 null 代表对端关闭了连接,此时要退出循环并做清理,不能继续死读。
2.3 消息协议:用分隔符还是用对象序列化
原始 Socket 传的是字节流,没有消息边界。如果直接write一段字符串,接收端可能把两条消息粘成一条,这叫粘包。这份源码采用的是“一行一条消息”的文本协议,靠readLine()按换行符切分,简单可靠。如果你的消息体里本身可能含换行,就得换成“长度前缀 + 内容”的二进制协议,或者用 JSON 序列化后加分隔符。
常见做法是定义消息类型字段,比如LOGIN|用户名、MSG|内容、LOGOUT,服务端按|前面的类型分发处理。这样一套协议就把登录、聊天、退出统一到同一条读写通道上,不用为每种操作开新连接。协议设计得越早定清楚,后面加功能越省事,我一般会在动手写业务前先把消息格式用表格列出来。
| 消息类型 | 格式示例 | 服务端处理 |
|---|---|---|
| 登录 | LOGIN|张三 | 校验并登记在线用户 |
| 群聊 | MSG|大家好 | 广播给所有在线用户 |
| 私聊 | PRIVATE|李四|在吗 | 定向转发给指定用户 |
| 退出 | LOGOUT | 移除在线状态并通知 |
3. 多线程并发处理:在线用户表与消息广播的线程安全
3.1 在线用户表为什么必须用 ConcurrentHashMap
服务端要维护“谁在线”,才能做广播和私聊。多个客户端线程会同时往这张表里增删用户,如果用普通HashMap,并发写会导致结构损坏甚至死循环——这是 Java 并发里经典的血泪教训。源码里用ConcurrentHashMap<String, ClientHandler>,key 是用户名,value 是对应的处理线程引用,既能查在线状态,又能拿到输出流发消息。
// 线程安全的在线用户表 private static final Map<String, ClientHandler> ONLINE_USERS = new ConcurrentHashMap<>(); // 登录时登记 public void register(String username) { ONLINE_USERS.put(username, this); } // 广播消息:遍历时用弱一致性迭代器,不会抛 ConcurrentModificationException public static void broadcast(String msg) { for (ClientHandler handler : ONLINE_USERS.values()) { handler.send(msg); } }ConcurrentHashMap的迭代器是弱一致性的,遍历过程中其他线程增删用户不会抛异常,但可能读不到最新加入的用户——对广播来说这个延迟可以接受。如果你要求严格实时,就得加锁或用CopyOnWriteArrayList,但后者在用户多时写性能差,要权衡。
3.2 广播时的输出流竞争与同步
每个ClientHandler持有自己的PrintWriter,广播时多个线程可能同时往同一个客户端的输出流写。PrintWriter本身不是线程安全的,并发写可能把两条消息的字节交错在一起,客户端收到半截乱码。解决办法是在send方法上加synchronized,保证同一时刻只有一个线程往这条连接写。
public synchronized void send(String msg) { if (writer != null) { writer.println(msg); } }这个锁的粒度是单个客户端连接,不会影响其他用户的收发,开销很小。别图省事把整个广播方法加锁,那样所有用户的消息都要排队,在线人数一多就明显卡顿。
3.3 线程池参数怎么定与连接清理
线程池大小不是拍脑袋定的。聊天室是 IO 密集型场景,线程大部分时间阻塞在read()上,所以线程数可以远大于 CPU 核心数。经验值是预期峰值在线人数的 1.2 倍左右,留点余量。但也不能无限大,newFixedThreadPool的队列是无界的,任务堆积会吃内存,生产环境更推荐ThreadPoolExecutor显式指定队列容量和拒绝策略。
连接清理同样关键。客户端异常断开时,readLine()会抛异常或返回 null,此时必须把该用户从ONLINE_USERS移除并关闭 Socket,否则表里会残留“幽灵用户”,广播时往已关闭的连接写会不断报错。清理逻辑要放在finally块里,保证无论正常退出还是异常都能执行。
try { // 处理消息循环 } catch (IOException e) { System.out.println("客户端异常断开"); } finally { ONLINE_USERS.remove(username); try { socket.close(); } catch (IOException ignored) {} }4. 数据库持久化:用户信息与聊天记录的落库设计
4.1 表结构设计与 JDBC 连接管理
聊天室需要落库的主要是两类数据:用户账号信息和聊天记录。用户表存用户名、密码(记得加盐哈希,别存明文)、注册时间;消息表存发送者、接收者、内容、时间戳。这份源码用 JDBC 直连,没有引入 ORM 框架,好处是依赖少、启动快,适合学习和中小规模部署。
CREATE TABLE chat_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sender VARCHAR(32) NOT NULL, receiver VARCHAR(32), content TEXT NOT NULL, send_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_sender (sender), INDEX idx_time (send_time) );username加唯一索引防止重复注册。消息表的receiver为空表示群聊,有值表示私聊。给sender和send_time建索引,是因为查历史记录时最常用的就是“某人某段时间的消息”,没索引数据一多查询就慢。
4.2 连接池与批量写入的取舍
每条消息都开一次数据库连接,在高并发下会把连接数打满。常见做法是用连接池,比如 HikariCP,配置最小空闲连接和最大连接数。但要注意,聊天消息的落库不应该阻塞消息转发——先广播再异步落库,或者用队列缓冲后批量插入,能明显降低延迟。
// 异步落库,避免阻塞消息广播 private static final ExecutorService DB_POOL = Executors.newSingleThreadExecutor(); public static void saveMessageAsync(String sender, String receiver, String content) { DB_POOL.execute(() -> { String sql = "INSERT INTO chat_message(sender, receiver, content) VALUES(?,?,?)"; try (Connection conn = DataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, sender); ps.setString(2, receiver); ps.setString(3, content); ps.executeUpdate(); } catch (SQLException e) { // 落库失败不应影响聊天主流程,记录日志即可 System.err.println("消息落库失败: " + e.getMessage()); } }); }单线程的DB_POOL保证写入顺序,避免消息时间戳错乱。用PreparedStatement而不是拼接 SQL,既防注入又提升预编译效率。落库失败只记日志不抛给用户,因为聊天本身已经完成了,数据库只是留痕。
4.3 登录校验与密码存储
登录流程是客户端发LOGIN|用户名|密码,服务端查库比对。密码绝不能明文存,用MessageDigest做 SHA-256 加盐,或者直接用 BCrypt。校验通过后把用户登记进在线表,失败则回一条错误消息,连接保持不断,让用户重试。
public boolean verify(String username, String rawPassword) throws SQLException { String sql = "SELECT password FROM chat_user WHERE username = ?"; try (Connection conn = DataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ResultSet rs = ps.executeQuery(); if (rs.next()) { String stored = rs.getString("password"); return stored.equals(hash(rawPassword)); // hash 内部含盐 } } return false; }5. 避坑与排查:跑这份源码最容易翻车的五个地方
5.1 端口被占用,启动直接抛 BindException
现象是服务端一启动就报java.net.BindException: Address already in use。原因是 8888 端口被上一个没退干净的进程占着,或者被其他软件占用。解决方法是先查占用进程,Linux 下lsof -i:8888或netstat -tlnp | grep 8888,Windows 下netstat -ano | findstr 8888,找到 PID 后结束进程,或者把服务端端口改成 8899 之类没被占用的值,客户端同步改。
5.2 中文乱码,收到的消息全是问号
现象是英文正常、中文变乱码。原因是服务端和客户端字符集不一致,或者某一端用了平台默认编码。解决方法是两端统一显式指定 UTF-8:new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8),输出流同理。别依赖File.encoding默认值,不同机器默认编码可能不同,显式写死最稳。
5.3 消息发出去对方收不到,卡在缓冲区
现象是调用send后对方没反应,过一会儿才批量到达或者干脆丢失。原因是PrintWriter没开自动 flush,数据留在缓冲区。解决方法是构造时传true,或者每次println后手动flush()。另外检查是不是用了BufferedWriter却忘了 flush,这类问题排查起来很隐蔽,因为代码逻辑看起来完全正确。
5.4 用户下线后仍收到广播,日志刷满异常
现象是某人关掉客户端后,服务端广播时不断报SocketException: Broken pipe。原因是对端已关闭,但服务端在线表里还留着这条连接,没做清理。解决方法是在读写循环的finally里移除用户并关闭 Socket,同时在send前判断连接是否已关闭,写失败时主动把该用户踢出在线表,避免反复报错。
5.5 并发注册同名用户,唯一索引报错没被捕获
现象是两个客户端几乎同时用同一个用户名登录,其中一个抛SQLIntegrityConstraintViolationException导致线程崩溃。原因是先查后插存在竞态,两个线程都查到“不存在”然后都去插。解决方法是直接依赖数据库唯一索引,捕获该异常并返回“用户名已存在”给客户端,而不是先查再插。并发场景下,让数据库做最终裁决比应用层判断可靠。
6. 进阶验证:用压测脚本确认多线程与落库真的扛得住
源码能跑通只是第一步,真正要确认它可用,得做一轮并发验证。我一般会写个小压测脚本,模拟几十个客户端同时连接、发消息、断开,观察服务端线程数、内存和数据库写入是否正常。下面这段用线程池模拟 50 个客户端并发登录并各发 20 条消息,重点看有没有连接泄漏和消息丢失。
public class LoadTest { public static void main(String[] args) throws Exception { int clients = 50; int msgsPerClient = 20; ExecutorService pool = Executors.newFixedThreadPool(clients); CountDownLatch latch = new CountDownLatch(clients); for (int i = 0; i < clients; i++) { final int id = i; pool.execute(() -> { try (Socket s = new Socket("127.0.0.1", 8888); PrintWriter out = new PrintWriter( new OutputStreamWriter(s.getOutputStream(), "UTF-8"), true)) { out.println("LOGIN|user" + id); for (int j = 0; j < msgsPerClient; j++) { out.println("MSG|user" + id + " 的第" + j + "条消息"); } out.println("LOGOUT"); } catch (IOException e) { System.err.println("客户端 " + id + " 异常: " + e.getMessage()); } finally { latch.countDown(); } }); } latch.await(60, TimeUnit.SECONDS); pool.shutdown(); System.out.println("压测结束,检查服务端在线表是否已清空"); } }跑完后重点核对三件事:服务端ONLINE_USERS是否归零(验证清理逻辑)、数据库消息条数是否等于 50×20(验证落库没丢)、服务端进程线程数是否回落到基线(验证线程池没泄漏)。如果消息条数对不上,多半是异步落库队列还没消费完就关了进程,等几秒再查。如果在线表没清空,说明finally清理有遗漏,回去检查异常分支。
| 验证项 | 预期结果 | 不符时的排查方向 |
|---|---|---|
| 在线用户表 | 压测后为 0 | finally 清理是否覆盖所有异常路径 |
| 消息落库条数 | 等于发送总数 | 异步队列是否消费完、SQL 是否报错被吞 |
| 线程数 | 回落到基线 | 线程池是否 shutdown、连接是否 close |
| 内存 | 无明显持续增长 | 是否有 Socket 或 ResultSet 未关闭 |
这套验证做完,你对这份源码的边界就有数了:它能扛住几十到上百的并发连接,再往上就得考虑 Netty 这类 NIO 框架替换阻塞 IO,以及消息表分表或换用消息队列削峰。从那以后我每次拿到一份网络编程源码,都会先跑一遍这个压测再决定要不要往生产环境靠,因为“能跑”和“扛得住”之间差的就是这一轮验证。希望帮到你。
本文还有配套的精品资源,点击获取