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. 數據結構簡單,內存占用少,有利于垃圾回收(這個優點可以算是最大的優點了,我剛剛還做過對比測試,在每個方案的代碼執行最后都顯式的調用垃圾回收方法,方案一基本沒有效果,還是占用很多內存,但是方案二幾乎全部回收完畢,這在多線程的任務中優勢是很大的。)

總結

越是接近底層的代碼執行效率越高,以后在做效率優化方案時要開放思維,深入底層,使用最簡單和最原始的結構實現我們想要的功能。

發表評論

電子郵件地址不會被公開。 必填項已用*標注