
简介这是一份基于Java开发的迷你网络聊天室项目源码完整覆盖Socket通信、多线程与数据库三大核心模块适合Java初学者、课程设计或毕业设计参考。压缩包内共46个文件包含7个Java源文件、20个已编译class文件、1个db数据库、3个txt使用说明以及jpg/gif界面素材等整体仅199KB轻量易上手目前已有193人浏览/学习。项目实现用户注册、登录验证、多人聊天广播、消息记录持久化等完整功能。src目录下以Java源文件方式展示服务器端与客户端代码结构bin目录提供可直接运行的class文件使用说明.txt和user.txt帮助快速配置环境与测试账号images目录中的界面图片则方便还原聊天窗口布局。借助源代码可深入理解Socket连接建立、多线程并发处理、数据库读写与异常捕获等关键机制。压缩包虽小但麻雀虽小五脏俱全既适合逐步调试学习网络编程原理也可在现有代码基础上扩展文件传输、表情聊天等功能。对于希望通过实战项目巩固Java技能的学习者来说这套源码加上配套文档与可运行产物是一份不错的参考资料。1. java网络聊天室先跑通通信链路再谈功能铺开很多人第一次接触 Socket 通信时以为难点在 ServerSocket 那几个 API 上真正跑起来才发现最难的不是建立连接而是消息边界怎么划定、多个客户端同时发言时广播算法能不能稳住、有人直接拔网线会不会把整个服务端拖垮。这个 java网络聊天室项目就是一条完整的通信链路登录注册走 MySQL 存储聊天消息走 Socket 传输服务端通过多线程给每个连接分配独立执行路径把网络通信、并发处理、数据库技术串在了一起。适合刚学完 Java 基础、想拿实战项目练手的人也适合想研究通信协议边界和线程协作方式的开发者。它不是什么高深框架却能把 Java 网络编程最核心的几个问题全部暴露出来。2. 架构总览Socket 与多线程组成的消息中枢2.1 目录结构从启动类到消息转发的完整工程骨架这个项目没有引入 Spring Boot 之类的重框架是一个基于 JDK 原生能力搭建的标准 Java 工程常见做法是 Maven 或 Gradle 管理依赖核心依赖只有 MySQL 驱动。工程骨架大致如下chatroom/ ├── src/main/java/com/chat/ │ ├── server/ │ │ ├── ChatServer.java // 服务端入口绑定端口并 accept 客户端 │ │ ├── ClientHandler.java // 每个客户端连接对应的线程处理类 │ │ └── MessageBroadcaster.java // 消息广播逻辑维护在线客户端集合 │ ├── client/ │ │ ├── ChatClient.java // 客户端入口负责建立连接和启动线程 │ │ └── ReceiveThread.java // 客户端接收消息的独立线程 │ ├── db/ │ │ ├── DBUtil.java // JDBC 连接工具类 │ │ └── UserDao.java // 用户相关数据库操作 │ └── model/ │ └── Message.java // 消息实体承载类型、发送者、目标与内容 ├── sql/ │ └── init.sql // 建库建表脚本 └── pom.xml // Maven 配置从目录可以看到职责划分server 包只管网络监听和消息转发client 包只管发起连接和收发db 包独立处理持久化model 包定义收发双方共用的消息格式。这种分层方式的好处是后续要扩展文件传输或语音消息只需要在 model 里增加消息类型在服务端的消息分发处多写一个分支不用动网络层的骨架。2.2 线程模型一个连接对应一条执行线程服务端的核心逻辑不复杂ChatServer 在 8888 端口上创建一个 ServerSocket用 while 循环反复调用 accept() 接收新连接。每 accept 到一个 Socket就创建一条新的 ClientHandler 线程去处理这条连接。这个模式通常被称为“一连接一线程”它最大的优势是理解成本低、隔离性好——某条连接上的 read 操作阻塞了只会卡住它自己的线程不会影响其他客户端的消息收发。ServerSocket serverSocket new ServerSocket(8888); while (true) { Socket socket serverSocket.accept(); ClientHandler handler new ClientHandler(socket); Thread t new Thread(handler); t.start(); }这段代码是整个服务端最朴素也最关键的部分。accept() 是一个阻塞方法没有客户端连进来时它会一直停在这行等待一旦有客户端握手成功accept() 返回一个 Socket代码立刻把 Socket 交给新线程去处理。参数 8888 是监听端口如果被占用会抛 BindException开发时改成 9000、9999 都行但客户端连接时的端口必须与之保持一致。所有在线客户端会被维护在一个共享集合中常见做法是用ListClientHandler再配合 synchronized 保证线程安全。这里有个细节值得注意遍历这个列表进行广播时绝不能直接在 for-each 循环里调用 remove会因为并发修改抛出 ConcurrentModificationException。我会在第五章避坑里单独讲。2.3 通信协议用 readUTF 自带长度前缀解决粘包Socket 底层是字节流发送方和接收方之间没有天然的“消息边界”。连续发送多条消息时接收方可能一次就读到粘在一起的数据也叫粘包问题。这个项目使用 DataInputStream/DataOutputStream 的 readUTF/writeUTF 方法绕开了这个麻烦writeUTF 在写入内容之前会先写两个字节的长度前缀readUTF 读消息时先读长度再按长度读取内容所以每次 writeUTF 和 readUTF 都能一一对应。// 服务端把消息写回某个客户端 DataOutputStream out new DataOutputStream(socket.getOutputStream()); out.writeUTF([张三] 大家好); // 客户端读取时一次就能取到完整消息 DataInputStream in new DataInputStream(socket.getInputStream()); String line in.readUTF();通信协议本身设计成一种简单的文本格式用竖线分割字段例如MSG|张三|所有人|大家好或者LOGIN|张三|123456。我这里只是描述了服务端转发环节的写读方式真正的协议在 Message 类中封装成字符串拼接。这里值得注意readUTF 和 BufferedReader.readLine 不同readLine 依赖换行符做边界readUTF 依赖长度前缀so 即使消息内容里包含换行符也不会被切断。3. 核心模块拆解一个消息从进入到返回的完整链路3.1 服务端入口accept 循环与客户端握手服务端承担三个职责建立连接、分发消息、维护在线列表。建立一个稳定的接收循环是第一步public class ChatServer { private static ListClientHandler clients new ArrayList(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8888); System.out.println(聊天室服务端启动端口 8888); while (true) { Socket socket serverSocket.accept(); ClientHandler handler new ClientHandler(socket); Thread thread new Thread(handler); thread.start(); } } }每次 accept 返回一个新的 Socket 后代码为什么要把 handler 再做一次 Thread 包装因为 ClientHandler 实现了 Runnable 接口它的 run 方法要在独立线程中执行。如果不这么做把 accept 循环和消息读取放在同一个线程后面连接的客户端一直无法被 accept如果只是 new Thread 而不 start代码永远不会执行。这两个点都是新手容易疏忽的。启动服务端之后可以用netstat -an | grep 8888命令验证端口是否进入 LISTEN 状态。如果提示端口被占用可以把端口改成 9000或者用lsof -i:8888查看占用进程。3.2 消息读取与转发手写广播算法每个 ClientHandler 的 run 方法里都有一条阻塞读取循环循环退出条件有两种客户端发送 quit 指令或者客户端异常断开。这里的核心难点是如何把一条消息转发给聊天室内所有人同时保证不重不漏。public void run() { try { out new DataOutputStream(socket.getOutputStream()); in new DataInputStream(socket.getInputStream()); String line; while (true) { line in.readUTF(); if (quit.equals(line)) { break; } // 转发格式MSG|发送者|内容 String[] parts line.split(\\|); if (parts.length 3 MSG.equals(parts[0])) { forwardToOthers(parts[1], parts[2]); } } } catch (EOFException e) { System.out.println(客户端连接异常断开); } catch (IOException e) { e.printStackTrace(); } finally { close(); } } private void forwardToOthers(String sender, String content) { Message msg new Message(sender, content); synchronized (clients) { for (ClientHandler handler : clients) { if (handler ! this) { handler.sendMessage(msg.toProtocolString()); } } } }这段代码里有两个容易被忽略的细节。第一个是synchronized (clients)在线列表是共享资源广播是典型的读多写少操作如果不加锁一个客户端退出时正好另一个消息在遍历列表就可能读到半删除状态。第二个细节是handler ! this这个判断把消息发送者排除在广播之外避免自己收到自己的消息。如果不排除客户端本地又打印了发送的内容就会看到消息重复显示。3.3 客户端双线程发送与接收必须分离客户端的实现同样需要两条线程。一条线程读取用户在控制台输入的内容并写入 Socket另一条线程专门从 Socket 读取服务端下发的消息打印到屏幕上。为什么一定要拆成两条线程因为 readUTF 是一个阻塞操作它不知道服务端什么时候发消息来。如果发送和接收放在同一个线程程序会卡在 readUTF 上键盘输入永远不会被读到。public void connect() throws IOException { Socket socket new Socket(127.0.0.1, 8888); DataOutputStream out new DataOutputStream(socket.getOutputStream()); DataInputStream in new DataInputStream(socket.getInputStream()); Thread sendThread new Thread(() - { Scanner scanner new Scanner(System.in); while (true) { String input scanner.nextLine(); try { out.writeUTF(MSG|我| input); } catch (IOException e) { e.printStackTrace(); } } }); Thread receiveThread new Thread(() - { try { while (true) { String message in.readUTF(); System.out.println(message); } } catch (IOException e) { e.printStackTrace(); } }); sendThread.start(); receiveThread.start(); }需要注意Socket 地址 127.0.0.1 表示连接本机如果服务端运行在其他机器上需要改成对应的 IP。发送线程和接收线程之间没有通信它们各自独立阻塞这正是 Socket 编程的标准结构。如果要退出客户端通常是在发送线程里输入 quit 后调用socket.close()让接收线程的 readUTF 抛异常退出。4. 数据库落地用户表与聊天记录的持久化方案4.1 数据库表设计用户表与聊天记录表项目采用 MySQL 存储数据建表脚本是最先要跑的。用户表存账号信息聊天记录表存每一笔消息。设计时注意用户名要做唯一约束密码字段长度要能容纳加密后的字符串而不是明文密码的原始长度。CREATE TABLE IF NOT EXISTS user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE IF NOT EXISTS chat_message ( id INT PRIMARY KEY AUTO_INCREMENT, from_user VARCHAR(50) NOT NULL, to_user VARCHAR(50), content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段类型需要注意两点content 用 TEXT 而不是 VARCHAR因为聊天内容长度不定VARCHAR 有明确的上限created_at 设置 DEFAULT CURRENT_TIMESTAMP这样插入时不用手动填时间查询结果也能按时间排序。to_user 字段在设计上做了约定值为 NULL 表示这是一条发给所有人的广播消息有值表示私聊。这个设计简单但作用非常关键离线消息查询时就是靠这个字段区分“哪些消息是属于我的”。4.2 登录校验PreparedStatement 与参数绑定用户登录时服务端做的操作就是查用户表比对用户名和密码是否匹配。这里必须使用 PreparedStatement 而不是拼接 SQL 字符串一方面防 SQL 注入另一方面 PreparedStatement 有预编译能力在高频登录场景下性能更好。public boolean checkLogin(String username, String password) { String sql SELECT id FROM user WHERE username ? AND password ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs ps.executeQuery()) { return rs.next(); } } catch (SQLException e) { e.printStackTrace(); return false; } }setString 方法把用户输入的内容作为参数绑定到 SQL 的占位符上数据库驱动会负责对特殊字符做转义。如果你把这段代码里的问号换成 username 输入admin OR 11就能直接绕过登录成键这是 SQL 注入最常见的攻击方式。注册功能同样是预编译主要区别是新增动作String sql INSERT INTO user(username, password) VALUES(?, ?); ps.setString(1, username); ps.setString(2, password); int rows ps.executeUpdate();不管是登录还是注册这里都有一步可选优化密码字段存储前应该加盐哈希而不是拿原始字符串入库。我在二开章节会提到。4.3 消息落库与离线拉取聊天过程中产生的每一条消息正常情况下都应该写入 chat_message 表这样用户重新登录后还能看到历史记录。落库操作发生在服务端转发之前先插入数据库再执行广播。顺序很关键——如果先广播、再落库数据库写失败时消息丢了先落库再广播至少历史记录是完整的。public void saveMessage(Message msg) { String sql INSERT INTO chat_message(from_user, to_user, content) VALUES(?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, msg.getFromUser()); // 如果是广播消息to_user 存 NULL ps.setString(2, msg.getToUser()); ps.setString(3, msg.getContent()); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } }发送方把to_user设为 null对应 SQL 中第二个参数传 null。需要注意 PreparedStatement 的 setString 接收 null 是合法的数据库会自动把它存为 SQL NULL。这部分我一般会判断一下如果消息目标是“所有人”就传ps.setNull(2, Types.VARCHAR)效果等价但语义更清晰。离线消息的拉取用一条 SQL 就能完成SELECT * FROM chat_message WHERE to_user ? OR to_user IS NULL ORDER BY created_at ASC LIMIT 50;这条查询的含义是拉取发给我的私聊消息加上所有广播消息按时间正序返回最近 50 条。ORDER BY created_at ASC保证消息顺序和历史时间线一致LIMIT 50防止一次性查询的数据量过大。如果把 LIMIT 去掉长时间运行的聊天室会把整个表查一遍内存和 IO 双双告急。5. 避坑指南跑了三遍才搞定的并发问题5.1 消息重复显示问题出在广播逻辑现象自己发送的消息在控制台打印了两遍别人发送的消息只有一遍。原因客户端本地 System.out.println 了一次发送内容服务端 broadcast 时没有做排除又原样把消息发回给发送者。解决在转发循环里加if (handler ! this)判断让服务端不回显如果设计上希望所有人都收到那客户端就不要在本地重复打印。这个项目里我采用服务端排除发送者客户端只管收和显示。5.2 客户端直接拔网线服务端线程崩了一片现象客户端异常断开后服务端控制台抛出大量 EOFException紧接着整个进程退出或卡死。原因readUTF 在读不到数据时不会返回 null而是抛出 EOFException。代码里没有捕获它或者捕获了却没有把对应的 ClientHandler 从在线列表中移除导致后面广播时遍历到一个已经失效的连接又抛出 SocketException。解决catch EOFException在 finally 块中从 clients 列表移除当前 handler并关闭 Socket。finally { synchronized (clients) { clients.remove(this); } try { socket.close(); } catch (IOException e) { e.printStackTrace(); } }注意移除动作必须在 synchronized 块内执行否则另一个线程正在遍历时列表结构被修改会抛 ConcurrentModificationException。监听异常、移除连接、关闭资源这三件事必须在 finally 里做齐全。5.3 中文乱码根源在编码不一致现象服务端收到的消息是??或者客户端打印出来是一堆乱码。原因服务端用 DataOutputStream.writeUTF 发送客户端用 BufferedReader.readLine 读取而 BufferedReader 默认使用系统字符集。Windows 下系统默认可能是 GBKUTF-8 编码的数据被当作 GBK 解码自然乱码。解决统一两端的读写方式要么全部用 DataInputStream/DataOutputStream 的 readUTF/writeUTF要么在创建 InputStreamReader 时显式指定字符集new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));这个问题的根源是编码不一致而不是网络传输本身的问题排查时先看服务端和客户端是否都使用了同一个字符集。5.4 数据库连接不关跑一会儿就卡死现象聊天室运行一段时间后所有数据库操作开始超时控制台报Too many connections。原因每次执行 SQL 都DriverManager.getConnection创建新连接用完后没有 close。连接数会随着消息量上涨MySQL 默认最大连接数是 151很快就耗尽了。解决严格按照 try-with-resources 或 finally 关闭 Connection、PreparedStatement、ResultSet。上面的代码示例里已经用了 try-with-resourcesConnection 和 PreparedStatement 都实现了 AutoCloseable自动关闭。如果连接的创建过程封装在 DBUtil 里也必须在 DBUtil 中管理好生命周期。5.5 服务端重启提示端口被占用现象关闭服务端进程后马上重启抛出java.net.BindException: Address already in use。原因Socket 关闭后端口进入 TIME_WAIT 状态要等操作系统回收端口资源。解决开发阶段在绑定 ServerSocket 前设置 setReuseAddressServerSocket serverSocket new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(8888));这样设置之后端口复用能力更强重启不容易踩到 TIME_WAIT 的窗口期。如果是生产环境要注意影响但对这个项目来说setReuseAddress 是性价比最高的处理办法。6. 二次开发从聊天室到通用通信框架的改造路线6.1 协议升级从竖线分隔符换成 JSON当前的协议是MSG|sender|content字段一多就变得难解析。例如增加消息类型、时间戳、客户端版本号分词解析的代码会越来越脆弱。改造思路是引入轻量 JSON 库把消息序列化成 JSON 字符串再配合writeUTF发送服务端用 JSONObject 解析字段。这样扩展一个字段只需要在 Message 类里加属性不需要改动split(\\|)的逻辑。Message msg new Message(); msg.setType(private); msg.setFromUser(张三); msg.setToUser(李四); msg.setContent(你好); msg.setTimestamp(System.currentTimeMillis()); out.writeUTF(msg.toJson());6.2 并发模型升级用线程池替换裸线程“一连接一线程”的开发体验好但并发量上升到几百上千线程数量会耗尽资源上下文切换成本也在上升。改造方向是把new Thread(handler).start()替换成一个有界线程池ExecutorService pool Executors.newFixedThreadPool(16); while (true) { Socket socket serverSocket.accept(); pool.execute(new ClientHandler(socket)); }固定 16 个线程处理客户端连接大量空闲连接会占住线程这个模型的扩展性有一定瓶颈配合 NIO 或虚拟线程能进一步优化但对于演示项目线程池已经是足够的改善。6.3 心跳保活与断线检测客户端掉线的情况在真实网络里很常见。改造思路是加一个心跳机制客户端每隔 30 秒发送一次PING消息服务端记录每个连接的最后心跳时间服务端起一个定时任务每 60 秒扫描一次在线列表超过 90 秒没有心跳的连接主动 close 并从列表移除。这样既能让客户端及时发现网络异常也能保证服务端的在线列表是可信的。从那以后我每次做通信类项目都会强制先跑一遍并发上线、断线重连、消息乱序这三个场景再往下叠功能。这套 Socket 加多线程加数据库的技术组合就是聊天室的全部底子把这一层啃透后面写 IM 推送、写文件传输都只是在这个模型上换格式。希望帮到你。本文还有配套的精品资源点击获取