简介这套Java聊天程序包含完整可运行的服务端与客户端源码采用经典C/S架构适合正在学习Java网络编程、需要课程设计或毕业设计参考的高校学生与自学者。服务端代码围绕聊天室服务器展开构造器通过新建服务器套接字创建监听端口运行方法中获取客户端主机名将每个连接封装为独立线程处理单次交谈并利用同步代码块维护多个客户端并发通信时的数据安全客户端则继承小应用程序实现图形界面初始化时创建聊天窗体并设置布局登录时建立套接字连接并启动新的聊天线程退出时主动关闭连接形成一套完整的建连、通信、断开流程。整个压缩包仅4个文件以3个Java源文件为主附带1个HTML页面作为客户端展示入口整包大小只有5KB轻量精炼、结构清晰适合直接导入开发工具阅读或二次改造。已有428人学习下载对理解Socket通信、多线程同步以及简易聊天室实现原理具有不错的参考价值。1. Java聊天程序服务端和客户端从零手写一套可用的消息系统聊天程序是Java网络编程中最典型也最容易被低估的项目。很多人以为它就是ServerSocket加Socket半天就能写完可真要两个人同时在两台电脑上聊起来消息不乱序、不丢失、不互相卡死会发现一堆黑匣子。这个标题要解决的就是两件事服务端怎么hold住多个客户端连接客户端怎么在收发消息时不堵死。读完你可以手写一套基于TCP的局域网聊天程序理解连接管理、消息分发和线程协作的完整链路。适合刚学完Java基础、想补网络编程短板的人也适合准备面试前突击Socket模型的开发者。2. 先搞清楚服务端和客户端的分工Socket模型与线程模型2.1 Socket通信的基本模型谁监听、谁连接Java里Socket编程的底层是TCP协议服务端和客户端各自扮演不同角色。服务端创建一个ServerSocket绑定到某个端口上然后进入阻塞状态等待客户端连接客户端创建一个Socket指定目标IP和端口发起连接请求。三次握手完成后两端各持有一个Socket对象可以通过输入输出流读写数据。这个模型里最反直觉的点是accept()不是接收数据而是接收“连接本身”。每次accept返回一个新的Socket这个Socket才是真正跟某个客户端通信的通道。如果你只写单线程版本只有一个客户端时没问题第二个客户端一来accept还在等第一个连接处理完第二个连接就悬着了。所以服务端一定要在accept之后立即把处理逻辑交出去让主循环继续接受新连接。import java.net.*; public class SocketDemo { public static void main(String[] args) throws Exception { // 绑定端口监听连接请求 ServerSocket serverSocket new ServerSocket(8888); while (true) { // 这里阻塞等待客户端连接返回的是独立的Socket Socket socket serverSocket.accept(); // 此处把socket交给另外一个线程处理主循环继续accept } } }这段代码是所有服务端的骨架。ServerSocket构造时如果端口被占用会抛BindException所以端口选择有讲究后面会专门讲。accept()是阻塞的线程会停在这里直到有客户端连进来。注意注释里强调“交给另外一个线程处理”这是单线程服务端和多线程服务端的唯一分界点。2.2 线程模型选择一个连接一个线程还是线程池常见的做法有两种一种是每个连接new一个Thread这种在连接数少的时候最简单直接另一种是用ExecutorService线程池限制并发线程数量避免客户端恶意建立大量连接拖垮服务端。我一般会优先选择线程池因为聊天场景下连接数通常不会太多但线程池给你留了扩展余地而且写起来并不比new Thread复杂。import java.util.concurrent.*; ExecutorService pool Executors.newFixedThreadPool(10); // 每收到一个连接就把连接处理任务丢进线程池 // pool.execute(new ClientHandler(socket));参数说明newFixedThreadPool(10)里这个10表示同时处理连接任务的最大线程数。如果你预期在线用户100人这里建议设为50到100之间。设小了线程不够用连接排队设大了每个线程默认栈空间1MB内存压力会上去。这是服务端第一个需要认真拍的参数。还有一个容易翻车的细节线程池的队列默认是无界的连接数突然暴增时任务排队等待虽然不会报错但客户端会一直等不到响应。生产环境建议用有界队列加拒绝策略不过在聊天程序的Demo阶段固定线程池已经够用了。2.3 通信协议先行字段设计与分隔符选择很多初学者上来就写读写代码结果客户端发的消息服务端读出来是拼接混乱的。根本原因是没有任何协议约定。协议不是高深的东西它就是在发数据之前把“消息的格式”定下来。比如最简单的文本协议一行一条消息消息内容里不允许出现换行符。服务端按行读客户端按行写这就是一个协议。我给这个聊天程序设计的协议是发消息时用“发送人昵称|消息内容”的格式一条消息占一行。私聊则加上“接收人|发送人|消息内容”的前缀。服务端每读到一行先按竖线拆字段再根据字段数量判定是公聊还是私聊。竖线在消息内容里出现的频率低不容易踩分隔符冲突的坑这个选择比逗号靠谱。// 协议示例 // 公聊nickname|hello everyone // 私聊bob|alice|hello bob // 服务端解析 String line in.readLine(); String[] parts line.split(\\|); if (parts[0].startsWith()) { // parts[0]是接收人parts[1]是发送人parts[2]是消息内容 } else { // parts[0]是昵称parts[1]是消息内容 }注意split(\|)里的双反斜杠竖线在正则里是有特殊含义的必须转义。这里还有一个隐藏问题如果消息内容本身就包含竖线split会拆出更多字段导致越界异常。解决方法是限制消息内容长度或者用更不容易冲突的分隔符比如控制字符。但在Demo阶段竖线加长度限制单条消息不超过500字完全够用。如果你打算让这条消息支持多行输入那协议就要改成“先是消息长度再是消息内容”的二进制方案那是另外一个深度了。文本协议虽然简单但胜在肉眼可读出了问题一眼就能看出格式对不对调试代价低很多。3. 服务端实现从ServerSocket到消息广播3.1 服务端主循环绑定端口、接受连接、交出处理权服务端核心结构分成三块主循环负责accept、客户端处理器负责某个连接上的收发、以及一个全局的客户端集合负责在线管理。这一步最容易犯的错是把收发逻辑写进主循环里导致后续客户端全部排队。正确写法是主循环只做两件事接受新连接、把连接丢给线程池。import java.io.*; import java.net.*; import java.util.concurrent.*; public class ChatServer { private static final int PORT 8888; private static final ConcurrentHashMapString, PrintWriter clients new ConcurrentHashMap(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(服务端启动监听端口 PORT); ExecutorService pool Executors.newFixedThreadPool(20); while (true) { Socket socket serverSocket.accept(); System.out.println(新客户端接入 socket.getRemoteSocketAddress()); pool.execute(new ClientHandler(socket)); } } }PORT是8888如果你在低端口比如80、8080部署Linux下需要root权限Windows下可能被其他服务占用所以聊天程序这种内部工具用8000以上的端口更省心。clients集合用ConcurrentHashMap因为多个线程会同时往里put和remove普通的HashMap在并发put时可能丢数据甚至死循环这是血泪经验。线程池大小这里设20覆盖几十个连接绰绰有余。3.2 客户端连接管理昵称注册与在线列表维护客户端连上来之后第一件事是上报昵称。我在ClientHandler的run方法里先读第一行把它当作用户昵称然后把昵称和对应的输出流放进clients集合。这里隐藏着一个关键点为什么只存PrintWriter而不存Socket因为后续广播消息只需要往输出流里写Socket本身不再需要持有输出流更轻量也避免误操作关闭整个连接。class ClientHandler implements Runnable { private Socket socket; private String nickname; public ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try { BufferedReader in new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); // 第一行是昵称 nickname in.readLine(); if (nickname null || nickname.trim().isEmpty()) { socket.close(); return; } clients.put(nickname, out); System.out.println(用户 nickname 上线当前在线 clients.size()); String line; while ((line in.readLine()) ! null) { // processMessage 的具体实现见 3.3 节 processMessage(line, out); } } catch (IOException e) { e.printStackTrace(); } finally { clients.remove(nickname); System.out.println(用户 nickname 下线当前在线 clients.size()); try { socket.close(); } catch (IOException ignored) {} } } }这段有个值得细想的顺序先读昵称再注册到clients。如果先注册再读昵称客户端连接建立但没发送昵称时集合里会残留一个没有实际身份的条目。另外finally块里一定要remove否则客户端异常断开后在线列表里就多出一个幽灵用户。PrintWriter初始化时第二个参数true表示自动flush意思是每次println后立即把数据刷到对端不必手动flush这个参数在网络编程里几乎必须置为true。3.3 消息广播与私聊分发逻辑processMessage是整个服务端的业务核心。公聊消息要广播给除发送者之外的所有在线客户端私聊消息则根据后面的昵称精确找到目标输出流。设计时我把发送者自己的消息回显到客户端本地所以服务端不需要给发送者再发一遍避免客户端看到两条重复消息。private void processMessage(String line, PrintWriter senderOut) { String[] parts line.split(\\|); if (parts[0].startsWith()) { // 私聊接收人|发送人|内容 if (parts.length 3) { senderOut.println(系统|消息格式错误); return; } String target parts[0].substring(1); PrintWriter targetOut clients.get(target); if (targetOut ! null) { targetOut.println(私聊 parts[1] parts[2]); } else { senderOut.println(系统|用户 target 不在线或不存在); } } else { // 公聊昵称|内容 if (parts.length 2) return; String message parts[1]; for (PrintWriter out : clients.values()) { if (out ! senderOut) { out.println(parts[0] message); } } } }细看这段遍历clients.values()时每个输出流的println都是线程安全的因为PrintWriter内部对加锁处理了。但要注意循环里如果某个客户端断开了它的PrintWriter还在集合里println不会立刻抛异常而是把错误吞掉消息就静默丢失了。这也是为什么心跳和清理机制很重要后面进阶章节会写。另外这里排除发送者自身用的是对象比较out ! senderOut前提是每个客户端对应的PrintWriter是唯一实例我们在注册时已经保证了这个约束。3.4 服务端退出与资源清理服务端进程正常退出时需要先把所有在线客户端的输出流关闭再关闭ServerSocket否则客户端会读到EOF或ConnectException。另一个容易被忽略的是线程池的shutdown。不调用shutdown非守护线程会阻止JVM退出进程看起来是“卡死”的。Runtime.getRuntime().addShutdownHook(new Thread(() - { System.out.println(服务端关闭中通知所有客户端); for (PrintWriter out : clients.values()) { out.println(系统|服务器正在关闭请稍后重连); } clients.clear(); }));ShutdownHook在JVM收到SIGTERM或CtrlC时触发把系统消息广播给所有客户端让用户知道是服务端主动关的而不是网络断了。这段代码在开发调试时特别有用因为你经常会在IDEA里点红色停止按钮如果没有shutdownHook客户端那边会卡在readLine上直到超时看起来像是程序挂了。注意shutdownHook里不能再启动新线程去执行复杂逻辑只是发个消息然后清理集合做完就返回。4. 客户端实现从Socket连接到收发消息4.1 客户端初始化与连接建立客户端的启动流程比服务端简单创建Socket连接指定IP和端口先发送昵称完成注册然后启动读线程和写线程。这里有一个新手很容易搞反的顺序必须先启动读线程再进入写循环。因为如果先写后读服务端返回的欢迎消息会积压在输入缓冲区里等用户输入第一条消息后才有机会被读出来体验上就是“回复慢半拍”。import java.io.*; import java.net.*; public class ChatClient { public static void main(String[] args) throws IOException { // 参数1是服务端IP参数2是端口都带上默认值 String host args.length 0 ? args[0] : 127.0.0.1; int port args.length 1 ? Integer.parseInt(args[1]) : 8888; Socket socket new Socket(host, port); System.out.println(已连接服务端请输入昵称); BufferedReader console new BufferedReader(new InputStreamReader(System.in)); String nickname console.readLine().trim(); PrintWriter out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); BufferedReader in new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); // 先发昵称 out.println(nickname); // 启动读线程后台收消息 new Thread(new ReceiveHandler(in)).start(); // 主线程处理用户输入发送消息 String input; while ((input console.readLine()) ! null) { if (input.equals(/quit)) break; out.println(nickname | input); } socket.close(); System.exit(0); } }这里new Socket(host, port)会阻塞直到TCP连接建立或超时。默认超时时间较长如果服务端没启动客户端会卡几十秒才抛ConnectException。调试时把IP抽成命令行入参避免每次跑都改代码。注意发送消息时拼的是nickname|内容跟服务端协议严格对应。如果用户输入里带了竖线这里就会拆出多余字段我在客户端做了个替换把输入中的竖线过滤掉防止协议错乱。4.2 读线程与写线程分离为什么不能一个线程干两件事客户端的核心矛盾在于readLine()是阻塞的而等待用户输入也是阻塞的。如果把读和写放在同一个线程里要么你先读那用户永远无法发消息要么你先写那别人发给你的消息你永远看不到。解决思路是拆成两个线程主线程负责读控制台写Socket后台线程负责读Socket写控制台两个方向的数据流互不干扰。class ReceiveHandler implements Runnable { private BufferedReader in; public ReceiveHandler(BufferedReader in) { this.in in; } Override public void run() { try { String line; while ((line in.readLine()) ! null) { System.out.println(line); } // 读到null说明服务端关闭了连接 System.out.println(连接已断开); } catch (IOException e) { if (!Socket closed.equals(e.getMessage())) { e.printStackTrace(); } } } }这个run方法就是一个典型的“读循环”。注意catch里对Socket closed的过滤因为当你在主线程调用socket.close()时读线程的readLine()会抛异常这是预期行为不该打印堆栈。另外一个关键点是控制台输出和读线程输出可能交错出现消息和提示混在一行的情况这个在Demo阶段无伤大雅真要做的话可以引入一个独立的输出线程把要显示的内容都塞进一个队列由它统一打印到控制台。4.3 消息编码与汉字乱码的解法客户端和服务端如果各自写了UTF-8编码的Input/OutputStream装饰乱码问题基本不会出现。但有个经典的坑BufferedReader.readLine()在读取中文时服务端发送的是100个汉字readLine会一次性返回整行没问题但如果某条消息恰好超过8KBTCP底层会分多个包传输readLine内部会处理重组这个不需要你操心。真正会导致乱码的是两端编码不一致比如服务端用UTF-8输出客户端用GBK读取结果就是一堆“锟斤拷”。// 统一使用UTF-8两端必须一致 new InputStreamReader(socket.getInputStream(), UTF-8) new OutputStreamWriter(socket.getOutputStream(), UTF-8)这里有个玄学点有时候在中文Windows环境下new OutputStreamWriter(socket.getOutputStream())没指定编码时默认使用系统编码GBK而服务端在Linux上默认是UTF-8两边一交叉就乱。所以无论服务端还是客户端创建Reader和Writer时都要显式指定UTF-8别依赖默认值。我的习惯是在代码里写死编码同时IDE的工程文件编码也统一调成UTF-8这样从头到尾一条链路都是统一编码排查起来不至于无从下手。4.4 断线重连与异常处理客户端跑起来之后网络断掉是常态。Wi-Fi切换、服务端重启、电脑休眠再唤醒都会导致TCP连接断开。断开的直接表现是读线程的readLine()返回null或抛出IOException。如果客户端不做处理用户看到的现象是“消息发不出去了”但程序没有任何提示就卡在那。// 主写循环中检测退出 while ((input console.readLine()) ! null) { if (/quit.equals(input)) { break; } try { out.println(nickname | input); } catch (Exception e) { System.out.println(发送失败连接可能已断开); break; } }我一般会给客户端加一个简单的重连机制当读线程检测到连接断开后设置一个AtomicBoolean标志位主写循环每次发送前检查这个标志如果为true则提示用户输入/reconnect重新连接。这个机制不复杂但能让程序的健壮性上一个台阶。重连时注意要重新发送昵称因为服务端把每个连接当成新用户不会记住之前状态。还有一点重连要加间隔控制比如每次失败后线程sleep 3秒再尝试防止服务端还没起来时客户端疯狂重连。5. 联调避坑客户端服务端对不上时去哪查5.1 端口被占用BindException与端口选择策略现象是服务端一启动就抛java.net.BindException: Address already in use。原因是端口被别的进程占用了。解决方法是先查端口Windows用netstat -ano | findstr 8888Linux用lsof -i:8888找到PID后kill掉。但换一个思路开发调试时选端口前先用一个零端口探测一下确认可用后再正式启动。// 检测端口是否可用0表示让操作系统随机分配 try (ServerSocket ss new ServerSocket(0)) { int availablePort ss.getLocalPort(); System.out.println(可用端口 availablePort); }注意这个探测和正式绑定之间存在时间窗口可能被其他进程抢占所以更稳妥的做法是固定端口加失败后递增重试。我一般会写一个for循环依次尝试8888、8889、8890三个端口哪个能绑定成功就用哪个这样既不跟同事撞端口也不会因为一次临时占用就起不来服务。5.2 读阻塞导致收发互相卡死现象是客户端能发消息但收不到别人的回复或者服务端某个连接卡住不动。原因是有人在一个线程里同时做了读写服务端在while循环里先用readLine读消息处理完再println回消息但如果对端一直不发新消息readLine会一直阻塞后面的println根本执行不到。客户端同理主线程先readLine等键盘输入就永远没机会去读服务端推来的新消息。解决方法是严格分离读写线程服务端每个连接只负责读和业务分发不直接回写客户端读线程只管读主线程只管写。如果你一定要在同一个线程里收发那只能上NIO或给Socket设置SoTimeout但代码复杂度会上升一个量级。双线程模型是聊天程序最省心的解法。5.3 并发写入与消息边界广播竞争和粘包半包现象是两条消息偶尔拼接成一条或者一条长消息的内容被拆开出现乱序。原因有两个层面多个Handler线程同时对同一个客户端输出流执行println字节在连接上交织TCP是流协议本身不维护消息边界。有趣的是这两类问题在文本协议下往往同时出现容易被误判成同一个bug。解决方法是双管齐下。第一为每个客户端输出流配一个私有锁广播前先拿锁把“遍历客户端集合”和“println”合并成一个临界区避免消息穿插。第二协议里约定每条消息以换行符结束且消息内容不允许出现换行符这样BufferedReader.readLine()会天然完成拆包粘包半包在文本协议下就不是问题。需要小心的只有一点如果客户端粘贴多行文本进输入框换行符会破坏协议简单做法是输入时过滤掉换行。5.4 Socket关闭顺序与服务端异常堆积现象是客户端主动点退出后服务端控制台疯狂刷IOException: Connection reset。原因是客户端直接调socket.close()没先通知服务端。服务端的readLine()在收到正常关闭时会返回null而不是抛异常但如果是客户端进程被强杀TCP会发RST包服务端再往这个连接写数据就会抛Connection reset。解决方法是客户端主动退出时先发一条/quit消息等服务端正常移除用户后再关Socket。服务端读到/quit就调用clients.remove并关闭连接整个流程没有异常。如果遇到强杀断网这类无法预料的场景服务端catch块里要忽略Connection reset和Broken pipe这类“连接已死”的异常不打印堆栈交给finally去清理客户端引用就行。5.5 局域网联调连不上和中文乱码两个环境坑现象是服务端本机自测一切正常换到另一台电脑就connect超时或者消息到了对面变成“??????”。连不上的原因就两个服务端绑定在127.0.0.1上监听不到局域网防火墙拦截了Java进程的入站连接。乱码的原因也有两个两端编码没统一以及Windows控制台用GBK显示UTF-8文本——这种属于“显示乱但传输不乱”。解决方法是按顺序排错。先确认服务端new ServerSocket(8888)用通配地址监听再用telnet 服务端IP 8888验证端口通不通不通就查防火墙Windows弹窗时选“专用网络”放行Java。编码问题先把所有InputStreamReader和OutputStreamWriter显式写成UTF-8再执行chcp 65001切换cmd代码页如果切换完显示正常了说明网络传的一直是好的只是控制台显示方式不对。6. 进阶给聊天程序加心跳检测告别幽灵在线到这里基本能聊起来了但运行一晚上后你会发现clients集合里残留着几个“幽灵用户”名字还在消息发过去却没人回。根因是网络异常断开时服务端没来得及清理。解决方案就是心跳机制服务端定期检查每个客户端的最后活跃时间超过阈值就踢下线。// 客户端每隔30秒发送一个心跳包 // 在写线程里另起一个定时任务 ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - out.println(heartbeat|keepalive), 30, 30, TimeUnit.SECONDS);服务端在processMessage里单独处理heartbeat协议字段每次收到就更新该客户端的lastActiveTime。再另开一个守护线程每10秒遍历clients集合检查当前时间减去lastActiveTime是否超过90秒超时就把对应连接关掉并从集合移除。这里的30秒发送间隔和90秒超时阈值是配合关系要保证超时阈值至少是发送间隔的2倍否则网络稍微抖动一下就会被误判下线。这样做的收益是在线列表始终反映真实状态消息不会发给不存在的用户服务端的资源也不会被死连接耗尽。这个机制从聊天程序延展到任何长连接系统都适用算是网络编程里最基础也最实用的保活手段。说实话我第一次跑通这套程序时觉得最难的不是Socket API而是“你以为在写网络代码其实在写并发代码”这种认知转换。客户端服务端两头跑、明明本机没问题换台机器就翻车这些坑全踩过一遍才算真的学会。回看整个项目Socket API只占两成剩下八成是线程协作、资源清理和协议设计。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?