天天看点

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()令该通道的所有修改提交到存储设备