文章背景图

Java八股 —— ArrayList

2026-02-12
0
-
- 分钟

学习来源:Java八股文网

ArrayList

简介

ArrayList底层基于数组实现,可以随机访问,内部使用一个Object数组来保存元素。它维护了一个 elementData 数组和一个 size 字段,elementData数组用来存放元素,size字段用于记录元素个数。它允许元素是null,可以动态扩容。

初始化

源码:

// 默认容量大小,当你使用无参构造器 new ArrayList() 创建对象时,第一次添加元素(调用 add 方法)时,数组长度会被扩容为这个值(10)
private static final int DEFAULT_CAPACITY = 10;


// 用于空实例的共享空数组实例(当用户显式指定容量为 0 时使用)
private static final Object[] EMPTY_ELEMENTDATA = {};

// 用于默认大小空实例的共享空数组实例(当用户使用无参构造器时使用)
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

// 存储元素的数组,transient 表示该字段不参与默认的序列化过程
// 原因:elementData 的长度通常比实际存储的数据 size 大(比如数组长 10,但只存了 1 个数)。如果直接序列化整个数组,会把后面 9 个 null 也序列化进去,浪费空间。
transient Object[] elementData;

// 数组中元素个数,默认是0,注意区分 size 和 elementData.length(容量),size是当前元素个数,length是数组的容量空间
private int size;

// 无参初始化,默认是空数组
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

// 有参初始化,指定容量大小
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);
    }
}

为什么要有两个空数组?

这两个数组的作用是为了在第一次添加元素(扩容)时,让代码知道该怎么扩容

当调用 add() 方法添加第一个元素时,ArrayList 会调用 calculateCapacity 方法来计算新容量:

  • 如果你用的是 DEFAULTCAPACITY_EMPTY_ELEMENTDATA(场景 B):

    • 系统会认为:“哦,这个用户是用无参构造器创建的,他想要默认体验。”

    • 于是,直接把容量从 0 跳变为 10 (DEFAULT_CAPACITY)

    • 目的: 避免刚开始添加几个元素就频繁扩容(0->1->2->3...),一步到位给 10 个空间,提升性能。

  • 如果你用的是 EMPTY_ELEMENTDATA(场景 A):

    • 系统会认为:“这个用户显式指定了容量为 0,可能他真的只想存很少的数据,或者想自己控制。”

    • 于是,它不会直接扩容到 10,而是按照最小需求(比如扩容到 1)开始增长。

序列化

序列化(Serializable):就是把内存中立体的、有结构的对象,转换成扁平的、连续的二进制字节流(010101...)的过程

反序列化 (Deserialization):就是把二进制字节流,重新恢复成内存中可用的对象的过程

在 Java 程序运行的时候,对象是活在内存(堆)里的。一旦程序关掉,内存清空,对象就没了。

如果你想让对象“活”得更久,或者“跑”到别的地方去,就需要序列化。

实现序列化:

  • implements Serializable:Serializable 接口里面没有任何方法!它只是一个“标记接口”。就像给箱子贴个“可空运”的贴纸,告诉 Java 虚拟机(JVM):“这个类的对象允许被拆解和组装”。

  • serialVersionUID:相当于“版本号”。

现在主要用的是 JSON 序列化:

  • 工具: Jackson, Gson, Fastjson.

  • 过程: 把 Java 对象变成 {"name": "张三", "age": 24} 这样的字符串。

  • 优点: 任何语言都认识字符串,轻量,可读性好。

添加元素

往ArrayList种添加元素时,调用的 add() 方法源码:

// 添加元素
public boolean add(E e) {
  // 确保数组容量够用,size是元素个数
  ensureCapacityInternal(size + 1);
  // 直接在下个位置赋值
  elementData[size++] = e;
  return true;
}

// 确保数组容量够用
private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

