想写这个系列很久了,对自己也是个总结与提高。原来在学JAVA时,那些JAVA入门书籍会告诉你一些规律还有法则,但是用的时候我们一般很难想起来,因为我们用的少并且不知道为什么。知其所以然方能印象深刻并学以致用。

本篇文章针对JAVA中的MMAP的文件映射读写机制,来分析为何很多告诉框架用了这个机制,以及这个机制好在哪里,快在哪里。

本文基于JDK 1.8

JAVA File MMAP原理解析

1. 内存管理术语

  • MMC:CPU的内存管理单元。
  • 物理内存:即内存条的内存空间。
  • 虚拟内存:计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
  • 页面文件:操作系统反映构建并使用虚拟内存的硬盘空间大小而创建的文件,在windows下,即pagefile.sys文件,其存在意味着物理内存被占满后,将暂时不用的数据移动到硬盘上。
  • 缺页中断:当程序试图访问已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由MMC发出的中断。如果操作系统判断此次访问是有效的,则尝试将相关的页从虚拟内存文件中载入物理内存。

2. 什么是MMAP

尽管从JDK 1.4版本开始,Java内存映射文件(Memory Mapped Files)就已经在java.nio包中,但它对很多程序开发者来说仍然是一个相当新的概念。引入NIO后,Java IO已经相当快,而且内存映射文件提供了Java有可能达到的最快IO操作,这也是为什么那些高性能Java应用应该使用内存映射文件来持久化数据。
作为NIO的一个重要的功能,Mmap方法为我们提供了将文件的部分或全部映射到内存地址空间的能力,同当这块内存区域被写入数据之后会变成脏页,操作系统会用一定的算法把这些数据写入到文件中,而我们的java程序不需要去关心这些。这就是内存映射文件的一个关键优势,即使你的程序在刚刚写入内存后就挂了,操作系统仍然会将内存中的数据写入文件系统。
另外一个更突出的优势是共享内存,内存映射文件可以被多个进程同时访问,起到一种低时延共享内存的作用。

3. Java MMAP实现

3.1. Java MMAP 与 FileChannel操作文件对比

package com.github.hashZhang.scanfold.jdk.file;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.util.Random;

public class FileMmapTest {
    public static void main(String[] args) throws Exception {
        //记录开始时间
        long start = System.currentTimeMillis();
        //通过RandomAccessFile的方式获取文件的Channel,这种方式针对随机读写的文件较为常用,我们用文件一般是随机读写
        RandomAccessFile randomAccessFile = new RandomAccessFile("./FileMmapTest.txt", "rw");
        FileChannel channel = randomAccessFile.getChannel();
        System.out.println("FileChannel初始化时间:" + (System.currentTimeMillis() - start) + "ms");

        //内存映射文件,模式是READ_WRITE,如果文件不存在,就会被创建
        MappedByteBuffer mappedByteBuffer1 = channel.map(FileChannel.MapMode.READ_WRITE, 0, 128 * 1024 * 1024);
        MappedByteBuffer mappedByteBuffer2 = channel.map(FileChannel.MapMode.READ_WRITE, 0, 128 * 1024 * 1024);

        System.out.println("MMAPFile初始化时间:" + (System.currentTimeMillis() - start) + "ms");

        start = System.currentTimeMillis();
        testFileChannelSequentialRW(channel);
        System.out.println("FileChannel顺序读写时间:" + (System.currentTimeMillis() - start) + "ms");

        start = System.currentTimeMillis();
        testFileMMapSequentialRW(mappedByteBuffer1, mappedByteBuffer2);
        System.out.println("MMAPFile顺序读写时间:" + (System.currentTimeMillis() - start) + "ms");

        start = System.currentTimeMillis();
        try {
            testFileChannelRandomRW(channel);
            System.out.println("FileChannel随机读写时间:" + (System.currentTimeMillis() - start) + "ms");
        } finally {
            randomAccessFile.close();
        }

        //文件关闭不影响MMAP写入和读取
        start = System.currentTimeMillis();
        testFileMMapRandomRW(mappedByteBuffer1, mappedByteBuffer2);
        System.out.println("MMAPFile随机读写时间:" + (System.currentTimeMillis() - start) + "ms");
    }


