为什么存储长字符串会导致 OOM 错误,但将其分解为短字符串列表不会导致 OOM 错误?

2022-09-04 03:31:29

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

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


答案 1

从本质上讲,你是对的。

A(更准确地说,)使用 a 来存储字符串表示形式(尽管通常 a 不是 )。虽然Java不能保证数组确实存储在连续的内存中,但它很可能是。因此,每当将字符串追加到基础数组时,都会分配一个新数组,如果它太大,则抛出一个。StringBuilderAbstractStringBuilderchar[]Stringchar[]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)

当内部到达第 3332 行时,将引发异常,因为没有足够的内存来容纳大小数组。char[] copy = new char[newLength];Arrays.copyOfnewLength

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

2.5.3. 堆

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 的大小在系统之间不是恒定的。


答案 2

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

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

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

追加字符串序列时,此数组的大小可能必须在一段时间后增加。这最终在基类中完成: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

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

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

但是,即使生成的字符串的实际大小仍远小于限制,大小的这种加倍也可能导致。OutOfMemoryError