MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java NIO的基础知识与应用

2023-04-113.1k 阅读

Java NIO 概述

Java NIO(New I/O)是从 Java 1.4 开始引入的一套新的 I/O 类库,用于弥补传统 Java I/O 流操作的一些不足。传统的 I/O 流是面向字节流或字符流的阻塞式 I/O 操作,而 NIO 则提供了基于缓冲区(Buffer)和通道(Channel)的非阻塞式 I/O 操作,大大提高了 I/O 操作的效率和灵活性。

NIO 的核心组件包括:

  • 缓冲区(Buffer):用于存储数据的容器,所有数据都通过缓冲区来处理。
  • 通道(Channel):用于在字节缓冲区和数据源或数据目标之间进行数据传输的链接。
  • 选择器(Selector):用于实现非阻塞 I/O 的多路复用机制,允许一个线程处理多个通道。

缓冲区(Buffer)

缓冲区的基本概念

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便地访问该块内存。

Java NIO 中的 Buffer 是一个抽象类,其具体实现类有 ByteBuffer、CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer 和 DoubleBuffer,分别对应不同的数据类型。

缓冲区的主要属性

  • 容量(Capacity):缓冲区能够容纳的数据元素的最大数量。一旦缓冲区被创建,它的容量就不能被改变。
  • 位置(Position):下一个要读取或写入的数据元素的索引。读或写操作都会改变位置的值。
  • 限制(Limit):缓冲区中可以读取或写入的数据的最后位置。在写模式下,限制通常等于缓冲区的容量;在读模式下,限制表示缓冲区中已写入的数据量。

缓冲区的基本操作

  1. 分配缓冲区:使用静态的 allocate() 方法来分配缓冲区。例如,分配一个容量为 1024 的 ByteBuffer:
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
  1. 写入数据到缓冲区:通过 put() 方法将数据写入缓冲区,写入后位置会相应增加。
String str = "Hello, NIO!";
byte[] bytes = str.getBytes();
byteBuffer.put(bytes);
  1. 切换到读模式:调用 flip() 方法将缓冲区从写模式切换到读模式。该方法会将限制设置为当前位置,并将位置重置为 0。
byteBuffer.flip();
  1. 从缓冲区读取数据:通过 get() 方法从缓冲区读取数据,读取后位置也会相应增加。
byte[] readBytes = new byte[byteBuffer.remaining()];
byteBuffer.get(readBytes);
String readStr = new String(readBytes);
System.out.println(readStr);
  1. 清空缓冲区:调用 clear() 方法将缓冲区清空,实际上并没有清除数据,而是将位置重置为 0,限制设置为容量。
byteBuffer.clear();

通道(Channel)

通道的基本概念

通道是一种用于在字节缓冲区和数据源或数据目标之间进行数据传输的链接。与传统 I/O 流不同,通道是双向的,可以同时进行读和写操作,而流通常是单向的(要么是输入流,要么是输出流)。

Java NIO 中的通道都继承自 java.nio.channels.Channel 接口,常见的实现类有 FileChannel(用于文件 I/O)、SocketChannel(用于 TCP 套接字 I/O)、ServerSocketChannel(用于监听 TCP 连接)和 DatagramChannel(用于 UDP 套接字 I/O)。

文件通道(FileChannel)

  1. 打开文件通道:通过 FileInputStreamFileOutputStreamRandomAccessFilegetChannel() 方法来获取文件通道。
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelExample {
    public static void main(String[] args) throws Exception {
        // 读取文件
        FileInputStream fis = new FileInputStream("input.txt");
        FileChannel readChannel = fis.getChannel();

        // 写入文件
        FileOutputStream fos = new FileOutputStream("output.txt");
        FileChannel writeChannel = fos.getChannel();

        ByteBuffer buffer = ByteBuffer.allocate(1024);

        while (readChannel.read(buffer) != -1) {
            buffer.flip();
            writeChannel.write(buffer);
            buffer.clear();
        }

        readChannel.close();
        writeChannel.close();
        fis.close();
        fos.close();
    }
}
  1. 文件通道的操作
    • 读取数据:使用 read(ByteBuffer dst) 方法将数据从文件通道读取到缓冲区。
    • 写入数据:使用 write(ByteBuffer src) 方法将缓冲区中的数据写入文件通道。
    • 文件映射:通过 map() 方法可以将文件的部分或全部内容直接映射到内存中,形成一个 MappedByteBuffer,这样可以直接对内存进行操作,而不必通过缓冲区来读写文件,大大提高了 I/O 效率。
FileChannel fileChannel = new RandomAccessFile("largeFile.txt", "rw").getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());

