前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java文件映射(mmap)全接触

Java文件映射(mmap)全接触

作者头像
用户1263954
发布2018-01-30 14:53:40
6.4K0
发布2018-01-30 14:53:40
举报

前言

我们在平时的工作中大多都会需要处理像下面这样基于Key-Value的数据:

其中UID是数据唯一标识,FIELD[1]是属性值。以QQ用户的Session为例,UID自然是QQ号,FIELD可能是性别、年龄、Session最后更新时间,上一个访问的URL等等。通常这些是要被频繁读写的,所以用C/C++的话通常的做法是使用共享内存划分出一大块内存,然后把它又分成同样大小的小块,用其中的一块或几块来保存一个UID和其所对应的数据,并配合一些索引和分配、回收块的算法等等。但是这些数据只是暂存,为了防止掉电或者硬件损坏等导致的数据丢失的问题,还需要用数据库或文件来作为持久化层,这就是我们常用的Cache+DB/File模型。但是这种模型如果用Java实现的话有两个问题:(1)在Cache层,虽然我们可以很方便地用一个HashMap<QQNum, UserSession>来保存数据,但是这样做的便利性是以消耗大量内存为代价的。因为中所周知Java里面万事万物皆对象,一个int和一个Integer的体积是差很多倍的。我曾经做过实验,读入一个有1000w行,每行两个数字的文件[大约170M]到一个HashMap<Integer,Integer>,该HashMap大约为400M。多么可怕的差距?!这使我很羡慕C/C++的可以使用操作系统的共享内存特性,可以很方便地直接对内存进行操作,那该多爽啊[暂不考虑JNI]。(2)在持久化方面,如果使用数据库的话,在海量的用户和读写操作面前,其性能将成为系统的主要瓶颈。使用文件的话又要自己写一套对于数据的增删改查操作方法,而且如果写的不通用的话就只能用在某个业务上,十分不经济。那有没有两全其美的办法,既能提供高效且经济[用类似共享内存的方式取代HashMap]的内存读写操作又能兼顾方便的持久化操作呢?JDK1.4引入的Mmap功能就是我们当前的选择。

1 功能简析

作为NIO的一个重要的功能,Mmap方法为我们提供了将文件的部分或全部映射到内存地址空间的能力,同当这块内存区域被写入数据之后[dirty],操作系统会用一定的算法把这些数据写入到文件中[这一过程java并没有提供API,后面会提到]。这样我们实际上就获得了间接操纵内存的能力,而且内存与文件之间的同步是由操作系统完成的,不用我们额外操心。也就是说,只要我们把内存数据块规划好[也就是实现一下C语言的SharedMemory功能],剩下的事情交给操作系统烦恼就好了。我们既获得了高效的读写操作能力,又解决了数据的持久化问题,多么理想的功能啊!但必须说明的是mmap毕竟不是数据库,不能很方便地提供事务功能、类似sql语句那样的查找功能,也不具备备份、回滚、迁移的能力,这些都要自己实现。不过这样显然不如放在数据库里放心,所以我们的经验是特别重要的数据还是存数据库,不太重要的、但是又访问量很大、读写操作多且需要持久化功能的数据是最适合使用mmap功能的。使用Java的mmapAPI代码框架如下所示: (1)RandomAccessFile raf = new RandomAccessFile (File, "rw"); (2)FileChannel channel = raf.getChannel(); (3)MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_WRITE,startAddr,SIZE); (4)buf.put((byte)255); (5)buf.write(byte[] data) 其中最重要的就是那个buff,它是文件在内存中映射的标的物,通过对buff的read/write我们就可以间接实现对于文件的读写操作,当然写操作是操作系统帮忙完成的。 虽然mmap功能是如此的强大,但凡事都有局限,java的mmap瓶颈在哪里?使用mmap会遇到哪些问题和限制?要回到这些问题,还是需要先从mmap的实现入手。

2 实现原理