    public static void testFileChannelSequentialRW(FileChannel fileChannel) throws Exception {
            byte[] bytes = "测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1".getBytes();
            byte[] to = new byte[bytes.length];
            //分配直接内存,减少复制
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(bytes.length);
            //顺序写入
            for (int i = 0; i < 100000; i++) {
                byteBuffer.put(bytes);
                byteBuffer.flip();
                fileChannel.write(byteBuffer);
                byteBuffer.flip();
            }

            fileChannel.position(0);
            //顺序读取
            for (int i = 0; i < 100000; i++) {
                fileChannel.read(byteBuffer);
                byteBuffer.flip();
                byteBuffer.get(to);
                byteBuffer.flip();
            }
    }

    public static void testFileMMapSequentialRW(MappedByteBuffer mappedByteBuffer1, MappedByteBuffer mappedByteBuffer2) throws Exception {
        byte[] bytes = "测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2".getBytes();
        byte[] to = new byte[bytes.length];

        //顺序写入
        for (int i = 0; i < 100000; i++) {
            mappedByteBuffer1.put(bytes);
        }
        //顺序读取
        for (int i = 0; i < 100000; i++) {
            mappedByteBuffer2.get(to);
        }
    }

    public static void testFileChannelRandomRW(FileChannel fileChannel) throws Exception {
        try {
            byte[] bytes = "测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1".getBytes();
            byte[] to = new byte[bytes.length];
            //分配直接内存,减少复制
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(bytes.length);
            //随机写入
            for (int i = 0; i < 100000; i++) {
                byteBuffer.put(bytes);
                byteBuffer.flip();
                fileChannel.position(new Random(i).nextInt(bytes.length*100000));
                fileChannel.write(byteBuffer);
                byteBuffer.flip();
            }
            //随机读取
            for (int i = 0; i < 100000; i++) {
                fileChannel.position(new Random(i).nextInt(bytes.length*100000));
                fileChannel.read(byteBuffer);
                byteBuffer.flip();
                byteBuffer.get(to);
                byteBuffer.flip();
            }
        } finally {
            fileChannel.close();
        }
    }

    public static void testFileMMapRandomRW(MappedByteBuffer mappedByteBuffer1, MappedByteBuffer mappedByteBuffer2) throws Exception {
        byte[] bytes = "测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2".getBytes();
        byte[] to = new byte[bytes.length];

        //随机写入
        for (int i = 0; i < 100000; i++) {
            mappedByteBuffer1.position(new Random(i).nextInt(bytes.length*100000));
            mappedByteBuffer1.put(bytes);
        }
        //随机读取
        for (int i = 0; i < 100000; i++) {
            mappedByteBuffer2.position(new Random(i).nextInt(bytes.length*100000));
            mappedByteBuffer2.get(to);
        }
    }
}

在这里,我们初始化了一个文件,并把它映射到了128M的内存中。分FileChannel还有MMAP的方式,通过顺序或随机读写,写了一些内容并读取一部分内容。

运行结果是:

FileChannel初始化时间:7ms
MMAPFile初始化时间:8ms
FileChannel顺序读写时间:420ms
MMAPFile顺序读写时间:20ms
FileChannel随机读写时间:860ms
MMAPFile随机读写时间:45ms

可以看到,通过MMAP内存映射文件的方式操作文件,更加快速,并且性能提升的相当明显。

3.2. Java MMAP 源代码分析

我们可以利用strace命令先看看上面程序的系统调用:

strace -c java com/github/hashZhang/scanfold/jdk/file/FileMmapTest

结果如下:

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 99.79    0.410139      205070         2           futex
  0.04    0.000151          13        12           mprotect
  0.03    0.000127           6        23           mmap
  0.03    0.000110           5        23        14 open
  0.02    0.000065          22         3           munmap
  0.01    0.000058           7         8           read
  0.01    0.000053           6         9           close
  0.01    0.000052           6         9           fstat
  0.01    0.000040           4        10         7 stat
  0.01    0.000039          10         4           brk
  0.01    0.000036          36         1           clone
  0.01    0.000033          11         3         2 access
  0.01    0.000025          13         2           readlink
  0.01    0.000024          12         2           rt_sigaction
  0.00    0.000012          12         1           getrlimit
  0.00    0.000012          12         1           set_tid_address
  0.00    0.000011          11         1           rt_sigprocmask
  0.00    0.000011          11         1           set_robust_list
  0.00    0.000000           0         1           execve
  0.00    0.000000           0         1           arch_prctl
------ ----------- ----------- --------- --------- ----------------
100.00    0.410998                   117        23 total

