001、开篇:Java集合框架全景与核心接口剖析


一、从一次深夜调试说起

上周排查线上问题,发现某服务在数据量激增时响应时间从50ms飙到2秒。堆栈定位到一段业务代码:用ArrayList存储实时推送的万级消息,每次插入都调用Collections.sort()做全量排序。内存dump显示大量数组拷贝,GC线程几乎占满CPU。

问题不在排序算法本身,而在数据结构的误用——这是典型“用对工具但用错场景”的案例。Java集合框架提供了十几种容器,但很多人只记住ArrayListHashMap,忽略了背后一整套设计哲学。今天我们就从顶层设计开始,把集合框架这栋大楼的地基打牢。


二、集合框架的骨架:两大根接口

打开JDK源码,java.util包下密密麻麻的类文件,其实都围绕两个核心接口展开:

// 所有单值容器的老大
public interface Collection<E> extends Iterable<E> {
    int size();
    boolean add(E e);
    boolean remove(Object o);
    // ...
}

// 键值对容器的老大  
public interface Map<K, V> {
    V put(K key, V value);
    V get(Object key);
    Set<K> keySet();
    // ...
}

关键认知Map并不继承Collection!很多面试者栽在这点上。它们平行存在,因为语义完全不同——Collection是单个元素的集合,Map是键值映射表。但Map可以通过entrySet()keySet()values()三个视图方法返回Collection对象,这种桥接设计很巧妙。


三、Collection的三条主线

往下看Collection的三个直接子接口,分别代表三种数据组织方式:

1. List:有序可重复的序列

public interface List<E> extends Collection<E> {
    // 核心特征:通过下标直接访问
    E get(int index);
    E set(int index, E element);
    void add(int index, E element);
    
    // 注意这个默认方法!JDK8之后才有
    default void sort(Comparator<? super E> c) {
        // 这里其实调用Arrays.sort
        // 但ArrayList和LinkedList会各自重写
    }
}

List最像传统数组的升级版,但允许动态扩容。ArrayListLinkedList都实现这个接口,但底层一个是动态数组,一个是双向链表——这个根本差异会引发后面所有的性能区别。

2. Set:唯一性保证的集合

public interface Set<E> extends Collection<E> {
    // 方法签名和Collection完全一样
    // 但语义约束不同:不允许重复元素
    // 注意:这里没有get(int index)方法!
}

新手常踩的坑:试图用Set的下标访问元素。Set根本不保证顺序(HashSet),或者只保证某种特定顺序(TreeSet按比较器排序,LinkedHashSet按插入顺序)。要遍历只能用迭代器或forEach。

3. Queue:排队处理的队列

public interface Queue<E> extends Collection<E> {
    boolean offer(E e);  // 入队,比add更友好
    E poll();            // 出队,队列空时返回null
    E peek();            // 看一眼队首但不移除
}

Deque(双端队列)扩展了Queue,可以在两头操作。ArrayDeque内部用循环数组实现,既可作为栈(替代老旧的Stack类),也可作为队列。


四、Map的两种视角

Map接口的设计体现“关注点分离”:

public interface Map<K,V> {
    // 核心操作
    V put(K key, V value);
    
    // 三个视图方法
    Set<K> keySet();          // 键的集合(因为键不可重复)
    Collection<V> values();   // 值的集合(值可能重复)
    Set<Map.Entry<K,V>> entrySet(); // 键值对的集合
    
    // 静态内部接口Entry
    interface Entry<K,V> {
        K getKey();
        V getValue();
        V setValue(V value);
    }
}

重要细节keySet()entrySet()返回的是Set,因为键和键值对都是唯一的。但values()返回的是Collection,因为值允许重复。这三个视图都是“活的”——直接修改视图会影响原始Map,反之亦然。


五、迭代器:统一的遍历方式

所有Collection都继承自Iterable

public interface Iterable<T> {
    Iterator<T> iterator();
    
    // JDK8的默认方法,允许forEach遍历
    default void forEach(Consumer<? super T> action) {
        for (T t : this) {
            action.accept(t);
        }
    }
}

Iterator的设计有个历史包袱:hasNext()next()分开。为什么不像C++那样在next()里同时判断?因为Java早期设计时想保持简单——检查状态和获取元素分离。实际编码中建议这样写:

// 安全写法:先获取再移动
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String item = it.next();  // 这里才移动指针
    if (shouldRemove(item)) {
        it.remove();  // 唯一安全的删除方式
    }
}

// 危险操作:用for循环时直接list.remove(i)
// 会抛ConcurrentModificationException!
// 我在这坑里掉过三次,血的教训

六、Comparable vs Comparator

排序相关接口常被混淆:

// 1. 内在比较能力(实现这个接口的类自己知道怎么比)
public interface Comparable<T> {
    int compareTo(T o);  // 返回负数、0、正数
}

// 2. 外在比较器(第三方裁判)
public interface Comparator<T> {
    int compare(T o1, T o2);
    
    // JDK8新增一堆静态方法
    public static <T> Comparator<T> comparing(
        Function<? super T, ? extends Comparable> keyExtractor) {
        // 按某个字段排序的快捷方式
    }
}

实战经验:如果类有自然排序(如String按字典序、Integer按数值),实现Comparable。如果需要多种排序方式(按年龄、按工资、按工号),用多个ComparatorTreeSetTreeMap必须指定一种比较方式,否则放不进去Comparable对象。


七、个人工具箱配置建议

工作十年,我的集合类使用习惯是这样的:

  1. 默认选择:80%场景用ArrayListHashMap。它们像螺丝刀中的十字款,最通用。ArrayList的随机访问是O(1),内存连续对CPU缓存友好;HashMap的哈希查找在数据分布均匀时接近O(1)。

  2. 内存敏感场景:考虑ArrayDeque替代LinkedListLinkedList每个元素都要包装成Node对象(前后指针+数据),内存开销是ArrayDeque(纯数组)的3-5倍。

  3. 并发环境:绝不直接用ArrayList/HashMap。哪怕只是读多写少,也考虑CopyOnWriteArrayListCollections.synchronizedList()包装。真正高并发用ConcurrentHashMap,它的分段锁设计比Hashtable的全表锁聪明太多。

  4. 保持警惕Collections.unmodifiableXXX()返回的是视图,不是快照!如果原始集合被修改,不可变视图也会变。要真正不可变,用List.copyOf()(JDK10+)或手动new一个新集合。

  5. 性能调优HashMap初始化时指定容量和负载因子。如果你知道大概要放1000个元素,写new HashMap<>(2048, 0.75f),避免多次rehash。ArrayList同理,new ArrayList<>(initialCapacity)能减少数组拷贝。


下篇预告

地基打好了,下一篇我们深入ArrayList源码。看看那个神秘的DEFAULT_CAPACITY = 10是怎么来的,grow()方法里的位运算>> 1为什么是1.5倍扩容,以及为什么说“ArrayList的插入复杂度是O(n)但平均仍是O(1)”。

记得某次面试,候选人说“ArrayList不适合频繁插入”,我让他看System.arraycopy()的native实现——很多时候理论分析和实际性能是两回事。咱们下篇见真章。

002、ArrayList源码精讲(一):类结构、核心字段与构造方法


从一次内存溢出说起

上周排查线上问题,发现一个服务在高峰期频繁Full GC。堆dump分析下来,一个ArrayList对象占了近800MB内存,里面只放了不到10万个字符串对象。仔细看代码,发现同事写了这么一句:

List<String> list = new ArrayList<>(Integer.MAX_VALUE - 8);

他本意是想“预留足够大的空间避免扩容”,结果差点把JVM搞崩。这个案例让我觉得,是时候重新审视ArrayList那些看似简单的构造方法和字段了——你以为你在优化,实际上可能正在挖坑。


类结构:比想象中复杂

打开ArrayList.java,先看类声明:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable

