Socket 编程之 BIO

本文介绍基于 BIO 实现 Socket 编程的方法及问题。


目录

  • BIO 简介
  • BIO Socket 代码示例
  • 问题分析
  • 多线程优化方案
  • 总结
  • 推荐参考

BIO 简介

BIO,全称 Blocking I/O,是 JDK 最早的传统 I/O 模型,属于同步阻塞 I/O 模型,即数据的读取和写入阻塞在一个线程内等待完成。


BIO Socket 代码示例

  1. 服务端
package tutorial.java.io;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class BIOSocketServer {

    public static void main(String[] args) throws IOException {
        try (ServerSocket serverSocket = new ServerSocket(7777)) {
            while (true) {
                try (Socket socket = serverSocket.accept();
                     InputStream inputStream = socket.getInputStream()) {
                    System.out.println("Client connected!");
                    int len;
                    byte[] buffer = new byte[1024];
                    while ((len = inputStream.read(buffer)) != -1) {
                        System.out.println(new String(buffer, 0, len));
                    }
                }
            }
        }
    }
}

注意:使用 try-with-resource 语法保证资源正常关闭。

  1. 客户端
package tutorial.java.io;

import java.io.IOException;
import java.net.Socket;
import java.time.LocalDateTime;

public class BIOSocketClient {

    public static void main(String[] args) throws IOException, InterruptedException {
        try (Socket socket = new Socket("127.0.0.1", 7777)) {
            for (int i = 0; i < 5; i++) {
                socket.getOutputStream().write((LocalDateTime.now() + ": Hi").getBytes());
                Thread.sleep(3000);
            }
        }
        System.out.println("Job finished!");
    }
}

注意:使用 try-with-resource 语法保证 Socket 使用完成后可以被正常关闭。

  1. 运行说明

3.1 在 BIOSocketServer 以下语句行中打断点。

System.out.println("Client connected!");

3.2 以 Debug 模式启动运行 BIOSocketServer,发现不会进入断点。
3.3 启动运行 BIOSocketClient 后才会进入 BIOSocketServer 断点。


问题分析

以上示例代码中存在两个阻塞点:

  • serverSocket.accept()
  • inputStream.read(buffer)

为了验证阻塞,对 BIOSocketClient 代码做部分修改。

package tutorial.java.io;

import java.io.IOException;
import java.net.Socket;
import java.time.LocalDateTime;

public class BIOSocketClient {

