1. 为什么学习Java要先啃下动态数组这块硬骨头
1.1 从固定数组到动态数组:你身边最常见的例子
很多刚接触Java的朋友,在学到基本数据结构时,都会被ArrayList这个名字搞得一头雾水。数组我懂,但为什么它还能自动变长?其实这个问题的答案,恰好覆盖了基础语法里类、对象、方法、泛型、异常处理的一大批知识点。动态数组不是一个高深的东西,它就是“能自己长大的数组”,但恰恰是这种“能自己长大”的能力,解决了实际开发里最让人头疼的问题:数据数量不确定。
举个非常生活化的例子。你现在要写一个学生名册程序,一个班可能30人,也可能50人,下个学期可能变成80人。如果写死String[] students = new String[30],来第31个人的时候就崩了;如果写成new String[100],平时内存又白白浪费。动态数组就是用来解决这个矛盾的:不用你一开始决定大小,它自己会随着元素增多而扩容,也会在元素减少时(通过手动整理)释放空间。你只需要不停地往里加,它替你兜底。
所以从Java基础语法的角度看,动态数组不是一个孤立概念。你需要用到:
- 类和对象:ArrayList本身就是一个类,你new出来的就是对象;
- 泛型:
ArrayList<String>里的尖括号就是在指定元素的类型; - 方法:
add、remove、get这些操作都是封装好的实例方法; - 异常:下标越界时会抛
IndexOutOfBoundsException,怎么拦截和避免; - 封装:你根本不知道它内部是怎么存的,只通过公共方法操作它。
把这些点全部串起来,基本功就算扎了一半根。这也是为什么我说动态数组是Java学习里第一个“综合性实战项目”。
1.2 动态数组在Java基本数据结构中的位置
Java的基本数据结构,说到根上就三类:数组、链表、哈希表。动态数组(ArrayList)在集合框架里属于List接口下的老大哥,它底层是数组,但对外提供的是“可动态伸缩”的列表能力。和它并列的LinkedList底层是链表,两者差距在性能和内存布局上表现得很明显,我会在后面专门开一节讲对比。
如果你翻开Java集合框架的家族图谱,会看到这样的关系:
Collection接口下面是List、Set、Queue;List的实现类中,ArrayList最常用,Vector是线程安全的远古版本,LinkedList是特殊场景下的替代品。
掌握动态数组,其实就是掌握List接口最有代表性的实现。理解了它,你以后看LinkedList、Vector、甚至CopyOnWriteArrayList都会容易得多。而且动态数组的扩容思想,在HashMap的resize、StringBuilder的扩容里都有相似的影子。说你学会一个动态数组,等于提前看懂了半本集合框架,一点不夸张。
2. 动态数组的底层实现:扩容机制与内存模型
2.1 ArrayList底层到底长什么样
网上给ArrayList的底层定义很简洁:Object[] elementData。但你得真正理解这句话。这不是一个“对象”数组,而是一个“可以装任何对象”的数组。因为数组在创建时就必须确定类型和长度,而动态数组希望“什么都能存”,所以Java选择了最通用的Object[]。配合泛型机制,你在外面看到的是清爽的ArrayList<String>,在编译时泛型会被擦除,底层实际运行的时候全是Object,取出时再帮你强转回String。这就是Java泛型擦除的体现。
一个ArrayList对象内部除了数组本身,还会有两个关键字段:
private Object[] elementData; // 真正存放数据的数组 private int size; // 当前已经存了多少个元素注意区分elementData.length和size。前者是“数组的容量”,后者是“已经用的容量”。我见过很多初学者搞混:以为size()方法返回的是数组长度,其实size是下面代码里那个实时递增的计数器。
public class MyArrayList<E> { private Object[] data; // 底层数组 private int size; // 当前元素个数 private static final int DEFAULT_CAPACITY = 10; public MyArrayList() { data = new Object[DEFAULT_CAPACITY]; } }这里的默认容量10是JDK官方定的。如果你new一个ArrayList,它并不会直接创建一个长度为10的数组,而是先创建一个空数组,等第一次add的时候才去扩容成10。这算是个JDK级别的懒加载优化,细节先记住,后面讲扩容的时候会用到。
2.2 扩容算法与时间复杂度的门道
动态数组最核心的操作就是“扩容”。你往一个快满的数组里塞新元素,这时必须重新申请一块更大的内存,把旧数据挪过去,然后继续往里面加。ArrayList的扩容系数是1.5倍,不是2倍,这是经过权衡的。
为什么不是2倍?假设从容量10开始,每次扩容到原来的1.5倍:10 → 15 → 22 → 33 → 49 → 73 … 这样扩容次数比2倍更多一些,但每次浪费的尾部落差更小,内存利用更均衡。JDK的设计者用位移运算实现了1.5倍:int newCapacity = oldCapacity + (oldCapacity >> 1);,右移一位相当于除以2,oldCapacity + oldCapacity / 2 就是1.5倍。位运算效率高,读起来也干净。
扩容后的具体动作是Arrays.copyOf(elementData, newCapacity)。这个方法会新创建一个数组,长度为newCapacity,然后把旧数组的所有元素用System.arraycopy批量复制过去。复制是O(n)操作,但它不是每次add都发生,只在容量不够时触发。因此往ArrayList末尾添加元素的平均时间复杂度是一个“均摊O(1)”,俗称摊还分析。
比如容量10,前10次add都没扩容,第11次扩容一次复制10个元素,之后容量变15,又可以安心存5个。把复制成本平摊到每次add上,每一次消耗的成本还是一个很小的常数。这就是为什么生产环境95%的“顺序添加”场景下,ArrayList明明内部频繁复制,整体却依然很快的原因。
2.3 缩容为什么Java选择了不做(以及何时需要自己处理)
扩容讲了一大堆,但很多人不知道ArrayList还有一个trimToSize()方法。它的作用是:把底层数组的容量精准调整到当前size大小,把多余空间砍掉。为什么JDK不自动做缩容?因为怕抖。如果用户频繁删数据又加数据,每次删除都缩容,后面add又要扩容,这会导致数组反复复制,性能会掉得很难看。所以官方采取了“只扩不缩”的策略,保持一种大阔佬心态:内存反正都是预留的,宁多勿少。
不过在一些特殊项目里,比如你已经加载了一个超大列表,处理完之后要长时间驻留在内存里,这时就该手动调用trimToSize()释放多余空间。另外还有个细节,clear()方法只是把每个元素置为null,让GC可以回收对象,但底层数组长度不变。如果你要彻底释放数组本身,还得让整个ArrayList对象都不可达,或者重新new一个新的。
3. 手写一个简易动态数组:把基础语法用起来
3.1 设计思路与核心字段
光看别人的源码容易飘飘然,自己动手写一遍才有体感。下面我带你写一个自己的动态数组,叫你MyArrayList,功能对齐ArrayList的核心方法。这一步不求生产级健壮,但要能把基础语法用起来。
先定义类和字段:
public class MyArrayList<E> { private Object[] data; // 存储元素的底层数组 private int size; // 已存元素个数 private static final int DEFAULT_CAPACITY = 10; public MyArrayList() { this.data = new Object[DEFAULT_CAPACITY]; } public MyArrayList(int initialCapacity) { if (initialCapacity < 0) { throw new IllegalArgumentException("容量不能小于0"); } this.data = new Object[initialCapacity]; this.size = 0; } }为什么字段是private?这就是封装。外部不能直接操作数组,只能通过我提供的方法,防止越界和脏数据。为什么用泛型E?这样MyArrayList<Student>能存Student,MyArrayList<String>能存字符串,代码复用度拉满。
3.2 增删改查的具体实现代码
下面的代码是核心。先把最常用的方法写出来,包括了扩容、add、get、set、remove、size、isEmpty、clear。每个方法我都加了注释,方便对照着读。
public void add(int index, E element) { checkForAdd(index); // 检查下标 ensureCapacity(size + 1); // 确认容量够用 // 从 index 开始的元素整体后移一位 System.arraycopy(data, index, data, index + 1, size - index); data[index] = element; size++; } public boolean add(E element) { ensureCapacity(size + 1); data[size++] = element; return true; } @SuppressWarnings("unchecked") public E get(int index) { checkIndex(index); return (E) data[index]; } @SuppressWarnings("unchecked") public E set(int index, E element) { checkIndex(index); E oldValue = (E) data[index]; data[index] = element; return oldValue; } @SuppressWarnings("unchecked") public E remove(int index) { checkIndex(index); E oldValue = (E) data[index]; // 要搬移的个数 int numMoved = size - index - 1; if (numMoved > 0) { System.arraycopy(data, index + 1, data, index, numMoved); } data[--size] = null; // 让GC可以回收对象,同时避免“内存泄漏” return oldValue; } private void ensureCapacity(int minCapacity) { if (minCapacity > data.length) { grow(minCapacity); } } private void grow(int minCapacity) { int oldCapacity = data.length; int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5倍扩容 if (newCapacity < minCapacity) { newCapacity = minCapacity; } data = Arrays.copyOf(data, newCapacity); } private void checkIndex(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); } } private void checkForAdd(int index) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); } } public int size() { return size; } public boolean isEmpty() { return size == 0; } public void clear() { for (int i = 0; i < size; i++) { data[i] = null; } size = 0; }这段代码包含了Java基础语法里非常重要的几个点:泛型方法、强制类型转换(配合@SuppressWarnings抑制编译警告)、System.arraycopy批量复制、异常抛出、自增自减表达式。尤其是data[--size] = null这行,我重点说一下。
很多人写remove时只是把size--,结果数组尾巴上还残留着旧对象的引用,导致这个对象明明该被回收却一直停留在老数组里。如果数据量很大,这就是一种隐蔽的内存泄漏。JDK源码里用data[--size] = null来“断开引用”,注释写的是“Let gc do its work”。我们自己写的时候必须注意这个细节。
3.3 数组越界、扩容时机这些细节怎么处理
如果我上面的代码你已经敲了一遍,会发现最隐蔽的坑就在“边界条件”。add允许插入到size这个位置(相当于追加到末尾),所以checkForAdd的判断是index > size,而不是index >= size。get、set、remove操作的是已存在的元素,所以必须是0 <= index < size。
再来说扩容时机。ensureCapacity(size + 1)里为什么传的是size + 1?因为我们要存的新元素会把数量变成size+1。如果这个值比当前数组长度还要大,说明存不下了,才扩容。这个“第size+1个元素”的边界很多人会忽略,写代码时总是忘记+1,结果在数组刚好满的情况下永远触发不了扩容,然后就越界了。我自己刚开始学的时候就在这里卡了很久,调试了半天才发现是size + 1写成了size。
默认容量为什么是10?JDK源码里这么定义,其实没有特别复杂的哲学,只能说10是一个经验值。太小会导致频繁扩容,太大又浪费空数组的内存。如果你能预估数据规模,就一定要用new MyArrayList<>(100000)这种带初始容量的构造函数。这个习惯在性能敏感的场景下非常值钱,后面我还会单独展开。
4. 核心操作源码级拆解:从add到remove
4.1 add方法与size管理
add方法分两种重载,一种只传元素,一种带下标。我们最常见的add(E e)是直接追加到末尾:先ensureCapacity,再data[size++] = e。这里size++是先赋值再自增,写起来很紧凑。
带下标的add(int index, E element)是ArrayList系列里比较讲究的方法。它先检查能否插入,再ensureCapacity,然后用System.arraycopy把index及其后面的所有元素向右搬移一位,最后把新元素放到腾出来的空位上。搬移的过程如果画图示意,就像一个排队打饭的队伍,来了一个插队的人,从插队位置开始所有人都往后退一步。
这里有个关键点:System.arraycopy处理的是同一数组内的重叠区域,JDK源码内部已经处理了重叠复制的问题,我们不用关心性能和安全。但如果你自己写循环搬家,要注意从尾部开始往前搬,否则前面被覆盖了后面就丢了。我用过错误示范,所以特别提醒一句。
4.2 remove方法与元素搬移
remove的写法比add更讲究。比如数组里有[A, B, C, D, E],移除下标2的C,那么D和E要往前挪,挪完之后数组变为[A, B, D, E, E],最后一位的E已经没意义了,应该被置null。所以代码是:
int numMoved = size - index - 1; if (numMoved > 0) { System.arraycopy(data, index + 1, data, index, numMoved); } data[--size] = null;size - index - 1计算的是被删元素后面还有多少个元素。如果删的是最后一个,numMoved为0,不执行复制,直接data[--size] = null。删中间元素的时间复杂度是O(n),这和你往中间插入一个元素是一样的,因为要挪元素。如果你需要频繁从中间删除,动态数组不太合适,应该用LinkedList。
4.3 get/set方法与边界校验
get和set都很快,因为底层是数组,按下标直接定位,时间复杂度O(1)。这也是动态数组相对链表最大的优势:按索引随便访问,根本不需要遍历。但缺点同样是数组的缺点:只有按下标访问才快,一旦你要按值查找,比如判断“对象列表里有没有某个对象”,就不得不从0遍历到size-1,效率瞬间变成O(n)。
get/set方法前都要做个范围检查,这叫rangeCheck。checkIndex是我自己写的,JDK里则分成rangeCheck和rangeCheckForAdd两个私有方法。这个设计体现了一个思想:异常越早抛越好。如果你在索引越界后还继续执行,后面会莫名其妙地出现空指针、数组越界、数据错乱。在入口处就把非法参数挡住,是最好的防御。
5. 动态数组的实战经验:面试题、避坑与调优
5.1 高频面试考点:ArrayList、Vector与LinkedList对比
面试里动态数组几乎必被问到,尤其“ArrayList和LinkedList区别”这种经典题。我个人在带新人时,喜欢让他们从三个维度去答:
| 维度 | ArrayList(动态数组) | LinkedList(双向链表) |
|---|---|---|
| 底层结构 | Object数组 | 节点对象(前后指针+数据) |
| 随机访问 | O(1) | O(n) |
| 尾部添加 | 均摊O(1) | O(1) |
| 中间插入/删除 | O(n),要搬移数组 | O(1)改指针(但找位置还是O(n)) |
| 内存占用 | 连续内存,可能有尾部空闲 | 每个节点额外存前后指针,开销大 |
| 适用场景 | 查询多、允许连续存储 | 频繁头部/尾部操作,插入删除频繁 |
还有Vector这个老前辈。Vector是线程安全的动态数组,方法都加了synchronized,但因为锁粒度太粗,性能比ArrayList差。现在如果真要在多线程环境下用,一般建议用CopyOnWriteArrayList,它走的是写时复制策略,读不用加锁,写的时候复制整个数组。这个以后再细说,当下你要理解:动态数组不是线程安全的集合,并发操作要么加锁,要么换线程安全实现。
5.2 日常编码里的性能陷阱与排查实录
我手上以前有个导出功能,从数据库批量查几十万条记录往外写Excel,代码里循环着往一个ArrayList里塞对象。结果测出来慢得离谱。后来用JFR一看,ArrayList.grow和Arrays.copyOf占了不少CPU。原因是我没用初始容量,ArrayList默认从10开始一路扩容到几十万,中间经历了很多次复制,每次复制都是全量拷贝,累积成本非常可观。
排查之后我直接改成:
int size = list.size(); ArrayList<ReportItem> items = new ArrayList<>(size);初始化容量等于数据规模,一次性把数组空间开够,后面add过程中永远不会扩容。那次优化之后,导出性能肉眼可见提升,倒不是因为什么高深算法,就只是“预估容量”这一件事。
还有一个长期踩坑点:for循环里删除元素。
for (int i = 0; i < list.size(); i++) { if (list.get(i).equals(target)) { list.remove(i); } }你猜会发生什么?删掉当前元素后,后面的元素会前移一位,但循环的i继续加1,直接跳过了下一个元素。要是正好有两个相邻元素都需要删,第二个就漏掉了。正确的做法是倒序遍历删除,或者用迭代器:
Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.equals(target)) { it.remove(); } }等等,如果使用for (String s : list)直接调remove,会抛ConcurrentModificationException。为什么?因为增强for循环底层用的是迭代器,而ArrayList的迭代器维护了一个modCount,用来记录结构被修改的次数。你遍历到一半用list自己的方法去删除元素,modCount变了,迭代器发现和你创建迭代器时的计数不一致,立刻抛异常。这是Java集合框架的fail-fast机制,目的就是防止你在迭代时偷偷改数据,导致脏读。我看过很多新人被这个异常吓到,其实记住一句话就行:遍历的时候要删元素,用迭代器的remove,或者倒序for循环。
5.3 动态数组在项目里的典型应用场景
既然学了动态数组,就得知道它在真实开发中用来干啥。最常见的场景就是缓存一批数据。比如表格查询结果、商品列表、日志批处理,这种“先攒一堆,后面按顺序处理”的模式,用动态数组非常自然。另外动态数组也可以当栈用,手写栈的时候底层就是一个数组加一个top指针,push、pop操作都在尾部进行,性能很好。
还有一个好玩的应用:动态数组作为HashMap扩容机制的启蒙。HashMap的底层是数组加链表(树化后是红黑树),它也要扩容,不过扩容时不只是复制,还要重新计算哈希桶的位置。你先搞懂动态数组的扩容,再去看HashMap的resize,会亲切很多,因为它们都离不开“数组快满了怎么办”这个本质问题。
6. 我自己踩过的几个坑,写在这里给你提个醒
这一节算是我个人项目经验的杂谈。第一天用动态数组时,我写了个方法返回ArrayList,结果返回之前忘了Collections.unmodifiableList,线上被人直接add进去一条脏数据,排查了半天。后来我养成习惯:凡是返回集合给外部调用的地方,该只读就只读。动态数组本身不是不可变的,你要用就明明白白用,但不要让它裸奔。
还有一次,我把一个超大ArrayList传给一个第三方接口,对方内部一直调list.remove(0)来处理任务。我当时没注意,这个操作是从头部删除,ArrayList的remove(0)要搬移所有元素,数据量一大就极其慢。换成LinkedList之后,从头部删除变成O(1),问题秒解决。所以不要迷信“动态数组万能”,它有明确的适用场景。
再有一个经验,写自己的动态数组时,equals和hashCode要不要重写?如果你没有重写,ArrayList里contains和remove一个对象,比对的是引用地址,不是对象内容。我在一次实际开发里,new了一个内容完全相同的新对象,去调list.remove(new Student("张三")),结果啥也没删掉。回过头来深挖,才发现Student没重写equals。所以只要你的自定义对象会放进集合做查找,就必须按要求重写equals和hashCode,这是基础但特别容易漏。
动态数组这步如果吃透了,你再去啃LinkedList、HashMap、甚至并发集合,都会觉得顺了很多。它们共用的是同一套“数据结构思维”:选对存储结构、控制容量、管理边界、理解时间复杂度。把这套东西内化了,后面写代码就不是再背API,而是一种条件反射了。