网络上介绍MappedByteBuffer的文章有很多,发现其中一些文章内容有出入,我想在本文中通过一下实验验证一下哪些是对的。
本文使用的是JDK8。

内存映射文件原理

首先先说一些内存映射文件原理。
一般的将文件内容读到内存需要经历下面几个步骤:

磁盘->系统空间(系统内核访问)->用户空间(用户程序可以直接访问)

文件内容最后到java程序中操作的对象,需要拷贝2次。下面来分析一下这几个步骤,首先从磁盘拷贝到内存,这个是不能少的,因为根据现代计算机结构来说,用户程序是不能直接访问磁盘的,那么从系统空间拷贝数据到用户空间是否可以省略?这就是内存映射要做的事情。当进行内存映射时,首先在内存中开辟一块虚拟地址空间,将系统空间和用户空间都映射到该虚拟空间,之后从磁盘将文件内容直接拷贝到该空间中,接下来用户程序便可以直接访问文件里面的数据了。通过内存映射,减少了一次拷贝操作,加快的文件访问速度。在java里面,内存映射是由MappedByteBuffer完成的。

MappedByteBuffer简介

MappedByteBuffer是一个抽象类,它有两个子类:DirectByteBuffer和DirectByteBufferR。DirectByteBufferR继承自DirectByteBuffer,两者的区别是,DirectByteBufferR是只读的,禁止修改,而DirectByteBuffer是可读可写的。我们可以看一下DirectByteBufferR的源码,发现只要是写入的方法都会抛出异常:

    public ByteBuffer put(byte x) {
        throw new ReadOnlyBufferException();
    }

一般我们可以通过如下代码得到MappedByteBuffer对象:

RandomAccessFile fileAccessor=new RandomAccessFile(path,"r");
FileChannel fileChannel=fileAccessor.getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY,0,102400);

如果指定只对内存映射进行读操作(FileChannel.MapMode.READ_ONLY),那么fileChannel.map()方法得到的是DirectByteBufferR对象,如果是可读写的(FileChannel.MapMode.READ_WRITE),那么创建的对象是DirectByteBuffer。
MappedByteBuffer对映射的大小有限制,最多只能映射2G的空间,如果文件超过2G,那么可以采取分段映射。

关于内存释放问题

通过DirectByteBuffer就可以知道,MappedByteBuffer对象使用是直接内存,我们都知道直接内存是不受java堆内存管理,这就引发了一个问题,如何释放MappedByteBuffer对象占用的直接内存?
网上有的文章介绍说建立内存映射后使用的直接内存永远都无法释放,即使把文件对象关闭也不行,有的文章介绍垃圾回收可以释放内存,有的文章介绍可以通过反射来释放内存。哪到底哪种方式可行?本文将通过实验和源码来说明。
在介绍如何释放内存前,我们先看看它占了多少内存。我写了如下代码:

    private static OperatingSystemMXBean osmxb = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();

    public static void main(String[] args) throws Exception {
        List list=new ArrayList();
        RandomAccessFile fileAccessor=new RandomAccessFile("F:\\test.log","r");
        FileChannel fileChannel=fileAccessor.getChannel();
        printMemory();
        MappedByteBuffer buffer=fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, Integer.MAX_VALUE-1);
        for(int i=0;i<Integer.MAX_VALUE-2;i++){
            buffer.get();
        }
        Thread.sleep(10000);
        printMemory();
        //关闭文件
        fileChannel.close();
        fileAccessor.close();
        Thread.sleep(10000);
        printMemory();
        list.clear();
        System.gc();
        Thread.sleep(10000);
        printMemory();
    }
    private static void printMemory(){
        System.out.println("==========================");
        long totalvirtualMemory = osmxb.getTotalPhysicalMemorySize();
        long freePhysicalMemorySize = osmxb.getFreePhysicalMemorySize();
        System.out.println("总内存="+(totalvirtualMemory/1024/1024)+"MB");
        System.out.println("可用内存="+(freePhysicalMemorySize/1024/1024)+"MB");
        System.out.println("已用内存="+((totalvirtualMemory-freePhysicalMemorySize)/1024/1024)+"MB");
    }

执行结果如下:

==========================
总内存=20382MB
可用内存=9186MB
已用内存=11196MB
==========================
总内存=20382MB
可用内存=9082MB
已用内存=11300MB
==========================
总内存=20382MB
可用内存=9077MB
已用内存=11305MB
==========================
总内存=20382MB
可用内存=9068MB
已用内存=11314MB

