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



我似乎在实现中偶然发现了一些有趣的东西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<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实现,其中桶的数量几乎总是比你要求的要多;但这样做是因为需要"两个幂"桶,但这里的情况并非如此。

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

您可以准确地获得所需的内容,以及指定的相应内容,即使在实现不同的旧版本中也是如此:

ArrayList()

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

ArrayList(int)

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

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

相反,具有int参数的构造函数将精确地使用指定的容量,受指定为

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

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

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

如本回答所述:

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

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

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

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

现在,如果您查看 openjdk(链接)中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 的原因。

对你的问题的简短回答是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大小。

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

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

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

但是为什么一个会膨胀到 10(比我要求的要多得多),另一个会膨胀到 1(完全按照我的要求)

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

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

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

含义:"未知"被解释为"几个",而"正好0(或1)"被解释为"嗯,正好是0或1"。

默认构造函数的容量为 10,仅仅是因为文档是这样说的。选择它作为明智的折衷方案,既不消耗太多内存,又在添加前几个元素时不必执行大量数组副本。

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

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

然后完成图片,显式初始化大小为 1 的ArrayList

预计增长速度会比默认的"10 元素"大小慢得多(直到它达到默认的"10 元素"大小),否则没有理由首先用一个小值初始化它。

最新更新