天天看點

FileOutputStream輸出檔案偶爾為空的問題

公司産品是基于android研發的機頂盒,有一個功能是可以将儲存在sd卡中的日志檔案複制到插入盒子的U盤中,以供傳閱。測試發現,當界面提示導出完畢後迅速拔掉U盤,則有很大機率導出的檔案大小為0kb(檔案存在)。而當界面提示導出完畢後等待約5~6秒鐘再拔出,日志檔案大小正常。

該問題給我的直覺感覺是目标檔案建立了,但是沒有寫入内容。然而通過調試資訊發現,經過API寫入操作後,目标檔案建立并且檔案大小是正确的,但是快速拔出U盤時,内容還沒來得及真正寫入到檔案。

這個問題最終解決了,問題在于檔案流寫入與儲存設備的同步,特此記錄一下。

Java複制檔案的4種方式

詳見:java複制檔案的4種方式

  1. 使用FileStreams複制
  2. 使用FileChannel複制
  3. 使用Commons IO複制
  4. 使用Java7的Files類複制

使用FileStreams複制

其中,産品原有代碼的日志複制采用最常見的FileStream方法,代碼如下:

private static void copyFileUsingFileStreams(File source, File dest)
        throws IOException {    
    InputStream input = null;    
    OutputStream output = null;    
    try {
           input = new FileInputStream(source);
           output = new FileOutputStream(dest);        
           byte[] buf = new byte[];        
           int bytesRead;        
           while ((bytesRead = input.read(buf)) > ) {
               output.write(buf, , bytesRead);
               output.flush();//注釋1:FileOutputStream.flush()實際是多餘的
           }
    } finally {
        input.close();
        output.close();
    }
}
           

對FileOutputStream.flush()方法的誤解

如上節代碼

注釋1

所示,在為FileOutputStream寫入資料後調用了flush(),試圖将緩沖區中的位元組全部寫入檔案。但檢視flush()源碼發現,FileOutputStream并沒有實作這個方法,因而調用的實際是其父類OutputStream.flush(),但也隻是一個空方法:

/**
     * Flushes this stream. Implementations of this method should ensure that
     * any buffered data is written out. This implementation does nothing.
     *
     * @throws IOException
     *             if an error occurs while flushing this stream.
     */
    public void flush() throws IOException {
        /* empty */
    }
           

也就是說FileOutputStream.flush()方法沒有任何作用,隻有BufferedOutputStream這類實作了緩存區的讀寫流的flush()才有作用。

可以為FileOutputStream接上BufferedOutputStream實作緩存區的讀寫:

FileOutputStream fos = new FileOutputStream("/sdcard/a.txt");
BufferedOutputStream bos = new BufferedOutputStream(fos);
bos.write(...);
bos.flush();
           

但是即使如此,快速拔掉U盤仍然會導緻導出檔案大小為0。走投無路了,是以嘗試其他的檔案複制操作。

使用FileChannel複制

FileChannel是一個連接配接到檔案的通道,可以通過檔案通道讀寫檔案。據說比檔案流複制的速度更快。

private static void copyFileUsingFileChannels(File source, File dest) throws IOException {    
        FileChannel inputChannel = null;    
        FileChannel outputChannel = null;    
    try {
        inputChannel = new FileInputStream(source).getChannel();
        outputChannel = new FileOutputStream(dest).getChannel();
        outputChannel.transferFrom(inputChannel, , inputChannel.size());
    } finally {
        inputChannel.close();
        outputChannel.close();
    }
}
           

使用該方法複制檔案到U盤,并不能解決快速拔出U盤導緻檔案大小為0的問題。但通過這個實踐,更讓我确定并不是FileStream或FileChannel代碼的問題,而是資料因為某種原因,從API調用寫入方法到真正寫入U盤存在一個延遲。于是,我嘗試找FileChannel是否有關于“立即寫入”之類的方法,發現了:

/**
     * Requests that all updates to this channel are committed to the storage
     * device.
     * @param metadata
     *            {@code true} if the file metadata should be flushed in
     *            addition to the file content, {@code false} otherwise.
     * @throws ClosedChannelException
     *             if this channel is already closed.
     * @throws IOException
     *             if another I/O error occurs.
     */
    public abstract void force(boolean metadata) throws IOException;
           