研究实现原理的最好方式就是阅读源码,由于SUN(或许不应该这样叫了?)开放了JDK源码,为我们的研究敞开了大门,这里我采用的是linux版的JDK1.6_u13的源码。

2.1 目标和方法

在查看Java源码之前,我首先google了一下mmap,结果发现mmap在linux下是一个系统调用: void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off ); man了一下发现其功能描述和JavaAPI上说的差不多,难道JDK底层就是用这个东东实现的?马上动手写个程序然后STrace一下看看是不是使用了这个系统调用。这个测试程序应用的就是上面提到的那个程序框架,map了1G的文件,然后每次一个字节地往里面写数据,由于很简单这里就不贴出来了。结果如下:

为简便起见中间的内容就忽略掉了,不过

我们可以很清楚地看到mmap的操作就是打开[使用open系统调用]文件,然后mmap之,之后的操作都是对内存地址的直接操作,而操作系统负责把剩下的事情搞定了。于是可以大胆预言,java的实现是用JNI包装了的mmap()系统调用。其功能也应该和下图所示的内容保持一致。

《APUE》中关于Mmap()系统调用的示意图 在经过上面的分析之后,我们已经有了初步的目标,那就是找到JavaMmap的C源码,看其使用了哪些系统调用。这样我们就可以更好地了解和控制JavaMmap的行为。

2.2 询源之旅

还是以下面这个代码框架为例,注意这里除了map文件的动作之外就只有写操作,因为mmap的读方法是读内存的,我们已经很清楚,所以这里我们只关心写操作。下面我们来一一分析和拆解这些类所使用到的系统调用。

2.2.1 打开文件

打开文件和建立FileChannel这两步应该只有一个open()系统调用。

2.2.2 Map文件、建立Buffer

这一步骤由于我们一开始已经用STrace验证过了,没什么悬念地用到了mmap系统调用。但值得注意的是JDK只提供了建立文件/内存映射的方法,而没有给出解除映射关系的API。在FileChannelImpl.java中我们可以看到,解除映射的方法[在Unmapper中定义]是在创建MappedByteBuffer时嵌入到这个类里面的,在buffer被GC回收之前会调用Unmapper的unmap方法来解除文件到内存的映射关系。也就是说我们要想解除映射只能先把buffer置为null,然后祈祷GC赶紧起作用,实在等不及还可以用System.gc()催促一下GC赶快干活,不过后果是会引发FullGC。虽然要求开放解除映射关系的呼声很高,官方的回答是开放了会有这样那样的问题,总之是JDK7之前暂不会开放。其实这样的事情我们应该习惯才是,既然对象可以只new不delete,当然也可以map完不unmap啦,这件事只能说明要么开放unmap方法真的在技术上有困难,要么就是Sun对JVM太有自信[小玩笑,别当真]。不过我们还是发现了一个隐藏的系统调用:munmap();它用了解除映射关系,除此之外还有一些副作用,我们后面涉及到的时候再说。

2.2.3 对映射内存的写操作

但是由于Unsafe.java类所对应的unsafe.cpp的源码比较奇怪,里面并不是标准的C/C++源码,而是包含了很多宏和标记,同时里面也没有一个叫putByte()的方法(我们知道,JNI方法和其Java方法名字是有一定的命名规则的),看来代码是在编译过程中才会被替换成相应的函数定义,为此我还特意编译了一下jdk6_u13的Hotspot部分的源码(因为Unsafe.cpp是在Hotspot/src/share/vm/prems/里面),然后反编译生成的unsafe.o文件,被我找到了上图最后一行的定义:public native void putByte(java.lang.Object arg0, long arg1, byte arg2),看来还是要仔细分析一下unsafe.c文件找到相应的putByte定义:

unsafe.c的源码我们分两部分看,左边是我找到的我认为是putByte的实现:就是那个Declare_GetSetNative(Type),这个type可以是Byte,Short,Int等Java的基本类型,其作用是把一个基本类型的数据写到相应的内存地址中去,应该符合我们的要求,它的定义在右边。代码看起来很简单,就是定位到addr个内存地址上,然后把一个java类型的数据写到那个地址上。注意,虽然地址参数addr是一个long,但是addr_from_java把这个long变成了int,所以即便是在64位机器上也只能用使用2G这么大的地址空间,这也是Java无法一次Map到超过2G文件的原因。

