JDK1.8 ArrayList默认容量与扩容机制源码解析
1. 从一个面试翻车现场说起前几年带过一个新人简历上写着精通Java集合框架结果面试官问了一句ArrayList不指定初始容量的时候底层数组一开始是多大他脱口而出10。面试官接着问那第一次add的时候数组长度是多少他犹豫了半天说也是10吧。结果可想而知。这个问题其实特别经典经典到几乎每一本Java基础书都会提但真正能把它讲清楚、讲透的人并不多。大部分人记住的只是默认容量10和扩容1.5倍这两个结论至于这两个数字是怎么来的、为什么是1.5而不是2、第一次add到底发生了什么、扩容时数据怎么搬、为什么有时候扩容反而更慢这些细节往往一问就露馅。这篇内容我就围绕JDK1.8 ArrayList的默认长度与扩容机制把源码层面的东西掰开揉碎讲一遍。适合已经会写Java、但对集合底层实现一知半解的开发者也适合正在准备技术面试、想把这块知识彻底吃透的朋友。看完之后你至少能做到三件事第一能准确说出ArrayList从创建到第一次add再到扩容的完整链路第二能自己算出扩容的临界点和容量变化第三能根据业务场景判断什么时候该指定初始容量、什么时候不该。我尽量不堆源码截图而是用人话关键代码实际推演的方式来讲毕竟看源码的目的是理解设计意图而不是背下来。2. 先搞清楚ArrayList到底是个什么东西2.1 底层就是一根会变长的数组ArrayList的底层结构非常简单就是一个Object[] elementData数组。你可以把它想象成一排连续的储物柜每个柜子放一个元素柜子编号从0开始。数组的特点是内存连续、随机访问快所以ArrayList的get(index)操作是O(1)的这是它最大的优势。但数组有个天生的毛病长度固定。你创建的时候说好放10个就放不了第11个。ArrayList要解决的就是这个问题——它通过扩容这个动作在数组满了之后换一根更长的数组把老数据搬过去从而模拟出可变长度的效果。这里有个关键点很多人忽略ArrayList的容量和大小是两个不同的概念。容量capacity底层数组elementData的实际长度也就是能装多少个元素。大小size当前实际装了多少个元素。比如一个ArrayList容量是10但你只add了3个元素那么size是3capacity是10。剩下的7个位置是空的等着被填。理解这两个概念的区别是理解扩容机制的前提。2.2 为什么默认容量是10JDK1.8里如果你用new ArrayList()这种无参构造源码是这样的private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA {}; private static final int DEFAULT_CAPACITY 10; public ArrayList() { this.elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }注意看无参构造并没有直接创建一个长度为10的数组而是把elementData指向了一个共享的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA。真正的10这个数字是在第一次add的时候才用上的。那为什么是10呢这个数字其实是Sun公司当年做的一个经验性权衡。太小了会导致频繁扩容性能差太大了又浪费内存尤其是创建大量小集合的时候。10这个数字在够用和不浪费之间取了个平衡。你要说它有什么数学上的必然性其实没有就是工程经验的产物。类似的还有HashMap默认容量16、负载因子0.75都是权衡的结果。注意JDK1.8里无参构造创建的是一个空数组不是长度为10的数组。这一点和很多人的直觉相反也是面试高频考点。2.3 三个构造函数的区别ArrayList有三个构造函数理解它们的区别对性能优化很重要构造函数底层数组初始状态适用场景ArrayList()指向共享空数组容量0不确定元素数量元素较少ArrayList(int initialCapacity)直接创建指定长度的数组能预估元素数量元素较多ArrayList(Collection c)复制集合元素容量为size从其他集合转换无参构造走的是懒加载路线第一次add才真正分配空间。带初始容量的构造则是一步到位直接创建指定长度的数组。如果你明确知道要放1000个元素用new ArrayList(1000)能避免多次扩容性能提升非常明显。3. 第一次add到底发生了什么3.1 从add方法一路追下去这是整个扩容机制里最容易被误解的一环。我们来看add(E e)的源码public boolean add(E e) { ensureCapacityInternal(size 1); elementData[size] e; return true; }关键在ensureCapacityInternal(size 1)。第一次add时size是0所以传进去的参数是1。接着看private void ensureCapacityInternal(int minCapacity) { if (elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); }这里就是默认容量10真正生效的地方。因为elementData还指向那个共享空数组所以条件成立minCapacity被更新为Math.max(10, 1)也就是10。然后进入ensureExplicitCapacityprivate void ensureExplicitCapacity(int minCapacity) { modCount; if (minCapacity - elementData.length 0) grow(minCapacity); }此时minCapacity是10elementData.length是010-00成立触发grow(10)。所以第一次add时数组被扩容到10。3.2 完整链路推演我把第一次add的完整过程整理成一张表方便你对照理解步骤操作sizeelementData.lengthminCapacity1new ArrayList()00-2调用add(e)0013ensureCapacityInternal(1)00104ensureExplicitCapacity(10)00105grow(10)010106elementData[0] e110-所以结论是无参构造创建时容量是0第一次add后容量变成10。这也就解释了为什么面试官问第一次add时数组长度是多少正确答案是10但创建时是0。实操心得很多人以为new ArrayList()就分配了10个空间其实不是。这个懒加载设计在创建大量空集合时能省不少内存。比如一个方法里创建了100个ArrayList但只用了几个JDK1.8的设计就避免了1000个Object槽位的浪费。3.3 带初始容量的情况如果你用new ArrayList(5)那么elementData直接是一个长度为5的数组第一次add时minCapacity是1elementData.length是51-5不大于0所以不触发扩容直接放进去。但这里有个坑如果你传的初始容量是0比如new ArrayList(0)那么elementData是一个长度为0的数组但不是那个共享的DEFAULTCAPACITY_EMPTY_ELEMENTDATA。第一次add时ensureCapacityInternal里的判断不成立minCapacity保持为1然后grow(1)。而grow方法里有这么一段private void grow(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); if (newCapacity - minCapacity 0) newCapacity minCapacity; ... }oldCapacity是0newCapacity也是00-10成立所以newCapacity被设为1。也就是说new ArrayList(0)第一次add后容量是1而不是10。这个细节很少有人注意到但确实是个容易踩的坑。4. 扩容机制的核心1.5倍是怎么来的4.1 grow方法的完整逻辑扩容的核心在grow方法我把它完整贴出来private void grow(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); if (newCapacity - minCapacity 0) newCapacity minCapacity; if (newCapacity - MAX_ARRAY_SIZE 0) newCapacity hugeCapacity(minCapacity); elementData Arrays.copyOf(elementData, newCapacity); }逐行拆解oldCapacity 1是右移一位等价于除以2。所以newCapacity oldCapacity * 1.5。如果1.5倍还不够比如一次add很多元素就直接用minCapacity。如果超过最大数组限制走hugeCapacity处理。最后用Arrays.copyOf创建新数组并复制数据。4.2 为什么是1.5倍而不是2倍这是个很有意思的设计问题。理论上扩容倍数越大扩容次数越少但浪费的空间也越多。1.5和2的区别在哪假设从10开始要装1000个元素2倍扩容10→20→40→80→160→320→640→1280共7次扩容。1.5倍扩容10→15→22→33→49→73→109→163→244→366→549→823→1234共12次扩容。看起来2倍扩容次数更少性能更好。但1.5倍有个隐藏优势空间利用率更高。2倍扩容时最后一次扩容可能浪费接近一半的空间比如装1000个元素容量到了1280浪费280个。而1.5倍扩容的容量增长更平滑最终容量更接近实际需求。还有一个更技术性的原因1.5倍扩容在多次扩容后旧数组的空间更容易被回收复用。因为1.5倍增长时新数组大小和之前所有旧数组大小之和有关系内存分配器更容易找到合适的连续空间。这个说法在社区里讨论很多算是1.5倍设计的一个加分项。注意这个1.5倍是JDK1.8的实现。不同JDK版本可能有细微差别但1.5倍这个核心策略从很早的版本就定下来了。4.3 扩容的临界点计算理解了1.5倍我们就能算出每次扩容的临界点。从默认容量10开始当前容量装满时sizecapacity下次扩容后容量10第10个元素1515第15个元素2222第22个元素3333第33个元素4949第49个元素7373第73个元素109注意22→33这一步221.533正好是整数。但151.522.5右移运算会向下取整变成22。所以实际容量序列是10, 15, 22, 33, 49, 73, 109... 这个序列有个特点越往后增长越快因为基数在变大。如果你要装100个元素用默认容量会经历10→15→22→33→49→73→109共6次扩容。而如果一开始就new ArrayList(100)一次扩容都不用。这就是为什么预估容量很重要。4.4 扩容时的数据搬运扩容最耗性能的地方不是创建新数组而是复制数据。Arrays.copyOf底层调用的是System.arraycopy这是一个native方法效率很高但仍然是O(n)的操作。假设一个ArrayList最终装了100万个元素从10开始扩容那么总的复制次数大约是10 15 22 ... 约100万这个和大约是最终容量的2到3倍。也就是说为了装100万个元素实际复制了200到300万个元素引用。如果一开始就指定容量100万复制次数是0。这个差距在大数据量下非常明显。实操心得我在处理批量数据导入时如果明确知道数据量在几千以上一定会用new ArrayList(expectedSize)。实测下来10万条数据的场景指定容量比默认容量能快30%以上因为省掉了十几次扩容和数据搬运。5. 那些容易踩坑的细节5.1 modCount和并发修改源码里有个modCount这是用来实现fail-fast机制的。每次结构性修改add、remove等都会让modCount加1。迭代器在遍历时会检查modCount有没有变如果变了就抛ConcurrentModificationException。这个机制本身不是扩容的一部分但它和扩容有个交集扩容也算结构性修改。所以如果你在多线程环境下用一个ArrayList一个线程在扩容另一个线程在遍历很容易触发异常。这也是为什么ArrayList不是线程安全的。5.2 MAX_ARRAY_SIZE的限制源码里有个常量private static final int MAX_ARRAY_SIZE Integer.MAX_VALUE - 8;为什么减8因为有些JVM的实现会在数组对象头里存一些额外信息留8个位置是保险起见。当容量接近Integer.MAX_VALUE时hugeCapacity会做特殊处理避免溢出。不过实际业务中几乎不可能碰到这个上限了解即可。5.3 扩容后的旧数组去哪了elementData Arrays.copyOf(elementData, newCapacity)这行代码执行后elementData指向了新数组旧数组失去了引用会被GC回收。所以扩容不仅是复制数据还涉及内存分配和垃圾回收。在高频扩容的场景下GC压力会明显增大。5.4 常见问题速查表问题答案原因无参构造时容量是多少0指向共享空数组第一次add后容量是多少10ensureCapacityInternal里取max(10,1)扩容倍数是多少1.5倍oldCapacity (oldCapacity 1)扩容后旧数组怎么处理被GC回收失去引用为什么默认容量是10经验权衡平衡内存和性能指定初始容量有什么好处避免多次扩容省去数据搬运ArrayList线程安全吗不安全无同步机制modCount会冲突6. 实际项目中的容量规划建议6.1 什么时候该指定初始容量我的经验是分三种情况第一种明确知道元素数量。比如从数据库查出来一批数据你知道大概有5000条那就new ArrayList(5000)。这是最理想的情况一次分配到位。第二种能估算数量级。比如要装用户列表你知道用户量在1万左右那就new ArrayList(10000)。稍微多分配一点没关系总比扩容强。第三种完全不确定。比如一个方法返回的集合调用方不知道会装多少。这种情况就用默认构造让ArrayList自己扩容。但如果你在方法内部能预估最好在返回前用new ArrayList(list)转换一下把容量调整到合适大小。6.2 容量估算的一个小技巧如果你要装n个元素但不确定具体数量可以按n / 0.75来估算初始容量。因为ArrayList扩容到1.5倍留25%的余量能减少一次扩容。比如预计装100个就设new ArrayList(134)这样装到100个时还没触发扩容。当然这个技巧不是绝对的因为1.5倍扩容的临界点不是线性的。但作为一个粗略的估算方法它挺好用的。6.3 和LinkedList的选型对比既然聊到ArrayList就顺带说下和LinkedList的对比。很多人知道ArrayList查快增删慢LinkedList增删快查慢但实际项目中LinkedList用得很少。原因是LinkedList每个节点都要额外存前后指针内存开销大。随机访问是O(n)实际用起来很别扭。大部分场景的增删其实是在尾部操作ArrayList的尾部add是O(1)摊销的并不慢。所以除非你确实需要在头部频繁插入删除否则ArrayList基本是默认选择。这个结论在JDK1.8之后更加明显因为ArrayList的扩容优化做得不错。7. 从源码里学到的设计思想7.1 懒加载的智慧无参构造不直接分配10个空间而是等到第一次add才分配这个设计叫懒加载lazy initialization。它的好处是如果创建了ArrayList但一直没add元素就完全不占数组空间。在创建大量集合的场景下这个优化能省下可观的内存。这个思想在很多地方都有体现比如Spring的bean默认懒加载、数据库连接池的按需创建。核心逻辑都是不到用的时候不分配资源。7.2 共享空数组的巧思DEFAULTCAPACITY_EMPTY_ELEMENTDATA是一个静态常量所有无参构造的ArrayList都指向它。这样即使创建了100万个空ArrayList它们共享的是同一个空数组对象而不是100万个独立空数组。这个设计把空集合的内存开销降到了最低。类似的还有EMPTY_ELEMENTDATA用于new ArrayList(0)的情况。两个空数组分开定义是为了在ensureCapacityInternal里区分无参构造和指定容量0两种情况前者第一次add扩容到10后者扩容到1。7.3 位运算的性能考量oldCapacity 1比oldCapacity / 2快因为位运算直接操作二进制不需要经过除法指令。虽然现代JVM会做优化但在JDK源码这种追求极致性能的地方位运算是常见选择。类似的还有HashMap里用(n - 1) hash代替取模运算。这些细节单独看可能微不足道但集合框架是几乎所有Java程序的基础每一点性能优化都会被放大到整个应用层面。8. 几个我实际踩过的坑8.1 用Arrays.asList转换后的坑Arrays.asList返回的是一个固定长度的List底层是数组不支持add和remove。如果你把它传给new ArrayList(list)会创建一个新的ArrayList容量正好是list的size。这个操作在需要修改集合时很常用但要注意它是一次复制不是视图。8.2 subList的扩容陷阱subList返回的是原List的一个视图不是独立集合。如果你对subList做add操作实际上是在修改原List而且可能触发原List的扩容。这个行为很容易让人困惑我见过有同事因为这个bug排查了一下午。8.3 多线程下的扩容灾难前面说过ArrayList不是线程安全的。两个线程同时add可能都判断需要扩容然后各自创建新数组最后只有一个线程的修改被保留另一个线程的数据丢失。更糟的是扩容过程中elementData的引用在变另一个线程可能读到中间状态。这种bug很难复现但一旦出现就是数据丢失级别的问题。多线程场景请用CopyOnWriteArrayList或加锁。8.4 序列化时的容量问题ArrayList实现了Serializable但elementData被标记为transient不直接序列化。它通过writeObject方法只序列化实际有元素的部分避免把空槽位也写进去。这个设计在容量远大于size时能省不少空间。反序列化时它会根据size重新计算容量而不是恢复原来的容量。9. 一个完整的容量推演示例最后我用一个具体例子把整个流程串一遍。假设我们要往一个默认构造的ArrayList里add 20个元素操作size变化是否扩容扩容后容量说明new ArrayList()0否0指向共享空数组add第1个1是10触发grow(10)add第2-10个2-10否10容量够用add第11个11是1510*1.515add第12-15个12-15否15容量够用add第16个16是2215*1.522.5取整22add第17-20个17-20否22容量够用所以装20个元素经历了3次扩容0→10→15→22。最终容量22浪费2个槽位。如果一开始就new ArrayList(20)一次扩容都没有最终容量20零浪费。这个推演清楚地展示了默认容量的代价为了省一点初始内存付出了3次数组创建和数据搬运的成本。在小数据量下无所谓但在大数据量下这个成本会累积成明显的性能差异。我个人在实际项目中的做法是方法内部临时用的集合如果元素数量可预估一律指定初始容量作为返回值或成员变量的集合如果无法预估用默认构造但在确定数量后尽早用带容量的构造重新包装一次。这个习惯帮我避免了很多不必要的扩容开销尤其是在批处理和数据转换的场景里效果立竿见影。