ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java动态数组深度解析:从ArrayList底层到实战避坑

Java动态数组深度解析:从ArrayList底层到实战避坑 1. 为什么学习Java要先啃下动态数组这块硬骨头1.1 从固定数组到动态数组你身边最常见的例子很多刚接触Java的朋友在学到基本数据结构时都会被ArrayList这个名字搞得一头雾水。数组我懂但为什么它还能自动变长其实这个问题的答案恰好覆盖了基础语法里类、对象、方法、泛型、异常处理的一大批知识点。动态数组不是一个高深的东西它就是“能自己长大的数组”但恰恰是这种“能自己长大”的能力解决了实际开发里最让人头疼的问题数据数量不确定。举个非常生活化的例子。你现在要写一个学生名册程序一个班可能30人也可能50人下个学期可能变成80人。如果写死String[] students new String[30]来第31个人的时候就崩了如果写成new String[100]平时内存又白白浪费。动态数组就是用来解决这个矛盾的不用你一开始决定大小它自己会随着元素增多而扩容也会在元素减少时通过手动整理释放空间。你只需要不停地往里加它替你兜底。所以从Java基础语法的角度看动态数组不是一个孤立概念。你需要用到类和对象ArrayList本身就是一个类你new出来的就是对象泛型ArrayListString里的尖括号就是在指定元素的类型方法add、remove、get这些操作都是封装好的实例方法异常下标越界时会抛IndexOutOfBoundsException怎么拦截和避免封装你根本不知道它内部是怎么存的只通过公共方法操作它。把这些点全部串起来基本功就算扎了一半根。这也是为什么我说动态数组是Java学习里第一个“综合性实战项目”。1.2 动态数组在Java基本数据结构中的位置Java的基本数据结构说到根上就三类数组、链表、哈希表。动态数组ArrayList在集合框架里属于List接口下的老大哥它底层是数组但对外提供的是“可动态伸缩”的列表能力。和它并列的LinkedList底层是链表两者差距在性能和内存布局上表现得很明显我会在后面专门开一节讲对比。如果你翻开Java集合框架的家族图谱会看到这样的关系Collection接口下面是List、Set、QueueList的实现类中ArrayList最常用Vector是线程安全的远古版本LinkedList是特殊场景下的替代品。掌握动态数组其实就是掌握List接口最有代表性的实现。理解了它你以后看LinkedList、Vector、甚至CopyOnWriteArrayList都会容易得多。而且动态数组的扩容思想在HashMap的resize、StringBuilder的扩容里都有相似的影子。说你学会一个动态数组等于提前看懂了半本集合框架一点不夸张。2. 动态数组的底层实现扩容机制与内存模型2.1 ArrayList底层到底长什么样网上给ArrayList的底层定义很简洁Object[] elementData。但你得真正理解这句话。这不是一个“对象”数组而是一个“可以装任何对象”的数组。因为数组在创建时就必须确定类型和长度而动态数组希望“什么都能存”所以Java选择了最通用的Object[]。配合泛型机制你在外面看到的是清爽的ArrayListString在编译时泛型会被擦除底层实际运行的时候全是Object取出时再帮你强转回String。这就是Java泛型擦除的体现。一个ArrayList对象内部除了数组本身还会有两个关键字段private Object[] elementData; // 真正存放数据的数组 private int size; // 当前已经存了多少个元素注意区分elementData.length和size。前者是“数组的容量”后者是“已经用的容量”。我见过很多初学者搞混以为size()方法返回的是数组长度其实size是下面代码里那个实时递增的计数器。public class MyArrayListE { 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);右移一位相当于除以2oldCapacity 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 MyArrayListE { 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这样MyArrayListStudent能存StudentMyArrayListString能存字符串代码复用度拉满。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因为我们要存的新元素会把数量变成size1。如果这个值比当前数组长度还要大说明存不下了才扩容。这个“第size1个元素”的边界很多人会忽略写代码时总是忘记1结果在数组刚好满的情况下永远触发不了扩容然后就越界了。我自己刚开始学的时候就在这里卡了很久调试了半天才发现是size 1写成了size。默认容量为什么是10JDK源码里这么定义其实没有特别复杂的哲学只能说10是一个经验值。太小会导致频繁扩容太大又浪费空数组的内存。如果你能预估数据规模就一定要用new MyArrayList(100000)这种带初始容量的构造函数。这个习惯在性能敏感的场景下非常值钱后面我还会单独展开。4. 核心操作源码级拆解从add到remove4.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(); ArrayListReportItem 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直接跳过了下一个元素。要是正好有两个相邻元素都需要删第二个就漏掉了。正确的做法是倒序遍历删除或者用迭代器IteratorString 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而是一种条件反射了。
返回列表