54. 【Java】阶段实战:高并发聊天室

摘要: 本文详细介绍了如何使用Java虚拟线程构建高并发实时聊天室。通过整合虚拟线程、ConcurrentHashMap、Socket网络编程等核心技术,实现了一个支持广播、私聊、在线用户列表等功能的命令行聊天室。项目采用传统IO+虚拟线程的简洁架构,既能享受阻塞式编程的直观性,又能轻松支持成千上万的并发连接,是学习Java并发网络编程的绝佳实践。

关键词: Java虚拟线程, 高并发聊天室, Socket网络编程, ConcurrentHashMap, 实时通信


恭喜你走到了这里。第五阶段“精通篇”已经接近尾声,我们已经学习了虚拟线程、并发工具、网络编程等重要的知识。今天,我们要把这一阶段学到的所有内容整合在一起,构建一个真正的、可用的、高并发的实时聊天室

这个项目会用到:

  • 虚拟线程:让每个客户端连接都能被轻量地处理,轻松支持成千上万的并发用户。
  • ConcurrentHashMap:安全地管理所有在线用户和他们的连接。
  • HttpClient:作为客户端发送消息(也可以改用 Socket 客户端,我们会用 Socket 实现客户端,更贴近网络编程本质,但也会提到 HttpClient 方式)。
  • 阻塞队列 / 线程安全容器:用于消息的缓冲和广播。
  • 异常处理:让服务器在遇到错误时保持稳定运行。
  • NIO 或传统 IO:我们采用传统 IO + 虚拟线程,简单且高效。

这个聊天室的功能包括:

  • 用户连接到服务器,设置昵称。
  • 用户可以发送消息给所有在线用户(广播)。
  • 用户可以私聊另一个用户(扩展功能,我们会实现)。
  • 用户可以查看在线用户列表
  • 用户可以退出聊天室。

这个项目虽然不大,但它模拟了一个真实世界中的网络服务——多个客户端同时连接、实时通信、消息广播。完成它之后,你会对“并发网络服务”这个主题有非常直观的深刻理解。


1. 需求分析 —— 我们要建一个什么样的聊天室?

我们做一个命令行版的多人聊天室

服务器端

  • 监听一个端口(如 8888),接受客户端连接。
  • 为每个客户端分配一个虚拟线程来处理。
  • 维护一个在线用户列表:Map<String, ClientHandler>(用户名 → 客户端的处理器)。
  • 当一个客户端发送消息时,服务器把消息广播给所有其他在线客户端。
  • 支持私聊(@用户名 消息 格式)。
  • 支持查看在线用户列表(/list 命令)。
  • 支持用户退出(/quit 或断开连接)。

客户端

  • 连接到服务器。
  • 提示用户输入昵称,发送给服务器注册。
  • 启动一个线程(或虚拟线程)持续接收服务器发来的消息并打印。
  • 主线程读取用户输入,发送到服务器。

协议设计(简化版)
我们使用基于文本行的协议——每条消息以换行符 \n 结尾。消息格式:

  • 普通广播消息:直接发送文本。
  • 私聊消息:@用户名 消息内容
  • 命令:/list/quit

客户端和服务器约定:服务器发送的消息以 [系统][消息][私聊] 等前缀区分。


2. 架构设计 —— 分层清晰

┌─────────────────────────────────────────────────────────────┐
│                      ChatServer                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  1. 启动 ServerSocket,监听端口                        │  │
│  │  2. 主循环:accept() 新连接,用虚拟线程处理              │  │
│  │  3. 每个 ClientHandler 管理一个客户端                   │  │
│  │  4. 用 ConcurrentHashMap 存储所有在线用户               │  │
│  │  5. 广播/私聊消息的路由                                 │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                              ▲
                              │ 网络 Socket
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      ChatClient                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  1. 连接到服务器                                       │  │
│  │  2. 发送昵称注册                                       │  │
│  │  3. 接收线程:持续接收并打印服务器消息                   │  │
│  │  4. 发送线程(主线程):读取用户输入并发送               │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

3. 实现服务器端

3.1 核心数据结构
// 用户映射:用户名 → 客户端处理器
private final Map<String, ClientHandler> clients = new ConcurrentHashMap<>();

// 可选:消息队列(这里我们直接在 ClientHandler 中处理,因为广播是即时进行的)

