ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ArrayList默认容量为何是10?源码解析扩容机制与面试要点

ArrayList默认容量为何是10?源码解析扩容机制与面试要点 1. 为什么偏偏是10而不是8或16先把这个经典面试题的答案说清楚ArrayList的默认初始化容量确实是10这个值定义在源码里就是我们常见的private static final int DEFAULT_CAPACITY 10;。但如果你以为“10”是什么经过精密数学推导得出的神奇数字那就想多了它更多是一个经验值是JDK开发人员在“空间占用”和“扩容成本”之间做的一个折中。我在带团队面试时经常拿这道题考察候选人对集合源码的理解深度。很多候选人能背出“容量是10”但当我追问“为什么不是8为什么不是16扩容为什么是1.5倍而不是2倍”时能答清楚的凤毛麟角。先说结论JDK里并没有官方文档解释“为什么是10”但结合Java集合框架的发展历史和JVM的内存分配机制我们可以从几个角度来推算这个值的合理性CPU与内存的折中考量底层数组分配在堆内存上太小了频繁扩容太大了浪费空间。10这个值对大多数业务场景来说是一个“够用且不浪费”的起步值。JVM对象头对齐相关的隐性逻辑虽然10本身没有对齐的特殊效果但ArrayList扩容到10后新增的数组对象开销在当时的JVM版本里是相对友好的。历史包袱与兼容性JDK 1.2引入集合框架时这个值就定成了10。后续版本哪怕内部逻辑大改比如Java 8引入了延迟初始化为了兼容既有行为这个值也一直保留至今。我刚接触Java那会儿以为ArrayList初始化就分配了10个元素的空间。后来追源码才发现new ArrayList()的时候底层其实是一个空数组真正分配那10个容量是你第一次往里面add元素时才发生的。这一点很多人答错也是面试官最爱埋的坑。2. 从源码层面拆解容量分配的真实时机既然聊到初始化容量就绕不开源码。不同JDK版本的实现略有差别但核心思路一致。我们以最常用的Java 8为例把几个关键方法拆开看。2.1 三个构造方法三种底层形态ArrayList有三个构造方法对应的底层数组状态完全不同// 无参构造底层是一个空数组注意不是容量10的数组 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA {}; public ArrayList() { this.elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } // 指定初始容量构造直接创建对应大小的数组 public ArrayList(int initialCapacity) { if (initialCapacity 0) { this.elementData new Object[initialCapacity]; } else if (initialCapacity 0) { this.elementData EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException(Illegal Capacity: initialCapacity); } } // 传入集合构造用集合元素填充 public ArrayList(Collection? extends E c) { elementData c.toArray(); if ((size elementData.length) ! 0) { // c.toArray() 可能返回的不是 Object[] 类型需要单独处理 if (elementData.getClass() ! Object[].class) elementData Arrays.copyOf(elementData, size, Object[].class); } else { this.elementData EMPTY_ELEMENTDATA; } }这里有个很反直觉的点无参构造出来的ArrayList容量是0不是10。真正的默认容量10是在第一次add元素的时候才“激活”的触发的逻辑在ensureCapacityInternal方法里。2.2 第一次add时发生了什么顺着add()方法一路看下去会走到这两个核心方法private void ensureCapacityInternal(int minCapacity) { // 关键判断如果当前还是那个默认的空数组 if (elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 取默认容量10和传入容量的较大值 minCapacity Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount; // 只有当需要的容量超过当前数组长度时才执行扩容 if (minCapacity - elementData.length 0) grow(minCapacity); }所以整个流程是这样的你new了一个ArrayList底层是个空数组size是0。第一次调用add()minCapacity变成1但因为底层还是DEFAULTCAPACITY_EMPTY_ELEMENTDATA会被强行拉高到10然后才走进grow()去分配一个容量为10的Object数组。这就是面试里常说的一句话ArrayList的默认容量10是首次添加元素时分配的不是构造时分配的。如果你只是new了一个ArrayList但从不添加元素它永远不会消耗那10个对象引用的堆空间。2.3 扩容时为什么是1.5倍这是同一个话题下最常见的衍生问题。看JDK 8的grow()方法private void grow(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); // 位运算等价于1.5倍 if (newCapacity - minCapacity 0) newCapacity minCapacity; if (newCapacity - MAX_ARRAY_SIZE 0) newCapacity hugeCapacity(minCapacity); elementData Arrays.copyOf(elementData, newCapacity); }oldCapacity 1就是把旧容量右移一位相当于除以2所以新容量是oldCapacity oldCapacity / 2即1.5倍。为什么不直接2倍原因其实有三个层面内存利用率更高。按2倍扩容数组长度会快速膨胀比如从10涨到20、40、80但实际存储的元素远没到满造成空间白白浪费。1.5倍的增长相对平缓在空间上更友好。扩容次数增加但总成本可控。扩容需要Arrays.copyOf底层是System.arraycopy批量复制很快。虽然1.5倍比2倍扩容次数多但因为复制的数据量总体接近时间成本差异不大换来的是空间占用明显优化。经验公式3倍太费2倍偏多1.5倍合适。Java集合框架的设计者权衡后选择了1.5倍这个值在实际大数据量的List操作中表现得比较均衡。顺便提一句MAX_ARRAY_SIZE是Integer.MAX_VALUE - 8这是JVM数组对象头的限制——数组长度超过这个值在某些JVM上可能直接OOM。所以如果你的数据量真要逼近这个上限系统早就先在堆内存上撑不住了。3. 初始化容量10在底层存储上的开销有多大容量10听起来不大但如果你对JVM内存模型不敏感可能意识不到这背后的分配细节。我实测过一次在64位JVM上、开启指针压缩的情况下一个Object引用占4字节分配一个容量10的Object数组大概要消耗多少内存简单算一下数组对象头12字节mark word 8字节 类指针压缩后4字节数组额外4字节用于记录length总16字节按8字节对齐数组数据区10个引用 × 4字节 40字节总大小56字节按8字节对齐后仍是56字节如果创建了一个有100万个元素的ArrayList并且每次都用无参构造然后一个个add中途会经历多次扩容。从10开始按1.5倍递增扩容序列是15、22、33、49、73、109、163、244、366、549、823、1234、1851、2776、4164、6246、9369、14053、21079、31618、47427、71140、106710、160065、240097、360145、540217、810325、1215487——在到100万的过程中一共扩容了29次每次都要把旧数组元素全部复制到新数组。这就是为什么我一再强调如果你提前知道数据规模一定要用带初始容量的构造方法。一个new ArrayList(1000000)直接分配到位省掉的不仅是29次数组分配和复制更是这几百万次引用拷贝对GC的压力和响应时间的影响。我在一个实际的接口性能优化中测过往ArrayList里插入50万条数据用无参构造耗时约180ms用预分配容量的构造耗时约95ms差距接近一倍。原因就在于扩容时的System.arraycopy是高频操作虽然单次很快但积累起来开销可观。3.1 一个很容易被忽略的细节数组复用与内存泄漏ArrayList扩容后旧数组如果还在被引用就不会被GC回收。实际开发中有些团队会用ArrayList.clear()来清空列表但要注意clear()只是把每个元素置为null并重置size底层数组还是那个数组容量并不会缩小。如果你有一个超大容量的ArrayList长驻内存clear掉之后那部分内存依然被占据。解决方案有两个一个是调用trimToSize()方法把底层数组的容量收缩到和size一致另一个是干脆把list置为null让整个对象可被回收。前者适合你还想复用这个list的场景后者适合彻底放弃的场景。3.2 为什么只有ArrayList有这个“懒加载”设计其实不只是ArrayList很多集合类都有类似的延迟初始化机制。比如HashMap你new HashMap()时底层table也是空的第一次put才会扩容到16的默认容量。这个设计思路在JDK 8之后越来越普遍——能懒就懒减少不必要的资源占用。Java 8的ArrayList让无参构造变成了“空数组 首次扩容到10”的模式而不是像Java 7那样直接分配10的容量。这个改动的直接动机很简单大量开发者在实际开发中会new一个ArrayList然后什么也不放或者放一两个元素如果每次构造都白占10个引用位对于创建了大量空集合的服务端程序来说是实实在在的内存浪费。虽然堆内存是动态的但大量小数组对象对GC扫描的影响是真实的尤其在高频创建集合的场景下优化效果会被放大。4. 不同初始容量搭配的业务场景选择聊完了源码和原理回到实际开发中什么时候用默认容量10就够了什么时候必须显式指定这里我给出自己这些年总结的一套选型习惯不一定绝对正确但踩过不少坑后验证下来还算可靠。4.1 该用默认容量的场景数据量在10以内且不确定未来会不会变多。比如一个方法里临时存几个状态值、解析几个固定字段这种就用无参构造。元素数量高度不确定可能是0也可能很多。这时用无参构造让ArrayList按需扩容即可。强行指定一个巨大的初始容量反而造成浪费。接受外部传入的Collection进行包装。直接new ArrayList(existingList)容量由集合大小决定根本不用你操心。4.2 必须显式指定容量的场景批量导入、批量查询结果集。比如从数据库查询10万条记录你提前知道大概的量直接new ArrayList(100000)。高频add操作的循环场景。比如在for循环里往list里塞数据循环次数确定或可预估。大列表的成员变量。如果一个ArrayList是类成员属性长期驻留内存恰好又没法预估最终大小建议评估后给一个偏大的初始容量或者等数据装载完成后调用trimToSize()裁掉多余空间。这里有一个经验公式如果你预估最终存放N个元素初始容量直接给N即可。如果同时还要往里add很多次且可能预估不准确给到N的1.5倍作为缓冲能明显减少扩容次数。当然这只是一层粗放的经验判断具体还是看业务特征。4.3 一个让我印象深刻的线上事故有一年我做了一个报表导出功能数据库里一张表有近百万条数据我偷懒没指定初始容量直接往ArrayList里塞。结果在导出过程中频繁扩容JVM Young GC明显增多导出接口的平均耗时从300ms被拉到900ms多而且高峰期出现过几次较长的GC停顿。后来我改成new ArrayList(rows.size())前提是SQL里多查了一个count接口耗时直接回落到200ms以内。那次之后我在代码评审里看到“new ArrayList()然后循环里疯狂add”的写法一定会追问一句能预估大小吗4.4 容量对迭代器和子列表的影响还有一个比较隐蔽的坑ArrayList在迭代过程中如果发生结构性修改modCount变化会抛出ConcurrentModificationException。这里的modCount在扩容时也会增加所以如果你在迭代过程中add数据大概率会踩到并发修改异常。从这个角度看合理预估初始容量也能降低迭代中途扩容引发bug的概率虽然不是你直接触发扩容但迭代期间如果其他线程改了list照样有问题。5. 面试怎么答以及延伸出来的“加分项”回到最初的问题。面试官如果问你“为什么ArrayList默认容量是10”你该怎么答结合我多年面试和被面试的经验一个比较立体的回答思路大概是这样的5.1 基础版回答先稳定输出核心事实无参构造的ArrayList默认容量是10但这个10是首次添加元素时才分配的不是构造时就分配好的。JDK 8里无参构造用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA空数组第一次add时通过ensureCapacityInternal把minCapacity拉高到10再执行扩容。5.2 进阶版回答紧接着可以从源码和数据结构层面补充几个关键点提示 1扩容因子是1.5利用右移一位实现新增容量是当前容量的1.5倍。这个设计是为了在时间和空间上取得平衡避免2倍扩容带来的空间浪费同时把复制成本控制在一个合理的区间。提示 2为什么选择10作为一个经验起步值其实官方没有明确说明更多的是一种结合常见业务数据量的工程折中选择——太小容易频繁扩容太大在大量空集合场景下浪费内存。面试时主动承认这是一个经验值并给出自己理解的理由反而比硬背结论加分。提示 3如果构造ArrayList时传入初始容量一定要用正整数传0得到空数组传负数直接抛IllegalArgumentException。5.3 加分项回答如果你还想在面试中多展示一些层次可以把这几个延伸话题串进去ArrayList与LinkedList的内存布局差异、CAP理论里List和Set的适用场景、HashMap的默认容量为什么是16而不是10、集合的fail-fast机制是如何通过modCount实现的。这样整场面试就从“背八股”变成了“展示你对集合框架底层设计的理解”面试官会明显更愿意跟你深入交流。顺便说一句现在网上很多“Java面试八股文”对这个问题的解读基本都提过但大多只停留在“默认10”这个表层结论。真正能在面试中脱险的候选人一定是把“延迟分配”“扩容因子”“内存开销”这几个点串在一个完整故事里的——你缺的不是结论而是一条能把结论讲圆的逻辑链。6. 实操中遇到的其他容量相关“坑”盘点既然标题涉及初始化容量就再把实际操作中几个跟容量强相关的坑一并列出来这些都是我在开发中真实踩过的每一条都对应着一段查源码查到怀疑人生的经历。6.1 坑一用Arrays.asList后直接add直接报UnsupportedOperationExceptionListString list Arrays.asList(a, b, c); list.add(d); // 抛异常原因很简单Arrays.asList返回的是一个内部私有类java.util.Arrays$ArrayList虽然名字里带ArrayList但它和真正的java.util.ArrayList完全是两码事。这个内部类没有重写add/remove方法直接调用了AbstractList的默认实现而AbstractList的add默认就是抛UnsupportedOperationException。正确的做法是包一层new ArrayList(Arrays.asList(a, b, c))这样底层就是真正可变、可扩容的ArrayList了。这个点几乎能难倒一大批刚入门Java的开发者问得太多了。6.2 坑二subList返回的视图操作会直接反映到原List上ListInteger list new ArrayList(Arrays.asList(1, 2, 3, 4, 5)); ListInteger sub list.subList(1, 3); sub.add(99); System.out.println(list); // [1, 2, 3, 99, 4, 5]subList返回的是原List的一个视图不是快照。对subList的修改会同步到原List。如果原List在subList使用期间发生了结构变化再操作subList就会抛ConcurrentModificationException。很多初学者拿subList当独立list用结果查了半天bug。这个点虽然不直接关于初始化容量但和底层数组的管理方式密不可分。6.3 坑三ArrayList的toArray和泛型擦除list.toArray()返回的是Object[]不是为了泛型而定的。list.toArray(new String[0])传一个指定类型的数组是为了让运行时能拿到类型信息。Java 11之前推荐传new String[0]Java 11之后官方推荐new String[0]仍是最高效的写法。这里不展开太多但面试偶尔会串着问。6.4 坑四容量和size是两个完全不同的概念好多人在概念上混着用其实容量capacity底层Object[]数组的长度。无参构造第一次add后是10扩容后可能是15、22、33……大小sizeArrayList里实际存了多少个元素。Java没有直接的capacity()方法但你可以通过反射黑科技拿到elementData数组的长度来查看当前容量。这个方法不宜在生产环境用但本地调试时很好使import java.lang.reflect.Field; import java.util.ArrayList; public class ArrayListCapacityDebug { public static void main(String[] args) throws Exception { ArrayListInteger list new ArrayList(); System.out.println(初始容量: getCapacity(list)); // 0或10取决于是否add过 list.add(1); System.out.println(add一个元素后容量: getCapacity(list)); // 10 // 扩容到第11个元素 for (int i 1; i 10; i) { list.add(i); } System.out.println(放满10个后再add几个容量变成: getCapacity(list)); // 15 } private static int getCapacity(ArrayList? list) throws Exception { Field f ArrayList.class.getDeclaredField(elementData); f.setAccessible(true); return ((Object[]) f.get(list)).length; } }这个脚本跑完之后你会发现ArrayList在容量10放满后继续add触发扩容新容量是1510 10/2验证了1.5倍扩容的结论。顺手还能看一眼size和capacity呈现的“隔离状态”很直观。7. ArrayList和LinkedList的容量差异以及各自的“适用边界”既然热词里高频出现“arraylist和linkedlist”的比较这里也把容量维度的对比放进来。两者在数据结构和容量行为上的差异很容易成为面试里“连环炮”的入口。7.1 容量与底层结构对比ArrayList底层是连续数组get(0)是O(1)操作add(E)在尾部最理想在头部大概率O(n)。LinkedList底层是双向链表add(E)在尾部O(1)但get(index)得从头或尾开始遍历平均O(n/2)。从容量角度讲ArrayList有“初始化容量”“扩容”“缩容”的概念LinkedList则没有容量概念添加元素时new一个Node节点挂在链上删除节点后GC直接回收。这也是为什么LinkedList在极端的大数据量随机访问场景下会明显慢于ArrayList。所以网上那些“LinkedList适合频繁插入删除ArrayList适合随机访问”的说法不是说LinkedList就一无是处而是要分场景看数据规模。如果你在几万个元素量级下做大量删除插入而且集中在头部LinkedList有优势但如果数据量到了百万级且主要做随机读ArrayList完胜。7.2 应该怎么选我的习惯是90%的场景用ArrayList有大量头部插入删除且数据量较大比如超过10万条时才会考虑LinkedList同时注意LinkedList的Node对象占用更大每个元素比数组多存prev/next两个引用在内存敏感场景里也算一个考量因素。还有一点LinkedList实现了Deque接口可以当栈或队列用。但如果你只是需要一个简单的后进先出队列Java 8之后的ArrayDeque实际上综合性能更好它也是动态扩容的初始容量是16扩容策略和ArrayList类似按双倍增长。7.3 阿里Java开发手册的提示部分大厂的代码规范里其实也提到过集合初始化尽量指定大小。比如阿里巴巴Java开发手册就有这么一条“集合初始化时指定集合初始值大小。说明HashMap使用HashMap(int initialCapacity)初始化ArrayList使用ArrayList(int initialCapacity)初始化。”建议维护的初衷很简单——避免扩容带来的性能损耗这在服务端高并发场景下是真实的优化点。8. 一次性搞懂ArrayList容量相关的几个高频面试题下面这些问题是我实际收集整理过的高频面试题建议每个Java开发者在面试前都过一遍答对了是基础答错了就暴露基础不牢。8.1 问题一new ArrayList(0)和无参构造有什么区别new ArrayList(0)底层初始化为EMPTY_ELEMENTDATA空数组之后add时ensureCapacityInternal不会把容量拉高到10而是直接按1扩充到1准确说是比较后取较大值。而无参构造用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA第一次add时会强行扩到10。究其原因在于代码里只对DEFAULTCAPACITY_EMPTY_ELEMENTDATA做了和DEFAULT_CAPACITY取较大的逻辑而EMPTY_ELEMENTDATA没有这层特殊处理。8.2 问题二ArrayList最大容量是多少理论上限是Integer.MAX_VALUE - 8对应源码里的MAX_ARRAY_SIZE。但实际操作中受堆内存限制远没到理论值就可能OOM。所以真实场景不要想着塞满整整21亿个元素那会让你的程序在任何一个JVM参数下面试都过不了。8.3 问题三如何在高并发下安全使用ArrayList这本身就是一个矛盾的问题线程不安全的ArrayList在高并发读写下会出各种问题比如脏读、索引越界、扩容期间数据异常等。可行方案有使用Collections.synchronizedList包一层或者使用CopyOnWriteArrayList再或者在读多写少时用不可变集合。注意CopyOnWriteArrayList每次set/add都会复制整个底层数组数据量大时成本很高不能拿它当普通List用。8.4 问题四为什么ArrayList的效率比Vector高从历史角度看Vector是JDK 1.0时代的老集合线程安全方法上加了synchronized性能远不如ArrayList。ArrayList不是线性安全的所以单线程环境下几乎总是比Vector快。如果你确实需要线程安全且是List语义优先考虑CopyOnWriteArrayList或Collections.synchronizedList。8.5 问题五为什么ArrayList用int做size而不是long因为数组下标在Java里就是int数组最大长度受限于int最大值。如果要用long做size等于自己再造一套容器体系复杂度会大幅上升。实际业务里到了21亿个元素的List绝大多数系统内存早就不够了。9. 从JDK 8到JDK 17容量逻辑有什么变化这一步在源码层面做下横向比对。JDK 8到JDK 21ArrayList的核心容量逻辑基本没大改还是“空数组 首次扩容到10 1.5倍扩容”。JDK 9及以后的版本主要是把构造函数里的if (elementData.getClass() ! Object[].class)这段逻辑保留了总体框架和JDK 8一致。但有一个细微差异值得注意JDK 9加入了模块化系统后ArrayList内部直接反射访问elementData的调试代码会受影响模块限制变严但这是开发调试层面的事不影响运行时逻辑。如果你是用JDK 11或JDK 17开发面试时谈ArrayList默认容量依然可以说“无参构造第一次add时分配容量10之后按1.5倍扩容”。这个答案在JDK 8到JDK 21都成立。9.1 为什么网上还有人说ArrayList初始容量是10且构造时就分配说这话的人大概率是在看JDK 7及更早的源码。因为JDK 7及更早版本里new ArrayList()确实会直接分配new Object[10]这也是很多老博客、老面试题的答案来源。JDK 8之后改成延迟分配后网上很多资料没及时更新导致这个说法一直流传。现在的标准答案必须以你用的JDK版本为准但面试官更希望听到“默认容量10但首次add时才真正分配”这个细分表述。10. 最后分享一点我的个人体会我最早读ArrayList源码时也一度纠结“为什么是10”后来自己想通了这个数字恰恰代表了一个很典型的工程取舍——不追求理论最优而是在绝大多数场景下都够用、好用、不突兀。就像HashMap的默认容量16、负载因子0.75不是数学上推算出来的最优解而是经过大量实际场景验证后的经验值。在你写代码的时候我最后想说的是多看一眼底层实现少写一点想当然。ArrayList这层看似“人人都会用”的API恰恰是很多隐蔽性能问题、并发问题、内存问题的爆点。把这10个元素背后的扩容逻辑吃透了你排查起这类线上问题来会顺手得多。如果你最近正准备面试或者想系统补一下Java集合这块的知识我建议你花一个下午时间把ArrayList、HashMap、LinkedList这三个类的源码完整读一遍然后自己跟着调试一遍。纸上得来终觉浅这个“10”到底是怎么在堆内存里“从0变成10再从10变成15”的只有亲眼看过源码和内存快照才算真正理解。
返回列表