Java基础面试专栏(十五):ArrayList和LinkedList的区别

3 阅读14分钟

上一篇专栏我们详解了ConcurrentHashMap的线程安全实现,明确了其在高并发场景下的核心优势与底层逻辑。今天我们聚焦Java集合框架中另一个高频面试考点——ArrayList和LinkedList的区别,这是基础面试中必问的集合对比题,核心考察两者的底层结构、性能差异及适用场景,很多开发者容易混淆两者的使用场景,今天我们就从面试答题角度,拆透核心差异,搭配全新实战代码,帮你快速掌握答题思路,避开易错点。

先给大家一个面试万能总结(一句话直达核心,适合开场快速应答):ArrayList基于动态数组实现,随机访问快(O(1)),但增删元素需移动数据(O(n));LinkedList基于双向链表实现,增删元素快(O(1)),但随机访问需遍历(O(n))。ArrayList内存连续但可能预留空间,LinkedList每个节点含前后指针更占内存。线程均不安全,适用场景取决于读写操作比例。

一、底层数据结构(核心区别,面试首选答)

ArrayList和LinkedList的所有差异,本质都源于底层数据结构的不同——一个是连续内存的动态数组,一个是非连续内存的双向链表,两者的结构差异直接决定了性能和使用场景的区别,整理成清晰对比表,方便记忆答题:

维度ArrayListLinkedList
实现原理基于动态数组(连续内存空间)基于双向链表(非连续内存空间)
内存布局元素连续存储,通过索引可直接计算内存地址访问每个节点(Node)存储元素本身,以及前驱、后继节点的引用
扩容机制容量不足时触发扩容,默认扩容为原容量的1.5倍,需复制原有元素到新数组无扩容操作,新增元素时直接创建新节点,修改前后节点引用即可

1. ArrayList底层:动态数组详解

ArrayList的底层是一个可动态扩容的Object数组(默认初始容量为10),当元素数量超过当前容量时,会自动扩容为原容量的1.5倍(扩容公式:newCapacity = oldCapacity + (oldCapacity >> 1))。由于元素存储在连续内存中,通过索引访问时,可直接通过“起始地址+索引×元素大小”计算出元素的内存地址,这也是其随机访问速度快的核心原因。

补充说明:ArrayList会预留一定的内存空间(扩容后会有空闲容量),避免频繁扩容带来的性能开销,但也会造成一定的内存浪费;当元素被删除时,后续元素需向前移动,填补删除位置的空缺,这也是其增删效率低的关键。

2. LinkedList底层:双向链表详解

LinkedList的底层是双向链表结构,每个节点(Node)包含三个部分:元素本身(item)、前驱节点引用(prev)、后继节点引用(next)。链表的首尾节点分别由first和last指针指向,无需连续内存空间,节点之间通过引用关联。

补充说明:LinkedList无需扩容,新增节点时只需创建新Node,修改相邻节点的prev和next引用即可,因此头尾增删操作效率极高;但由于节点分散在内存中,无法通过索引直接定位元素,必须从头或尾开始遍历,直到找到目标节点,这也是其随机访问效率低的核心原因。

实战代码示例(模拟底层结构,直观理解差异)

场景:分别模拟ArrayList的动态数组和LinkedList的双向链表核心结构,清晰呈现两者的底层差异。

// 模拟ArrayList底层动态数组结构
class MyArrayList<E> {
    private Object[] elementData; // 底层数组
    private int size; // 当前元素数量
    private static final int DEFAULT_CAPACITY = 10; // 默认初始容量

    // 无参构造,初始化数组
    public MyArrayList() {
        this.elementData = new Object[DEFAULT_CAPACITY];
        this.size = 0;
    }

    // 添加元素,触发扩容
    public void add(E e) {
        // 检查容量,不足则扩容
        if (size >= elementData.length) {
            int newCapacity = elementData.length + (elementData.length >> 1); // 1.5倍扩容
            Object[] newArray = new Object[newCapacity];
            // 复制原有元素到新数组
            System.arraycopy(elementData, 0, newArray, 0, elementData.length);
            elementData = newArray;
        }
        elementData[size++] = e;
    }

    // 随机访问元素(O(1))
    public E get(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("索引越界");
        }
        return (E) elementData[index];
    }

    // 删除指定索引元素(需移动后续元素,O(n))
    public E remove(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("索引越界");
        }
        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;
    }
}

