【深入理解Java IO流0x08】解读Java NIO核心知识(上篇)

发布于:2024-04-11 ⋅ 阅读:(125) ⋅ 点赞:(0)

1. 引言

在学习Java NIO之前,请大家首先对Java的各种IO模型有个初步的认识。如果不太清楚的话,可以看这篇博客:【搞懂Java中的三种IO模型的区别:BIO & NIO & AIO】
这里有一张图,对比一下传统IO(即BIO)和NIO的区别。
image.png

  • 传统 IO 基于字节流或字符流(如 FileInputStream、BufferedReader 等)进行文件读写,以及使用 Socket 和 ServerSocket 进行网络传输。
  • NIO 使用通道(Channel)和缓冲区(Buffer)进行文件操作,以及使用 SocketChannel 和 ServerSocketChannel 进行网络传输。

传统 IO 采用阻塞式模型,对于每个连接,都需要创建一个独立的线程来处理读写操作。当一个线程在等待 I/O 操作时,无法执行其他任务。这会导致大量线程的创建和销毁,以及上下文切换,降低了系统性能。
NIO 使用非阻塞模型,允许线程在等待 I/O 时执行其他任务。这种模式通过使用选择器(Selector)来监控多个通道(Channel)上的 I/O 事件,实现了更高的性能和可伸缩性。
⚠需要注意:
使用 NIO 并不一定意味着高性能,它的性能优势主要体现在高并发和高延迟的网络环境下当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO

实际上,旧的IO包已经使用NIO优化过,所以即使我们不显式使用NIO编程,也能从中受益。

针对上面的注意点,我们来实战一下,看看不同场景下NIO与传统IO(BIO)的差异。

2. NIO比BIO强在何处?

2.1 处理文件IO时

上面我们提到过这句话,“实际上,旧的IO包已经使用NIO优化过,所以即使我们不显式使用NIO编程,也能从中受益”。具体体现在哪里呢,就是我们使用IO操作文件的时候。
这里我们将对比BIO和NIO做文件复制的性能:

@Test
public void test006() throws IOException{
    // 这是一个750MB左右的测试文件
    File file = new File("C:/Users/IQ50/Desktop/PVZ_for_test.zip");
    File bio_des = new File("C:/Users/IQ50/Desktop/PVZ_for_test_bio.zip");
    File nio_des = new File("C:/Users/IQ50/Desktop/PVZ_for_test_nio.zip");

    long time1 = transferByBio(file,bio_des);
    System.out.println("BIO花费了:"+time1);

    long time2 = transferByNio(file,nio_des);
    System.out.println("NIO花费了:"+time2);
}

// 传统IO
private long transferByBio(File file, File bioDes) throws IOException {
    long start = System.currentTimeMillis();

    if(!bioDes.exists()){
        bioDes.createNewFile();
    }
    // 创建输入输出流
    FileInputStream fis = new FileInputStream(file);
    BufferedInputStream bis = new BufferedInputStream(fis);

    FileOutputStream fos = new FileOutputStream(bioDes);
    BufferedOutputStream bos = new BufferedOutputStream(fos);

    // 使用字节数组传输数据
    byte[] buf = new byte[1024*1024];
    int len = 0;
    while((len=bis.read(buf))!=-1){
        bos.write(buf,0,len);
    }
    bis.close();
    bos.close();
    long end = System.currentTimeMillis();
    return end-start;
}

// NIO
private long transferByNio(File file, File nioDes) throws IOException{
    long start = System.currentTimeMillis();

    if(!nioDes.exists()){
        nioDes.createNewFile();
    }

    // 创建随机存取文件对象
    RandomAccessFile read = new RandomAccessFile(file,"rw");
    RandomAccessFile write = new RandomAccessFile(nioDes,"rw");

    // 获取文件通道
    FileChannel readChannel = read.getChannel();
    FileChannel writeChannel = write.getChannel();

    // 使用ByteBuffer传输数据
    ByteBuffer buf = ByteBuffer.allocate(1024*1024);
    while(readChannel.read(buf)>0){
        buf.flip();
        writeChannel.write(buf);
        buf.clear();
    }

    writeChannel.close();
    readChannel.close();
    long end = System.currentTimeMillis();
    return end-start;
}

------------------------------------------------------
output:
BIO花费了:595
NIO花费了:335

先解释一下这段代码,里面出现的 RandomAccessFile 我们之前讲过,FileChannel 是 Java NIO(New I/O)库中的一个类,它提供了对文件的高效 I/O 操作,支持随机访问文件,允许在文件的任意位置进行读写操作。
与 RandomAccessFile 不同,FileChannel 使用了缓冲区(ByteBuffer)进行数据传输。
上述代码我们测试的对象是一个750MB左右的文件,大家可以自行测试小文件/大文件下的差异。
由于不同的JDK版本(实现可能不同),不同的测试文件内容都有可能影响结果,我这里只能有个大概的情况:

  • 文件比较小的时候,两者差距不大;
  • 文件较大时,NIO一般会比BIO快一些,也会有BIO更快的情况;

