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


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

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


2810
2017-07-31 00:28


起源

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


答案:


基本上,你是对的。

一个 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)

当第3332行 char[] copy = new char[newLength]; 在里面到达 Arrays.copyOf,抛出异常,因为没有足够的内存用于大小数组 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的大小在系统之间不是恒定的。


6
2017-07-31 11:26



实际上,了解异常是否真的很有帮助 说过 “Java堆空间”。如果是这样,人们就可以开始使用 java -Xmx3000m TheProgram 没关系如果错误来自实际的字符串实现,那么问题就是数组显然太长了...... - Marco13
@ Marco13关于错误消息的确实。我的回答中的最后一句同意你可以增加堆大小(但是我会在我的例子中增加数字:))。我不认为字符串实现在这里是相关的,因为它在这里表示为数组,或者你的意思是其他什么? - user1803551
这一点提到了这一事实 StringBuilder 它的大小加倍 values 数组,即使有足够的内存,那么对于大于的数组大小 Integer.MAX_VALUE (通过检查溢出检测到), StringBuilder 会抛出一个 OutOfMemoryError  - 无 “Java堆空间”部分,即。 - Marco13


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

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

使用a连接字符串时 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 可能会被抛出。 (严格来说,不一定非必须如此  那里“记忆犹新”。鉴于数组可以拥有的最大大小,它只是检查溢出...)。

(编辑:还看看 用户1803551回答 :这不一定是您的错误来自的地方!你的确可能来自 Arrays 类,或者更确切地说来自JVM内部)

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

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


3
2017-07-31 11:32



我也看了一下 ensureCapacityInternal 一段时间以来,我无法通过一系列追加来想象 minimumCapacity 在堆大小变得太大之前会被数字溢出。可能是一个具体的计算是有序的。 - user1803551
@ user1803551考虑到重复的分配以及字符串这一事实,这里很难理解数学 附加 可能包含在字符串池中(导致进一步的内存压力)。但天真,一个 char[] 拥有10亿个元素的数组大约需要2 GB。可以使堆大小更大,例如,同 -Xmx16g。与此相反,阵列的技术限制 决不 Java语言和VM中内置了超过20亿个元素。更一般:即使堆足够大,OOME仍然可能会抛到这里。 - Marco13
我做了类似的计算。当数组长度为2147483647时发生溢出 char 是2个字节,填充该数组将需要~4.3GB。如果你能负担得起堆的大小,那么确实会发生溢出。我对我的评价过于乐观,认为堆大小几乎肯定是限制因素。在OOME被抛出之前重复分配是GC'd所以我不为此烦恼。我在我的例子中也使用了一个字符串来保持字符串池的小,这样就不会占用堆空间,但这仍然是限制因素。 - user1803551
出于好奇,哪个版本的Java是源代码? Java 8和Java 9非常不同 - 他们使用 newCapacity(int), hugeCapacity(int) 和 MAX_ARRAY_SIZE = Integer.MAX_VALUE-8 - Carlos Heuberger
@CarlosHeuberger那是来自Java8,我认为这是偶尔在7/8/9之间改变的一点(虽然我还没有深入研究9) - Marco13