// 模拟LinkedList底层双向链表结构
class MyLinkedList<E> {
    // 链表节点类
    private static class Node<E> {
        E item; // 元素本身
        Node<E> prev; // 前驱节点引用
        Node<E> next; // 后继节点引用

        Node(Node<E> prev, E element, Node<E> next) {
            this.prev = prev;
            this.item = element;
            this.next = next;
        }
    }

    private Node<E> first; // 头节点
    private Node<E> last; // 尾节点
    private int size; // 当前元素数量

    // 无参构造,初始化空链表
    public MyLinkedList() {
        this.first = null;
        this.last = null;
        this.size = 0;
    }

    // 尾部添加元素(O(1))
    public void addLast(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; // 原有尾节点的next指向新节点
        }
        size++;
    }

    // 头部添加元素(O(1))
    public void addFirst(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++;
    }

    // 随机访问元素(需遍历,O(n))
    public E get(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("索引越界");
        }
        // 优化:判断索引靠近头还是尾,减少遍历次数
        if (index < (size >> 1)) {
            // 从头部遍历
            Node<E> x = first;
            for (int i = 0; i < index; i++) {
                x = x.next;
            }
            return x.item;
        } else {
            // 从尾部遍历
            Node<E> x = last;
            for (int i = size - 1; i > index; i--) {
                x = x.prev;
            }
            return x.item;
        }
    }

    // 删除指定索引元素(需遍历定位,O(n))
    public E remove(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("索引越界");
        }
        // 先定位目标节点
        Node<E> x = node(index);
        E oldValue = x.item;

        // 获取目标节点的前驱和后继
        Node<E> prev = x.prev;
        Node<E> next = x.next;

        // 处理前驱节点
        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 oldValue;
    }

    // 定位目标节点(内部方法)
    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;
        }
    }
}

// 测试类,对比两者核心操作差异
public class ListCompareTest {
    public static void main(String[] args) {
        // 测试MyArrayList
        MyArrayList<String> arrayList = new MyArrayList<>();
        arrayList.add("Java");
        arrayList.add("Spring");
        arrayList.add("MyBatis");
        System.out.println("ArrayList随机访问索引1:" + arrayList.get(1)); // O(1)高效
        arrayList.remove(1); // 需移动后续元素,O(n)
        System.out.println("ArrayList删除索引1后,索引1元素:" + arrayList.get(1));

        // 测试MyLinkedList
        MyLinkedList<String> linkedList = new MyLinkedList<>();
        linkedList.addFirst("MySQL");
        linkedList.addLast("Redis");
        linkedList.addLast("MongoDB");
        System.out.println("LinkedList随机访问索引1:" + linkedList.get(1)); // 需遍历,O(n)
        linkedList.remove(1); // 需遍历定位,O(n)
        System.out.println("LinkedList删除索引1后,索引1元素:" + linkedList.get(1));
    }
}

运行结果说明:ArrayList的get操作直接通过索引访问,效率极高;remove操作需移动后续元素,效率较低;LinkedList的头尾添加操作无需遍历,效率极高,但随机访问和中间删除需遍历定位,效率较低,直观体现了两者的底层结构差异带来的性能区别。

二、核心性能对比(面试重点,必记)

基于底层数据结构的差异,ArrayList和LinkedList在不同操作场景下的性能差异显著,重点关注“随机访问”“增删操作”“内存占用”和“缓存友好性”,这也是面试中常考的性能对比点,整理如下:

操作类型ArrayListLinkedList
随机访问(get(int index))O(1),直接通过索引计算内存地址,效率极高O(n),需从头或尾遍历链表,定位目标节点
插入/删除操作① 尾部操作:O(1)(直接在数组末尾添加/删除,无需移动元素);② 中间/头部:O(n)(需移动后续元素,填补空缺)① 尾部/头部:O(1)(直接修改首尾节点引用,无需遍历);② 中间:O(n)(需遍历定位目标节点,修改相邻节点引用)
内存占用较低,仅存储元素本身,扩容时预留的空闲空间较少较高,每个节点除了存储元素,还需额外存储前驱、后继两个指针(约24字节)
缓存友好性高,元素连续存储,CPU缓存可批量加载元素,提升访问效率低,节点分散在内存中,CPU缓存命中率低,访问效率受影响