总结一下就是,在做文件IO时,NIO比BIO牛一点。
那么到网络IO时,NIO可就比BIO牛大了。我们接着看。

2.2 处理网络IO时

NIO 的魅力主要体现在网络中
NIO(No-blocking IO,也可以叫New I/O)的设计目标是解决传统 I/O(BIO,Blocking I/O)在处理大量并发连接时的性能瓶颈。传统 I/O 在网络通信中主要使用阻塞式 I/O,为每个连接分配一个线程。当连接数量增加时,系统性能将受到严重影响,线程资源成为关键瓶颈。而 NIO 提供了非阻塞 I/O 和 I/O 多路复用,可以在单个线程中处理多个并发连接,从而在网络传输中显著提高性能。
以下是 NIO 在网络传输中优于传统 I/O 的原因:

  1. NIO 支持非阻塞 I/O,这意味着在执行 I/O 操作时,线程不会被阻塞。这使得在网络传输中可以有效地管理大量并发连接(数千甚至数百万)。而在操作文件时,这个优势没有那么明显,因为文件读写通常不涉及大量并发操作。
  2. NIO 支持 I/O 多路复用,这意味着一个线程可以同时监视多个通道(如套接字),并在 I/O 事件(如可读、可写)准备好时处理它们。这大大提高了网络传输中的性能,因为单个线程可以高效地管理多个并发连接。操作文件时这个优势也无法提现出来。
  3. NIO 提供了 ByteBuffer 类,可以高效地管理缓冲区。这在网络传输中很重要,因为数据通常是以字节流的形式传输。操作文件的时候,虽然也有缓冲区,但优势仍然不够明显。

接下来我们进行实战:
我们使用BIO去处理网络请求,简单来说就是在服务端用while去循环监听客户端的套接字Socker:

