使用Java NIO 和 NIO2实现文件输入/输出


当您需要快速移动大量文件数据或套接字数据时,请使用这些低级 Java API。
本文是关于在文件输入/输出方面实现高性能的。高性能不仅意味着快速执行 I/O 操作,而且还消耗(或占用)JVM 和其他地方的最少资源。
 
介绍 NIO 和缓冲区
最好的起点是添加第一个 API 以解决 Java 文件输入/输出的性能问题。新的输入/输出 (NIO) 作为JSR 51添加到 Java 1.4 中。缺乏非阻塞通信和其他 I/O 特性一直是 Java 早期版本的主要批评,NIO 的到来带来了广泛而有用的特性集,包括

  • 不需要遍历 Java 堆的 I/O 操作的抽象层
  • 编码和解码字符集的能力
  • 可以将文件映射到内存中保存的数据的接口
  • 执行非阻塞 I/O 的能力
  • 一个新的正则表达式库

随着 Java 成长为一种用于服务器端开发的有吸引力的语言,这些功能尤其重要。最近的版本延续了这一趋势,并添加了其他面向性能的 API,例如异步 I/O。这些将在稍后讨论。
不过,首先,考虑一下 NIOBufferChannel类。它们每个都提供一个类,该类充当特定(原始)类型元素的线性序列的容器。为简单起见,以下示例使用ByteBuffer( 的子类Buffer)。
请注意,NIO 为高性能 I/O提供了低级抽象,但这些 API 对于开发人员来说并不总是像前面文章中介绍的高度抽象的 API 那样容易使用。仅当您有特定的性能需求时才应使用低级 API。
字节缓冲区可以由堆上 Javabyte[]数组或 Java 堆之外的内存区域(但仍在 JVM 进程的 C 堆内)支持。在这两种方法中,第二种更为常见。
  • 在第一种情况下,堆上数组缓冲区本质上提供了一个更面向对象的底层byte[].
  • 第二种情况称为直接缓冲区方法,这种方法尽可能绕过Java堆。这可以带来性能优势,例如,当您在其他堆外数据之间进行大量复制时。在实践中,直接缓冲区比堆上数组缓冲区更常用。

ByteBuffer 提供了三种创建缓冲区的静态方法。
  • allocateDirect() 用于执行本机 I/O 操作
  • allocate() 用于创建新的堆分配缓冲区
  • wrap() 用于设置由已经存在的字节数组支持的缓冲区

您可以在以下代码中看到它是如何工作的:
ByteBuffer b =
   ByteBuffer.allocateDirect(128 * 1024 * 1024);
ByteBuffer b2 =
   ByteBuffer.allocate(1024 * 1024);

byte[] data = {1, 2, 3, 4, 5};
ByteBuffer b3 = ByteBuffer.wrap(data);

字节缓冲区都是关于对字节的低级访问,这意味着您必须手动处理细节。这包括需要处理数据格式的字节顺序以及 Java 数字原语(包括字节)已签名的事实。
缓冲区低级细节的 API 非常简单。

b.order(ByteOrder.LITTLE_ENDIAN);

int capacity = b.capacity();
int position = b.position();
int limit = b.limit();
int remaining = b.remaining();
boolean more = b.hasRemaining();

如您所见,当您使用ByteBufferAPI 时,您必须处理非常底层的方面,例如缓冲区的容量和您在其中的当前位置。
您可以通过以下两种方式之一在缓冲区中读取和写入数据:一次一个值或作为批量操作。通过使用批量操作,您可以期望从这些低级函数中获得性能提升。
这是处理单个值的 API。
b.put((byte)101);
b.putChar('a');
b.putInt(0xcafebabe);

double d = b.getDouble()

单值 API 还包含一个重载,用于缓冲区内的绝对定位。

b.put(0, (byte)9);

批量 API 与 abyte[]或 a一起使用ByteBuffer,并将可能大量的值作为单个操作进行操作,如下所示。

b.put(data);
b.put(b2);