关键补充(面试加分项)

① ArrayList的扩容开销:扩容时需复制原有元素到新数组,若频繁添加大量元素,可通过构造方法指定初始容量(如new ArrayList<>(100)),减少扩容次数,提升性能;

② LinkedList的遍历优化:LinkedList的get(int index)方法会做优化——判断索引靠近头还是尾,选择从头部或尾部遍历,减少遍历次数,但本质还是O(n)时间复杂度;

③ 批量操作性能:ArrayList的addAll()方法可通过数组复制实现批量添加,效率高于LinkedList(LinkedList需逐个创建节点、修改引用)。

三、核心方法差异(实战+面试常考)

ArrayList和LinkedList都实现了List接口,具备List的基本方法(add、remove、get等),但由于底层结构不同,两者的核心方法存在明显差异,尤其在“头尾操作”“队列操作”上,整理如下:

功能ArrayListLinkedList
快速访问原生支持get(int index)、set(int index, E element),效率O(1),优化完善无原生优化方法,get(int index)需遍历实现,效率O(n),不适合快速访问
头尾操作无原生头尾操作方法,add(0, e)、remove(0)效率O(n)(需移动所有元素)原生支持addFirst(E e)、addLast(E e)、removeFirst()、removeLast(),效率O(1)
队列/栈操作不支持,需手动实现队列/栈逻辑,效率较低实现Deque接口,天然支持队列(offer、poll)、栈(push、pop)操作,适配多场景
批量操作addAll(int index, Collection<? extends E> c)效率高,通过数组复制实现addAll操作需遍历集合,逐个创建节点、修改引用,效率较低

实战代码示例(对比核心方法使用差异)

场景:分别使用ArrayList和LinkedList完成头尾操作、队列操作,对比两者的方法使用和效率差异。

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.Deque;

public class ListMethodCompare {
    public static void main(String[] args) {
        // 1. 头尾操作对比
        ArrayList<String> arrayList = new ArrayList<>();
        LinkedList<String> linkedList = new LinkedList<>();

        // ArrayList头尾添加(效率低)
        long arrayListStart = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            arrayList.add(0, "array-" + i); // 头部添加,O(n)
        }
        long arrayListEnd = System.currentTimeMillis();
        System.out.println("ArrayList头部添加10000个元素耗时:" + (arrayListEnd - arrayListStart) + "ms");

        // LinkedList头尾添加(效率高)
        long linkedListStart = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            linkedList.addFirst("link-" + i); // 头部添加,O(1)
        }
        long linkedListEnd = System.currentTimeMillis();
        System.out.println("LinkedList头部添加10000个元素耗时:" + (linkedListEnd - linkedListStart) + "ms");

        // 2. 队列操作对比(LinkedList实现Deque接口)
        Deque<String> queue = new LinkedList<>();
        queue.offer("队列元素1"); // 入队
        queue.offer("队列元素2");
        System.out.println("队列头部元素:" + queue.peek()); // 查看队头
        System.out.println("出队元素:" + queue.poll()); // 出队
        System.out.println("队列剩余元素:" + queue.size());

        // 3. 随机访问对比
        // 先给ArrayList添加元素,方便随机访问
        for (int i = 0; i < 10000; i++) {
            arrayList.add("test-" + i);
        }
        long arrayGetStart = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            arrayList.get(i * 10); // 随机访问,O(1)
        }
        long arrayGetEnd = System.currentTimeMillis();
        System.out.println("ArrayList随机访问1000次耗时:" + (arrayGetEnd - arrayGetStart) + "ms");

        // LinkedList随机访问
        long linkGetStart = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            linkedList.get(i * 10); // 随机访问,O(n)
        }
        long linkGetEnd = System.currentTimeMillis();
        System.out.println("LinkedList随机访问1000次耗时:" + (linkGetEnd - linkGetStart) + "ms");
    }
}

运行结果说明:ArrayList头部添加元素耗时远高于LinkedList,随机访问耗时远低于LinkedList;LinkedList可直接作为队列使用,方法简洁、效率高,而ArrayList不适合队列操作,直观体现了两者的方法差异和性能差异。

四、适用场景(面试必答,结合实战)

