一、开篇:为什么数组总是从0开始?

在学习集合之前,我们先来思考一个基础问题:为什么数组的索引总是从0开始,而不是从1开始?

这其实是历史遗留和性能考虑的结果。在计算机底层,数组是一块连续的内存空间,当我们访问数组的第i个元素时,计算机会通过一个公式来定位它的内存地址:

第i个元素的地址 = 数组首地址 + i × 每个元素占用的字节数

如果索引从0开始,计算起来就是上面的公式,非常简单。但如果索引从1开始,公式就变成了:

第i个元素的地址 = 数组首地址 + (i-1) × 每个元素占用的字节数

每次访问都要多一次减法运算。虽然看起来微不足道,但在计算机底层,这种微小的性能差异积累起来就是可观的提升。所以C语言设计者选择了从0开始,后来的Java也沿用了这个设计。

二、数组的特点与局限

数组是Java中最基础的数据结构,它有两大特点:

优点:内存空间连续,通过索引访问元素的时间复杂度是O(1),极快!

缺点:一旦创建,长度就固定了,不能动态增加。比如你创建了一个长度为10的数组,存了8个元素后想存第9个没问题,但想存第11个就装不下了。

int[] arr = new int[10];  // 长度固定为10
arr[0] = 1;
arr[1] = 2;
// ... 存到 arr[9] 是极限了
arr[10] = 11;  // 数组下标越界!

正是为了解决数组不能动态增长的痛点,Java推出了ArrayList。

三、ArrayList:升级版的数组

3.1 底层结构

ArrayList的底层就是一个数组,但它比普通数组聪明的地方在于:它会自动扩容。

// 创建ArrayList
ArrayList<String> list = new ArrayList<>();
list.add("张三");  
list.add("李四");

当你创建ArrayList时,它内部会初始化一个数组(默认容量是10)。当你往里面添加元素,发现数组装满了,ArrayList会怎么做呢?

3.2 扩容机制

ArrayList的扩容规则是:当数组满了需要添加新元素时,会创建一个新的数组,新数组的大小是原数组的1.5倍,然后把原数组的所有元素复制过去,最后释放原数组的内存

// 伪代码展示ArrayList扩容逻辑
if (当前元素个数 == 数组长度) {
    int 新容量 = 原容量 + (原容量 >> 1);  // 右移一位相当于除以2,所以是1.5倍
    Object[] 新数组 = new Object[新容量];
    System.arraycopy(原数组, 0, 新数组, 0, 原容量);
    原数组 = 新数组;  // 指向新数组
}

3.3 访问元素为什么快?

因为ArrayList的底层是数组,内存空间连续,所以通过索引访问元素直接就是O(1)的时间复杂度:

String name = list.get(5);  // 直接定位到第5个元素的位置,瞬间拿到

四、LinkedList:基于节点的链表

4.1 底层结构

LinkedList的底层是双向链表,它由一个个节点(Node)组成,每个节点包含三部分:数据、指向前一个节点的引用、指向后一个节点的引用。

// 伪代码展示LinkedList内部节点结构
class Node {
    Object item;      // 存储的数据
    Node prev;        // 指向前一个节点
    Node next;        // 指向后一个节点
}

4.2 链表的特点

优点:添加和删除元素理论上很快,只需要修改前后节点的指向,不需要移动其他元素。

缺点:不能通过索引直接访问元素。比如你想获取第5个元素,LinkedList只能从头节点开始,一个一个往后找,直到找到第5个。

LinkedList<String> linkedList = new LinkedList<>();
linkedList.add("A");
linkedList.add("B");
linkedList.add("C");
// ... 添加很多元素

String element = linkedList.get(100);  
// 需要从第0个开始,next->next->next... 找100次!

五、缓存对性能的影响

5.1 什么是CPU缓存?

CPU处理数据的速度极快,而内存读取数据的速度相对较慢。为了解决这个速度不匹配的问题,CPU内部嵌入了一块小而快的内存,叫做缓存(Cache)

缓存的工作原理是:当CPU读取一个内存地址的数据时,它会把这个地址附近的一块数据也一起加载到缓存中。因为程序访问内存时往往具有局部性原理——访问了一个地址,很可能很快就会访问它附近的地址。

5.2 连续存储 vs 离散存储

ArrayList的优势:数组在内存中是连续存储的,遍历时CPU会把整个数组块加载到缓存,后面的访问都直接从缓存中读取,速度极快。

// 遍历ArrayList - 缓存友好
for (int i = 0; i < arrayList.size(); i++) {
    System.out.println(arrayList.get(i));  
    // CPU预加载了连续的内存,大部分访问命中缓存
}

