【问题标题】:Why does storing a long string cause an OOM error but a breaking it into a list of short strings does not?为什么存储长字符串会导致 OOM 错误,但将其分解为短字符串列表不会?
【发布时间】:2018-01-06 10:39:46
【问题描述】:

我有一个 Java 程序,它使用 StringBuilder 从输入流构建字符串,当字符串过长时最终导致内存不足错误。我尝试将其分解为较短的字符串并将它们存储在ArrayList 中,即使我试图存储相同数量的数据,这也避免了 OOM。这是为什么呢?

我的怀疑是,对于一个非常长的字符串,计算机必须在内存中为它找到一个连续的位置,但是对于ArrayList,它可以使用内存中的多个较小的位置。我知道在 Java 中记忆可能很棘手,所以这个问题可能没有一个直截了当的答案,但希望有人能让我走上正轨。谢谢!

【问题讨论】:

  • 我想你已经找到答案了。
  • @AndrewWilliamson,直觉很棒,但测量更好。这是一个有趣的问题,在有人可以指出源代码或显示更详细的测量值之前没有答案。
  • @Rexana,请提供您的实验源代码和Java版本以及平台。这将使其他人能够复制您的结果。
  • 根据字符串生成器的设计方式,它可以使用简单的连接。在这种情况下,每次将 10 个字符的新字符串附加到 100 个字符的现有字符串时,系统都会分配一个 110 个字符的新字符串,并在删除旧字符串之前先复制现有字符串,然后复制新字符串。在某些时候,您在两个块中使用 210 个字符。如果现有字符串超过可用内存的一半,则会抛出 Out Of Memory 错误。仅供参考,速度也是基于串联的字符串构建的一个问题。
  • 值得注意的是,通过分离虚拟内存和物理内存,您可以轻松找到 1TB 的连续空闲地址空间并在其中映射一些内容,即使物理内存少得多。问题是 Java 自己的内存管理是否会阻止使用该功能。

标签: java string out-of-memory heap-memory


【解决方案1】:

基本上,你是对的。

StringBuilder(更准确地说,AbstractStringBuilder)使用char[] 来存储字符串表示形式(尽管通常String 不是char[])。虽然 Java 确实 not guarantee 表明数组确实存储在连续内存中,但它很可能是。因此,每当将字符串附加到底层数组时,都会分配一个新数组,如果它太大,则会抛出 OutOfMemoryError

确实,执行代码

StringBuilder b = new StringBuilder();
for (int i = 0; i < 7 * Math.pow(10, 8); i++)
    b.append("a"); // line 11

抛出异常:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
    at java.lang.StringBuilder.append(StringBuilder.java:136)
    at test1.Main.main(Main.java:11)

Arrays.copyOf 内部到达第3332 行char[] copy = new char[newLength]; 时,会抛出异常,因为没有足够的内存用于大小为newLength 的数组。

还要注意与错误一起给出的消息:“Java 堆空间”。这意味着无法在 Java 堆中分配对象(在本例中为数组)。 (编辑:此错误还有另一个可能的原因,请参阅Marco13's answer)。

2.5.3. Heap

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的堆。堆是为所有类实例和数组分配内存的运行时数据区域。

...堆的内存不需要是连续的。

Java 虚拟机实现可以让程序员或用户控制堆的初始大小,如果堆可以动态扩展或收缩,还可以控制最大和最小堆大小。

以下异常情况与堆相关:

  • 如果计算需要的堆比自动存储管理系统所能提供的更多,Java 虚拟机会抛出 OutOfMemoryError

将数组分成总大小相同的较小数组可以避免 OOME,因为每个数组可以单独存储在较小的连续区域中。当然,您为此“付费”,必须从每个数组指向下一个数组。

将上面的代码与下面的代码进行比较:

static StringBuilder b1 = new StringBuilder();
static StringBuilder b2 = new StringBuilder();
...
static StringBuilder b10 = new StringBuilder();

public static void main(String[] args) {
    for (int i = 0; i < Math.pow(10, 8); i++)
        b1.append("a");
    System.out.println(b1.length());
    // ...
    for (int i = 0; i < Math.pow(10, 8); i++)
        b10.append("a");
    System.out.println(b10.length());
}

输出是

100000000
100000000
100000000
100000000
100000000
100000000
100000000
100000000

然后抛出 OOME。

虽然第一个程序分配的数组单元数不能超过7 * Math.pow(10, 8),但这个总和至少为8 * Math.pow(10, 8)

请注意,堆的大小可以通过 VM 初始化参数来更改,因此引发 OOME 的大小在系统之间不是恒定的。

【讨论】:

  • 确实,知道异常是否真的“Java堆空间”会很有帮助。如果是这样,可以用java -Xmx3000m TheProgram 启动程序就可以了。如果错误来自实际的字符串实现,那么问题将是数组显然太长了......
  • @Marco13 关于错误消息是正确的。我的回答中的最后一句话同意您可以增加堆大小(但我会在我的示例中增加数字:))。我不认为字符串实现在这里是相关的,因为它在这里表示为一个数组,还是你的意思是别的?
  • 这一点是指StringBuilder将其values数组的大小翻倍,即使有足够的内存,那么对于大于Integer.MAX_VALUE的数组大小(通过检查对于溢出),StringBuilder 将抛出一个OutOfMemoryError - 没有“Java堆空间”部分,即。