套接字通道(SocketChannel 和 ServerSocketChannel)

  1. SocketChannel:用于客户端与服务器端建立 TCP 连接并进行数据传输。
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class SocketChannelClientExample {
    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost", 8080));

        String message = "Hello, Server!";
        ByteBuffer buffer = ByteBuffer.wrap(message.getBytes());
        socketChannel.write(buffer);

        buffer.clear();
        socketChannel.read(buffer);
        buffer.flip();
        byte[] responseBytes = new byte[buffer.remaining()];
        buffer.get(responseBytes);
        String response = new String(responseBytes);
        System.out.println("Server response: " + response);

        socketChannel.close();
    }
}
  1. ServerSocketChannel:用于服务器端监听 TCP 连接请求。
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

public class ServerSocketChannelServerExample {
    public static void main(String[] args) throws Exception {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8080));

        while (true) {
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (socketChannel != null) {
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                socketChannel.read(buffer);
                buffer.flip();
                byte[] requestBytes = new byte[buffer.remaining()];
                buffer.get(requestBytes);
                String request = new String(requestBytes);
                System.out.println("Client request: " + request);

                String response = "Message received: " + request;
                buffer = ByteBuffer.wrap(response.getBytes());
                socketChannel.write(buffer);

                socketChannel.close();
            }
        }
    }
}

数据报通道(DatagramChannel)

DatagramChannel 用于 UDP 套接字 I/O,它可以发送和接收 UDP 数据包。

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;

public class DatagramChannelExample {
    public static void main(String[] args) throws Exception {
        // 发送端
        DatagramChannel sendChannel = DatagramChannel.open();
        InetSocketAddress receiveAddress = new InetSocketAddress("localhost", 9090);
        String sendMessage = "Hello, UDP!";
        ByteBuffer sendBuffer = ByteBuffer.wrap(sendMessage.getBytes());
        sendChannel.send(sendBuffer, receiveAddress);

        // 接收端
        DatagramChannel receiveChannel = DatagramChannel.open();
        receiveChannel.bind(new InetSocketAddress(9090));
        ByteBuffer receiveBuffer = ByteBuffer.allocate(1024);
        receiveChannel.receive(receiveBuffer);
        receiveBuffer.flip();
        byte[] receiveBytes = new byte[receiveBuffer.remaining()];
        receiveBuffer.get(receiveBytes);
        String receiveMessage = new String(receiveBytes);
        System.out.println("Received message: " + receiveMessage);

        sendChannel.close();
        receiveChannel.close();
    }
}

选择器(Selector)

选择器的基本概念

选择器是 Java NIO 实现非阻塞 I/O 的关键组件,它允许一个线程监视多个通道的 I/O 事件(如连接建立、数据可读、数据可写等)。通过使用选择器,应用程序可以用一个线程处理多个通道,从而提高系统的并发性能。

选择器通过 Selector.open() 方法创建,并且可以通过 register() 方法将通道注册到选择器上,并指定要监听的事件类型。

选择器的事件类型

  • OP_READ:表示通道有数据可读。
  • OP_WRITE:表示通道可以写入数据。
  • OP_CONNECT:表示连接操作已经完成(仅适用于 SocketChannel)。
  • OP_ACCEPT:表示服务器套接字通道(ServerSocketChannel)有新的连接请求。

选择器的使用示例

以下是一个简单的使用选择器的示例,展示了如何使用一个线程处理多个 SocketChannel 的读操作。

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;

public class SelectorExample {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8080));
        serverSocketChannel.configureBlocking(false);

        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            int readyChannels = selector.select();
            if (readyChannels == 0) continue;

            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();

                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel socketChannel = server.accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    int bytesRead = socketChannel.read(buffer);
                    if (bytesRead > 0) {
                        buffer.flip();
                        byte[] bytes = new byte[buffer.remaining()];
                        buffer.get(bytes);
                        String message = new String(bytes);
                        System.out.println("Received message: " + message);
                    }
                }

                keyIterator.remove();
            }
        }
    }
}

在上述示例中,服务器使用 ServerSocketChannel 监听 8080 端口,并将其注册到选择器上,监听 OP_ACCEPT 事件。当有新的连接请求时,接受连接并将新的 SocketChannel 注册到选择器上,监听 OP_READ 事件。当有通道可读时,读取数据并处理。

直接缓冲区与非直接缓冲区

非直接缓冲区

在前面的示例中,我们使用的都是非直接缓冲区。非直接缓冲区是通过 allocate() 方法创建的缓冲区,数据会先被写入到 JVM 内存中的缓冲区,然后再从该缓冲区复制到内核空间进行 I/O 操作。这种方式相对简单,但由于数据需要在用户空间和内核空间之间进行复制,可能会影响性能。

直接缓冲区

直接缓冲区是通过 allocateDirect() 方法创建的缓冲区,它直接分配在内核空间,避免了数据在用户空间和内核空间之间的复制,因此在进行大量数据的 I/O 操作时,直接缓冲区通常能提供更好的性能。不过,直接缓冲区的分配和释放比非直接缓冲区更复杂,并且可能会占用更多的系统资源。

ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(1024);

