杰瑞科技汇

Java Socket 服务端如何实现多客户端连接?

我将从最基础的 单线程服务端 开始,逐步演进到更实用的 多线程服务端线程池服务端,并解释其中的关键概念和最佳实践。

Java Socket 服务端如何实现多客户端连接?-图1
(图片来源网络,侵删)

核心概念

在开始编码前,我们先理解几个核心概念:

  • IP 地址 (InetAddress): 网络上设备的唯一标识,168.1.100localhost (代表本机)。
  • 端口号 (Port): 应用程序在网络上的“门牌号”,一个 IP 地址上的不同应用通过不同的端口号来区分,范围是 0-65535,0-1023 是系统保留端口。
  • Socket (套接字): 是网络编程的 API,它封装了底层的 TCP/IP 协议,我们可以把它想象成一个“电话插座”,服务器和客户端通过这个插座来建立连接、发送和接收数据。
  • ServerSocket: 服务端使用的 Socket,它负责在指定的 IP 和端口上“监听”客户端的连接请求,当有客户端连接时,它会创建一个新的 Socket 与该客户端进行通信。
  • TCP (Transmission Control Protocol): 我们这里讨论的是基于 TCP 的 Socket,TCP 是面向连接的、可靠的协议,在通信前,必须先建立一个连接(三次握手),数据传输完成后,需要断开连接(四次挥手)。

服务端的工作流程

一个标准的 TCP Socket 服务端遵循以下步骤:

  1. 创建 ServerSocket 实例:指定一个端口号,开始监听客户端的连接。
  2. 等待并接受连接:调用 accept() 方法,这个方法是阻塞的,程序会在这里暂停,直到有客户端连接上来,一旦有客户端连接,accept() 方法会返回一个新的 Socket 对象,用于与这个特定的客户端通信。
  3. 通信
    • 通过返回的 Socket 对象获取 InputStreamOutputStream
    • 通过 InputStream 从客户端读取数据。
    • 通过 OutputStream 向客户端发送数据。
  4. 关闭连接:与当前客户端的通信结束后,关闭与该客户端相关的 Socket 流和 Socket 本身。
  5. 循环监听:服务端通常会返回第 2 步,继续等待下一个客户端的连接。

示例代码

示例 1:基础单线程服务端

这个版本最简单,但有一个致命缺点:一次只能服务一个客户端,当与第一个客户端通信时,其他客户端必须等待。