几个关键点:

  1. RandomAccess接口:这是个标记接口,空实现。但别小看它,Collections.binarySearch()方法会根据这个接口决定用索引遍历还是迭代器遍历。ArrayList有这个标记,说明它“支持快速随机访问”,这是和LinkedList的根本区别之一。

  2. Cloneable和Serializable:浅拷贝和序列化都支持,但这里有个坑——ArrayListclone()方法是浅拷贝,两个列表会共享元素引用。修改其中一个列表的元素对象,另一个也会受影响,这个我踩过雷。

  3. 泛型设计:元素数据实际存储在Object[] elementData里,泛型<E>是通过类型擦除实现的。所以运行时类型检查得自己注意,特别是用toArray()方法时。


核心字段:三个关键变量

// 真正存数据的地方
transient Object[] elementData;

// 当前列表中的元素个数(注意不是数组长度!)
private int size;

// 默认初始容量
private static final int DEFAULT_CAPACITY = 10;

elementData为什么用transient修饰?

这个问题面试常考。数组本身是transient,但ArrayList的序列化是自定义的:只序列化实际存储的元素(size个),而不是整个数组。比如你new ArrayList(1000)但只放了10个元素,序列化时只会写10个元素,不会带990个null。这是ArrayList自己实现的writeObject/readObject的功劳。

size和capacity的区别:

新手容易混淆。size是元素个数,capacity是底层数组长度(elementData.length)。size <= capacity,等号成立时,下次添加元素就要扩容了。看代码时一定要分清这两个概念。


构造方法:三种初始化方式

1. 无参构造——最常用也最容易被误解

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

注意这里赋的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,不是new Object[10]!这是个空数组:

private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

延迟初始化:只有第一次添加元素时,才会真正分配10个空间的数组。这样设计是为了避免创建一堆空列表时浪费内存。但这也意味着,new ArrayList()new ArrayList(0)在初始状态下底层数组不一样,前者是空数组,后者是长度为0的数组。这个细节在序列化时有影响。

2. 指定初始容量——用对了是优化,用错了是灾难

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);
    }
}

经验法则

  • 如果你能预估大概的元素数量,指定初始容量确实能避免多次扩容。比如要放1000个元素,new ArrayList(1000)比默认构造节省至少6次扩容(10→15→22→33→…)。
  • 但别像开头案例那样瞎指定。Integer.MAX_VALUE - 8这个减8是因为有些JVM实现中数组头需要8字节开销,超过这个值可能直接OutOfMemoryError
  • 实际工作中,我一般按预估值 * 1.2来设,留点余量。

3. 从集合构造——注意深浅拷贝问题

public ArrayList(Collection<? extends E> c) {
    Object[] a = c.toArray();
    if ((size = a.length) != 0) {
        if (c.getClass() == ArrayList.class) {
            elementData = a;
        } else {
            elementData = Arrays.copyOf(a, size, Object[].class);
        }
    } else {
        elementData = EMPTY_ELEMENTDATA;
    }
}

关键细节

  • 如果传入的就是ArrayList,直接引用它的数组(elementData = a)。这意味着两个列表共享底层数组吗?不,因为toArray()返回的是新数组(除非是Arrays.ArrayList这种特殊实现)。
  • 其他集合类型会用Arrays.copyOf复制一份。所以这个构造方法创建的是浅拷贝列表,元素引用是共享的。
  • 如果传入的集合是null,这里会抛NullPointerException,异常出现在c.toArray()这一行。

个人踩坑心得

  1. 无参构造不是“创建10个空间”:很多教科书说new ArrayList()会分配10个位置,这是错的。实际是第一次add时才分配。知道这个细节对内存敏感的应用有帮助。

  2. 估算容量别太激进:我见过有人为了“绝对避免扩容”把初始容量设得巨大,结果在并发场景下,多个这样的列表直接把老年代撑爆。合理估算,留20%余量足够。

  3. 构造方法选型

    • 不确定元素数量 → 用无参构造
    • 确定数量且数量不大(<1000)→ 指定容量
    • 确定数量且很大 → 考虑是否真需要ArrayList,也许用LinkedList或分批处理更合适
    • 从已有集合构建 → 记住这是浅拷贝,修改元素对象会影响原集合
  4. 序列化陷阱:如果你自定义了继承ArrayList的类并添加了字段,记得重写writeObject/readObject,否则你的字段不会被序列化。这个坑我踩过,排查了半天。


下篇预告

下一篇我们深入addgrow方法,看看扩容机制到底怎么工作,以及为什么说“ArrayList尾部插入时间复杂度O(1)”这个说法需要加前提条件。你会发现,就连简单的add(E e)方法,里面也有不少门道。


技术笔记写于凌晨两点,咖啡已凉,bug未改。 这些源码细节看似枯燥,但关键时刻能帮你省下几个小时的生产问题排查时间。共勉。

003、ArrayList源码精讲(二):动态扩容机制与性能代价分析


从一次线上告警说起

上周排查一个服务端性能问题,监控显示某接口在晚高峰时段RT(响应时间)频繁出现毛刺。抓取火焰图后发现,大量CPU时间消耗在java.util.ArrayList.add()方法上。查看业务代码,发现开发同学在循环里不断向ArrayList添加数据,而初始容量用的是默认值。问题就藏在这个看似简单的操作背后——动态扩容。


默认构造器的“温柔陷阱”

很多工程师随手写下这样的代码:

List<User> userList = new ArrayList<>();

看起来清爽简洁,但这里有个隐藏的代价。点开ArrayList的无参构造器:

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

这个DEFAULTCAPACITY_EMPTY_ELEMENTDATA是个空数组。也就是说,初始容量是0,不是很多人以为的10。

第一次添加元素时才会真正分配容量:

private static final int DEFAULT_CAPACITY = 10;  // 注意:这个10不是初始容量,是首次扩容的目标值

扩容的核心逻辑:grow()方法解剖

当数组已满时,add()方法会调用ensureCapacityInternal(),最终走到grow()

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);  // 关键!扩容1.5倍
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity);  // 性能瓶颈所在!
}

几个关键点:

  1. 1.5倍扩容oldCapacity + (oldCapacity >> 1)就是乘以1.5。这个系数是经验值,太小会导致频繁扩容,太大浪费内存
  2. Arrays.copyOf:这里才是真正的性能杀手!它创建新数组,然后把老数据逐个复制过去

一次扩容的成本有多高?

我们做个简单实验:

// 测试代码:连续添加1000万个元素
List<Integer> list = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
    list.add(i);
}
System.out.println("耗时:" + (System.currentTimeMillis() - start) + "ms");

默认初始容量下,这个循环会触发约24次扩容(0→10→15→22→33…)。每次扩容都要复制整个数组,数据量越大复制成本越高。

更糟糕的是这种写法

// 反例:在循环里频繁扩容
for (User user : queryFromDB()) {
    userList.add(user);  // 如果不知道数据量,可能一直在扩容
}

扩容的“阵痛”不只是复制数据

考虑这个场景:

ArrayList<byte[]> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
    list.add(new byte[1024 * 1024]);  // 每次添加1MB
}

在扩容瞬间,JVM需要同时存在两个大数组:老数组(比如64MB)和新数组(96MB)。这可能导致:

  1. 瞬间内存需求激增
  2. 触发Full GC(如果堆内存紧张)
  3. 复制期间老数组不能被回收

实战中的优化策略

策略一:预分配容量(最简单有效)

// 如果知道大概数据量
int expectedSize = queryCountFromDB();
List<User> users = new ArrayList<>(expectedSize + 10);  // 加个余量更安全

策略二:分批处理超大集合

// 处理百万级数据时
List<Data> batch = new ArrayList<>(BATCH_SIZE);
for (Data data : hugeDataSource) {
    batch.add(data);
    if (batch.size() >= BATCH_SIZE) {
        processBatch(batch);
        batch.clear();  // 复用这个ArrayList
        // 注意:clear()不会缩容,数组还在,下次添加不用扩容
    }
}

