Java网络聊天室实战:Socket通信、多线程与JDBC数据库设计

发布时间:2026/10/11 3:42:47
Java网络聊天室实战:Socket通信、多线程与JDBC数据库设计 简介一份基于Java的迷你网络聊天室项目源码面向Java初学者及需要课程设计参考的开发者完整演示了Socket通信、多线程并发与数据库管理的协作方式。压缩包共46个文件体积约199KB包含7个Java源文件、20个class编译文件、1个db数据库文件以及jpg/gif等界面图片素材并配有使用说明结构清晰便于直接导入工程运行。已有193人学习下载。项目涵盖用户注册登录、多人消息广播、聊天记录存储等核心模块可帮助读者理解客户端与服务器端的Socket交互流程、多线程处理并发连接的方式、数据库读写操作等关键实现。进一步阅读源码还能掌握ServerSocket监听、accept阻塞、线程池或每连接一线程的典型写法并通过db文件了解用户信息与聊天记录的持久化设计整体代码规模适中适合在课程设计或毕业设计中直接复用或二次开发。1. java网络聊天室项目socket通信、多线程与数据库为什么必须一起设计准备动手做一个java网络聊天室的时候很多人以为难点在界面做得好不好看或者消息能不能发出去。实际上卡住大多数人的是另外三件事socket通信怎么处理客户端突然断开多线程下在线列表怎么保证不串、不错以及数据库里的聊天记录怎么写才不至于把服务拖垮。这个题目把SOCKET通信、多线程技术、数据库技术绑在一起恰好是网络编程里最典型的一组组合。它适合正在准备课程设计、想补网络编程基础的开发者也适合第一次接触C/S模型的人——做完这一个至少能把连接管理、线程生命周期、JDBC这条链路完整跑通。2. 通信模型先于代码socket连接管理与多线程架构怎么选聊天的功能看起来只是“发出去”和“收进来”但一旦做成服务端和客户端两个进程问题就变成了服务端怎么同时伺候几十个连接哪个线程负责读哪个线程负责写在线列表放在哪里才不会被并发搞乱这一章先把模型想清楚再动手写代码。2.1 先定边界这个C/S架构里服务端到底要管哪三件事常见做法是服务端只做三件事监听端口、接收连接、转发消息。听起来简单实际展开是这样的——服务端要维护一个ServerSocket循环等待客户端接入每接入一个客户端就创建一个会话每个会话要持续读取该客户端的消息读取到消息后不仅要广播给别人还要更新“谁在线”这个共享状态。客户端的职责则是对称的连接服务器读键盘输入并发送同时开一个接收线程持续读取服务端推送过来的消息。我在动手之前习惯先画一条数据流客户端A输入一行文字 → 客户端A的socket输出流 → 服务端A线程的readLine→ 服务端遍历在线列表 → 写入每个连接对应的输出流 → 其他客户端接收线程打印。这条链路里有两个最容易出错的位置一个是“遍历在线列表”时的并发安全另一个是“写入输出流”时的串行性。先记下这两个点后面避坑章节还会反复提到。2.2 多线程选型对比一连接一线程和线程池分别适合什么场景聊天室项目最常见、也最适合练手的方案是“一连接一线程”服务端accept到一个socket就new Thread(...).start()线程内部用循环读取消息。这个模型优点非常明显——代码直观、线程之间互相隔离一个客户端卡住不会阻塞其他连接缺点是线程数量受系统资源限制几百个连接时就可能出现上下文切换开销过大。我的建议是课程设计或第一版先做一连接一线程把功能跑通因为这样调试最方便如果后面想拿它做简历项目再换成线程池或者NIO。线程池的典型写法是用ExecutorService接住整个会话任务而不是让每个连接直接占一个线程。但要注意一个反常识的地方聊天连接是长连接一个socket从登录到退出可能持续几十分钟如果直接用固定大小线程池连接多了线程同样会被占满。所以线程池在这里解决的是“线程创建销毁开销”而不是“并发连接上限”真正要解决并发上限得走NIO多路复用那是另一个话题。初学者没必要一上来就上Netty先把阻塞式模型吃透。2.3 最小消息协议为什么建议用“按行分隔”而不是发对象流很多初学者会直接用ObjectOutputStream把消息对象整个写出去然后另一头用ObjectInputStream读回来。第一次跑通很爽踩坑在后面对象流有自己的头和缓存多个线程同时写同一个ObjectOutputStream时会出现对象头错乱而且客户端一旦升级或者改成其他语言整个协议就废了。我一般建议自己定义一个极简单的文本协议约定每条消息以换行符结尾发送方用println接收方用readLine。这样做的另一个好处是天然规避了粘包问题。TCP是流协议没有消息边界但readLine会按换行符把数据切成一条条完整消息前提是消息内容本身不能包含换行符。如果将来要传多行文本或者文件可以在消息体里包一个[BODY]标记或者引入JSON格式但第一版先用“一行一条消息”能让调试成本降到最低。这个协议同时承担了登录、聊天、系统通知三类消息靠前缀字符串区分即可。3. 手写服务端和客户端从socket建立到消息转发的可运行骨架模型定好之后写代码就顺了。我用一个极简但能跑的服务端骨架来讲它用ServerSocket监听一个端口维护一个ConcurrentHashMap当作在线用户表每个客户端接入后立刻创建一个ClientHandler线程去读消息。核心代码量很少但每一行都有讲究。3.1 服务端骨架ServerSocket、accept与在线用户表// ChatServer.java import java.io.*; import java.net.*; import java.util.concurrent.*; public class ChatServer { private static final int PORT 8888; // 在线用户表key 是用户名value 是写给该客户端的输出流 private static final ConcurrentHashMapString, PrintStream ONLINE_USERS new ConcurrentHashMap(); 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()); // 一连接一线程每个会话独立处理 new Thread(new ClientHandler(socket)).start(); } } public static ConcurrentHashMapString, PrintStream getOnlineUsers() { return ONLINE_USERS; } }这段代码的逻辑很简单accept()阻塞直到有客户端连接拿到socket后交给ClientHandler。这里用ConcurrentHashMap而不是HashMap是有意为之——后面多个ClientHandler线程会同时往在线表里put和remove并发容器能避免结构性修改时抛异常。PORT定义为常量方便统一修改如果你本地端口被占用把这个值改掉即可但客户端连的端口必须保持一致。3.2 会话处理线程登录、广播、退出清理// ClientHandler.java import java.io.*; import java.net.*; public class ClientHandler implements Runnable { private final Socket socket; private String username; private BufferedReader in; private PrintStream out; public ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try { in new BufferedReader(new InputStreamReader( socket.getInputStream(), UTF-8)); out new PrintStream(socket.getOutputStream(), true, UTF-8); // 约定第一条消息是昵称服务端用它注册在线表 username in.readLine(); if (username null || username.isBlank()) { return; } ChatServer.getOnlineUsers().put(username, out); broadcast([系统] username 进入了聊天室); String line; while ((line in.readLine()) ! null) { // 收到的每条消息都广播给所有人 broadcast(username : line); System.out.println([ username ] line); } } catch (IOException e) { // 客户端断开时 readLine 会抛异常这里捕获后走清理逻辑 System.out.println(连接异常 socket.getRemoteSocketAddress()); } finally { // 无论正常退出还是异常断开都要移除在线表并关闭连接 if (username ! null) { ChatServer.getOnlineUsers().remove(username); broadcast([系统] username 退出了聊天室); } closeQuietly(); } } private void broadcast(String message) { for (PrintStream ps : ChatServer.getOnlineUsers().values()) { ps.println(message); } } private void closeQuietly() { try { if (in ! null) in.close(); if (out ! null) out.close(); if (socket ! null) socket.close(); } catch (IOException ignored) { } } }readLine()是服务端会话的核心它阻塞直到读到一行完整消息或者连接关闭返回null或者发生异常。这里做了三件容易漏的事登录消息判空避免客户端一上来就断开时把null写进在线表finally里做清理无论正常退出还是异常断开都能移除用户广播遍历的是在线表当前的副本因为用的是并发容器遍历过程中其他线程增删用户不会导致崩溃。PrintStream构造器的第二个参数true表示自动刷新保证println之后数据立刻写出去否则要手动flush。3.3 客户端骨架一个发送线程加一个接收线程// ChatClient.java import java.io.*; import java.net.*; public class ChatClient { public static void main(String[] args) throws Exception { Socket socket new Socket(localhost, 8888); System.out.println(已连接聊天室服务器); BufferedReader console new BufferedReader( new InputStreamReader(System.in, UTF-8)); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintStream out new PrintStream(socket.getOutputStream(), true, UTF-8); System.out.print(请输入昵称); String username console.readLine().trim(); out.println(username); // 先发昵称完成登录 // 接收线程持续打印服务端推送的消息 new Thread(() - { String msg; try { while ((msg in.readLine()) ! null) { System.out.println(msg); } } catch (IOException e) { e.printStackTrace(); } }).start(); // 主线程读取控制台输入并发送 String input; while ((input console.readLine()) ! null) { out.println(input); } } }客户端必须有两个线程的原因在于输入和接收都是阻塞操作如果把读取键盘和读取socket放在同一个线程里发送消息时你就永远收不到别人发来的消息反之亦然。console.readLine()阻塞等待用户在终端输入in.readLine()阻塞等待服务器推送两个线程互不干扰。注意输入输出全部指定了UTF-8这是中文不乱码的前提后面避坑章节会说为什么这一步不能省。这个骨架跑起来之后你开两个客户端窗口用两个昵称各自登录互相发消息服务端控制台也能看到转发日志。第1版能跑通这个项目的一半工作就完成了剩下的工作是数据库接入和各类异常处理。4. 数据库接入实战用JDBC完成注册、登录与聊天记录落库聊天室不接数据库消息发完就没了用户身份也没法校验。接数据库的目的有三块注册登录时校验用户名密码把聊天记录存下来方便离线补拉以及让这个项目从“玩具”变成“带状态的系统”。我建议用MySQL但下面的SQL和JDBC写法换到其他关系型数据库也基本通用。4.1 建表设计t_user和t_message两张表的字段说明CREATE DATABASE IF NOT EXISTS chat_room DEFAULT CHARSET utf8mb4; USE chat_room; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_message ( id INT PRIMARY KEY AUTO_INCREMENT, sender VARCHAR(50) NOT NULL, receiver VARCHAR(50) DEFAULT ALL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_send_time (send_time) );t_user存用户username加唯一索引注册时靠它判断昵称是否被占用。t_message存聊天内容sender是发送者receiver是接收者默认ALL表示群聊消息如果想做私聊这个字段就用来存目标用户昵称。send_time建索引是因为后面拉取离线消息时要按时间排序。这里有个细节password字段建议存加密后的值哪怕是简单做一次SHA-256也尽量不要明文入库这个习惯最好从课程设计就开始养成。4.2 注册与登录JDBC连接与PreparedStatement防注入// UserDao.java import java.security.MessageDigest; import java.sql.*; public class UserDao { private Connection getConnection() throws SQLException { String url jdbc:mysql://localhost:3306/chat_room ?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/Shanghai; return DriverManager.getConnection(url, root, 你自己的密码); } public boolean register(String username, String password) { String hashed sha256(password); String sql INSERT INTO t_user(username, password) VALUES(?, ?); try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, hashed); return ps.executeUpdate() 0; } catch (SQLIntegrityConstraintViolationException e) { System.out.println(用户名已存在 username); return false; } catch (SQLException e) { e.printStackTrace(); return false; } } public boolean login(String username, String password) { String hashed sha256(password); String sql SELECT id FROM t_user WHERE username ? AND password ?; try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, hashed); ResultSet rs ps.executeQuery(); return rs.next(); } catch (SQLException e) { e.printStackTrace(); return false; } } private String sha256(String input) { try { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] bytes md.digest(input.getBytes(UTF-8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(e); } } }这里必须用PreparedStatement加参数占位符不要用字符串拼接SQL。原因不只是防SQL注入它还能帮你避免拼接时字符串引号转义的麻烦。try-with-resources写法保证Connection、PreparedStatement、ResultSet在方法结束后自动关闭这是避免数据库连接耗尽的关键后面避坑章节会说为什么这个习惯能救命。4.3 聊天记录落库同步写库的代价与异步队列的简单做法// MessageDao.java import java.sql.*; public class MessageDao { public void saveMessage(String sender, String receiver, String content) { String sql INSERT INTO t_message(sender, receiver, content) VALUES(?, ?, ?); try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, sender); ps.setString(2, receiver); ps.setString(3, content); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } } }把saveMessage调用塞进ClientHandler的消息处理循环里聊天记录就能落库。但要注意如果每收到一条消息就同步执行一次JDBC插入数据库的响应时间会直接卡住消息转发聊天室会变得一卡一卡。常见做法是先用一个LinkedBlockingQueue接住待写入的消息再开一个后台线程批量消费这是最简单的异步落库。等熟练之后再考虑线程池批量提交或者引入连接池。// 在服务端启动时创建一个后台写库线程 private static final LinkedBlockingQueueString[] MESSAGE_QUEUE new LinkedBlockingQueue(); static { new Thread(() - { MessageDao dao new MessageDao(); while (true) { try { String[] m MESSAGE_QUEUE.take(); dao.saveMessage(m[0], m[1], m[2]); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }, db-writer).start(); }这段代码用阻塞队列做生产消费聊天线程只负责offer消息到队列后台的db-writer线程负责取出来写库。好处是聊天线程永远不会被数据库拖住而且消息会按入队顺序落库。代价是如果服务端进程崩溃队列里还没写库的消息会丢但聊天室场景下这个代价可以接受。5. 聊天室避坑指南5个并发场景下踩过的坑和排查顺序这个项目我前后帮人排查过很多次问题基本集中在下面五类。每一个都是真实翻过车的我按“现象 → 原因 → 解决”来写顺序也基本对应排查时的优先级。5.1 客户端直接强退服务端刷出一片Connection reset现象客户端点了右上角关闭按钮或者直接断网服务端控制台开始刷java.net.SocketException: Connection reset之后如果代码没写清理逻辑在线列表里就残留一个幽灵用户他的“下线”通知永远不会广播。原因TCP连接被异常终止时服务端正在阻塞的readLine()会抛SocketException而不是返回null。很多初学者只处理了正常退出忽略了异常分支。解决在ClientHandler.run()里把IOException捕获住并且在finally块里统一做“移除在线表 广播下线消息 关闭socket”。finally是这里的关键保证无论哪种退出路径清理代码都会执行。注意readLine()返回null代表对方正常关闭了连接抛异常代表异常中断这两种情况都要走同一套清理逻辑。5.2 在线用户列表并发修改抛ConcurrentModificationException现象在线人数一多服务端时不时抛出java.util.ConcurrentModificationException而且不是每次都能复现让人很头疼。原因多个客户端同时登录和退出多个线程同时修改一个普通HashMap其中某个线程正在遍历时要删除另一个用户就会触发这个异常。解决把在线表换成ConcurrentHashMap。它内部做了分段或CAS优化遍历时的迭代器是弱一致性的不会因为并发修改抛异常。注意ConcurrentHashMap不允许null键值所以登录消息判空不能省否则会把NullPointerException带进来。5.3 数据库连接用完不关跑半天所有客户端卡在登录现象聊天室刚启动时一切正常运行一两个小时后新客户端连不上老客户端发消息也没反应服务端日志里全是Too many connections。原因每一处getConnection()都是新开一个JDBC连接用完不关MySQL默认最多只能同时开一百多个连接。连接被占满后所有新的数据库操作只能排队等待表现为整个聊天室“变卡变死”。解决第一版先用try-with-resources保证Connection、PreparedStatement、ResultSet都自动关闭。这一步不做后面无论是换连接池还是加缓存都治标不治本。验证方法很简单跑一个小时的压测执行SHOW PROCESSLIST;看看有没有睡死的连接堆积。5.4 中文昵称和消息全变成“???”现象客户端发中文另一端收到一堆???或者昵称直接乱码服务端控制台打印的日志也乱。原因socket的字节流没有统一编码。Windows控制台默认GBKLinux默认UTF-8而网络传输用的是Java默认字符集三个地方只要有一个不一致中文就废了。解决客户端和服务端两边的InputStreamReader和PrintStream全部指定UTF-8代码里写死的重点就是new InputStreamReader(socket.getInputStream(), UTF-8)和new PrintStream(socket.getOutputStream(), true, UTF-8)。数据库连接串里也要带characterEncodingutf8建表用utf8mb4三层全部对齐乱码问题才算根治。5.5 两条消息偶发串在一起接收端一次读到两行现象某个客户端突然收到用户A: xxx用户B: xxx这样粘在一块的输出但服务端日志里两条消息是分开的。原因多个ClientHandler线程同时调用broadcast()两个线程同时往同一个客户端输出流里println数据在流里发生了交错。解决给每个客户端的输出流加一个同步锁或者用一个专用的发送队列。最简单的做法是在broadcast方法上加上synchronized保证同一时刻只有一个线程在遍历在线表并写入输出流。这个方法会牺牲一点并发度但对初学者来说正确性远重要于性能。6. 让聊天室更接近可用心跳、离线消息与线程池改造骨架能跑、数据能存之后这个项目还可以往“更像产品”的方向走三步。第一是心跳机制。现在的服务端依赖readLine()返回异常来发现断线但客户端如果只是网络闪断或休眠服务端可能等很久才能感知。常见做法是客户端每隔30秒发一行PING服务端更新该用户的最后活跃时间服务端每10秒扫一次在线表把超过60秒没活跃的用户强制踢下线。这个机制不复杂但能让在线列表变得可靠。第二是离线消息。登录成功后客户端可以请求拉取最近50条聊天记录服务端从t_message表里SELECT ... ORDER BY send_time DESC LIMIT 50查出后按时间正序补发。这一步把数据库的价值真正用上了聊天室从“只能看当下”变成“能看历史”。第三是线程池替换。把new Thread(...).start()换成ExecutorService后连接创建的线程可以被复用。我建议自己去测一次分别用一连接一线程和线程池跑100个模拟客户端记录内存占用和CPU切换开销。这个实验做下来你对多线程的理解会比背十遍八股文都深。我自己做这个项目时最狼狈的一次翻车就是客户端断网后服务端没清理连接第二天早上看日志在线表里躺着十几个幽灵用户。后来把清理逻辑挪进finally并加上心跳这个问题才彻底消失。经历过的经验就是做网络编程先想清楚“连接断了怎么办”再想“消息怎么写出去”顺序反了必然返工。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询