JavaNIO摸鱼聊天室:单线程玩转多连接
我做了一个很朴素的小软件,名字叫 NIO 摸鱼聊天室。它没有网页,没有数据库,没有复杂登录页,也没有把自己包装成大型即时通信平台。它只完成一件事:多个客户端连接到同一个服务端,然后进行群聊、私聊、查看在线用户和退出。功能看起来很轻,但它刚好覆盖了 Java NIO 中最核心的几个东西:ServerSocketChannel、SocketChannel、Selector、SelectionKey、ByteBuffer,以及非阻塞通信。
这个项目的技术出发点很清楚。传统 BIO 模型的问题在于“一个连接绑定一个线程”。连接数量一旦上来,线程数量也会跟着膨胀,服务器很容易进入一种表面忙碌、实际窒息的状态。更麻烦的是,BIO 中的 accept() 和 read() 都可能阻塞。没有客户端连接时,线程等着;没有数据到达时,线程继续等着。
NIO 的思路冷静得多。通道设置为非阻塞,事件交给 Selector 统一监听。哪个连接有事,就处理哪个连接;没有事件发生,就安静等待。
这个聊天室的服务端使用一个 Selector 监听所有客户端连接。它有点像一个情绪稳定的前台,不会因为来了十个人就立刻分裂出十个自己。有人要连接,就触发 OP_ACCEPT;有人发消息,就触发 OP_READ。服务端根据事件类型做不同处理。
项目结构刻意保持简单。目标是把 NIO 本身讲明白,所以整个软件只需要两个主要入口:ChatServer 和 ChatClient。服务端负责监听端口、维护在线用户、解析消息命令、转发群聊和私聊。客户端负责连接服务器、读取用户输入、发送消息,同时单独启动一个线程接收服务器推送。为了方便在 IntelliJ IDEA 中使用,我把项目做成 Maven 结构,并配置了可以直接点击绿色运行按钮的启动项。点一下,服务端启动;再点一下,客户端上线。
服务端启动后,会先创建 ServerSocketChannel,绑定端口,并设置为非阻塞模式。这个动作非常关键,因为 Selector 管理的是非阻塞通道。核心代码大概如下:
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(8888));
Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
这里的 ServerSocketChannel 负责监听客户端连接,Selector 负责管理事件,SelectionKey.OP_ACCEPT 表示服务端关心“有没有新客户端连接进来”。这就是整个 NIO 服务端的第一层骨架。
之后服务端进入主循环。循环里最重要的两个动作是 selector.select() 和 selectedKeys()。前者负责等待事件发生,后者拿到已经发生的事件集合。每个事件由一个 SelectionKey 表示,服务端根据这个 key 判断当前事件类型。
如果 key.isAcceptable() 为真,说明有新的客户端连接;如果 key.isReadable() 为真,说明某个客户端发来了数据。每次处理完一个事件后,都要调用 iterator.remove()。
当客户端连接进来后,服务端通过 accept() 拿到一个 SocketChannel。这个 SocketChannel 就代表某一个具体客户端。接着服务端把这个客户端通道也设置成非阻塞,并注册到同一个 Selector 上,监听它的读事件:
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
最后的 ByteBuffer.allocate(1024) 很关键。它给当前客户端连接绑定了一个缓冲区。后续这个客户端发送消息时,服务端就从这个缓冲区中读取数据。ByteBuffer 在 NIO 里承担数据中转站的角色。数据从通道进来,先进 Buffer;数据要写到通道,也先从 Buffer 出去。它不负责解释业务含义,只负责安静地管理字节。
ByteBuffer 有几个核心参数:capacity、limit、position 和 mark。capacity 是容量,分配之后固定;position 是当前操作位置;limit 在写模式下表示最多能写到哪里,在读模式下表示最多能读到哪里;mark 像一个书签,可以让 position 回到之前标记的位置。最容易绕晕的是读写模式切换。写入数据后,需要调用 flip() 切换到读模式;读完之后,再调用 clear(),让缓冲区准备下一轮写入。
在聊天室里,客户端发送消息时,会把字符串转成 UTF-8 字节,然后放进 ByteBuffer,再通过 SocketChannel.write() 写给服务端。服务端读取时则反过来:先把通道里的字节读入 Buffer,调用 flip() 切换成读模式,再根据 buffer.remaining() 创建 byte 数组,把数据取出来还原成字符串。这个过程比传统 IO 看起来麻烦一点,但控制感很强。数据在哪里、读了多少、什么时候清空、什么时候继续,都能看得清清楚楚。
为了让这个聊天室具备真正的交互意义,我设计了一套简单的文本命令协议。用户可以输入 /name Alice 设置昵称,输入 /all 大家好 群发消息,输入 /to Bob 你好 给 Bob 私聊,输入 /users 查看在线用户,输入 /quit 退出。这个协议很轻,但已经有了应用层协议的味道:客户端和服务端约定一套文本格式,然后服务端根据前缀解析语义。它不花哨,也不装腔,够用。
服务端内部维护两个映射关系。第一个是从 SocketChannel 找用户名,第二个是从用户名找 SocketChannel。前者用于判断“当前发消息的是谁”,后者用于私聊时定位目标用户。比如 Alice 输入 /to Bob 你在吗,服务端会解析出目标用户名 Bob,然后从 nameChannelMap 中找到 Bob 对应的通道,最后只把消息写给 Bob。群聊的处理方式更直接,服务端遍历所有在线客户端通道,把消息广播出去。
这个设计的好处在于逻辑非常清楚。Selector 负责发现事件,SocketChannel 负责连接通信,ByteBuffer 负责数据读写,Map 负责维护用户状态,命令协议负责表达用户意图。
客户端这边也有一个小细节。主线程负责读取控制台输入,另一个线程负责接收服务器消息。这样设计是为了避免输入和接收互相卡住。用户在控制台迟迟不输入内容时,客户端依然可以收到别人发来的消息。一个线程负责说话,一个线程负责听话。成年人通信,大概也就这样。
运行方式很简单。在 IntelliJ IDEA 中打开 Maven 项目,等待导入完成,然后先运行 ChatServer,再运行一个或多个 ChatClient。如果要模拟多人聊天,可以在 IDEA 的运行配置中允许 ChatClient 多实例运行。一个客户端输入 /name Alice,另一个输入 /name Bob,然后就可以开始测试群聊和私聊。
实际测试时可以这样操作:
/name Alice
/all 大家好,我是 Alice
/users
/to Bob 你能收到吗
/quit
如果另一个客户端叫 Bob,那么 Bob 会收到 Alice 的私聊消息。如果 Alice 使用 /all,所有在线客户端都会收到广播。服务端会持续管理这些连接,谁上线、谁改名、谁退出,都能根据通道和用户名映射进行处理。整个过程没有数据库,用户状态只存在内存里;没有 Spring Boot,也没有 HTTP 接口;没有前端页面,也不需要 Vue。技术范围被压得很窄,NIO 的主角位置就很突出。
这个项目实际用到的技术栈可以概括为:Java SE、Java NIO、TCP Socket、Selector 多路复用、ByteBuffer 缓冲区、简单文本协议、多线程客户端、Maven 项目管理和 IntelliJ IDEA 运行配置。它没有引入 Spring Boot、MySQL、Redis、Netty、WebSocket 或 Vue。
从项目价值上看,NIO 摸鱼聊天室不是大型系统,但它是一个很合适的底层通信练习。它展示了从客户端连接、服务端监听、事件注册、消息读取、Buffer 切换、命令解析,到群发和私聊转发的一整条链路。 NIO 的工作方式:Selector 负责轮询事件,Channel 负责通信,Buffer 管理字节。
这是一个基于 Java NIO 的轻量级多人即时通信软件,使用 Selector 实现单线程多连接管理,使用 SocketChannel 完成 TCP 通信,使用 ByteBuffer 进行消息读写,并在此基础上实现群聊、私聊和在线用户管理。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)