java大文本文件分析处理效率提升方案

背景介绍

我们做的聆客系统有个功能需要分析用户上传的格式文本文件获取符合条件的内容保存到数据库里面,第一个版本的方案就是直接将文本文件通过文件流一行一行读取到程序里面并处理成方便后续筛选内容的数据格式,这对于几十K或者几M的小文件来说,这个方案没有什么问题,但是当用户上传的文件大小达到十几M或者几十M甚至几百M是,处理文件的速度就急剧下降甚至会让程序出现内存溢出然后终止线程,这种情况是绝对不能授受的,所以必须出第二个方案来解决这些问题!

问题分析

在分析问题前先上第一个方案的部分代码:

方案一

//先把上传的聊天记录的文件里面的内容按消息对象进行分割,然后将每个消息对象的每行内容单独存储为一个List对象的元素,方便逐行分析
BufferedReader reader=new BufferedReader(new InputStreamReader(file.getFileInputStream(),"UTF-8"));
String line = null;
List<List<String>> chatArrs = new ArrayList<List<String>>();
List<String> chatContent = new ArrayList<String>();
while ((line = reader.readLine()) != null) {
    if (Strings.contains(line, "消息分组")) {
        if (chatContent.size() != 0) {
            chatContent.remove(chatContent.size() - 1);
            chatArrs.add(chatContent);
        }
        chatContent = new ArrayList<String>();
    }
    chatContent.add(line + "\n");
}
chatArrs.add(chatContent);
chatArrs.remove(0);
//将消息对象做为KEY将消息按消息对象进行分类处理
Map<String, List<String>> objectToContent = new HashMap<String, List<String>>();
for (List<String> chat : chatArrs) {
    String friendName = chat.get(2).replace("消息对象:","").replace("\n", "");
    for (int i = 0; i < 5; i++) {
        chat.remove(0);
    }
    objectToContent.put(friendName, chat);
}

通过上面的代码可以看到文件内容被全部读取到内存里面分组保存起来后再对文件内容做相应处理,直到整个函数执行完成,这些内容才有可能被垃圾回收,内存占用不仅大,而且占用时间也很长。经过我的测试发现这种方法处理文件基本上会占用文件大小三倍的内存空间,如果同求请求这个功能的用户过多就会使程序崩溃!既然将文件内容全部读入系统会导致内存溢出,那就要找到一种方法即不用将文件全部读入系统又可以随时获取文件指定位置内容的方法才能行了,幸好!我们的JDK已经给我们提供了相应的类可以实现这样的效果。

RandomAccessFile和MappedByteBuffer

这两个类我就不做介绍了,直接引用百度来的介绍比我自己介绍的更好!

   RandomAccessFile是不属于InputStream和OutputStream类系的。实际上,除了实现DataInput和DataOutput接口之外(DataInputStream和DataOutputStream也实现了这两个接口),它和这两个类系毫不相干,甚至都没有用InputStream和OutputStream已经准备好的功能;它是一个完全独立的类,所有方法(绝大多数都只属于它自己)都是从零开始写的。这可能是因为RandomAccessFile能在文件里面前后移动,所以它的行为与其它的I/O类有些根本性的不同。总而言之,它是一个直接继承Object的,独立的类。

基本上,RandomAccessFile的工作方式是,把DataInputStream和DataOutputStream粘起来,再加上它自己的一些方法,比如定位用的getFilePointer( ),在文件里移动用的seek( ),以及判断文件大小的length( )。此外,它的构造函数还要一个表示以只读方式(“r”),还是以读写方式(“rw”)打开文件的参数 (和C的fopen( )一模一样)。它不支持只写文件,从这一点上看,假如RandomAccessFile继承了DataInputStream,它也许会干得更好。

只有RandomAccessFile才有seek方法,而这个方法也只适用于文件。BufferedInputStream有一个mark( )方法,你可以用它来设定标记(把结果保存在一个内部变量里),然后再调用reset( )返回这个位置,但是它的功能太弱了,而且也不怎么实用。 —————摘抄自百度百科

关于MappedByteBuffer类的具体介绍可以参考这篇文章《深入浅出MappedByteBuffer》

为什么要两个类结合起来用?

相信很多人看了RandomAccessFile类的介绍以后觉得只在这一个类就完全够用了,为什么还要使用MappedByteBuffer类呢?其实原因也很简单,因为RandomAccessFile类读取文件的时候是一个字节一个字节从IO里面读的,效率不高,MappedByteBuffer就相当于给RandomAccessFile增加了缓存功能,可以提高RandomAccessFile的读取效率。

