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



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

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

本质上,你是对的。

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

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

如果您发布了堆栈跟踪(如果可用),可能会有所帮助。但是,您观察到的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的地方。(严格来说,它不一定真的"内存不足"。它只是根据数组可以具有的最大大小检查溢出......

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

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

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

最新更新