我们可以注意到有open、mmap、munmap、close这几个比较重要的系统调用,我们的文件操作基本主要和这几个系统调用有关。

接下来我们来看MMAP相关的源代码:

3.2.1. 初始化内存映射文件Buffer -> MappedByteBuffer

MappedByteBuffer的类关系:

java解析DCM格式 java dmp文件分析_mmap

可以看到,DirectByteBuffer是一种MappedByteBuffer。

channel.map(FileChannel.MapMode.READ_WRITE, 0, 128 * 1024 * 1024);对应的源代码:

FileChannel.java:

public MappedByteBuffer map(FileChannel.MapMode mode, long position, long size)
            throws IOException
{
    //保证文件还没被关闭,基本上FileChannel的每个方法都会做这个判断
    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");
    //size不能超过Integer的最大值
    //因为在写入数据时,java地址转换为linux内存地址的时候,强制转换成了int类型,所以映射大小不能超过Integer的最大值,也就是<2G(2^31-1)
    if (size > Integer.MAX_VALUE)
        throw new IllegalArgumentException("Size exceeds Integer.MAX_VALUE");

    int imode = -1;
    if (mode == FileChannel.MapMode.READ_ONLY)
        imode = MAP_RO;
    else if (mode == FileChannel.MapMode.READ_WRITE)
        imode = MAP_RW;
    else if (mode == FileChannel.MapMode.PRIVATE)
        imode = MAP_PV;
    assert (imode >= 0);
    if ((mode != FileChannel.MapMode.READ_ONLY) && !writable)
        throw new NonWritableChannelException();
    if (!readable)
        throw new NonReadableChannelException();

    long addr = -1;
    int ti = -1;
    try {
        //线程锁控制,这里用的原生线程锁,限制最多同时有两个线程(进程)同时去映射同一个文件
        begin();
        ti = threads.add();
        if (!isOpen())
            return null;

        //首先要获取文件目前的大小,来判断是否需要扩展文件
        long filesize;
        do {
            //JNI调用1:调用fstat命令获取文件大小
            filesize = nd.size(fd);
        } while (
            //遇到系统EINTR信号(被中断的系统调用)时,要一直重试,因为这不代表调用有错误
            (filesize == IOStatus.INTERRUPTED)
            && isOpen()
        );
        if (!isOpen())
            return null;

        //当映射的文件位置与大小超过文件整体大小时,需要扩展文件
        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 {
                //JNI调用2: 调用ftruncate扩展文件
                rv = nd.truncate(fd, position + size);
            } while (
                //遇到系统EINTR信号(被中断的系统调用)时,要一直重试,因为这不代表调用有错误
                (rv == IOStatus.INTERRUPTED) && isOpen()
            );
            if (!isOpen())
                return null;
        }

        //如果映射size为0,直接返回空的DirectByteBuffer或者只读的DirectByteBufferR
        //个人感觉这个可以提前,放到判断文件大小之前,但是更严谨的话,应该放到现在的位置
        if (size == 0) {
            addr = 0;
            // a valid file descriptor is not required
            FileDescriptor dummy = new FileDescriptor();
            if ((!writable) || (imode == MAP_RO))
                return Util.newMappedByteBufferR(0, 0, dummy, null);
            else
                return Util.newMappedByteBuffer(0, 0, dummy, null);
        }

        //计算出页位置, allocationGranularity是系统文件分页大小(pageCache的page大小)
        int pagePosition = (int)(position % allocationGranularity);
        //计算出映射起始页位置
        long mapPosition = position - pagePosition;
        //计算需要的大小
        long mapSize = size + pagePosition;
        try {
            //JNI调用3: mmap
            addr = map0(imode, mapPosition, mapSize);
        } catch (OutOfMemoryError x) {
            // 如果因为内存不足导致的失败,尝试请求Full-GC
            System.gc();
            try {
                //因为System.gc()只是告诉jvm要做FullGC,但是不一定立刻做,所以这里等待100ms来让JVM FullGC(FullGC时间不算在这个100ms内,因为FullGC是全局中断)
                Thread.sleep(100);
            } catch (InterruptedException y) {
                Thread.currentThread().interrupt();
            }
            try {
                //重新尝试
                addr = map0(imode, mapPosition, mapSize);
            } catch (OutOfMemoryError y) {
                //FullGC之后还是没能分配,就抛异常
                throw new IOException("Map failed", y);
            }
        }

        // On Windows, and potentially other platforms, we need an open
        // file descriptor for some mapping operations.
        FileDescriptor mfd;
        try {
            mfd = nd.duplicateForMapping(fd);
        } catch (IOException ioe) {
            unmap0(addr, mapSize);
            throw ioe;
        }

        assert (IOStatus.checkAll(addr));
        assert (addr % allocationGranularity == 0);
        int isize = (int)size;
        //新建一个Unmapper来在GC的时候回收掉mmap出来的内存
        //这个回收也是jni调用munmap
        FileChannelImpl.Unmapper um = new FileChannelImpl.Unmapper(addr, mapSize, isize, mfd);
        if ((!writable) || (imode == MAP_RO)) {
            //返回只读的DirectByteBuffer封装的mmap内存
            return Util.newMappedByteBufferR(isize,
                    addr + pagePosition,
                    mfd,
                    um);
        } else {
            //返回DirectByteBuffer封装的mmap内存
            return Util.newMappedByteBuffer(isize,
                    addr + pagePosition,
                    mfd,
                    um);
        }
    } finally {
        threads.remove(ti);
        end(IOStatus.checkAll(addr));
    }
}