// 计算所需最小容量
private static int calculateCapacity(Object[] elementData, int minCapacity) {
  	// 如果数组等于空数组,就设置默认容量为10
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

// 确保容量够用
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
  	// 如果所需最小容量大于数组长度,就进行扩容
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

有关modcount

modCount 并不是在 ArrayList 中定义的,而是在它的父类 AbstractList 中定义的,它的核心作用只有一个:防止在遍历(迭代)集合的过程中,集合的内容被意外修改。

我们看看 ArrayList 内部类 Itr(迭代器)的源码:

private class Itr implements Iterator<E> {
    // 1. 迭代器创建时,先把 ArrayList 的 modCount 抄下来
    int expectedModCount = modCount; 

    public E next() {
        // 2. 每次获取下一个元素前,先检查一下:
        //    现在的 modCount 和我当初抄下来的 expectedModCount 还一样吗?
        checkForComodification(); 
        
        // ... 取元素逻辑 ...
    }

    final void checkForComodification() {
        // 3. 如果不一样,说明有人动过集合,直接报错!
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

结构性修改(Structural Modification) 会增加 modCount

  • add():增加元素 ✅ (modCount++)

  • remove():删除元素 ✅ (modCount++)

  • clear():清空集合 ✅ (modCount++)

  • ensureCapacity():扩容(虽然不改内容,但改了内部结构) ✅

如果在遍历 ArrayList 时,我一定要删除元素怎么办?

错误写法(会报错):

for (String s : list) {
    if ("bad".equals(s)) {
        list.remove(s); // ❌ 这里调用的是 ArrayList 的 remove,会 modCount++,导致下次循环报错
    }
}

正确写法(使用迭代器的 remove),或者使用 remove(int index) 或者 removeIf() 方法

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    if ("bad".equals(s)) {
        it.remove(); // ✅ 调用的是 Iterator 的 remove
    }
}
        // 使用 remove(int index) 删除元素
        for (int i = 0; i < list.size(); i++) {
            if (list.get(i).equals(2)) {
                list.remove(i);
            }
        }

        // 使用removeIf删除元素
        list.removeIf(key -> key.equals(2));

扩容逻辑:

// 扩容,就是把旧数据拷贝到新数组里面
private void grow(int minCapacity) {
  int oldCapacity = elementData.length;
  // 计算新数组的容量大小,是旧容量的1.5倍
  int newCapacity = oldCapacity + (oldCapacity >> 1);

  // 如果扩容后的容量小于最小容量,扩容后的容量就等于最小容量
  if (newCapacity - minCapacity < 0)
    newCapacity = minCapacity;

  // 如果扩容后的容量大于Integer的最大值,就用Integer最大值
  if (newCapacity - MAX_ARRAY_SIZE > 0)
    newCapacity = hugeCapacity(minCapacity);
 
  // 扩容并赋值给原数组
  elementData = Arrays.copyOf(elementData, newCapacity);
}
  • 扩容的触发条件是数组全部被占满

  • 扩容是以旧容量的1.5倍扩容,并不是2倍扩容,如果扩容后小于最小容量,就直接用最小容量。

  • 最大容量是Integer的最大值

  • 添加元素时,没有对元素校验,允许为null,也允许元素重复。

再看一下数组拷贝的逻辑,这里都是Arrays类里面的方法了:

/**
 * @param original  原数组
 * @param newLength 新的容量大小
 */
public static <T> T[] copyOf(T[] original, int newLength) {
    return (T[]) copyOf(original, newLength, original.getClass());
}

public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
    // 如果原数组本身就是 Object[] 类型,直接 new Object[]
    // 如果原数组是 String[] 等子类型,需要用反射 Array.newInstance 创建对应类型的数组
    T[] copy = ((Object)newType == (Object)Object[].class)
        ? (T[]) new Object[newLength]
        : (T[]) Array.newInstance(newType.getComponentType(), newLength);
  	// 把原数组的元素拷贝到新数组
    System.arraycopy(original, 0, copy, 0,
                     Math.min(original.length, newLength));
    return copy;
}

最终调用了System类的数组拷贝方法,是native方法:

/**
 * @param src     原数组
 * @param srcPos  原数组的开始位置
 * @param dest    目标数组
 * @param destPos 目标数组的开始位置
 * @param length  被拷贝的长度
 */
public static native void arraycopy(Object src,  int  srcPos,
                                    Object dest, int destPos,
                                    int length);

Class<? extends T[]>

它的意思是:“传入的这个 newType 参数,必须是一个类对象(Class),而且这个类必须是 T[] 或者 T[] 的子类。”

在 Java 中,数组是有继承关系的:

  • StringObject 的子类。

  • 所以,String[] 也是 Object[] 的子类。

  • 这意味着:Object[] arr = new String[10]; 是合法的。

在泛型里,关系就变了:

  • 虽然 StringObject 的子类。

  • Class<String[]> 不是 Class<Object[]> 的子类!

  • 这意味着:如果方法参数写死为 Class<T[]> newType,当你 TObject 时,你只能传 Object[].class不能传 String[].class,否则编译器会报错。

// 假设 T 是 Number(数字的基类)
// 我们想拷贝出一个 Integer[](Integer 是 Number 的子类)
Number[] result = Arrays.copyOf(original, 10, Integer[].class);
  • 因为 Java 数组是协变的,Integer[]Number[] 的子类。

  • 所以 Integer[].class 符合 ? extends Number[] 的限制。

如果源码写成 Class<T[]> newType

  • TNumber 时,参数就死板地要求必须是 Class<Number[]>

  • 你传入 Integer[].class 就会直接报错,因为泛型不认父子关系。

总结一下ArrayList的 add() 方法的逻辑:

  1. 检查容量是否够用,如果够用,直接在下一个位置赋值结束。

  2. 如果是第一次添加元素,则设置容量默认大小为10。

  3. 如果不是第一次添加元素,并且容量不够用,则执行扩容操作。扩容就是创建一个新数组,容量是原数组的1.5倍,再把原数组的元素拷贝到新数组,最后用新数组对象覆盖原数组。

需要注意的是,每次扩容都会创建新数组和拷贝数组,会有一定的时间和空间开销。在创建ArrayList的时候,如果我们可以提前预估元素的数量,最好通过有参构造函数,设置一个合适的初始容量,以减少动态扩容的次数。

删除单个元素

再看一下删除元素的方法 remove() 的源码:

public boolean remove(Object o) {
  	// 判断要删除的元素是否为null
    if (o == null) {
      	// 遍历数组
        for (int index = 0; index < size; index++)
          	// 如果和当前位置上的元素相等,就删除当前位置上的元素
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
      	// 遍历数组
        for (int index = 0; index < size; index++)
          	// 如果和当前位置上的元素相等,就删除当前位置上的元素
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

// 删除该位置上的元素
private void fastRemove(int index) {
    modCount++;
  	// 计算需要移动的元素的个数
    int numMoved = size - index - 1;
    if (numMoved > 0)
      	// 从index+1位置开始拷贝,也就是后面的元素整体向左移动一个位置
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
  	// 设置数组最后一个元素赋值为null,防止会导致内存泄漏
    elementData[--size] = null;
}

删除元素的流程是:

  1. 判断要删除的元素是否为null,如果为null,则遍历数组,使用双等号比较元素是否相等。如果不是null,则使用 equals() 方法比较元素是否相等。这里就显得啰嗦了,可以使用 Objects.equals()方法,合并ifelse逻辑。

  2. 如果找到相等的元素,则把后面位置的所有元素整体相左移动一个位置,并把数组最后一个元素赋值为null结束。

可以看到遍历数组的时候,找到相等的元素,删除就结束了。如果ArrayList中存在重复元素,也只会删除其中一个元素

批量删除

再看一下批量删除元素方法 removeAll() 的源码:

// 批量删除ArrayList和集合c都存在的元素
public boolean removeAll(Collection<?> c) {
    // 非空校验
    Objects.requireNonNull(c);
    // 批量删除
    return batchRemove(c, false);
}

// complement:removeAll 调用时传的是 false;retainAll 调用时传的是 true
private boolean batchRemove(Collection<?> c, boolean complement){
    // 将堆内存(Heap)中的实例变量,转存为栈内存(Stack)中的局部变量
    final Object[] elementData = this.elementData;
    int r = 0, w = 0;
    boolean modified = false;
    try {
        for (; r < size; r++)
            if (c.contains(elementData[r]) == complement)
                // 把需要保留的元素左移
                elementData[w++] = elementData[r];
    } finally {
		// 当出现异常情况的时候,可能不相等
        if (r != size) {
            // 可能是其它线程添加了元素,把新增的元素也左移
            System.arraycopy(elementData, r,
                             elementData, w,
                             size - r);
            w += size - r;
        }
      	// 把不需要保留的元素设置为null
        if (w != size) {
            for (int i = w; i < size; i++)
                elementData[i] = null;
            modCount += size - w;
            size = w;
            modified = true;
        }
    }
    return modified;
}

有关堆、栈区域的划分和功能

区域

别名

存什么东西

谁私有/共享

垃圾回收 (GC)

常见异常

堆 (Heap)

仓库

对象实例 (new出来的)、数组

线程共享

主要发生地

OOM (内存溢出)

栈 (Stack)

工作台

局部变量、对象引用、方法调用链

线程私有

无 (自动释放)

StackOverflow

方法区

配方墙

类结构信息、常量、静态变量

线程共享

很少发生

OOM (元空间不足)

程序计数器

进度条

代码执行位置

线程私有

本地方法栈

外包台

C/C++ Native 方法

线程私有

StackOverflow

并发修改的问题

即modcount问题

评论交流

文章目录