2.3 Mmap实现小结

通过上面的分析,我们可以总结一下Java的Mmap的实际操作过程:使用mmap系统调用map一个文件的某一部分到内存,在要向里面写数据的时候就直接把以byte为单位的数据写到内存相应的地址(byte[]数组可以用一个for循环去写入)上去,并依靠操作系统的同步算法实现内存与映射文件之间的数据同步。至此我们已经基本搞清楚了JavaMmap的实现原理,不过在正式使用的过程中我们还遇到了一些奇怪的问题,下面我们就来逐一进行排查。

3 Mmap实现的Q&A

3.1 为什么Java的Mmap一次只能Map到2G大小的文件到内存?

这个问题已经在上面的分析中提到过了,这里不再赘述。

3.2 前面曾经提到,将已经Dirty的内存数据同步到文件的操作是操作系统控制的,有没有手动flush的方法?

这个问题的答案是有,不过使用这个方法会大幅度降低效率,应该慎用。Java实现的具体分析如下: 由前面的分析我们知道,putByte的实现方法是直接把数据写到共享内存,然后就不管了,所以数据什么时候写到文件是由操作系统算法决定的。理由是只有调用msync()系统调用之后,系统才会立刻把内存中的数据写入文件,否则即使是调用munmap()方法解除与map文件的关联也不能促使操作系统将共享内存中的数据写入文件(这个就由系统算法实现了)。不过java的mmapAPI也提供了立刻将内存数据刷到文件中的方法,其实内部就是用了msync系统调用。

3.3 为什么被映射的文件的时间戳总是不变?难道数据没有被写入吗?

在使用Java的Mmap功能的时候,我们会发现一个很诡异的问题,就是被映射的文件[也就是用open系统调用打开的那个文件]的时间戳居然是不变的?!我们知道,如果我们对文件使用了write(),或者用vim等编辑器对其进行了编辑,文件的时间戳是要发生改变的。而mmap对文件的写入操作是由操作系统完成的,难道操作系统写入之后就不改变时间戳吗?于是我写了一个C程序进行了验证,C程序的主要代码如下:#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) int main(int argc, char *argv[]) { int mapFile; void* mapAddr; void *filePath = "/data/log/storage_file.mmap"; void *p; if ((mapFile = open(filePath, O_RDWR, "rw")) < 0){ printf("can't creat %s for writing\n", filePath); exit(1); } if ((mapAddr = mmap(NULL, 1073741824, PROT_READ|PROT_WRITE, MAP_SHARED, mapFile, 0)) == NULL) { exit(1); } p=(void*)mapAddr; *(unsigned char*)p='y'; return 0; }

改程序执行结果如下:

可以看出在写了一个字节到映射文件之后,该文件的最后修改时间确实变了。但是如果在数据写完之后、return之前先用pause()或者sleep()将进程停住,再观察文件的最后修改时间就会发现文件的时间戳是没有变化的。所以我们得到结论:在进程结束之后映射文件的时间戳会变成进程结束的时间。除此之外,经过实验我们还得到:在调用了munmap系统调用解除映射关系之后,文件的时间戳会变成调用munmap结束之后的时间,也就是说解除映射关系也会导致时间戳的变化,除上述两种情况之外,文件的时间戳是不会发生变化的。其实想想也能理解,操作系统也不会傻到每把一个byte写到文件就改一次时间戳,这样太影响效率。

3.4 Mmap和共享内存[SharedMemory]有何异同?

