为啥使用不同的 ArrayList 构造函数会导致内部数组的增长率不同?

Posted

技术标签:

【中文标题】为啥使用不同的 ArrayList 构造函数会导致内部数组的增长率不同?【英文标题】:Why does using different ArrayList constructors cause a different growth rate of the internal array?为什么使用不同的 ArrayList 构造函数会导致内部数组的增长率不同? 【发布时间】:2019-10-31 23:24:31 【问题描述】:

我似乎在ArrayList 实现中偶然发现了一些我无法理解的有趣内容。这是一些代码,说明了我的意思:

public class Sandbox 

    private static final VarHandle VAR_HANDLE_ARRAY_LIST;

    static 
        try 
            Lookup lookupArrayList = MethodHandles.privateLookupIn(ArrayList.class, MethodHandles.lookup());
            VAR_HANDLE_ARRAY_LIST = lookupArrayList.findVarHandle(ArrayList.class, "elementData", Object[].class);
         catch (Exception e) 
            e.printStackTrace();
            throw new RuntimeException();
        
    

    public static void main(String[] args) 

        List<String> defaultConstructorList = new ArrayList<>();
        defaultConstructorList.add("one");

        Object[] elementData = (Object[]) VAR_HANDLE_ARRAY_LIST.get(defaultConstructorList);
        System.out.println(elementData.length);

        List<String> zeroConstructorList = new ArrayList<>(0);
        zeroConstructorList.add("one");

        elementData = (Object[]) VAR_HANDLE_ARRAY_LIST.get(zeroConstructorList);
        System.out.println(elementData.length);

    

这个想法是,如果你像这样创建一个ArrayList

List<String> defaultConstructorList = new ArrayList<>();
defaultConstructorList.add("one");

看看elementData(所有元素都保存在Object[])里面,它会报告10。因此,您添加了一个元素 - 您将获得 9 个额外的未使用的插槽。

另一方面,如果你这样做:

List<String> zeroConstructorList = new ArrayList<>(0);
zeroConstructorList.add("one");

您添加一个元素,保留的空间仅用于该元素,仅此而已。

在内部,这是通过两个字段实现的:

/**
 * Shared empty array instance used for empty instances.
 */
private static final Object[] EMPTY_ELEMENTDATA = ;

/**
 * Shared empty array instance used for default sized empty instances. We
 * distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
 * first element is added.
 */
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = ;

当您通过new ArrayList(0) 创建ArrayList 时,将使用EMPTY_ELEMENTDATA

当您通过new Arraylist() 创建ArrayList - 使用DEFAULTCAPACITY_EMPTY_ELEMENTDATA

我内心的直观部分 - 只需尖叫“删除DEFAULTCAPACITY_EMPTY_ELEMENTDATA”并让所有案例都使用EMPTY_ELEMENTDATA处理;当然是代码注释:

我们将此与 EMPTY_ELEMENTDATA 区分开来,以了解添加第一个元素时要膨胀多少

确实有道理,但是为什么一个膨胀到10(比我要求的要多得多)而另一个膨胀到1(完全符合我的要求)。


即使您使用List&lt;String&gt; zeroConstructorList = new ArrayList&lt;&gt;(0),并不断添加元素,最终您也会达到elementData 大于请求的值:

    List<String> zeroConstructorList = new ArrayList<>(0);
    zeroConstructorList.add("one");
    zeroConstructorList.add("two");
    zeroConstructorList.add("three");
    zeroConstructorList.add("four");
    zeroConstructorList.add("five"); // elementData will report 6, though there are 5 elements only

但它的增长速度小于默认构造函数的情况。


这让我想起了HashMap 实现,其中桶的数量几乎总是比你要求的多;但是这样做是因为需要“两个的幂”桶,但这里不是这种情况。

所以问题是 - 有人可以向我解释这种差异吗?

【问题讨论】:

In Java 8, why is the default capacity of ArrayList now zero?的可能重复 @Joe 我已经看过并阅读了那个,但它解决了一个不同的问题 - 这解释了一个空的 ArrayList具有大小为 @ 的数组987654346@了;数组是惰性计算的——这完全不同 @Eugene 这并没有完全不同。请注意,现在我们在ArrayList 中实现了两种 不同的策略,而过去只有一种。为了决定使用哪种策略,引入了新常量。 @JimmyB 正是这个问题 - 为什么 现在有两个? Q&A 中的想法是 ArrayList 现在,当它没有条目时,将由一个空数组备份,这可以通过 EMPTY_ELEMENTDATA 实现,那么为什么要保留两者呢? @Eugene 我没有具体的支持,但我想这会在现有代码中导致难以识别的问题,这些代码分配了很多 default-ctor 实例,并不断添加 (最多)10 个元素。如果它像new ArrayList&lt;&gt;(0) 的情况一样增长,你最终会分配更多的后备数组,这可能会增加内存使用/GC 使用。 【参考方案1】:

因为the docs say so,默认构造函数的容量是10。选择它作为在不立即使用太多内存和在添加前几个元素时不必执行大量数组副本之间的明智折衷。

零行为有点投机性,但我对我的推理相当有信心:

这是因为如果你显式初始化一个大小为零的ArrayList,然后向它添加一些东西,你是在说“我不希望这个列表包含太多,如果有的话完全没有。”因此,缓慢增长后备数组更有意义,就好像它是用值 1 初始化的,而不是将其视为根本没有指定初始值。因此,它会处理将其增长到仅 1 个元素的特殊情况,然后照常进行。

为了完成图片,显式初始化大小为 1 的 ArrayList 预计会比默认的增长慢得多(直到它达到默认的“10 元素”大小),否则会有一开始就没有理由用一个小的值来初始化它。

【讨论】:

【参考方案2】:

即使在实现不同的旧版本中,您也可以准确地获得您所要求的内容和指定的内容:

ArrayList()

构造一个初始容量为 10 的空列表。

ArrayList(int)

构造一个具有指定初始容量的空列表。

因此,使用默认构造函数构造 ArrayList 将为您提供初始容量为 10 的 ArrayList,因此只要列表大小为 10 或更小,就不需要调整大小操作。

相比之下,带有int参数的构造函数将精确地使用指定的容量,受制于growing policy,它被指定为

除了添加一个元素具有恒定的摊销时间成本这一事实之外,没有指定增长策略的细节。

即使您指定初始容量为零也适用。

Java 8 添加了将十元素数组的创建推迟到添加第一个元素的优化。这专门解决了ArrayList 实例(使用默认容量创建)长时间甚至整个生命周期都为空的常见情况。此外,当第一个实际操作是addAll 时,它可能会跳过第一个数组调整大小操作。这不会影响具有明确初始容量的列表,因为这些列表通常是经过仔细选择的。

如this answer中所述:

根据我们的性能分析团队,大约 85% 的 ArrayList 实例是按默认大小创建的,因此这种优化对绝大多数情况都有效。

动机是精确优化这些场景,而不是触及指定的默认容量,这是在创建 ArrayList 时定义的。 (虽然JDK 1.4 是第一个明确指定它的)

【讨论】:

我会考虑改写部分答案,因为 initial capacity 实际上不再是 10(除了对“initial capacity”应该是什么的不恰当的优雅解释意思是……)。 @Marco13 答案使用与the specification 相同的措辞。初始容量为十;只是一个优化增加了第一个数组创建的惰性。其他任何内容都不会受到影响,尤其是在使用 ArrayList 时是否以及指定什么作为替代初始容量的任何考虑都不会改变。 再一次,这可能被认为是对术语“初始容量”的挑剔:可以说,它足够模糊,以至于他们可以改变 Java 8 中的行为。但在此之前,“初始容量”的意思是:构造后直接内部数组的大小。这是10。现在是0。或者这么说:如果你称10为“初始容量”,你怎么称呼0? “前期容量”? @Marco13 因为那个零大小的数组不是特定于ArrayList 实例,而是一个共享数组,只是一个初始大小是默认大小的标记,我不会调用它的大小“初始能力”。他们本可以使用 null 代替,但由于与 JVM 优化器的已知交互,他们决定使用标记数组。除此之外,我仍然认为,当用“初始容量”来描述行为时,无论是您期望的方式,还是可以很容易理解的,然后添加一个针对特定情况的特定优化这一事实,与此不同意思。【参考方案3】:

您的问题的简短回答是 Java 文档中的内容:我们有 两个 常量,因为我们现在需要能够在以后区分 两个 不同的初始化,见下文。

他们当然可以引入而不是两个常量,例如ArrayListprivate boolean initializedWithDefaultCapacity 中的布尔字段;但这需要每个实例额外的内存,这似乎与保存几个字节内存的目标背道而驰。

为什么我们需要区分这两者?

看看ensureCapacity(),我们会看到DEFAULTCAPACITY_EMPTY_ELEMENTDATA会发生什么:

public void ensureCapacity(int minCapacity) 
    int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
        // any size if not default element table
        ? 0
        // larger than default for default empty table. It's already
        // supposed to be at default size.
        : DEFAULT_CAPACITY;

    if (minCapacity > minExpand) 
        ensureExplicitCapacity(minCapacity);
    

似乎这样做是为了与旧实现的行为有些“兼容”:

如果你确实用默认容量初始化列表,它现在实际上会用一个空数组初始化,但是,一旦插入第一个元素,它基本上会恢复到原来的样子行为与旧实现相同,即添加第一个元素后,后备数组具有DEFAULT_CAPACITY,从那时起,列表的行为与以前相同。

另一方面,如果您明确指定初始容量,则数组不会“跳转”到DEFAULT_CAPACITY,而是相对于您指定的初始容量增长。

