
简介这是一份面向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 并发里经典的血泪教训。源码里用ConcurrentHashMapString, ClientHandlerkey 是用户名value 是对应的处理线程引用既能查在线状态又能拿到输出流发消息。// 线程安全的在线用户表 private static final MapString, 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 8888Windows 下netstat -ano | findstr 8888找到 PID 后结束进程或者把服务端端口改成 8899 之类没被占用的值客户端同步改。5.2 中文乱码收到的消息全是问号现象是英文正常、中文变乱码。原因是服务端和客户端字符集不一致或者某一端用了平台默认编码。解决方法是两端统一显式指定 UTF-8new 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清理有遗漏回去检查异常分支。验证项预期结果不符时的排查方向在线用户表压测后为 0finally 清理是否覆盖所有异常路径消息落库条数等于发送总数异步队列是否消费完、SQL 是否报错被吞线程数回落到基线线程池是否 shutdown、连接是否 close内存无明显持续增长是否有 Socket 或 ResultSet 未关闭这套验证做完你对这份源码的边界就有数了它能扛住几十到上百的并发连接再往上就得考虑 Netty 这类 NIO 框架替换阻塞 IO以及消息表分表或换用消息队列削峰。从那以后我每次拿到一份网络编程源码都会先跑一遍这个压测再决定要不要往生产环境靠因为“能跑”和“扛得住”之间差的就是这一轮验证。希望帮到你。本文还有配套的精品资源点击获取