14

我似乎在实现中偶然发现了一些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(所有元素都保存在哪里) 。因此,您添加了一个元素 - 您将获得 9 个未使用的额外插槽。Object[]10

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

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 = {};

当你创建一个ArrayListvia new ArrayList(0)-EMPTY_ELEMENTDATA时将被使用。

当你创建一个ArrayList通过new Arraylist()-DEFAULTCAPACITY_EMPTY_ELEMENTDATA使用。

我内心最直观的部分——简单地尖叫“删除DEFAULTCAPACITY_EMPTY_ELEMENTDATA”,让所有的案件都处理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实现,桶的数量几乎总是比你要求的多;但是这样做是因为需要“两个的力量”桶,但这里的情况并非如此。

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

4

6 回答 6

16

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

ArrayList()

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

ArrayList(int)

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

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

相反,带有int参数的构造函数将精确地使用指定的容量,受制于指定为的增长策略

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

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

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

本答案所述:

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

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

于 2019-06-18T11:22:59.527 回答
3

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

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

现在,如果您查看ensureCapacityInternalopenjdk ( 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。

于 2019-06-18T11:05:24.760 回答
1

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

他们当然可以在 , 中引入布尔字段,而不是两个ArrayList常量private 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_CAPACITYand 从那时起,列表的行为与以前相同。

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

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

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

于 2019-06-18T10:59:17.037 回答
0

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

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

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

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

含义:“未知”被解释为“一些”,而“正好 0(或 1)”被解释为“嗯,正好 0 或 1”。

于 2019-06-18T11:15:39.440 回答
0

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

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

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

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

于 2019-06-18T11:11:32.973 回答
0

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

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

于 2019-06-18T11:02:47.323 回答