一篇文章搞懂 ArrayList 核心底层 + 使用场景
ArrayList 的使用场景
-
概念:
-
ArrayList 的底层基于动态数组实现。
-
特点:
-
查询操作快(基于索引定位)。
-
增删操作较慢(涉及数据移动或扩容)。
补充:
对于插入或删除操作频繁的场景(特别是在中间位置插入或删除元素),推荐使用 LinkedList,因为它基于链表实现,插入和删除操作的时间复杂度为 O(1)(前提是已找到位置)。
-
-
适用场景:
- 在业务中,增删操作较少而查询操作较多的情况下,推荐使用 ArrayList。
-
ArrayList 的查询本质
前置知识 : 时间复杂度
-
O(1)(常数时间复杂度)
O(1) 表示操作所需的时间固定,无关数据规模。直接通过索引定位特定元素,无需遍历整个数据结构。例子:
arr = [1, 2, 3, 4] element = arr[2] // O(1)
-
O(N)(线性时间复杂度)
O(N) 表示操作所需的
时间与数据规模
N 成正比,通常指需要遍历整个数据结构。 例
遍历一个列表、查找一个元素(未排序情况下)等操作都需要逐个访问所有元素。
arr = [1, 2, 3, 4] target = 3 for (x in arr) { // O(N) if( x == target ) { sout("Find!") break } }
-
最坏情况:目标元素不存在或在最后一个位置,需要访问整个列表,复杂度为 O(N)。
-
最佳情况:目标元素是第一个,复杂度是 O(1),但我们通常讨论的是
最坏复杂度
-
-
数组的查询特点:
-
底层是连续内存存储,通过索引可以直接定位到目标元素的位置(时间复杂度为 O(1))。
连续内存存储 ?
人话 : 数据在内存中按顺序分布 -
这也是数组查询快的原因之一。
-
-
ArrayList 的查询特点:
- ArrayList 是基于数组实现的,因此它也具备通过索引访问的特性,查询某个索引上的元素是常数时间复杂度 O(1)。
- 但是,ArrayList 本身并不支持通过值来直接定位元素,也就是说,如果你要查找某个值的位置,需要进行线性搜索,这时候时间复杂度是 O(n)。
区分两种查询:
-
通过索引访问(随机访问/定位访问):
-
arrayList.get(index)
这种操作是 O(1),因为它底层是数组,可以直接通过索引快速访问。 -
例如:
ArrayList<Integer> list = new ArrayList<>(); list.add(10); list.add(20); System.out.println(list.get(1)); // 直接访问索引1,返回20
-
-
通过值搜索(线性搜索):
-
arrayList.contains(value)
或arrayList.indexOf(value)
等操作需要遍历整个数组,查找对应值的位置,时间复杂度是 O(n)。 -
例如:
ArrayList<Integer> list = new ArrayList<>(); list.add(10); list.add(20); System.out.println(list.indexOf(20)); // 需要遍历 ArrayList 才能找到20的位置
-
总结:
- ArrayList 的查询效率取决于查询方式:
- 通过索引访问是 O(1),性能与数组相同,直接定位。
- 通过值查找是 O(n),需要线性搜索
ArrayList 的底层逻辑
数组的默认长度
- 虽然默认初始容量为
10
,但底层数组只有在第一次添加元素时才会被初始化为长度为10
。 - 扩容机制:
- 每次数组容量不足时,会扩容到原本长度的 1.5 倍。
数组的动态初始化与扩容原理
- 添加元素的核心逻辑:
-
入口方法:
add(E e)
public boolean add(E e) { modCount++; // 标记数组变动的次数,便于并发操作时的检测 add(e, elementData, size); // 实际执行添加操作 return true; }
modCount
:记录数组的修改次数,用于并发安全检测。- 实际添加逻辑由内部方法
add(e, elementData, size)
实现。
-
内部方法:
add(E e, Object[] elementData, int s)
private void add(E e, Object[] elementData, int s) { if (s == elementData.length) { // 判断是否需要扩容 elementData = grow(); elementData[s] = e; // 添加元素 size = s + 1; // 更新实际长度 } }
- 两种情况:
- 数组未初始化(
elementData
长度为 0):- 调用扩容方法
grow()
初始化数组。
- 调用扩容方法
- 数组已初始化但即将溢出(
s == elementData.length
):- 调用扩容方法
grow()
增加容量。
- 调用扩容方法
- 如果当前容量足够,则直接添加元素并更新长度。
- 数组未初始化(
- 两种情况:
-
- 扩容的核心逻辑:
-
扩容触发:
grow()
private Object[] grow() { return grow(size + 1); // 模拟现有容量的扩展 }
-
实际扩容实现:
grow(int minCapacity)
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); // 扩容为原容量的 1.5 倍 return elementData = Arrays.copyOf(elementData, newCapacity); } else { // 初始化容量,默认 10 return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; } }
- 逻辑解析:
- 已初始化(
oldCapacity > 0
):- 通过
ArraysSupport.newLength()
方法计算扩容后的长度。
- 通过
- 未初始化:
- 初始化数组,默认长度为
10
或等于minCapacity
(更大者)。
- 初始化数组,默认长度为
- 已初始化(
- 核心扩容公式:
- 新容量 = 旧容量 +
Math.max(新增容量, 旧容量的一半)
。
- 新容量 = 旧容量 +
- 逻辑解析:
-
- 扩容长度计算:
ArraysSupport.newLength()
方法:public static int newLength(int oldLength, int minGrowth, int prefGrowth) { int prefLength = oldLength + Math.max(minGrowth, prefGrowth); // 优化容量 if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) { return prefLength; } else { return hugeLength(oldLength, minGrowth); // 极限情况下的处理 } }
- 逻辑解析:
prefLength
为推荐的新容量,计算方式为旧容量加上较大的增长幅度。- 如果推荐的新容量在合理范围内(
0 < prefLength <= SOFT_MAX_ARRAY_LENGTH
),则直接返回。 - 超出范围时,调用
hugeLength()
方法确保安全扩容。
- 逻辑解析:
ArrayList 总结
- ArrayList 的底层通过动态数组实现,初始容量为
10
,并根据需要动态扩容。 - 扩容策略为 1.5 倍增长,同时确保扩容后的容量在合理范围内。
- 优点: 查询速度快,适合读多写少的场景。
- 缺点: 增删操作需要移动数据或扩容,性能相对较慢。
如果这篇文章帮到你, 帮忙点个关注呗, 点赞或收藏也行鸭 ~ (。•ᴗ-)✧
^ '(இ﹏இ`。)