面试场上最让人措手不及的一句话,往往不是连环八股问答,而是面试官云淡风轻地来一句:“来,手撕一个List。”
手撕这个词,在程序员圈子里含义很明确:不给IDE提示、不给参考文档、不从工具类里直接import现成的集合实现,靠一块白板和一支笔,从空类开始把最核心的API手动写出来。而List作为日常开发中使用频率最高的接口之一,几乎是手撕代码环节的必考题目。这篇文章就围绕“手撕List”这个经典题目,把背后的数据结构、扩容逻辑、指针操作和面试追问全部拆开讲清楚。它适合正在准备面试的同学、刚学完Java基础想深入集合框架的开发者,以及所有想真正弄懂ArrayList和LinkedList底层原理的人。
先说明白一个前提:手撕List,撕的不是API拼写,而是数据结构本身。ArrayList背后是动态数组,LinkedList背后是双向链表。这两个结构半小时就能写完,但能把每一行代码为什么这么写讲明白,比背一百个API细节都值钱。
1. 手撕List到底在撕什么:先把目标和底层逻辑理清楚
1.1 手撕不是表演,是考察你是否真的懂数据结构
有一个挺真实的面试场景:候选人把ArrayList的常用方法背得滚瓜烂熟,add、get、remove张口就来,可当面试官递过来一支笔让他手写一个简化版的时候,写了不到十行就卡住了,原因是不知道扩容方法里该填什么扩容倍数,也说不清remove之后为什么要置空。面试结果自然不理想。
之所以面试官喜欢用手撕来考察,是因为手写代码最能暴露一个人对底层原理的理解程度。背API只能说明你看过文档,而能写出动态数组的扩容逻辑、能画出双向链表的断链过程,说明你是真的在脑子里构建过这个数据结构的运行模型。手撕List的价值也正在于此:它不是表演给面试官看的,而是逼着自己把“数组加size”“节点加前后指针”这两个最基础的数据组织方式彻底吃透。
顺带说一句,手撕list这个说法在互联网语境里多少带着点“硬碰硬”的意味。你平时用的ArrayList是别人已经封装好的轮子,手撕就是要你把这个轮子的内部结构拆开、看懂、再拼起来。这个过程非常接近我在日常code review里对新人常说的一句话:如果你不能从零实现一个数据结构,那你对它所有的时间复杂度结论都只是背下来的,而不是推导出来的。
1.2 数组和链表:两种截然不同的“存储姿势”
要手撕List,先得把两种底层结构掰开揉碎。数组是一块连续的内存空间,每个元素紧挨着排布,通过下标可以直接计算出地址,所以随机访问是O(1)。但正因为空间连续,在中间插入或删除一个元素时,后面的所有元素都得整体挪动,这就是ArrayList增删慢的根本原因。
链表则完全相反,每个节点是一个独立对象,节点里存着数据以及前驱、后继的指针,内存不连续,需要通过指针一个接一个地找。所以随机访问是O(n),但如果是已知某个节点,在其前后插入或删除节点,只需要改变指针指向,严格来说是O(1)。ArrayList和LinkedList谁更优,从来不是绝对问题,而是看你的操作特征是偏读还是偏写。
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 随机访问 get(index) | O(1) | O(n) |
| 尾部添加 add(e) | O(1) 均摊 | O(1) |
| 头部添加 addFirst | O(n) 数组搬移 | O(1) |
| 中间插入 | O(n) | O(n) 查找 + O(1) 插入 |
| 删除 | O(n) | O(n) 查找 + O(1) 删除 |
| 内存占用 | 连续内存、有部分空闲容量 | 每个节点多两个指针 |
记住这张表,后面手撕代码的所有细节基本都可以从这张表推出来,而不是死记硬背。比如ArrayList的remove为什么要整体搬移?就是因为数组空间连续,中间出现空洞就必须补上。LinkedList的按索引查找为什么要分前后半段?就是因为双向链表可以两头向中间夹击。数据结构决定操作代价,这个思路贯穿全文。
2. 手撕ArrayList:一个动态数组的自我修养
2.1 骨架搭起来:字段、构造方法与size的设计
ArrayList最核心的成员只有两个:一个Object数组,用来装数据;一个int类型的size,用来记录当前已存放的元素个数。这里要把“数组容量”和“实际大小”区分开,容量是elementData.length,size是已经装进去的元素数量,前者决定了会不会扩容,后者决定了get、set、remove的边界范围。
字段为什么用Object[]而不是E[]?原因是Java泛型的擦除机制。在虚拟机层面,E会被擦除成Object,直接new E[DEFAULT_CAPACITY]在编译期就不合法,强行转又会留下未检查的警告。所以源码里也是Object[]加运行时强转(E)的写法,这是语言机制决定的,不是偷懒。
public class MyArrayList<E> { private Object[] elementData; private int size; private static final int DEFAULT_CAPACITY = 10; public MyArrayList() { elementData = new Object[DEFAULT_CAPACITY]; } public MyArrayList(int initialCapacity) { if (initialCapacity < 0) { throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity); } elementData = new Object[initialCapacity]; } }一个容易被忽略的细节:JDK 8开始,无参构造其实是先给一个空数组,等第一次add的时候才懒加载地扩容到默认容量10,这么做是为了避免创建后从不添加元素时白白分配10个对象槽位。手撕的时候直接new一个容量10的数组不算错,但如果面试官较真,你能说出懒加载这个差异绝对是加分项。这一点我自己在模拟面试时验证过,很多候选人栽在“为什么无参构造还要判断空数组”这种追问上。
2.2 add与扩容机制:核心中的核心
add方法看起来只是elementData[size++] = e,但在这之前要做一件关键的事:容量检查。当size + 1超过了elementData.length,就必须先扩容,否则数组越界。扩容的核心是grow方法,JDK里的扩容倍数是oldCapacity + (oldCapacity >> 1),也就是原容量的1.5倍。
private void ensureCapacity(int minCapacity) { if (minCapacity > elementData.length) { grow(minCapacity); } } private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity < minCapacity) { newCapacity = minCapacity; } elementData = Arrays.copyOf(elementData, newCapacity); } public boolean add(E e) { ensureCapacity(size + 1); elementData[size++] = e; return true; }这里有几个面试官最爱追问的点。第一,为什么是1.5倍而不是2倍?如果每扩容一次容量翻倍,扩容次数固然少了,但每次扩容后会有大量空闲空间被白白占用;如果只扩到原来的1.1倍,空间是省了,可插入稍多一点就要频繁拷贝。1.5倍恰好是这两个方向的折中,既能减少扩容次数,又不会造成太多内存浪费。更微妙的一点是,1.5倍扩容后,新数组需要的内存比旧数组大,而旧数组正好可以被回收,内存分配器可以在一定程度上复用连续空间,GC压力相对可控。第二,为什么用位运算oldCapacity >> 1?因为右移一位就是除以2,位运算比除法更高效。第三,传递minCapacity是为了应对极端情况,比如从无参构造第一次add时,旧容量是0,0 + (0 >> 1)还是0,如果没有minCapacity兜底,这个数组永远扩不起来。
从实际使用角度看,扩容是ArrayList所有操作里最昂贵的一个,因为它涉及整块数组的复制。所以日常开发中如果预先能估计数据量,就应该直接new ArrayList<>(expectedSize),把扩容次数压缩到最低,这也是手撕之后你自然会形成的习惯。
2.3 get、set、remove:边界检查与System.arraycopy
随机访问是ArrayList的看家本领,但访问前必须做范围检查。边界检查的本质是防止数组越界,同时给出更友好的异常信息。注意get和set的范围是[0, size),而add的范围是[0, size],因为允许在末尾追加元素,index等于size是合法的。
public E get(int index) { rangeCheck(index); return (E) elementData[index]; } public E set(int index, E element) { rangeCheck(index); E oldValue = (E) elementData[index]; elementData[index] = element; return oldValue; } public E remove(int index) { rangeCheck(index); E oldValue = (E) elementData[index]; int numMoved = size - index - 1; if (numMoved > 0) { System.arraycopy(elementData, index + 1, elementData, index, numMoved); } elementData[--size] = null; return oldValue; }remove(int index)是整个ArrayList里最容易写错的方法。删除中间元素时,index后面的所有元素都要向前移动一位,移动的个数是size - index - 1。这里使用的是System.arraycopy本地方法,底层是内存级别的批量拷贝,比for循环一个一个赋值的效率高得多。System.arraycopy的参数顺序非常容易记混,我还记得第一次手撕时把destPos写到srcPos的位置,结果数组直接乱掉。后来我习惯在写之前先在注释里标清楚:src、srcPos、dest、destPos、length,用的时候再对照一遍,这个习惯也值得各位保留。
还有一个细节:remove的返回类型。这是一个很基础但很多人临场会犹豫的点。remove(int index)返回的是被删除的元素,而add(boolean)返回的是操作是否成功,set返回的是旧值。这些返回值是Java集合框架的老传统,ABList抽象类里就定好了契约,手撕的时候不要想当然地返回void。
2.4 那行看起来多余的elementData[--size] = null
我见过很多手撕代码,remove之后直接size--就完事。表面上看,反正size已经减小,后面的元素就算还在数组里也访问不到,似乎没什么影响。但问题在于对象引用还在数组中存着。这里用一个生活中的类比:你退租之后,钥匙还留在门垫底下,虽然你已经不住那儿了,可下一个人还是能拿到钥匙。数组里的残留引用也一样,它让对象无法被GC回收,如果这个数组很大且频繁增删,就会造成内存泄漏,最终触发OutOfMemoryError。
JDK源码里在remove和clear之后都会把废弃位置置为null,注释里明确写了“Let gc do its work”。手撕时能写出这一行,并解释清楚置空的原因,说明你确实读过源码、理解GC机制,这种细节在面试中很加分。我甚至觉得,这行代码比整个扩容逻辑更能区分“背过的”和“真懂的”。
3. 手撕LinkedList:双向链表怎么做到处处O(1)
3.1 从Node开始:一个节点三个引用
LinkedList的底层是双向链表,每个节点是一个Node对象,持有三个信息:自己的数据item、指向前驱的prev、指向后继的next。整个链表只需要记住两个入口指针:first指向第一个节点,last指向最后一个节点,size记录节点数量。这个Node结构是整个LinkedList手撕的核心,连它都写不对,后面所有指针操作都是空中楼阁。
public class MyLinkedList<E> { private Node<E> first; private Node<E> last; private int size; private static class Node<E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } } }为什么要用双向而不是单向?最直接的原因是双向链表让“从尾部向前遍历”成为可能,也让remove节点时不需要额外记录前驱节点。后面会看到,按索引查找跟双向链表是配合着用的,单向链表做不了那个二分式的加速。面试如果问你为什么JDK不用单向链表,这两个理由就是标准答案。
3.2 头插、尾插与中间插入:指针操作的三种姿势
头插法和尾插法的代码看起来对称,但都躲不开一个边界判断:当链表为空时,first和last都指向null,插入第一个节点后,first和last要同时指向新节点,而不是只更新一头。我见过不少初学手撕代码的同学在linkLast里忘记处理first为null的情况,结果第一个节点插入后,链表就断成了两截,遍历时直接NPE。
private void linkFirst(E e) { Node<E> f = first; Node<E> newNode = new Node<>(null, e, f); first = newNode; if (f == null) { last = newNode; } else { f.prev = newNode; } size++; } private void linkLast(E e) { Node<E> l = last; Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) { first = newNode; } else { l.next = newNode; } size++; }中间插入add(int index, E element)的核心是linkBefore方法:先通过索引找到目标节点node(index),再新建一个节点插在它前面。插入时先处理新节点的prev和next,再让succ.prev指向新节点,然后回头处理前驱:如果前驱为null,说明新节点是头节点,更新first;否则让前驱的next指向新节点。这里四行指针操作的顺序不能乱,否则会出现节点丢失或者形成环。我在调试时甚至见过一种搞笑的错误:新节点插进去之后,自己指着自己,链表变成了一条环形单链表,怎么遍历都停不下来。
3.3 node(int index):链表查找如何做到平均少走一半路
LinkedList按索引访问是O(n),这个印象本身没错,但JDK的实现里做了一个很聪明的优化:不是每次都从头开始数。而是先判断index在整个链表的前半段还是后半段,如果index < size / 2,就从first往后找;否则从last往前找。这样最多遍历n/2个节点,同样是O(n)复杂度,但常数开销砍半。
Node<E> node(int index) { if (index < (size >> 1)) { Node<E> x = first; for (int i = 0; i < index; i++) { x = x.next; } return x; } else { Node<E> x = last; for (int i = size - 1; i > index; i--) { x = x.prev; } return x; } }手撕LinkedList的时候,这个判断有必要写吗?有必要。因为面试官考察的往往不是“会不会写node方法”,而是“怎么让最坏情况下的搜索更快”。能答出用双向链表做前后夹击,说明你真的理解为什么JDK选择双向链表而不是单向链表。如果你写的是单向链表版本,这个优化根本无从谈起,所以数据结构的选型直接决定了代码的上限,这一点在日常开发里同样适用。
另一个需要注意的地方是size >> 1的边界行为。当size为奇数时,比如5,右移一位得到2,index等于2时走的是后半段,从last往前找两步即可,结果和从first往后找两步一致,所以边界判断用小于还是小于等于影响不大,但要保持一致,别一会儿<一会儿<=。
3.4 unlink:断链的完整姿势
删除节点是手撕LinkedList的另一个重灾区。unlink的第一步是保存待删节点的item、prev、next三个引用,然后分别处理前驱和后继。
private E unlink(Node<E> x) { E element = x.item; Node<E> next = x.next; Node<E> prev = x.prev; if (prev == null) { first = next; } else { prev.next = next; x.prev = null; } if (next == null) { last = prev; } else { next.prev = prev; x.next = null; } x.item = null; size--; return element; }如果prev为null,说明删除的是头节点,first要指向next;否则要把prev.next直接接到next上,并且把x.prev置为null,防止双向引用残留。对称地处理next。最后把x.item置为null并size--。这里每一条指针操作都有它的意义。把x.prev和x.next置为null,一方面是为了断开原节点对前后节点的引用,让GC可以回收这个节点;另一方面也是方便调试时观察链表状态,避免出现“幽灵引用”干扰判断。
手撕链表时最容易犯的错误是:只更新了前驱的next,忘了更新后继的prev。结果链表虽然看起来长度减了,但双向结构被破坏,后面从尾部向前遍历时直接NPE。我自己调试这种问题时的经验是:拿到代码后先画一张三节点的链表图,把每个节点的prev和next箭头都标出来,再对照代码一步一步走一遍,几乎所有链表Bug都能在这个过程里现形。
4. 手撕现场的完整实现:一份能直接跑的简化版代码
4.1 完整版手写ArrayList
把上面拆开的片段拼起来,再补上add(int index, E element)和两个边界检查方法,就是我手撕时最常写的完整版本:
import java.util.Arrays; public class MyArrayList<E> { private Object[] elementData; private int size; private static final int DEFAULT_CAPACITY = 10; public MyArrayList() { elementData = new Object[DEFAULT_CAPACITY]; } public MyArrayList(int initialCapacity) { if (initialCapacity < 0) { throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity); } elementData = new Object[initialCapacity]; } public boolean add(E e) { ensureCapacity(size + 1); elementData[size++] = e; return true; } public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacity(size + 1); System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } public E get(int index) { rangeCheck(index); return (E) elementData[index]; } public E set(int index, E element) { rangeCheck(index); E oldValue = (E) elementData[index]; elementData[index] = element; return oldValue; } public E remove(int index) { rangeCheck(index); E oldValue = (E) elementData[index]; int numMoved = size - index - 1; if (numMoved > 0) { System.arraycopy(elementData, index + 1, elementData, index, numMoved); } elementData[--size] = null; return oldValue; } public int size() { return size; } private void ensureCapacity(int minCapacity) { if (minCapacity > elementData.length) { grow(minCapacity); } } private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity < minCapacity) { newCapacity = minCapacity; } elementData = Arrays.copyOf(elementData, newCapacity); } private void rangeCheck(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); } } private void rangeCheckForAdd(int index) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); } } }注意add(int index, E element)里的System.arraycopy参数:把index及其之后的所有元素整体后移一位,所以源位置是index,目标位置是index + 1,拷贝长度是size - index。这个逻辑跟remove正好是镜像关系,一个向后搬,一个向前搬,搬完一定要记得size++。写完这个版本之后我用一个随机操作测试跑过,和JDK的ArrayList对拍结果一致,基本可以放心。
4.2 完整版手写LinkedList
LinkedList的完整版比ArrayList多一些方法,但核心就是link和unlink两个方向。下面是我推荐的一个手写版本,覆盖了面试里最常考的API:
public class MyLinkedList<E> { private Node<E> first; private Node<E> last; private int size; private static class Node<E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } } public boolean add(E e) { linkLast(e); return true; } public void addFirst(E e) { linkFirst(e); } public void addLast(E e) { linkLast(e); } public void add(int index, E element) { checkPositionIndex(index); if (index == size) { linkLast(element); } else { linkBefore(element, node(index)); } } public E get(int index) { checkElementIndex(index); return node(index).item; } public E remove(int index) { checkElementIndex(index); return unlink(node(index)); } public boolean remove(Object o) { if (o == null) { for (Node<E> x = first; x != null; x = x.next) { if (x.item == null) { unlink(x); return true; } } } else { for (Node<E> x = first; x != null; x = x.next) { if (o.equals(x.item)) { unlink(x); return true; } } } return false; } public int size() { return size; } private void linkFirst(E e) { Node<E> f = first; Node<E> newNode = new Node<>(null, e, f); first = newNode; if (f == null) { last = newNode; } else { f.prev = newNode; } size++; } private void linkLast(E e) { Node<E> l = last; Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) { first = newNode; } else { l.next = newNode; } size++; } private void linkBefore(E e, Node<E> succ) { Node<E> pred = succ.prev; Node<E> newNode = new Node<>(pred, e, succ); succ.prev = newNode; if (pred == null) { first = newNode; } else { pred.next = newNode; } size++; } private E unlink(Node<E> x) { E element = x.item; Node<E> next = x.next; Node<E> prev = x.prev; if (prev == null) { first = next; } else { prev.next = next; x.prev = null; } if (next == null) { last = prev; } else { next.prev = prev; x.next = null; } x.item = null; size--; return element; } private Node<E> node(int index) { if (index < (size >> 1)) { Node<E> x = first; for (int i = 0; i < index; i++) { x = x.next; } return x; } else { Node<E> x = last; for (int i = size - 1; i > index; i--) { x = x.prev; } return x; } } private void checkElementIndex(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); } } private void checkPositionIndex(int index) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); } } }remove(Object o)这个方法容易被忽略,但它其实是JDK源码里坑比较多的一个地方。JDK对null和非null走了两个分支,因为null不能用equals,只能直接用==比较。手撕时如果时间有限,可以先只实现remove(int index),但要把“为什么null要单独处理”这个点说出来,说明你考虑到了边界输入。
4.3 手撕代码和JDK源码差在哪
JDK源码比我们的简化版多了一堆东西:modCount、迭代器、序列化、subList、批量删除等等。面试时哪些能省哪些不能省?这是一个策略问题。扩容、边界检查、元素搬移、置空、断链,这些是数据结构骨架,不能省;迭代器和序列化属于锦上添花,时间不够时可以明确告诉面试官“这部分JDK里有,但为了突出重点我选择省略”。反而是modCount如果能主动提一嘴fail-fast机制,会让面试官觉得你了解并发场景下的行为。
差异的意义不在于一字不差地背源码,而在于你知道每个被省略的部分解决的是什么问题。比如JDK的ArrayList实现了RandomAccess标记接口,目的是让Collections.binarySearch等算法能识别它支持快速随机访问;实现了Cloneable和Serializable是为了克隆和序列化。这些都属于工程化扩展,手撕练习真正要抓住的只有那两个数据结构的核心骨架,其余都可以放到后续深读时再看。
5. 手撕List最容易翻车的五个坑
手撕List这件事,写代码本身不难,难的是在15分钟的白板场景里不踩坑。我梳理了五个高频问题,基本覆盖了大部分人第一次手撕时的翻车现场。
| 坑位 | 典型症状 | 根因 | 修复办法 |
|---|---|---|---|
| size忘记自增或自减 | 多次add后size不对,get总是越界 | 只改了数组不更新计数器 | 所有link、unlink、搬移操作后检查size |
| 边界条件用反或漏判 | remove(0)抛异常,remove(size-1)反而正常 | 用>还是>=、用<还是<=没想清 | 记住get/set是[0, size),add是[0, size] |
| System.arraycopy参数错位 | 数组元素错乱,或出现NullPointerException | 五个参数顺序记混 | 写前在注释标注src/srcPos/dest/destPos/length |
| 链表断链不彻底 | 删完节点后从尾巴遍历仍能访问,内存无法回收 | 只更新一个方向的指针 | 对照三节点图,把prev和next分支全部置空 |
| 返回值记不住 | add返回void,set返回void,方法签名编译不过 | 没理解Java集合框架的契约 | 记住add返回boolean、set返回旧值、remove返回旧值 |
这五个坑里,我最想单独说的是边界条件。ArrayList的remove(index)在index等于size时会rangeCheck失败,这没问题;但add(index, element)在index等于size时反而是合法的,因为允许追加到末尾。很多手撕代码在add方法里复用了get的边界检查,结果最后一个位置永远插不进去,这种错误非常隐蔽,单测都未必能发现。
另外一个容易被忽略的坑是ArrayList扩容时的类型转换。elementData是Object[],get回去需要强转(E),这个强转本身不会检查类型,因为泛型擦除后运行时都是Object。如果因为手滑把别的类型对象放进了同一个数组,get时ClassCastException是逃不掉的。虽然手撕一般不要求处理这种场景,但知道这个坑的存在,能让你在解释“为什么ArrayList可以放任意类型”时更有底气。
根据我个人的经验,手撕List前最好给自己立一个“三分钟规则”:拿到白板后先不着急写代码,先用三分钟把类结构、字段、边界条件在脑子里过一遍,相当于画个草图。大多数翻车都不是逻辑想不明白,而是上来就写,写到一半才发现add和remove的边界定义自相矛盾,后面越改越乱。
6. 面试官追问清单:手撕之后别被问懵
6.1 扩容为什么是1.5倍而不是2倍
这个问题我在前面已经解释过:1.5倍是这时间复杂度和空间浪费的折中。背答案容易,但你真正理解它的标志是能说出“如果极端场景下minCapacity比1.5倍还大,比如第一次add时旧容量是0,就必须用minCapacity兜底”。另外,扩容之后旧数组就失去引用,会被GC回收,而新数组申请的内存通常能复用旧数组释放出来的连续区域,这对减少内存碎片有实际帮助。能讲到这一层,面试官基本不会再追问下去。
6.2 ArrayList和LinkedList到底怎么选
这个问题看起来简单,但实际场景里没那么绝对。如果数据量小到几十个元素,两个结构的性能差异几乎无感,选哪个都行;如果数据量很大且操作是“读多写少”,毫无疑问ArrayList;如果数据量很大且“只在两端增删”,LinkedList更合适。但要注意,LinkedList的O(1)插入有个前提,你得已经持有了目标节点的引用,如果每次都是按index插入,光node(index)的查找就已经是O(n)了,整体还是O(n)。
我见过一个很典型的场景:某同学在循环里反复调用LinkedList.add(0, item)来做头插,以为这是O(1),实际确实是的,因为add(0)走的是linkFirst而非node查找,所以这个场景LinkedList真的很快。但如果换成add(size/2, item),LinkedList的中间插入反而比ArrayList还要慢,因为查找和插入要各做一遍。所以面试被问到时就回答:ArrayList偏向随机访问和尾部增删,LinkedList偏向头尾增删和迭代遍历,中间位置操作两者都是O(n),选型要看数据访问模式。
6.3 线程安全与fail-fast机制
手撕List通常只写单线程版本,但面试官几乎一定会追问线程安全。ArrayList是线程不安全的,多线程同时add会产生两个严重后果:一是size++不是原子操作,会丢数据;二是多个线程同时触发扩容,可能把数组搞得长度不一致,甚至数组越界。解决方式有几种:用Collections.synchronizedList包装,缺点是全方法加锁,读多写少时并发度太低;用CopyOnWriteArrayList,读操作无锁,写操作复制整个数组,适合读多写少的场景。
modCount和fail-fast是另一个高频追问。JDK的AbstractList里维护了一个modCount字段,每次结构性修改(add、remove、clear)都会自增。迭代器在遍历时会记录expectedModCount = modCount,每走一步检查两者是否一致,不一致就抛ConcurrentModificationException。这个机制不是为了在多线程下保证一致性,而是为了“快速失败”,也就是在数据状态已经被破坏之前尽早暴露问题。手撕时可以说明你了解这个机制,但完整实现迭代器往往超出白板题目范围。
6.4 从手撕List到读源码的路径
手撕List的经验完全可以推广到整个集合框架。我建议的深读顺序是:先自己手写ArrayList和LinkedList,然后对照JDK源码逐个方法比较差异,把modCount、迭代器、subList这些省略部分的实现补看一遍,接着再看HashMap、红黑树、PriorityQueue这些更复杂的结构。路径是层层递进的,但地基永远是“数组加指针”这两个最基本的存储姿势。
读源码的时候有一个技巧很适合从手撕List迁移过来:每看一个类,先猜它的核心字段是什么,再猜它的add和remove怎么做,最后打开源码验证。我自己当时就是先猜再验证,结果发现ArrayList的懒加载、LinkedList的node二分查找都是第一次猜不到的,这种“猜错”的冲击反而比直接读印象深得多。后续看HashMap的resize、TreeNode的红黑树的时候,这个习惯帮我省了大量时间。
回到“手撕list”这件事本身,我个人在实际操作中最大的体会是:写一遍比看十遍都管用。你以为你理解了动态数组的扩容,但只有当你真的在纸上写出elementData[--size] = null,并且被问到为什么要置空时,那个关于GC和内存泄漏的知识点才算真正长在你身上。同样,只有当你画过双向链表的三节点图,亲眼看到断链后两个方向的双向引用都该被清理,你对指针操作才算有了肌肉记忆。
最后再分享一个小技巧:手撕完List之后,可以用它来杜绝一个坏习惯。很多人写代码时喜欢无脑Ctrl加C/V,看到接口就用ArrayList,完全不考虑数据访问模式。当你亲手实现了这两个结构,亲身体会过数组搬移和指针断链的开销之后,下一次在面对大数据量场景时,你会下意识地先问自己:这里到底是读多还是写多?是头尾操作还是中间操作?这比记住任何选型结论都重要。
这个内容后续还可以这样扩展:把同样的方法用到手撕HashMap上。你会发现HashMap的核心不再是简单的数组加指针,而是数组加链表加红黑树,并且扩容机制变成了两倍扩容加rehash。有了手撕List打底,再去看HashMap时的门槛会低很多,因为你对“底层数据结构决定操作代价”这句话已经有了切身体会。