force()方法令該通道的所有修改送出到儲存設備。果然,在transferFrom()後之後增加該方法的調用,檔案大小為0的問題解決。文章JAVA NIO系列教程(七) FILECHANNEL 對force()方法的說明是:

FileChannel.force()方法将通道裡尚未寫入磁盤的資料強制寫到磁盤上。出于性能方面的考慮,作業系統會将資料緩存在記憶體中,是以無法保證寫入到FileChannel裡的資料一定會即時寫到磁盤上。要保證這一點,需要調用force()方法。

FileOutputStream flush操作時有時無效的解決辦法

FileChannel有force()方法可以強制寫入資料,難道FileOutputStream.flush()不是這類的方法嗎?那難道檔案流方式就沒辦法達到一樣的效果嗎?當然可以!一篇文章解決了我的疑惑:FileOutputStream flush操作時有時無效的解決辦法

flush 的正常協定是:如果此輸出流的實作已經緩沖了以前寫入的任何位元組,則調用此方法訓示應将這些位元組立即寫入它們預期的目标。如果此流的預期目标是由基礎作業系統提供的一個抽象(如一個檔案),則重新整理此流隻能保證将以前寫入到流的位元組傳遞給作業系統進行寫入,但不保證能将這些位元組實際寫入到實體裝置(如磁盤驅動器)

正如前文所說,FileOutputStream的flush()實際沒有作用,該文章此處可能需要勘誤,但不影響我們的了解。

正如我猜測的,API調用了Stream或Channel的寫入方法,隻是寫入給了作業系統(如android),但是作業系統什麼時候寫入到檔案系統(如U盤),我們并不知道。從目前的現象看,至少有5~6秒的延遲。

該文章也給出了FileDescriptor.sync()來解決這個問題:

FileDescriptor.sync()強制所有系統緩沖區與基礎裝置同步。該方法在此 FileDescriptor 的所有修改資料和屬性都寫入相關裝置後傳回。特别是,如果此 FileDescriptor 引用實體存儲媒體,比如檔案系統中的檔案,則一直要等到将與此 FileDesecriptor 有關的緩沖區的所有記憶體中修改副本寫入實體媒體中,sync 方法才會傳回。 sync 方法由要求實體存儲(比例檔案)處于某種已知狀态下的代碼使用。例如,提供簡單事務處理設施的類可以使用 sync 來確定某個檔案所有由給定事務造成的更改都記錄在存儲媒體上。 sync 隻影響此 FileDescriptor 的緩沖區下遊。如果正通過應用程式(例如,通過一個 BufferedOutputStream 對象)實作記憶體緩沖,那麼必須在資料受 sync 影響之前将這些緩沖區重新整理,并轉到 FileDescriptor 中(例如,通過調用 OutputStream.flush)。

在檔案流上使用該方法:

private static void copyFileUsingFileStreamsSync(File source, File dest)
        throws IOException {    
    InputStream input = null;    
    OutputStream output = null;    
    try {
           input = new FileInputStream(source);
           output = new FileOutputStream(dest);        
           byte[] buf = new byte[];        
           int bytesRead;        
           while ((bytesRead = input.read(buf)) > ) {
               output.write(buf, , bytesRead);
           }
           output.getFD().sync();//寫入後同步
    } finally {
        input.close();
        output.close();
    }
}
           

事實證明,sync後的檔案大小恢複正常。以下是複制檔案後快速拔出U盤的測試,其中單數序号進行了sync,偶數序号未sync。

FileOutputStream輸出檔案偶爾為空的問題

總結

  1. OutputStream的flush()沒有實際實作,隻有部分子類重寫了該方法,如BufferedOutputStream
  2. flush()的作用在于将以前寫入到流的位元組傳遞給作業系統進行寫入,但不保證作業系統馬上将這些位元組實際寫入到實體裝置(如磁盤驅動器)
  3. 檔案流可以使用FileOutputStream.getFD().sync()将檔案流的修改同步到儲存設備
  4. 檔案通道可以使用FileChannel.force()令該通道的所有修改送出到儲存設備