为什么在Java中将文件读入内存需要4倍的内存?

12
我有以下代码,用于读取以下文件,在每行末尾附加 \r\n,并将结果放入字符串缓冲区中:
public InputStream getInputStream() throws Exception {
    StringBuffer holder = new StringBuffer();
    try{
        FileInputStream reader = new FileInputStream(inputPath);


        BufferedReader br = new BufferedReader(new InputStreamReader(reader));
        String strLine;
        //Read File Line By Line
        boolean start = true;
        while ((strLine = br.readLine()) != null)   {
            if( !start )    
                holder.append("\r\n");

            holder.append(strLine);
            start = false;
        }
        //Close the input stream
        reader.close();
    }catch (Throwable e){//this is where the heap error is caught up to 2Gb
      System.err.println("Error: " + e.getMessage());
    }


    return new StringBufferInputStream(holder.toString());
}

我尝试读取一个 400Mb 的文件,将最大堆空间改为 2Gb,但仍然出现了内存不足异常。有什么想法吗?


4
如果你只是想将文件从Unix格式转换为Windows格式,我建议你使用许多地方都提供的unix2dos命令(在大多数Linux系统上都是标准的,也包括在Cygwin中等)。 - rmeador
使用Java仍然可以进行流式转换,只需不将strLine聚合到持有者中,而是立即将其打印到FileOutputStream中。您能向我们展示MemExc指向哪里吗? - akarnokd
9个回答

24

可能跟当 StringBuffer 达到容量时的重新调整大小有关——这会创建一个新的比之前大两倍的 char[],并将内容复制到新数组中。再加上Java中字符存储为2个字节的特点,这肯定会增加你的内存使用。

要解决这个问题,你可以一开始就创建具有足够容量的 StringBuffer,因为你知道文件大小(从而知道要读入的字符数量)。但是请注意,如果你尝试将这个大的 StringBuffer 转换为 String,数组分配也会发生。

另外,你应该更倾向于使用 StringBuilder,因为它的操作速度更快。

你可以考虑实现自己的 "CharBuffer",例如使用 LinkedList 的 char[],以避免昂贵的数组分配/复制操作。你可以让这个类实现 CharSequence,或许可以完全避免转换为 String。另一个更紧凑的表示建议:如果你正在阅读包含大量重复单词的英语文本,你可以读取和存储每个单词,使用 String.intern() 函数来显著减少存储。


只有在旧数组已满时,它才会分配新数组。 - Adamski
3
旧数组大小为1GB,当旧数组被填满时,创建一个新的2GB数组,并将1GB数组复制到2GB数组中(然而您目前手头有3GB的内存)。1GB数组失去引用等待垃圾收集,2GB数组成为新的存储空间,其剩余空间(因为第一个1GB已经被复制到新数组中)开始被使用。 - Sekhat
那么,答案将是使用初始容量=文件大小吗?如果可能的话? - OscarRyz
1
看起来至少是 file.size() * 2,再加上换行符的数量(因为额外插入了 \r)。 - Yishai
@Adamski, @Yishai:为什么是 file.size() * 2StringBuffer 的容量是按字符计算的,而不是按字节计算的,文件中的字符数几乎不可能超过字节数(假设没有使用“奇特”的编码方式)。初始容量为 file.size() + expectedLineCount * 2 更加经济实惠。 - gustafc
显示剩余6条评论

13
首先,Java字符串是UTF-16编码(即每个字符2个字节),所以假设您的输入文件是ASCII或类似的每个字符1个字节的格式,那么holder将比输入数据大大约2倍,再加上每行的额外\r\n和任何其他开销,则大约为800MB,假设StringBuffer中存储开销非常低。
我也可以相信你的文件内容被缓冲了两次——一次在I/O层,一次在缓冲读取器中。
但是,要确定原因,最好还是查看堆上实际的情况-使用类似HPROF这样的工具查看内存使用情况。
解决此问题,建议您逐行处理,添加每行终止符后立即写出该行。这样,您的内存使用量应该与一行的长度成比例,而不是整个文件。

我已经考虑过了,但仍然无法解释为什么它超过了2GB(可能更多,尚未测试超过2GB)。 - erotsppa
你的应用程序可用的堆比2GB少得多。例如,在Windows上,单个进程的地址空间默认为仅2GB。在这2GB中,您必须适合所有.dll文件的映射,Java虚拟机可能会为自己保留一些空间等。在剩余的部分中,您将面临内存碎片化的问题-防止重新分配BIG对象(例如需要复制整个对象然后释放原始对象的数组)因为没有足够的空间容纳这样一个大的东西-只有一些小的空隙可以容纳小的东西。 - nos

12

这是一个有趣的问题,但与其为Java使用了很多内存而感到压力,不如尝试一种不需要将整个文件加载到内存中的设计?


14
我惊讶于我的回答被踩了。有时候我们开发者会浪费时间去尝试找出为什么以某种特定方式做事情不按照我们预期的那样工作,而我们也许应该退后一步,尝试另一种方法。我认为每当处理大文件并将整个文件加载到内存中时,第一个问题应该是“为什么?” - Chris W. Rea
16
当开发者请求解决方案时,显然是有理由的。不要假设每个问题都来自一名高中学生。 - erotsppa
5
你不必是高中生才会被细节困扰并错失更大的图景/其他解决方案。 - Andrew Coleson
14
这不是一个答案,而是一个非常有用的评论。应该放在评论部分而不是答案部分,并且不应该得到赞同(因为它没有回答问题)。http://bit.ly/MohSi - OscarRyz
4
我这么说吧:如果我提出一个问题,某个 Stack Overflow (SO) 用户说“嘿,你一开始就做错了,试试这个方法!”然后我按照他的建议去做,并且效果非常好,我会感到很高兴。 - Ed S.
显示剩余14条评论

