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

14

ArrayList的实现中,我似乎偶然发现了一些有趣的东西,但是我无法理解。以下是一些代码,展示了我的意思:


Translated:

我似乎在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");

查看 elementDataObject[] 存放所有元素的地方)的内部,它将报告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。尽管代码注释提到“我们将其与EMPTY_ELEMENTDATA区分开来,以了解在添加第一个元素时要扩展多少”,但是为什么一个要扩展到10(比我要求的多得多),而另一个只扩展到1(正好和我请求的一样)还是不太清楚的。即使您使用List<String> zeroConstructorList = new ArrayList<>(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的实现,那里的存储桶数量几乎总是比您请求的多;但那是因为需要“2的幂”个存储桶,而不是本例中的情况。

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


3
@Joe,我看过并阅读了那个问题,但它回答的是另一个问题——解释了一个空的ArrayList不再具有大小为10的数组;该数组是惰性计算的——这完全不同。 - Eugene
2
@Eugene,它并不完全不同。请注意,现在我们在ArrayList中实现了两种不同的策略,而以前只有一种。为了决定使用哪种策略,引入了新的常量。 - JimmyB
@JimmyB 确切的问题是 - 为什么现在有两个?那个问答中的想法是,当 ArrayList 没有任何条目时,它现在将由一个空数组支持,这可以通过 EMPTY_ELEMENTDATA 实现,那么为什么还要保留两者呢? - Eugene
1
@Eugene,我没有具体的依据支持这一点,但我想象它可能会在已经为大量默认构造函数实例分配内存的现有代码中引起难以识别的问题,并且不断向其中添加(最多)10个元素。如果增长像new ArrayList<>(0)那样,你将不得不分配更多的后备数组,这可能会增加内存使用/垃圾回收使用率。 - Andy Turner
@AndyTurner,实际上我并没有特别指出这一点,我只能假设在分析了大量的实时应用程序后,他们得出结论:绝大多数new ArrayList():1)要么不向其中添加任何内容(因此需要一个空数组)2)添加最多10个元素,但几乎总是接近10个。 - Eugene
6个回答

16

您将获得您所要求的内容,无论在旧版本中实现方式如何,都将得到相应的结果:

ArrayList()

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

ArrayList(int)

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

因此,使用默认构造函数构造ArrayList将会给您一个初始容量为十的ArrayList,只要列表大小不超过十个,就不需要进行任何调整操作。

相比之下,带有int参数的构造函数将精确地使用指定的容量,受到增长策略的限制,该策略被指定为

增长策略的细节未作规定,除了添加元素具有恒定的平摊时间成本外。

即使您指定了初始容量为零,该规则仍适用。

Java 8增加了优化功能,延迟创建十个元素的数组直到添加第一个元素。这主要解决了ArrayList实例(使用默认容量创建)长时间保持为空甚至终其一生为空的常见情况。此外,在第一个实际操作是addAll时,它可能会跳过第一个数组调整大小操作。这不会影响具有显式初始容量的列表,因为通常会仔细选择它们。

正如这篇答案中所述:

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

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


我会考虑重新措辞回答的某些部分,因为初始容量实际上不再是10(除非对“初始容量”应该意味着什么有一个不恰当宽容的解释...)。 - Marco13
7
@Marco13 的回答使用了与 该规范 相同的措辞。初始容量为十;只是在第一个数组创建时添加了一些懒惰优化。其他任何内容都不受影响,特别是使用 ArrayList 时是否以及如何指定替代初始容量的考虑,都不会改变。 - Holger
再次强调,“初始容量”这个术语可能被视为吹毛求疵:可以说,它的含义足够模糊,以至于在Java 8中可以更改其行为。但在此之前,“初始容量”的含义是:构建后内部数组的大小。而这个大小是10。现在是0。或者这么说:如果你把10称为“初始容量”,那么你如何称呼0呢?“前置初始容量”? - Marco13
8
@Marco13,由于这个大小为零的数组不是特定于ArrayList实例的,而是一个共享数组,仅仅是一个标记,表示初始大小是默认大小,我不会称其大小为“初始容量”。他们本可以使用null,但由于已知与JVM优化器的交互作用,决定使用标记数组。除此之外,我仍然认为,如果用“初始容量”来描述它的行为方式,这个行为是很容易理解的,无论你是以你期望的方式,还是添加了一个特定情况的特定优化,跟这个意思有所偏差。 - Holger