    public static void main(String[] args) {
        Runnable hi = () -> {
            try {
                String threadName = Thread.currentThread().getName();
                try (Socket socket = new Socket("127.0.0.1", 7777)) {
                    for (int i = 0; i < 5; i++) {
                        socket.getOutputStream().write((LocalDateTime.now() + ": Hi from " + threadName).getBytes());
                        Thread.sleep(3000);
                    }
                }
                System.out.println(threadName + " Job finished!");
            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        };
        Thread t1 = new Thread(hi);
        Thread t2 = new Thread(hi);
        Thread t3 = new Thread(hi);
        t1.start();
        t2.start();
        t3.start();
    }
}

验证步骤:首先启动运行 BIOSocketServer,然后启动运行 BIOSocketClient,服务端打印结果如下:

Client connected!
2019-12-07T14:19:12.188: Hi from Thread-1
2019-12-07T14:19:15.189: Hi from Thread-1
2019-12-07T14:19:18.189: Hi from Thread-1
2019-12-07T14:19:21.190: Hi from Thread-1
2019-12-07T14:19:24.191: Hi from Thread-1
Client connected!
2019-12-07T14:19:12.188: Hi from Thread-22019-12-07T14:19:15.189: Hi from Thread-22019-12-07T14:19:18.189: Hi from Thread-22019-12-07T14:19:21.190: Hi from Thread-22019-12-07T14:19:24.191: Hi from Thread-2
Client connected!
2019-12-07T14:19:12.188: Hi from Thread-02019-12-07T14:19:15.189: Hi from Thread-02019-12-07T14:19:18.189: Hi from Thread-02019-12-07T14:19:21.190: Hi from Thread-02019-12-07T14:19:24.191: Hi from Thread-0

从运行结果中可以看出,客户端线程发送的消息并非交替到达服务端,而是服务端接收完一个客户端线程的全部消息后才会接收其它客户端线程消息,说明服务端在同一时间点只能维持一个客户端 Socket 连接。


多线程优化方案

解决阻塞问题需要为每个客户端连接分配一个新的线程单独处理,修改 BIOSocketServer 代码如下。

package tutorial.java.io;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class BIOSocketServer {

    public static void main(String[] args) throws IOException {
        try (ServerSocket serverSocket = new ServerSocket(7777)) {
            while (true) {
                Socket socket = serverSocket.accept();
                System.out.println("Client connected!");
                new Thread(() -> {
                    try (InputStream inputStream = socket.getInputStream()) {
                        int len;
                        byte[] buffer = new byte[1024];
                        while ((len = inputStream.read(buffer)) != -1) {
                            System.out.println(new String(buffer, 0, len));
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    } finally {
                        try {
                            socket.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            }
        }
    }
}

再次运行 BIOSocketServerBIOSocketClient 可见以下打印结果,客户端线程发送的消息交替到达服务端,说明服务端同时维持多个客户端连接。

Client connected!
Client connected!
Client connected!
2019-12-07T15:00:53.695: Hi from Thread-0
2019-12-07T15:00:53.695: Hi from Thread-2
2019-12-07T15:00:53.693: Hi from Thread-1
2019-12-07T15:00:56.696: Hi from Thread-1
2019-12-07T15:00:56.696: Hi from Thread-2
2019-12-07T15:00:56.696: Hi from Thread-0
2019-12-07T15:00:59.697: Hi from Thread-1
2019-12-07T15:00:59.697: Hi from Thread-2
2019-12-07T15:00:59.697: Hi from Thread-0
2019-12-07T15:01:02.697: Hi from Thread-2
2019-12-07T15:01:02.697: Hi from Thread-0
2019-12-07T15:01:02.697: Hi from Thread-1
2019-12-07T15:01:05.699: Hi from Thread-2
2019-12-07T15:01:05.699: Hi from Thread-1
2019-12-07T15:01:05.699: Hi from Thread-0

总结

  1. BIO 模型说明:一个独立的 Acceptor 线程负责监听客户端连接,接收到客户端连接请求时为每个连接分配一个新的线程处理该请求消息,处理完成后回复应答,然后销毁对应线程,属于典型的 请求-应答 模型。
    BIO 模型图可以参考:Netty 系列之 Netty 高性能之道
  2. 从 BIO 通信模型中可以看出其明显弊端:服务器需要为请求分配独立的新线程,线程数量和并发访问数量成线性正比,而线程资源属于 JVM 非常重要的系统资源,这种 重要性 表现在:
    • 线程的创建和销毁成本很高,在 Linux 这类操作系统中,线程本质上就是一个进程,创建和销毁都是重量级的系统函数;
    • 线程本身占用较大内存,Java 线程栈一般分配 512K ~ 1M 内存,如果线程过多会造成 JVM 内存空间被大量占用;
    • 线程切换成本很高,操作系统线程切换时需要保留线程的上下文,然后执行系统调用,如果线程数过多,可能造成线程切换的耗时大于线程执行时间,此时操作系统负载高,CPU 使用率高,系统可能进入不可用状态;
    • 容易造成锯齿状的系统负载,在线程数量高但网络环境不稳定时容易出现大量请求结果同时返回,此时激活大量阻塞线程使得系统负载压力过大。
  3. 本文多线程优化方案示例中显式创建线程,在实际生产环境中是不允许的,通常使用线程池来替代,但是线程池容量的设置需要考虑实际使用场景。
  4. 本文多线程优化方案只解决了 I/O 读写阻塞的问题。
  5. 除基于 BIO 实现的客户端代码外,还可以使用 telnet 命令测试验证基于 BIO 实现的 Socket 服务端功能。

推荐参考

  • Netty 系列之 Netty 高性能之道

你可能感兴趣的:(Socket 编程之 BIO)