public class BioServer {
    public static void main(String[] args) {
        try{
            ServerSocket serverSocket = new ServerSocket(6767);
            while(true){
                Socket client = serverSocket.accept();
                InputStream is = client.getInputStream();
                OutputStream os = client.getOutputStream();
                
                byte[] buf = new byte[1024];
                int len = is.read(buf);
                os.write(buf,0,len);

                is.close();
                os.close();
                client.close();
            }
        }catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

接下来实现以下NIO的网络server端,主要用到的ServerSocketChannel和Selector,代码中会有注释:

public class NioServer {
    public static void main(String[] args) {
        try {
            // 创建 ServerSocketChannel
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            // 绑定端口
            serverSocketChannel.bind(new InetSocketAddress(8081));
            // 设置为非阻塞模式
            serverSocketChannel.configureBlocking(false);

            // 创建 Selector
            Selector selector = Selector.open();
            // 将 ServerSocketChannel 注册到 Selector,关注 OP_ACCEPT 事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            // 无限循环,处理事件
            while (true) {
                // 阻塞直到有事件发生
                selector.select();
                // 获取发生事件的 SelectionKey
                Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    // 处理完后,从 selectedKeys 集合中移除
                    iterator.remove();

                    // 判断事件类型
                    if (key.isAcceptable()) {
                        // 有新的连接请求
                        ServerSocketChannel server = (ServerSocketChannel) key.channel();
                        // 接受连接
                        SocketChannel client = server.accept();
                        // 设置为非阻塞模式
                        client.configureBlocking(false);
                        // 将新的 SocketChannel 注册到 Selector,关注 OP_READ 事件
                        client.register(selector, SelectionKey.OP_READ);
                    } else if (key.isReadable()) {
                        // 有数据可读
                        SocketChannel client = (SocketChannel) key.channel();
                        // 创建 ByteBuffer 缓冲区
                        ByteBuffer buffer = ByteBuffer.allocate(1024);
                        // 从 SocketChannel 中读取数据并写入 ByteBuffer
                        client.read(buffer);
                        // 翻转 ByteBuffer,准备读取
                        buffer.flip();
                        // 将数据从 ByteBuffer 写回到 SocketChannel
                        client.write(buffer);
                        // 关闭连接
                        client.close();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

上面的代码创建了一个基于 Java NIO 的简单 TCP 服务器。它使用 ServerSocketChannel 和 Selector(后面会讲) 实现了非阻塞 I/O 和 I/O 多路复用。服务器循环监听事件,当有新的连接请求时,接受连接并将新的 SocketChannel 注册到 Selector,关注 OP_READ 事件。当有数据可读时,从 SocketChannel 中读取数据并写入 ByteBuffer,然后将数据从 ByteBuffer 写回到 SocketChannel。
为了方便理解,简单说一下Socket & ServerSocket,以及ServerSocketChannel & SocketChannel
Socket 和 ServerSocket 是传统的阻塞式 I/O 编程方式,用于建立和管理 TCP 连接。

  • Socket:表示客户端套接字,负责与服务器端建立连接并进行数据的读写。
  • ServerSocket:表示服务器端套接字,负责监听客户端连接请求。当有新的连接请求时,ServerSocket 会创建一个新的 Socket 实例,用于与客户端进行通信。

在传统阻塞式 I/O 编程中,每个连接都需要一个单独的线程进行处理,这导致了在高并发场景下的性能问题。在接下来的客户端测试用例中会看到。
为了解决传统阻塞式 I/O 的性能问题,Java NIO 引入了ServerSocketChannelSocketChannel。它们是非阻塞 I/O,可以在单个线程中处理多个连接。

  • ServerSocketChannel:类似于 ServerSocket,表示服务器端套接字通道。它负责监听客户端连接请求,并可以设置为非阻塞模式,这意味着在等待客户端连接请求时不会阻塞线程。
  • SocketChannel:类似于 Socket,表示客户端套接字通道。它负责与服务器端建立连接并进行数据的读写。SocketChannel 也可以设置为非阻塞模式,在读写数据时不会阻塞线程。

再来简单说一下 Selector,后面会再细讲。
Selector 是 Java NIO 中的一个关键组件,用于实现 I/O 多路复用。它允许在单个线程中同时监控多个 ServerSocketChannelSocketChannel,并通过SelectionKey标识关注的事件。当某个事件发生时,Selector会将对应的SelectionKey添加到已选择的键集合中。通过使用Selector,可以在单个线程中同时处理多个连接,从而有效地提高 I/O 操作的性能,特别是在高并发场景下。


接下来我们来测试一下上面的两种客户端:

public class TestClient {
    public static void main(String[] args) throws InterruptedException {
        int clientCount = 10000;
        ExecutorService executorServiceIO = Executors.newFixedThreadPool(10);
        ExecutorService executorServiceNIO = Executors.newFixedThreadPool(10);

        // 使用传统 IO 的客户端
        Runnable ioClient = () -> {
            try {
                Socket socket = new Socket("localhost", 8080);
                OutputStream out = socket.getOutputStream();
                InputStream in = socket.getInputStream();
                out.write("Hello, 沉默王二 IO!".getBytes());
                byte[] buffer = new byte[1024];
                in.read(buffer);
                in.close();
                out.close();
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        };

        // 使用 NIO 的客户端
        Runnable nioClient = () -> {
            try {
                SocketChannel socketChannel = SocketChannel.open();
                socketChannel.connect(new InetSocketAddress("localhost", 8081));
                ByteBuffer buffer = ByteBuffer.wrap("Hello, 沉默王二 NIO!".getBytes());
                socketChannel.write(buffer);
                buffer.clear();
                socketChannel.read(buffer);
                socketChannel.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        };

        // 分别测试 NIO 和传统 IO 的服务器性能
        long startTime, endTime;

        startTime = System.currentTimeMillis();
        for (int i = 0; i < clientCount; i++) {
            executorServiceIO.execute(ioClient);
        }
        executorServiceIO.shutdown();
        executorServiceIO.awaitTermination(1, TimeUnit.MINUTES);
        endTime = System.currentTimeMillis();
        System.out.println("传统 IO 服务器处理 " + clientCount + " 个客户端耗时: " + (endTime - startTime) + "ms");

        startTime = System.currentTimeMillis();
        for (int i = 0; i < clientCount; i++) {
            executorServiceNIO.execute(nioClient);
        }
        executorServiceNIO.shutdown();
        executorServiceNIO.awaitTermination(1, TimeUnit.MINUTES);
        endTime = System.currentTimeMillis();
        System.out.println("NIO 服务器处理 " + clientCount + " 个客户端耗时: " + (endTime - startTime) + "ms");
    }
}

在这个简单的性能测试中,我们使用固定线程池(10个线程)来模拟客户端并发请求。分别测试 NIO 和传统 IO 服务器处理 10000 个客户端请求所需的时间。来看一下结果。
image.png
当然,实际生产环境的代码和各种条件是远远复杂于上面的demo的。

3. 小结

本篇内容主要讲了 NIO(New IO)和传统 IO 之间的差异,包括 IO 模型、操作文件、网络传输等方面。

  • 传统 I/O 采用阻塞式模型,线程在 I/O 操作期间无法执行其他任务。NIO 使用非阻塞模型,允许线程在等待 I/O 时执行其他任务,通过选择器(Selector)监控多个通道(Channel)上的 I/O 事件,提高性能和可伸缩性。
  • 传统 I/O 使用基于字节流或字符流的类(如 FileInputStream、BufferedReader 等)进行文件读写。NIO 使用通道(Channel)和缓冲区(Buffer)进行文件操作,NIO 在性能上的优势并不大。
  • 传统 I/O 使用 Socket 和 ServerSocket 进行网络传输,存在阻塞问题。NIO 提供了 SocketChannel 和 ServerSocketChannel,支持非阻塞网络传输,提高了并发处理能力。

在下一篇博客中,我们将详细介绍NIO的核心组件Buffer缓冲区、Channel通道、Selector选择器。


网站公告

今日签到

点亮在社区的每一天
去签到