从上面的结果可以看到,建立内存映射后,内部并没有发生剧烈的变化,只是可用内存少了将近100M的空间,但是建立的内存映射是将近2G。
这说明了一点,建立内存映射并没有将文件全部拷贝到内存。通过查阅资料(引用【1】的资料)得知,建立内存映射时,系统返回给应用程序一个指针,这个指针是逻辑指针,指向的是用户空间中的地址,也就是将磁盘上的文件映射到内存的虚拟地址,这样应用程序就认为该指针以及它后面的一块连续的空间存放的是文件内容,它便可以使用这个指针访问文件的任意位置。注意这里说的空间和指针都是虚拟的,不是物理内存上真实存在的。
建立内存映射后,当应用程序使用虚拟地址访问文件的某个位置时,操作系统的内存地址管理程序会将虚拟地址转换为真实内存地址,如果发现要访问的数据并不在内存,那么会导致一次缺页中断,将磁盘数据拷贝到内存。
因为每次都是发现数据不在内存时才从磁盘拷贝,所以使用内存映射访问文件并不会发生很大内存的占用情况。

下面来看一下什么时候释放内存空间。
因为占用的内存空间不会发生剧烈变化,所以通过查看内存使用情况,是无法判断空间是否释放的,网络上提供了一种方法是检查文件是否可以删除,如果内存已经完全释放了,那么文件是可以删除的,如果没有释放,那么文件在内存中有引用,是不允许删除的。接下来本文也使用这种方式。
先来看一下实验代码:

    public static void main(String[] args) throws Exception {
        List list=new ArrayList();
        File file=new File("C:\\Users\\XX\\Desktop\\test.txt");
        RandomAccessFile fileAccessor=new RandomAccessFile("C:\\Users\\XX\\Desktop\\test.txt","r");
        FileChannel fileChannel=fileAccessor.getChannel();
        list.add(fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, 100));
        System.out.println(file.delete());//false,删除失败
        //关闭文件
        fileChannel.close();
        fileAccessor.close();
        System.out.println(file.delete());//false,删除失败
        Thread.sleep(10000);
        System.gc();
        System.out.println(file.delete());//false,删除失败
        list.clear();
        System.gc();
        Thread.sleep(10000);
        System.out.println(file.delete());//true,删除成功
    }

从上面的代码可以看出,即使关闭了文件流,只要MappedByteBuffer对象存在,内存映射还是存在。所以要释放映射占用的内存,依赖于GC。
那么我们就没有办法主动释放了吗?
很遗憾,MappedByteBuffer没有提供像关闭文件流之类的close方法,不过它提供了一个Cleaner属性可以帮助关闭映射。网络上文章介绍到这里时,普遍采用反射方法获得这个Cleaner对象。
我查了一下DirectByteBuffer源码,发现cleaner()方法是public的,因此是可以直接调用方法获得对象,那么为什么使用反射呢?

private final Cleaner cleaner;
public Cleaner cleaner() { return cleaner; }

当看到DirectByteBuffer类定义的时候,我发现该类不是public的:

class DirectByteBuffer{}

一切就都明白了,因为这个类不是public的,因此没有办法将MappedByteBuffer对象强转为DirectByteBuffer,因此必须使用反射获得Cleaner对象。
下面使用Cleaner对象试一下效果如何,是否真的如网上介绍的可以关闭内存。

public static void main(String[] args) throws Exception {
    List list=new ArrayList();
    File file=new File("C:\\Users\\XX\\Desktop\\test.txt");
    RandomAccessFile fileAccessor=new RandomAccessFile("C:\\Users\\XX\\Desktop\\test.txt","r");
    FileChannel fileChannel=fileAccessor.getChannel();
    MappedByteBuffer buffer=fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, 100);
    list.add(buffer);
    //关闭文件
    fileChannel.close();
    fileAccessor.close();
    System.out.println(file.delete());//false,删除失败
    Thread.sleep(10000);
    System.gc();
    System.out.println(file.delete());//false,删除失败
    clean(buffer);
    System.out.println(file.delete());//true,删除成功
}
//使用反射方法获得Cleaner对象,然后释放内存
public static void clean(final Object buffer) throws Exception {
    AccessController.doPrivileged(new PrivilegedAction() {
        public Object run() {
            try {
                Method getCleanerMethod = buffer.getClass().getMethod("cleaner",new Class[0]);
                getCleanerMethod.setAccessible(true);
                sun.misc.Cleaner cleaner =(sun.misc.Cleaner)getCleanerMethod.invoke(buffer,new Object[0]);
                cleaner.clean();
            } catch(Exception e) {
                e.printStackTrace();
            }
            return null;
        }
    });
}

执行clean方法之后,文件便可以删除成功,说明使用Cleaner对象是可以成功释放内存的。

【1】MappedByteBuffer以及ByteBufer的底层原理

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