Java 基础之 Mmap
以 linux 实现出发
名词解释
- 物理内存:即内存条的内存空间。
- 虚拟内存:计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。 举个例子,你获取某一个对象的地址,这个地址其实反应的就是它的虚拟内存地址,我们使用这个地址时会由操作系统帮助我们正确地转换为物理地址,它帮助我们实际上扩大了内存容量且给进程一个自己占用全部内存的”假象“
- 内存映射:即建立虚拟内存到物理内存的映射,默认情况下不同进程的内存映射表是不一样的
- 内存页:对于文件系统的交互,内存访问等均基于某一个大小,比如说 linux 默认一口气从磁盘读 4K 作为一页,即操作必须基于整数页数,你使用 read 原语读取一个字节也会导致直接读取一页到内存
- 页面文件:操作系统反映构建并使用虚拟内存的硬盘空间大小而创建的文件,在 windows 下,即 pagefile.sys 文件,其存在意味着物理内存被占满后,将暂时不用的数据移动到硬盘上。
- MMC:CPU 的内存管理单元。
- 缺页中断:当程序试图访问已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由 MMC 发出的中断。如果操作系统判断此次访问是有效的,则尝试将相关的页从虚拟内存文件中载入物理内存。
- COW:
操作系统提供一头奶牛其实 COW 是“copy on write”的缩写,指的是一种惰性拷贝的手段,写入时复制(英语:Copy-on-write,简称 COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。 - mmap:Memory Mapped 内存映射,这个系统调用适用于把 linux 文件(linux 文件不只是文件)和设备映射到内存中。
- SIGSECV:是当一个进程执行了一个无效的内存引用,或发生 段错误 时发送给它的信号。简单来说就是访问了无效的内存
我们试想一个常见的场景 你使用了 malloc 申请了一块内存,返回一个指针值为 0x12345678 这个值其实就是代表一个虚拟内存的地址,此时还未建立到物理内存的映射,当我们开始 *p = 13 时才正式分配物理内存,为了映射内存此时就会发生一个缺页中断,这个时候才是真正建立了物理内存的映射。
mmap
太长不看版: 你可以利用 mmap 函数返回值获取的指针,像操作一个 byte[]一样自由读写这一段映射出来的文件(在合法范围内,后面讲)
mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用 指针 的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图所示:
请注意 mmap 是惰性的,在没有访问对应页的情况下其实并没有触发从磁盘读取的操作
使用
MappedByteBuffer
这个其实和 ByteBuffer 都一样,操作起来是一样的
本质上就是 DirectByteBuffer,想起来也很正常吧 DirectByteBuffer 简化一下就是某个区域的指针+长度
mmap 也是利用指针如同访问内存一样访问文件
READ_ONLY
见名知意 只读模式
public static void read_mmap(){
File file = new File("temp.txt");
try(FileChannel read_channel = FileChannel.open(file.toPath(), StandardOpenOption.READ))
{
MappedByteBuffer buffer = read_channel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
byte[] bytes = new byte[(int) file.length()];
buffer.get(bytes);
//读取
System.out.println(new String(bytes));
} catch (IOException e){
e.printStackTrace();
}
}
READ_WRITE
public static void write_mmap(){
File file = new File("temp.txt");
try(FileChannel write_channel = FileChannel.open(file.toPath(), StandardOpenOption.READ,StandardOpenOption.WRITE))
{
long length = file.length();
var append = "\n 追加啊".getBytes(StandardCharsets.UTF_8);
System.out.println(file.length());
MappedByteBuffer buffer = read_channel.map(FileChannel.MapMode.READ_WRITE, 0, length +append.length);
//注意 mmap 之后 file 的 length 会发生改变
System.out.println(file.length());
buffer.put((int) length, append);
System.out.println(file.length());
//对应 filechannel 的概念差不多 刷盘
buffer.force();
} catch (IOException e){
e.printStackTrace();
}
}
PRIVATE
前两种模式的 mmap 实际上是跨进程共享的
比如说进程 A,B 都映射了同一个文件,A 追加了一个字节,B 就能立刻看到这个字节的追加
而 PRIVATE 则见名知意 当前进程映射的 mmap 是私有的,他的修改并不会反映到对应文件中也不会被其他进程发现
简单来说:建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个
File file = new File("temp.txt");
try(FileChannel read_channel = FileChannel.open(file.toPath(), StandardOpenOption.READ,StandardOpenOption.WRITE))
{
long length = file.length();
var append = "\n 追加啊".getBytes(StandardCharsets.UTF_8);
System.out.println(file.length());
MappedByteBuffer buffer = read_channel.map(FileChannel.MapMode.PRIVATE, 0, length);
//注意 mmap 之后 file 的 length 会发生改变
System.out.println(file.length());
buffer.put((int) length, append);
System.out.println(file.length());
buffer.force();
} catch (IOException e){
e.printStackTrace();
}
优点:
先来看下用户进程调用 read() 在 Linux 中是怎样实现的。比如要读取磁盘上某个文件的 8192 个字节数据,那么这 8192 个字节会首先拷贝到内存中作为 page cache (方便以后快速读取),然后再从 page cache 拷贝到用户指定的 buffer 中,也就是说,在数据已经加载到 page cache 后,还需要一次内存拷贝操作和一次系统调用
如果使用 mmap(),则在磁盘数据加载到内存后,用户进程可以通过指针操作直接读写内存,不再需要系统调用和内存拷贝,实际上还是走的 pagecache
其实,mmap() 在数据加载到内存的过程中,会触发大量的 page fault 和建立页表映射的操作,开销并不小。另一方面,随着硬件性能的发展,内存拷贝消耗的时间已经大大降低了。所以啊,很多情况下,mmap() 的性能反倒是比不过 read() 和 write() 的、其本质就是 page fault 回调+将 page cache 映射到进程中
限制:
Linux 中的文件是一个抽象的概念,并不是所有类型的文件都可以被 mmap 映射,比如目录和管道就不可以。一个文件可能被多个进程通过 mmap 映射后访问并修改,根据所做的修改是否对其他进程可见,mmap 可分为共享映射和私有映射两种。
对于共享映射,修改对所有进程可见,也就是说,如果进程 A 修改了其中某个 page 上的数据,进程 B 之后读取这个 page 得到的就是修改后的内容。有共享就有竞态(race condition),mmap 本身并没有提供互斥机制,需要调用者在使用的过程中自己加锁。注意手册中写 mmap 是线程安全的 其指的是调用这个 api 是线程安全的,不代表对其返回的指针进行操作是线程安全的
java 这边限制 int 大小的 mmap,如果你需要更大的请映射多次
细节*
以下细节都是 c 去直接调用 mmap 需要注意的,java 这边做了封装 其实不会遇到这种问题(比如说对齐和自动扩展文件大小)
- 使用 mmap 需要注意的一个关键点是,mmap 映射区域大小必须是物理页大小(page_size) 的整倍数(32 位系统中通常是 4k 字节)。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap 从磁盘到虚拟地址空间的映射也必须是页。
- 内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。具体情形参见“情形三”。
- 映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。
在上面的知识前提下,我们下面看看如果大小不是页的整倍数的具体情况:
情形一:一个文件的大小是 5000 字节,mmap 函数从一个文件的起始位置开始,映射 5000 字节到虚拟内存中。
分析:因为单位物理页面的大小是 4096 字节,虽然被映射的文件只有 5000 字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此 mmap 函数执行后,实际映射到虚拟内存区域 8192 个 字节,5000~8191 的字节部分用零填充。映射后的对应关系如下图所示:
情形二:一个文件的大小是 5000 字节,mmap 函数从一个文件的起始位置开始,映射 15000 字节到虚拟内存中,即映射大小超过了原始文件的大小。
分析:由于文件的大小是 5000 字节,和情形一一样,其对应的两个物理页。那么这两个物理页都是合法可以读写的,只是超出 5000 的部分不会体现在原文件中。由于程序要求映射 15000 字节,而文件只占两个物理页,因此 8192 字节~15000 字节都不能读写,操作时会返回异常。如下图所示:
此时:
- 进程可以正常读/写被映射的前 5000 字节(0~4999),写操作的改动会在一定时间后反映在原文件中。
- 对于 5000~8191 字节,进程可以进行读写过程,不会报错。但是内容在写入前均为 0,另外,写入后不会反映在文件中。
- 对于 8192~14999 字节,进程不能对其进行读写,会报 SIGBUS 错误。表明你访问的这段内存区域,没有对应的文件
- 对于 15000 以外的字节,进程不能对其读写,会引发 SIGSEGV 错误。
情形三:一个文件初始大小为 0,使用 mmap 操作映射了 1000*4K 的大小,即 1000 个物理页大约 4M 字节空间,mmap 返回指针 ptr。
分析:如果在映射建立之初,就对文件进行读写操作,由于文件大小为 0,并没有合法的物理页对应,如同情形二一样,会返回 SIGBUS 错误。 这里 java 会检测后扩展文件
但是如果,每次操作 ptr 读写前,先增加文件的大小,那么 ptr 在文件大小内部的操作就是合法的。例如,文件扩充 4096 字节,ptr 就能操作 ptr ~ [ (char)ptr + 4095]的空间。只要文件扩充的范围在 1000 个物理页(映射范围)内,ptr 都可以对应操作相同的大小。
这样,方便随时扩充文件空间,随时写入文件,不造成空间浪费。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: Java 基础之 io 流
下一篇: Jvm 常量池
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论