我认为这种“优化”的原因可能是您知道您将只在列表中存储一个或两个(即少于DEFAULT_CAPACITY)元素并相应地指定初始容量;在这些情况下,例如对于单元素列表,您只会得到一个单元素数组,而不是 DEFAULT_CAPACITY-sized。

不要问我保存引用类型的九个数组元素有什么实用好处。每个列表可能高达大约 9*64 位 = 72 字节的 RAM。是的。 ;-)

【讨论】:

我感谢您对答案的“试用”,但它只是从不同的角度陈述了相同的问题......而且参考可能更像32 bits;除非你有一个相当大的堆。 我不认为它重复了这个问题。您的问题的简短回答是 Java 文档中的内容:我们有 two 常量,因为我们现在需要能够区分 two 以后的不同初始化,即在 @ 987654331@。为什么我们需要区分这两者? - 因为我们希望在一种情况下兼容并在另一种情况下优化内存,请参阅ensureCapacity() @Eugene 除了两个常量,他们当然可以引入例如ArrayList, private boolean initializedWithDefaultCap; 中的布尔字段,但这需要每个实例额外的内存,这似乎与 save 内存的目标背道而驰。 我猜在 64 位 JVM 上,内部对象引用将是本机指针大小,即 64 位。此外,如果 JVM 确实支持超过 2GB 的堆,它必须使用 > 32 位的指针。 @Eugene 哦,现在没有那个标志。感谢您指出:)【参考方案4】:

但是为什么一个膨胀到 10(比我要求的要多得多)而另一个膨胀到 1(完全符合我的要求)

可能是因为大多数创建列表的人都希望在其中存储超过 1 个元素。

您知道,当您只想要一个条目时,为什么不使用 Collections.singletonList() 为例。

换句话说,我认为答案是实用主义。当您使用默认构造函数时,典型 用例是您可能会快速添加少量元素。

含义:“unknown”被解释为“一些”,而“exactly 0 (or 1)”被解释为“hmm,exactly 0 or 1”。

【讨论】:

【参考方案5】:

如果使用默认构造函数,其想法是尝试平衡内存使用和重新分配。因此使用较小的默认大小 (10),这对于大多数应用程序来说应该没问题。

如果您使用具有显式大小的构造函数,则假定您知道自己在做什么。如果你用 0 初始化它,你实际上是在说:我很确定它要么保持为空,要么不会超过很少的元素。

现在,如果您查看 openjdk (link) 中 ensureCapacityInternal 的实现,您会发现只有第一次添加项目时,这种差异才会发挥作用:

private void ensureCapacityInternal(int minCapacity) 
    if (elementData == EMPTY_ELEMENTDATA) 
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    

    ensureExplicitCapacity(minCapacity);

如果使用默认构造函数,大小会增长到DEFAULT_CAPACITY (10)。这是为了防止在添加多个元素时进行过多的重新分配。但是,如果您显式创建了大小为 0 的 ArrayList,它只会在您添加的第一个元素上增长到大小 1。这是因为你告诉它你知道你在做什么。

ensureExplicitCapacity 基本上只是调用grow(带有一些范围/溢出检查),所以让我们看看:

private void grow(int minCapacity) 
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);

如您所见,它不会简单地增长到特定的大小,而是会尝试变得聪明。数组越大,即使minCapacity 仅比当前容量大 1,它也会增长得越大。这背后的原因很简单:如果列表已经很大,则添加大量项目的概率会更高,反之亦然。这也是为什么在第 5 个元素之后您会看到增长增量先增加 1,然后再增加 2。

【讨论】:

我可能需要多考虑一下,但我喜欢你的推理;尤其是答案最底部的那些。 非线性增长甚至是强制性的,以提供“添加元素具有恒定的摊销时间成本”的保证,就像当您在每个 add 上增加一个数组时,您会所有添加元素的时间成本都是二次方。【参考方案6】:

这很可能是由于两个构造函数具有不同的感知默认用途。

默认(空)构造函数假定这将是“典型的ArrayList”。因此,数字10 被选为一种启发式方法,也就是“插入的元素的典型平均数量将不会占用太多空间,但也不会不必要地增长数组”。另一方面,容量构造函数的前提是“你知道你在做什么”或“你知道你将使用ArrayList for”。因此,不存在这种类型的启发式算法。

【讨论】:

以上是关于为啥使用不同的 ArrayList 构造函数会导致内部数组的增长率不同?的主要内容,如果未能解决你的问题,请参考以下文章

为啥在构造函数中抛出异常会导致空引用?

为啥在构造函数中释放会导致 EXC_BAD_ACCESS?

为啥 C++ 构造函数在继承中需要默认参数?

为啥为非泛型方法或构造函数提供显式类型参数会编译?

为啥要以初始容量启动 ArrayList?

为啥 cProfile 会导致函数返回不同的值?