策略三:警惕“扩容风暴”

在并发场景下,多个线程同时操作ArrayList可能导致多次扩容。虽然ArrayList线程不安全,但实际中还是经常被误用:

// 危险代码:多个线程同时add
List<String> sharedList = new ArrayList<>();
executor.submit(() -> {
    for (int i = 0; i < 10000; i++) {
        sharedList.add("data");  // 可能触发ConcurrentModificationException
    }
});

扩容的“后遗症”:内存碎片

即使ArrayList被清空,底层数组可能还占用着内存:

List<BigObject> list = new ArrayList<>(10000);
// ... 添加大量数据
list.clear();  // 只是size=0,elementData.length还是10000
list = null;   // 此时数组才会被GC回收

这种设计有它的道理——避免频繁扩容缩容。但如果你需要立即释放内存,可以:

list.clear();
list.trimToSize();  // 缩容到当前size,底层会创建新小数组

个人经验谈

  1. 养成习惯:创建ArrayList时,只要不是真的不知道数据量,都该给个初始容量。哪怕估不准,给个大概值也比默认强。

  2. 监控扩容:在性能敏感的场景,可以加个日志:

if (list.size() == list.capacity()) {
    log.warn("ArrayList即将扩容,当前容量:{}", list.capacity());
}
  1. 别迷信1.5倍:这个系数适合通用场景。如果你的业务数据量是指数增长(比如缓存热点数据),可以考虑继承ArrayList重写grow()逻辑。

  2. 内存敏感场景:考虑用LinkedList吗?别急,下篇我们会详细对比。简单说:LinkedList的“扩容”是零散的节点分配,没有大块复制,但内存局部性差,实际性能往往更糟。

  3. 最容易被忽视的点Arrays.asList()返回的“假ArrayList”(其实是Arrays内部类)不支持扩容!很多同学在这里踩过坑:

List<String> list = Arrays.asList("a", "b", "c");
list.add("d");  // 直接抛UnsupportedOperationException

ArrayList的扩容机制就像汽车的自动变速箱——平时感觉不到存在,一旦出问题就是大问题。理解它的工作原理,不是为了炫技,而是为了在关键时刻能快速定位那些“看不见”的性能损耗。下次写new ArrayList<>()时,不妨多花一秒想想:我真的不知道它要装多少数据吗?

004、ArrayList源码精讲(三):增删改查操作实现与时间复杂度


从一次线上性能问题说起

上周排查一个服务接口超时问题,发现某段业务代码在循环中频繁调用list.remove(0)处理数据。数据量到万级时接口响应直接飙升到秒级。打开监控一看,CPU和内存都没瓶颈,但线程堆栈卡在ArrayList的System.arraycopy()上。这个案例太典型了——很多人用ArrayList却不知道它的删除成本有多高。今天我们就深入ArrayList的增删改查,看看这些日常操作背后到底发生了什么。


查操作:随机访问的代价真的是O(1)吗?

先看最简单的get(int index)

public E get(int index) {
    // 边界检查:这里如果越界会抛IndexOutOfBoundsException
    // 实际开发中建议先判断index < size,别等异常抛出
    rangeCheck(index);
    
    // 直接通过下标访问数组元素
    // 注意:这里返回的是ElementData[index],时间复杂度确实是O(1)
    // 但前提是数组在内存中是连续存储的
    return elementData(index);
}

ArrayList的查询性能确实稳定,但有个细节容易被忽略:elementData()方法内部其实做了类型转换(E) elementData[index]。当泛型类型擦除后,这里会有强制转型的开销。不过比起链表遍历,这代价几乎可以忽略。

实际使用中要注意:别在循环里用list.get(i)做条件判断后再调list.remove(i),这种写法会导致多次计算下标,尤其是删除后size变化,容易引发逻辑错误。


增操作:扩容那点事儿

尾部添加

public boolean add(E e) {
    // 确保容量够用:这里是最容易出性能问题的地方
    ensureCapacityInternal(size + 1);
    
    // 在数组末尾赋值,然后size加1
    // 看起来简单,但如果你在循环里add,可能触发多次扩容
    elementData[size++] = e;
    return true;
}

ensureCapacityInternal()会判断当前数组是否已满。如果满了,会触发grow()方法:

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    // 新容量 = 旧容量 * 1.5(位运算实现)
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    
    // 特殊情况处理:比如第一次扩容时oldCapacity=0
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    
    // 这里有个坑:超大数组处理
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    
    // 关键步骤:创建新数组并拷贝数据
    // System.arraycopy是native方法,但数据量大时依然耗时
    elementData = Arrays.copyOf(elementData, newCapacity);
}

经验之谈:如果知道数据量大概范围,初始化时直接指定容量。比如new ArrayList<>(10000),避免中间多次扩容和数组拷贝。我见过有人用默认构造器加载10万条数据,中途触发了十几次扩容,性能直接掉一半。

中间插入

public void add(int index, E element) {
    rangeCheckForAdd(index);
    
    ensureCapacityInternal(size + 1);
    
    // 重点来了:要把index之后的元素全部后移一位
    // 这个操作的时间复杂度是O(n-index)
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    
    elementData[index] = element;
    size++;
}

在索引2的位置插入元素,需要把[2]到[size-1]的所有元素往后挪。数据量大的时候,这个拷贝操作相当昂贵。有个反模式:有人喜欢用list.add(0, item)实现栈效果,这在LinkedList里没问题,但在ArrayList里就是灾难。


删操作:隐藏的性能杀手

按索引删除

public E remove(int index) {
    rangeCheck(index);
    
    E oldValue = elementData(index);
    
    // 计算需要移动的元素数量
    int numMoved = size - index - 1;
    if (numMoved > 0) {
        // 把index后面的元素往前挪一位
        // 注意:这里覆盖了被删除元素的位置
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    }
    
    // 关键步骤:显式置空最后一个位置,帮助GC回收
    // 很多新手不知道这个设计的意义
    elementData[--size] = null;
    
    return oldValue;
}

删除操作有两个成本:1) 移动元素的System.arraycopy;2) 可能触发缩容(虽然ArrayList默认不会自动缩容,但trimToSize()可以手动触发)。

按元素删除

public boolean remove(Object o) {
    if (o == null) {
        for (int index = 0; index < size; index++) {
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
        }
    } else {
        // 遍历查找:这里用equals比较,所以元素要正确实现equals方法
        // 我遇到过有人存自定义对象但没重写equals,导致remove失效
        for (int index = 0; index < size; index++) {
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
        }
    }
    return false;
}

fastRemove()就是去掉边界检查的remove(int index)。注意这个实现:它只删除第一个匹配项,并且需要遍历查找。如果要删除所有匹配项,应该用迭代器或者从后往前遍历删除。


改操作:set的陷阱

public E set(int index, E element) {
    rangeCheck(index);
    
    E oldValue = elementData(index);
    // 直接替换指定位置的引用
    // 注意:这里不会自动处理原元素的清理,如果原元素持有资源,可能泄露
    elementData[index] = element;
    return oldValue;
}

看起来简单,但有个实际坑:如果你在ArrayList里存的是需要手动清理的对象(比如某些Native资源包装类),替换元素后记得处理旧对象。另外,并发场景下,即使只是set操作也不安全,可能丢失更新。


时间复杂度真相

很多人背过“ArrayList查询O(1),增删O(n)”,但实际情况更复杂:

  1. 尾部add:平均O(1),但扩容瞬间O(n)
  2. 中间insert:O(n),且n是当前位置到末尾的距离
  3. 按索引remove:O(n),移动元素数量取决于删除位置
  4. 按值remove:O(n)查找 + O(n)移动,最坏情况遍历整个列表
  5. get/set:确实是O(1),但要注意数组边界

