序言我们在平常的工作中大多就会须要处理像下边这样基于Key-Value的数据:
其中UID是数据惟一标示,FIELD[1]是属性值。以QQ用户的Session为例,UID自然是QQ号,FIELD可能是性别、年龄、Session最后更新时间,上一个访问的URL等等。一般这种是要被频繁读写的,所以用C/C++的话一般的做法是使用共享显存界定出一大块显存,之后把它又分成同样大小的小块,用其中的一块或几块来保存一个UID和其所对应的数据,并配合一些索引和分配、回收块的算法等等。并且这种数据只是暂存,为了避免掉电或则硬件受损等引起的数据遗失的问题,还须要用数据库或文件来作为持久化层,这就是我们常用的Cache+DB/File模型。并且这些模型假如用Java实现的话有两个问题:(1)在Cache层,即使我们可以很便捷地用一个HashMap来保存数据,而且这样做的便利性是以消耗大量显存为代价的。由于中所周知Java上面万事万物皆对象,一个int和一个Integer的容积是差好多倍的。我以前做过实验linux常用命令,读入一个有1000w行,每行两个数字的文件[大概170M]到一个HashMap,该HashMap大概为400M。多么可怕的差别?!这使我很艳羡C/C++的可以使用操作系统的共享显存特点,可以很便捷地直接对显存进行操作,那该多爽啊[暂不考虑JNI]。
(2)在持久化方面,假如使用数据库的话,在海量的用户和读写操作面前,其性能将成为系统的主要困局。使用文件的话又要自己写一套对于数据的增删改查操作方式,但是假如写的不通用的话就只能用在某个业务上,非常不经济。那有没有两全其美的办法,既能提供高效且经济[用类似共享显存的形式代替HashMap]的显存读写操作又能兼具便捷的持久化操作呢?JDK1.4引入的Mmap功能就是我们当前的选择。1功能简述作为NIO的一个重要的功能,Mmap方式为我们提供了将文件的部份或全部映射到显存地址空间的能力linux学习论坛,同当这块显存区域被写入数据然后[dirty],操作系统会用一定的算法把这种数据写入到文件中[这一过程java并没有提供API,前面会提及]。这样我们实际上就获得了间接操纵显存的能力,但是显存与文件之间的同步是由操作系统完成的,不用我们额外操劳。也就是说,只要我们把显存数据块规划好[也就是实现一下C语言的SharedMemory功能],剩下的事情交给操作系统苦恼就好了。我们既获得了高效的读写操作能力,又解决了数据的持久化问题,多么理想的功能啊!但必须说明的是mmap虽然不是数据库,不能很便捷地提供事务功能、类似sql句子那样的查找功能,也不具备备份、回滚、迁移的能力,这种都要自己实现。
不过这样恐怕不如置于数据库里放心,所以我们的经验是非常重要的数据还是存数据库,不太重要的、但是又访问量很大、读写操作多且须要持久化功能的数据是最适宜使用mmap功能的。使用Java的mmapAPI代码框架如下所示:(1)RandomAccessFileraf=newRandomAccessFile(File,"rw");(2)FileChannelchannel=raf.getChannel();(3)MappedByteBufferbuff=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_tlen,intprot,intflag,intfiledes,off_toff);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.2Map文件、建立Buffer
这一步骤因为我们一开始早已用STrace验证过了mmap linux 文件,没哪些悬念地用到了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文件,被我找到了上图最后一行的定义:publicnativevoidputByte(java.lang.Objectarg0,longarg1,bytearg2),看来还是要仔细剖析一下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.3Mmap实现小结通过前面的剖析,我们可以总结一下Java的Mmap的实际操作过程:使用mmap系统调用map一个文件的某一部份到显存,在要向上面写数据的时侯就直接把以byte为单位的数据讲到显存相应的地址(byte[]链表可以用一个for循环去写入)起来,并借助操作系统的同步算法实现显存与映射文件之间的数据同步。至此我们早已基本厘清楚了JavaMmap的实现原理,不过在即将使用的过程中我们还遇见了一些奇怪的问题,下边我们就来逐一进行排查。3Mmap实现的Q&A3.1为何Java的Mmap一次只能Map到2G大小的文件到显存?这个问题早已在前面的剖析中提及过了,这儿不再赘言。3.2上面以前提及,将早已Dirty的显存数据同步到文件的操作是操作系统控制的,有没有自动flush的方式?这个问题的答案是有mmap linux 文件,不过使用这个技巧会大幅度增加效率,应当慎用。Java实现的具体剖析如下:由上面的剖析我们晓得,putByte的实现方式是直接把数据讲到共享显存,之后就不管了,所以数据哪些时侯讲到文件是由操作系统算法决定的。理由是只有调用msync()系统调用以后,系统就会立即把显存中的数据写入文件,否则就算是调用munmap()方式解除与map文件的关联也不能使得操作系统将共享显存中的数据写入文件(这个就由系统算法实现了)。不过java的mmapAPI也提供了立即将显存数据刷到文件中的方式,虽然内部就是用了msync系统调用。
3.3为何被映射的文件的时间戳总是不变?莫非数据没有被写入吗?在使用Java的Mmap功能的时侯,我们会发觉一个很奇特的问题,就是被映射的文件[也就是用open系统调用打开的那种文件]的时间戳竟然是不变的?!我们晓得,假如我们对文件使用了write(),或则用vim等编辑器对其进行了编辑,文件的时间戳是要发生改变的。而mmap对文件的写入操作是由操作系统完成的,莫非操作系统写入以后就不改变时间戳吗?于是我写了一个C程序进行了验证,C程序的主要代码如下:#defineFILE_MODE(S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)intmain(intargc,char*argv[]){intmapFile;void*mapAddr;void*filePath="/data/log/storage_file.mmap";void*p;if((mapFile=open(filePath,O_RDWR,"rw"))<0){printf("can'tcreat%sforwritingn",filePath);exit(1);}if((mapAddr=mmap(NULL,1073741824,PROT_READ|PROT_WRITE,MAP_SHARED,mapFile,0))==NULL){exit(1);}p=(void*)mapAddr;*(unsignedchar*)p='y';return0;}改程序执行结果如下:
可以看出在写了一个字节到映射文件以后,该文件的最后更改时间确实变了。而且假如在数据写完以后、return之前先用pause()或则sleep()将进程挪开,再观察文件的最后更改时间都会发觉文件的时间戳是没有变化的。所以我们得到推论:在进程结束以后映射文件的时间戳会弄成进程结束的时间。除此之外,经过实验我们还得到:在调用了munmap系统调用解除映射关系以后,文件的时间戳会弄成调用munmap结束以后的时间,也就是说解除映射关系也会造成时间戳的变化,除上述两种情况之外,文件的时间戳是不会发生变化的。虽然想想也能理解,操作系统也不会傻到每把一个byte讲到文件就改一次时间戳,这样太影响效率。3.4Mmap和共享显存[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文件超过显存大小就要当心了,这时侯系统性能还会狂降的。4杂记我们颇有遗憾地结束了Java的Mmap之旅,最终也没能找到一个简单而确切的方式来查看当前进程的占用了多少化学显存[前提是不引入影响系统性能的组件和带界面的东西,简单的命令行结果最好]。欢迎发短信与我联系,我们共同阐述。
文章评论