Skip to content

Java IO - 07 TCP通信

本文介绍java.net下的TCP通信编程,涉及ServerSocketSocket和线程相关知识。

ServerSocket表示服务端程序,用于监听客户端连接;

Socket表示服务端与客户端之间的通道,可通过Socket对象获取输入与输出流:

  • getInputStream():获取输入流以读取数据;
  • getOutputStream():获取输出流以写出数据;

1. 案例一:一发一收

该案例实现客户端发送一条消息,服务端接收消息并显示。

服务端代码如下:

java
@Slf4j
public class ServerDemo {
    public static void main(String[] args) throws IOException {
        // 1. 创建服务端程序,监听8888端口
        ServerSocket serverSocket = new ServerSocket(8888);
        log.info("服务端启动,监听端口:{}", 8888);

        // 2. 阻塞等待客户端连接
        Socket socket = serverSocket.accept();
        SocketAddress remoteSocketAddress = socket.getRemoteSocketAddress();  // 获取客户端地址
        log.info("{} 连接成功", remoteSocketAddress);

        // 3. 获取输入流,以读取客户端发送的数据
        InputStream inputStream = socket.getInputStream();
        DataInputStream dataInputStream = new DataInputStream(inputStream);

        // 4. 阻塞等待客户端发送数据
        String data = dataInputStream.readUTF();

        // 5. 打印客户端发送的数据
        log.info("{} 发送:{}",remoteSocketAddress, data);

        // 6. 关闭资源
        dataInputStream.close();
    }
}

客户端代码如下:

java
public class ClientDemo {
    public static void main(String[] args) throws IOException {
        // 1. 与服务端建立连接
        Socket socket = new Socket("127.0.0.1", 8888);

        // 2. 获取输出流以输出数据
        OutputStream outputStream = socket.getOutputStream();
        DataOutputStream dataOutputStream = new DataOutputStream(outputStream);

        // 3. 输出数据
        dataOutputStream.writeUTF("你好");

        // 4. 关闭资源
        dataOutputStream.close();
        socket.close();
    }
}

结果如下:

txt
2025-02-06 16:52:32 [main] INFO  com.lee.tcp.ServerDemo - 服务端启动,监听端口:8888
2025-02-06 16:52:37 [main] INFO  com.lee.tcp.ServerDemo - /127.0.0.1:65252 连接成功
2025-02-06 16:52:37 [main] INFO  com.lee.tcp.ServerDemo - /127.0.0.1:65252 发送:你好

2. 案例二:多发多收

该案例实现客户端可以读取用户在控制台的输入,并发送给服务端,服务端显示多条消息。当用户输入exit时,退出程序。

客户端代码:

java
public class ClientDemo {
    public static void main(String[] args) throws IOException {
        // 1. 与服务端建立连接
        Socket socket = new Socket("127.0.0.1", 8888);

        // 2. 获取输出流以输出数据
        OutputStream outputStream = socket.getOutputStream();
        DataOutputStream dataOutputStream = new DataOutputStream(outputStream);

        // 用户输入消息并发送给服务端
        Scanner scanner = new Scanner(System.in);
        while (true){
            String input = scanner.nextLine();
            if("exit".equals(input)) {
                // 4. 关闭资源
                dataOutputStream.close();
                socket.close();
                break;
            }

            // 3. 输出数据
            dataOutputStream.writeUTF(input);
        }

    }
}

服务端代码:

java
@Slf4j
public class ServerDemo {
    public static void main(String[] args) throws IOException {
        // 1. 创建服务端程序,监听8888端口
        ServerSocket serverSocket = new ServerSocket(8888);
        log.info("服务端启动,监听端口:{}", 8888);

        // 2. 阻塞等待客户端连接
        Socket socket = serverSocket.accept();
        SocketAddress remoteSocketAddress = socket.getRemoteSocketAddress();  // 获取客户端地址
        log.info("{} 连接成功", remoteSocketAddress);

        // 3. 获取输入流,以读取客户端发送的数据
        InputStream inputStream = socket.getInputStream();
        DataInputStream dataInputStream = new DataInputStream(inputStream);

        try {
            // 循环等待
            while (true) {
                // 4. 阻塞等待客户端发送数据
                String data = dataInputStream.readUTF();
                // 5. 打印客户端发送的数据
                log.info("{} 发送:{}", remoteSocketAddress, data);
            }
        }catch (Exception e){
            log.error("{} 退出了", remoteSocketAddress);
            // 6. 关闭资源
            dataInputStream.close();
        }
    }
}

