底层是会自动长大的数组,1.5 倍扩容藏细节
ArrayList是 Java 里出场率最高的集合,没有之一。但真要问"它底层是什么、add 一下到底发生了什么、为什么会 10→15→22 这样扩容",很多人就卡壳了。今天我们从源码层面把它讲透,顺带把"为什么查询快、增删慢"这件事坐实。
一、底层就是个会自己长大的数组
ArrayList的核心字段就三个:
transientObject[]elementData;// 真正装元素的数组privateintsize;// 当前元素个数(不是容量)privatestaticfinalintDEFAULT_CAPACITY=10;注意区分size(元素个数)和elementData.length(数组容量)。比如你 add 了 3 个元素,size=3,但 elementData.length 可能已经 10 了——多出来的 7 个空位是"预留容量",避免每次 add 都搬一次家。
transient标记说明序列化时elementData不会原样写盘,ArrayList自己实现了writeObject只序列化有效元素,省空间。
二、无参构造:延迟分配,第一次 add 才给 10
很多人以为new ArrayList<>()立刻分配了长度为 10 的数组,其实不是:
privatestaticfinalObject[]DEFAULTCAPACITY_EMPTY_ELEMENTDATA={};publicArrayList(){this.elementData=DEFAULTCAPACITY_EMPTY_ELEMENTDATA;// 指向共享空数组}JDK 7 之后做了优化:空构造先指向一个全局共享的空数组常量,不占内存。直到第一次add,才触发扩容到DEFAULT_CAPACITY = 10。这叫"懒初始化",省了那些创建了却一直不用的集合的内存。
三、add 全流程:先算容量,再搬数据
add(E e)的源码(JDK 8):
publicbooleanadd(Ee){ensureCapacityInternal(size+1);// ① 确保容量够elementData[size++]=e;// ② 放入末尾returntrue;}privatevoidensureCapacityInternal(intminCapacity){if(elementData==DEFAULTCAPACITY_EMPTY_ELEMENTDATA){minCapacity=Math.max(DEFAULT_CAPACITY,minCapacity);// 首次 add 拉到 10}ensureExplicitCapacity(minCapacity);}privatevoidensureExplicitCapacity(intminCapacity){modCount++;// 结构修改计数,fail-fast 靠它if(minCapacity-elementData.length>0)grow(minCapacity);// 容量不够,扩容}关键点在于grow——这是理解"10→15→22"的钥匙。
四、grow 扩容公式:old + old/2
privatevoidgrow(intminCapacity){intoldCapacity=elementData.length;intnewCapacity=oldCapacity+(oldCapacity>>1);// 新容量 = 旧 + 旧/2if(newCapacity-minCapacity<0)newCapacity=minCapacity;if(newCapacity-MAX_ARRAY_SIZE>0)newCapacity=hugeCapacity(minCapacity);elementData=Arrays.copyOf(elementData,newCapacity);// 拷贝到新数组}oldCapacity >> 1就是右移一位 = 除以 2 取整。所以扩容是"变成原来的 1.5 倍":
- 初始 10 → 第一次满,new = 10 + 5 =15
- 15 满 → new = 15 + 7 =22
- 22 满 → new = 22 + 11 =33
注意整数除法的截断:15/2=7(不是 7.5),所以 15→22,不是 22.5。这就是为什么是"1.5 倍但向下取整"的序列。
五、扩容的代价:Arrays.copyOf 是 O(n)
每次grow都要Arrays.copyOf把老数组整体拷贝到新数组,时间复杂度 O(n)。所以单次 add 的均摊复杂度是 O(1),但触发扩容的那次是 O(n)。均摊的意思是:连续 add n 次,拷贝总次数约 n + n/2 + n/4 + … ≈ 2n,平均每次 O(1)。
这就是为什么大量 add 时,预先指定容量能大幅提速。
六、为什么"查快、增删慢"?
这句话要分场景说,不能一刀切:
查快:get(i)直接return elementData[i],数组随机访问 O(1)。这是 ArrayList 的最大优势。
尾部增快:add(e)不触发扩容时也是 O(1)(均摊)。
中间增删慢:add(index, e)和remove(index)要把 index 之后的所有元素整体后移/前移一位,调用System.arraycopy,最坏 O(n):
publicvoidadd(intindex,Eelement){rangeCheckForAdd(index);ensureCapacityInternal(size+1);System.arraycopy(elementData,index,elementData,index+1,size-index);// 把后面整体往后挪elementData[index]=element;size++;}所以"增删慢"特指中间/头部的插入删除。如果你的场景是"只在尾部 append + 随机读",ArrayList 其实非常快。
七、构造时指定容量:性能第一步
如果你知道大概要装 1000 个元素,请务必:
List<User>list=newArrayList<>(1000);// 直接分配 1000 容量否则从 10 一路 1.5 倍扩到 1000,要经历 10→15→22→33→49→73→109→163→244→366→549→823→1234 共 12 次扩容、12 次整体拷贝。指定容量直接省掉这 12 次拷贝。批量 add 前用ensureCapacity(int)也能达到同样效果:
list.ensureCapacity(1000);// 只扩不拷元素,比反复 add 触发扩容更省八、subList 的隐藏大坑
subList(from, to)返回的是原列表的视图,不是拷贝:
List<Integer>sub=list.subList(0,3);sub.set(0,999);// 原 list 的第 0 个元素也变成 999!list.add(100);// 改了原列表结构sub.get(0);// 抛 ConcurrentModificationException!坑有三:① 改视图会反映到原列表;② 改原列表结构后再用视图会抛 CME;③ 视图的add/remove会影响原列表。想要独立副本,必须new ArrayList<>(list.subList(...))。
九、线程安全吗?不安全
ArrayList没有任何同步。多线程同时 add 会丢元素、甚至数组越界。多线程场景请用:
Vector(老旧,方法级synchronized,性能差)Collections.synchronizedList(new ArrayList<>())(包装一层同步)CopyOnWriteArrayList(读多写少、遍历远多于修改时最优,见并发容器篇)
十、面试连环追问
- Q:扩容为什么是 1.5 倍而不是 2 倍?1.5 倍是"空间浪费"和"扩容次数"的折中:太小(如 1.2)扩容频繁拷贝多;太大(如 2)浪费内存且不利于 GC。1.5 倍下,旧数组(1) + 新数组(1.5) 的和(2.5) > 下次所需(2.25),旧空间刚好能被后续复用,是工程上验证过的甜点。
- Q:elementData 为什么用 transient?因为数组常有多余空位,序列化全部字段会写出一堆 null,浪费 IO。ArrayList 自己重写
writeObject只写size个有效元素。 - Q:ArrayList 能存 null 吗?能,且可存多个 null;
indexOf(null)会找到第一个 null 的位置。 - Q:for-each 遍历时 remove 为什么报错?for-each 底层是 Iterator,remove 没走迭代器的
remove(),导致modCount与迭代器记录的expectedModCount不一致,下次next()抛ConcurrentModificationException(fail-fast)。
总结
ArrayList 底层是"会自己长大的 Object 数组",默认空构造懒到首次 add 才给 10,满了就 1.5 倍扩容(10→15→22→33…)并整体拷贝。随机读 O(1) 是它的最大优势,中间增删 O(n) 是代价。大量数据务必预设容量,别碰 subList 的视图陷阱,多线程换 CopyOnWriteArrayList。把扩容这条线吃透,List 家族就通了一半。