注:本文主要记录这次解决内存溢出问题的过程而不是具体问题。
最近在写一个搜索引擎,使用倒排索引结构进行文档检索,保存索引的基本思想是先将倒排列表保存到内存中一个有序Map里(TreeMap),然后当内存占用达到一定阈值的时候将内存中的倒排列表有序写入磁盘,当磁盘已经存在索引时,则将内存中的索引和磁盘中的索引进行合并,生成新的索引,合并过程类似于归并排序。合并内存索引和磁盘索引的代码如下:
public synchronized void merge(){ LogUtil.info("InvertIndex merge start..."); File f=new File(path); //这个文件是原磁盘上的索引文件 //磁盘存在索引,合并磁盘索引和内存索引 if (f.exists()) { String outPath=path+".temp"; File outFile=new File(outPath); TreeMap<String, TreeSet<Long>> ramSnapshot=null; ramSnapshot=ram; //ram保存的是内存索引,这里因为ram可能被其他添加文档的线程修改,因此先存一份快照然后将ram清空,后面实际操作的是快照 ram=new TreeMap<>(); BufferedReader reader=null; PrintWriter writer=null; try { //合并过程,有3个指针:分别指向内存索引的当前位置、原磁盘索引读到的位置、新的磁盘索引写入的位置 Iterator<Entry<String, TreeSet<Long>>> ramIterator= ramSnapshot.entrySet().iterator(); reader=new BufferedReader(new FileReader(f)); writer=new PrintWriter(new BufferedWriter(new FileWriter(outFile))); Entry<String, TreeSet<Long>> entry=ramIterator.hasNext()?ramIterator.next():null; String line=reader.readLine(); while (entry!=null&&line!=null) { long freeRam=Runtime.getRuntime().freeMemory()/1000/1000; System.out.println("freeRam: "+freeRam); String ramWord=entry.getKey(); String diskWord=line.split(separator1)[0]; String out=""; int c=ramWord.compareTo(diskWord); //合并过程,因为是两个有序列表,采用类似归并排序的合并方法,区别在于这里如果遇到倒排词相等的时候,需要合并到一个倒排词(合并两者文档列表) if (c==0) { TreeSet<Long> ramDocIds=entry.getValue(); TreeSet<Long> diskDocIds=this.convertLine2DocIds(line); TreeSet<Long> union=ramDocIds; union.addAll(diskDocIds); out=this.convertIndex2Line(ramWord, union); entry=ramIterator.hasNext()?ramIterator.next():null; line=reader.readLine(); }else if (c<0) { out=this.convertIndex2Line(ramWord, entry.getValue()); entry=ramIterator.hasNext()?ramIterator.next():null; }else { out=this.convertIndex2Line(diskWord, this.convertLine2DocIds(line)); line=reader.readLine(); } writer.println(out); } LogUtil.info("InvertIndex complex merge complete."); while (ramIterator.hasNext()) { entry=ramIterator.next(); String out=this.convertIndex2Line(entry.getKey(), entry.getValue()); writer.println(out); } LogUtil.info("InvertIndex ram merge complete."); while ((line=reader.readLine())!=null) { writer.println(line); } LogUtil.info("InvertIndex disk merge complete."); } catch (Exception e) { LogUtil.err("merge ram index and disk index fail.", e); }finally { try { if (reader!=null) { reader.close(); } if (writer!=null) { writer.close(); } } catch (Exception e2) {LogUtil.err("release resource fail.", e2);} } f.delete(); if (!outFile.renameTo(new File(path))) { throw new RuntimeException("rename temp file fail."); } }else { //磁盘上原本不存在索引,直接将内存索引写入磁盘 //代码略 }
代码的主要思想是维持3个指针:内存索引是一个有序TreeMap,iterator相当于一个虚拟指针;磁盘索引也是有序的,BufferdReader相当于一个虚拟指针;合并后生成的新索引使用BufferdWriter作为指针。然后使用归并排序的思想,比较内存索引词和磁盘索引词的大小,哪个小就将哪个写入新的索引然后将其指针前进一位,如果两个索引词相等,则合并两者的文档列表。
从上面的描述来看以上代码使用的内存应该是O(1)的,因为内存中除了内存索引之外,同一时刻只会从磁盘索引读出一行,但是实际运行的时候,总是在合并时报出GC overhead limit exceeded,这个异常就是说jvm用了大量时间(超过98%)执行GC但是只释放了很少的堆内存(小于2%),换句话说就是OOM的前兆。根据我对程序的内存占用的分析,这种情况是不正常的。
于是给程序添加 “-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=C:/dump” 参数,在程序出现内存溢出异常的时候dump出内存信息。
用eclipse MAT查看占用内存最多的对象:
发现是TreeMap的实例,在这个程序中我用了TreeMap来保存内存索引,而在合并索引的过程中,内存索引的大小应该是不变的,那么为什么会溢出呢?
通过看代码将问题定位在了红色代码处:
这里首先将ramDocIds指向entry.getValue(),然后又将union指向ramDocIds,此时union实际上是直接指向entry.getValue()的,然后union执行了addAll操作。。众所周知,addAll操作是将元素加到对象本身的,这里我的原意是声明一个局部变量保存列表合并结果然后存入新的索引文件,但是无意中却同时修改了内存索引导致内存索引越来越大。
OK,到这里问题就解决了,只需要将
TreeSet<Long> union=ramDocIds;
改为
TreeSet<Long> union=new TreeSet<>(ramDocIds);
即可。
回顾一下这次解决内存溢出问题的过程:
- 分析程序空间复杂度,看内存溢出的是否正常。
- 添加jvm参数,让程序在内存溢出的时候dump出内存快照。
- 使用eclipse MAT分析占用内存最多的对象。
- 在源码中找到相应的对象查找问题。