h

hening1

V1

2022/08/26阅读:35主题:默认主题

FileChannel和MMAP

最近参加数据库性能大赛以及之前看rocketmq的源码,都有mmap和fileChannel的使用,懒得自己测试性能,找到了一片文章,作者测试的很全面,翻译一下,学习一波,原文地址结果文档地址

本文系列的前五个部分涵盖了读写文件,目录和文件路径构建,目录和文件操作以及结构化数据的写入和读取。 在今天的部分中,我解释了用JSR 51 ("New I/O APIs for the JavaTM Platform")在Java 1.4中引入的NIO类FileChannel和ByteBuffer 。而且,我展示了它们为读取和写入文件提供了哪些可能性,以及与之前讨论的方法相比它们的优势是什么。 详细地说,读完本文你将看到:

  1. 什么是文件通道和ByteBuffers,它们的优点是什么?
  2. 如何使用FileChannel和ByteBuffer编写和读取文件?
  3. 什么是内存映射文件,它们的优点是什么?
  4. 如何锁定文件的特定部分?
  5. 哪种写方法性能最好?

您可以在我的 GitHub Repository中找到本文中的代码。

什么是FileChannel?

Channel(通道)是指向文件,socket或提供I/O功能的其他组件的通信链接。与InputStream或OutputStream不同,通道是双向的,这意味着您可以将其用于写入和读取。 FileChannel是用于连接到文件的通道。

什么是ByteBuffer

ByteBuffer是一个以字节数组为基础 (在Java堆上或在本地内存中),并结合了写入和读取方法。这种封装允许写入ByteBuffer或从ByteBuffer读取数据,而不必知道写入/读取数据在实际数组中的位置。 从FileChannel读写数据你需要ByteBuffer。 image.png 调用put()方法将数据放入ByteBuffer,然后用FileChannel.write(buffer) 从缓冲区写入文件。FileChannel.write() 调用缓冲区上的get() 方法来检索数据。 使用FileChannel.read()方法从文件中读取 (缓冲区) 数据。read() 方法使用put() 将数据放入ByteBuffer,从那里,您可以使用get() 检索它。

FileChannel的优势

与FileInputStream和FileOutputStream类相比,FileChannel具有以下优势:

  1. 可以在文件中的任何位置进行读写。

  2. 可以强制操作系统将更改后的数据从缓存写入存储介质。

  3. 可以将文件的部分映射到内存 (“内存映射文件”),可以高效访问数据。

  4. 可以在文件部分上设置锁,以便其他线程和进程不能同时访问它们。

    1. 数据可以非常有效地从一个通道传输到另一个通道。

用ByteBuffer和FileChannel读写数据

打开一个FileChannel读写数据

调用FileChannel.open创建FileChannel

Path path = ...;
FileChannel channel = FileChannel.open(path, StandardOpenOption.READ);

或者,您可以从RandomAccessFile创建FileChannel

Path path = ...;
RandomAccessFile file = new RandomAccessFile(path.toFile(), "r");
FileChannel channel = file.getChannel();

还可以通过FileInputStream创建

Path path = ...;
FileInputStream in = new FileInputStream(path.toFile());
FileChannel channel = in.getChannel();