如何做?

重点来了,知道了这两个类以后应该如何做呢?我先讲讲大体的思路,然后再上代码吧。 思路是这样的:因为MappedByteBuffer对象读取文件也是按字节读的,所以我们先把文件从头到尾读一遍,得到所有行的开始位置和结束位置,这就相当于我们已经得到每一行的数据了,再对遍历代表每行的开始位置和结束位置的数据获取到相应行的内容,再根据每行内容分析出每个分组内容的开始和结束位置并按分组保存,最后再获取到我们需要的分组从分组内容中获取到每行再次分析是否是我们需要的内容,如果是就存起来。看完这段描述是不是还是不懂?没关系!接下来我们看代码

方案二

public class BigFileFastReader {
    private Log log = LogFactory.get(this.getClass());
    private MappedByteBuffer buffer;
    private RandomAccessFile raf;


    public BigFileFastReader(String filePath) throws IOException {
        raf=new RandomAccessFile(filePath,"r");
        buffer=raf.getChannel().map(FileChannel.MapMode.READ_ONLY,0,raf.length());
    }

    public BigFileFastReader(File file) throws IOException {
        raf = new RandomAccessFile(file, "r");
        buffer = raf.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, raf.length());
    }

    /**
     * 使用int数组保存文件指针位置是最有效率和最节省内存的方法,每两个元素表示某一行的开始(前一个元素)和结尾(后一个元素)
     * 数组下标和行数的关系为:
     * 行开始:行数*2
     * 行结束:行数*2+1
     * e.g. lines[0] 保存第一行的开始 lines[1]保存第一行的结束
     *
     * @param start
     * @param end
     * @return
     * @throws Exception
     */
    public int[] getLinesStartAndEndPosArr(int start, int end) throws Exception {
        int[] lines = new int[10240000];//数组初始大小约为10M,这是经过测试效率较高且比较节省内存的大小
        int lineStartIndex = start;  //临时存储每行开始的位置
        int lineNum = 0; //行数
        for (int offset = start; offset <= end; offset++) {
            int c = buffer.get(offset);
            if (c == '\n' || (c == '\r' && buffer.get(offset + 1) != '\n')) {//遇到换行符时认为已经到一行的结尾
                lines = writeLinesStartEndPos(lines, lineNum, lineStartIndex, offset);
                lineStartIndex = offset + 1;
                lineNum++;
            }
        }
        if (lineStartIndex <= end) {
            lines = writeLinesStartEndPos(lines, lineNum, lineStartIndex, end);
            lineNum++;
        }
        lines = Arrays.copyOf(lines, lineNum << 1);
        return lines;
    }

    /**
     * 向保存行开始和结束位置的数据中写入对应行的开始和结束位置
     *
     * @param lines
     * @param lineNum
     * @param startPos
     * @param endPos
     */
    private int[] writeLinesStartEndPos(int[] lines, int lineNum, int startPos, int endPos) {
        int startIndex = lineNum << 1;  //通过位移的方式计算此行开始和结束对应的数组下标是最快的
        int endIndex = (lineNum << 1) + 1;
        if (endIndex >= lines.length) {
            lines = ensureCapacity(lines);
        }
        lines[startIndex] = startPos;
        lines[endIndex] = endPos;
        return lines;
    }

    /**
     * 分析每行的内容,以【消息对象:】作为一个对象标识,这个标识到下个标识中间的内容则是和该对象聊天的内容,聊天内容的开始位置以及结束位置和该对象关联起来
     *
     * @return
     * @throws Exception
     */
    public Map<String, int[]> getFriendToContentMap() throws Exception {
        Map<String, int[]> objectToContent = new HashMap<String, int[]>();
        int[] lines = getLinesStartAndEndPosArr( 0, buffer.capacity() - 1);
        log.info("数组lines的长度:{}", lines.length);
        String previousFriendName = null; //保存上一个消息对象的名称
        for (int lineNum = 0; lineNum < lines.length / 2; lineNum++) {
            int lineStartIndex = lines[lineNum << 1];
            int lineEndIndex = lines[(lineNum << 1) + 1];
            String lineStr = getContent(lineStartIndex, lineEndIndex);
            if (lineStr.contains("消息对象:")) {
                String friendName = lineStr.replace("消息对象:", "").replaceAll("\t|\r|\n", "");
                int[] location = new int[2]; //表示聊天内容的开始和结束位置
//                log.info("当前行数:{},下移3行后的开始位置:{}",lineNum, (lineNum + 3) << 1);
                location[0] = lines[(lineNum + 3) << 1];//从该行开始往下数第3行为聊天内容的开始
                objectToContent.put(friendName, location);
                if (previousFriendName != null) { //上一个消息对象不为空时则可以把行数上移得到上一个消息对象聊天内容的结束位置
                    location = objectToContent.get(previousFriendName);
                    location[1] = lines[((lineNum - 5) << 1) + 1];
                    objectToContent.put(previousFriendName, location);
                }
                previousFriendName = friendName;
            }

        }
        //获取最后一个消息对象聊天内容的结束位置
        if (previousFriendName != null) {
            int[] location = objectToContent.get(previousFriendName);
            location[1] = lines[lines.length - 1];
            objectToContent.put(previousFriendName, location);
        }
        return objectToContent;
    }

    /**
     * 从内存映射文件中获取部分内容
     *
     * @param start
     * @param end
     * @return
     * @throws UnsupportedEncodingException
     */
    public String getContent(int start, int end) throws UnsupportedEncodingException {
        byte[] lineBytes = new byte[end - start + 1];
        for (int i = start; i <= end; i++) {
            lineBytes[i - start] = buffer.get(i);
        }
        return new String(lineBytes, "UTF-8");
    }

    private int[] ensureCapacity(int[] oldArray) {
        int oldCapacity = oldArray.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        return Arrays.copyOf(oldArray, newCapacity);
    }

    private void close() throws Exception {
        Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
        getCleanerMethod.setAccessible(true);
        sun.misc.Cleaner cleaner = (sun.misc.Cleaner)
                getCleanerMethod.invoke(buffer, new Object[0]);
        cleaner.clean();
        raf.close();
    }
}