LinkedList的劣势:链表的节点在内存中是离散的,可能分散在各个角落。遍历时,访问完一个节点,下一个节点可能在很远的地方,CPU需要不断从内存重新加载数据到缓存,缓存命中率低。

// 遍历LinkedList - 缓存不友好
for (String s : linkedList) {
    System.out.println(s);
    // 每个节点都可能在不同位置,CPU缓存几乎用不上
}

这就是为什么实际测试中,遍历LinkedList往往比ArrayList慢很多的原因之一。

六、删除操作

教科书上常说:LinkedList的增删操作快,ArrayList的增删操作慢。但这个结论需要仔细推敲。

6.1 理论上的对比

ArrayList删除元素

// 删除索引为5的元素
arrayList.remove(5);
// 需要把索引6、7、8...的所有元素向前移动一位
// 时间复杂度 O(n)

LinkedList删除元素

// 删除索引为5的元素
linkedList.remove(5);
// 只需要修改节点5前后两个节点的指向
// 但是!必须先找到节点5
// 找节点的过程是 O(n),修改指向是 O(1)
// 总复杂度还是 O(n)

6.2 现实中的真相

删除操作的核心成本有两部分:

  1. 查找目标元素的时间

  2. 真正删除的时间

对于ArrayList:

  • 查找:O(1)(直接索引)

  • 删除后移动元素:O(n)

  • 总成本:O(n)

对于LinkedList:

  • 查找:O(n)(需要遍历到目标位置)

  • 删除(修改指针):O(1)

  • 总成本:O(n)

关键点来了:虽然两者都是O(n),但ArrayList的O(n)是内存连续的移动操作,CPU缓存友好;LinkedList的O(n)是遍历节点,每个节点都可能缓存缺失。所以实际情况往往是:ArrayList的删除比LinkedList更快

测试一下:

// 测试删除中间元素
ArrayList<Integer> arrayList = new ArrayList<>();
LinkedList<Integer> linkedList = new LinkedList<>();

// 都添加100万条数据
for (int i = 0; i < 1000000; i++) {
    arrayList.add(i);
    linkedList.add(i);
}

// 删除中间位置的元素
long start1 = System.currentTimeMillis();
arrayList.remove(500000);
long end1 = System.currentTimeMillis();
System.out.println("ArrayList删除耗时:" + (end1 - start1) + "ms");

long start2 = System.currentTimeMillis();
linkedList.remove(500000);
long end2 = System.currentTimeMillis();
System.out.println("LinkedList删除耗时:" + (end2 - start2) + "ms");

// 结果:
// ArrayList删除耗时:8ms
// LinkedList删除耗时:15ms

6.3 什么时候LinkedList真的快?

只有当你在链表头部或者已知节点位置进行增删时,LinkedList才真正体现优势:

// LinkedList的优势场景
linkedList.addFirst("开头插入");  // O(1),不需要查找
linkedList.removeFirst();        // O(1),删除头节点

// 或者你已经通过遍历拿到了某个节点
ListIterator<String> it = linkedList.listIterator();
while (it.hasNext()) {
    String s = it.next();
    if (s.equals("某个条件")) {
        it.remove();  // 基于当前节点删除,O(1)
        break;
    }
}

七、适用场景总结

7.1 适合用ArrayList的场景:

  • 频繁按索引访问元素(绝大多数情况)

  • 主要在末尾添加/删除元素

  • 需要遍历所有元素(缓存友好)

  • 不知道用什么的时候(默认选ArrayList)

7.2 适合用LinkedList的场景:

  • 频繁在列表头部插入/删除(队列、栈)

  • 已经持有某个节点,需要在其前后操作

  • 内存要求苛刻,且元素数量巨大(链表没有预留空间)

八、总结

ArrayList和LinkedList是Java中最常用的两种List实现,它们的区别可以简单概括为:

  • 底层结构:ArrayList基于动态数组,LinkedList基于双向链表

  • 内存布局:ArrayList内存连续,LinkedList节点离散

  • 访问性能:ArrayList索引访问O(1),LinkedList索引访问O(n)

  • 增删性能:理论上LinkedList快,实际受缓存影响往往ArrayList更快

  • 扩容机制:ArrayList需要扩容(1.5倍),LinkedList不需要

除非明确需要LinkedList的特殊功能,否则默认选择ArrayList。这是无数Java前辈用经验和教训换来的建议。


以上是我作为初学者的梳理与思考,如果有理解不准确之处,欢迎各位前辈指正!毕竟集合框架是Java的根基,多思考底层原理,才能写出更高效的代码。

Logo

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

更多推荐