我有一个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
。