Java中的RAII...资源释放总是如此丑陋吗?

17

我刚刚使用了Java文件系统API,并编写了以下函数,用于复制二进制文件。原始代码来源于网络,但我添加了try/catch/finally语句以确保,在发生错误时,缓冲流将在退出函数前关闭(因此,我的操作系统资源得到释放)。

我简化了函数以显示其模式:

public static void copyFile(FileOutputStream oDStream, FileInputStream oSStream) throw etc...
{
   BufferedInputStream oSBuffer = new BufferedInputStream(oSStream, 4096);
   BufferedOutputStream oDBuffer = new BufferedOutputStream(oDStream, 4096);

   try
   { 
      try
      { 
         int c;

         while((c = oSBuffer.read()) != -1)  // could throw a IOException
         {
            oDBuffer.write(c);  // could throw a IOException
         }
      }
      finally
      {
         oDBuffer.close(); // could throw a IOException
      }
   }
   finally
   {
      oSBuffer.close(); // could throw a IOException
   }
}

据我所知,我不能将两个close()放在finally子句中,因为第一个close()可能会抛出异常,那么第二个就不会被执行。
我知道C#有Dispose模式,可以使用using关键字处理此问题。
我甚至更了解C++代码,可能是这样的(使用类似Java的API):
void copyFile(FileOutputStream & oDStream, FileInputStream & oSStream)
{
   BufferedInputStream oSBuffer(oSStream, 4096);
   BufferedOutputStream oDBuffer(oDStream, 4096);

   int c;

   while((c = oSBuffer.read()) != -1)  // could throw a IOException
   {
      oDBuffer.write(c);  // could throw a IOException
   }

   // I don't care about resources, as RAII handle them for me
}

我是否遗漏了什么,或者我真的必须在Java中编写丑陋而臃肿的代码才能处理缓冲流的close()方法中的异常吗?
(请告诉我我哪里错了...)
编辑:是我还是在更新此页面时,我看到问题和所有答案在几分钟内都减少了一分?有人在享受匿名的乐趣吗?
编辑2:McDowell提供了一个非常有趣的链接,我觉得我不得不在这里提到: http://illegalargumentexception.blogspot.com/2008/10/java-how-not-to-make-mess-of-stream.html 编辑3:根据McDowell的链接,我发现Java 7提出了一种类似于C# using模式的模式:http://tech.puredanger.com/java7/#resourceblock。我的问题被明确描述。显然,即使使用Java 7的do,问题仍然存在。

抱歉我之前的回答可能误导了您。我不确定您是真的想在Java中找到一种RAII的方法,还是只是不知道如何通常将数据从输入流复制到输出流。 - Alexander
没问题...事实上,我也不知道如何制作一个干净高效的副本... :-p ... - paercebal
如果您使用Java代码规范,第一个列表中可以节省8行代码,第二个列表中可以节省2行代码。在这种情况下,代码就不会那么丑陋了。 - msangel
@msangel: 如果您使用Java代码约定,可以在第一个列表中节省8行代码,在第二个列表中节省2行代码。在这种情况下,此代码将不会那么丑陋。但是,您SO错过了这篇文章的重点... :-D - paercebal
1
oDBuffer的分配应该放在外部的try语句中。这表明这种做法是多么不方便。 - Nicola Musatti
@Nicola Musatti:你是对的... 噢. 考虑到他们等到2012年才实现try-with-resources(C# 1.0在2001年左右实现了using关键字)... - paercebal
5个回答

19

在Java 6及以下版本中,使用try/finally模式是处理流的正确方式。

一些人提倡静默关闭流。但要注意以下几点原因:Java:如何不搞砸流处理


Java 7引入了try-with-resources

/** transcodes text file from one encoding to another */
public static void transcode(File source, Charset srcEncoding,
                             File target, Charset tgtEncoding)
                                                             throws IOException {
    try (InputStream in = new FileInputStream(source);
         Reader reader = new InputStreamReader(in, srcEncoding);
         OutputStream out = new FileOutputStream(target);
         Writer writer = new OutputStreamWriter(out, tgtEncoding)) {
        char[] buffer = new char[1024];
        int r;
        while ((r = reader.read(buffer)) != -1) {
            writer.write(buffer, 0, r);
        }
    }
}

AutoCloseable 类型将会自动关闭:

public class Foo {
  public static void main(String[] args) {
    class CloseTest implements AutoCloseable {
      public void close() {
        System.out.println("Close");
      }
    }
    try (CloseTest closeable = new CloseTest()) {}
  }
}

在大多数情况下,但有趣的是,在这种情况下不是这样。 :) - Tom Hawtin - tackline
@Tom - 是的,这不是一个好的流复制机制,我会采用你的建议。 - McDowell
我的观点更多地涉及RAII而不是使用BufferOutputStream的代码实现。你提供的链接是我关于RAII问题的正确答案。有趣的是,我曾有机会参与一个Java新项目的开发,但最终拒绝了邀请,选择了另一个.NET项目(几乎)只因为C#的“using”和C++/CLI的析构函数和终结器... - paercebal
@McDowell:感谢您更新了try-with-resources代码示例。如果我能多次投票,我会再次投票支持您的回答... :-) ... - paercebal

4

不幸的是,这种类型的代码在Java中往往会变得有些臃肿。

顺便说一下,如果oSBuffer.read或oDBuffer.write的调用之一抛出异常,那么您可能希望让该异常渗透到调用层次结构中。