从man上来看,mmap似乎和共享内存[后面简称SM]有很多相同之处。相同点是共享的概念,mmap和SM都可以让操作系统划分出一块内存来共多个进程共享使用,也就是说多个进程可以对同一块内存进行读写操作,某一个进程的写入操作的结果可以被其他进程看到。不同点是mmap需要把内容写回到文件,所以还需要与文件打交道;而SM则是完全的内存操作,不涉及文件IO,效率上可能会好很多。还有就是SM使用的系统调用是shmget和shmctl。普通的JDK并没有提供SM的几口,而收费的JavaRTS提供了SM接口,也是通过JNI实现的。

值得注意的是,无论是mmap还是SM,他们初始所向操作系统申请的那一块内存并不是一开始就能全额得到,操作系统会根据当前的内存使用状况为期分配一定大小的内存,而如果系统内存不足的话,操作系统还可以选择把mmap或SM中不常用的内存页换出来腾出地方给其他进程用。

3.5 为什么在使用了mmap之后,我用TOP/PS看到进程RSS越来越大?

这其实是一个很麻烦的问题,因为在一般运维监控的时候,我们都会很自然地选择Top或者PS看一下进程当前实用的物理内存是多少,以防进程内存占用过高导致系统崩溃。虽然TOP/PS的结果不是十分精确,但是大部分时候还是够用的。然而在使用了java的mmap之后我们发现,top和ps命令居然失效了。在我们的程序中map了一个3G大小的文件[这个文件自此之后一直没有变大],可是过几天之后[当然程序里面还有一些业务逻辑]却发现TOP命令的RSS字段居然变成了19G,更夸张的是过几天之后RSS的值仍然在不断增长,这已经远远超过了内存的实际大小,但此时系统的IO并不高,效率没有降低,也根本没用到swap。这就是说TOP/PS的结果是有问题的,此时的RSS已经不能正确标示当前进程所占用的物理内存了,而导致这个问题发生的原因又是什么呢?

为此我查看了一下/proc/PID/smaps文件,因为这里面描述了进程地址空间的使用情况,我得到的结果如下:

看到没?同一个文件被map了几次,smap文件中就有多少条记录项。于是我们可以大胆猜想,TOP/PS命令是否就是把smaps文件的中RSS做了一个简单的加法输出出来?后来经我们验证果然是这样的!也就是说文件被统一进程map的次数越多,smaps里面的对应项也就越多,所以TOP/PS的RSS字段值也就越大。 既然TOP/PS的值已经不可靠了,那么应该怎样获取使用了mmap的进程当前所占用的物理内存呢?google了一下排名最靠前的是一个叫做exmap的工具,不过那个工具不仅自己要重新编译,还需要重新编译内核[因为可能操作系统禁用了Module载入],最不能接受的是还是图形界面的,还有可能造成性能上的不稳定,这些限制使其在开发机上部署和使用变得不现实。后来又尝试使用mincore()系统调用,该系统调用的运作过程是,按照mmap的大小计算出一共需要多少内存页[例如每页4K],我们记为len,然后创建一个char* vector[len]数组,char[i]=0表示该页没在内存,而等于1的话表示该页在内存中。最后,该系统调用扫描一遍文件映射到内存中的部分,将结果写入vector数组中,我们可以根据其中1的个数来大概判断map文件中有多少物理页在内存中,不过遗憾的是这个系统调用貌似有点问题。举例来说,一个进程[P]映射了一个1G大小的文件,然后向里面写数据,这时候用mincore看到的结果是A,但是当P结束之后,再用mincore查看发现结果还是A,隔了一段时间之后再看结果还是A而不是0,即便是使用了munmap也没用。这就有些让人摸不着头脑。难道在内存空间够用的情况下操作系统把这部分内存一直保留着?但是当我把map文件删除之后用一个空文件替代再看结果就是0,再运行上面的程序结果和以前一样。看样子这个系统调用的结果不是很理想。所以现在也没有什么特别好的办法来解决这个问题。不过好在可以通过监控map文件大小来间接对mmap进行监控,如果map文件超过内存大小就要小心了,这时候系统性能就会狂降的。

(完)

出处:https://site.douban.com/161134/widget/articles/8506170/article/18487141/

版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-07-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 IT技术精选文摘 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档