首页 / 资讯中心 / 文章详情

Java网络编程实战:从零手写一个多人聊天系统

Java网络编程实战:从零手写一个多人聊天系统 ★ FEATURED ARTICLE
真正理解多人聊天系统是Java网络编程里最值得亲手做一遍的入门项目。这个项目覆盖了Socket通信、多线程并发、I/O流处理、连接生命周期管理这几座大山把这些吃透再去看Netty、看分布式通信心里就有底了。这篇博文我从零开始把完整套路走一遍——从最朴素的多线程版本讲起讲清楚每一个设计决策背后的原因再给出能直接跑的代码最后把那些不踩一遍根本发现不了的坑都列出来。目标是你看完能自己动手写出来而不是只能看懂。1. 从一块白板开始先想清楚聊天系统要干什么1.1 先别写代码把需求说成人话一个多人聊天系统翻译成大白话就三件事有人连接上来客户端连服务器有人说话发消息给服务器所有人都能听到服务器把消息广播给每个人听起来很简单但这里藏着一个核心矛盾服务器要同时应对很多人每个人都在随时发消息。如果服务器一次只能处理一个人其他人就得一直等着那就不是聊天室是排队叫号。理解这个矛盾的突破口在于消息来的时间是不确定的。张三可能在晚上八点发消息李四可能在晚上八点零一秒发王五可能一直潜水不说话。服务器不能只盯着一个人看必须有办法同时留意所有人——在多线程技术里最直白的做法就是“来一个客户端开一个线程伺候着”。这就是BIOBlocking I/O阻塞式I/O模型的经典应用场景。虽然现在NIONon-blocking I/O非阻塞式I/O和Netty是主流但零基础阶段先用BIO把模型跑通你才能真正理解“阻塞”是什么体验后面学非阻塞模型才有对照。1.2 技术选型为什么第一版要用BIO加多线程有些人一上来就想用Netty我的建议是第一版千万别用。先搞清楚底层发生了什么再用高级工具。BIO版本的实现逻辑极其直观服务端创建ServerSocket绑定一个端口开始等待。客户端创建Socket指定服务器IP和端口发起连接。服务器accept()到一个客户端就new一个线程去处理这个客户端的后续操作。每个客户端线程负责读取该客户端发来的消息然后把消息转发给所有在线客户端。这套模型一眼就能看明白特别适合零基础建立心智模型。它的局限也很明显每来一个客户端就开一个线程客户端多了线程就多系统资源很快就撑不住了。但这是后面要考虑的优化问题不是现在要解决的问题。把简单版本写对了再谈高性能。1.3 消息流转的核心链路先用一个具体的场景把消息流转过程走一遍假设小A、小B、小C三个人在同一个聊天室。小A输入“大家好”点击发送。小A的客户端把字符串转成字节流通过Socket的输出流发送到服务器。服务器上负责小A的线程从Socket的输入流中读取这串字节。服务器把“小A说大家好”这个消息逐个写入所有在线客户端的输出流。小B和小C的客户端从自己的输入流读取到消息显示在聊天界面上。注意一个小细节服务器要把消息发给小A的客户端吗要的。因为小A自己也需要看到自己发送的消息这叫“回显”。如果不做回显用户会怀疑自己是不是没发出去。所谓实现一个聊天系统本质上就是把这条链路稳定、有序、不出错地跑通并且处理各种边界情况。2. 手写第一个类服务端的三块基石2.1 服务端主类建立据点写代码之前约定一下环境JDK 8或以上即可不需要任何第三方依赖一个普通的Java工程就够。服务端的主类逻辑不复杂核心代码长这样public class ChatServer { private static final int PORT 8888; // 保存所有客户端输出流的集合用于消息广播 private static ListPrintWriter clients new CopyOnWriteArrayList(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(服务器已启动监听端口 PORT); while (true) { Socket clientSocket serverSocket.accept(); System.out.println(新客户端接入 clientSocket.getRemoteSocketAddress()); // 来一个客户端就开一个线程处理 new Thread(new ClientHandler(clientSocket)).start(); } } }这里有一个关键封装clients集合存的是PrintWriter不是Socket。为什么因为广播消息的时候我们只想往每个客户端“写数据”不想关心它具体的Socket连接细节。把输出流提取出来单独保存后面广播逻辑会非常干净。为什么会用CopyOnWriteArrayList而不是ArrayList因为多个线程同时往里面加元素、可能在广播时同时遍历普通ArrayList在并发读写时会抛ConcurrentModificationException。CopyOnWriteArrayList是线程安全的每次修改都会复制一份快照读的时候不受影响适合这种“读多写少”的场景。第一版用这个虽然代价是内存开销大一点但能避免并发问题值。2.2 客户端处理器每个线程的完整生命周期ClientHandler这个类才是真正的核心。它的职责有三块注册客户端、监听消息、处理退出。public class ClientHandler implements Runnable { private Socket clientSocket; private PrintWriter writer; private String nickname; public ClientHandler(Socket socket) { this.clientSocket socket; } Override public void run() { try ( BufferedReader in new BufferedReader( new InputStreamReader(clientSocket.getInputStream())); PrintWriter out new PrintWriter( clientSocket.getOutputStream(), true) ) { this.writer out; // 第一步接收客户端设置的昵称 nickname in.readLine(); if (nickname null || nickname.trim().isEmpty()) { nickname 匿名用户; } // 第二步注册到在线列表 ChatServer.clients.add(writer); broadcast([系统] nickname 加入了聊天室当前在线人数 ChatServer.clients.size()); // 第三步持续监听该客户端的消息 String message; while ((message in.readLine()) ! null) { if (message.startsWith()) { // 私聊消息先不实现留到后面扩展 System.out.println(收到私聊指令待扩展); } else { broadcast(nickname message); } } } catch (IOException e) { System.out.println(客户端连接异常 e.getMessage()); } finally { // 客户端断开时清理资源 if (writer ! null) { ChatServer.clients.remove(writer); } broadcast([系统] nickname 离开了聊天室当前在线人数 ChatServer.clients.size()); try { clientSocket.close(); } catch (IOException ignored) { } } } private void broadcast(String message) { System.out.println([服务器转发] message); for (PrintWriter cw : ChatServer.clients) { cw.println(message); } } }这个类里有几个细节需要仔细讲。第一缓冲流是必须的吗是。如果不使用BufferedReader每次只读一个或几个字节网络传输中的半包问题会严重影响体验。BufferedReader提供了readLine()方法只有在读到换行符时才返回天然适合“一行一条消息”的聊天场景应用层协议就这么简单地定下来了一条消息 一行文本。第二PrintWriter构造函数第二个参数传true表示自动刷新autoFlush。意思是每次调用println之后缓冲区内容立即强制写出不会因为缓冲区满而攒着不发。聊天场景下消息讲究实时性这个参数非常重要。忘写这个参数是新手最经典的坑——服务器觉得发出去的消息迟迟到不了客户端就是因为数据还躺在缓冲区里。第三finally块的意义。在finally里清理资源是Java处理异常的标准姿势不管run方法是怎么退出的——正常读完了、客户端断开、IO异常——都要把该客户端从在线列表移除并向其他人广播离开消息。如果不做这步离线客户端会一直占着列表位置广播时还会往一个已经断开的Socket写数据轻则打印异常重则拖慢整个服务器。2.3 为什么广播要用这种看似笨拙的遍历有人可能会问依次遍历所有客户端逐个发送这效率高吗并发来消息的时候会不会乱效率问题在几十个连接的场景下完全不是问题println往本地Socket写数据的速度远比你想象得快。真正的性能瓶颈不在这个遍历而在线程数和Socket本身。消息会不会乱不会。每条消息走的是独立的Socket连接服务器每条消息都会完整写入各客户端的输出流。两个线程同时往同一个客户端发消息这种事不会发生因为每一个客户端在服务器端只对应一个线程是单写模型天然线程安全。真正要注意的是共享集合的并发安全而我们已经用CopyOnWriteArrayList解决了。这是BIO模型里“会踩的坑”和“已经踩过的坑”之间的一个典型例子。3. 从服务器视角切到客户端视角3.1 客户端要解决的核心问题服务端程序写好了客户端该长什么样刚才说过客户端要显示“自己发出的消息”和“别人发来的消息”这两种输入来源是完全独立的。用户输入是一个操作服务器推送是另一个操作它们不该互相等待。这个场景又和阻塞不谋而合InputStream的readLine()方法真的要等数据来了才返回。如果客户端主线程一直在等待“用户输入”用System.in读取当服务器发来一条新消息时主线程正卡在用户输入上根本没机会去读Socket输入流——消息就会卡住直到用户下一次输入时才有机会被顺带读到。这不是我们要的体验。解决方案依然是多线程一个线程专门读用户键盘输入一个线程专门读服务器推送。两个线程互不干扰。3.2 客户端代码实现说清楚了原理代码就顺理成章了。客户端主方法负责建立连接和启动两个线程public class ChatClient { public static void main(String[] args) throws IOException { Socket socket new Socket(127.0.0.1, 8888); System.out.println(已连接到聊天服务器); BufferedReader consoleReader new BufferedReader( new InputStreamReader(System.in)); // 先让用户设置昵称 System.out.print(请输入你的昵称); String nickname consoleReader.readLine(); PrintWriter serverWriter new PrintWriter(socket.getOutputStream(), true); serverWriter.println(nickname); // 开启两个线程一个读键盘一个读服务器 new Thread(new ServerListener(socket)).start(); // 主线程负责读取用户输入并发送 String userInput; while ((userInput consoleReader.readLine()) ! null) { serverWriter.println(userInput); } } }ServerListener是接收端的核心public class ServerListener implements Runnable { private Socket socket; public ServerListener(Socket socket) { this.socket socket; } Override public void run() { try ( BufferedReader serverReader new BufferedReader( new InputStreamReader(socket.getInputStream())) ) { String message; while ((message serverReader.readLine()) ! null) { System.out.println(message); } } catch (IOException e) { System.out.println(与服务器的连接已断开); } } }主线程while循环是另一个细节。consoleReader.readLine()在用户关闭程序前会一直阻塞等待输入每读到一行就发送一行。这两个线程互相配合就实现了“边聊边收”的效果。这里有个挺容易被忽略的细节主线程并不会因为你启动了一个新线程就停下来main方法会一直跑在while循环里。这个while循环什么时候结束只有两种可能用户按了CtrlC终止程序或者输入流结束了。在命令行模式下这个主线程通常就是“永远”地跑下去直到用户主动关闭程序。4. 把整个系统拼起来完整流程演示与细节打磨4.1 运行效果演示服务端启动后控制台会打印“服务器已启动监听端口8888”。打开三个终端窗口分别运行三个客户端实例。每个客户端启动后会提示输入昵称已连接到聊天服务器 请输入你的昵称假设三个客户端分别输入小明、小红、小刚。服务器会打印[服务器转发] [系统] 小明 加入了聊天室当前在线人数1 [服务器转发] [系统] 小红 加入了聊天室当前在线人数2 [服务器转发] [系统] 小刚 加入了聊天室当前在线人数3小红发一条“大家好”大家好终端上会立刻出现小红大家好小明的终端和小刚的终端也同时出现这行。所有人都能看到这就是广播的效果。4.2 把私聊功能加上设计一套简单的指令协议其实做完了群聊只要再加入一段逻辑就能支持私聊。实现方式是在服务器解析消息时检查消息是否以私有指令开头。在ClientHandler的run方法里把else分支扩展一下if (message.startsWith()) { // 格式昵称 消息内容 int spaceIndex message.indexOf( ); if (spaceIndex 0) { String targetNickname message.substring(1, spaceIndex); String privateContent message.substring(spaceIndex 1); sendPrivateMessage(targetNickname, nickname 私聊 privateContent); } else { out.println([系统] 私聊格式不正确请使用昵称 消息内容); } }这里需要把“在线客户端的nickname和writer的对应关系”存下来光靠一个writer的List不够用了。解决方案是引入Mapprivate static MapString, PrintWriter clientsMap new ConcurrentHashMap();注册时同时往List和Map里放退出时两边删除。每次发消息前从Map里查找目标昵称对应的writer如果存在就只发给他不存在就回执一条“用户不在线”。私聊的核心思路是从广播模式切换到定向发送模式。广播要遍历所有人私聊只要找到那一个人。这个对比非常直观。4.3 再进一步消息的实时性与心跳正常使用中如果某个客户端网络中断服务器要过很久才能感知到连接异常因为TCP的连接其实没有实时状态检测机制。要解决这个问题最常用也最简单的方案是心跳。做法不复杂客户端每隔一段时间比如30秒发送一个空消息或特殊标记服务端定期检查每个客户端的“最后心跳时间”超过一定时长没有心跳就主动断开连接。从实现上看只需要在队列里记一个lastHeartbeat字段加一个后台定时任务定期扫描就行。这是聊天室系统从“能跑”走向“能上线”的第一个关键升级。5. 常见问题与排查技巧实录那些不踩不会懂的坑5.1 消息发不出去十有八九是缓冲区问题症状客户端输入消息后自己看不到别人也看不到服务器啥反应没有。排查思路先看服务器有没有转发日志如果也没有95%的可能性是消息没有到达服务器。在客户端发送代码里PrintWriter如果没有开启autoFlushprintln之后数据还留在缓冲区里得要缓冲区满了才真正发出去。处理办法创建PrintWriter时传true或者主动调用flush()方法。这是一个典型的“程序看着没毛病跑起来不对”的问题。凡是遇到网络传输相关的问题第一个要查的就是到底有没有真正把数据送到对端。5.2 客户端退出后服务器报错没有清理在线列表症状关闭一个客户端后服务器控制台开始刷java.net.SocketException: Connection reset。原因客户端直接关闭了Socket服务器还在往这个Socket的输出流上写数据。TCP协议检测到对端不可达就会返回异常。处理办法确保在finally里及时从clients集合中移除对应的writer并在广播前检查writer是否已经关联了一个有效的Socket。正规做法是通过一个包装对象比如ClientConnection类把Socket和输出流、昵称封装在一起移除时统一处理。代码规范一点错误就少一半。5.3 获取服务器IP时踩的坑别用127.0.0.1连不上原因客户端程序和服务端程序如果都在本机127.0.0.1没问题但真实的聊天场景必然要跨设备连接要用服务器的局域网IP或公网IP。如果另一个设备连不上先ping一下再telnet验证端口通不通这是最基础的排查三步曲。5.4 一个有用的排查工具妙用telnet很多新手的错误做法是页面聊天工具没调通就直接写一个大客户端来测试。实际上聊天系统的服务端可以用telnet直接测——telnet会建立一条裸TCP连接你输入的每个字都会直接发给服务器。telnet 127.0.0.1 8888如果telnet能连上说明服务端监听没有问题问题在客户端代码。如果连不上检查防火墙和端口是否被占用。用telnet验证服务端这一点很多教程都没提过但它能把“代码问题”和“环境问题”快速区分开。先确认这条线索再仔仔细细查代码。5.5 多线程的陷阱并发修改异常症状两个客户端聊着聊着服务器抛出ConcurrentModificationException。原因在遍历clients集合发消息时另一个线程恰好在同一个集合里执行remove操作ArrayList在这种情况下直接爆炸。处理办法用CopyOnWriteArrayList替换ArrayList或者用Collections.synchronizedList加锁。更保险的办法是广播前先拷贝一份快照遍历快照。这个小问题几乎每个写过聊天室的人都会碰到它的背后是Java集合框架中“快速失败”机制的底层逻辑。理解了这一层异常你才算真正理解Java并发编程的入门第一课。6. 怎么看懂第一版代码的边界它适合学到哪一步写到这里你已经掌握了“能跑的聊天室”的全部核心逻辑。但作为项目实战我希望你也知道它的能力边界在哪里免得拿它直接上生产环境然后被现实教做人。这一版代码不能解决的问题包括第一线程资源浪费严重。每一个客户端对应一个线程如果聊天室有500个同时在线用户就有500个线程在运行。线程本身要占用内存和CPU调度资源撑不了太多并发。第二没有流量控制机制。如果有人恶意刷屏或者网络状况不好导致慢客户端堆积服务器的内存会越涨越高最后OOMOutOfMemoryError内存耗尽。第三没有可靠的消息记录和重传机制。如果客户端的网络在消息丢失的瞬间抖动一下消息就丢了没有重发机制也没有离线消息存储。解决这些问题的方向有三个按学习顺序讲方向一用线程池限制并发数。把new Thread改成Executors.newCachedThreadPool()或固定大小线程池能解决一点资源失控问题。方向二换NIO模型。用Selector实现单线程管理大量连接这一步的进阶就是Netty。理解BIO的痛处才有动力去理解NIO的巧妙。方向三引入消息队列概念。服务器先将消息丢到一个队列里再由分发线程统一发送这样的“生产者-消费者”思想是后面学习消息中间件比如RabbitMQ、Kafka的概念雏形。这个系统的价值恰恰就在于简单——它把网络通信、并发模型、多线程安全、I/O流处理这些基础知识全都串起来了。等你升级到NIO和Netty时会发现底层概念无非还是那几样Socket、Buffer、Channel、Selector。我自己当年写这个项目时最深的体会是不要害怕一次没跑通Bug排得多了你对这个模型的理解才会越来越接近真实世界。先让程序跑起来再去思考“怎么能跑得更好”永远是进阶的正确路线。
阅读完成 · 觉得有帮助?
咨询建站