这就是按照上面描述的思路写出来的代码了,相信大部分人都能看懂了,这里我再解释一些地方。这段代码里面的核心方法是“getLinesStartAndEndPosArr”方法,在这个方法里申明了一个int类型的数组来保存每行的起始位置,偶数位下标对应的(我把0也当作偶数,特此说明)位置保存每行的开始位置,奇数位下标对应的位置保存每行的结束位置。因为这段代码主要是处理大文件的读取操作的,所以这个数组的初始化大小我设置成了10M,这是经过我测试后速度比较快,也比较节省内存的一个大小。看到这里应该有人会有疑问了,既然目标文件的行数是未知的,那为什么不用List而要用数组保存每行的起始位置呢?其实我在做测试的时候一开始也是用的List,最终发现速度不快,才有了现在的这种方案,后来我想了下,原理也很简单,因为首先ArrayList也是通过扩充数组实现的,而且这个类只能用Intger类型的泛型,int类型是用不了的,虽然Intger就是对int的封装,但是他们还是有本质的区别的,毕竟Intger是一个类,那么实例化以后还是需要占用内存空间的,这对内存来说就是额外的开销,但是使用int这种原始类型可以说是最容易被计算机处理的,没有额外内存开销的方式了,也就相当于把ArrayList的核心方法做了个特例使用了。不知道大家有没有发现,我在通过行数计算这个数组的下标的时候是使用的位移的方式,这也是一种提高性能的方法,这种运算方式是最快的。

第二个方案相对第一个方案效率提升了多少?

本次测试对比使用了一个大约300M大小的文本文件做为要分析的文件,结果如下表:

方案 执行时间 占用内存
方案一 8秒多 900M以上
方案二 4秒左右 200M~300M

上面对比数据是在我对每个方案的测试代码只执行一次得出来的,参考价值也不大,我主要还是要用说的吧,来分析一下方案二到底有多少优势。

  1. 理论上可以处理所有2G以下的文件。
  2. 可以随时获取文件任意位置的内容
  3. 数据结构简单,内存占用少,有利于垃圾回收(这个优点可以算是最大的优点了,我刚刚还做过对比测试,在每个方案的代码执行最后都显式的调用垃圾回收方法,方案一基本没有效果,还是占用很多内存,但是方案二几乎全部回收完毕,这在多线程的任务中优势是很大的。)

总结

越是接近底层的代码执行效率越高,以后在做效率优化方案时要开放思维,深入底层,使用最简单和最原始的结构实现我们想要的功能。

发表评论

电子邮件地址不会被公开。 必填项已用*标注