在finally子句中存在未保护的close()调用将导致原始异常被由close()调用产生的异常所替换。换句话说,失败的close()-方法可能会隐藏read()或write()产生的原始异常。因此,我认为您只想忽略由close()抛出的异常,仅当其他方法没有抛出异常时。

我通常通过在内部try中包含显式的close-call来解决这个问题:

  try {
    while (...) {
      read...
      write...
    }
    oSBuffer.close(); // 不要忽略此处的异常
    oDBuffer.close(); // 不要忽略此处的异常
  } finally {
    silentClose(oSBuffer); // 忽略此处的异常
    silentClose(oDBuffer); // 忽略此处的异常
  }
  static void silentClose(Closeable c)  {
    try {
      c.close();
    } catch (IOException ie) {
      // 忽略; 调用者必须有此意图
    }
  }

最后,为了提高性能,代码应该使用缓冲区(每次读/写多个字节)。虽然无法通过数字来支持这一点,但较少的调用应该比在其上添加缓冲流更有效。


如果你静默地关闭流,当close方法抛出异常时你的代码将没有错误处理。很多流(如BufferedOutputStream)在关闭时会写入数据。 - McDowell
BufferedOutputStream 有点棘手。我倾向于显式刷新(非异常情况下),但你需要记住它。如果我没记错,在 Java SE 1.6 之前,close 方法破坏了异常处理。 - Tom Hawtin - tackline
我知道close()的异常非常重要。但是,如果在最后一次调用write()之后立即进行close()调用,那么这不应该确保异常的正确跟踪吗?McDowell,请确认这里是否有缺陷;然后我会自己撤销代码,但我真的想知道。 :) - volley
一些加密/压缩有点麻烦。你不仅需要处理底层资源,而且实现中可能还有一些“C”非Java内存资源。 - Tom Hawtin - tackline
@Derek 当然,修复(不良的思维到文本映射)。@McDowell,谢谢。 - volley
显示剩余2条评论

4

虽然存在问题,但你在网上找到的代码确实很糟糕。

关闭缓冲流会关闭底层流。你真的不想这样做。你只需要刷新输出流即可。而且指定底层流是文件没有任何意义。性能很差,因为你每次只复制一个字节(实际上,如果你使用java.io,可以使用transferTo/transferFrom,速度会更快)。顺便说一下,变量名也很糟糕。所以:

public static void copy(
    InputStream in, OutputStream out
) throw IOException {
    byte[] buff = new byte[8192];
    for (;;) {
        int len = in.read(buff);
        if (len == -1) {
            break;
        }
        out.write(buff, 0, len);
    }
}

如果你发现自己经常使用try-finally,那么你可以使用“执行周围”惯用语将其分解。

我认为:Java应该有一种在作用域结束时关闭资源的方式。我建议添加private作为一元后缀运算符,在封闭块的末尾关闭。


感谢您提供更好的代码。对于我目前的个人项目来说,这并不是非常重要,但我现在将您的代码复制/粘贴作为未来的替代方案。+1。 - paercebal
如果他正在复制文件,那么他很可能希望在完成后关闭流。复制已经完成,因此保持流处于打开状态没有意义。在这种情况下,他的嵌套try-finally块和close()调用是适当的。 - Derek Park
Derek Park是正确的。虽然你的代码让我很感兴趣,但它仍然没有抓住问题的重点,即资源处理。假设我有一个copyFile(String in, String out)方法,它实例化了FileOutputStream和FileInputStream,并调用了这个copy(InputStream in, OutputStream out)方法,那么copyFile应该如何编写才能正确处理资源释放? - paercebal

3
是的,这就是Java的工作方式。存在控制反转——对象的用户必须知道如何清理对象,而不是对象自己在清理后清理。不幸的是,这会导致大量的清理代码散布在您的Java代码中。
C#有“using”关键字,可以在对象超出范围时自动调用Dispose。Java没有这样的东西。

1
无论是否有特殊的语法,客户端代码都必须告诉资源何时进行清理。资源无法自行判断。当然,您可以通过回调函数来抽象出资源获取、设置和释放,并执行有趣的代码。 - Tom Hawtin - tackline
1
我对这些编程语言有一定的了解。在C#中,客户端代码在using块结束时调用dispose方法。在C++中,客户端代码在作用域结束时调用析构函数。 - Tom Hawtin - tackline
困惑。在C++中,析构函数由客户端代码在客户端代码决定的某个时间点调用。这不是参与吗?在C#中,只有一点语法糖,以完全相同的方式调用方法,就像try-finally一样。 - Tom Hawtin - tackline
2
在C++和C#中,调用者只需使用适当的语法说“我想要自动销毁”(C#通过"using",C++通过基于栈的初始化),对象本身会处理细节。然而,在Java中,调用者必须自行执行销毁操作(调用close()或其他方法)。 - Eggs McLaren
@Tom Hawtin - tackline: Dale 是对的。在 C# 中,using 关键字表示当代码退出作用域时,终结器必须运行。在 C++ 中,析构函数将在声明对象的代码退出作用域时被调用。在 Java 中,您必须编写 finalize 并处理令人讨厌的细节。 - paercebal
显示剩余3条评论

2
对于常见的IO任务,例如复制文件,像上面展示的代码是在重新发明轮子。不幸的是,JDK没有提供任何更高级别的实用程序,但是apache commons-io提供了这些工具。
例如,FileUtils 包含各种与文件和目录(包括复制)相关的实用方法。另一方面,如果你真的需要使用JDK中的IO支持,IOUtils 包含一组closeQuietly()方法,可以在不抛出异常的情况下关闭读取器、写入器、流等。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接