在此之前,我一直以为这两个类之间是完全不同的东西,因为他们的理念并不相同,一个是在堆外分配内存,一个是使用内存映射(虽然其也是占用了堆外内存),先引用大佬的文章
占小狼:深入浅出MappedByteBuffer
前言
java io操作中通常采用BufferedReader,BufferedInputStream等带缓冲的IO类处理大文件,不过java nio中引入了一种基于MappedByteBuffer操作大文件的方式,其读写性能极高,本文会介绍其性能如此高的内部实现原理。
内存管理
在深入MappedByteBuffer之前,先看看计算机内存管理的几个术语:
- MMC:CPU的内存管理单元。
- 物理内存:即内存条的内存空间。
- 虚拟内存:计算机系统内存管理的一种技术。它使得应用程序认为它拥有4连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
- 页面文件:操作系统反映构建并使用虚拟内存的硬盘空间大小而创建的文件,在windows下,即pagefile.sys文件,其存在意味着物理内存被占满后,将暂时不用的数据移动到硬盘上。
- 缺页中断:当程序试图访问已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由MMC发出的中断。如果操作系统判断此次访问是有效的,则尝试将相关的页从虚拟内存文件中载入物理内存。
为什么会有虚拟内存和物理内存的区别?
如果正在运行的一个进程,它所需的内存是有可能大于内存条容量之和的,如内存条是256M,程序却要创建一个2G的数据区,那么所有数据不可能都加载到内存(物理内存),必然有数据要放到其他介质中(比如硬盘),待进程需要访问那部分数据时,再调度进入物理内存。
什么是虚拟内存地址和物理内存地址?
假设你的计算机是32位,那么它的地址总线是32位的,也就是它可以寻址00xFFFFFFFF(4G)的地址空间,但如果你的计算机只有256M的物理内存0x0x0FFFFFFF(256M),同时你的进程产生了一个不在这256M地址空间中的地址,那么计算机该如何处理呢?回答这个问题前,先说明计算机的内存分页机制。
计算机会对虚拟内存地址空间(32位为4G)进行分页产生页(page),对物理内存地址空间(假设256M)进行分页产生页帧(page frame),页和页帧的大小一样,所以虚拟内存页的个数势必要大于物理内存页帧的个数。在计算机上有一个页表(page table),就是映射虚拟内存页到物理内存页的,更确切的说是页号到页帧号的映射,而且是一对一的映射。
问题来了,虚拟内存页的个数 > 物理内存页帧的个数,岂不是有些虚拟内存页的地址永远没有对应的物理内存地址空间?不是的,操作系统是这样处理的。操作系统有个页面失效(page fault)功能。操作系统找到一个最少使用的页帧,使之失效,并把它写入磁盘,随后把需要访问的页放到页帧中,并修改页表中的映射,保证了所有的页都会被调度。
现在来看看什么是虚拟内存地址和物理内存地址:
- 虚拟内存地址:由页号(与页表中的页号关联)和偏移量(页的小大,即这个页能存多少数据)组成。
举个例子,有一个虚拟地址它的页号是4,偏移量是20,那么他的寻址过程是这样的:首先到页表中找到页号4对应的页帧号(比如为8),如果页不在内存中,则用失效机制调入页,接着把页帧号和偏移量传给MMC组成一个物理上真正存在的地址,最后就是访问物理内存的数据了。
MappedByteBuffer是什么
从继承结构上看,MappedByteBuffer继承自ByteBuffer,内部维护了一个逻辑地址address。
示例
通过MappedByteBuffer读取文件
public class MappedByteBufferTest {
public static void main(String[] args) {
File file = new File("D://data.txt");
long len = file.length();
byte[] ds = new byte[(int) len];
try {
MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r")
.getChannel()
.map(FileChannel.MapMode.READ_ONLY, 0, len);
for (int offset = 0; offset < len; offset++) {
byte b = mappedByteBuffer.get();
ds[offset] = b;
}
Scanner scan = new Scanner(new ByteArrayInputStream(ds)).useDelimiter(" ");
while (scan.hasNext()) {
System.out.print(scan.next() + " ");
}
} catch (IOException e) {}
}
}
map过程
FileChannel提供了map方法把文件映射到虚拟内存,通常情况可以映射整个文件,如果文件比较大,可以进行分段映射。
FileChannel中的几个变量:
- MapMode mode:内存映像文件访问的方式,共三种:
1. MapMode.READ_ONLY:只读,试图修改得到的缓冲区将导致抛出异常。
2. MapMode.READ_WRITE:读/写,对得到的缓冲区的更改最终将写入文件;但该更改对映射到同一文件的其他程序不一定是可见的。
3. MapMode.PRIVATE:私用,可读可写,但是修改的内容不会写入文件,只是buffer自身的改变,这种能力称之为”copy on write”。
4. position:文件映射时的起始位置。
5. allocationGranularity:Memory allocation size for mapping buffers,通过native函数initIDs初始化。
接下去通过分析源码,了解一下map过程的内部实现。
1.通过RandomAccessFile获取FileChannel。
public final FileChannel getChannel() {
synchronized (this) {
if (channel == null) {
channel = FileChannelImpl.open(fd, path, true, rw, this);
}
return channel;
}
}
上述实现可以看出,由于synchronized ,只有一个线程能够初始化FileChannel。
2.通过FileChannel.map方法,把文件映射到虚拟内存,并返回逻辑地址address,实现如下:
只保留了核心代码
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize,
addr + pagePosition,
mfd,
um);
} else {
return Util.newMappedByteBuffer(isize,
addr + pagePosition,
mfd,
um);
}
}
上述代码可以看出,最终map通过native函数map0完成文件的映射工作。
- 如果第一次文件映射导致OOM,则手动触发垃圾回收,休眠100ms后再次尝试映射,如果失败,则抛出异常。
- 通过newMappedByteBuffer方法初始化MappedByteBuffer实例,不过其最终返回的是DirectByteBuffer的实例,实现如下:
static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) {
MappedByteBuffer dbb;
if (directByteBufferConstructor == null)
initDBBConstructor();
dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
new Object[] { new Integer(size),
new Long(addr),
fd,
unmapper }
return dbb;
}
// 访问权限
private static void initDBBConstructor() {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
Class<?> cl = Class.forName("java.nio.DirectByteBuffer");
Constructor<?> ctor = cl.getDeclaredConstructor(
new Class<?>[] { int.class,
long.class,
FileDescriptor.class,
Runnable.class });
ctor.setAccessible(true);
directByteBufferConstructor = ctor;
}});
}
由于FileChannelImpl和DirectByteBuffer不在同一个包中,所以有权限访问问题,通过AccessController类获取DirectByteBuffer的构造器进行实例化。
DirectByteBuffer是MappedByteBuffer的一个子类,其实现了对内存的直接操作。
get过程
MappedByteBuffer的get方法最终通过DirectByteBuffer.get方法实现的。
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
public byte get(int i) {
return ((unsafe.getByte(ix(checkIndex(i)))));
}
private long ix(int i) {
return address + (i << 0);
}
map0()函数返回一个地址address,这样就无需调用read或write方法对文件进行读写,通过address就能够操作文件。底层采用unsafe.getByte方法,通过(address + 偏移量)获取指定内存的数据。
- 第一次访问address所指向的内存区域,导致缺页中断,中断响应函数会在交换区中查找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则从硬盘上将文件指定页读取到物理内存中(非jvm堆内存)。
- 如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘的虚拟内存中。
性能分析
从代码层面上看,从硬盘上将文件读入内存,都要经过文件系统进行数据拷贝,并且数据拷贝操作是由文件系统和硬件驱动实现的,理论上来说,拷贝数据的效率是一样的。
但是通过内存映射的方法访问硬盘上的文件,效率要比read和write系统调用高,这是为什么?
首先我们要知道,MappedByteBuffer底层使用的是mmap()
这个函数,其会在内存中开启一个空间,这个空间可以算是属于用户空间,重点不是这个。这块空间是使用虚拟地址空间管理的,内核空间的程序和用户空间的程序都可以读写该空间的内容。
- read()是系统调用,首先将文件从硬盘拷贝到内核空间的一个缓冲区,再将这些数据拷贝到用户空间,实际上进行了两次数据拷贝;
- mmap()也是系统调用,当缺页中断发生时,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不会经过内核空间。
- write数据时,用户空间的程序将数据写入到这映射的这块区域,内核会自动将其同步到磁盘上,也不会经过内核空间,
所以,采用内存映射的读写效率要比传统的read/write性能高。
总结
- MappedByteBuffer使用虚拟内存,因此分配(map)的内存大小不受JVM的-Xmx参数限制,但是也是有大小限制的。
- 如果当文件超出1.5G限制时,可以通过position参数重新map文件后面的内容。
- MappedByteBuffer在处理大文件时的确性能很高,但也存在一些问题,如内存占用、文件关闭不确定,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。
javadoc中也提到:A mapped byte buffer and the file mapping that it represents remain valid until the buffer itself is garbage-collected.*
看起来一切正常,直到我发现,MappedByteBuffer是一个抽象类,而且DirectByteBuffer还继承自MappedByteBuffer,但是这两个类的理念明明完全不一样,一个是利用缺页中断这种机制来实现,可以直接将文件拷贝到内存中而不用经过内核的缓冲区,而另外一个就是在堆外申请了内存,为什么会形成这样的继承关系
我的想法是,MappedByteBuffer其核心原理还是缺页中断的机制,但是其内部没有实现操作内存的方法,map0()
方法返回的是映射的内存的首地址,然后他利用这个首地址去创建了DirectByteBuffer
这种类型的对象,利用下面的第一个构造函数
// 操作的内存的地址是外部传入的
DirectByteBuffer(long addr, int cap, Object ob) {
super(-1, 0, cap, cap);
address = addr;
cleaner = null;
att = ob;
}
// 这个构造函数中,addr是通过unsafe.allocateMemory(size)得来的
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
ByteBuffer.allocateDirect(1024)来产生的ByteBuffer
public static ByteBuffer allocateDirect(int capacity) {
// 直接创建了DirectByteBuffer这个类的实例,利用DirectByteBuffer(int cap)这个构造函数
// 其内部会利用Unsafe.allocateMemory()来分配堆外内存,返回内存的首地址
// DirectByteBuffer操作的该地址指向的内存
return new DirectByteBuffer(capacity);
}
channel.map(FileChannel.MapMode.READ_ONLY,0,10)
这种方式产生的对象实例也是DirectByteBuffer类型
的,不过他没有使用的不是DirectByteBuffer(int cap)
,这个构造函数,而是使用的DirectByteBuffer(long addr, int cap, Object ob)
,这个明显内存地址是传递进去的,这就是他们最大的不同,即内存的分配方式不同。MappedByteBuffer和DirectByteBuffer的对内存操作的API其实是可以共用的,只是内存的分配方式有所不同而已。
上述两种方式创建的其实都是DirectByteBuffer
类型的实例