目录

一、从数据结构说起:什么是线性表和顺序表?

1.1 线性表的概念

1.2 顺序表:ArrayList的底层灵魂

二、ArrayList的"家族地位"

三、ArrayList的使用:从创建到操作

3.1 三种构造方式

3.2 核心操作方法

3.3 三种遍历方式

四、ArrayList的扩容机制:源码级别的深入分析

4.1 为什么要扩容?

4.2 扩容流程(JDK 17源码分析)

4.3 扩容的关键结论

4.4 一个常见的思考题

五、实战案例:洗牌算法

5.1 定义牌类

5.2 实现洗牌和发牌

六、ArrayList的局限性

6.1 主要问题

6.2 什么时候不适合用ArrayList?

七、总结


写在前面:本文基于《ArrayList与顺序表》课程内容整理,结合个人学习笔记和代码实践编写。文中所有示例代码均为独立编写,旨在帮助读者深入理解ArrayList的工作原理。如需系统学习,建议配合Oracle官方文档和JDK源码阅读。


一、从数据结构说起:什么是线性表和顺序表?

1.1 线性表的概念

在正式开始讲ArrayList之前,有必要先搞清楚两个基础概念:线性表顺序表

线性表是n个具有相同特性的数据元素的有限序列。听起来有点抽象?其实生活中到处都是线性表:

  • 排队买奶茶的队伍(先进先出)

  • 手机的通讯录列表(可以上下滑动浏览)

  • 书架上一排整齐的书(每本书都有固定位置)

线性表在逻辑结构上是连续的(像一条直线),但在物理存储上不一定连续。常见的线性表包括:顺序表、链表、栈、队列等。

1.2 顺序表:ArrayList的底层灵魂

顺序表就是用一段物理地址连续的存储单元来依次存储数据元素的线性结构。通俗地说,就是用数组来实现的线性表

顺序表的特点是:

  • 元素在内存中是挨着放的

  • 可以通过下标快速访问任意位置的元素

  • 插入和删除需要移动大量元素

ArrayList就是Java帮我们封装好的一个动态顺序表——它会自动扩容,不用我们自己操心数组长度不够的问题。


二、ArrayList的"家族地位"

在Java集合框架中,ArrayList的继承关系是这样的:

Iterable (可迭代)
    ↑
Collection (集合)
    ↑
List (列表)
    ↑
AbstractList (抽象类)
    ↑
ArrayList (最终实现)

ArrayList还实现了几个重要的接口:

接口

含义

RandomAccess

支持随机访问(通过下标直接获取元素)

Cloneable

支持克隆(可以复制一份)

Serializable

支持序列化(可以写入文件或网络传输)

重要提醒:ArrayList是线程不安全的。如果在多线程环境下使用,需要考虑使用VectorCopyOnWriteArrayList


三、ArrayList的使用:从创建到操作

3.1 三种构造方式

// 方式一:无参构造,初始容量为0(JDK 8+),第一次添加时扩容为10
List<Integer> list1 = new ArrayList<>();

// 方式二:指定初始容量,避免频繁扩容
List<Integer> list2 = new ArrayList<>(20);

// 方式三:用已有集合构造
List<Integer> source = Arrays.asList(1, 2, 3);
List<Integer> list3 = new ArrayList<>(source);

一个容易被忽视的点:无参构造在JDK 8之后,初始容量是0而不是10。只有在第一次添加元素时,才会扩容到10。这是一种懒加载的思想,节省内存。

3.2 核心操作方法

List<String> list = new ArrayList<>();
list.add("JavaSE");
list.add("JavaWeb");
list.add("JavaEE");
list.add("JVM");

// 获取元素个数
System.out.println(list.size());  // 4

// 获取指定位置的元素
System.out.println(list.get(1));  // JavaWeb

// 修改指定位置的元素
list.set(1, "JavaWEB");
System.out.println(list.get(1));  // JavaWEB

// 在指定位置插入元素(后续元素后移)
list.add(1, "数据结构");
System.out.println(list);  // [JavaSE, 数据结构, JavaWEB, JavaEE, JVM]

