Java 基础之 FileChannel

发布于 2025-01-16 10:47:00 字数 7331 浏览 2 评论 0

只想看 API 怎么用的可以直接从目录跳转到 Java 代码

因为我电脑上面只有 linux 手册 查起来方便所以我下面都是基于 linux 讲的

名词解释

堆内

public static void main(String[] args) {
    Object o = new Object();
}

默认情况下这个对象将分配到堆上,假设它的地址是 0x12345678。由于 GC 的问题,在某一次 GC 后它的地址就可能变为 0x22345789,一般是那种带整理内存的 GC 会发生则这种情况,整理内存的目的在于避免内存碎片化,碎片化会导致虽然容量足够但是没有连续的内存用于分配对象。

综上得到,你不可以假定某一个对象固定在内存原地不动,这就是堆内内存的一个特性

有些地方会称之为 java heap

堆外

堆外内存就很好理解了 这是一块不受 GC 管理的内存,有些地方你可能看到一种描述——C Heap,就是指出由 native 分配的内存

注意 :堆内内存和堆外内存这种分类法是用来描述 java 这种具有虚拟机语言的内存分类,像 C 这种 native 语言并没有这种说法,也和内核态空间,用户态空间不是一回事。堆内堆外内存均属于用户态内存。以上两种内存 都分配到当前进程的堆上

简单来说 堆外内存来源于 native 方法中诸如 malloc 这种方法分配出来的内存

何使使用堆外内存?

  1. 需要进程间共享
  2. 需要很多内存用于储存数据,且生命周期比较长,会给 GC 很大压力,这种就可以尝试挂到堆外去
  3. 在某些场景下可以提升程序 I/O 操纵的性能。少去了将数据从堆内内存拷贝到堆外内存的步骤(或者反过来)。

native 调用和内核态,用户态

内核态:cpu 可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu 也可以将自己从一个程序切换到另一个程序。

用户态:只能受限的访问内存,且不允许访问外围设备,占用 cpu 的能力被剥夺,cpu 资源可以被其他程序获取。

系统调用:为了使上层应用能够访问到这些资源,内核为上层应用提供访问的接口

我们的代码跑在用户态是无法直接调用文件 IO 这些”特权“操作的,必须切换到内核态才行。而系统调用库就是帮我们调用切换到内核态的 api(x86 是使用一个陷阱机制 保存用户态上下文切换到内核态上下文实现的 并且切换了 CPU 的优先级机制 ),然后才可以实现我们的文件读写

传统的文件系统 IO 的原语

//从文件中读取,存入 tmp_buf
read(file_fd, tmp_buf, len);
//将 tmp_buf 写入文件
write(file_fd, tmp_buf, len);

我们先来看 read 方法 如果此时是使用的 C 语言来调用这个方法 那么数据和指令是怎么流动的?

  1. read 方法调用 刷新寄存器 保存当前状态 从用户态切换到内核态,从用户栈切换到内核栈,进行了一次 上下文切换
  2. 内核态发起 io 请求 将当前线程/进程设置为阻塞态,交由 DMA 将数据拷贝到 内核空间的内存 中,CPU 进行任务切换执行其他的任务
  3. DMA 拷贝完毕 向 CPU 发送中断(不知道中断是什么无所谓,就是告诉 CPU IO 数据准备完毕了),CPU 打断当前执行的任务,处理中断。 此时类似于执行了一次回调 将进程/线程恢复就绪态,CPU 此时将数据从 内核空间 拷贝到 用户空间 (即 tem_buf 的地址)
  4. 线程/进程获得到 CPU 时间片之后 从内核态切换到用户态,进行了一次 上下文切换 ,接着执行

综上,我们进行了两次切换,一次 CPU 拷贝

那么在 java 中调用也是这样吗?

至少至今为止的操作都是这样的,但是此时拷贝到用户空间的 tmp_buf 可以直接被我们的程序使用吗?显然不行,因为他在堆外,要使用就得拷贝进来(变成 byte[]),进而又追加了一次 拷贝 这就是我们使用 inputstream.read 方法发生的事情。

那么为什么不直接让操作系统帮我们拷贝到堆内呢?

  1. read 原语传入的 tmp_buf 地址需要被”固定住“,是个逻辑上有效的
  2. byte[]对象会收到 GC 的影响 他的实际内存地址会发生变动
  3. JVM 并没有规定 byte[]的实现必须要是连续的

因此若指定堆内的某一个地址 IO 完成时其地址可能不再”有效“,故不能直接拷贝到堆内

既然我们提到了 GC 的影响 为什么在堆外到堆内拷贝过程中我们却不考虑 GC 呢?

很简单,在调用 native 方法的时候实际上在 safe point 此时允许 GC 运行,这也是为什么 native 方法退出时要检测 safe point 的原因,而拷贝时没有 safe point 所以内存中的 byte[]的地址是有效的

同理 write 原语 java heap -> c heap -> kneral

具体请参考 复制和固定 - IBM 文档

directbuffer

如果我们能提供一个长时间有效的,不受 GC 管理的地址 就可以做到和 C 语言调用一样的效果了

这个就是 DirectBuffer 存在的意义,其就是通过 Unsafe 的 native 方法在 C heap 上分配了一段堆外的内存

ByteBuffer buffer = ByteBuffer.allocateDirect(int capacity);

1650615455334

filechannel example

读写

下面演示了一个最基础的通过 channel 读和追加

实际上就是读取全部然后再追加回去