ConcurrentHashMap 保证了线程安全,多个虚拟线程可以并发地添加、删除、遍历用户列表,而不会产生数据竞争。

3.2 ClientHandler —— 管理每个客户端

每个客户端连接对应一个 ClientHandler,它负责:

  • 读取客户端发送的消息。
  • 处理消息(广播、私聊、命令)。
  • 在客户端断开时清理资源。
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
 * 处理单个客户端连接的处理器
 * 每个实例运行在一个虚拟线程中
 */
public class ClientHandler implements Runnable {
    private final Socket socket;
    private final Map<String, ClientHandler> clients;
    private final String welcomeMessage;
    private String username;
    private BufferedReader reader;
    private PrintWriter writer;

    public ClientHandler(Socket socket, Map<String, ClientHandler> clients, String welcomeMessage) {
        this.socket = socket;
        this.clients = clients;
        this.welcomeMessage = welcomeMessage;
    }

    @Override
    public void run() {
        try {
            // 初始化输入输出流
            reader = new BufferedReader(
                new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
            writer = new PrintWriter(
                new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);

            // 1. 注册昵称
            if (!registerUsername()) {
                return; // 注册失败,关闭连接
            }

            // 2. 广播用户加入消息
            broadcastMessage("[系统] " + username + " 加入了聊天室", null);

            // 3. 发送欢迎消息
            sendMessage("[系统] 欢迎 " + username + " 来到聊天室!");
            sendMessage("[系统] 输入 /list 查看在线用户,@用户名 私聊,/quit 退出");

            // 4. 主循环:读取并处理消息
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.trim().isEmpty()) continue;
                processMessage(line);
            }

        } catch (IOException e) {
            System.out.println("客户端 " + (username != null ? username : "未知") + " 连接异常:" + e.getMessage());
        } finally {
            // 清理资源
            disconnect();
        }
    }

    /**
     * 注册用户名,确保唯一性
     * @return 是否注册成功
     */
    private boolean registerUsername() throws IOException {
        while (true) {
            sendMessage("请输入你的昵称:");
            String name = reader.readLine();
            if (name == null) {
                return false; // 客户端关闭
            }
            name = name.trim();
            if (name.isEmpty()) {
                sendMessage("昵称不能为空,请重新输入");
                continue;
            }
            // 检查是否已被占用
            if (clients.containsKey(name)) {
                sendMessage("昵称已被占用,请重新输入");
                continue;
            }
            // 昵称可用,注册
            this.username = name;
            clients.put(name, this);
            return true;
        }
    }

    /**
     * 处理一条消息
     */
    private void processMessage(String line) {
        // 1. 私聊检测:以 @ 开头
        if (line.startsWith("@")) {
            handlePrivateMessage(line);
            return;
        }

        // 2. 命令检测
        if (line.startsWith("/")) {
            handleCommand(line);
            return;
        }

        // 3. 普通广播消息
        broadcastMessage("[" + username + "] " + line, this);
    }

    /**
     * 处理私聊消息
     */
    private void handlePrivateMessage(String line) {
        // 格式: @target 消息内容
        int spaceIndex = line.indexOf(' ');
        if (spaceIndex == -1) {
            sendMessage("[系统] 私聊格式错误,请使用: @用户名 消息");
            return;
        }
        String targetName = line.substring(1, spaceIndex).trim();
        String content = line.substring(spaceIndex + 1).trim();

        if (content.isEmpty()) {
            sendMessage("[系统] 消息内容不能为空");
            return;
        }

        ClientHandler target = clients.get(targetName);
        if (target == null) {
            sendMessage("[系统] 用户 " + targetName + " 不在线");
            return;
        }

        // 发送私聊消息
        target.sendMessage("[私聊] " + username + " 对你说: " + content);
        sendMessage("[私聊] 你对 " + targetName + " 说: " + content);
    }

    /**
     * 处理命令
     */
    private void handleCommand(String line) {
        if (line.equalsIgnoreCase("/list")) {
            // 显示在线用户列表
            if (clients.isEmpty()) {
                sendMessage("[系统] 当前没有在线用户");
            } else {
                StringBuilder sb = new StringBuilder("[系统] 在线用户: ");
                String names = String.join(", ", clients.keySet());
                sb.append(names);
                sendMessage(sb.toString());
            }
        } else if (line.equalsIgnoreCase("/quit")) {
            sendMessage("[系统] 正在退出...");
            disconnect();
        } else {
            sendMessage("[系统] 未知命令,可用命令: /list, /quit");
        }
    }

    /**
     * 广播消息给所有在线用户(除指定用户外)
     */
    private void broadcastMessage(String message, ClientHandler exclude) {
        for (ClientHandler client : clients.values()) {
            if (client != exclude) {
                client.sendMessage(message);
            }
        }
    }

    /**
     * 发送消息给当前客户端
     */
    public void sendMessage(String message) {
        if (writer != null) {
            writer.println(message);
        }
    }

    /**
     * 断开连接,清理资源
     */
    private void disconnect() {
        try {
            // 从在线列表中移除
            if (username != null) {
                clients.remove(username, this);
                // 广播用户离开
                broadcastMessage("[系统] " + username + " 离开了聊天室", this);
            }
            // 关闭资源
            if (reader != null) try { reader.close(); } catch (IOException ignored) {}
            if (writer != null) writer.close();
            if (socket != null && !socket.isClosed()) try { socket.close(); } catch (IOException ignored) {}
        } catch (Exception e) {
            // 忽略清理时的异常
        }
    }
}
3.3 ChatServer —— 主服务器类
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;