在示例中,选择方式没有区别。getChannel() 方法使用存储在RandomAccessFile或FileInputStream中的文件信息创建一个新的文件通道。因此,尽管只能从FileInputStream按顺序读取数据,但此限制不适用于从FileInputStream创建的FileChannel,依然可以在任意位置读取数据。 但是,相应地设置了可读和可写标志: 使用FileInputStream.getChannel() 创建的文件通道只能用于读取。 使用RandomAccessFile.getChannel() 创建的文件通道可用于读写。 如何使用使用FileChannel.open() 创建的文件通道由作为第二个参数传入的选项决定。由于我们在上面的示例中指定了StandardOpenOption.READ,因此在这种情况下仅允许读取访问。(具体可移栽StandardOpenOption的JavaDoc中找到可用选项的描述。

用FileChannel和ByteBuffer读数据

打开文件通道后,可以使用FileChannel.read() 将其读取到ByteBuffer中。以下示例读取每个1,024字节的块,输出它们各自的长度以及它们的第一个和最后一个字节。

Path path = Path.of("read-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.READ)) {
  ByteBuffer buffer = ByteBuffer.allocate(1024);

  int bytesRead;
  while ((bytesRead = channel.read(buffer)) != -1) {
    System.out.printf("bytes read from file: %d%n", bytesRead);
    if (bytesRead > 0) {
      System.out.printf("  first byte: %d, last byte: %d%n",
              buffer.get(0), buffer.get(bytesRead - 1));
    }
    buffer.rewind();
  }
}

使用channel.read (buffer),我们从文件中读取尽可能多的字节,并将它们放入缓冲区。使用buffer.get(index),我们从缓冲区中读取单个字节,而无需在过程中设置其读取位置。使用buffer.rewind(),我们在循环结束时将缓冲区的位置设置为0,以便可以再次填充。 使用ByteBuffer.flip() 和compact() 读取文件在下面的例子中。我们读取缓冲区的所有字节并将其求和。代替使用buffer.get(index) 访问数据,我们首先使用buffer.flip() 将读取位置设置为缓冲区的开头,然后使用buffer.get() 从当前读取位置读取单个字节。 我们不读取整个缓冲区,而仅读取一个随机的字节数,从而模拟出我们无法完全处理数据。然后,我们用buffer.compact() 切换回缓冲区写入模式,并从文件通道中读取更多字节。

Path path = Path.of("read-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.READ)) {
  ByteBuffer buffer = ByteBuffer.allocate(1024);

  int bytesRead;
  while ((bytesRead = channel.read(buffer)) != -1) {
    System.out.printf("bytes read from file: %d%n", bytesRead);

    long sum = 0;

    buffer.flip();
    int numBytesToRead =
            ThreadLocalRandom.current().nextInt(buffer.remaining());
    for (int i = 0; i < numBytesToRead; i++) {
      sum += buffer.get();
    }

    System.out.printf("  bytes read from buffer: %d, sum of bytes: %d%n",
            numBytesToRead, sum);
    buffer.compact();
  }
}

内存映射文件: 如何将文件部分映射到内存中

一种特殊的ByteBuffer是MappedByteBuffer-它将文件的一部分直接映射到内存中 (因此: “内存映射文件”)。这允许非常有效地访问文件,而不必使用FileChannel.write() 和read()。可以像字节数组一样访问MappedByteBuffer,即可以在任何位置写入并从任何位置读取。更改在后台透明地写入文件。 与传统的读写相比,直接映射可带来巨大的性能提升。文件直接映射到内存的 “用户空间” 中。相比之下,使用常规的写入和读取方法,数据必须在 “内核空间” 和 “用户空间” 之间来回复制。

Path path = Path.of("mapped-byte-buffer-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE,
        StandardOpenOption.READ)) {
  MappedByteBuffer buffer =
          channel.map(FileChannel.MapMode.READ_WRITE, 0256);

  // Write backwards
  for (int pos = 255; pos >= 0; pos--) {
    buffer.put(pos, (byte) pos);
  }

  // Read from random positions
  for (int i = 0; i < 10; i++) {
    int pos = ThreadLocalRandom.current().nextInt((int) channel.size());
    byte b = buffer.get(pos);
    System.out.printf("Byte at position %d: %d%n", pos, b);
  }
}

使用内存映射文件时,请注意以下几点:

  1. 必须在开头指定要映射的部分的位置和大小。在该示例中,映射前256个字节。如果该文件不存在,则将创建一个256字节的文件。如果文件存在且较小,则将其扩展为256字节。如果文件较大,则其大小和前256个字节之后的内容保持不变。
  2. 最多可以将2 GB映射到内存中。当Java 1.4 2002年引入MappedByteBuffer时,Java开发人员显然无法想象今天几乎每台开发人员笔记本电脑都配备了16到32 GB RAM。直到并包括Java 15 (早期访问),这个限制没有增加。
  3. MappedByteBuffer没有实现Closeable的接口。因此,在上面的示例中,我们无法在try块内创建它。也没有手动 “取消映射” 它的方法。如果我们尝试在上面的示例末尾删除文件,则在大多数情况下会得到一个AccessDeniedException。当不再需要MappedByteBuffer时,垃圾收集器将其删除。要 “取消映射” 文件,它会注册一个所谓的 “清理器”,当MappedByteBuffer仅是 “phantom reachable” 时,将调用该清理器。在下面描述的性能测试一下的代码中,您可以找到使用sun.misc.Unsafe手动取消映射文件的hack。

从文件输入流/文件输出流创建MappedByteBuffer

如何锁定文件

对于复杂的应用程序 (例如,文件或数据库服务器),您可能希望从不同的线程甚至进程访问相同的文件。因此,必须锁定正在写入的整个文件或文件部分,以便没有其他线程或进程可以同时访问它们。 操作系统和文件系统直接支持锁定,因此这也可以在不同的Java程序之间或Java程序与同一系统上的任何其他进程之间工作,或者在使用共享存储时 (例如,网络驱动器) 也可以在不同系统上的进程之间工作。 在共享锁 (“读锁”) 和排他锁 (“写锁”) 之间进行了区分。如果一个进程在文件部分上持有独占锁,则没有其他进程可以在相同或重叠的文件部分上获得锁如果一个进程持有共享锁,其他进程也可以获得相同或重叠文件部分的共享锁。 您可以使用以下方法设置锁: FileChannel.lock (position, size, shared)-此方法wait直到可以为位置和大小指定的文件部分设置请求类型的锁 (shared = true → shared; shared = false → exclusive)。 FileChannel.lock() -此方法wait直到可以为整个文件设置排他锁。 FileChannel.tryLock (position, size, shared)-此方法尝试为指定的文件部分设置请求类型的锁。如果无法获得锁,则该方法不会等待,而是返回null。 FileChannel.tryLock() -此方法尝试为整个文件设置排他锁。如果这是不可能的,它返回null。 如果成功设置了锁,则这些方法将返回FileLock对象。您可以使用其release() 或close() 方法释放锁。这里是一个简单的例子,设置一个排他的锁在整个文件,然后写入1,000随机字节:

Path path = Path.of("lock-demo.bin");

byte[] bytes = new byte[1000];
ThreadLocalRandom.current().nextBytes(bytes);

try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE);
     FileLock lock = channel.lock()) {
  ByteBuffer buffer = ByteBuffer.wrap(bytes);
  channel.write(buffer);
}

性能测试

(最重要的部分来了) 性能测试 我编写了一个程序来衡量不同写入方法的性能-在不同的缓冲区和文件大小下。 为了获得尽可能无副作用的结果,我重复每次测试一下32次,然后确定中位数。我创建了大小从1 MB到1 GB的文件,并为ByteBuffer提供了1 KB到1 MB的文件。 所有测试均没有调用force()方法。我想测试一下数据传输到操作系统的速度,而不是存储介质的速度。 欢迎您从我的GitHub存储库中克隆测试一下程序,并在您的系统上运行它。 测试一下程序还测量通过RandomAccessFile.getChannel() 和FileOutputStream.getChannel() 创建的那些文件通道的写入速度。但是,由于测试结果与FileChannel.open() 的测试结果几乎相同,因此我不在以下各节中显示它们。

测试结果

测试一下结果过于广泛,无法在这里全文打印。您可以在此Google Document中查看它们。 首先,写入速度取决于访问的类型-顺序访问或随机访问。有趣的是,缓冲区和文件大小也会对结果产生重大影响。

顺序写访问测试结果

通过顺序写访问,速度连续增加到128 MB的文件大小; 之后,它停滞或下降。我怀疑从这个大小开始,操作系统开始将数据写入存储介质。因此,我只显示文件大小最大128 MB的结果。 以下四个图显示了1 MB、8 MB、16 MB和128 MB的文件大小与缓冲区大小相关的写入速度。

image.png
image.png

image.png

image.png
image.png

image.png

顺序写访问结果分析

对于大小不超过8 MB的文件,无论缓冲区大小如何,内存映射的文件都是最快的。 对于16 MB文件,这仅在缓冲区大小为16 KB时有效。缓冲区大小为32 KB或更大,native ByteBuffer的FileChannel更快。文件大小为128 MB,在16 KB的缓冲区大小下,FileChannel的速度已经更快。 Native ByteBuffer比Java HeapByteBuffer快20%。文件和缓冲区越大,native ByteBuffer的性能增益就越高。 缓冲区大小达8 KB,带有BufferedOutputStream的FileOutputStream比FileChannel快。 超过8 KB的缓冲区大小,流和带有堆缓冲区的FileChannel的速度大致相同。8 KB的限制是由于BufferedOutputStream的内部8 KB缓冲区。BufferedOutputStream首先填充缓冲区,然后再将数据写入文件。 从1 MB缓冲区大小开始,所有写入方法和文件大小的写入速度都会降低。

随机写访问测试一下结果

以下三个图显示了1 MB、8 MB和128 MB的文件大小与缓冲区大小相关的随机访问写入速度。我没有写任何更大的文件,因为随机写访问测试通常比顺序写访问测试花费更长的时间。 image.png image.png image.png

随机写访问分析测试结果分析

使用随机写入访问,内存映射文件是写入文件的最快方法-无论文件和缓冲区大小如何。FileChannel紧随其后; 本机缓冲区的性能增益也可以达到20%。

结论

对于随机写访问,选择应该始终是内存映射的文件。 通过顺序写入访问,您还可以使用内存映射文件,文件大小不超过8 MB。对于较大的文件,使用FileChannel和最小大小为16 KB,最大大小为512 KB的native ByteBuffer可以实现最佳性能。 当然,这些只是粗略的测试,是从我的系统上的测量结果得出的。实际情况我建议针对您的特定用例测试不同的写入方法和缓冲区大小。

总结

在今天的文章中,我向您展示了什么是FileChannel和ByteBuffer,以及如何使用它们读写文件。您了解了什么是内存映射文件,以及如何在文件部分上设置锁,以使其他进程无法同时写入它们。 关于Java文件的六部分系列文章到此结束。

分类:

后端

标签:

Java

作者介绍

h
hening1
V1