实际性能还受JVM缓存、内存布局影响。比如连续访问ArrayList元素会触发CPU缓存预取,而LinkedList的节点分散在堆中,缓存命中率低。


个人经验建议

  1. 初始化时就给容量:哪怕只是大概值,也比默认强。我习惯用new ArrayList<>(expectedSize + 10)留点余量。

  2. 删除时从后往前遍历

// 错误写法:正序删除会漏元素
for (int i = 0; i < list.size(); i++) {
    if (condition) {
        list.remove(i); // i会跳位
    }
}

// 正确写法:倒序删除
for (int i = list.size() - 1; i >= 0; i--) {
    if (condition) {
        list.remove(i); // 不影响前面索引
    }
}
  1. 批量操作用addAll:一次性添加多个元素比循环add性能好,因为只需一次容量检查和可能的扩容。

  2. 慎用contains和remove(Object):需要遍历,数据量大时用HashSet辅助查询。

  3. 线程安全不是加synchronized那么简单:即使给每个方法加锁,复合操作(如“检查再添加”)仍需外部同步。高并发场景考虑用CopyOnWriteArrayList,但要知道它的写代价。

  4. 监控扩容频率:如果频繁扩容,考虑用ensureCapacity()提前扩容,或者换用LinkedList(如果增删多)。

ArrayList就像数组——简单直接,但用不好就踩坑。理解数据移动的成本,才能写出对性能敏感的业务代码。下次见到有人用ArrayList做频繁的中间插入,记得把这篇文章甩给他。

005、LinkedList源码精讲(一):双向链表节点与类结构设计


从一次诡异的空指针说起

上周排查线上问题时遇到一个场景:某业务系统在遍历LinkedList过程中执行了remove操作,迭代器直接抛出了ConcurrentModificationException。同事的第一反应是“我没用多线程啊”,但问题确实发生了。这种时候,就得翻开LinkedList的源码看看它到底是怎么组织数据的——你会发现,它的实现比ArrayList要“有脾气”得多。

今天我们就从最基础的双向链表节点设计开始,把LinkedList的骨架拆清楚。


Node类:双向链表的灵魂

打开java.util.LinkedList源码,翻到第970行附近(JDK 8版本),你会看到这个静态内部类:

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;
    }
}

这个类简单得让人意外,但恰恰是LinkedList所有操作的基础。三个字段各司其职:

  • item:存放实际数据,泛型设计让它能装任何对象
  • prevnext:典型的双向链表指针,分别指向前驱和后继节点

注意这个构造器的设计:它要求你在创建节点时就明确前后邻居是谁。这种“一步到位”的初始化方式,在后续的插入操作中会频繁使用。


LinkedList的类结构:比想象中复杂

很多人以为LinkedList就是个简单的链表实现,其实它的类声明藏着不少信息:

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable

第一层继承关系AbstractSequentialList。这个父类专门为“顺序访问”的数据结构提供骨架实现。它默认所有操作都通过ListIterator完成,所以你在LinkedList里看到很多方法都是先获取迭代器再操作——这是模板方法模式的典型应用。

第二层接口实现:除了基本的List接口,还实现了Deque(双端队列)。这意味着你的LinkedList不仅可以当列表用,还能当栈、队列、双端队列使。这也是为什么你会在源码里看到addFirstpollLast这些队列风格的方法。

第三层标记接口CloneableSerializable。LinkedList支持浅拷贝和序列化,但这里有个坑:序列化时它只保存元素数据,不保存链表结构本身,反序列化时重新构建链表。


核心字段:三个关键变量撑起整个链表

看类的头部定义的字段:

transient int size = 0;           // 当前链表长度
transient Node<E> first;          // 头节点引用
transient Node<E> last;           // 尾节点引用

size字段很直观,就是元素个数。但注意它是transient的——序列化时不保存。为什么?因为LinkedList在序列化时走的是自定义的writeObjectreadObject,只序列化元素内容,不序列化节点间的引用关系。

firstlast是整个链表的入口。空链表时这两个都是null;只有一个元素时,first和last指向同一个节点。这里的设计很清晰:从头尾都能快速访问链表,这是双向链表的天然优势。


构造器:两种初始化方式

// 空构造器:创建一个空链表
public LinkedList() {
}

// 集合构造器:把现有集合的元素全部加进来
public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}

空构造器简单到极致,就是初始化三个字段为默认值。集合构造器先调空构造器,再调用addAll。这里有个细节:addAll方法内部会检查索引边界,即使是空链表也要走完整的插入逻辑。


基础方法设计:头尾操作的对称性

由于实现了Deque接口,LinkedList的方法成对出现:

// 头尾插入
private void linkFirst(E e)    // 在头部插入新节点
void linkLast(E e)             // 在尾部插入新节点

// 头尾删除
private E unlinkFirst(Node<E> f)  // 删除头节点
private E unlinkLast(Node<E> l)   // 删除尾节点

// 通用节点操作
void linkBefore(E e, Node<E> succ)  // 在指定节点前插入
E unlink(Node<E> x)                 // 解除任意节点的链接

这些私有方法才是LinkedList真正的核心操作。所有公开的addremoveofferpoll方法最终都委托给它们。比如addFirst(e)内部就是linkFirst(e)removeLast()内部就是unlinkLast(last)

这种设计保证了代码复用:公开方法只是对基础操作的不同包装。


一个容易踩坑的细节:边界处理

linkBefore方法里的一段代码:

void linkBefore(E e, Node<E> succ) {
    final Node<E> pred = succ.prev;  // 拿到指定节点的前驱
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;             // 更新后继的前驱指针
    if (pred == null)                // 如果前驱是null,说明succ是原头节点
        first = newNode;             // 新节点成为新的头节点
    else
        pred.next = newNode;         // 否则更新前驱的后继指针
    size++;
    modCount++;
}

这里有个关键判断:if (pred == null)。这个判断处理的是在链表头部插入的特殊情况。很多人在自己实现链表时会漏掉这种边界检查,结果就是在头尾插入时指针错乱。

modCount++也要注意——这是快速失败机制的基础。每次结构修改都要更新这个计数器,迭代器会检查它的值是否变化,变了就抛ConcurrentModificationException。文章开头提到的那个问题,就是因为遍历时直接调用list的remove方法,导致modCount变化,而迭代器自己的expectedModCount没更新。


给实际开发的几点建议

  1. 遍历时删除要用迭代器自己的remove方法
    直接调用list.remove()会破坏迭代器的状态跟踪,大概率抛ConcurrentModificationException。正确的做法是iterator.remove(),这个方法会同步更新expectedModCount。

  2. LinkedList的随机访问很贵
    get(int index)方法内部会根据索引位置决定从头还是尾开始遍历。访问中间元素的时间复杂度是O(n)。如果你需要频繁按索引访问,ArrayList才是合适的选择。

  3. 头尾插入删除确实快,但前提是你得能直接访问头尾
    很多业务场景下,你并不知道要操作的元素在链表中的位置,还是得遍历查找。这时候LinkedList的优势就没了,反而因为每个元素都要包装成Node对象,内存开销比ArrayList大。

  4. 考虑用LinkedList实现简单队列时,想清楚需不需要阻塞
    如果只是简单的生产者-消费者模型,LinkedList作为Queue够用了。但如果需要阻塞等待,还是用LinkedBlockingQueue更稳妥。

  5. 序列化有性能损耗
    因为要重新构建链表,反序列化时间复杂度是O(n)。高频序列化的场景下,这点损耗累积起来可能比你想象的大。


LinkedList的节点和类设计体现了一种“朴素的美感”——没有复杂的算法,就是靠指针的精确维护。但越是简单的东西,越容易在细节上出错。下次用LinkedList时,不妨想想它背后的这些节点是怎么链接起来的,很多使用上的误区自然就明白了。

下一章我们深入它的迭代器实现,看看这个“有脾气”的链表是怎么被安全遍历的。

LinkedList源码精讲(二):作为List的增删改查操作实现