效果如下:

java
2025-02-06 17:00:01 [main] INFO  com.lee.tcp.ServerDemo - 服务端启动,监听端口:8888
2025-02-06 17:00:04 [main] INFO  com.lee.tcp.ServerDemo - /127.0.0.1:65390 连接成功
2025-02-06 17:00:09 [main] INFO  com.lee.tcp.ServerDemo - /127.0.0.1:65390 发送:123
2025-02-06 17:00:11 [main] INFO  com.lee.tcp.ServerDemo - /127.0.0.1:65390 发送:你好
2025-02-06 17:00:12 [main] INFO  com.lee.tcp.ServerDemo - /127.0.0.1:65390 发送:hello
2025-02-06 17:00:17 [main] ERROR com.lee.tcp.ServerDemo - /127.0.0.1:65390 退出了

3. 多客户端连接:多线程版

在上面的案例中,服务端只能服务一个客户端,我们可以使用线程,让一个线程服务一个客户端接收消息。

image-20250206170447762

服务端代码:

java
@Slf4j
public class ServerDemo {
    public static void main(String[] args) throws IOException {
        // 创建服务端程序,监听8888端口
        ServerSocket serverSocket = new ServerSocket(8888);
        log.info("服务端启动,监听端口:{}", 8888);

        // 阻塞等待客户端连接
        while (true) {
            Socket socket = serverSocket.accept();

            // 创建线程,处理客户端请求
            new Thread(new ReceiverThread(socket)).start();
        }
    }
}

@Slf4j
class ReceiverThread implements Runnable{

    private Socket socket;
    public ReceiverThread(Socket socket){
        this.socket = socket;
    }

    @Override
    public void run() {
        SocketAddress remoteSocketAddress = socket.getRemoteSocketAddress();  // 获取客户端地址
        log.info("{} 连接成功", remoteSocketAddress);
        // 获取输入流,以读取客户端发送的数据
        InputStream inputStream = null;
        DataInputStream dataInputStream = null;

        try {
            inputStream = socket.getInputStream();
            dataInputStream = new DataInputStream(inputStream);

            // 循环等待
            while (true) {
                // 阻塞等待客户端发送数据
                String data = dataInputStream.readUTF();
                // 打印客户端发送的数据
                log.info("{} 发送:{}", remoteSocketAddress, data);
            }
        }catch (Exception e){
            log.error("{} 退出了", remoteSocketAddress);
            // 6. 关闭资源
            try {
                dataInputStream.close();
            } catch (IOException ex) {
                throw new RuntimeException(ex);
            }
        }
    }
}

效果如下:

