《从 Linux 内核角度探秘 JDK MappedByteBuffer(上)》
2.5 JDK 完整的内存映射过程
private Unmapper mapInternal(MapMode mode, long position, long size, int prot, boolean isSync)
throws IOException
{
// 确保文件处于 open 状态
ensureOpen();
// 对相关映射参数进行校验
if (mode == null)
throw new NullPointerException("Mode is null");
if (position < 0L)
throw new IllegalArgumentException("Negative position");
if (size < 0L)
throw new IllegalArgumentException("Negative size");
if (position + size < 0)
throw new IllegalArgumentException("Position + size overflow");
// 如果 mode 设置了 READ_ONLY,但文件并没有以读的模式打开,则会抛出 NonReadableChannelExceptio
// 如果 mode 设置了 READ_WRITE 或者 PRIVATE ,那么文件必须要以读写的模式打开,否则会抛出 NonWritableChannelException
// 如果 isSync 为 true,但是对应 CPU 体系架构不支持 cache line write back 指令,那么就会抛出 UnsupportedOperationException
checkMode(mode, prot, isSync);
long addr = -1;
int ti = -1;
try {
// 这里不要被命名误导,beginBlocking 并不会阻塞当前线程,只是标记一下表示当前线程下面会执行一个 IO 操作可能会无限期阻塞
// 而这个 IO 操作是可以被中断的,这里会设置中断的回调函数 interruptor,在线程被中断的时候回调
beginBlocking();
// threads 是一个 NativeThread 的集合,用于暂存阻塞在该 channel 上的 NativeThread,用于后续统一唤醒
ti = threads.add();
// 如果当前 channel 已经关闭,则不能进行 mmap 操作
if (!isOpen())
return null;
// 映射文件大小,同 mmap 系统调用中的 length 参数
long mapSize;
// position 距离其所在文件页起始位置的距离,OS 内核以 page 为单位进行内存管理
// 内存映射的单位也应该按照 page 进行,pagePosition 用于后续将 position,size 与 page 大小对齐
int pagePosition;
// 确保线程串行操作文件的 position
synchronized (positionLock) {
long filesize;
do {
// 底层通过 fstat 系统调用获取文件大小
filesize = nd.size(fd);
// 如果系统调用被中断则一直重试
} while ((filesize == IOStatus.INTERRUPTED) && isOpen());
if (!isOpen())
return null;
// 如果要映射的文件区域已经超过了 filesize 则需要扩展文件
if (filesize < position + size) { // Extend file size
if (!writable) {
throw new IOException("Channel not open for writing " +
"- cannot extend file to required size");
}
int rv;
do {
// 底层通过 ftruncate 系统调用将文件大小扩展至 (position + size)
rv = nd.truncate(fd, position + size);
} while ((rv == IOStatus.INTERRUPTED) && isOpen());
if (!isOpen())
return null;
}
// 映射大小为 0 则直接返回 null,随后会创建一个空的 MappedByteBuffer
if (size == 0) {
return null;
}
// OS 内核是按照内存页 page 为单位来对内存进行管理的,因此我们内存映射的粒度也应该按照 page 的单位进行
// allocationGranularity 表示内存分配的粒度,这里是内存页的大小 4K
// 我们指定的映射 offset 也就是这里的 position 应该是与 4K 对齐的,同理映射长度 size 也应该与 4K 对齐
// position 距离其所在文件页起始位置的距离
pagePosition = (int)(position % allocationGranularity);
// mapPosition 为映射的文件内容在磁盘文件中的偏移,同 mmap 系统调用中的 offset 参数
// 这里的 mapPosition 为 position 所属文件页的起始位置
long mapPosition = position - pagePosition;
// 映射位置 mapPosition 减去了 pagePosition,所以这里的映射长度 mapSize 需要把 pagePosition 加回来
mapSize = size + pagePosition;
try {
// If map0 did not throw an exception, the address is valid
// native 方法,底层调用 mmap 进行内存文件映射
// 返回值 addr 为 mmap 系统调用在进程地址空间真实映射出来的虚拟内存区域起始地址
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted
// memory so force gc and re-attempt map
// 如果内存不足导致 mmap 失败,这里触发 Full GC 进行内存回收,前提是没有设置 -XX:+DisableExplicitGC
// 默认情况下在调用 System.gc() 之后,JVM 马上会执行 Full GC,并且等到 Full GC 完成之后才返回的。
// 只有使用 CMS ,G1,Shenandoah 时,并且配置 -XX:+ExplicitGCInvokesConcurrent 的情况下
// 调用 System.gc() 会触发 Concurrent Full GC,java 线程在触发了 Concurrent Full GC 之后立马返回
System.gc();
try {
// 这里不是等待 gc 结束,而是等待 cleaner thread 运行 directBuffer 的 cleaner,在 cleaner 中释放 native memory
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
// 重新进行内存映射
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
} // synchronized
// 检查 mmap 调用是否成功,失败的话错误信息会放在 addr 中
assert (IOStatus.checkAll(addr));
// addr 需要与文件页尺寸对齐
assert (addr % allocationGranularity == 0);
// Unmapper 用于调用 unmmap 释放映射出来的虚拟内存以及物理内存
// 并统计整个 JVM 进程调用 mmap 的总次数以及映射的内存总大小
// 本次 mmap 映射出来的内存区域信息都会封装在 Unmapper 中
Unmapper um = (isSync
? new SyncUnmapper(addr, mapSize, size, mfd, pagePosition)
: new DefaultUnmapper(addr, mapSize, size, mfd, pagePosition));
return um;
} finally {
// IO 操作完毕,从 threads 集合中删除当前线程
threads.remove(ti);
// IO 操作完毕,清空线程的中断回调函数,如果此时线程已被中断则抛出 closedByInterruptException 异常
endBlocking(IOStatus.checkAll(addr));
}
}
3. 与 MappedByteBuffer 相关的几个系统调用
从第一小节介绍的 mmap 在内核中的整个内存映射的过程我们可以看出,当调用 mmap 之后,OS 内核只是会为我们分配了一段虚拟内存(MappedByteBuffer),然后将虚拟内存与磁盘文件进行映射,仅此而已。
我们映射的文件内容此时还静静地躺在磁盘中还未加载进内存,映射文件的 page cache 还是空的,由于还未发生物理内存的分配,所以 MappedByteBuffer 在 JVM 进程页表中相关的页表项 pte 也是空的。
image.png当我们开始访问这段 MappedByteBuffer 的时候,由于此时还没有物理内存与之映射,于是会产生一个缺页中断,随后 JVM 进程进入内核态,在内核缺页处理程序中分配物理内存页,然后将刚刚分配的物理内存页加入到映射文件的 page cache。
最后将映射的文件内容从磁盘中读取到这个物理内存页中并在页表中建立 MappedByteBuffer 与物理内存页的映射关系,后面我们在访问这段 MappedByteBuffer 的时候就是直接访问 page cache 了。
我们利用 MappedByteBuffer 去映射磁盘文件的目的其实就是为了通过 MappedByteBuffer 去直接访问磁盘文件的 page cache,不想切到内核态,也不想发生数据拷贝。
所以为了避免访问 MappedByteBuffer 可能带来的缺页中断产生的开销,我们通常会在调用 FileChannel#map
映射完磁盘文件之后,马上主动去触发一次缺页中断,目的就是先把 MappedByteBuffer 背后映射的文件内容预先加载到 page cache 中,并在 JVM 进程页表中建立好 page cache 中的物理内存与 MappedByteBuffer 的映射关系。
后续我们对 MappedByteBuffer 的访问速度就变得非常快了,上述针对 MappedByteBuffer 的预热过程,JDK 封装在 MappedByteBuffer#load
方法中:
public abstract class MappedByteBuffer extends ByteBuffer
{
public final MappedByteBuffer load() {
if (fd == null) {
return this;
}
try {
// 最终会调用到 MappedMemoryUtils#load 方法
SCOPED_MEMORY_ACCESS.load(scope(), address, isSync, capacity());
} finally {
Reference.reachabilityFence(this);
}
return this;
}
}
MappedByteBuffer 预热的核心逻辑主要分为两个步骤:首先 JDK 会调用一个 native 方法 load0
将 MappedByteBuffer 背后映射的文件内容先预读进 page cache 中。
private static native void load0(long address, long length);
// MappedMemoryUtils.c 文件
JNIEXPORT void JNICALL
Java_java_nio_MappedMemoryUtils_load0(JNIEnv *env, jobject obj, jlong address,
jlong len)
{
char *a = (char *)jlong_to_ptr(address);
int result = madvise((caddr_t)a, (size_t)len, MADV_WILLNEED);
if (result == -1) {
JNU_ThrowIOExceptionWithLastError(env, "madvise failed");
}
}
这里我们看到 load0
方法在 native 层面调用了一个叫做 madvise
的系统调用:
#include <sys/mman.h>
int madvise(caddr_t addr, size_t len, int advice);
madvise 在各大中间件中应用还是非常广泛的,应用程序可以通过该系统调用告知内核,接下来我们将会如何使用 [addr, addr + len] 这段范围的虚拟内存,内核后续会根据我们提供的 advice
做针对性的处理,用以提高应用程序的性能。
比如,我们可以通过 madvise 系统调用告诉内核接下来我们将顺序访问这段指定范围的虚拟内存,那么内核将会增大对映射文件的预读页数。如果我们是随机访问这段虚拟内存,内核将会禁止对映射文件的预读。
这里我们用到的 advice 选项为 MADV_WILLNEED
,该选项用来告诉内核我们将会马上访问这段虚拟内存,内核在收到这个建议之后,将会马上触发一次预读操作,尽可能将 MappedByteBuffer 背后映射的文件内容全部加载到 page cache 中。
但是 madvise 这里只是负责将 MappedByteBuffer 映射的文件内容加载到内存中(page cache),并不负责将 MappedByteBuffer(虚拟内存) 与 page cache 中的这些文件页(物理内存)进行关联映射,也就是说此时 MappedByteBuffer 在 JVM 进程页表中相关的页表项 PTE 还是空的。
所以 JDK 在调用完 load0
方法之后,还需要再次按照内存页的粒度对 MappedByteBuffer 进行访问,目的是触发缺页中断,在缺页中断处理中内核会将 MappedByteBuffer 与 page cache 通过进程页表关联映射起来。后续我们在对 MappedByteBuffer 进行访问就是直接访问 page cache 了,没有缺页中断也没有磁盘 IO 的开销。
关于 MappedByteBuffer 的 load 逻辑 , JDK 封装在 MappedMemoryUtils
类中:
class MappedMemoryUtils {
static void load(long address, boolean isSync, long size) {
// no need to load a sync mapped buffer
// isSync = true 表示 MappedByteBuffer 背后直接映射的是 non-volatile memory 而不是普通磁盘上的文件
// MappedBuffer 背后映射的内容已经在 non-volatile memory 中了不需要 load
if (isSync) {
return;
}
if ((address == 0) || (size == 0))
return;
// 返回 pagePosition
long offset = mappingOffset(address);
// MappedBuffer 实际映射的内存区域大小 也就是调用 mmap 时指定的 mapSize
long length = mappingLength(offset, size);
// mappingAddress 用于获取实际的映射起始位置 mapPosition
// madvise 也是按照内存页为粒度进行操作的,所以这里和 mmap 一样
// 需要对指定的 address 和 length 按照内存页的尺寸对齐
load0(mappingAddress(address, offset), length);
// 对 MappedByteBuffer 进行访问,触发缺页中断
// 目的是将 MappedByteBuffer 与 page cache 在进程页表中进行关联映射
Unsafe unsafe = Unsafe.getUnsafe();
// 获取内存页的尺寸,大小为 4K
int ps = Bits.pageSize();
// 计算 MappedByteBuffer 这片虚拟内存区域所包含的虚拟内存页个数
long count = Bits.pageCount(length);
// mmap 起始的映射地址,后面将基于这个地址挨个触发缺页中断
long a = mappingAddress(address, offset);
byte x = 0;
for (long i=0; i<count; i++) {
// 以内存页为粒度,挨个对 MappedByteBuffer 中包含的虚拟内存页触发缺页中断
x ^= unsafe.getByte(a);
a += ps;
}
if (unused != 0)
unused = x;
}
}
这里我们调用 load 方法的目的就是希望将 MappedByteBuffer 背后所映射的文件内容加载到物理内存中,在本文 《2.2 针对 persistent memory 的映射》 小节中,笔者介绍过,当我们调用 FileChannel#map
对文件进行内存映射的时候,如果参数 MapMode
设置了 READ_ONLY_SYNC 或者 READ_WRITE_SYNC 的话,那么这里的 isSync = true
。
表示 MappedByteBuffer 背后直接映射的是 non-volatile memory 而不是普通磁盘上的文件,映射内容已经在 non-volatile memory 中了,因此就不需要加载了,直接 return 掉。
non-volatile memory 也是需要 filesystem 来进行管理的,这些 filesystem 会通过 dax(direct access mode)进行挂载,从后面相关的 madvise 系统调用源码中我们也会看出,如果映射文件是 DAX 模式的,那么内核也会直接 return,不需要加载。
if (IS_DAX(file_inode(file))) {
return 0;
}
本文 《2.4.1 Unmapper 到底包装了哪些映射信息》小节中我们介绍过,通过 mmap 系统调用真实映射出来的虚拟内存范围与 MappedByteBuffer 所表示的虚拟内存范围是不一样的,MappedByteBuffer 只是其中的一个子集而已。
因为我们在 FileChannel#map
函数中指定的映射起始位置 position 是需要与文件页尺寸进行对齐的,这也就是说底层 mmap 系统调用必须要从文件页的起始位置处开始映射。
如果我们指定的 position 没有和文件页进行对齐,那么在 JDK 层面就需要找到 position 所在文件页的起始位置,也就是上图中的 mapPosition
,mmap 将会从这里开始映射,映射出来的虚拟内存范围为 [mapPosition,mapPosition+mapSize]
。最后 JDK 在从这段虚拟内存范围内划分出 MappedByteBuffer 所需要的范围,也就是我们在 FileChannel#map
参数中指定的 [position,position+size]
这段区域。
而 madvise 和 mmap 都是内核层面的系统调用,不管你 JDK 内部如何划分,它们只关注内核层面实际映射出来的虚拟内存,所以我们在调用 madvise 指定虚拟内存范围的时候需要与 mmap 真实映射出来的范围保持一致。
native 方法 load0 中的参数 address
,其实就是 mmap 的起始映射地址 mapPosition,参数 length
其实就是 mmap 真实的映射长度 mapSize。
private static native void load0(long address, long length);
而 MappedMemoryUtils#load
方法中的参数 address
指的是 MappedByteBuffer 的起始地址也就是上面的 position
,参数 size
指的是 MappedByteBuffer 的容量也就是我们指定的映射长度(并不是实际的映射长度)。
static void load(long address, boolean isSync, long size) {
所以在进入 load0
native 实现之前,需要做一些转换工作。首先通过 mappingOffset 根据 MappedByteBuffer 的起始地址 address
计算出 address 距离其所在文件页的起始地址的长度,也就是上图中的 pagePosition。该函数的计算逻辑比较简单且之前也已经介绍过了,这里不再赘述。
private static long mappingOffset(long address)
通过 mappingLength 计算出 mmap 底层实际映射出的虚拟内存大小 mapSize。
private static long mappingLength(long mappingOffset, long length) {
// mappingOffset 即为 pagePosition
// length 是之前指定的映射长度 size,也就是 MappedByteBuffer 的容量
return length + mappingOffset;
}
mappingAddress 用于获取 mmap 起始映射地址 mapPosition。
private static long mappingAddress(long address, long mappingOffset, long index) {
// address 为 MappedByteBuffer 的起始地址
// index 这里指定为 0
long indexAddress = address + index;
// mmap 映射的起始地址
return indexAddress - mappingOffset;
}
这样一来,我们通过 load0
方法进入 native 实现中调用 madvise 的时候,这里指定的参数 addr
就是上面 mappingAddress 方法返回的 mapPosition
,参数 len
就是 mappingLength 方法返回的 mapSize
,参数 advice
指定为 MADV_WILLNEED,立即触发一次预读。
#include <sys/mman.h>
int madvise(caddr_t addr, size_t len, int advice);
3.1 madvise
// 文件:/mm/madvise.c
SYSCALL_DEFINE3(madvise, unsigned long, start, size_t, len_in, int, behavior)
{
end = start + len;
vma = find_vma_prev(current->mm, start, &prev);
for (;;) {
/* Here vma->vm_start <= start < tmp <= (end|vma->vm_end). */
error = madvise_vma(vma, &prev, start, tmp, behavior);
}
out:
return error;
}
madvise 的作用其实就是在我们指定的虚拟内存范围 [start, end] 内包含的所有虚拟内存区域 vma 中依次根据我们指定的 behavior 触发 madvise_vma 执行相关的 behavior 处理逻辑。
find_vma_prev 的作用就是根据我们指定的映射起始地址 addr(start),在进程地址空间中查找出符合 addr < vma->vm_end
条件的第一个 vma 出来(下图中的蓝色部分)。
image.png关于该函数的详细实现,感兴趣的读者可以回看下笔者之前的文章《从内核世界透视 mmap 内存映射的本质(源码实现篇)》
如果我们指定的起始虚拟内存地址 start 是一个无效的地址(未被映射),那么内核这里就会返回 ENOMEM
错误。
通过 find_vma_prev 查找出来的 vma 就是我们指定虚拟内存范围 [start, end] 内的第一个虚拟内存区域,后续内核会在一个 for 循环内从这个 vma 开始依次调用 madvise_vma,在指定虚拟内存范围内的所有 vma 中执行 behavior 相关的处理逻辑。
static long
madvise_vma(struct vm_area_struct *vma, struct vm_area_struct **prev,
unsigned long start, unsigned long end, int behavior)
{
switch (behavior) {
case MADV_WILLNEED:
return madvise_willneed(vma, prev, start, end);
}
}
其中 MADV_WILLNEED
的处理逻辑被内核封装在 madvise_willneed
方法中:
static long madvise_willneed(struct vm_area_struct *vma,
struct vm_area_struct **prev,
unsigned long start, unsigned long end)
{
// 获取映射文件
struct file *file = vma->vm_file;
// 映射内容在文件中的偏移
loff_t offset;
// 判断映射文件是否是 persistent memory filesystem 上的文件
if (IS_DAX(file_inode(file))) {
// 这里说明 mmap 映射的是 persistent memory 直接返回
return 0;
}
// madvise 底层其实调用的是 fadvise
vfs_fadvise(file, offset, end - start, POSIX_FADV_WILLNEED);
return 0;
}
从这里我们可以看出,如果映射文件是 persistent memory filesystem (通过 DAX 模式挂载)中的文件,那么表示这段虚拟内存背后直接映射的是 persistent memory ,madvise 系统调用直接就返回了。
这也解释了为什么 JDK 会在 MappedMemoryUtils#load
方法的一开始,就会判断如果 isSync = true
就直接返回,因为映射的文件内容已经存在于 persistent memory 中了,不需要再次加载了。
最终内核关于 advice 的处理逻辑封装在 vfs_fadvise
函数中,这里我们也可以看出 madvise 系统调用与 fadvise 系统调用本质上是一样的,最终都是通过这里的 vfs_fadvise 函数来处理。
// 文件:/mm/fadvise.c
int vfs_fadvise(struct file *file, loff_t offset, loff_t len, int advice)
{
return generic_fadvise(file, offset, len, advice);
}
int generic_fadvise(struct file *file, loff_t offset, loff_t len, int advice)
{
// 获取映射文件的 page cache
mapping = file->f_mapping;
switch (advice) {
case POSIX_FADV_WILLNEED:
// 将文件中范围为 [start_index, end_index] 的内容预读进 page cache 中
start_index = offset >> PAGE_SHIFT;
end_index = endbyte >> PAGE_SHIFT;
// 计算需要预读的内存页数
// 但内核不一定会按照 nrpages 指定的页数进行预读,还需要结合预读窗口来综合判断具体的预读页数
nrpages = end_index - start_index + 1;
// 强制进行预读,之后映射的文件内容就会加载进 page cache 中了
// 如果预读失败的话,这里会忽略掉错误,所以在应用层面是感知不到预读成功或者失败了的
force_page_cache_readahead(mapping, file, start_index, nrpages);
break;
}
return 0;
}
EXPORT_SYMBOL(generic_fadvise);
内核对于 MADV_WILLNEED
的处理其实就是通过 force_page_cache_readahead
立即触发一次预读,将之前通过 mmap 映射的文件内容全部预读进 page cache 中。
关于 force_page_cache_readahead 的详细内容,感兴趣的读者可以回看之前的文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》
但这里需要注意的是预读可能会失败,内核这里会忽略掉预读失败的错误,我们在应用层面调用 madvise 的时候是感知不到预读失败的。
还有一点就是 madvise 中的 MADV_WILLNEED 只是将虚拟内存(MappedByteBuffer)背后映射的文件内容加载到 page cache 中。
image.png当 madvise 系统调用返回的时候,虽然此时映射的文件内容已经在 page cache 中了,但是这些刚刚被加载进 page cache 的文件页还没有与 MappedByteBuffer 进行关联,也就是说 MappedByteBuffer 在 JVM 进程页表中对应的页表项 pte 仍然还是空的。
image.png后续我们访问这段 MappedByteBuffer 的时候仍然会触发缺页中断,但是这种情况下的缺页中断是轻量的,属于 VM_FAULT_MINOR 类型的缺页,因为之前映射的文件内容已经通过 madvise 加载到 page cache 中了,这里只需要通过进程页表将 MappedByteBuffer 与 page cache 中的文件页关联映射起来就可以了,不需要重新分配内存以及发生磁盘 IO 。
image.png所以这也是为什么在 MappedMemoryUtils#load
方法中,JDK 在调用完 native 方法 load0
之后,仍然需要以内存页为粒度再次访问一下 MappedByteBuffer 的原因,目的是通过缺页中断(VM_FAULT_MINOR)将 page cache 与 MappedByteBuffer 通过页表关联映射起来。
3.2 mlock
MappedByteBuffer 经过上面 MappedByteBuffer#load
函数的处理之后,现在 MappedByteBuffer 背后所映射的文件内容已经加载到 page cache 中了,并且在 JVM 进程页表中也已经建立好了 MappedByteBuffer 与 page cache 的映射关系。
从目前来看我们通过 MappedByteBuffer 就可以直接访问到 page cache 了,不需要经历缺页中断的开销。但 page cache 所占用的是物理内存,当系统中物理内存压力大的时候,内核仍然会将 page cache 中的文件页 swap out 出去。
这时如果我们再次访问 MappedByteBuffer 的时候,依然会发生缺页中断,当 MappedByteBuffer 被我们用来实现系统中的核心功能时,这就迫使我们要想办法让 MappedByteBuffer 背后映射的物理内存一直驻留在内存中,不允许内核 swap 。那么本小节要介绍的 mlock 系统调用就派上用场了。
#include <sys/mman.h>
int mlock(const void *addr, size_t len);
mlock 的主要作用是将 [addr, addr+len] 这段范围内的虚拟内存背后映射的物理内存锁定在内存中,当内存资源紧张的时候,这段物理内存将不会被 swap out 出去。
如果 [addr, addr+len] 这段虚拟内存背后还未映射物理内存,那么 mlock 也会立即在这段虚拟内存上主动触发缺页中断,为其分配物理内存,并在进程页表中建立映射关系。
// 文件:/mm/mlock.c
SYSCALL_DEFINE2(mlock, unsigned long, start, size_t, len)
{
return do_mlock(start, len, VM_LOCKED);
}
do_mlock 的核心主要分为两个步骤:
-
利用 apply_vma_lock_flags 函数在锁定范围内的虚拟内存区域内打上一个
VM_LOCKED
标记,后续内核在 swap 的时候,如果遇到被VM_LOCKED
标记的虚拟内存区域,那么它背后映射的物理内存将不会被 swap out 出去,而是会一直驻留在内存中。 -
如果指定锁定范围内的虚拟内存还未有物理内存与之映射,那么内核则调用 __mm_populate 主动为其填充物理内存,并在进程页表中建立虚拟内存与物理内存的映射关系,从本文的视角上来说,就是建立 MappedByteBuffer 与 page cache 的映射关系。
static __must_check int do_mlock(unsigned long start, size_t len, vm_flags_t flags)
{
// 本次需要锁定的内存页个数
unsigned long locked;
// 内核允许单个进程能够锁定的物理内存页个数
unsigned long lock_limit;
// 检查内核是否允许进行内存锁定
if (!can_do_mlock())
return -EPERM;
// 进程的相关资源限制配额定义在 task_struct->signal_struct->rlim 数组中
// rlimit(RLIMIT_MEMLOCK) 表示内核允许单个进程对物理内存锁定的限额,单位为字节
lock_limit = rlimit(RLIMIT_MEMLOCK);
// 转换为内存页个数
lock_limit >>= PAGE_SHIFT;
locked = len >> PAGE_SHIFT;
// mm->locked_vm 表示当前进程已经锁定的物理内存页个数
locked += current->mm->locked_vm;
// 如果需要锁定的内存资源没有超过内核的限制
// 并且内核允许进行内存锁定
if ((locked <= lock_limit) || capable(CAP_IPC_LOCK))
// 将 VM_LOCKED 标志设置到 [start, start + len] 这段虚拟内存范围内所有 vma 的属性 vm_flags 中
error = apply_vma_lock_flags(start, len, flags);
// 遍历 [start, start + len] 这段虚拟内存范围内所包含的所有虚拟内存页
// 依次在每个虚拟内存页上进行缺页处理,将其背后映射的文件内容读取到 page cache 中
// 并在进程页表中建立好虚拟内存到 page cache 的映射关系
error = __mm_populate(start, len, 0);
return 0;
}
一个进程能够允许锁定的内存资源在内核中是有限制的,内核对进程相关资源的限制配额保存在 task_struct->signal_struct->rlim
数组中:
struct task_struct {
struct signal_struct *signal;
}
struct signal_struct {
// 进程相关的资源限制,相关的资源限制以数组的形式组织在 rlim 中
// RLIMIT_MEMLOCK 下标对应的是进程能够锁定的内存资源,单位为bytes
struct rlimit rlim[RLIM_NLIMITS];
}
struct rlimit {
__kernel_ulong_t rlim_cur;
__kernel_ulong_t rlim_max;
};
我们可以通过修改 /etc/security/limits.conf
文件中的 memlock 相关配置项来调整能够被锁定的内存资源配额,设置为 unlimited 表示不对锁定内存进行限制。
进程能够锁定的物理内存资源配额通过 rlimit(RLIMIT_MEMLOCK)
来获取,单位为字节。
// 定义在文件:/include/linux/sched/signal.h
static inline unsigned long rlimit(unsigned int limit)
{
// 参数 limit 为相关资源的下标
return task_rlimit(current, limit);
}
static inline unsigned long task_rlimit(const struct task_struct *task,
unsigned int limit)
{
return READ_ONCE(task->signal->rlim[limit].rlim_cur);
}
内核在对内存进行锁定之前,需要通过 can_do_mlock 函数判断一下是否允许本次锁定操作:
-
rlimit(RLIMIT_MEMLOCK) != 0
表示进程能够锁定的内存资源限额还没有用完,允许本次锁定操作。 -
如果锁定内存资源的限额已经用完,但是
capable(CAP_IPC_LOCK) = true
表示当前进程拥有CAP_IPC_LOCK
权限,那么即使在锁定资源配额用完的情况下,内核也是允许进程对内存资源进行锁定的。
bool can_do_mlock(void)
{
// 内核会限制能够被锁定的内存资源大小,单位为bytes
// 这里获取 RLIMIT_MEMLOCK 能够锁定的内存资源,如果为 0 ,则不能够锁定内存了。
if (rlimit(RLIMIT_MEMLOCK) != 0)
return true;
// 检查内核是否允许 mlock ,mlockall 等内存锁定操作
if (capable(CAP_IPC_LOCK))
return true;
return false;
}
如果当前进程已经锁定的内存资源没有超过内核的限制或者是当前进程拥有 CAP_IPC_LOCK
权限,那么内核就调用 apply_vma_lock_flags 将 [start, start + len] 这段虚拟内存范围内映射的物理内存锁定在内存中。
if ((locked <= lock_limit) || capable(CAP_IPC_LOCK))
error = apply_vma_lock_flags(start, len, flags);
内存锁定的逻辑其实非常简单,首先将 [start, start + len] 这段虚拟内存范围内的所有的虚拟内存区域 vma 查找出来,然后依次遍历这些 vma , 并将 VM_LOCKED
标志设置到 vma 的 vm_flags 标志位中。
struct vm_area_struct {
unsigned long vm_flags;
}
后续在物理内存资源紧张,内核开始 swap 的时候,当遇到 vm_flags 设置了 VM_LOCKED
的虚拟内存区域 vma 的时候,那么它背后映射的物理内存将不会被内核 swap out 出去。
从这里我们可以看出,所谓的内存锁定只不过是在指定锁定范围内的所有虚拟内存区域 vma 上打一个 VM_LOCKED
标记而已,但我们锁定的对象却是虚拟内存背后映射的物理内存。
所以接下来内核就会调用 __mm_populate 为 [start, start + len] 这段虚拟内存分配物理内存。内核这里首先还是将 [start, start + len] 这段虚拟内存范围内的所有 vma 查找出来,并立即依次为每个 vma 填充物理内存。
int __mm_populate(unsigned long start, unsigned long len, int ignore_errors)
{
end = start + len;
// 依次遍历进程地址空间中 [start , end] 这段虚拟内存范围的所有 vma
for (nstart = start; nstart < end; nstart = nend) {
........ 省略查找指定地址范围内 vma 的过程 ....
// 为 vma 分配物理内存
ret = populate_vma_page_range(vma, nstart, nend, &locked);
// 继续为下一个 vma (如果有的话)分配物理内存
nend = nstart + ret * PAGE_SIZE;
ret = 0;
}
return ret; /* 0 or negative error code */
}
populate_vma_page_range 负责计算单个 vma 中包含的虚拟内存页个数,然后调用 __get_user_pages
函数在每一个虚拟内存页上依次主动触发缺页中断处理。
long populate_vma_page_range(struct vm_area_struct *vma,
unsigned long start, unsigned long end, int *nonblocking)
{
// 获取进程地址空间
struct mm_struct *mm = vma->vm_mm;
// 计算 vma 中包含的虚拟内存页个数,后续会按照 nr_pages 分配物理内存
unsigned long nr_pages = (end - start) / PAGE_SIZE;
int gup_flags;
// 循环遍历 vma 中的每一个虚拟内存页,依次为其分配物理内存页
return __get_user_pages(current, mm, start, nr_pages, gup_flags,
NULL, NULL, nonblocking);
}
__get_user_pages 函数首先会通过 follow_page_mask
在进程页表中检查一下每一个虚拟内存页是否已经映射了物理内存,如果已经有物理内存了,那么这里就不用分配了,直接跳过。
如果虚拟内存页还没有映射物理内存,那么内核就会调用 faultin_page
立即触发一次缺页中断,在缺页中断的处理中,内核就会将该虚拟内存页(MappedByteBuffer)背后所映射的文件内容读取到 page cache 中,并在进程页表中建立 MappedByteBuffer 与 page cache 的映射关系。
关于缺页中断的处理细节,感兴趣的读者可以回看下《一文聊透 Linux 缺页异常的处理 —— 图解 Page Faults》
static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
unsigned long start, unsigned long nr_pages,
unsigned int gup_flags, struct page **pages,
struct vm_area_struct **vmas, int *nonblocking)
{
// 循环遍历 vma 中的每一个虚拟内存页
do {
struct page *page;
// 在进程页表中检查该虚拟内存页背后是否有物理内存页映射
page = follow_page_mask(vma, start, foll_flags, &ctx);
if (!page) {
// 如果虚拟内存页在页表中并没有物理内存页映射,那么这里调用 faultin_page
// 底层会调用到 handle_mm_fault 进入缺页处理流程 (write fault),分配物理内存,在页表中建立好映射关系
ret = faultin_page(tsk, vma, start, &foll_flags,
nonblocking);
} while (nr_pages);
return i ? i : ret;
}
到这里 mlock 系统调用就为大家介绍完了,接下来我们把上小节介绍的 madvise 系统调用与本小节的 mlock 放在一起对比一下,加深一下理解。
首先 madvise 系统调用中的 MADV_WILLNEED
作用很简单,当我们在 MappedByteBuffer 身上运用 madvise 之后,内核只是会将 MappedByteBuffer 背后所映射的文件内容加载到 page cache 中而已。
但 madvise 不会将 page cache 与 MappedByteBuffer 在进程页表中映射,后面进程在访问 MappedByteBuffer 的时候仍然会产生缺页中断,在缺页中断处理中才会与 page cache 在进程页表中进行映射关联。
当内存资源紧张的时候,page cache 中的文件页可能会被内核 swap out 出去,这时访问 MappedByteBuffer 还是会触发缺页中断。
当我们在 MappedByteBuffer 身上运用 mlock 之后,情况就不一样了,首先 mlock 系统调用也会将 MappedByteBuffer 背后所映射的文件内容加载到 page cache 中,除此之外,mlock 还会将 MappedByteBuffer 与 page cache 在进程页表中映射起来,更重要的一点是,mlock 会将 page cache 中缓存的相关文件页锁定在内存中。
3.3 msync
我们都知道 MappedByteBuffer 在刚被 FileChannel#map
映射出来的时候,它只是一片虚拟内存而已,映射文件的 page cache 是空的,进程页表中对应的页表项也都是空的。
后续我们通过访问 MappedByteBuffer 直接触发缺页中断也好,亦或者是通过前面介绍的两个系统调用 madvise , mlock 也罢,它们解决的问题是负责将 MappedByteBuffer 背后映射的文件内容加载到物理内存中(page cache),然后在进程页表中设置 MappedByteBuffer 与 page cache 的关联关系,以保证后续进程可以通过 MappedByteBuffer 直接访问 page cache。
但无论是通过 MappedByteBuffer 还是传统的 FileChannel#read or write
,它们在对文件进行读写的时候都是直接操作的 page cache。page cache 中被写入的文件页就会变成脏页,后续内核会根据自己的回写策略将脏页刷新到磁盘文件中。
但内核的回写策略是内核自己的行为,站在用户进程的角度来看属于被动回写,如果用户进程想要自己主动触发脏页的回写就需要用到一些相关的系统调用。
而负责脏页回写的系统调用有很多,比如:sync,fsync, fdatasync 以及本小节要介绍的 msync。其中 sync 主要负责回写整个系统内所有的脏页以及相关文件的 metadata。
而 fsync 和 fdatasync 主要是针对特定文件的脏页回写,其中 fsync 不仅会回写特定文件的脏页数据而且会回写文件的 metadata,fdatasync 就只会回写特定文件的脏页数据不会回写文件的 metadata。
FileChannel 中的 force 方法就是针对特定文件脏页的回写操作,参数 metaData 指定为 true 表示我们不仅需要对文件脏页内容进行回写还需要对文件的 metadata 进行回写,所以在 native 层调用的是 fsync。
参数 metaData 指定为 false 表示我们仅仅是需要回写文件的脏页内容,所以在 native 层调用的是 fdatasync 。
public class FileChannelImpl extends FileChannel
{
public void force(boolean metaData) throws IOException {
do {
// metaData = true 调用 fsync
// metaData = false 调用 fdatasync
rv = nd.force(fd, metaData);
} while ((rv == IOStatus.INTERRUPTED) && isOpen());
}
}
但 MappedByteBuffer 的回写却不是针对整个文件的,而是针对其所映射的文件区域进行回写,这就用到了 msync 系统调用。
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
msync 主要针对 [addr, addr+ken] 这段虚拟内存范围内所映射的文件区域进行回写,但 msync 只会回写脏页数据并不会回写文件的 metadata。参数 flags 用于指定回写的方式,最常用的是 MS_SYNC
,它表示进程需要等到回写操作完成之后才会从该系统调用中返回。
除了 MS_SYNC 之外内核还提供了 MS_ASYNC,MS_INVALIDATE 这两个 flags 选项,但翻阅 msync 系统调用的源码你会发现,当我们设置了 MS_ASYNC 或者 MS_INVALIDATE 时,msync 不会做任何事情,相当于白白调用了一次。内核之所以会继续保留这两个选项,笔者这里猜测可能是为了兼容老版本内核关于脏页相关的处理逻辑,这里我们就不详细展开了。
MappedByteBuffer#force
方法用于对指定映射范围 [index,index+len] 内的文件内容进行回写:
public abstract class MappedByteBuffer extends ByteBuffer
{
public final MappedByteBuffer force(int index, int length) {
int capacity = capacity();
if ((address != 0) && (capacity != 0)) {
SCOPED_MEMORY_ACCESS.force(scope(), fd, address, isSync, index, length);
}
return this;
}
}
关于 MappedByteBuffer 的核心回写逻辑 JDK 封装在 MappedMemoryUtils 类中:
class MappedMemoryUtils {
static void force(FileDescriptor fd, long address, boolean isSync, long index, long length) {
if (isSync) {
// 如果 MappedByteBuffer 背后映射的是 persistent memory
// 那么在 force 回写数据的时候是通过 CPU 指令完成的而不是 msync 系统调用
Unsafe.getUnsafe().writebackMemory(address + index, length);
} else {
// force writeback via file descriptor
long offset = mappingOffset(address, index);
try {
force0(fd, mappingAddress(address, offset, index), mappingLength(offset, length));
} catch (IOException cause) {
throw new UncheckedIOException(cause);
}
}
}
private static native void force0(FileDescriptor fd, long address, long length) throws IOException;
}
如果 MappedByteBuffer 背后映射的是 persistent memory(isSync = true),那么这里的回写指的是将数据从 CPU 高速缓存 cache line 中刷新到 persistent memory 中。
不过这个刷新操作是通过 CLWK 指令(cache line writeback)将 cache line 中的数据 flush 到 persistent memory 中。不需要像传统磁盘文件那样需要启动块设备 IO 来回写磁盘。
如果 MappedByteBuffer 背后映射的是普通磁盘文件的话,JDK 这里就会调用一个 native 方法 force0 将映射文件区域的脏页回写到磁盘中,我们在 force0 的 native 实现中可以看到,JVM 这里调用了 msync。
msync 和 mmap 也是需要配对使用的,mmap 负责映射,msync 负责将映射出来的文件区域相关的脏页回写到磁盘中,所以我们在调用 msync 的时候,指定的虚拟内存范围需要和 mmap 真实映射出来的虚拟内存范围保持一致。
通过 mappingAddress 函数获取 mmap 真实的起始映射地址 mapPosition,通过 mappingLength 获取真实映射出来的区域大小 mapSize,将这两个值作为要进行回写的文件映射范围传入 msync 系统调用中。
// 文件:MappedMemoryUtils.c
JNIEXPORT void JNICALL
Java_java_nio_MappedMemoryUtils_force0(JNIEnv *env, jobject obj, jobject fdo,
jlong address, jlong len)
{
void* a = (void *)jlong_to_ptr(address);
int result = msync(a, (size_t)len, MS_SYNC);
if (result == -1) {
JNU_ThrowIOExceptionWithLastError(env, "msync failed");
}
}
下面我们来看一下当 JVM 调用 msync 之后,在内核中到底发生了什么:
首先如果我们指定的这段 [start , end] 虚拟内存地址是无效的,也就是还未被映射过,那么内核就会返回 ENOMEM
错误。
后面还是老套路,通过 find_vma
函数在进程地址空间中查找出 [start , end] 这段虚拟内存范围内第一个 vma 出来,然后在一个 for 循环中依次遍历指定范围内的所有 vma,并通过 vfs_fsync_range
将 vma 背后映射的文件区域内的脏页回写到磁盘中。
// 文件:/mm/msync.c
SYSCALL_DEFINE3(msync, unsigned long, start, size_t, len, int, flags)
{
unsigned long end;
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma;
// [start,end] 这段虚拟内存范围内所映射的文件内容将会被回写到磁盘中
end = start + len;
// 在进程地址空间中查找第一个符合 start < vma->vm_end 的 vma 区域
vma = find_vma(mm, start);
// 遍历 [start,end] 区域内的所有 vma,依次回写脏页
for (;;) {
// 映射文件
struct file *file;
// MappedByteBuffer 映射的文件区域 [fstart,fend]
loff_t fstart, fend;
// 如果我们指定了一段无效的虚拟内存区域 [start,end],那么内核会返回 ENOMEM 错误
error = -ENOMEM;
if (!vma)
goto out_unlock;
/* Here start < vma->vm_end. */
if (start < vma->vm_start) {
start = vma->vm_start;
if (start >= end)
goto out_unlock;
unmapped_error = -ENOMEM;
}
file = vma->vm_file;
// 映射的文件内容在磁盘文件中的起始偏移
fstart = (start - vma->vm_start) +
((loff_t)vma->vm_pgoff << PAGE_SHIFT);
// 映射的文件内容在文件中的结束偏移
fend = fstart + (min(end, vma->vm_end) - start) - 1;
if ((flags & MS_SYNC) && file &&
(vma->vm_flags & VM_SHARED)) {
// 回写 [fstart,fend] 这段文件区域内的脏页到磁盘中
error = vfs_fsync_range(file, fstart, fend, 1);
}
}
out_unlock:
// 释放进程地址空间锁
up_read(&mm->mmap_sem);
out:
return error ? : unmapped_error;
}
vfs_fsync_range 函数最后一个参数 datasync
表示是否回写映射文件的 metadata,datasync = 0
表示文件的 metadata 以及脏页内容都需要回写。datasync = 1
表示只需要回写脏页内容。
这里我们看到 msync 系统调用将 datasync 设置为 1,只需要回写脏页内容即可。
int vfs_fsync_range(struct file *file, loff_t start, loff_t end, int datasync)
{
struct inode *inode = file->f_mapping->host;
// 映射文件所在的文件系统必须定义脏页回写函数 fsync
if (!file->f_op->fsync)
return -EINVAL;
if (!datasync && (inode->i_state & I_DIRTY_TIME))
// datasync = 0 表示不仅需要回写脏页数据而且还需要回写文件 metadata
mark_inode_dirty_sync(inode);
// 调用具体文件系统中实现的 fsync 函数,实现对指定文件区域内的脏页进行回写
return file->f_op->fsync(file, start, end, datasync);
}
EXPORT_SYMBOL(vfs_fsync_range);
msync 系统调用最终会调用到文件相关的操作函数 fsync,它和具体的文件系统相关,不同的文件系统有不同的实现,但最终回写脏页的时候都需要启动磁盘块设备 IO 对脏页进行回写。
网友评论