12

这里有几个问题:

  • Unicode:字符在内存中占用的空间是磁盘上空间的两倍(假设使用一字节编码)
  • StringBuffer 调整大小:可能会永久性地将所占用的内存增加一倍,或者暂时增加三倍,尽管这是最坏的情况
  • StringBuffer.toString() 会暂时将所需内存翻倍,因为它会复制

所有这些综合起来意味着你可能需要暂时使用高达文件大小8倍的RAM,例如对于一个400M的文件,需要3.2G的RAM。即使您的计算机物理上有这么多RAM,它也必须运行64位操作系统和JVM才能为JVM获得那么多堆。

总之,在内存中保留如此庞大的字符串是一个非常糟糕的想法 - 而且完全没有必要 - 因为您的方法返回一个InputStream,您真正需要的只是一个FilterInputStream,可以实时添加换行符。


1
只需扩展FilterInputStream并覆盖其read()方法以检测换行符并在继续底层流的其余部分之前返回\r\n即可。如果您想支持标记/重置,那么它可能会变得有些复杂,但您可能不需要这样做。 - Michael Borgwardt
StringBuffer.toString() 并不总是会复制。它采用的是写时复制技术,这意味着复制被延迟到下一次修改 StringBuffer 时才会进行。 - finnw
我的JDK 1.6.0u12源代码与你的不一致。 - Michael Borgwardt
Michael Borgwardt: 要覆盖哪个读取方法?有很多。你能提供样例代码吗? - erotsppa
没事了,我从Java源代码中复制了。不确定这是否是最好的方法。 - erotsppa
显示剩余6条评论

4

这是StringBuffer。空构造函数创建一个初始长度为16字节的StringBuffer。如果您添加了内容并且容量不足,则会将内部字符串数组复制到新缓冲区。

实际上,每次添加一行时,StringBuffer都必须创建完整内部数组的副本,这几乎会使在添加最后一行时所需的内存翻倍。再加上UTF-16表示,这就导致了观察到的内存需求。

编辑

迈克尔说得对,当需要更多内存时,内部缓冲区不会逐渐增加 - 它的大小大约会每次翻倍。但是,在最坏的情况下,假设缓冲区仅需要通过最后一次附加来扩展容量,则会创建一个两倍于实际大小的新数组 - 因此在这种情况下,您暂时需要大约三倍的内存。

无论如何,我已经学到了教训:StringBuffer(和Builder)可能会导致意外的OutOfMemory错误,因此当我必须存储大型字符串时,我将始终使用初始化大小。感谢您的提问 :)


1
-1 不是真的;StringBuffer 在当前大小不足时会加倍,而不是逐渐增加。 - Michael Borgwardt
@Andreas,我只有JDK 1.5版本,但公共Java文档显示容量至少增加了一倍,所以我不认为他们会改变这个。请检查ensureCapacity方法。也许是你理解错了。 - Yishai
1
不,区别在于抽象字符序列的长度,当然会因为追加的字符数量而增加,而底层数组的大小可能要大得多,并且会以大步骤扩展以减少复制量。 - Michael Borgwardt

1
在StringBuffer的最后一次插入时,你需要分配三倍内存,因为StringBuffer始终通过(size + 1) * 2(已经因为Unicode而翻倍了)来扩展。因此,一个400GB的文件可能需要分配800GB * 3 == 2.4GB的内存。这可能会少一些,这取决于何时到达阈值。
建议在此处连接字符串而不是使用Buffer或Builder。将会有大量的垃圾收集和对象创建(因此速度很慢),但内存占用率要低得多。
[在Michael的提示下,我进一步调查了这个问题,concat在这里并没有帮助,因为它会复制字符缓冲区,所以虽然它不需要三倍的内存,但在最后仍需要两倍的内存。]
如果你知道文件的最大大小并在创建时初始化Buffer的大小,并且你确定这个方法只会从一个线程调用,那么你可以继续使用Buffer(更好的方法是使用Builder)。
但实际上,将如此大的文件一次性加载到内存中,应该只在万不得已的情况下才这样做。

1
哇,这个问题的回答正在引起很多负评。但是如果你要点踩,请至少说明原因。 - Yishai
使用字符串连接将需要非常长的时间。很可能是数年之久。不,我并没有夸大其词。 - Michael Borgwardt

1

我建议您使用操作系统文件缓存,而不是通过字符将数据复制到Java内存中,然后再转换回字节。如果需要重新读取文件(可能在转换时),它将更快,并且很可能更简单。

您需要超过2 GB的空间,因为1字节的字母在内存中使用char(2字节),当您的StringBuffer调整大小时,您需要双倍的空间(将旧数组复制到较大的新数组)。新数组通常比原始文件大小大50%,因此您需要多达6倍的原始文件大小。如果性能还不够差,您正在使用StringBuffer而不是StringBuilder,后者在明显不需要同步的情况下每次调用都会同步。(这只会减慢您的速度,但使用相同数量的内存)


1

其他人已经解释了为什么你的内存不足。至于如何解决这个问题,我建议编写一个自定义的FilterInputStream子类。这个类会一次读取一行,追加"\r\n"字符并缓冲结果。一旦消费者使用了你的FilterInputStream读取了该行,你就可以读取另一行。这样,你每次只会在内存中有一行。


0
我还建议查看Commons IO FileUtils类。具体来说:org.apache.commons.io.FileUtils#readFileToString。如果您知道只使用ASCII,还可以指定编码。

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