txt
2025-02-06 17:09:29 [main] INFO  com.lee.tcp.ServerDemo - 服务端启动,监听端口:8888
2025-02-06 17:09:35 [Thread-0] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49273 连接成功
2025-02-06 17:09:38 [Thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49276 连接成功
2025-02-06 17:09:44 [Thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49276 发送:111
2025-02-06 17:09:47 [Thread-0] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49273 发送:222
2025-02-06 17:09:52 [Thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49276 发送:你好
2025-02-06 17:09:56 [Thread-0] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49273 发送:hello
2025-02-06 17:09:58 [Thread-0] ERROR com.lee.tcp.ReceiverThread - /127.0.0.1:49273 退出了
2025-02-06 17:10:01 [Thread-1] ERROR com.lee.tcp.ReceiverThread - /127.0.0.1:49276 退出了

可以看到同时有两个客户端与服务端通信。

4. 多客户端连接:线程池版

多线程版的缺点在于,如果有非常多客户端与服务端通信,那么服务端会创建很多线程,将会造成内存溢出程序崩溃,所以我们可以使用线程池改进。

image-20250206171409492

服务端代码:

java
@Slf4j
public class ServerDemo {
    public static void main(String[] args) throws IOException {
        // 创建服务端程序,监听8888端口
        ServerSocket serverSocket = new ServerSocket(8888);
        log.info("服务端启动,监听端口:{}", 8888);

        // 创建线程池
        ExecutorService pool = new ThreadPoolExecutor(
                Runtime.getRuntime().availableProcessors() * 2,
                Runtime.getRuntime().availableProcessors() * 2,
                0,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(8),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy()
        );

        // 阻塞等待客户端连接
        while (true) {
            Socket socket = serverSocket.accept();

            // 向线程池中提交任务
            pool.submit(new ReceiverThread(socket));
        }
    }
}

// ReceiverThread与上面的相同,省略...

效果如下:

txt
2025-02-06 17:25:07 [main] INFO  com.lee.tcp.ServerDemo - 服务端启动,监听端口:8888
2025-02-06 17:25:11 [pool-1-thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49488 连接成功
2025-02-06 17:25:17 [pool-1-thread-2] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49492 连接成功
2025-02-06 17:25:20 [pool-1-thread-3] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49495 连接成功
2025-02-06 17:25:22 [pool-1-thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49488 发送:111
2025-02-06 17:25:25 [pool-1-thread-2] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49492 发送:222
2025-02-06 17:25:28 [pool-1-thread-3] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49495 发送:333
2025-02-06 17:25:33 [pool-1-thread-1] ERROR com.lee.tcp.ReceiverThread - /127.0.0.1:49488 退出了
2025-02-06 17:25:39 [pool-1-thread-3] ERROR com.lee.tcp.ReceiverThread - /127.0.0.1:49495 退出了
2025-02-06 17:25:42 [pool-1-thread-2] ERROR com.lee.tcp.ReceiverThread - /127.0.0.1:49492 退出了

使用线程池的问题在于,如果TCP连接是长连接,那么线程池中的线程会被占满,导致后续的客户端连接无法被服务,例如,我们可以将上面的连接池大小改为2,那么第三个客户端连接将无法及时响应:

txt
2025-02-06 17:27:47 [main] INFO  com.lee.tcp.ServerDemo - 服务端启动,监听端口:8888
2025-02-06 17:27:54 [pool-1-thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49521 连接成功
2025-02-06 17:27:57 [pool-1-thread-2] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49525 连接成功
2025-02-06 17:28:04 [pool-1-thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49521 发送:111
2025-02-06 17:28:07 [pool-1-thread-2] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49525 发送:222
2025-02-06 17:28:12 [pool-1-thread-1] ERROR com.lee.tcp.ReceiverThread - /127.0.0.1:49521 退出了
2025-02-06 17:28:12 [pool-1-thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49529 连接成功
2025-02-06 17:28:12 [pool-1-thread-1] INFO  com.lee.tcp.ReceiverThread - /127.0.0.1:49529 发送:333

上面的日志显示了,当连接池被占满后,后续的客户端连接将会被阻塞,直至有空闲线程后才会被处理。所以连接池版本适合HTTP这种请求响应式通信。

5. 响应HTTP请求

HTTP协议是基于TCP构建的,所以我们可以在底层通过响应TCP连接来响应HTTP请求:

java
@Slf4j
public class HttpServerDemo {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888); // 9000会出问题???
        System.out.println("Server started on port 8888");

        // 创建线程池
        ExecutorService pool = new ThreadPoolExecutor(
                Runtime.getRuntime().availableProcessors() * 2,
                Runtime.getRuntime().availableProcessors() * 2,
                0,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(8),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy()
        );

        while (true){
            // 接受客户端连接
            Socket socket = serverSocket.accept();
            log.info(socket.getRemoteSocketAddress().toString());

            // 向线程池提交任务
            pool.submit(new HttpRespondeTask(socket));
        }
    }
}

@Slf4j
class HttpRespondeTask implements Runnable{
    private Socket socket;
    public HttpRespondeTask(Socket socket){
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 获取请求头
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            String requestLine = in.readLine();
            log.info(requestLine);

            // 以HTTP格式输出内容
            OutputStream outputStream = socket.getOutputStream();
            PrintWriter printWriter = new PrintWriter(outputStream);

            String statusLine = "HTTP/1.1 200 OK\r\n";
            String contentType = "Content-Type: text/html\r\n";
            String responseBody = "<html><body><h1>Hello, world!</h1></body></html>\r\n";
            String response = statusLine + contentType + "\r\n" + responseBody;

            printWriter.print(response);
            printWriter.flush();

            printWriter.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}