// 删除指定元素(删除第一个匹配的)
list.remove("JVM");
System.out.println(list);  // [JavaSE, 数据结构, JavaWEB, JavaEE]

// 删除指定位置的元素
list.remove(list.size() - 1);
System.out.println(list);  // [JavaSE, 数据结构, JavaWEB]

// 判断是否包含某元素
System.out.println(list.contains("JavaSE"));  // true

// 查找元素位置
list.add("JavaSE");
System.out.println(list.indexOf("JavaSE"));     // 0(第一个)
System.out.println(list.lastIndexOf("JavaSE")); // 3(最后一个)

// 截取子列表(注意:是视图,共享底层数组)
List<String> sub = list.subList(0, 2);
System.out.println(sub);  // [JavaSE, 数据结构]

3.3 三种遍历方式

List<String> list = Arrays.asList("Java", "Python", "Go");

// 方式一:for循环 + 下标(最常用)
for (int i = 0; i < list.size(); i++) {
    System.out.println(list.get(i));
}

// 方式二:增强for循环(最简洁)
for (String s : list) {
    System.out.println(s);
}

// 方式三:迭代器(适合遍历时删除)
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    System.out.println(it.next());
}

四、ArrayList的扩容机制:源码级别的深入分析

4.1 为什么要扩容?

ArrayList底层是一个Object[]数组。当我们不断往里面添加元素时,总有一天数组会填满。这时候就需要一个更大的数组来容纳新元素。

4.2 扩容流程(JDK 17源码分析)

// 核心源码(简化版)
private Object[] elementData;  // 存储元素的数组
private int size;              // 当前元素个数

public boolean add(E e) {
    modCount++;  // 记录修改次数,用于快速失败机制
    add(e, elementData, size);
    return true;
}

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length) {
        // 数组满了,需要扩容
        elementData = grow();
    }
    elementData[s] = e;
    size = s + 1;
}

private Object[] grow() {
    return grow(size + 1);
}

private Object[] grow(int minCapacity) {
    int oldCapacity = elementData.length;
    if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        // 新容量 = 旧容量 + 旧容量右移1位(即1.5倍)
        int newCapacity = ArraysSupport.newLength(
            oldCapacity,
            minCapacity - oldCapacity,  // 最小增长量
            oldCapacity >> 1           // 首选增长量(旧容量的一半)
        );
        return elementData = Arrays.copyOf(elementData, newCapacity);
    } else {
        // 第一次添加,分配默认容量10
        return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
    }
}

4.3 扩容的关键结论

  1. 无参构造:第一次添加时,数组从0扩容到10

  2. 后续扩容:每次扩容为原来的1.5倍oldCapacity + oldCapacity >> 1

  3. 扩容代价:需要创建新数组,并将旧数组的所有元素拷贝过去,时间复杂度O(n)

  4. 空间浪费:如果扩容到200,只用了105个元素,就浪费了95个位置

4.4 一个常见的思考题

List<Integer> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
    list.add(i);
}

这段代码有什么问题?答案是:扩容次数太多。无参构造初始容量为0,第一次扩容到10,然后15→22→33→49→73→109,一共经历了7次扩容。每次扩容都要拷贝全部已有元素,性能损耗不小。

优化方案:如果能预知数据量,最好指定初始容量:

List<Integer> list = new ArrayList<>(100);  // 一次到位,无需扩容

五、实战案例:洗牌算法

把理论知识用在实践中,我们来写一个完整的洗牌发牌程序。

5.1 定义牌类

public class Card {
    private int rank;    // 牌面值:1~13
    private String suit; // 花色:♠♥♣♦
    
    public Card(int rank, String suit) {
        this.rank = rank;
        this.suit = suit;
    }
    
    @Override
    public String toString() {
        return String.format("[%s %d]", suit, rank);
    }
}

