我似乎偶然发现了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 = {};
通过ArrayList
创建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
的实现,其中存储桶的数量几乎总是比您要求的要多;但是之所以这样做是因为需要“两个”的存储桶,尽管这里不是这种情况。
问题是-有人可以向我解释这种差异吗?
答案 0 :(得分:14)
即使在实现不同的旧版本中,您也可以准确地得到所需的内容,所指定的内容:
ArrayList()
构造一个初始容量为10的空列表。
ArrayList(int)
使用指定的初始容量构造一个空列表。
因此,使用默认构造函数构造ArrayList
将为您提供ArrayList
,其初始容量为10,因此只要列表大小为10或更小,就不会进行任何大小调整操作需要。
相反,带有int
参数的构造函数将精确地使用指定的容量,但要受growing policy的约束,
除了添加元素具有固定的摊销时间成本外,没有指定增长策略的详细信息。
即使您将初始容量指定为零,它也适用。
Java 8添加了以下优化:将十个元素的数组的创建推迟到添加第一个元素之前。这是专门针对以下情况:ArrayList
实例(使用默认容量创建)在很长一段时间甚至整个生命周期内都保持为空。此外,当第一个实际操作为addAll
时,它可能会跳过第一个数组调整大小操作。这不会影响具有明确初始容量的列表,因为通常会仔细选择这些列表。
如this answer中所述:
根据我们的性能分析小组的说法,大约有85%的ArrayList实例是在默认大小下创建的,因此该优化将在绝大多数情况下有效。
这样做的动机是精确地优化这些方案,而不是触摸创建ArrayList
时定义的指定默认容量。 (尽管JDK 1.4是第一个明确指定它的人)
答案 1 :(得分:3)
如果使用默认构造函数,则其目的是尝试平衡内存使用和重新分配。因此,使用较小的默认大小(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,数组也会增大。这背后的原因很简单:如果列表已经很大,则添加lof项的可能性就更高,反之亦然。这也是为什么在第5个元素之后看到增长分别为1和2的原因。
答案 2 :(得分: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_CAPACITY
,从此以后,列表的行为与以前相同。
另一方面,如果您明确指定了初始容量,则该数组不会“跳转”到DEFAULT_CAPACITY
,而是从您指定的初始容量开始相对增加。
我认为进行这种“优化”的原因可能是因为您知道您将只在列表中存储一个或两个(即少于DEFAULT_CAPACITY
)元素,并相应地指定了初始容量;在这种情况下,例如对于单元素列表,您只会得到一个单元素数组,而不是DEFAULT_CAPACITY
大小。
不要问我,保存九个引用类型的数组元素的实用好处是什么。每个列表可能最多约9 * 64位= 72字节RAM。是的;-)
答案 3 :(得分:0)
这很可能是由于两个构造函数具有不同的感知默认用途的情况。
默认(空)构造函数假定这将是“典型的ArrayList
”。因此,选择数字10
作为一种试探法,也就是“插入的元素的典型平均数量将是不会占用太多空间但也不会不必要地增加数组”。另一方面,容量构造函数的前提是“您知道自己在做什么”或“您知道将使用ArrayList for
”。因此,不存在这种启发式方法。
答案 4 :(得分:0)
由于the docs say so,默认构造函数的容量为10。选择这种方式是明智的折衷方案,既可以在不浪费过多内存的情况下,又可以在添加前几个元素时不必执行大量数组副本。
零行为有点投机,但是我对我的推理很有信心:
这是因为,如果您明确地初始化大小为零的ArrayList
,然后向其中添加一些内容,那是在说:“我不希望这个列表有很多东西,如果有的话。”因此,慢慢地增加后备阵列(就像它是用值1初始化),而不是像根本没有指定初始值一样对待它,就显得非常有意义。因此,它处理了将其增长到1个元素然后再正常进行的特殊情况。
为完成图片,可以预期ArrayList
的显式初始化为1的增长(直到达到默认的“ 10元素”大小)要比默认的慢得多,否则,没有理由首先使用较小的值对其进行初始化。
答案 5 :(得分:0)
但是为什么一个会膨胀到10(比我要求的要多得多),另一个会膨胀到1(与我要求的一样多)
可能是因为大多数创建列表的人都希望在其中存储多于 1个元素。
您知道,当您只需要一个条目时,为什么不使用Collections.singletonList()
。
换句话说,我认为答案是实用主义。当您使用默认构造函数时,典型用例将是您要快速添加少量元素。
含义:“未知”解释为“几个”,而“恰好0(或1)”解释为“嗯,正好是0或1”。