b.get(data, 0, data.length);

 
介绍通道
缓冲区是您使用堆上还是堆外操作的内存中抽象。在堆外操作的情况下,您应该将数据从缓冲区移动到也是堆外的其他地方,而不必通过 Java 堆传输数据。
例如,您可能希望直接从缓冲区读取数据到文件或套接字或从文件或套接字写入数据。这是通过Channel从 package中获得第二个对象 a 来实现的java.nio.channels。通道对象表示可以支持读取或写入操作的实体。文件和套接字是常见的示例,但您可以想象用于特殊目的的其他自定义实现。
通道对象在打开状态下创建,随后可以关闭。但是,一旦关闭,它们将无法重新打开。通道被认为是进出缓冲区的单向流。这意味着它们要么是可读的,要么是可写的——但不是两者兼而有之。
了解渠道的关键是

  • 从通道读取将字节放入缓冲区
  • 写入通道从缓冲区中获取字节

因此,通道上的关键方法是
  • read() 从通道读取到缓冲区(用于可读通道)。
  • write() 从缓冲区写入通道(对于可写通道)。

这些方法经常与compact()缓冲区对象上的方法结合使用。该compact()方法丢弃缓冲区中当前位置之前的所有数据,并将所有后面的数据复制到缓冲区的开头。该方法还将光标的位置跳过到已复制的字节之后。这允许紧跟操作紧随其后,例如,另一个read().
通过实际操作,这可能是最容易理解的。例如,假设您有一个大文件,要以 16 MB 的块进行校验和。

FileInputStream fis = getSomeStream();
boolean fileOK = true;

try (FileChannel fchan = fis.getChannel()) {
   var buffy =
      ByteBuffer.allocateDirect(16 * 1024 * 1024);

   while(fchan.read(buffy) !=
      -1 || buffy.position() > 0 || fileOK) {
      fileOK = computeChecksum(buffy);
      buffy.compact();
      }
   }
   catch (IOException e) {
   System.out.println("Exception in I/O");
}

上面的代码将尽可能地使用本机 I/O,并避免在 Java 堆内外大量复制字节。如果computeChecksum()您使用的方法得到了很好的实现,那么这可能是一个非常高效的实现。
 
映射字节缓冲区
映射字节缓冲区是一种包含内存映射文件(或文件区域)的直接字节缓冲区。这些缓冲区是从一个FileChannel对象创建的,但这里有一个重要的注意事项:在内存映射操作之后一定不能使用File对应的对象,MappedByteBuffer否则将引发异常。为了缓解这种情况,您可以使用try严格限定对象的范围,如下所示:

try (var raf = new RandomAccessFile(new
     File("input.txt"), "rw");
     var fc = raf.getChannel()) {
  MappedByteBuffer mbf =
    fc.map(FileChannel.MapMode.READ_WRITE, 0,
    fc.size());
  byte[] b = new byte[(int)fc.size()];
  mbf.get(b, 0, b.length);

 
// Zero the in-memory copy
  for (int i = 0; i < fc.size(); i = i + 1) {
    b[i] = 0;
  }

 
// Reposition to the start of the file
  mbf.position(0);

 
// Zero the file
  mbf.put(b);
}

映射缓冲区扩展ByteBuffer了特定于内存映射文件区域的附加操作,但它在其他方面是直接字节缓冲区。映射文件的要点是可以在外部更改内容。这意味着映射字节缓冲区中的数据可以随时更改,例如映射文件的相应区域的内容被另一个程序更改时。

请注意,文件映射的精确语义取决于操作系统,因此 JDK 无法保证对文件磁盘副本的任何更改何时会显示在内存映射副本中。

SeekableByteChannel
对文件 I/O 的并发访问由接口处理java.nio.channels.SeekableByteChannel。这个接口 ( FileChannel) 的主要实现可以保存您从文件中读取的当前位置,以及您正在写入的文件中的位置。这意味着您可以让多个线程在不同位置读取和/或写入同一通道,从而实现更快的文件 I/O。

创建可搜索的频道非常容易。

var readChannel =
   Files.newByteChannel(Path.of("temp.txt"),
   StandardOpenOption.READ);

var writeChannel =
   Files.newByteChannel(Path.of("temp.txt"),
   StandardOpenOption.WRITE);

通道现在可以使用 上的方法SeekableByteChannel,包括

  • position(),返回此通道的位置
  • position(long newPosition),设置此通道的位置
  • read(ByteBuffer dst),它将一个字节序列读入给定的缓冲区
  • size(),它返回此通道连接到的实体的当前大小
  • write(ByteBuffer src),它将字节从缓冲区写入通道