SimpleServer.java

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class SimpleServer {
    public static void main(String[] args) {
        // 定义服务端要监听的端口号
        int port = 8888;
        try ( // 使用 try-with-resources 语句,确保资源自动关闭
              // 1. 创建 ServerSocket,并绑定到指定端口
              ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器已启动,等待客户端连接...");
            System.out.println("服务器地址: " + InetAddress.getLocalHost().getHostAddress() + ":" + port);
            // 2. 调用 accept() 方法,等待客户端连接,这是一个阻塞方法。
            //    当有客户端连接时,accept() 返回一个 Socket 对象,代表与该客户端的连接。
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
            // 3. 获取输入流和输出流
            //    BufferedReader 用于按行读取客户端发送的文本数据
            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            //    PrintWriter 用于向客户端发送文本数据,autoFlush=true 表示自动刷新缓冲区
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
            String inputLine;
            // 4. 循环读取客户端发送的数据
            while ((inputLine = in.readLine()) != null) {
                System.out.println("收到客户端消息: " + inputLine);
                // 如果客户端发送 "exit",则结束通信
                if ("exit".equalsIgnoreCase(inputLine)) {
                    System.out.println("客户端请求关闭连接。");
                    break;
                }
                // 5. 向客户端发送响应
                String response = "服务器回复: " + inputLine.toUpperCase();
                out.println(response);
            }
            System.out.println("与客户端的连接已关闭。");
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

如何测试:

  1. 运行 SimpleServer.java
  2. 打开一个命令行窗口,使用 telnet 命令连接:telnet localhost 8888
  3. telnet 窗口中输入任意文本,然后按回车,你会在服务端的控制台看到收到的消息,telnet 窗口会显示服务器的回复。
  4. 输入 exit 并回车,客户端连接会断开,服务端程序也会结束。

示例 2:多线程服务端

为了解决单线程服务端一次只能处理一个客户端的问题,我们引入多线程,每当有新的客户端连接时,服务端就创建一个新的线程来专门处理这个客户端的通信,主线程则继续返回 accept() 状态,等待下一个客户端。

MultiThreadServer.java

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class MultiThreadServer {
    public static void main(String[] args) {
        int port = 8888;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("多线程服务器已启动,等待客户端连接...");
            System.out.println("服务器地址: " + InetAddress.getLocalHost().getHostAddress() + ":" + port);
            // 无限循环,持续接受客户端连接
            while (true) {
                // accept() 阻塞,等待新连接
                Socket clientSocket = serverSocket.accept();
                System.out.println("新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
                // 为每个客户端连接创建一个新的线程来处理
                ClientHandler clientHandler = new ClientHandler(clientSocket);
                new Thread(clientHandler).start();
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
        }
    }
}
/**
 * 一个静态的内部类,用于处理单个客户端的通信逻辑
 */
class ClientHandler implements Runnable {
    private final Socket clientSocket;
    public ClientHandler(Socket socket) {
        this.clientSocket = socket;
    }
    @Override
    public void run() {
        // try-with-resources 确保每个客户端的流都被正确关闭
        try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
             PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)) {
            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                System.out.println("[" + Thread.currentThread().getName() + "] 收到来自 " + 
                                   clientSocket.getInetAddress().getHostAddress() + " 的消息: " + inputLine);
                if ("exit".equalsIgnoreCase(inputLine)) {
                    System.out.println("客户端请求关闭连接。");
                    break;
                }
                String response = "服务器回复: " + inputLine.toLowerCase();
                out.println(response);
            }
        } catch (IOException e) {
            // 如果客户端异常断开,会收到 SocketException,这是正常情况
            if (e instanceof java.net.SocketException) {
                System.out.println("客户端 " + clientSocket.getInetAddress().getHostAddress() + " 已断开连接。");
            } else {
                System.err.println("处理客户端时发生错误: " + e.getMessage());
            }
        } finally {
            try {
                clientSocket.close();
                System.out.println("与客户端 " + clientSocket.getInetAddress().getHostAddress() + " 的连接已关闭。");
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

改进点:

  • 并发处理:主线程 main 专注于接受连接,每个客户端的通信任务被分配给一个独立的线程。
  • 职责分离ClientHandler 类封装了与单个客户端交互的所有逻辑,使代码结构更清晰。
  • 资源管理:每个 ClientHandler 线程都管理自己的 SocketI/O 流,确保在通信结束后能正确关闭。

示例 3:使用线程池优化 (最佳实践)

虽然多线程服务端解决了并发问题,但如果同时有成千上万的客户端连接,创建和销毁线程会消耗大量系统资源,并可能导致服务器不稳定。

解决方案:使用线程池,线程池可以复用已创建的线程,避免了频繁创建和销毁线程的开销,从而提高了系统的响应速度和稳定性。

ThreadPoolServer.java

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolServer {
    // 定义线程池,核心线程数为10,最大线程数为200,当任务队列满时,新任务会等待
    private static final ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static void main(String[] args) {
        int port = 8888;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("线程池服务器已启动,等待客户端连接...");
            System.out.println("服务器地址: " + InetAddress.getLocalHost().getHostAddress() + ":" + port);
            while (true) {
                Socket clientSocket = serverSocket.accept();
                System.out.println("新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
                // 将客户端处理任务提交给线程池执行
                threadPool.execute(new ClientHandler(clientSocket));
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
        } finally {
            // 当服务器需要关闭时,优雅地关闭线程池
            System.out.println("服务器正在关闭,线程池正在关闭...");
            threadPool.shutdown();
        }
    }
}
// ClientHandler 类与 MultiThreadServer 中的完全相同
class ClientHandler implements Runnable {
    // ... (代码同上) ...
}

改进点:

  • 资源控制:通过 Executors.newFixedThreadPool(10) 我们限制了同时处理客户端请求的线程数量为 10,防止因客户端过多而导致系统资源耗尽。
  • 性能提升:线程复用减少了线程创建和销毁的开销,提高了系统吞吐量。
  • 可管理性:线程池提供了统一的线程管理机制,可以方便地调整线程数量、监控线程状态等。

关键点总结与最佳实践

  1. 使用 try-with-resources:对于所有实现了 AutoCloseable 接口的对象(如 Socket, ServerSocket, InputStream, OutputStream),都应该使用 try-with-resources 语句,可以确保它们在使用完毕后自动关闭,避免资源泄漏。
  2. 处理阻塞方法serverSocket.accept()inputStream.read() 都是阻塞方法,在多线程或线程池模型中,必须将它们放在各自的线程任务中执行,否则会阻塞主线程或其他任务。
  3. 区分 ServerSocketSocket
    • ServerSocket:只有一个,负责“监听”和“接纳”连接。
    • Socket:每有一个客户端连接,就会创建一个,负责与该客户端“通信”。
  4. I/O 流的选择
    • 对于文本数据,使用 InputStreamReader + BufferedReaderOutputStreamWriter + PrintWriter 非常方便。
    • 对于二进制数据(如图片、文件),直接使用 SocketgetInputStream()getOutputStream()
  5. 编码问题:在处理文本时,最好明确指定字符集,new InputStreamReader(socket.getInputStream(), "UTF-8"),以避免因客户端和服务端编码不一致导致的乱码问题。
  6. 优雅关闭:在服务端关闭时,除了关闭 ServerSocket,还应该关闭线程池(threadPool.shutdown()),并通知所有客户端连接关闭。

希望这个从基础到高级的讲解能帮助你全面理解 Java Socket 服务端的实现!

分享:
扫描分享到社交APP
上一篇
下一篇