在使用直接缓冲区时,需要注意以下几点:

  • 直接缓冲区的创建和销毁开销较大,因此适用于长期存在且需要频繁进行 I/O 操作的场景。
  • 直接缓冲区不受 JVM 堆内存大小的限制,但会受到系统内存的限制。
  • 直接缓冲区的垃圾回收可能会比较复杂,因为它不在 JVM 堆内存中。

分散(Scatter)与聚集(Gather)

分散读取(Scatter Read)

分散读取是指将数据从通道读取到多个缓冲区中。在 Java NIO 中,read(ByteBuffer[] buffers) 方法可以实现分散读取操作。例如,假设有两个缓冲区 headerBufferbodyBuffer,可以通过以下方式进行分散读取:

ByteBuffer headerBuffer = ByteBuffer.allocate(100);
ByteBuffer bodyBuffer = ByteBuffer.allocate(1024);
ByteBuffer[] buffers = {headerBuffer, bodyBuffer};
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 8080));
socketChannel.read(buffers);

在上述示例中,数据会首先被读取到 headerBuffer 中,当 headerBuffer 填满后,剩余的数据会被读取到 bodyBuffer 中。

聚集写入(Gather Write)

聚集写入是指将多个缓冲区中的数据写入到通道中。在 Java NIO 中,write(ByteBuffer[] buffers) 方法可以实现聚集写入操作。例如,假设有两个缓冲区 headerBufferbodyBuffer,可以通过以下方式进行聚集写入:

ByteBuffer headerBuffer = ByteBuffer.wrap("Header data".getBytes());
ByteBuffer bodyBuffer = ByteBuffer.wrap("Body data".getBytes());
ByteBuffer[] buffers = {headerBuffer, bodyBuffer};
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 8080));
socketChannel.write(buffers);

在上述示例中,headerBuffer 中的数据会首先被写入通道,然后 bodyBuffer 中的数据会接着被写入通道。

字符集与编解码

在处理文本数据时,字符集的编解码是一个重要的环节。Java NIO 提供了 CharsetCharsetEncoderCharsetDecoder 类来处理字符集相关的操作。

字符集(Charset)

Charset 类代表一个字符集,Java 中内置了多种字符集,如 UTF-8UTF-16ISO-8859-1 等。可以通过 Charset.forName(String charsetName) 方法来获取指定名称的字符集。

Charset utf8Charset = Charset.forName("UTF-8");

编码(Encoding)

编码是将字符序列转换为字节序列的过程。通过 CharsetEncoder 来实现编码操作。

CharsetEncoder encoder = utf8Charset.newEncoder();
CharBuffer charBuffer = CharBuffer.wrap("Hello, 世界".toCharArray());
ByteBuffer byteBuffer = encoder.encode(charBuffer);

解码(Decoding)

解码是将字节序列转换为字符序列的过程。通过 CharsetDecoder 来实现解码操作。

CharsetDecoder decoder = utf8Charset.newDecoder();
ByteBuffer byteBuffer = ByteBuffer.wrap("Hello, 世界".getBytes("UTF-8"));
CharBuffer charBuffer = decoder.decode(byteBuffer);

Java NIO 与传统 I/O 的比较

  1. 性能
    • 传统 I/O:基于字节流或字符流,操作是阻塞式的,在进行 I/O 操作时,线程会被阻塞,直到操作完成。这在处理大量并发连接时,会导致大量线程被阻塞,消耗大量系统资源。
    • Java NIO:基于缓冲区和通道,支持非阻塞式 I/O 操作,通过选择器可以用一个线程处理多个通道,大大提高了系统的并发性能,减少了线程资源的消耗。
  2. 数据处理方式
    • 传统 I/O:是面向流的,数据以字节或字符的形式逐个处理,处理过程相对简单,但灵活性较差。
    • Java NIO:是面向缓冲区的,数据先被读入缓冲区,然后可以对缓冲区中的数据进行灵活的操作,如随机访问、分片等,灵活性更高。
  3. 使用场景
    • 传统 I/O:适用于简单的、对性能要求不高的 I/O 操作,如读取配置文件、简单的文件读写等。
    • Java NIO:适用于高并发、高性能的网络编程、大规模文件处理等场景,如开发高性能的网络服务器、分布式系统等。

总结

Java NIO 提供了一套强大的非阻塞式 I/O 编程模型,通过缓冲区、通道和选择器等核心组件,使得开发者能够更高效地处理 I/O 操作,特别是在高并发场景下。掌握 Java NIO 的基础知识和应用,对于开发高性能、可扩展的 Java 应用程序至关重要。希望通过本文的介绍和示例代码,读者能够对 Java NIO 有更深入的理解和掌握,并在实际项目中灵活运用。