5.2 实现洗牌和发牌

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class PokerGame {
    private static final String[] SUITS = {"♠", "♥", "♣", "♦"};
    
    // 买一副新牌
    private static List<Card> buyDeck() {
        List<Card> deck = new ArrayList<>(52);
        for (int i = 0; i < 4; i++) {
            for (int j = 1; j <= 13; j++) {
                deck.add(new Card(j, SUITS[i]));
            }
        }
        return deck;
    }
    
    // 交换两张牌
    private static void swap(List<Card> deck, int i, int j) {
        Card temp = deck.get(i);
        deck.set(i, deck.get(j));
        deck.set(j, temp);
    }
    
    // Fisher-Yates洗牌算法
    private static void shuffle(List<Card> deck) {
        Random random = new Random();
        for (int i = deck.size() - 1; i > 0; i--) {
            int r = random.nextInt(i + 1);  // 生成[0, i]之间的随机数
            swap(deck, i, r);
        }
    }
    
    public static void main(String[] args) {
        // 1. 买牌
        List<Card> deck = buyDeck();
        System.out.println("新买的牌:" + deck);
        
        // 2. 洗牌
        shuffle(deck);
        System.out.println("洗过的牌:" + deck);
        
        // 3. 三个人轮流抓5张牌
        List<List<Card>> hands = new ArrayList<>();
        hands.add(new ArrayList<>());
        hands.add(new ArrayList<>());
        hands.add(new ArrayList<>());
        
        for (int round = 0; round < 5; round++) {
            for (int player = 0; player < 3; player++) {
                // 从牌堆顶部取一张牌
                hands.get(player).add(deck.remove(0));
            }
        }
        
        // 4. 显示结果
        System.out.println("\n=== 发牌结果 ===");
        System.out.println("玩家A的手牌:" + hands.get(0));
        System.out.println("玩家B的手牌:" + hands.get(1));
        System.out.println("玩家C的手牌:" + hands.get(2));
        System.out.println("\n剩余牌数:" + deck.size());
    }
}

运行效果

新买的牌:[♠ 1, ♠ 2, ♠ 3, ..., ♦ 13]
洗过的牌:[♣ 7, ♥ 3, ♦ 11, ..., ♠ 5]

=== 发牌结果 ===
玩家A的手牌:[♣ 7, ♦ 5, ♥ 12, ♠ 4, ♣ 2]
玩家B的手牌:[♥ 3, ♠ 8, ♣ 13, ♦ 9, ♠ 10]
玩家C的手牌:[♦ 11, ♥ 7, ♠ 6, ♥ 9, ♦ 3]

剩余牌数:37

六、ArrayList的局限性

尽管ArrayList非常强大,但它并非万能。了解它的局限,才能做出正确的技术选型。

6.1 主要问题

  1. 插入和删除效率低:在中间位置插入或删除元素时,需要移动大量元素,时间复杂度O(n)

  2. 扩容代价高:扩容时需要拷贝整个数组

  3. 空间浪费:扩容通常是1.5倍,可能造成内存浪费

6.2 什么时候不适合用ArrayList?

  • 频繁在列表头部或中间插入/删除元素 → 考虑LinkedList

  • 多线程环境 → 考虑CopyOnWriteArrayListVector

  • 需要频繁扩容且数据量巨大 → 预先指定合理容量


七、总结

知识点

核心要点

底层结构

动态数组(Object[])

扩容机制

无参首次扩容到10,后续1.5倍

随机访问

O(1),支持RandomAccess接口

插入删除

O(n),需要移动元素

线程安全

不安全,需外部同步

适用场景

读多写少,尾部追加

一句话总结:ArrayList是Java中最常用的集合之一,底层基于动态数组实现,擅长随机访问和尾部操作,但在频繁插入删除的场景下表现不佳。


如果你觉得这篇文章对你有帮助,欢迎点赞收藏。下一篇我们将深入LinkedList的源码,看看链表结构是如何弥补ArrayList的不足的,敬请期待!


注:本文为个人学习总结,所有代码示例均为独立编写。建议读者在学习过程中结合JDK官方文档和源码进行验证。

Logo

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

更多推荐