3
如果您使用默认构造函数,则意图是尝试平衡内存使用和重新分配。因此,使用较小的默认大小(10),这应该对大多数应用程序都可以满足要求。
如果您使用具有显式大小的构造函数,则假定您知道自己在做什么。如果您将其初始化为0,则基本上是在说:我非常确定这将保持为空或不会增长超过很少的元素。
现在,如果您查看openjdk中ensureCapacityInternal的实现(link),则可以看到只有第一次添加项时,才会出现这种差异:
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的原因。

我可能需要再考虑一下,但我喜欢你的推理;特别是你回答底部的那些。 - Eugene
3
非线性增长甚至是必须的,以提供“添加元素具有恒定摊销时间成本”的保证,因为当你在每个“添加”操作中将数组增长一个元素时,所有添加的元素都将导致二次时间成本。 - Holger

1
你的问题的简短回答就是Java文档中所述:我们有两个常量是因为我们现在需要在稍后区分这两种不同的初始化,请参见下面的内容。
他们当然可以引入一个布尔字段例如ArrayList中的“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大小的数组。
不要问我保存九个引用类型数组元素的实际好处是什么。可能每个列表最多节约大约9 * 64位 = 72字节的RAM。耶。;-)

我感谢你“试探性”的回答,但它只是从不同的角度陈述了同一个问题...并且一个参考值可能更接近于32位;除非你有一个相当大的堆。 - Eugene
2
我认为它没有重复问题。对你的问题的简短回答就是Java文档中所述:我们有两个常量,因为我们现在需要能够区分以后的两个不同初始化,即在ensureCapacity()中。为什么我们需要区分这两个?-因为我们想在一种情况下保持兼容性,在另一种情况下优化内存,参见ensureCapacity() - JimmyB
@Eugene 他们当然可以引入一个布尔字段 private boolean initializedWithDefaultCap; 来代替两个常量,但这将需要每个实例额外的内存,这似乎违背了节省内存的目标。 - JimmyB
我猜在64位的JVM中,对象引用内部将是本地指针大小,即64位。此外,如果JVM支持超过2GB的堆,则必须使用大于32位的指针。 - JimmyB
@Eugene Oh,不知道那个标志。谢谢你指出来 :) - JimmyB

0

这很可能是因为两个构造函数具有不同的默认使用方式。

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


0

默认构造函数的容量为10,仅仅是因为文档如此说明。这是一个明智的折衷方案,既不会一开始就占用太多内存,又不必在添加前几个元素时执行大量的数组复制操作。

零行为略有推测,但我对我的推理相当有信心:

这是因为如果你使用大小为零的ArrayList进行显式初始化,然后向其中添加内容,你就是在说“我不希望这个列表保存太多东西,甚至可能什么都不保存。”因此,将支持数组缓慢增长,就好像它被初始化为1一样,而不是像没有指定初始值一样处理它。所以它处理了增长到只有1个元素的特殊情况,然后继续正常运行。

为了完整地描述,使用大小为1的ArrayList进行显式初始化,预计增长速度要比默认值慢得多(直到达到默认的“10个元素”大小),否则就没有理由一开始就使用小的值进行初始化。


0
“但是为什么一个会膨胀到10(比我要求的多得多),而另一个只膨胀到1(正好和我要求的一样)?”
“可能是因为大多数创建列表的人想要在其中存储不止一个元素。”
“你知道,当你只想要一个条目时,为什么不使用Collections.singletonList()呢?”
“换句话说,我认为答案是实用主义。当你使用默认构造函数时,典型的用例可能是快速添加几个或更多的元素。”
“也就是说:“未知”被解释为“几个”,而“恰好为0(或1)”则被解释为“嗯,恰好为0或1”。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接