简介面向Java网络编程初学者与课程设计者的一份C/S结构聊天程序源码包整合服务端与客户端完整实现适合用来理解Socket通信、多线程处理与GUI界面搭建。程序由ChatServer服务端、ChatHandler聊天线程及ChatRoom客户端组成服务端通过ServerSocket建立连接并用synchronized同步客户列表客户端继承Applet创建聊天窗体通过login/logout方法实现连接与关闭。压缩包共4个文件含3个Java源文件和1个HTML页面整体仅5KB代码精简便于直接阅读、调试与二次改造。已有428人学习下载借助该示例可快速掌握主机名获取、线程并发同步、Socket连接建立与关闭等关键知识点是Java网络编程入门和课设参考的实用素材。1. Java聊天程序实战从Socket原理到一份能跑通的服务端客户端很多Java学习者卡在同一个地方书上把Socket、ServerSocket讲得清清楚楚真正动手写一个聊天程序却不知道怎么把服务端、客户端、多线程、界面几块拼到一起。这份Java聊天程序服务端和客户端程序做了一件很实在的事——把完整可编译的代码拆成服务端模块、客户端模块和共用协议三部分覆盖了从TCP连接建立到消息转发、多人同时在线、异常断开处理的全过程适合课程设计、毕业设计同学和想快速掌握同步Socket编程的开发者。本文会按“结构拆解 → 编译复现 → 踩坑记录 → 改造思路”的顺序带你把这份资源吃透最后分享两条调试经验。2. 把服务端和客户端拆开看线程模型与消息协议决定能不能跑2.1 服务端核心ServerSocket监听、Accept循环与每客户端一个线程服务端的代码骨架其实很固定几乎所有聊天服务端都是同一种形态先创建ServerSocket绑定一个端口然后进入accept循环等待客户端接入每个客户端连接到来后单独启动一个线程去处理它自己的消息收发。文件里这段代码做的是标准做法核心逻辑也就几十行。// 服务端核心监听、接入、分发 public class ChatServer { private ServerSocket serverSocket; private MapString, PrintWriter clients new ConcurrentHashMap(); public void start(int port) throws IOException { serverSocket new ServerSocket(port); // 绑定端口 System.out.println(服务端已启动监听端口 port); while (true) { Socket socket serverSocket.accept(); // 阻塞等待客户端接入 // 每个客户端连接交给一个独立线程处理 new Thread(new ClientHandler(socket)).start(); } } private class ClientHandler implements Runnable { private Socket socket; private BufferedReader in; private PrintWriter out; public ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try { in new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); // 读取第一行作为昵称 String nickname in.readLine(); if (nickname null) { return; } clients.put(nickname, out); System.out.println(nickname 上线了当前在线 clients.size()); // 循环读取该客户端的消息 String message; while ((message in.readLine()) ! null) { String text [ nickname ] message; System.out.println(text); broadcast(text); } } catch (IOException e) { System.out.println(客户端连接异常 e.getMessage()); } finally { if (nickname ! null) { clients.remove(nickname); } closeQuietly(socket); } } private void broadcast(String text) { for (PrintWriter writer : clients.values()) { writer.println(text); } } } }这段代码有两个关键取舍。第一为什么坚持每个客户端一个线程而不是一个线程循环处理所有Socket因为accept返回的Socket要持续等待这个客户端发来的数据如果放在同一个线程里去轮询所有连接代码复杂度会大幅上升且某一连接阻塞会影响所有人每客户端一线程是同步Socket时代最直观的并发模型几百人同时在线完全扛得住学Netty框架时再换异步也不晚。第二为什么用ConcurrentHashMap而不是普通的HashMap因为多个ClientHandler线程会同时往clients里put、remove、遍历HashMap在并发写入时可能出现内部链表成环导致死循环ConcurrentHashMap用分段锁或CAS避开了这个问题这份资源直接用上去是对的。PrintWriter的构造参数里那个true值得单独说明。它表示自动flush每次println之后会立即把缓冲内容写入网络流。如果没有这个参数消息会积压在内存里服务端要等到缓冲满了才发出去聊天就会变成几秒蹦一条体验极差。同理BufferedReader用readLine读一行协议就约定每条消息以换行符结尾这就把网络流的粘包问题从设计上规避掉了后文避坑部分会细说。2.2 客户端核心读线程与写线程分离、Swing界面刷新注意点客户端的难点不在Socket本身而在Socket的数据读写和界面刷新是两种节奏。用户消息是手动点击发送的频率低但服务端随时可能广播过来一条消息如果直接在主线程里阻塞读Socket窗口就会卡死按钮点了没反应。所以客户端的标准做法是拆两个线程主线程负责Swing事件处理也就是接收用户输入、点击发送按钮另一个后台线程专门阻塞在readLine上等待服务端数据。这份代码就用这个模型。public class ChatClient { private Socket socket; private BufferedReader in; private PrintWriter out; private JTextArea messageArea; private JTextField inputField; public void connect(String host, int port) throws IOException { socket new Socket(host, port); // 连接服务端 in new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF-8)); out new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); out.println(某开发者); // 发送昵称与服务端第一行协议对应 // 后台读线程接收服务端消息并刷新到界面 Thread reader new Thread(() - { try { String line; while ((line in.readLine()) ! null) { final String msg line; // 必须在事件调度线程中更新Swing组件 SwingUtilities.invokeLater(() - appendMessage(msg)); } } catch (IOException e) { appendMessage(连接已断开); } }); reader.setDaemon(true); reader.start(); } public void sendMessage(String text) { out.println(text); appendMessage([我] text); } private void appendMessage(String text) { messageArea.append(text \n); } }这里有一个必踩的Swing规则不要在主线程以外直接操作JTextArea这类界面组件。Swing组件不是线程安全的后台读线程收到消息后如果直接调messageArea.append轻则界面刷新延迟重则抛异常导致线程死掉表现为聊天窗口收不到消息但程序还没退出。SwingUtilities.invokeLater是把更新动作丢给事件调度线程去执行这是所有带界面的网络程序必须遵守的纪律。另外一个实用细节是读线程设为daemon守护线程。这样当用户关闭聊天窗口时主线程退出后台读线程也会自动结束不用手动写stop逻辑。如果它是普通线程关窗口后JVM会发现还有活线程程序就退不干净Linux服务器上跑久了会积累一堆僵尸Java进程。connect方法里发送昵称那一行对应的是服务端readLine读到的第一行数据这个顺序两边必须严格一致否则服务端会把用户第一条聊天消息当成昵称收上去聊天记录就全错乱了。2.3 消息协议为什么是一行文本而不是JSON或二进制服务端和客户端之间必须约定消息格式。最简单的方案是每条消息用换行符分隔这也是这份资源默认采用的协议然后把客户端收上来的String当作昵称或消息来处理。这个设计适合小型聊天程序但如果你准备往更复杂的方向扩展得先搞清楚它省掉了什么麻烦。协议命名规则再明确一点第一行是昵称后面每一行都是一条聊天消息由服务端统一加工成[昵称] 消息内容再广播给所有人。为什么要简化成一行文本因为BufferedReader.readLine天然以换行符为界读到一行就返回一行直接从JDK层面处理了粘包和拆包问题——TCP是字节流没有消息边界一次网络包可能同时包含两三条聊天消息也可能一条消息被拆成两个包到达readLine帮你把边界重新切好了。如果用JSON还得处理截断和重组的逻辑对聊天这种小消息来说是过度设计。协议简单的好处是排查问题直观。服务端println一条消息到客户端客户端用readLine读中间任何一个环节出问题你只要抓两种字符串就能定位。我看了一下这份资源里的实现广播逻辑是在ClientHandler线程里遍历clients字典逐个writer.println中间夹着一条System.out.println打日志这个日志在调试时很有用——能区分“客户端没发过来”和“服务端没广播出去”两种故障方向。对学习用途一行文本协议是最划算的选择生产级聊天系统才是JSON 长度前缀的戏码。3. 把代码跑起来编译、启动、联调三步走3.1 环境准备与编译JDK版本选择与目录结构这份代码是标准Java不依赖第三方库理论上JDK 8到JDK 21都能编译但有一点要注意如果你的环境变量配错或者用了IDE自带的JRE编译通过后运行时可能报ClassNotFoundException那是启动器版本不一致导致的。我习惯命令行先确认当前JDK版本再决定怎么编译。# 确认当前JDK版本 java -version javac -version # 工程目录结构假设如下 # chat/ # ├── src/ # │ ├── ChatServer.java # │ └── ChatClient.java # └── out/ # 编译全部源码到out目录 javac -encoding UTF-8 -d out src/ChatServer.java src/ChatClient.java # 启动服务端先起服务端 java -cp out ChatServer 8888 # 另开两个终端各启动一个客户端 java -cp out ChatClient 127.0.0.1 8888这三个命令建议按固定顺序执行。先确认javac版本的目的是排掉编码坑——如果系统默认GBK而源码是UTF-8编译时中文注释和字符串常量可能变成乱码所以javac后面加-encoding UTF-8是必选项而不是可选项。这是很常见的一个翻车点在Windows控制台直接敲javac src/ChatServer.java能编译成功但打开客户端窗口看到的全是乱码因为锁的字符集和源码不一致。目录建议保持src和out分离不要把class文件散落在源码目录里。聊天程序体积小混在一起看似省事但一旦改完代码重新编译旧class文件很容易被残留下来然后你调试半天发现改的代码没生效其实JVM加载的是旧版本。这个坑我在别的项目里踩过多遍现在每次编译前习惯性先执行rm -rf out再重新生成宁可多花两秒。3.2 启动顺序与服务端日志先服务端还是先客户端聊天程序必须先把服务端拉起来再启动客户端。你先启动客户端大概率报连接被拒绝java.net.ConnectException: Connection refused这不是程序 bug是TCP服务还没监听。工作目录和端口要一致下面是完整的启动顺序验证# 服务端起在8088端口 java -cp out ChatServer 8088 # 正常输出会看到一行服务端已启动监听端口8088 # 客户端连接 java -cp out ChatClient 127.0.0.1 8088 # 输入昵称后服务端终端出现某开发者 上线了当前在线1服务端终端上打印的“服务端已启动监听端口8088”这几行字是整个程序健康状态的第一道信号。如果绑定端口失败会直接抛出BindException而不是静默运行。看到它再开客户端比盲跑靠谱得多。客户端的“上线了”日志则是第二道信号表示TCP连接、第一行昵称协议、clients注册三步全部成功。如果客户端这里没反应先去服务端看是不是显示上线了——上线了但客户端窗口没反应则是读线程或Swing刷新有问题上线也没打出来则是socket连接和流包装的问题。当多个客户端同时在线时服务端日志能直接告诉你消息是哪个昵称发的、当前总在线几人。我在实际调试环境里总是让服务端开着终端输出不用.debugger靠打印日志足够定位90%的问题。3.3 双客户端互聊验证消息流怎么走服务端跑起来后开两个客户端窗口分别输入不同昵称在窗口A里敲下一行字回车窗口B里应该能带[A的昵称]前缀显示。这一步过了这个聊天程序的核心链路就算完全打通。验证时我一般会按下面这个顺序操作# 窗口A客户端1 # 昵称alice # 输入你好有人吗 # 窗口B客户端2 # 昵称bob # 输入在的收到。 # 服务端终端输出 # [alice] 你好有人吗 # [bob] 在的收到。走通之后要特别验证一个容易被忽略的点只有发消息的客户端自己能看得见自己的消息。因为客户端sendMessage里把[我] 消息内容直接追加到自己的消息区而其他客户端看到的是服务端广播的[昵称] 消息内容两边格式不同。这个设计是刻意的让你能直观分清“本地回显”和“服务端广播”两条路径。如果哪个版本把这两条路径搞混了就会出现自己发的消息显示两遍或者格式对不齐的问题。再多验证一步把窗口A直接关掉去服务端看bob有没有异常。正常情况下服务端ClientHandler的readLine会返回null或者抛SocketException当前客户端对应的PrintWriter会被从clients里移除服务端不会崩。这是断线清理逻辑的核心验证很多demo在这块处理不好客户端强退后整个服务端跟着崩这份资源如果没这个处理你自己也要补上后章节细说。4. 调试与避坑端口占用、编码错乱、消息只发不达4.1 服务端重启时报端口被占用现象服务端程序退掉后马上再次启动控制台报java.net.BindException: Address already in use端口8888起不来。原因上一个服务端进程没完全退出Socket还没从TIME_WAIT状态释放。这在Windows和Linux上都很常见尤其是你用IDE停止按钮或者点击了控制台的关闭Java进程可能还残留在后台。解决先找出占用端口的进程再杀掉不要换端口了事因为你客户端已经写死了8888。# Windows查看占用端口的PID netstat -ano | findstr 8888 taskkill /PID 你的PID /F # Linux查看占用进程 lsof -i:8888 kill -9 你的PID另外有个习惯问题关掉服务端最好通过程序自己的退出机制比如在客户端发一条命令触发服务端正常退出。直接kill -9会跳过finally里的清理逻辑等下次启动时一堆残留端口和未释放的资源全跳出来。服务端异常退出后等待30秒到2分钟再重启TIME_WAIT期过了也能自动恢复。4.2 客户端中文消息变成问号或乱码现象双端互聊时英文正常中文全部变成??????或者乱码。原因JVM默认字符集和代码里的读写字符集不一致最常见的场景是Windows上控制台默认GBK代码里InputStreamReader用的UTF-8或者反过来。两边约定不一致数据进服务端时decode出错出客户端时又错一次中文从源头就丢了。解决统一字符集并且启动时强制指定JVM字符集避免它猜来猜去。java -Dfile.encodingUTF-8 -cp out ChatServer 8888 java -Dfile.encodingUTF-8 -cp out ChatClient 127.0.0.1 8888代码层面还有一个隐蔽点PrintWriter的构造函数有第二个参数autoFlush构造时指定true才能保证println立即发送至网络如果漏了这个参数短消息会一直积在缓冲区里直到缓冲满表现就是“消息过半天才蹦出来”。4.3 客户端强退后服务端空转或死循环现象A客户端直接断网或点X关闭服务端B客户端没收到提示过一会服务端CPU占用飙升。原因readLine在连接正常关闭时返回null可以退出循环但遇到异常中断网络时抛的是SocketException而不是返回null。如果代码不区分这两者异常没处理干净就可能卡在循环里空转。更严重的变体是Socket半开状态——客户端突然关机服务端读不到数据也检测不到断开连接一直挂着占着资源。解决写代码时把IOException捕获住无论异常还是null都走finally清理。同时给socket设置超时参数防止半开连接永久挂起// 服务端侧设置读取超时毫秒 socket.setSoTimeout(60 * 1000); // 但要注意一旦超时会抛SocketTimeoutException // 如果只是用来保活检测需要捕获后继续循环。实际环境中我习惯每隔30秒从服务端发送心跳包到客户端客户端读线程接收并回一帧确认帧。如果超过60秒没收到客户端心跳服务端就主动关闭该Socket并做清理动作。这个小技巧保住了很多看起来“无缘无故”挂住的连接。4.4 消息发出去了但对方没收到现象A客户端窗口显示“[我] 你好”B客户端没有任何反应服务端日志也没打出来。原因这不是网络问题而是协议或流的问题。如果服务端日志没有消息输出先排查是不是客户端发消息时用了System.out.println而不是out.println——前者打到了控制台而没进Socket的OutputStream消息根本没离开本机。如果服务端日志有消息但B客户端没显示则是广播环节出了问题要么广播时遍历的是一个空clients集合要么B客户端的读线程卡在了别的IO上。解决按链路分段验证。第一段看客户端发送后自己的日志有没有打印第二段看服务端收没收到第三段看服务端广播时clients集合里有没有目标客户端的PrintWriter。我这里有一个很实用的排查思路在服务端广播方法里临时加一条System.out.println(广播给 clients.size() 个客户端)再互发一条消息日志直接告诉你集合里有几个人。如果集合里只有一个人就是这个客户端的写流被remove了或没注册进去。我遇到过一次最迷惑的场景A、B两个客户端都能收到服务端的广播但A始终收不到B发来的私聊内容。最后定位原因是B连接服务端时少发了一行昵称服务端把B的第一条聊天消息readLine当成了昵称所以B永远没有注册进clients集合——消息从B传到服务端成功了但服务端以为B是“未命名连接”广播池里自然没有它。后来我把服务端启动后打印注册成功日志的习惯固定下来了每个客户端接入时打一条“xxxx已注册当前在线N人”没这条日志的客户端一定是协议握手没走完。5. 进阶改造心跳检测、私聊与离线消息的扩展思路聊天程序跑通基础版本之后真正的价值在改造过程。我自己会把三块必改项排个优先级断线重连最紧急接着是私聊然后才是离线消息。断线重连不解决其他功能做得再多一个破网就能让用户放弃。断线重连的代码思路其实不复杂。客户端读线程发现连接异常断开后不要直接把界面置灰而是启动一个重连定时器每3秒尝试一次重建Socket并重新走一遍昵称注册private void startReconnectThread() { Thread t new Thread(() - { while (!isConnected) { try { Thread.sleep(3000); connect(serverHost, serverPort); // 复用原有connect方法 isConnected true; SwingUtilities.invokeLater(() - appendMessage(已重新连接)); } catch (Exception e) { // 继续重试 } } }); t.setDaemon(true); t.start(); }重连这里有一个多次踩过的坑重连成功前用户如果在输入框敲了消息这些消息会调用out.println而out引用指向旧的、已失效的Socket关联的输出流导致似连非连的状态。所以在客户端里要维护一个volatile boolean isConnected标志位每次写消息之前先检查它。服务端这边重连回来还要处理一件事客户端自动重新发送昵称后旧昵称对应的PrintWriter已在断线时被移出clients集合新注册进去没问题但聊天记录要补发的话需要一个历史消息数组用ArrayList或LinkedList在服务端保存最近N条。心跳检测也一并加上。客户端每隔30秒向服务端发送一行约定心跳包比如PING_HEARTBEAT服务端的ClientHandler读到这一行不要广播只更新时间戳。另起一个定时任务每60秒扫描一遍所有客户端的时间戳超过90秒没心跳的Socket直接关闭。这个思路同样适用于WebSocket项目区别只是心跳格式从一行文本变成JSON字符串。私聊改造则要在协议上动手脚。把消息格式从一行纯文本升级成结构化的三字段格式接收人|消息内容|发送人。服务端解析出接收人后直接往对应PrintWriter写而不广播发送人不匹配则忽略。改动量不大但牵扯到协议兼容性——旧的客户端如果还是发一行纯文本服务端解析时会出异常所以要在解析时做好防御性判断。离线消息是最后加的功能需要一个持久化容器。生产场景一般用Redis或者MQ教学场景用一个ConcurrentHashMapString, List 存离线用户的消息列表就行用户上线时一次性把这串列表推给它。这部分工作做完之后整个聊天程序的架构就从“能聊”变成了“能抗”你再去看Netty源码或MQ实现时也会清楚它们解决的是这一串问题里的哪几环。我从那以后每次拿到一个通信类程序都会强制自己走一遍“断线场景模拟”这个流程先开着两边手动断掉网络几秒再恢复观察客户端能不能自动重连、服务端会不会误删用户。不只是聊天项目WebSocket推送、IM组件、甚至是调试网络监控时都养成了这个习惯。希望这篇文章能帮你少走几个弯路尽快把这份Java聊天程序资源跑成你自己的东西。本文还有配套的精品资源点击获取