ArrayList和LinkedList没有绝对的优劣,选择核心取决于“操作场景”——重点看“随机访问”和“增删操作”的比例,结合内存需求,整理出明确的适用场景对比,帮你快速做出选择:

场景特征推荐选择核心原因
频繁随机访问(如按索引查询、遍历读取)ArrayList动态数组支持O(1)随机访问,连续内存提升CPU缓存命中率,效率极高
头尾频繁插入/删除(如队列、栈操作)LinkedList头尾操作O(1),无需移动元素,避免ArrayList的元素迁移开销
内存敏感型应用(需节省内存)ArrayList仅存储元素本身,无额外指针开销,内存利用率高于LinkedList
中间位置高频增删(如频繁修改列表中间元素)LinkedList仅需修改相邻节点引用,避免ArrayList的O(n)元素移动开销
需要实现队列/双端队列/栈LinkedList实现Deque接口,原生支持offer、poll、push、pop等方法,无需手动实现
批量添加大量元素ArrayListaddAll方法通过数组复制实现,效率高于LinkedList的逐个节点添加

五、高频面试陷阱(必记,避开踩坑)

ArrayList和LinkedList的面试易错点,主要集中在“性能认知”“适用场景”和“线程安全”,记住以下3点,轻松避开所有陷阱:

陷阱1:认为LinkedList的增删操作一定比ArrayList快

错误原因:忽略了“增删位置”的影响。LinkedList仅在“头尾增删”时效率为O(1),若增删位置在中间,需先遍历定位目标节点(O(n)),整体效率可能低于ArrayList(尤其当元素数量较少时);而ArrayList在“尾部增删”时效率也为O(1),并不比LinkedList差。

陷阱2:认为ArrayList和LinkedList是线程安全的

错误原因:两者均未加任何同步机制,属于线程不安全集合。多线程并发操作(如同时add、remove)时,会出现数据错乱、索引越界等问题;若需线程安全,需使用Collections.synchronizedList()包装,或使用CopyOnWriteArrayList(读多写少场景)。

陷阱3:认为ArrayList的扩容一定是1.5倍

错误原因:默认扩容为1.5倍,但可通过构造方法指定初始容量,也可通过ensureCapacity(int minCapacity)方法手动扩容;此外,当指定的最小容量大于原容量的1.5倍时,扩容后的容量会直接等于指定的最小容量,而非1.5倍。

六、常见面试场景与答题技巧

结合日常开发和面试高频场景,总结3个核心答题要点,帮你快速应对面试提问,避免踩坑:

  1. 核心差异答题逻辑:先一句话总结两者的核心区别(底层结构+性能差异),再分“底层结构”“性能对比”“适用场景”三个层面展开,重点突出底层结构对性能的影响。

  2. 性能对比答题逻辑:重点区分“随机访问”和“增删操作”的时间复杂度,说明不同操作场景下的性能差异,补充扩容、缓存友好性等细节,体现专业性。

  3. 适用场景答题逻辑:结合实际开发场景,说明“什么时候选ArrayList,什么时候选LinkedList”,避免笼统回答,结合具体操作(如随机访问、头尾增删)说明原因。

七、面试总结

  1. 核心梳理:ArrayList和LinkedList的核心区别源于底层数据结构——ArrayList是动态数组,侧重高效随机访问;LinkedList是双向链表,侧重高效头尾增删和队列/栈操作。两者均线程不安全,内存占用和缓存友好性也有明显差异,选择时需结合操作场景和内存需求。

  2. 高频面试题(提前准备,直接应答):

① ArrayList和LinkedList的底层结构有什么区别?(ArrayList:动态数组;LinkedList:双向链表,补充内存布局和扩容机制)

② ArrayList和LinkedList的性能差异体现在哪里?(随机访问:ArrayList O(1)优于LinkedList O(n);增删:头尾LinkedList O(1)优于ArrayList,中间两者均为O(n))

③ 什么时候用ArrayList?什么时候用LinkedList?(结合适用场景,如随机访问用ArrayList,头尾增删、队列用LinkedList)

④ ArrayList的扩容机制是什么?默认扩容几倍?(默认初始容量10,扩容为原容量1.5倍,可手动指定容量)

⑤ ArrayList和LinkedList是线程安全的吗?如何实现线程安全?(均不安全,可用Collections.synchronizedList()或CopyOnWriteArrayList)