【解决方案2】:

如果您发布了堆栈跟踪(如果有的话)可能会有所帮助。但是您观察到的OutOfMemoryError 有一个非常可能的原因。

(虽然到目前为止,这个答案可能只是一个“有根据的猜测”。如果不检查系统上发生错误的条件,没有人可以确定原因)

当使用StringBuilder 连接字符串时,StringBuilder 将在内部维护一个包含要构造的字符串字符的char[] 数组。

当附加一个字符串序列时,这个char[] 数组的大小可能需要在一段时间后增加。这最终在AbstractStringBuilder 基类中完成:

/**
 * This method has the same contract as ensureCapacity, but is
 * never synchronized.
 */
private void ensureCapacityInternal(int minimumCapacity) {
    // overflow-conscious code
    if (minimumCapacity - value.length > 0)
        expandCapacity(minimumCapacity);
}

/**
 * This implements the expansion semantics of ensureCapacity with no
 * size check or synchronization.
 */
void expandCapacity(int minimumCapacity) {
    int newCapacity = value.length * 2 + 2;
    if (newCapacity - minimumCapacity < 0)
        newCapacity = minimumCapacity;
    if (newCapacity < 0) {
        if (minimumCapacity < 0) // overflow
            throw new OutOfMemoryError();
        newCapacity = Integer.MAX_VALUE;
    }
    value = Arrays.copyOf(value, newCapacity);
}

只要字符串生成器注意到新数据不适合当前分配的数组,就会调用它。

这显然是一个可以抛出OutOfMemoryError 的地方。 (严格来说,它并不一定是真的“内存不足”。它只是根据数组可以具有的最大大小来检查溢出......)。

(编辑:也看看answer by user1803551 :这不一定是你的错误来自的地方!你的错误可能确实来自Arrays 类,或者更确切地说来自JVM)

仔细检查代码,您会注意到数组的大小每次扩展时都会加倍。这是至关重要的:如果它只确保可以附加新的数据块,那么将n 字符(或其他固定长度的字符串)附加到StringBuilder 的运行时间将为 O(n²)。当大小以一个常数因子(这里为 2)增加时,运行时间只有 O(n)。

然而,这种大小的加倍可能会导致OutOfMemoryError,即使结果字符串的实际大小仍然远小于限制。

【讨论】:

  • 我也浏览了ensureCapacityInternal 一段时间,但我无法想象在堆大小变得太大之前,minimumCapacity 会在数字上溢出。可能需要进行具体的计算。
  • @user1803551 考虑到重复分配以及 附加 的字符串可能包含在字符串池中(导致更多内存)的事实,这里很难明智地进行数学运算压力)。但天真地,具有 10 亿个元素的 char[] 数组将占用大约 2 GB。堆大小可以做得更大,例如与-Xmx16g。与此相反,Java 语言和 VM 中内置了数组永远无法包含超过 20 亿个元素的技术限制。所以更笼统:即使堆足够大,OOME 仍然可能会被抛到这里。
  • 我做过类似的计算。当数组长度为 2147483647 时会发生溢出。由于每个 char 是 2 个字节,填充该数组将需要 ~4.3GB。如果你能负担得起堆的大小,那么确实会发生溢出。我对堆大小几乎肯定是限制因素的评估过于乐观。在抛出 OOME 之前,重复的分配是 GC 的,所以我没有为此烦恼。在我的示例中,我还使用了单个字符串来保持字符串池较小,这样它就不会占用堆空间,但这仍然是限制因素。
  • 出于好奇,该源代码来自哪个 Java 版本? Java 8 和 Java 9 非常不同 - 它们使用 newCapacity(int)hugeCapacity(int)MAX_ARRAY_SIZE = Integer.MAX_VALUE-8
  • @CarlosHeuberger 那来自 Java8,我认为这一点在 7/8/9 之间偶尔会发生变化(尽管我还没有深入研究 9)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-03-12
  • 1970-01-01
  • 2017-01-19
  • 2018-10-02
相关资源
最近更新 更多