「Java 进阶之路」系列 Day21
写在前面
模块二收尾了,从这篇开始进入模块三------集合框架。ArrayList 和 LinkedList 是最常被拿来对比的一对,很多人张口就是"数组适合查、链表适合改",但一问到"LinkedList插入一定比ArrayList快吗",就容易想岔。这篇从底层结构讲起,把这个常见误解纠正过来。
一、是什么:两种完全不同的底层结构
ArrayList 内部是一段连续的数组 (Object[] elementData),每个元素紧挨着存放,靠下标直接定位;LinkedList 是一条双向链表,每个节点各自存着数据、以及指向前一个/后一个节点的引用,元素在内存里是分散的,靠一个个跳指针才能找到目标节点。
二、为什么会有性能差异:结构决定了访问方式
数组因为是连续内存,知道下标就能直接算出内存地址,一步到位;链表没有这种"按下标计算地址"的能力,想找到第 N 个节点,只能从头(或从尾,LinkedList 会根据下标离哪端近选择方向)一个个往后跳,跳 N 次才能到。这个结构性差异决定了两者操作的时间复杂度全面不同:
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 按下标随机访问 get(index) | O(1) 直接计算地址 | O(n) 要从头或尾跳指针过去 |
| 在头部插入删除 | O(n) 后面所有元素要整体后移 | O(1) 只改头节点的指针 |
| 在尾部插入删除 | O(1) 通常直接追加,除非要扩容 | O(1) 只改尾节点的指针 |
| 在中间插入删除 | O(n) 定位是O(1),但要移动后续元素 | O(n) 移动指针是O(1),但定位到这个位置要遍历 |
| 内存占用 | 较低,只存数据本身 | 较高,每个节点还要多存两个指针引用 |
表格里最容易被忽略的一行是"中间插入删除" ------很多人只记住"链表插入删除是O(1)",却忘了这个O(1)指的是"改指针"这一步本身,但在中间位置插入删除之前,你必须先找到这个位置,而链表定位到中间某个下标同样得老老实实一个个跳过去,是O(n)。所以严格来说,在一个已知下标的中间位置插入删除,ArrayList和LinkedList的总耗时都是O(n),只是O(n)里具体在忙活的事情不一样------一个在搬元素,一个在跳指针。
真正能让LinkedList发挥O(1)优势的场景,是已经拿着一个指向目标位置的Iterator,直接在当前位置insert/remove ,不需要重新从头定位------这也是为什么LinkedList实现的ListIterator支持add/remove操作,是它相比ArrayList真正的优势场景,而不是"随便传一个下标进去插入就一定快"。
三、怎么用:ArrayList 的扩容机制
ArrayList 默认初始容量是 10,当元素个数超过当前数组容量时会触发扩容,源码里的核心逻辑是:
java
// 简化后的ArrayList扩容逻辑
private Object[] grow() {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 新容量约等于旧容量的1.5倍
elementData = Arrays.copyOf(elementData, newCapacity); // 拷贝到一个新数组
return elementData;
}
扩容的代价是一次完整的数组拷贝 ,容量从10到15、从15到22,每次扩容都要把旧数组的内容整个复制到新数组里,这也是O(n)的操作。如果能提前预估数据量,用带初始容量的构造方法 new ArrayList<>(1000),能省掉扩容过程中反复拷贝的开销。
LinkedList完全没有这个问题 ------它不需要连续内存,来一个元素就 new 一个节点挂上去,没有"容量不够、整体搬家"这种烦恼,但代价是每个节点都要额外存两个引用(prev、next),元素少的时候这个开销占比不明显,元素一多,内存占用会明显高于 ArrayList。
四、实际选型:LinkedList 用得比想象中少
现代 Java 开发里,LinkedList 实际使用频率远低于很多人的想象。原因是:
- 绝大多数业务场景是"遍历读取"或者"尾部追加",这两种操作
ArrayList都是强项 - 需要频繁在两端进出(当队列/栈用)的场景,
ArrayList家族里的ArrayDeque通常性能更好------ArrayDeque底层是循环数组,没有LinkedList每个节点额外存指针的内存开销,Java 官方文档也建议优先用ArrayDeque而不是LinkedList来实现栈或队列 LinkedList真正的用武之地,是那种"拿着迭代器边遍历边在当前位置插入删除"的场景,这在实际业务代码里并不常见
五、面试追问
Q1:ArrayList 和 LinkedList 的核心区别是什么?
底层数据结构不同:ArrayList 是连续的动态数组,靠下标直接计算地址访问;LinkedList 是双向链表,靠节点间的指针一个个跳转访问。这个结构差异决定了两者在随机访问、插入删除等操作上的时间复杂度完全不同。
Q2:在集合中间位置插入一个元素,LinkedList 真的比 ArrayList 快吗?
不一定。如果只是传入一个下标去插入,LinkedList 定位到这个下标同样需要从头或尾遍历,是O(n),插入本身改指针才是O(1),两者加起来还是O(n),和 ArrayList 需要搬移后续元素的O(n)本质上是一个量级,只是具体耗时在做的事情不同。LinkedList 真正的O(1)优势,只体现在已经持有指向目标位置的Iterator、直接在当前位置操作时。
Q3:ArrayList 的扩容机制是怎样的?
默认初始容量是10,元素个数超过当前数组长度时触发扩容,新容量大约是旧容量的1.5倍(oldCapacity + oldCapacity >> 1),然后把旧数组的全部元素拷贝到新数组里。这个拷贝过程是O(n)的开销,如果能预估数据规模,用带初始容量的构造方法能减少扩容次数。
Q4:为什么说 LinkedList 在实际项目中用得比想象中少?
因为大部分业务场景是遍历读取或者尾部追加,这些操作 ArrayList 效率更高;即使是需要频繁两端进出的队列/栈场景,ArrayDeque 通常比 LinkedList 性能更好、内存占用更低(LinkedList 每个节点要多存两个指针引用)。LinkedList 真正适合的场景------边遍历边在当前位置插入删除------在实际业务代码里并不多见。
Q5:LinkedList 比 ArrayList 多占用的内存开销体现在哪?
LinkedList 的每个节点除了存储数据本身,还要额外存两个引用字段分别指向前一个和后一个节点;而 ArrayList 的数组里每个位置只存数据本身,没有这类额外的指针开销。数据量越大,LinkedList 这部分额外的内存占用就越明显。
下一篇预告
Day22 讲 HashMap 的底层原理------数组加链表加红黑树的混合结构,扩容机制是怎么运作的,这是集合框架里面试问法最深的一个。