作为一个每天跟代码打交道的Java开发者我太清楚ArrayList在面试里出现的频率了——几乎可以说Java集合相关的面试题里十道有八道绕不开ArrayList。很多初学者觉得ArrayList简单无非就是一个动态数组嘛能扩容、能增删改查闭着眼都能写出来。但真到了面试官追问扩容因子是多少扩容后数组怎么迁移为什么线程不安全的时候不少人就支支吾吾答不上来了。这篇博文我就把这一个知识点彻底揉碎了讲清楚从源码到实操、从原理到坑点给准备Java面试的朋友和刚接触集合框架的初学者一份可以直接抄作业的深度笔记。1. 一次看懂ArrayList的底层结构与扩容机制1.1 数组存储为什么ArrayList查询快、增删慢ArrayList的底层就是一个Object数组源码里定义是这样的transient Object[] elementData;transient关键字意味着这个数组不会被默认序列化机制序列化ArrayList内部自己实现了writeObject和readObject方法来处理序列化这样做是为了只序列化实际存储的元素而不是把整个数组包括空位置都写出去能省不少空间。这个细节很多文章不会讲但面试里真要深挖绝对是个加分项。既然是数组存储那内存空间是连续的每个元素可以通过首地址加偏移量直接定位所以按下标访问的时间复杂度是O(1)。这也是ArrayList查询快的最根本原因——不需要像链表那样从头遍历。但对应的中间插入和删除就没那么舒服了插入一个元素后续所有元素都要往后挪一位删除一个元素后续所有元素都要往前挪一位。批量移动元素用的方法是System.arraycopy这是JVM层面的native方法效率很高但毕竟数据量大了以后移动的成本依然摆在那里。还有一个容易被忽略的点ArrayList允许存储null值。这点和HashMap不同HashMap的key和value都允许null这是它自己的规定而ArrayList因为底层是数组没有对null做任何限制所以在做业务判断的时候要记得对取出的元素做判空不然很容易踩NPE。1.2 扩容机制从10到1.5倍的完整流程这是面试必考题。先看add方法的核心逻辑public boolean add(E e) { modCount; add(e, elementData, size); return true; } private void add(E e, Object[] elementData, int s) { if (s elementData.length) elementData grow(); elementData[s] e; size s 1; }也就是说当当前元素个数等于数组长度的时候就会触发grow()扩容。扩容的关键逻辑在grow方法里private Object[] grow(int minCapacity) { int oldCapacity elementData.length; if (oldCapacity 0 || elementData ! DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { int newCapacity ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity 1); return elementData Arrays.copyOf(elementData, newCapacity); } else { return elementData new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; } }这里的oldCapacity 1就是旧容量的一半也就是扩容因子是1.5。举个例子初始容量10第一次扩容变成15第二次变成2215 × 1.5 22.5向下取整第三次变成33。这里需要注意newLength方法不会让容量小于minCapacity如果1.5倍的结果还不够装下新增的元素就按minCapacity来。还有一个非常经典的面试陷阱无参构造创建的ArrayList第一次添加元素时容量直接变成10而不是从0扩容到1。看源码就知道无参构造给的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA这个空数组第一次add的时候进入grow方法走到else分支直接new一个长度为DEFAULT_CAPACITY也就是10的数组然后再往里放元素。所以别说什么从0开始逐个扩容第一次就是10。1.3 初始化容量别小看这一个参数用无参构造创建ArrayList数组是空的直到第一次add才分配10个长度的空间。而用new ArrayList(1000)这种方式直接就在构造时分配好1000个长度的数组。这两者的区别在数据量大的场景下非常明显。很多人在代码里写new ArrayList()就完事了从不关心容量。这在小数据量时没毛病但如果要批量插入几万条数据就会发生多次扩容。每次扩容都要Arrays.copyOf重新创建一个新数组然后把旧数组全部复制过去这期间旧数组会变成垃圾等待GC。扩容次数越多内存开销和时间开销都越高。所以如果事先能估算出大概的数据量最好在构造时就指定容量。举个例子如果你知道列表最终大概会有10000条数据new ArrayList(10000)和无参构造相比能省掉大约log1.5(10000/10)次扩容算下来大概13次左右的数组拷贝。别小看这十几次拷贝几万条数据的拷贝一次可能就要耗费几毫秒累计起来在接口性能优化上也是能感知的差异。2. 核心操作的时间复杂度与选型实战分析2.1 增删改查每种操作的时间复杂度拆解我把ArrayList最常用的几个操作列个表面试时心里要有数操作方法时间复杂度说明按下标访问get(int index)O(1)直接数组定位最快按下标修改set(int index, E element)O(1)直接替换数组元素末尾添加add(E element)O(1) 均摊不需要移动元素但可能触发扩容指定位置插入add(int index, E element)O(n)需要移动index之后的元素按下标删除remove(int index)O(n)需要移动index之后的元素删除指定对象remove(Object o)O(n)先遍历查找再移动元素查找元素indexOf(Object o)O(n)线性遍历是否包含contains(Object o)O(n)调用indexOf实现末尾添加的时间复杂度为什么写O(1) 均摊因为绝大多数情况下直接往数组末尾塞一个元素就完事了但如果赶上扩容那一次就要重新复制整个数组。把所有add操作的成本均摊到每次操作上依然是O(1)。这种均摊分析在数据结构里很常见面试中如果能把均摊这个词解释清楚会显得很专业。2.2 ArrayList与LinkedList的对比选型我见过太多人背结论查询用ArrayList增删用LinkedList。这句话其实有一定误导性。真实场景下LinkedList的增删优势主要集中在头部操作和已知节点位置的插入上如果只知道下标LinkedList的add(int index, E element)照样要遍历时间复杂度同样O(n)。而且在现代CPU和内存架构下ArrayList因为连续内存的特性CPU缓存命中率远高于LinkedList的散列节点实际跑起来反而经常比LinkedList快。我做个简单的对比维度ArrayListLinkedList底层结构动态数组双向链表随机访问按下标O(1)O(n)需遍历头部插入O(n)涉及整体移动O(1)直接改节点指针尾部插入O(1) 均摊O(1)内存占用连续空间有部分闲置容量每个节点额外存前后指针占用更大缓存友好性高低适用场景查询多、按下标操作多频繁在头部插入删除、内存写入频繁实际项目里除非你确定场景是在头部频繁插入删除比如实现一个LRU队列之类的否则无脑ArrayList基本不会出错。LinkedList在日常业务代码里的出场率其实很低尤其在做数据批量处理的时候ArrayList配合Stream的便利性碾压LinkedList。2.3 批量操作与ensureCapacity的优化实战Java的ArrayList给了一个很少被用但实际上很好用的方法ensureCapacity(int minCapacity)。这个方法可以手动触发扩容提前把底层数组扩大到预期大小避免后续add过程中反复扩容。配合addAll批量添加时效果尤其明显。看这段代码ListString targetList new ArrayList(); // 批量拼接某些数据 ListString sourceList getSourceList(); targetList.ensureCapacity(sourceList.size()); targetList.addAll(sourceList);先手动扩容到sourceList的大小然后addAll就走一次扩容流程后面就是纯粹的数组按位赋值性能干净利落。如果不做这一步addAll内部自己会算容量其实也还行但如果你要连续多次addAll每次数据量都不小提前统一ensureCapacity一次会高效很多。另外一个提一下addAll的实现里有个calculateNewCapacity的逻辑如果传入的集合很大需要扩容到超过1.5倍时会直接用minCapacity作为新容量。也就是说扩容并不总是1.5倍当一次性加入超大集合时会直接以目标容量为准。3. 线程安全并发场景下的ArrayList与替代方案3.1 ArrayList的线程不安全究竟体现在哪很多面试者知道ArrayList线程不安全但要他说出个所以然来就含糊了。我把它拆成三个典型问题第一个问题size和elementData的竞态。多个线程同时addA线程在grow扩容后写入元素并执行size s 1B线程在扩容前就已经拿到size直接覆盖了A写入的位置导致数据丢失。这不是理论推演并发环境下只要数量够大必现。第二个问题数组越界。两个线程同时add都判断size elementData.length都进来扩容其中一个扩容后的数组是10另一个也是10但两个线程同时往位置10写元素直接ArrayIndexOutOfBoundsException。第三个问题可见性。elementData和size没有用volatile修饰一个线程修改了size另一个线程读到的可能是旧值导致各种诡异的行为。3.2 并发场景的三种替代方案对比遇到并发场景建议按照下面的优先级选型方案核心机制适用场景缺点CopyOnWriteArrayList写时复制底层数组读多写少、遍历频繁每次写都复制全量数组写成本高Collections.synchronizedList所有方法加synchronized简单场景代码侵入小锁粒度大并发效率低Vector方法级synchronized基本已经被淘汰性能差不建议新代码使用CopyOnWriteArrayList是我个人比较推荐的一个类它的设计思路很有意思读操作完全不加锁写操作先拷贝一份新数组在新数组上修改修改完成后再把原数组引用指向新数组。这样读线程永远读到一致的数据且不会被阻塞。这个方案适用于读多写少的场景比如配置缓存、监听器列表等。但注意如果频繁写每次都全量复制数组成本会非常恐怖几万条数据的列表每写一次就是几万次数组元素复制量级上来后根本扛不住。synchronizedList相对粗暴一点它把每个方法都用synchronized锁住。注意使用的时候要特别注意复合操作的问题——单个方法是线程安全的但多个方法的组合不是。比如先判断isEmpty再add中间可能被别的线程插入数据这时需要自己手动给整个复合操作加锁ListString list Collections.synchronizedList(new ArrayList()); synchronized (list) { if (list.isEmpty()) { list.add(data); } }3.3 如何选择适合自己的方案我给个简单粗暴的判断标准如果并发写的频率极低但读和遍历极其频繁那就CopyOnWriteArrayList如果并发量本身不高只是偶尔会有多线程触碰同一个list用synchronizedList就够如果并发写和读都高频那应该考虑替代数据结构比如用ConcurrentLinkedQueue或者干脆用支持并发的消息队列来做解耦。千万别把ArrayList裸着丢到多线程环境里这是生产事故的重灾区。4. 遍历、删除与fail-fast机制的深度解读4.1 四种删除元素的姿势哪些安全哪些不安全先抛一个经典的代码场景一个ArrayList你想在遍历时把符合条件的元素删掉。很多新手这样写ListString list new ArrayList(Arrays.asList(a, b, c, d)); for (int i 0; i list.size(); i) { if (b.equals(list.get(i))) { list.remove(i); } }这段代码在这个特定例子里不会报错因为删除后i继续递增跳过了原本紧跟在后面的元素可能会出现漏删。比如数据变成a,b,b,c删第一个b时i从1变成2但原本index2的b已经挪到index1了就直接被跳过了。这就是经典的删除导致索引错位问题。正确的姿势有几种姿势一倒序遍历删除for (int i list.size() - 1; i 0; i--) { if (b.equals(list.get(i))) { list.remove(i); } }倒序删除的好处是删除当前元素不会影响后续还未遍历到的元素的下标。姿势二Iterator的remove方法IteratorString iterator list.iterator(); while (iterator.hasNext()) { String item iterator.next(); if (b.equals(item)) { iterator.remove(); } }Iterator.remove是官方推荐的遍历删除方式它内部会调用ArrayList的remove方法并把expectedModCount同步更新不会触发ConcurrentModificationException。姿势三Java 8的removeIflist.removeIf(item - b.equals(item));这个最简洁底层也是基于Iterator机制实现推荐在业务代码里直接使用。4.2 并发修改异常modCount与expectedModCount的博弈ConcurrentModificationException是ArrayList里的高频异常。它的原理是ArrayList内部维护一个modCount字段记录结构被修改的次数add、remove、clear等都会让modCount加1。而Iterator内部有一个expectedModCount字段在创建迭代器时被初始化为当前的modCount。每次调用iterator.next()时都会检查modCount和expectedModCount是否一致不一致就直接抛异常。这就是fail-fast机制——一旦检测到并发修改快速失败而不是带病运行。有人会问单线程下自己remove也会触发吗会的比如在foreach循环底层也是Iterator里直接调用list.remove()同样会抛异常。因为list.remove会让modCount变动而iterator里的expectedModCount还停留在创建时的值两者不一致就报异常。那为什么iterator.remove不报异常因为iterator.remove内部会执行 ArrayList.this.remove(lastRet) 之后把expectedModCount重新赋值为当前的modCount保持两者同步。4.3 遍历性能对比fori、foreach、stream各有优劣遍历方式优点缺点适用场景普通fori可通过下标直接定位性能最高代码略繁琐需要按下标操作时增强for代码简洁可读性好不能删除元素会fail-fast纯遍历场景Iterator支持遍历删除安全可控代码稍微啰嗦边遍历边删除增强for代码简洁可读性好不能删除元素会fail-fast纯遍历场景Stream forEach函数式风格支持并行、链式操作有额外的stream对象开销配合filter/map等操作实际开发中最推荐的做法是纯遍历就用增强for简单直观需要边遍历边删除就用removeIf需要转换、过滤、聚合就用Stream API。别为了炫技用Stream遍历那么简单的场景反而把代码搞得难读。5. ArrayList使用中的高阶陷阱与避坑清单5.1 Arrays.asList返回的ArrayList不是你想的那个ArrayList很多人写过这样的代码ListString list Arrays.asList(a, b, c); list.add(d); // 这里会抛UnsupportedOperationException原因在于Arrays.asList返回的是java.util.Arrays内部类ArrayList而不是java.util.ArrayList。这个内部类ArrayList的底层直接引用传入的数组不支持add和remove调用就会抛UnsupportedOperationException。还有一个坑Arrays.asList得到的list和原数组共享同一个数组引用修改list里的元素原数组也跟着变。反过来也一样。在业务代码里如果不想有这种联动可以这样包一层ListString list new ArrayList(Arrays.asList(a, b, c));这样就是真正的ArrayList了可以自由增删复制出来的数组和原数组也没关系。另外一个和基本类型数组相关的经典问题Arrays.asList(intArray)得到的List的元素类型是int[]而不是Integer因为泛型擦除后T被推断成int[]无法自动把int[]拆成Integer元素。所以处理基本类型数组时建议用IntStream的boxed方法转包装类或者用循环手动装填。5.2 subList()返回的是视图不是副本这个坑非常隐蔽。很多人拿subList当截取子列表用然后往子列表里add发现原列表也变了。这不是bug是设计如此——subList返回的是原列表的视图底层操作的是同一个elementData数组。看源码就知道SubList类内部持有父List的引用和offset范围所有操作都直接作用于父List的数组上。还有更严重的问题如果在subList创建之后原list做了结构性修改比如add或remove再操作subList就会抛出ConcurrentModificationException。因为subList里的modCount和父List的modCount对不上了。所以如果你的意图是截取一段独立的数据务必要复制一份ListString subList new ArrayList(originalList.subList(1, 4));5.3 remove()重载的陷阱remove(1)删除的是对象还是下标这个太经典了。如果列表里存的是Integer对象ListInteger list new ArrayList(Arrays.asList(10, 20, 30)); list.remove(1); // 删除的是下标1即20 list.remove(Integer.valueOf(1)); // 删除的是值为1的对象第一次remove(1)调用的是remove(int index)删除下标为1的元素第二个remove(Integer.valueOf(1))调用的是remove(Object o)删除值为1的元素。如果列表里根本没有值为1的元素第二个remove不会报错只是没删掉。这个坑在代码Review和面试里都很常见尤其是涉及到Integer的列表时不加注意很容易把想删的元素没删掉、不想删的元素删没了。5.4 定制排序Comparator与sort()方法ArrayList自带的sort方法接收一个Comparator参数底层调用Arrays.sort用的是TimSort算法。业务里最常见的排序需求就是根据对象的某个字段排ListUser userList getUsers(); userList.sort(Comparator.comparing(User::getAge)); // 倒序 userList.sort(Comparator.comparing(User::getAge).reversed()); // 多字段排序 userList.sort(Comparator.comparing(User::getAge) .thenComparing(User::getName));Comparator.comparing这种链式写法非常简洁底层是JDK 8引入的函数式接口。还有一个细节如果比较器写得不严谨比如compare(a,b)和compare(b,a)返回的结果不对称TimSort会抛出IllegalArgumentExceptionComparison method violates its general contract。这个异常常见于多条件比较时没有处理好null和边界值排序字段出现null时用Comparator.nullsLast或nullsFirst处理。5.5 元素比较与Integer缓存的坑ArrayList的contains和indexOf方法依赖equals来判断元素是否相同。Integer类型在比较时有个非常坑的细节Integer的equals比较的是值但如果直接用比较两个Integer对象在-128到127之间会命中IntegerCache缓存结果相等超过这个范围就会比较对象引用结果不相等。看这段Integer a 100; Integer b 100; System.out.println(a b); // true命中IntegerCache Integer c 200; Integer d 200; System.out.println(c d); // false两个不同对象如果业务代码里拿Integer做比较在数据量超过127的边界时会出问题。所以非要用比较包装类型就先调intValue()或者直接equals。在ArrayList场景里contains判断是否有某个Integer值的时候输入的是Integer对象内部调用的equals是按值比较的所以正常使用没问题。但如果你在自定义对象里重写了equals记得同步重写hashCode否则ArrayList在contains的时候虽然只用equals不用hashCode放到HashSet里就会出问题。6. 面试高频问题速查表与实操心得6.1 面试官最喜欢追问的ArrayList问题把上面讲的浓缩成一张速查表面试前看这一眼就够了问题核心答案要点ArrayList底层数据结构是什么Object数组支持动态扩容扩容因子是多少默认1.5倍oldCapacity 1无参构造第一次add后容量为多少10DEFAULT_CAPACITY指定容量构造的容量是多少传入多少就是多少不调整为什么线程不安全size/elementData无同步竞态导致数据丢失/越界如何线程安全使用CopyOnWriteArrayList、synchronizedList、VectorArrayList vs LinkedList怎么选随机访问多选ArrayList头部增删多考虑LinkedListfail-fast机制是什么modCount与expectedModCount不一致时抛异常遍历时如何安全删除Iterator.remove或removeIfsubList修改为什么影响原listsubList是视图共享底层数组Arrays.asList返回的是什么Arrays内部类ArrayList不支持增删print打印ArrayList为什么是[a, b, c]AbstractCollection的toString重写了ArrayList允许null吗允许6.2 我在项目里总结的几条实操建议最后说几个我在真实项目中踩过坑之后总结出来的习惯希望能帮大家少走弯路。第一创建ArrayList时尽量指定容量。尤其是批量插入场景一个容量参数能省掉多次扩容的数组复制成本。这个习惯在数据量大的接口里能明显降低GC压力和响应耗时。第二千万别在foreach里做结构性修改。我见过线上代码在foreach循环里调list.remove导致偶发的ConcurrentModificationException。这种问题在测试环境不一定能复现但到了大数据量并发时就冒出来排查起来非常痛苦。统一用removeIf或者Iterator.remove。第三高层代码里少依赖下标操作。ArrayList按下标访问虽然快但代码的可读性和可维护性很差。能用增强for就用增强for需要过滤就上Stream。我见过一段从列表中部反复删除的代码性能差到接口超时改成正序加一个临时列表收集要保留的元素性能一下就回来了。核心思路是尽量减少数组元素移动的次数。第四了解你的数据规模选择合适的数据结构。我曾经接手过一个用LinkedList存大量数据做随机访问的模块改成ArrayList后接口耗时就砍掉了一半。链表节点的指针跳转在内存上是不连续的大数据量下比数组的连续访问慢得多。ArrayList这个知识点看起来简单但细究起来能挖的深度远超想象。它既是Java集合框架的地基也是面试和日常开发绕不开的核心类。把这篇文章里涉及到的源码原理、时间复杂度和坑点吃透应付面试的追问绰绰有余更重要的是在实际编码时你会下意识地避开那些地雷写出更健壮的代码。如果对某些细节还有疑问建议直接打开JDK源码对照着看源码本身就是最好的老师。写到这里我想说的是ArrayList这个东西你在项目里用了无数次但真正把它搞清楚是从你能独立解释扩容机制和fail-fast原理那一刻开始的。这篇笔记是我在准备面试和平时写代码过程中慢慢整理沉淀出来的里面每一个坑都是我或身边同事真实踩过的。希望这些经验能帮你在面试时多一分底气在写代码时少几分焦虑。如果你在实战中也遇到过ArrayList的奇怪问题欢迎一起交流毕竟这种事情踩过一次就知道了。