1.JNI调用1:调用fstat命令获取文件大小:

FileDispatcherImpl.c:

#define fstat64 fstat
Java_sun_nio_ch_FileDispatcherImpl_size0(JNIEnv *env, jobject this, jobject fdo)
{
    struct stat64 fbuf;

    if (fstat64(fdval(env, fdo), &fbuf) < 0)
        return handle(env, -1, "Size failed");
    return fbuf.st_size;
}

fstat函数:

定义函数: int fstat(int fildes, struct stat *buf);

函数说明: fstat()用来将参数fildes 所指的文件状态, 复制到参数buf 所指的结构中(struct stat). Fstat()与stat()作用完全相同, 不同处在于传入的参数为已打开的文件描述词. 详细内容请参考stat().

返回值: 执行成功则返回0, 失败返回-1, 错误代码存于errno.

对于返回值如果为EINTR的处理如下:
FileDispatcherImpl.c:

static jlong
handle(JNIEnv *env, jlong rv, char *msg)
{
    if (rv >= 0)
        return rv;
    if (errno == EINTR)
        return IOS_INTERRUPTED;
    JNU_ThrowIOExceptionWithLastError(env, msg);
    return IOS_THROWN;
}

2.JNI调用2:调用ftruncate扩展文件:

FileDispatcherImpl.c:

#define ftruncate64 ftruncate
JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_truncate0(JNIEnv *env, jobject this,
                                             jobject fdo, jlong size)
{
    return handle(env,
                  ftruncate64(fdval(env, fdo), size),
                  "Truncation failed");
}

ftruncate函数:

定义函数: int ftruncate(int fd, off_t length);

函数说明: ftruncate()会将参数fd 指定的文件大小改为参数length 指定的大小。参数fd 为已打开的文件描述词,而且必须是以写入模式打开的文件。如果原来的文件大小比参数length 大,则超过的部分会被删去。

返回值: 执行成功则返回0, 失败返回-1, 错误原因存于errno.

错误代码:
1、EBADF 参数fd 文件描述词为无效的或该文件已关闭。
2、EINVAL 参数fd 为一socket 并非文件, 或是该文件并非以写入模式打开。

3. JNI调用3:调用mmap构建内存映射

FileDispatcherImpl.c:

#define mmap64 mmap
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                                     jint prot, jlong off, jlong len)
{
    void *mapAddress = 0;
    jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
    jint fd = fdval(env, fdo);
    int protections = 0;
    int flags = 0;

    if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
        protections = PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
        protections = PROT_WRITE | PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
        protections =  PROT_WRITE | PROT_READ;
        flags = MAP_PRIVATE;
    }
    //调用mmap
    mapAddress = mmap64(
        0,                    /* Let OS decide location */
        len,                  /* Number of bytes to map */
        protections,          /* File permissions */
        flags,                /* Changes are shared */
        fd,                   /* File descriptor of mapped file */
        off);                 /* Offset into file */
    //内存不足时,抛出OutOfMemoryError
    if (mapAddress == MAP_FAILED) {
        if (errno == ENOMEM) {
            JNU_ThrowOutOfMemoryError(env, "Map failed");
            return IOS_THROWN;
        }
        return handle(env, -1, "Map failed");
    }

    return ((jlong) (unsigned long) mapAddress);
}

mmap函数:

定义函数: void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offsize);