public class ChatServer {
    private static final int PORT = 8888;
    private final Map<String, ClientHandler> clients = new ConcurrentHashMap<>();

    public void start() {
        System.out.println("高并发聊天室服务器启动,监听端口:" + PORT);
        System.out.println("使用虚拟线程处理每个客户端连接...");

        // 使用虚拟线程执行器
        try (var executor = Executors.newVirtualThreadPerTaskExecutor();
             ServerSocket serverSocket = new ServerSocket(PORT)) {

            while (true) {
                // 接受客户端连接
                Socket clientSocket = serverSocket.accept();
                System.out.println("新客户端连接:" + clientSocket.getRemoteSocketAddress());

                // 每个客户端分配一个虚拟线程
                ClientHandler handler = new ClientHandler(clientSocket, clients, 
                        "欢迎来到高并发聊天室!");
                executor.submit(handler);
            }

        } catch (IOException e) {
            System.err.println("服务器异常:" + e.getMessage());
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        new ChatServer().start();
    }
}

就这么简单!服务器的主循环只是一个 accept() + submit(handler)。虚拟线程让每个客户端的处理成本极低,你可以轻松支持成千上万的并发连接。


4. 实现客户端

客户端要稍微复杂一些,因为它需要同时做两件事:

  1. 接收消息:持续从服务器读取消息并打印到控制台(需要独立线程)。
  2. 发送消息:读取用户输入并发送到服务器(主线程)。

我们仍然使用虚拟线程来处理接收任务,让主线程专注于用户输入。

import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class ChatClient {
    private static final String SERVER_HOST = "localhost";
    private static final int SERVER_PORT = 8888;

    private Socket socket;
    private BufferedReader reader;
    private PrintWriter writer;
    private volatile boolean running = true;

    public void start() {
        try {
            socket = new Socket(SERVER_HOST, SERVER_PORT);
            reader = new BufferedReader(
                new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
            writer = new PrintWriter(
                new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);

            System.out.println("已连接到服务器 " + SERVER_HOST + ":" + SERVER_PORT);

            // 1. 启动接收线程(使用虚拟线程)
            Thread receiver = Thread.ofVirtual().start(this::receiveMessages);

            // 2. 主线程读取用户输入并发送
            BufferedReader consoleReader = new BufferedReader(
                new InputStreamReader(System.in, StandardCharsets.UTF_8));
            String line;
            while (running && (line = consoleReader.readLine()) != null) {
                if (!running) break;
                writer.println(line);
                if (line.trim().equalsIgnoreCase("/quit")) {
                    break;
                }
            }

            running = false;
            // 等待接收线程结束
            receiver.join();
            System.out.println("客户端已关闭");

        } catch (IOException e) {
            System.err.println("连接服务器失败:" + e.getMessage());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            close();
        }
    }

    /**
     * 接收线程:持续从服务器读取消息并打印
     */
    private void receiveMessages() {
        try {
            String line;
            while (running && (line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            if (running) {
                System.err.println("接收消息异常:" + e.getMessage());
            }
        } finally {
            running = false;
        }
    }

    /**
     * 清理资源
     */
    private void close() {
        try {
            if (writer != null) writer.close();
            if (reader != null) try { reader.close(); } catch (IOException ignored) {}
            if (socket != null && !socket.isClosed()) {
                try { socket.close(); } catch (IOException ignored) {}
            }
        } catch (Exception ignored) {}
    }

    public static void main(String[] args) {
        new ChatClient().start();
    }
}

5. 运行与测试

  1. 启动服务器:运行 ChatServermain 方法。你会看到:

    高并发聊天室服务器启动,监听端口:8888
    使用虚拟线程处理每个客户端连接...
    
  2. 启动客户端:运行多个 ChatClient(你可以开多个终端或使用 IDEA 的多个 Run 窗口)。

    • 每个客户端会提示输入昵称。
    • 输入一个独一无二的昵称,注册成功后会看到欢迎消息。
  3. 体验聊天

    • 在任意客户端输入消息,所有其他在线客户端都会收到广播。
    • 输入 /list 查看在线用户列表。
    • 输入 @另一个用户 你好 发送私聊消息。
    • 输入 /quit 退出聊天室。
  4. 测试高并发

    • 可以写一个简单的测试脚本,循环创建大量客户端连接到服务器,观察服务器是否能稳定运行。或者,你可以在本地启动数十个客户端,手动测试。

6. 用虚拟线程带来的好处

如果把 ClientHandler 改成继承 Thread 的传统实现,每个客户端就是一个平台线程。在 1000 个并发连接下,光线程栈就占用 1GB 内存,可能还会触发操作系统的线程数限制。

而使用虚拟线程:

  • 每个客户端只占用几百字节到几 KB 的内存(具体取决于 JVM 实现)。
  • 可以轻松支持 10,000+ 并发连接而不会耗尽内存。
  • 线程的创建和切换开销极小,服务器可以快速响应每个客户端的消息。

这就是虚拟线程在 I/O 密集型网络服务中的典型应用——它让你用最简单的“阻塞式”编程模型,实现了过去需要复杂的 NIO 和异步框架才能达到的并发性能。


7. 扩展方向

这个聊天室已经可以工作,但还有很多可以改进和扩展的地方:

  1. 消息持久化:把聊天记录保存到文件或数据库中。
  2. 房间/频道:支持不同的聊天房间,用户可以加入/退出房间。
  3. 更丰富的命令:如 /help/whois 用户名 查看用户信息。
  4. 图形化客户端:用 JavaFX 或 Swing 实现一个图形化聊天界面。
  5. 使用 HttpClient 替代 Socket 客户端:如果采用 RESTful API,可以把消息通过 HTTP POST 发送,但实时性会差一些(需要轮询)。更好的方式是用 WebSocket(JDK 也支持),但超出了本章范围。
  6. 认证与授权:加入密码验证,让用户登录更安全。
  7. 心跳机制:检测客户端是否意外断开,自动清理僵尸连接。

你可以选择其中一个方向继续拓展,把聊天室变成真正可用的产品。


8. 知识点回顾

这个项目用到了第五阶段的大部分核心知识:

知识点应用位置
虚拟线程Executors.newVirtualThreadPerTaskExecutor() 处理每个客户端连接
ConcurrentHashMap安全地存储在线用户列表,支持并发读写
Socket / ServerSocket底层 TCP 网络通信
BufferedReader / PrintWriter字符流读写,处理文本行
异常处理try-catch-finallyIOException 处理
线程协作客户端用两个线程(接收和发送)协同工作
进程隔离服务器和客户端在不同进程中运行
优雅关闭使用 volatile boolean running 控制线程退出
编码规范使用 StandardCharsets.UTF_8 统一编码
try-with-resources自动关闭 ServerSocket 等资源

9. 写在最后

恭喜你完成了这个实战项目!你不再是只会写单机程序的开发者,而是能够构建高并发网络服务的程序员了。

这个聊天室,虽然只有几百行代码,但它背后承载了 Java 并发编程、网络编程、数据结构、异常处理等多个领域的知识积累。你走到这里,已经比大多数 Java 初学者走得更远、更扎实。

从下一篇开始,我们将进入这个系列的第六阶段——实战篇。我们会学习如何用 Maven 管理项目、如何写单元测试、如何集成 Spring Boot,最终构建一个真正的 Web 应用。

休息一下,喝杯水,然后继续前进吧。😃

📌 获取本系列示例代码请访问 GitCode

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值