若你需要一些特殊的 flag 比如说 O_DIRECT 请看看 ExtendedOpenOption

        File file = new File("temp.txt");
        ByteBuffer direct = ByteBuffer.allocateDirect((int) file.length());
        try( FileChannel read_channel = FileChannel.open(file.toPath(), StandardOpenOption.READ);
             FileChannel write_channel = FileChannel.open(file.toPath(), StandardOpenOption.APPEND,StandardOpenOption.WRITE)) 
        {
            read_channel.read(direct, 0);
            direct.flip();
            write_channel.write(direct);
        }

请注意 read 和 write 方法接收的入参为 ByteBuffer 的子类,其有个 heapByteBuffer 的子类,若以这个子类入参的话 还是需要从 java heap 拷贝到 c heap。若你需要从文件读取 再写入到文件 或者直接写入到 socket 中 最好还是使用 directbuffer 来减少拷贝

其最后都会转发到 IOUtil 的 read 方法上面 可以很清楚看到若不是 directbuffer 就会触发额外的拷贝

1650620206872

文件传输

File file = new File("temp.txt");
        File file1 = new File("t.txt");
        ByteBuffer direct = ByteBuffer.allocateDirect((int) file.length());
        try( FileChannel read_channel = FileChannel.open(file.toPath(), StandardOpenOption.READ);
             FileChannel write_channel = FileChannel.open(file1.toPath(), StandardOpenOption.APPEND,StandardOpenOption.WRITE))
        {
            long size = read_channel.size();
            read_channel.transferTo(0, size,write_channel);
        }
//public void transferTo(long position,long count,WritableByteChannel target);

请注意 transferTo 的第三个参数实际上是 WritableByteChannel 其中 SocketChannel 也是这个类的子类

刷新到硬盘和 page cache

write_channel.force(true);

为什么要刷盘?

CPU 如果要访问外部磁盘上的文件,需要首先将这些文件的内容拷贝到内存中,由于硬件的限制,从磁盘到内存的数据传输速度是很慢的,如果现在物理内存有空余,干嘛不用这些空闲内存来缓存一些磁盘的文件内容呢,这部分用作 缓存磁盘文件 的内存就叫做 page cache。

用户进程启动 read() 系统调用后,内核会首先查看 page cache 里有没有用户要读取的文件内容,如果有(cache hit),那就直接读取,没有的话(cache miss)再启动 I/O 操作从磁盘上读取,然后放到 page cache 中,下次再访问这部分内容的时候,就又可以 cache hit,不用忍受磁盘的龟速了(相比内存慢几个数量级)。

那么什么时候触发刷盘呢?

  • 从空间的层面 ,当系统中 dirty 的内存大于某个阈值时。该阈值以在 dirtyable memory 中的占比"dirty_background_ratio"(默认为 10% ),或者绝对的字节数"dirty_background_bytes"(2.6.29 内核引入)给出。如果两者同时设置的话,那么以"bytes"为更高优先级。

此外,还有 dirty_ratio(默认为 20% )和"dirty_bytes",它们的意思是当"dirty"的内存达到这个数量(屋里太脏),进程自己都看不过去了,宁愿停下手头的 write 操作(被阻塞, 同步 ),先去把这些"dirty"的 writeback 了(把屋里打扫干净)。

而如果"dirty"的程度介于这个值和"background"的值之间( 10% - 20% ),就交给后面要介绍的专门负责 writeback 的 background 线程去做就好了(专职的清洁工, 异步 )。

  • 从时间的层面 ,即 周期性 的扫描(扫描间隔用"dirty_writeback_interval"表示,以毫秒为单位),发现存在最近一次更新时间超过某个阈值的 pages(该阈值用"dirty_expire_interval"表示, 以毫秒为单位)。

若你的服务是需要保证数据不丢失,需要立刻刷入磁盘就需要自己手动刷一下 即 force 一下,以防止出现操作系统还没刷盘就崩溃的情况

注意 :/proc/sys/vm 文件夹查看或修改以上提到的几个参数

因为 page cache 的存在 所以连续访问的性能比随机访问好很多

O_DIRECT

这个 flag 太有意义 所以我单开一个写

这个 flag 实际上就是绕开 page cache 直接写磁盘,你可能会思考,绕开 page cache 不会导致性能很差吗?

实际上做数据库这种应用的时候用的很多,此时需要自己维护一套受控制的”page cache“机制

其还有几个缺点,比如说需要自己做内存对齐,虽然是直通硬盘但也不是确保完全刷盘(O_DIRECT 只是绕过了 page cache,但它并不等待数据真正写到了磁盘上。open() 中 flags 参数使用 O_SYNC 才能保证 writepage() 会等到数据可靠的写入磁盘后再返回,适用于某些不容许数据丢失的关键应用)

这个实际上用的很少,以至于 jdk10 才加入这个 flag 功能

结论

java 提供的 filechannel 实际上就是提供了一套对应到 glibc IO 原语的 java api

由于 Bytebuffer api 过于难用 其实有更新的替代 api

OpenJDK: Panama (java.net)

在本文完成时(2022 年 4 月 22 日),这个 project 已经进入孵化器状态,已经合并进主线,下一个 lts 将正式进入系统库

本文仍有很多不足之处,毕竟 unix 编程花了半本书来讲述这些系统调用的实现和使用,以及坑。请读者自行进行扩展学习操作系统等相关知识。

若有时间请一定要用 C 试试这些 IO 原语 加强理解

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

上一篇:

下一篇:

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

紙鸢

暂无简介

文章
评论
25 人气
更多

推荐作者

七七

文章 0 评论 0

囍笑

文章 0 评论 0

盛夏尉蓝

文章 0 评论 0

ゞ花落谁相伴

文章 0 评论 0

Sherlocked

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文