先说些题外话,Java的内存管理一直是让人又爱又恨玩意。学生时代写Java程序主要考虑的只是实现算法、完成功能,偶尔碰到OutofMemory也是递归搞成死循环。什么垃圾回收、内存管理统统交给JVM去烦恼。上班之后再用Java发现完全不是那么回事,大访问量的情况下如何减少fullgc和停顿时间,内存管理无法由程序员控制和干预、甚至无法获知究竟哪个部分用了多少内存有时着实让人有些抓狂。各位可能无法想象当我们撞大运般的用jdk6_u12代替之前的某个版本解决了困扰多时的内存在高峰期无法正常回收的问题(后面会提到)时,那种如释重负却又心有不甘的感觉。因为存在对于内存管理丝毫插不上手的无力感,从那时起,我就一直在寻找一种把数据丢到内存里但又能够对其有所控制的方法,mmap算是不错的选择。
其实在日常工作中,很多时候最麻烦的问题是要把数据存在哪里和如何对这些数据进行存取和管理。保存数据也无外乎数据库、文件和内存这几种方式,我这里涉及到的情况是用户登陆时把很多数据(比如好友列表、在线信息等)从各个地方拉过来丢到内存里,当用户logout之后再把这些东西清理出去,其实就是一个临时的cache。目前的做法是弄几个HashMap,再起几个清理线程每隔一段时间遍历一遍把map里过期数据清理出去。看起来貌似没啥问题,事实上之前跑了很长时间也没出任何问题。但倒霉的是我接手不久之后,随着用户数量的增长,出现高峰期java进程占用内存数量越来越高、直到把所有内存都吃干净然后挂掉的现象。但是我用jprofiler等工具经过仔细分析验证之后发现即便是达到目前设计容量的上限,内存也还会有很大富余,那问题就出在JVM没有很好地进行内存回收。虽然升级JDK解决了问题,但是引发了我其他的一些思考。(1)对于内存管理引发的问题,如果jvm没帮我们搞定怎么办?虽然sun的更新还算快,我们也许不会每次都如此幸运(2)java对象占用的内存中远比我们通过看到的东西计算出来的要大很多,例如一个含有800万个key/value对的HashMap,其key和value都是int(注意不是Integer),在内存中大概就要340M。所以有些结构化数据,例如每个用户的年龄、性别、生日等信息(按ID进行索引)似乎用共享内存的方法开辟出一块缓冲区来管理更合适。(3)同样是这些信息,虽然只是临时的cache,但是我希望Server重启之后这些信息不要丢失,因为重新load需要不少时间和代价。
可能有不少人有这种需求,所以sun在jdk1.4里提供了共享内存和读盘文件进行映射的mmap方法,有了它上述问题几乎都可以得到解决,如果有更深层次的需要,比如数据格式定义和抽象、备份、扩容等需求的话也可以自己写代码进行扩展。写mmap的代码应该不难,这里就不去讨论了。这里我想关注一下别的问题,那就是我们知道unix/linux中的mmap是一种进程间通信的方式之一,所以多个进程map到同一个文件的话在内存中应该只有一份镜像数据。java的的底层实现也应该是这样,但是网上和一些书中只有在多线程情况下的讨论,没亲自验证过毕竟不太放心,所以我就想办法验证一下。这里值得一提的是,linux的ps、top等工具是没办法进行验证的,他们看到的都是程序的虚拟内存空间的大小[VIRT列],如果用同样的程序map同一个文件,那么看到的VIRT值是一样的。同理用more /proc/进程ID/maps也是一样:
可以看到两个进程map同一个文件之后所映射到的虚拟地址空间是一样的,但是用同样的程序换个同样大小的文件还是会映射到这个地址区间去,可见用这个方法是不可靠的。那么还是得用最原始的方法--写个程序验证一下。验证的原理是mmap既然是进程间通信的方式之一,那么如果一个进程先往里写而另一个进程读的话,那么读的的进程就应该能够读到写进程写入的信息,这样就能印证这个文件的映像在内存中只有一份copy了。我的程序如下:
程序的输出如下:
看样子,java的mmap没让我失望,确实是一个文件会map到内存相同的地址空间去,即该文件在内存中的映射只有一份。
PS:用/proc/进程ID/maps查看进程内存信息的时候还有点以外收获,当我用"java -cp . 主类名"这种什么都不加的方式启动一个应用程序的时候用TOP查看VIRT总是显示1G左右的大小,用/proc/进程ID/maps就可以比较清楚地看到可能是java虚拟机启动时候分配给应用程序的共享内存,可能是怕我们不够用吧^_^.