资讯动态

Java ArrayList深度解析:从底层源码到扩容机制、线程安全与性能选型

发布时间:2026/9/9 23:21:33 来源:尧图企业网站定制
开篇先聊点实在的。Java ArrayList是绝大多数Java开发者入门阶段就会碰到的集合类也是面试八股文里出镜率极高的一个点。我这些年面试别人、被面试、看各种项目代码ArrayList几乎无处不在。它看起来简单就是动态数组嘛但真往深了问扩容机制、modCount、subList视图、与LinkedList的取舍能挖出不少东西。这篇文章我打算把ArrayList从底层实现到使用场景、从源码细节到面试常见追问都捋一遍。你不用把它当成枯燥的源码解析我更想把这么多年实际用下来的体会、踩过的坑、还有面试官真正想听的回答逻辑一起讲清楚。不管你是刚学Java基础的新手还是准备跳槽面试的Java工程师这篇都能让你对ArrayList有点新的认识。1. 整体设计与场景选择为什么大家都在用ArrayList1.1 ArrayList在Java集合体系里的真实定位Java集合框架里List接口下面最常用的两个实现就是ArrayList和LinkedList。ArrayList从名字就能看出来底层用的是数组array它是List接口基于动态数组的实现。在日常业务开发中ArrayList的使用频率我觉得能占集合类的半壁江山。查询数据列表要转List、批量处理要存中间结果、接口返回要组装集合随手就是new ArrayList()。为什么它这么受欢迎核心就两个字简单、快。从数据结构的角度去理解ArrayList的核心优势是随机访问。数组在内存中是一段连续的空间每个元素占用的空间大小一致通过下标访问时可以直接根据起始地址加偏移量算出目标位置这个操作的时间复杂度是O(1)。这个特性让它在“按下标取元素”这个场景下几乎是无敌的。但它的短板也恰恰来自数组的连续存储特性。中间插入和删除元素需要把后面的元素整体往后挪或者往前挪这个操作是O(n)的。所以ArrayList的设计哲学很清晰适合读多写少、尤其是按索引访问的场景。1.2 什么场景该选ArrayList什么场景该绕开我自己的习惯是拿到一个需求先判断数据操作的读写比和访问方式再决定用哪个List实现。适合用ArrayList的场景有这些需要频繁按索引随机访问元素的场景比如列表展示、分页数据存储。主要在末尾追加元素的场景add方法尾部添加均摊时间复杂度是O(1)性能非常好。数据量可以预估或者存储过程中扩容成本可以接受的场景。需要遍历元素做批量操作的场景无论是for循环还是增强for循环数组连续内存的特性对CPU缓存也很友好。不太适合ArrayList的场景我遇到过几类需要频繁在头部或中间插入、删除元素的场景。每次操作都涉及System.arraycopy批量搬移元素数据量大时性能会很难看。数据结构是典型的先进先出队列时不要用ArrayList硬扛应该用ArrayDeque或LinkedList。需要线程安全保证的共享集合直接用ArrayList会在并发场景翻车应该考虑CopyOnWriteArrayList或Collections.synchronizedList。选型这件事很多人喜欢背结论但我建议大家从数据结构的本质去理解。连续数组和链表的物理存储差异决定了它们在不同操作上的优劣势这个底层逻辑捋顺了选型就不会纠结。2. 底层存储与扩容机制ArrayList性能的核心秘密2.1 初始化细节不只是new ArrayList()这么简单看ArrayList的源码核心字段就三个// 底层数组缓冲区ArrayList的所有元素都存放在这里 transient Object[] elementData; // 实际存储的元素个数 private int size; // 默认初始容量 private static final int DEFAULT_CAPACITY 10;当我们执行new ArrayList()时它并不会直接创建容量为10的数组而是先指向一个共享的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA等真正添加第一个元素时才初始化容量为10。这是JDK 8之后做的一个优化目的是避免创建了ArrayList却一个元素不存时浪费内存。另外两个常用的构造方法也值得注意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); } }所以如果你能预估大概的数据量直接new ArrayList(1000)这种写法是更好的实践。它可以避免多次扩容带来的性能损耗。我自己在写批量查询接口时如果查询结果可能上千条都会预估容量初始化。2.2 扩容计算逻辑1.5倍是怎么来的ArrayList最核心的机制就是扩容。当数组容量不够时它需要创建一个更大的新数组把旧数组里的元素拷贝过去。源码里的关键逻辑是grow方法private Object[] grow(int minCapacity) { int oldCapacity elementData.length; if (oldCapacity 0 || elementData ! DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { int newCapacity oldCapacity (oldCapacity 1); return elementData Arrays.copyOf(elementData, newCapacity); } else { return elementData new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; } }大家注意oldCapacity (oldCapacity 1)这行 1是右移一位相当于除以2。所以新容量是旧容量加上旧容量的一半也就是原来的1.5倍。如果从容量10开始扩容扩容序列是10、15、22、33、49、73、109、163、244、366、549、823、1234……可以看到容量越大每次扩出来的绝对空间越大。那为什么要扩容到1.5倍而不是2倍或者1.1倍这里其实有个时间和空间的权衡。如果扩容倍数太小比如1.1倍那么扩容操作会非常频繁每次都要重新分配内存并拷贝全部元素时间成本很高。如果扩容倍数太大比如2倍虽然扩容次数少了但可能浪费大量内存比如已经有100万个元素了一次扩容直接多出100万个空位内存压力不小。1.5倍是一个相对平衡的值黄金分割比0.618倒过来大约是1.6181.5已经很接近这个经验值了。2.3 扩容的实际代价为什么说add是均摊O(1)每次扩容都要做一次Arrays.copyOf也就是申请新数组并System.arraycopy把所有旧元素复制过去。这个操作的时间复杂度是O(n)。可能有人会问既然扩容是O(n)那ArrayList的add方法为什么说时间复杂度是O(1)答案在于“均摊”。扩容不是每次add都发生它的频率是随着容量增长越来越低的。10容量的数组插满10个元素才扩一次扩到15又能用15次再扩到22又能用22次。把扩容的拷贝开销平摊到每一次add操作上每个元素平均只被拷贝常数次这个常数大约小于2所以均摊下来add的时间复杂度就是O(1)。这个思路在很多高频场景下很重要。比如在不知道数据规模的情况下批量add你可以不用太担心性能问题均摊成本是可控的。但如果数据规模能提前预估我还是建议用指定容量构造器把扩容直接消灭在摇篮里。注意扩容后需要将旧数据完整拷贝到新数组期间会占用双倍内存空间。如果你存储的是很大对象列表且刚好触发了扩容可能出现短暂的GC压力甚至OOM。在大数据量场景预估容量不是优化项而是必选项。3. 核心操作源码解析面试追问的那些细节3.1 add方法全流程尾部添加其实不简单常见的add(E e)方法内部其实就两步public boolean add(E e) { modCount; add(e, elementData, size); return true; }modCount是用来记录结构性修改次数的这个字段和fail-fast机制有关后面细说。真正干活的是重载的private方法private void add(E e, Object[] elementData, int s) { if (s elementData.length) { elementData grow(); } elementData[s] e; size s 1; }先检查当前size是不是已经等于数组长度了如果是就扩容然后把新元素放到size位置最后size加1。逻辑非常简单但有一个点值得注意先判断再写入。如果数组满了会先扩容然后写入不会存在越界问题。还有一种add方法是指定下标插入public void add(int index, E element) { rangeCheckForAdd(index); modCount; final int s size; Object[] elementData this.elementData; if (s elementData.length) { elementData grow(); } System.arraycopy(elementData, index, elementData, index 1, s - index); elementData[index] element; size s 1; }核心步骤是System.arraycopy把index位置及之后的所有元素整体往后挪一位再把新元素插入index位置。如果index比较靠前比如0那就要搬移所有n个元素这就是O(n)的来源。这里有个很容易忽略的细节System.arraycopy是native方法底层做了内存块级别的复制优化比用for循环一个个复制快很多但在理解复杂度时我们仍然把它看作O(n)。还有一个面试里经常被追问的坑就是add的返回值和类型。add(E e)返回的是boolean始终为true。这个设计是为了让实现类可以继承Collection接口的约束比如Set某些实现加不进去时会返回false而List允许重复所以始终返回true。3.2 get/set/remove时间复杂度为什么不一样get方法是最简单的public E get(int index) { Objects.checkIndex(index, size); return elementData(index); }先做下标的越界检查然后直接从数组中返回对应位置的元素。越界会抛出IndexOutOfBoundsException这是ArrayList最常见的运行时异常之一。它的时间复杂度严格是O(1)因为数组的随机访问能力决定了这一点。这也是ArrayList在面试中对比LinkedList时最大的底气。set方法也很直白public E set(int index, E element) { Objects.checkIndex(index, size); E oldValue elementData(index); elementData[index] element; return oldValue; }找到旧值赋新值返回旧值。也是O(1)。remove方法有两个重载。按下标删除public E remove(int index) { Objects.checkIndex(index, size); final Object[] es elementData; SuppressWarnings(unchecked) E oldValue (E) es[index]; fastRemove(es, index); return oldValue; }fastRemove里做的是把后面的元素往前移一位并把最后一个位置置为null方便GC回收。按对象删除的版本先遍历找到第一个匹配的元素然后也是类似的搬移操作。无论是哪种最坏时间复杂度都是O(n)。这里有个实际操作建议循环删除ArrayList元素时如果想要高效且正确应该使用iterator.remove()避免在for循环里边遍历边调用list.remove()那样会触发ConcurrentModificationException而且连续删除可能会出现元素遗漏的问题。3.3 容易被问懵的subList、toArray、contains这几个方法在面试和实际开发中都挺有戏的。先说subListpublic ListE subList(int fromIndex, int toIndex) { subListRangeCheck(fromIndex, toIndex, size); return new SubList(this, fromIndex, toIndex); }看到new SubList应该立刻明白返回的不是新建的独立List而是一个视图。它内部持有父ArrayList的引用和起始偏移量。对subList返回的List做修改操作直接影响原ArrayList。如果你期望subList是一个独立副本那必须这样写ListString sub new ArrayList(list.subList(1, 3));这个知识点在面试里经常作为陷阱出现问“subList返回的List和原List是什么关系”很多人会答错。实际开发中如果你对subList的结果做了结构修改还想用原List做别的操作很容易踩到并发修改的坑。toArray方法也有个常见问题。直接调用toArray()返回的是Object[]如果你非要强转成String[]运行时会抛ClassCastException。正确用法是list.toArray(new String[0])。JDK 11之后对toArray(new String[0])做了优化使用Arrays.copyOf后面跟一个小技巧当传入数组容量够大时直接复用传入数组因此不需要像以前那样传一个刚好大小的数组。contains方法本质上就是遍历通过indexOf实现最坏O(n)。如果在一个大列表上频繁调用contains判断元素是否存在性能是很差的。这种情况我会建议换成HashSet把O(n)的查找变成O(1)。4. 与LinkedList的真实对比别再背那些面试答案了4.1 理论对比到底谁快谁慢关于ArrayList和LinkedList的区别网上的面试答案通常是ArrayList底层是数组查询快增删慢LinkedList底层是双向链表增删快查询慢。但这句话在实际场景里往往并不准确我把这两个集合的底层结构列个表格咱们逐个对比操作ArrayList时间复杂度LinkedList时间复杂度get(int index)O(1)数组直接定位O(n)需要从头或尾逐节点遍历add(E e)尾部添加均摊O(1)可能触发扩容O(1)直接修改尾节点指针add(int index, E e)中间插入O(n)需要搬移元素O(n)查找位置O(n)修改指针O(1)remove(int index)O(n)需要搬移元素O(n)查找位置O(n)修改指针O(1)内存占用连续内存有预分配和扩容冗余每个节点额外存前后指针占用更大从表格能看出来所谓LinkedList增删快其实只在头尾操作时成立。如果是中间插入或删除LinkedList依然要先找到对应位置的节点这个查找过程是O(n)整体并没有快多少。而在随机访问上LinkedList被ArrayList碾压。还有一个容易被忽略的点是内存占用。LinkedList每个节点是一个Node对象除了存储数据还要存prev和next两个引用。在64位JVM上光是对象头加两个指针就有好几十字节的额外开销。如果你存储大量小型对象LinkedList的内存占用可能是ArrayList的数倍。ArrayList唯一的额外空间来自于容量和size之间的冗余也就是扩容预留的部分。4.2 实测感受数据量小的时候别纠结说实话在日常业务开发的绝大多数场景里ArrayList和LinkedList的性能差异根本感觉不出来。比如你有一个List存了几百条数据库查询记录无论是随机访问还是遍历两者都是毫秒级的。但数据量上升到十万、百万级别时差异就非常明显了。我做过一次简单的性能压测向两个列表各插入100万条数据再随机访问10万次ArrayList在访问阶段比LinkedList快了近百倍。而在头部插入的场景LinkedList确实快但也就快在找不到节点的情况下如果还要频繁随机访问整体还是ArrayList占优。我的个人建议是默认无脑用ArrayList别多想。明确需要用到Deque的接口能力、或者确定只做头尾操作时才考虑LinkedList。至于“ArrayList和LinkedList谁快”这类问题回答的关键不在结论而在于你能不能说清楚为什么不同操作的时间复杂度不同。从JDK官方角度来说LinkedList并不推荐在普通业务场景大量使用它更多是作为Deque接口的一个实现存在。4.3 面试官想听到的回答逻辑如果面试官问“ArrayList和LinkedList的区别”我建议这样组织回答先说底层数据结构ArrayList基于动态数组LinkedList基于双向链表。然后分别讲各自的优势操作和复杂度ArrayList的优势是随机访问O(1)LinkedList的优势是头尾操作O(1)。接着指出LinkedList的隐藏劣势包括内存占用大、中间插入需要先查找、对CPU缓存不友好。最后用一句总结绝大多数场景下ArrayList更通用LinkedList只在明确的头尾操作或需要Deque能力时有优势。这个回答逻辑之所以好是因为它展示了你能基于数据结构原理做分析而不是背了一个简单结论。面试官追问时你也能从容应对。5. 线程安全、fail-fast与最佳实践指南5.1 ArrayList不是线程安全的并发修改会怎样先给结论ArrayList的所有方法都没有加锁所以它是明确线程不安全的。如果多个线程同时对同一个ArrayList实例进行结构性修改可能出现多个问题数据丢失两个线程同时向尾部添加元素后写的覆盖先写的。数组越界扩容的check-then-act不是原子的多个线程同时发现容量不够同时触发扩容可能抛出ArrayIndexOutOfBoundsException。元素错乱size变量不是volatile的线程之间不可见导致读到脏数据。那并发场景用什么有三个选择第一个是CopyOnWriteArrayList。它的核心思想是写时复制每次修改操作都先复制一份完整数组在新数组上改完再整体替换原数组的引用。读操作不加锁完全并发但写操作的代价很大适合读多写极少、集合数据量不大的场景。第二个是Collections.synchronizedList(new ArrayList())通过把每个方法加synchronized锁来实现串行化。实现简单但并发度低而且迭代遍历时需要手动加锁。第三个就是完全没有线程安全需求的时候直接用ArrayList就好不要为了应对不存在的并发白白损失性能。5.2 fail-fast机制为什么遍历时不能直接删fail-fast是Java集合的一种快速失败机制。在遍历ArrayList的过程中如果其他线程或同一线程通过非迭代器方式修改了集合的结构增加、删除元素遍历就会抛出ConcurrentModificationException而不是继续遍历可能已经损坏的数据。这个机制的核心就是之前提到的modCount字段。创建迭代器时会记录当前的modCount叫expectedModCount。每次迭代时检查modCount是否还等于expectedModCount不相等就抛异常。举个例子下面这段代码运行会直接异常ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (s.equals(a)) { list.remove(s); // 会抛ConcurrentModificationException } }正确的删除方式是使用迭代器自己的removeIteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (s.equals(a)) { it.remove(); } }因为迭代器内部的remove方法会同时更新modCount和expectedModCount所以不会触发异常。这个知识点在Java基础面试里属于高频考点实际开发中也经常有人踩坑。5.3 日常开发的优化细节与个人经验最后分享几个我用ArrayList多年积累的小经验。第一个合理预估容量。比如在循环里组装一个可能上万条的列表直接new ArrayList(list.size())或者根据业务量预估一个初始容量能明显减少扩容次数。我见过有项目在上千次循环里每次都无参new ArrayList再一个个add白白多了好几次数组复制。第二个批量添加用addAll。如果你要合并两个Collection直接list1.addAll(list2)内部会先检查容量并按需一次扩容到刚好容纳的量比循环add高效得多。第三个删除注意equals和hashCode的实现。remove(Object o)依赖equals方法判断相等性。如果你往ArrayList放自定义对象却没有正确重写equals方法删除时可能删不掉预期对象。这个坑很隐蔽排查起来费时间。第四个把List转数组时如果不需要修改数组元素直接list.toArray()就够了如果需要特定类型的数组用list.toArray(new T[0])简洁又高效。第五个避免在循环里调用list.size()作为条件判断以外的复杂逻辑。size()本身是O(1)的但很多人会在循环里调用list.contains()或者list.indexOf()这些是O(n)操作整体循环就变成O(n^2)了。重要经验写代码时一定要区分“CPU时间复杂度的理论最优”和“真实场景下的实际最优”。ArrayList在大量场景下虽然理论复杂度相同但因为底层数组的连续内存特性对内存局部性和CPU缓存非常友好遍历和批量操作的实际性能往往比理论上看起来更好。这一点在做性能优化时尤其值得重视。再补充一个很多人没注意到的点ArrayList的trimToSize()方法可以把底层数组的容量缩减到当前size的大小释放多余内存。如果你有一个超大的ArrayList处理完之后要长期持有但不再添加元素调用一下trimToSize()能省下不少内存。不过正因为缩减容量后有可能会再次扩容所以不到确定不用增长的场景不要轻易调用。从源码角度看ArrayList的代码量在集合框架里不算多但信息密度很高。每次我回头读它的实现都能发现一些之前没留意的细节。这也是为什么ArrayList能成为Java基础面试的钉子户。它简单到任何Java工程师都能写两行代码用它又复杂到能从中延伸出动态数组设计、扩容策略、快速失败机制、并发控制策略等一系列核心知识。把ArrayList研究透你不只是学会了一个集合类而是借着它把Java集合框架的设计思路打通了。后面再看HashMap、ConcurrentHashMap这些更复杂的类很多设计理念都是相通的。要是你在面试或者项目中遇到和ArrayList相关的问题卡住了建议先回头看看底层的elementData很多答案都在那一段源码里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价