您现在可以传递readChannel给读取线程和writeChannel写入线程,并且这些通道可以独立使用——尽管最终在同一个文件上操作。
尽管可以使用缓冲区实现所有功能,但对于大型 I/O 操作的功能仍然存在限制。例如,缓冲区的分配限制为 2 GB,因为构造函数参数是 int。这意味着对比这更大的文件的操作必须零碎完成。
这不是假设。想象一下任务,例如在文件系统之间传输 10 GB 的数据,当在单个线程上同步完成时性能很差。
在带有 Java 7 的 NIO.2 到来之前,处理这些类型的操作通常是通过编写自定义多线程代码并管理一个单独的线程池来执行后台复制来完成的。这种类型的低级编码很容易出错,坦率地说,这是应该委托给框架功能的代码类型。
 
NIO.2 的异步操作
NIO.2 为基于套接字和基于文件的 I/O 添加了异步功能,允许您充分利用硬件的功能。对于任何希望在服务器端和系统编程空间中保持相关性的语言来说,这是一个重要的特性。
那么,术语异步 I/O是什么意思?异步 I/O 只是一种输入/输出处理,它允许在读取和写入完成之前进行其他活动。
NIO.2 API 提供了许多您可以使用的新异步通道。
  • AsynchronousFileChannel 用于基于文件的 I/O
  • AsynchronousSocketChannel用于基于套接字的 I/O;这支持超时
  • AsynchronousServerSocketChannel 对于接受连接的异步套接字
  • AsynchronousDatagramChannel用于“一劳永逸”的 I/O;它不检查有效的连接

使用 NIO.2 异步 I/O API 时可以采用两种主要风格:Future风格和Callback风格。
未来风格。Future 样式使用Future来自java.util.concurrent. 该Future对象表示您的异步操作的结果,并且仍然处于挂起状态或将在操作完成后完全实现。
通常,get()当异步 I/O 活动完成时,您将使用该方法(有或没有超时)来检索结果。以下示例从文件中读取 100 个字节,然后获取结果(这将是实际读取的字节数)。
var file = Path.of("/usr/ben/foobar.txt");

try (var channel =
  AsynchronousFileChannel.open(file)) {
    var buffer = ByteBuffer.allocate(100);
    Future<Integer> result = channel.read(buffer, 0);

    BusinessProcess.doSomethingElse();

    var bytesRead = result.get();
    System.out.println(
"Bytes read [" + bytesRead + "]");
  } catch (IOException | ExecutionException |
    InterruptedException e) {
    e.printStackTrace();
}

Future对象相对来说比较简单,因为它们有一个get()方法可以返回操作的结果——如果操作还没有完成就会阻塞。这些对象还具有isDone()告诉您操作是否已完成的非阻塞方法。这意味着您可以通过简单地定期检查isDone()然后仅get()在操作完成后才调用来避免无限期阻塞。
回调样式。如果 Future 样式对您来说似乎违反直觉,有一种替代技术,称为 Callback 样式,它使用该CompletionHandler接口。一些开发人员更喜欢使用回调样式,因为它类似于事件处理代码。
java.nio.channels.CompletionHandler<V, A>接口(其中是V结果类型,并且A是您从中获取结果的附加对象)有两个必须给出实现的方法。当然,这意味着您不能使用 lambda 表达式来表示一个。相反,通常使用匿名内部类。
必须为这些方法completed(V, A)和failed(V, A)提供一个实现,该实现描述当异步 I/O 操作成功完成或由于某种原因失败时程序应如何运行。当异步 I/O 活动完成时,将调用这两种方法中的一种(并且只有一种)。
以下是和之前一样的任务,从文件中读取 100 个字节,但这次使用的是CompletionHandler<Integer, ByteBuffer>接口:

var file = Path.of("/usr/ben/foobar.txt");

try (var channel = AsynchronousFileChannel.open(file)) {
    var buffer = ByteBuffer.allocate(100);
    var handler = new CompletionHandler<Integer,
        ByteBuffer>() {
        public void completed(Integer result,
            ByteBuffer attachment) {
            System.out.println(
"Bytes read [" + result + "]");
        }

        public void failed(Throwable exception,
            ByteBuffer attachment) {
            exception.printStackTrace();
        }
   };

   channel.read(buffer, 0, buffer, handler);
} catch (IOException e) {
    e.printStackTrace();
}

上面的示例都是基于文件的,但也可以使用套接字 API 执行非常相似的任务。