昨天排查线上问题,发现一个同事在遍历LinkedList时用get(i)按索引取值,性能直接崩了。监控显示单次查询耗时随数据量线性增长,万级数据时接口响应突破2秒。这让我决定专门写一篇,讲讲LinkedList作为List接口实现时,那些看似简单却暗藏玄机的操作细节。

从那个踩坑的get()方法说起

打开LinkedList源码,找到get(int index)方法:

public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

表面看只是检查下标后返回节点元素,但真正的成本在node(index)里。这个方法决定了LinkedList随机访问的性能特征:

Node<E> node(int index) {
    // 关键在这里:判断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没有数组的随机访问能力,每次按索引取值都是遍历。虽然它聪明地判断从哪头开始遍历(size >> 1 就是除以2),但时间复杂度仍然是O(n)。这就是为什么开头提到的场景会出问题——在for循环里反复调用get(),实际是嵌套遍历,复杂度飙到O(n²)。

增删操作的“双向优势”

LinkedList的插入删除真的都是O(1)吗?看源码才知道有条件。在已知节点位置时,比如在listIterator迭代器位置插入:

void linkBefore(E e, Node<E> succ) {
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}

这是标准的链表插入,四个指针调整,确实是O(1)。但很多新手忽略了一个前提:你得先找到这个succ节点。如果调用的是add(int index, E element),它内部还是要先调用node(index)找到位置节点,这个查找过程就是O(n)了。

所以准确说:LinkedList在已知节点引用时的插入删除是O(1),按索引操作仍然是O(n)。这个细微差别在实际开发中经常被误解。

修改操作set()的陷阱

修改指定位置的元素看起来简单:

public E set(int index, E element) {
    checkElementIndex(index);
    Node<E> x = node(index);  // 这里又要遍历!
    E oldVal = x.item;
    x.item = element;
    return oldVal;
}

又是node(index)!这意味着set()也不是随机访问。我见过有人用LinkedList存储需要频繁按索引修改的数据,比如实时更新的排行榜,结果性能还不如ArrayList。虽然修改本身只是赋值,但找到这个节点已经花了O(n)成本。

查找操作的线性本质

indexOf(Object o)lastIndexOf(Object o)展示了双向遍历的两种方向:

public int indexOf(Object o) {
    int index = 0;
    for (Node<E> x = first; x != null; x = x.next) {
        if (o == null ? x.item == null : o.equals(x.item))
            return index;
        index++;
    }
    return -1;
}

这里有个细节:LinkedList允许存储null元素,所以判断要分两种情况。查找操作必须遍历,没有捷径。如果业务需要频繁按值查找,考虑用HashMap或HashSet辅助,纯靠LinkedList的indexOf()在大数据量下会成瓶颈。

批量操作addAll()的优化空间

addAll(int index, Collection<? extends E> c)的实现值得琢磨:

public boolean addAll(int index, Collection<? extends E> c) {
    checkPositionIndex(index);
    
    Object[] a = c.toArray();
    int numNew = a.length;
    if (numNew == 0)
        return false;
    
    Node<E> pred, succ;
    if (index == size) {  // 尾部追加是常见优化场景
        succ = null;
        pred = last;
    } else {
        succ = node(index);  // 这里又有O(n)成本
        pred = succ.prev;
    }
    
    for (Object o : a) {
        @SuppressWarnings("unchecked") E e = (E) o;
        Node<E> newNode = new Node<>(pred, e, null);
        if (pred == null)
            first = newNode;
        else
            pred.next = newNode;
        pred = newNode;
    }
    
    if (succ == null) {
        last = pred;
    } else {
        pred.next = succ;
        succ.prev = pred;
    }
    
    size += numNew;
    modCount++;
    return true;
}

注意到没?批量插入时,LinkedList只需要一次node(index)找到连接点,然后批量创建节点并链接。这比多次调用add(index, element)要高效得多,因为后者每次都要重新遍历。实际开发中,如果要在中间位置插入大量数据,尽量收集成集合一次性addAll。

迭代器的正确打开方式

文章开头的问题,正确解法是用迭代器:

ListIterator<E> it = list.listIterator();
while (it.hasNext()) {
    E element = it.next();  // 这里是O(1)!
    // 处理元素
}

LinkedList的迭代器ListItr内部维护了当前节点引用nextlastReturnednext()方法只是移动指针:

public E next() {
    checkForCommodification();
    if (!hasNext())
        throw new NoSuchElementException();
    
    lastReturned = next;
    next = next.next;
    nextIndex++;
    return lastReturned.item;
}

这才是遍历LinkedList的正确姿势。而且listIterator(int index)还做了优化,会判断从哪头开始找初始位置。

个人经验建议

  1. 别用LinkedList当“数组”用——如果需要频繁按索引随机访问,ArrayList才是正解。LinkedList的get(i)性能在数据量大时是灾难。

  2. 警惕中间位置的批量操作——即使批量插入,如果插入点在链表中间,仍然需要O(n)时间找到位置。这时要评估是否真的需要链表特性。

  3. 迭代器比for-i循环靠谱——遍历LinkedList一定要用迭代器,无论是for-each还是显式的ListIterator。for-i配合get()是典型反模式。

  4. 考虑使用ListIterator的add()——如果需要在遍历中插入元素,ListIterator.add()是O(1),而list.add(index, element)是O(n)。

  5. LinkedList适合“两头忙”的场景——比如实现队列、双端队列,或者需要频繁在头部操作的情况。Java的Deque接口实现就用了LinkedList。

实际项目中,我越来越少用LinkedList作为通用List。更多时候,要么用ArrayList(随机访问多),要么用ArrayDeque(队列场景)。LinkedList处于一个尴尬的位置——它什么都能做,但很多场景下都不是最优选。理解它的实现细节,就是为了知道什么时候该用它,什么时候该换方案。

LinkedList源码精讲(三):作为Deque的队列与栈操作

昨天排查线上问题时遇到个有意思的案例:某消息队列的本地缓冲池用了LinkedList做临时存储,压测时发现入队操作偶尔会卡顿。用jstack抓线程栈一看,好几个线程都卡在addLast()方法上。这让我想起很多开发者其实并不清楚LinkedList作为双端队列时的完整能力,今天就来拆解它的队列与栈操作实现。

从调试现场说起

当时看到的堆栈片段是这样的:

java.util.LinkedList.addLast(LinkedList.java:157)
java.util.LinkedList.add(LinkedList.java:352)
com.example.MessageBuffer.offer(MessageBuffer.java:47)

点进源码发现addLast()里就是个简单的尾插操作:

// LinkedList第157行附近
void addLast(E e) {
    final Node<E> l = last;      // 这里并发时可能拿到过时的last节点
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;        // 问题可能出在这里,多个线程同时修改
    size++;
    modCount++;
}

这个案例暴露出关键点:LinkedList本身不是线程安全的,但很多人误以为“队列操作”就应该天然线程安全。实际上它的队列方法只是提供了队列的接口形态。

作为Queue的双端操作

LinkedList实现了Deque接口,这意味着它同时支持FIFO(队列)和LIFO(栈)两种模式。先看队列的经典操作:

// 入队操作,队尾添加
public boolean offer(E e) {
    return add(e);  // 内部调用addLast()
}

// 出队操作,队头移除
public E poll() {
    final Node<E> f = first;
    return (f == null) ? null : unlinkFirst(f);
}

// 查看队头但不移除
public E peek() {
    final Node<E> f = first;
    return (f == null) ? null : f.item;
}

注意offer()add()的区别:当队列容量受限时(LinkedList不受限),offer()返回false而add()抛异常。但LinkedList永远返回true,这两个方法在这里完全等价——这种设计其实容易让人困惑,我建议在明确需要队列语义时坚持用offer/poll/peek组合,形成编码习惯。

作为Stack的栈操作

Java官方文档早就说过Stack类已经不推荐使用了,该用Deque接口实现栈。LinkedList提供了完整的栈操作:

// 压栈,等价于addFirst()
public void push(E e) {
    addFirst(e);
}

// 弹栈,等价于removeFirst()
public E pop() {
    return removeFirst();
}

这里有个坑我见过不少人踩:pop()在空栈时会抛NoSuchElementException,而poll()返回null。如果你不确定栈是否为空,又不想处理异常,可以用pollFirst()替代pop()。不过这样会破坏栈的语义一致性,需要团队内部统一约定。

双端队列的四种视角

LinkedList最强大的地方在于支持四种操作视角,这是普通队列不具备的:

// 1. 普通队列视角(FIFO)
queue.offer("A");  // 队尾入
queue.poll();      // 队头出

// 2. 栈视角(LIFO)  
stack.push("A");   // 栈顶入
stack.pop();       // 栈顶出

// 3. 双端队列视角
deque.offerFirst("A");  // 队头入
deque.offerLast("Z");   // 队尾入
deque.pollFirst();      // 队头出
deque.pollLast();       // 队尾出

// 4. 集合视角(这个容易滥用)
deque.add(2, "M");      // 中间插入,慎用!O(n)操作

很多刚接触Deque的开发者会滥用随机访问能力,在需要中间插入时直接调用add(index, element)。要知道这个操作的时间复杂度是O(n),如果频繁使用就完全失去了双端队列的优势。我见过有人用LinkedList做滑动窗口,结果在窗口中间频繁插入删除,性能还不如ArrayList

迭代器的特殊行为

队列操作和迭代器配合时有个细节需要注意:

LinkedList<String> deque = new LinkedList<>();
deque.add("A");
deque.add("B");

Iterator<String> it = deque.iterator();
deque.poll();  // 修改队列结构
it.next();     // 这里会抛ConcurrentModificationException

即使单线程操作,在迭代过程中调用队列的修改方法也会触发快速失败机制。如果需要在遍历时修改,考虑用ListIterator

ListIterator<String> lit = deque.listIterator();
while (lit.hasNext()) {
    String item = lit.next();
    if (condition) {
        lit.remove();  // 安全移除当前元素
        lit.add("New"); // 在当前位置添加
    }
}

性能陷阱与实战建议

  1. 不要用LinkedList做随机访问。虽然它实现了List接口,但get(int index)需要遍历,性能是O(n)。曾经有同事用它存了几千个元素然后循环调用get(i),性能直接崩掉。

  2. 线程安全必须自己保证。最简单的方案是用Collections.synchronizedList()包装,但注意迭代时仍需手动同步:

List<String> syncList = Collections.synchronizedList(new LinkedList<>());
// 遍历时必须加锁
synchronized (syncList) {
    for (String item : syncList) {
        // 操作
    }
}
  1. 考虑用ArrayDeque替代。大多数情况下ArrayDequeLinkedList性能更好,内存更紧凑。除非你需要频繁在中间插入删除,或者需要null元素(ArrayDeque不支持null),否则优先选ArrayDeque

  2. 实现阻塞队列考虑LinkedBlockingDeque。如果需要线程安全的阻塞队列,Java并发包提供了现成的实现,别自己造轮子。

个人经验

这些年用LinkedList的经验告诉我,它的最佳使用场景是:需要频繁在两端插入删除,且不需要随机访问的序列。比如实现一个LRU缓存淘汰策略,或者处理撤销操作的历史记录栈。

在微服务架构中,我常用它做本地任务缓冲池(配合锁或CAS保证线程安全),特别是当任务有优先级时需要从两端操作。但切记容量要控制,否则大内存队列一旦Full GC,整个应用都可能卡住。

最后分享个编码习惯:当我声明一个LinkedList变量时,总是用Deque接口类型接收:

Deque<String> deque = new LinkedList<>();

这样在IDE里就只能看到队列相关的方法,避免手滑调用不合适的列表操作。接口约束不仅是规范,更是对设计意图的传达。

下次遇到需要在队列和栈之间切换的场景,不妨想想LinkedList的双重身份——它可能比你想象的更擅长这种“两面派”的工作。

008、性能对决:ArrayList vs LinkedList 场景化基准测试与选型指南

那天下午,同事在会议室白板上画着链表结构,嘴里念叨着“这里怎么会OOM呢”。我凑近一看,监控图上显示某个分页查询接口的响应时间曲线像过山车——平时20毫秒的接口,偶尔会飙到500毫秒以上。他信誓旦旦地说:“我把ArrayList全换成LinkedList了,按理说插入删除应该更快才对啊。”

我们回到工位,打开他改过的代码。一个分页查询方法里,他为了“优化性能”,把原本的ArrayList<UserVO>换成了LinkedList,理由是他觉得分页时需要频繁截取子列表。我默默写了个测试用例,用JMH跑了一遍,结果让他愣住了:在5000条数据量下,LinkedList的分页性能反而比ArrayList慢了近8倍。

内存布局的真相

先看两个最简单的创建语句:

List<String> arrayList = new ArrayList<>();
List<String> linkedList = new LinkedList<>();

这两行代码在内存里完全是两个世界。ArrayList背后是个Object数组,在堆上连续分配。连续存储意味着CPU缓存友好——当你访问list.get(5)时,附近的下标46很可能已经被预加载到CPU缓存行里了。

LinkedList的节点则是散落在堆内存各个角落的Node对象,每个节点要额外存储前后指针。在64位JVM开启指针压缩的情况下,每个节点至少多出16字节开销。遍历链表时,CPU得不停地在不同内存地址间跳转,缓存命中率惨不忍睹。

随机访问的代价

我们常听说“LinkedList随机访问慢”,但到底多慢?看这段源码:

// ArrayList的get方法,简单到令人发指
public E get(int index) {
    Objects.checkIndex(index, size);
    return elementData[index];  // 一次数组访问,O(1)
}

// LinkedList的get方法,需要遍历
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

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的get方法最坏情况下要遍历半个链表。我测过,在10万数据量下,用for循环遍历LinkedList比ArrayList慢300倍以上。所以千万别这样写:

// 灾难代码示例
for (int i = 0; i < linkedList.size(); i++) {
    String item = linkedList.get(i);  // 每次get都是O(n)!
}

插入删除的误区

教科书说LinkedList插入删除快,但没说清楚前提。看这两个场景:

场景A:在已知位置插入

// ArrayList在末尾添加
list.add(value);  // 平均O(1),扩容时O(n)

// LinkedList在末尾添加
list.add(value);  // 需要创建Node对象,维护指针

实际测试发现,在尾部插入时,ArrayList通常更快,因为数组的连续内存分配比创建对象开销小。只有一种情况LinkedList真快:在列表中间频繁插入删除,且你已经持有迭代器位置

场景B:中间插入对比

// ArrayList在中间插入
list.add(5000, element);  // 要移动后面所有元素

// LinkedList用迭代器在中间插入
ListIterator<E> it = list.listIterator(5000);
it.add(element);  // 修改指针即可

但注意!要到达那个迭代器位置,你还是得遍历过去,这个开销经常被忽略。

真实场景基准测试

我在本地用JMH做了组测试(数据量:10000个元素):

  1. 随机读取测试
ArrayList.get() 平均: 2.3 ns/op
LinkedList.get() 平均: 856.7 ns/op

差距近400倍,因为LinkedList每次get都要遍历。

  1. 头部插入测试
ArrayList.add(0, element) 平均: 512.4 ns/op  
LinkedList.addFirst(element) 平均: 48.2 ns/op

这次LinkedList赢了10倍,因为ArrayList要整体后移。

  1. 遍历求和测试
// 用迭代器遍历LinkedList
for (String s : linkedList) { sum += s.length(); }

// 用for循环遍历ArrayList  
for (int i = 0; i < arrayList.size(); i++) {
    sum += arrayList.get(i).length();
}

结果ArrayList的for循环版本比LinkedList的迭代器版本还快20%,颠覆认知吧?原因是ArrayList的内存局部性太好了。

内存占用分析

用JOL工具看对象布局:

ArrayList(1000个元素)占用: 4016 bytes
LinkedList(1000个元素)占用: 24016 bytes 

LinkedList多出来的不仅是节点对象开销,还有每个Node里的前后指针。在大数据量下,这个差距能差出一个数量级。

选型决策树

根据我的踩坑经验,给出几个实战建议:

无脑选ArrayList的情况:

  • 90%以上的业务场景
  • 数据量小于100万(现代机器内存完全扛得住)
  • 需要频繁随机访问
  • 要做分页、批处理操作
  • 内存敏感的应用

考虑LinkedList的情况:

  • 实现LRU缓存,需要快速移动头部/尾部元素
  • 需要实现撤销操作栈(LinkedList实现了Deque)
  • 中间插入极其频繁,且能复用迭代器位置
  • 列表长度变化巨大且无法预估初始容量

容易踩坑的场景:

  1. Collections.sort()排序LinkedList——会先转成数组,排序后再重建链表,性能灾难
  2. subList()对LinkedList分页——底层还是遍历,不如直接转ArrayList
  3. 在循环里用contains()检查存在性——两个都是O(n),该用HashSet

个人经验包

这些年我总结了几条铁律:

第一,默认永远用ArrayList。直到性能测试证明它真的是瓶颈,再去考虑LinkedList。我见过太多“优化”反而把系统搞垮的案例。

第二,如果数据量真的很大(比如超过50万),先考虑分页或分批处理,而不是换链表结构。内存连续的优势在大数据量下更明显。

第三,关注JVM的优化。现代JVM对连续内存访问有大量优化,比如逃逸分析、标量替换,这些优化对LinkedList的节点对象效果有限。

第四,实在拿不准就写基准测试。用JMH花10分钟跑个测试,比纠结一上午都有用。记住要模拟真实数据规模和访问模式。

最后说回开头那个分页问题。我们把LinkedList改回ArrayList,同时给ArrayList设置了合理的初始容量,接口的TP99从500毫秒降到了15毫秒。有时候,最简单的方案反而是最有效的。

在Java集合的世界里,ArrayList就像瑞士军刀——不是最专业的工具,但能解决大多数问题。LinkedList则是特种手术刀——特定场景下无可替代,但日常使用容易伤到手。知道什么时候该用什么,才是资深工程师的真正功力。

009、底层内存模型:从JVM视角看数组与链表的存储与访问差异

上周排查一个线上性能抖动问题,监控显示某个列表遍历操作在特定时段耗时突然增加3倍。用JProfiler抓取内存快照后发现,ArrayList在扩容期间出现了大量临时数组拷贝,而LinkedList的节点在堆中散落分布导致缓存命中率骤降。这让我意识到,很多开发者对这两种结构的理解停留在“数组查询快、链表增删快”的层面,却很少从JVM内存模型的角度思考它们的本质差异。

内存布局的物理差异

ArrayList的底层是一个Object[] elementData数组。在堆内存中,这个数组占据一块连续的内存区域。假设我们存储10万个Integer对象,JVM会在堆中分配一块连续空间,每个引用占4字节(32位JVM)或8字节(64位JVM,开启压缩指针后为4字节)。这种连续布局对CPU缓存极其友好——当访问第一个元素时,相邻的几十个元素很可能已经被预加载到L1/L2缓存中。

// 实际存储的是引用,不是对象本身
Object[] elementData = new Object[10];
elementData[0] = new Integer(1);  // 这里只是存储对象的地址
elementData[1] = new Integer(2);  // 这个引用紧挨着上一个引用存储

LinkedList则完全不同。每个Node节点包含item、prev、next三个引用,在堆中像“珍珠项链”一样通过指针连接,但珍珠本身散落在堆的各个角落。这种非连续存储带来两个后果:一是每个节点都需要额外的对象头开销(约16字节),二是遍历时CPU无法预读下一个节点的内存地址。

// 实际的内存访问模式
Node<E> {
    E item;         // 指向实际对象的引用
    Node<E> next;   // 指向下一个节点的引用
    Node<E> prev;   // 指向上一个节点的引用
}
// 这三个引用可能分布在堆的不同区域,CPU缓存帮不上忙

访问局部性与缓存行

现代CPU的缓存行(Cache Line)通常是64字节。当读取ArrayList中第一个元素时,整个缓存行(包含后续15个引用)会被一次性加载。这意味着后续15次get()操作几乎都在L1缓存中完成,速度比主内存快100倍以上。

LinkedList的节点在堆中随机分布,每个节点本身可能只占32字节(对象头16+三个引用12+对齐填充4),但两个相邻节点很可能不在同一个缓存行,甚至不在同一个内存页。遍历时每次访问next节点都是一次缓存未命中(Cache Miss),必须从主内存重新加载。这就是为什么遍历100万个元素的LinkedList比ArrayList慢几十倍的根本原因。

// 伪代码展示缓存行利用差异
// ArrayList遍历 - 内存访问模式
for (int i = 0; i < list.size(); i++) {
    // 这里大概率是缓存命中,CPU在偷着乐
    Object item = elementData[i]; 
}

// LinkedList遍历 - 内存访问模式  
Node<E> current = first;
while (current != null) {
    // 这里每次都要问内存:“下一个节点在哪?”
    current = current.next;  // 缓存说:我又不知道,你去问主内存吧
}

内存分配与GC压力

ArrayList初始化时分配连续数组,后续扩容时创建新数组并拷贝旧数据。这个“旧数组”会变成垃圾等待回收。如果频繁扩容(比如默认从10扩容到15、22、33…),会产生大量内存碎片。但好处是对象数量少:100万个元素只有1个数组对象+100万个元素对象。

LinkedList每次add()都new一个Node对象。100万个元素意味着100万个Node对象+100万个元素对象。这对年轻代GC是巨大压力——每次Minor GC都要标记和移动这200万个对象。更糟糕的是,由于节点间相互引用,这些对象很容易晋升到老年代,引发Full GC。

// 实际开发中的坑:大量数据用LinkedList存储
List<LogEntry> logList = new LinkedList<>();  // 别这样写!
for (int i = 0; i < 1000000; i++) {
    logList.add(new LogEntry(...));  // 每秒几千个Node对象产生,年轻代哭晕
}
// 改成这样至少内存连续,GC压力小很多
List<LogEntry> logList = new ArrayList<>(1000000);

指针追逐与分支预测

CPU有分支预测器(Branch Predictor)来优化循环。ArrayList的for循环是典型的顺序访问模式,预测器能准确预判下一次循环。LinkedList的while循环中,每次current.next的地址都是不确定的,分支预测经常失败,导致流水线清空(Pipeline Flush)。

还有一个隐藏问题:现代JVM对数组有特殊优化(比如逃逸分析后可能直接在栈上分配),但对LinkedList节点几乎无法优化。通过async-profiler查看CPU的LBR(Last Branch Record)数据,会发现LinkedList遍历时分支误预测率高达10%-15%,而ArrayList几乎为零。

实战经验与建议

  1. 数据量小于1000时,别纠结选哪个,差异微乎其微。但代码可读性上,ArrayList更符合直觉。

  2. 需要实现队列时,用ArrayDeque而不是LinkedList。ArrayDeque同样基于数组,但实现了环形缓冲区,避免了头部操作时的数据拷贝。

  3. 内存敏感场景(如Android开发或大数据处理),优先考虑ArrayList并指定初始容量。避免扩容时的拷贝和碎片。

  4. 遍历为主的操作,即使需要频繁在中间插入,也值得考虑ArrayList。实测发现,在10000个元素中,即使每10次遍历才做1次插入,ArrayList整体性能仍高于LinkedList,因为遍历的优势太大了。

  5. 监控GC时,如果发现大量Node对象在年轻代,赶紧检查是不是用了LinkedList。曾经有个案例:将LinkedList换成ArrayList后,Young GC频率从每分钟10次降到2次。

最后分享一个调试技巧:用HSDB(HotSpot Debugger)查看对象内存布局时,ArrayList显示为一片连续的[I对象(数组),LinkedList则是满天星斗的Node对象。这种视觉差异能帮你直观理解为什么数组的遍历能享受缓存红利,而链表只能忍受内存的随机访问延迟。选择数据结构时,多想想数据在内存中的真实模样,这比死记教科书上的时间复杂度更有价值。

010、总结与进阶:迭代器、并发修改异常与自定义高性能集合实践


从一次深夜调试说起

上周排查一个线上问题,日志里频繁出现 ConcurrentModificationException。场景很典型:一个 ArrayList 在遍历过程中,另一个线程偷偷执行了 add 操作。但有意思的是,代码里明明加了 synchronized 块,异常却依然抛出。打开 ArrayList.java 源码,盯着 modCount 这个字段看了半天,突然意识到——我们以为的“线程安全”和集合框架设计的“线程安全”根本不是一回事。

今天我们就从迭代器的设计哲学出发,聊聊那些源码里藏着的细节,最后动手写一个真正适合自己业务的高性能集合。


迭代器:不只是个遍历工具

很多人觉得迭代器就是个 for-each 的底层实现,其实它背后是一套集合状态快照的契约。看 ArrayListItr 内部类:

private class Itr implements Iterator<E> {
    int cursor;       // 下一个要返回的元素索引
    int lastRet = -1; // 最近一次返回的索引,删除元素时用
    int expectedModCount = modCount; // 关键在这里!

    Itr() {}

    public boolean hasNext() {
        return cursor != size;
    }

    @SuppressWarnings("unchecked")
    public E next() {
        checkForComodification(); // 每次next都检查
        // ... 省略具体逻辑
    }

    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

注意 expectedModCount = modCount 这一行。迭代器创建时,就把集合当前的修改次数“冻结”了。之后任何结构性修改(增删,不包括改值)都会让 modCount++,于是 next() 里一检查就抛异常。

这里踩过坑:你以为用 synchronized 锁住整个遍历过程就安全了?如果遍历代码里调了 list.remove(item),迭代器自己就会触发 modCount++,下次 next() 时自己和自己比都对不上。正确做法是用迭代器自己的 remove()

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    if (s.startsWith("test")) {
        it.remove(); // 这个操作会同步更新 expectedModCount
    }
}

LinkedList 的迭代器优化

LinkedList 的迭代器实现更聪明。因为链表访问慢,它的 ListItr 会缓存上一次访问的节点:

private class ListItr implements ListIterator<E> {
    private Node<E> lastReturned;
    private Node<E> next;
    private int nextIndex;
    private int expectedModCount = modCount;

    ListItr(int index) {
        // 根据index靠近头还是尾,决定从头遍历还是从尾遍历
        next = (index == size) ? null : node(index);
        nextIndex = index;
    }

    public E next() {
        checkForComodification();
        if (!hasNext()) throw new NoSuchElementException();
        lastReturned = next;
        next = next.next; // 直接指针后移,不用重新从头找
        nextIndex++;
        return lastReturned.item;
    }
}

这种“缓存节点”的设计让 LinkedList 在顺序遍历时并不慢,但随机访问 list.get(i) 才是它的性能瓶颈。


并发修改异常的真实场景

ConcurrentModificationException 并不只在多线程中出现。单线程下这个异常更常见:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
for (String s : list) {
    if (s.equals("b")) {
        list.add("new"); // 这里直接抛异常
    }
}

增强 for 循环实际用的是迭代器,在迭代过程中直接操作原集合就会触发。开发中容易忽略的是,即使不在同一个方法里修改也可能中招:

void process(List<String> list) {
    for (String s : list) {
        helper.modify(list); // 这个方法里可能偷偷加了元素
    }
}

经验法则:遍历时如果必须修改集合,要么用迭代器自己的方法,要么改用 CopyOnWriteArrayList(注意性能开销),要么遍历时收集需要修改的内容,遍历完再统一处理。


动手:实现一个带版本戳的快速失败集合

理解了 modCount 机制,我们可以自己写一个更贴合业务的集合。比如需要一个能记录“何时被修改”的列表:

public class VersionedList<E> extends AbstractList<E> {
    private final ArrayList<E> delegate = new ArrayList<>();
    private volatile long version = 0L; // 用long,避免溢出问题
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    @Override
    public boolean add(E e) {
        lock.writeLock().lock();
        try {
            delegate.add(e);
            version++; // 每次修改都更新版本
            return true;
        } finally {
            lock.writeLock().unlock();
        }
    }

    @Override
    public E get(int index) {
        lock.readLock().lock();
        try {
            return delegate.get(index);
        } finally {
            lock.readLock().unlock();
        }
    }

    // 提供快照式迭代器
    public Iterator<E> snapshotIterator() {
        long snapVersion;
        List<E> copy;
        lock.readLock().lock();
        try {
            snapVersion = version;
            copy = new ArrayList<>(delegate); // 这里复制有开销,但保证一致性
        } finally {
            lock.readLock().unlock();
        }
        return new Iterator<E>() {
            private int index = 0;
            private final long expectedVersion = snapVersion;
            private final List<E> snapshot = copy;

            @Override
            public boolean hasNext() {
                checkVersion();
                return index < snapshot.size();
            }

            @Override
            public E next() {
                checkVersion();
                if (!hasNext()) throw new NoSuchElementException();
                return snapshot.get(index++);
            }

            private void checkVersion() {
                if (expectedVersion != version) {
                    throw new ConcurrentModificationException(
                        "集合在迭代期间被修改,版本号从 " + expectedVersion + " 变为 " + version);
                }
            }
        };
    }

    // 其他方法省略...
}

这个实现有几个特点:

  1. 用读写锁区分读多写少的场景,比 synchronized 粒度更细
  2. 版本号用 long,避免 int 溢出后意外相等的问题(虽然概率极低)
  3. 提供快照迭代器,遍历期间即使原集合被修改也不抛异常(业务上有时需要这种语义)

性能取舍的思考

ArrayListLinkedList 的取舍老生常谈,但实际项目中,我见过更多性能问题出在误用上:

  • 在已知大小的场景下,new ArrayList<>(initialCapacity) 能避免多次扩容
  • LinkedList 真的很少用到,除非你在中间频繁插入删除且能直接用迭代器定位
  • 遍历 LinkedList 时用 for-each 或迭代器,千万别用 for (int i=0; i<list.size(); i++),那是 O(n²) 的灾难

还有一个隐藏细节:Arrays.asList() 返回的列表是固定大小的,调用 add 会直接抛 UnsupportedOperationException。这种坑我见过不止一次。


给工程师的几点建议

  1. 遍历时修改集合前,先问自己是否真的需要实时修改。很多时候收集起来批量处理更安全、更快。

  2. 多线程场景下,Collections.synchronizedList 只是基础保障,它的迭代器仍然需要手动同步。高并发考虑 ConcurrentHashMapCopyOnWriteArrayList,但务必理解它们的写时复制开销。

  3. 自定义集合类前,先看看 Guava 或 Apache Commons Collections。人家实现了几十种变体,可能早就有你需要的。

  4. 生产环境慎用 subList。它返回的是原列表的视图,原列表修改后子列表可能失效,这种隐式关联容易埋雷。

  5. 调试集合问题时,记得看 modCount。在 IDE 里把这个字段加到监视窗口,能帮你快速判断是不是并发修改问题。

集合框架用起来简单,但用好需要理解它的契约。每次写 list.add() 时,心里都清楚它背后在做什么,这才是资深工程师的底气。


下次我们聊聊 HashMap 在 JDK 8 的红黑树改造,那又是另一个充满权衡的设计故事。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