函数说明: mmap()用来将某个文件内容映射到内存中,对该内存区域的存取即是直接对该文件内容的读写。

参数说明:

参数

说明

start

指向欲对应的内存起始地址,通常设为NULL,代表让系统自动选定地址,对应成功后该地址会返回。

length

代表将文件中多大的部分对应到内存。

prot

代表映射区域的保护方式,有下列组合:PROT_EXEC:映射区域可被执行;PROT_READ:映射区域可被读取;PROT_WRITE:映射区域可被写入;PROT_NONE:映射区域不能存取。

flags

会影响映射区域的各种特性:MAP_FIXED:如果参数 start 所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。通常不鼓励用此旗标。MAP_SHARED:对应射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享。MAP_PRIVATE:对应射区域的写入操作会产生一个映射文件的复制,即私人的”写入时复制” (copy on write)对此区域作的任何修改都不会写回原来的文件内容。MAP_ANONYMOUS:建立匿名映射,此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享。MAP_DENYWRITE:只允许对应射区域的写入操作,其他对文件直接写入的操作将会被拒绝。MAP_LOCKED:将映射区域锁定住,这表示该区域不会被置换(swap)。

在调用mmap()时必须要指定MAP_SHARED 或MAP_PRIVATE。
fd open()返回的文件描述词,代表欲映射到内存的文件。
offset 文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。
返回值:若映射成功则返回映射区的内存起始地址,否则返回MAP_FAILED(-1),错误原因存于errno 中。

错误代码:
- EBADF 参数fd 不是有效的文件描述词。
- EACCES 存取权限有误。如果是MAP_PRIVATE 情况下文件必须可读,使用MAP_SHARED 则要有PROT_WRITE 以及该文件要能写入。
- EINVAL 参数start、length 或offset 有一个不合法。
- EAGAIN 文件被锁住,或是有太多内存被锁住。
- ENOMEM 内存不足。

3.2.1.1. Q1. 内存映射文件占用的是哪里的内存?

java解析DCM格式 java dmp文件分析_字符串_02

通过上面的源码分析,我们可以看到返回的是DirectByteBuffer,但是这个DirectByteBuffer并没有占用到JVM的-XX:MaxDirectMemorySize的空间。我们可以从两个地方看出来:

  1. DirectByteBuffer的构造方法与其他的构造方法不一样,调用的是:protected DirectByteBuffer(int cap, long addr, FileDescriptor fd, Runnable unmapper),而且是通过反射,所以没走到sun对于DirectMemory使用的统计中
  2. 我们可以在代码中添加查看直接内存占用的代码来看是否有占用,这个参考我的系列上一篇文章JDK核心JAVA源码解析(4) - 堆外内存、零拷贝、DirectByteBuffer以及针对于NIO中的FileChannel的思考
    这个mmap占用的内存是一块独立于JVM之外的,可以进程间共享的内存。
3.2.1.2. Q2. 内存映射文件的内存什么时候会被回收?

首先,munmap并没有被JDK开放。我们想主动回收是无法做到的(当然也可以通过反射调用,但是并不推荐)。这个不开放也可以理解,因为用户如果主动调用,会导致GC DirectBuffer的时候,报出内存访问异常导致JVM崩溃(如果用户调用了munmap,对应的MappedByteBuffer被GC时,会在被调用一次。这时如果内存已经被其他程序占用,会报一个内存访问异常)。

我们要想解除映射只能先把buffer置为null,然后祈祷GC赶紧起作用,实在等不及还可以用System.gc()催促一下GC赶快干活,不过后果是会引发FullGC。

所以,在写完当前文件块,需要映射下一块文件时,我们一般就把对应的MappedByteBuffer设置为null就行了,然后继续map就行了,因为在之前的源代码讲解中,我们看到,如果内存不足,会调用System.gc()

3.2.2. 对MappedByteBuffer进行读写

对于MappedByteBuffer的读写和对于ByteBuffer的读写是一样的,可以参考我的另一片文章

3.2.2.1 对MappedByteBuffer进行读写,为何最大只能2GB-1B

我们来看底层实现:对于所有DirectByteBuffer的读写,都用到了Unsafe类的public native void putByte(Object o, long offset, byte x);方法,底层实现是:

unsafe.cpp:

UNSAFE_ENTRY(void, Unsafe_SetNative##Type(JNIEnv *env, jobject unsafe, jlong addr, java_type x)) \
  UnsafeWrapper("Unsafe_SetNative"#Type); \
  JavaThread* t = JavaThread::current(); \
  t->set_doing_unsafe_access(true); \
  //获取地址
  void* p = addr_from_java(addr); \
  //设置值
  *(volatile native_type*)p = x; \
  t->set_doing_unsafe_access(false); \
UNSAFE_END \

那么这个获取地址的方法是啥样子呢?

inline void* addr_from_java(jlong addr) {
  // This assert fails in a variety of ways on 32-bit systems.
  // It is impossible to predict whether native code that converts
  // pointers to longs will sign-extend or zero-extend the addresses.
  //assert(addr == (uintptr_t)addr, "must not be odd high bits");
  //转换为int
  return (void*)(uintptr_t)addr;
}

这里我们看到,转换地址会被强制转换为int类型,所以只能映射2GB-1B。

但是为何-XX:MaxDirectMemory可以指定比2G大的值呢?因为对于分配的直接内存中的buffer,有对一个BitMap管理他们的基址,可以保证映射出对的地址,类似于堆内存的基址映射。但是对于文件映射内存,JVM没办法维护这么一个基址,或者说没必要(大于2GB-1B我们多映射两次自己维护就行了)。

3.2.2.2. 文件映射内存是如何更新到硬盘文件的?

对于我们的程序,只需要修改MappedByteBuffer就可以了,之后操作系统根据优先搜索树的算法,通过pdflush进程刷入磁盘。
就算我们的程序挂了,操作系统也会把这部分内存的脏页刷入磁盘。
但是如果系统挂了,重启等,这部分数据会丢失。

那我们有强制刷入磁盘的方法么?linux对应的系统调用是msync()函数。对应的Java方法是MappedByteBuffer.force(),不过使用这个方法会大幅度降低效率,慎用!

MappedByteBuffer.java:

public final MappedByteBuffer force() {
    checkMapped();
    if ((address != 0) && (capacity() != 0)) {
        long offset = mappingOffset();
        //原生调用force0
        force0(fd, mappingAddress(offset), mappingLength(offset));
    }
    return this;
}

MappedByteBuffer.c:

JNIEXPORT void JNICALL
Java_java_nio_MappedByteBuffer_force0(JNIEnv *env, jobject obj, jobject fdo,
                                      jlong address, jlong len)
{
    void* a = (void *)jlong_to_ptr(address);
    //调用msync
    int result = msync(a, (size_t)len, MS_SYNC);
    if (result == -1) {
        JNU_ThrowIOExceptionWithLastError(env, "msync failed");
    }
}
3.2.2.3. 如何查看文件映射脏页,如何统计MMAP的内存大小?

我们写一个测试程序:

public static void main(String[] args) throws Exception {
    RandomAccessFile randomAccessFile = new RandomAccessFile("./FileMmapTest.txt", "rw");
    FileChannel channel = randomAccessFile.getChannel();
    MappedByteBuffer []mappedByteBuffers = new MappedByteBuffer[5];

    //开5个相同文件的MappedByteBuffer,但是实际机器内存只有8G
    mappedByteBuffers[0] = channel.map(FileChannel.MapMode.READ_WRITE, 0, 2 * 1024 * 1024 * 1024 - 1);
    mappedByteBuffers[1] = channel.map(FileChannel.MapMode.READ_WRITE, 0, 2 * 1024 * 1024 * 1024 - 1);
    mappedByteBuffers[2] = channel.map(FileChannel.MapMode.READ_WRITE, 0, 2 * 1024 * 1024 * 1024 - 1);
    mappedByteBuffers[3] = channel.map(FileChannel.MapMode.READ_WRITE, 0, 2 * 1024 * 1024 * 1024 - 1);
    mappedByteBuffers[4] = channel.map(FileChannel.MapMode.READ_WRITE, 0, 2 * 1024 * 1024 * 1024 - 1);


    for (int j = 0; j < 2*1024*1024*1024 - 1; j++) {
        mappedByteBuffers[0].put("a".getBytes());
    }
    TimeUnit.SECONDS.sleep(1);
    byte []to = new byte[1];

    for (int j = 0; j < 2*1024*1024*1024 - 1; j++) {
        mappedByteBuffers[1].get(to);
        mappedByteBuffers[2].get(to);
        mappedByteBuffers[3].get(to);
        mappedByteBuffers[4].get(to);
    }

    while(true) {
        TimeUnit.SECONDS.sleep(1);
    }
}

等到程序运行到最后的死循环的时候,我们来看top -c的结果:

KiB Mem :  7493092 total,   147876 free,  3891680 used,  3453536 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  2845100 avail Mem 

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+                                                                                                                                                                                              
25458 zhangha+  20   0 14.147g 8.840g 8.599g S   0.0  124   2:33.16 java

可以观察到非常有意思的现象,这个进程占用了124%的内存,实际上Swap为0。总占用也没到100%。这是为什么呢?

我们来看下这个进程的smaps文件,这里进程号是25485,我们映射的文件是FileMmapTest.txt:

$ grep -A 11 FileMmapTest.txt  /proc/25458/smaps
7fa870000000-7fa8f0000000 rw-s 00000000 ca:01 25190272                   /home/zhanghaoxin/FileMmapTest.txt
Size:            2097152 kB
Rss:             2097152 kB
Pss:              493463 kB
Shared_Clean:    2097152 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:      2014104 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
--
7fa8f0000000-7fa970000000 rw-s 00000000 ca:01 25190272                   /home/zhanghaoxin/FileMmapTest.txt
Size:            2097152 kB
Rss:             2097152 kB
Pss:              493463 kB
Shared_Clean:    2097152 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:      2014104 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
--
7fa970000000-7fa9f0000000 rw-s 00000000 ca:01 25190272                   /home/zhanghaoxin/FileMmapTest.txt
Size:            2097152 kB
Rss:             2097152 kB
Pss:              493463 kB
Shared_Clean:    2097152 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:      2014104 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
--
7fa9f0000000-7faa70000000 rw-s 00000000 ca:01 25190272                   /home/zhanghaoxin/FileMmapTest.txt
Size:            2097152 kB
Rss:             2097152 kB
Pss:              493463 kB
Shared_Clean:    2097152 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:      2014104 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
--
7faa70000000-7faaf0000000 rw-s 00000000 ca:01 25190272                   /home/zhanghaoxin/FileMmapTest.txt
Size:            2097152 kB
Rss:              616496 kB
Pss:              123299 kB
Shared_Clean:     616496 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:       616492 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB

其中比较重要的8个字段的含义分别如下:

  • Size:表示该映射区域在虚拟内存空间中的大小。
  • Rss:表示该映射区域当前在物理内存中占用了多少空间 
  • Pss:该虚拟内存区域平摊计算后使用的物理内存大小(有些内存会和其他进程共享,例如mmap进来的)。比如该区域所映射的物理内存部分同时也被另一个进程映射了,且该部分物理内存的大小为1000KB,那么该进程分摊其中一半的内存,即Pss=500KB。
  • Shared_Clean:和其他进程共享的未被改写的page的大小
  • Shared_Dirty: 和其他进程共享的被改写的page的大小
  • Private_Clean:未被改写的私有页面的大小。
  • Private_Dirty: 已被改写的私有页面的大小。
  • Swap:表示非mmap内存(也叫anonymous memory,比如malloc动态分配出来的内存)由于物理内存不足被swap到交换空间的大小。

我们可以看到,把这五个MappedByteBuffer的Pss加起来正好是2097151,就是我们映射的大小。可以推断出,我们这五个MappedByteBuffer在linux中的实现就是对应同一块内存。
同时,top命令看到的内存并不准,top,命令统计的是RSS字段,其实对于MMAP来说,更准确的应该是统计PSS字段

3.2.2.4. pdflush如何配置

在linux操作系统中,写操作是异步的,即写操作返回的时候数据并没有真正写到磁盘上,而是先写到了系统cache里,随后由pdflush内核线程将系统中的脏页写到磁盘上,在下面几种情况下:
1. 定时方式: 定时机制定时唤醒pdflush内核线程,周期为/proc/sys/vm/dirty_writeback_centisecs ,单位
是(1/100)秒,每次周期性唤醒的pdflush线程并不是回写所有的脏页,而是只回写变脏时间超过
/proc/sys/vm/dirty_expire_centisecs(单位也是1/100秒)。
注意:变脏的时间是以文件的inode节点变脏的时间为基准的,也就是说如果某个inode节点是10秒前变脏的,
pdflush就认为这个inode对应的所有脏页的变脏时间都是10秒前,即使可能部分页面真正变脏的时间不到10秒,
细节可以查看内核函数wb_kupdate()。
2. 内存不足的时候: 这时并不将所有的dirty页写到磁盘,而是每次写大概1024个页面,直到空闲页面满足需求为止。
3. 写操作时发现脏页超过一定比例: 当脏页占系统内存的比例超过/proc/sys/vm/dirty_background_ratio 的时候,write系统调用会唤醒pdflush回写dirty page,直到脏页比例低于/proc/sys/vm/dirty_background_ratio,但write系统调用不会被阻塞,立即返回。当脏页占系统内存的比例超过/proc/sys/vm/dirty_ratio的时候, write系统调用会被被阻塞,主动回写dirty page,直到脏页比例低于/proc/sys/vm/dirty_ratio,这一点在2.4内核中是没有的。
4. 用户调用sync系统调用: 这是系统会唤醒pdflush直到所有的脏页都已经写到磁盘为止。

proc下的相关控制参数:

  • /proc/sys/vm/dirty_ratio: 这个参数控制一个进程在文件系统中的文件系统写缓冲区的大小,单位是百分比,表示系统内存的百分比,表示当一个进程中写缓冲使用到系统内存多少的时候,再有磁盘写操作时开始向磁盘写出数据。增大之会使用更多系统内存用于磁盘写缓冲,也可以极大提高系统的写性能。但是,当你需要持续、恒定的写入场合时,应该降低其数值.一般缺省是 40。设置方法如下:echo 30 >/proc/sys/vm/dirty_ratio
  • /proc/sys/vm/dirty_background_ratio: 这个参数控制文件系统的pdflush进程,在何时刷新磁盘。单位是百分比,表示系统总内存的百分比,意思是当磁盘的脏数据缓冲到系统内存多少的时候,pdflush开始把脏数据刷新到磁盘。增大会使用更多系统内存用于磁盘写缓冲,也可以极大提高系统的写性能。但是,当你需要持续、恒定的写入场合时,应该降低其数值.一般缺省是10。设置方法如下:echo 8 >/proc/sys/vm/dirty_background_ratio
  • /proc/sys/vm/dirty_writeback_centisecs: Pdflush写后台进程每隔多久被唤醒并执行把脏数据写出到硬盘。单位是 1/100 秒。如果你的系统是持续地写入动作,那么实际上还是降低这个数值比较好,这样可以把尖峰的写操作削平成多次写操作。缺省数值是500,也就是 5 秒。设置方法如下:echo 200 >/proc/sys/vm/dirty_writeback_centisecs
  • /proc/sys/vm/dirty_expire_centisecs: 这个参数声明Linux内核写缓冲区里面的脏数据多“旧”了之后,pdflush 进程就开始考虑写到磁盘中去。单位是 1/100秒。对于特别重载的写操作来说,这个值适当缩小也是好的,但也不能缩小太多,因为缩小太多也会导致IO提高太快。缺省是 30000,也就是 30 秒的数据就算旧了,将会刷新磁盘。建议设置为 1500,也就是15秒算旧。设置方法如下:echo 1500 >/proc/sys/vm/dirty_expire_centisecs

4. JAVA File MMAP总结

  • MMAP对于文件读写效率最快,内存映射文件提供了Java有可能达到的最快文件IO操作
  • MMAP最大可以Map小于但是不包含2G大小(2GB - 1B)的内存
  • 读写内存映射文件是操作系统来负责的,因此,即使你的Java程序在写入内存后就挂掉了,只要操作系统工作正常,数据就会写入磁盘。如果电源故障或者主机瘫痪,有可能内存映射文件还没有写入磁盘,意味着可能会丢失一些关键数据。
  • 用于加载文件的内存在Java的堆内存之外,存在于共享内存中,允许两个不同进程访问文件。并且这块内存大小不受也不占用JVM内存(包括Xmx和XXMaxDirectMemory限定的内存)
  • 不要经常调用MappedByteBuffer.force()方法,这个方法强制操作系统将内存中的内容写入硬盘,所以如果你在每次写内存映射文件后都调用force()方法,你就不能真正从内存映射文件中获益,而是跟disk IO差不多。
  • 我们要想解除MMAP只能先把buffer置为null,然后祈祷GC赶紧起作用,实在等不及还可以用System.gc()催促一下GC赶快干活,不过后果是会引发FullGC。
  • 如果被请求的页面不在内存中,内存映射文件会导致缺页中断
  • 我们只能通过进程的Pss来统计文件映射内存的大小,top统计的内存占用不准确
  • 通过合理的pdflush参数调优,我们能进一步优化MMAP的性能