Java里的容器选择一直是老生常谈,但有一个问题每次面试都能炸出一堆人——Array、ArrayList、LinkedList的长度到底能不能变?很多新手背了无数遍"数组定长、集合变长",到了实际项目里照样踩坑,比如数组想加元素只能新建、ArrayList扩容导致线上OOM、LinkedList在随机访问时卡到怀疑人生。我当初也在这个问题上栽过跟头,所以今天干脆把这三个容器的长度机制从头到尾掰开揉碎讲清楚,顺便聊聊Arrays工具类的排序用法和ArrayList扩容的具体过程,希望能帮你真正搞懂它们,而不仅仅是背答案。
这篇文章适合刚学Java不久、准备面试、或者写代码时经常在数组和集合之间犹豫的人。我会从底层存储结构讲起,把"长度可变"的本质拆开,再结合反射实验、排序实操代码和常见问题,让你看完之后不仅知道选谁,还能说出为什么。
1. 长度可变的本质:先搞清楚Java里"长度"到底指什么
很多人在这个问题上绕圈,是因为没分清"容器能装多少"和"容器里装了多少"这回事。数组、ArrayList、LinkedList三者看起来都是装数据的容器,但它们对"长度"的认定方式完全不同,这直接决定了你能不能往里追加元素、能不能收缩容量,以及操作复杂度是O(1)还是O(n)。
1.1 三种容器的底层存储形态
先说底层结构。普通数组在JVM内存里是一块连续的地址空间,声明的那一刻就把这块空间申请好了,比如int[] arr = new int[5],JVM直接分配5个int的连续内存,这个大小就是固定的,之后既没法扩,也没法缩。
ArrayList底层本质上还是一个数组,只不过这个数组被封装成了一个"动态"的壳子。它内部维护着一个Object[] elementData,当默认的空列表第一次添加元素时,它会把内部数组扩展到默认容量10。之后随着元素越加越多,数组装不下了,它就去申请一块更大的新数组,把旧数据全部搬过去。所以你从外面看,ArrayList似乎可以无限往里面塞东西,其实它内部一直在经历"申请新数组—拷贝—释放旧数组"的循环。
LinkedList就完全不同了,它根本不依赖连续内存。每个元素都是一个Node节点对象,节点之间通过前驱指针prev和后继指针next串起来。你往链表里加一个元素,本质只是创建一个新的Node对象,然后把头尾的指针重新接一下,不存在"数组满了要扩容"这回事。它没有容量的概念,只有一个size字段在记录当前节点数量。
1.2 length、length()、size() 三个概念别混淆
这是另一个高频迷惑点,Java里关于"长度"就有三种写法,很多人混着用,轻则编译报错,重则上线NPE。
length:这是数组的固有属性,注意它是个属性,不是方法,所以写成arr.length,后面不能加括号。它表示数组分配的最大容量,一旦创建就固定不变。length():这是String类的方法,"abc".length()返回3,它跟数组没有关系,纯粹是字符串里字符个数。size():这是集合类都有的方法,arrayList.size()返回的是集合里实际存储的元素个数。注意这是"已存储的数量",不等于内部的容量。
这里有一个非常经典的坑:很多新人把size()当成数组的length来理解,写出list.get(list.size()),铁定抛IndexOutOfBoundsException,因为索引是从0开始的,最后一个元素的索引应该是size() - 1。面试里考这个的也不少,实际上就是在考察你清不清楚"容量"和"个数"是两回事。
2. 数组:从出生那一刻就定型的长短
2.1 数组长度为什么不可变
数组在JVM里是不可变长度的,根本原因在于它的内存模型。我的理解是:数组存储的是一段连续内存空间的起始地址,JVM在访问arr[3]时,其实是通过"起始地址 + 3 × 元素占用字节数"直接算出目标地址的。只有所有元素连续排列,才能用这种极快的寻址方式。
如果允许数组在运行时改变长度,那就意味着要么在这段连续内存后面硬续一块,但相邻地址可能已经被其他对象占了;要么整体搬家,那原来持有这个数组引用的所有变量全都得跟着更新。这种设计会让JVM的内存管理和访问效率大打折扣。所以Java选择了最简单干脆的方案:数组的长度在创建时一次性定死,想变就新建一个数组,自己拷贝数据。
我们做一个简单的实验就能感受到这个约束:
int[] arr = new int[3]; arr[0] = 1; arr[1] = 2; arr[2] = 3; // arr.length 恒为 3,无法通过任何方法修改 System.out.println(arr.length); // 3数组对象上没有提供任何类似setLength的方法,因为语言层面就没打算让你改。所有关于数组"变长"的诉求,最终都得靠包装或者强转成其他结构。
2.2 想要"变长"数组的三种替代方案
实际开发中发现数组不够装数据了,有几种处理方式,各有适用场景,我按推荐程度排一下:
第一,直接用Arrays.copyOf复制出更大的新数组。这是最"数组思维"的做法,不引入额外依赖。比如我需要把长度为5的数组扩展成能装下8个元素的数组:
String[] oldArr = {"A", "B", "C", "D", "E"}; String[] newArr = Arrays.copyOf(oldArr, 8); System.out.println(newArr.length); // 8这段代码执行后,newArr的前5个元素是原来的数据,后面3个位置是null。要注意的是,oldArr本身还是5,你只是拿到了一个新的8长度数组,原来持有oldArr的地方如果还想访问新数据,得手动重新赋值。
第二,用ArrayList替代数组。如果数据量本身不确定、又频繁需要在尾部追加,就应该直接用ArrayList,把扩容的逻辑交给JDK处理,没必要自己手动搬数据。后面我会详细讲它的扩容机制。
第三,用System.arraycopy手动拷贝。这个方法更底层,需要自己创建目标数组、指定源位置和目标位置,虽然灵活,但代码啰嗦,容易出错。一般情况下Arrays.copyOf内部就是调的它,你没必要绕一圈。
3. ArrayList扩容机制:Java中最典型的动态长度实现
3.1 扩容触发条件与1.5倍增长规则
理解了数组为什么定长,再看ArrayList的动态长度就清晰了。它能在"有限数组"之上模拟出"无限容量"的效果,靠的就是一个被反复执行的扩容流程。整个流程可以浓缩成三步:判断容量是否够用、计算新容量、把老数据搬到新数组。
先看触发条件。每次执行add()方法时,ArrayList会先调用ensureCapacityInternal(),实质上就是检查elementData数组的长度是否比size + 1大。如果还能装下,直接往elementData[size]位置赋值即可,没有任何额外开销。如果装不下了,就得触发扩容。
接着是核心的新容量计算。JDK里的关键代码是这样的:
int newCapacity = oldCapacity + (oldCapacity >> 1);这里oldCapacity >> 1等价于除以2,所以新容量就是旧容量的1.5倍,前提是newCapacity大于minCapacity。比如当前容量是10,扩容后变成15;容量是15,扩容后变成22(因为取整);容量是22,扩容后变成33。这个1.5倍是JDK作者权衡过的结果:增长太快浪费内存,增长太慢会导致频繁扩容增加拷贝开销。
但是1.5倍规则有两个特殊情况。一是如果我们用addAll一次加入一大批元素,比如当前容量10、要加入100个元素,1.5倍后的15明显不够,这时newCapacity会直接被设置成minCapacity(也就是实际所需的容量),不会傻乎乎地按1.5倍硬算。二是当minCapacity超过Integer.MAX_VALUE - 8这个阈值时,会走一个hugeCapacity()方法,如果溢出就直接抛OutOfMemoryError,否则返回Integer.MAX_VALUE。
3.2 扩容时的数据拷贝过程
容量算好了,接下来就是数据搬迁,这一块依赖Arrays.copyOf(elementData, newCapacity)。它底层调用System.arraycopy,这是一个native方法,由JVM直接操作内存块复制,速度非常快,但本质上仍然是把旧数组的所有元素逐一拷到新数组。
我画一下这个过程的直觉感受:假设ArrayList现在容量是10,里面有10个元素,你再次add第11个元素。此时程序会创建一个长度为15的新数组,把原来10个元素整体搬过去,然后在第10个索引(第11个位置)放下新元素。你这次add操作的时间复杂度看起来是O(1)的尾巴,但实际付出了O(n)的拷贝代价——这就是"均摊复杂度"的由来。偶尔一次add很慢,但把扩容次数摊到每次add上,平均代价依然是O(1)。
这个过程中有一个隐藏风险:频繁扩容意味着频繁分配新数组、频繁复制,如果一开始就能估算出最终数据量,就应该通过构造方法直接指定初始容量。
// 初始化时就给足空间,避免多次扩容 ArrayList<String> list = new ArrayList<>(10000);我见过不少线上问题是这么来的:默认容量10的ArrayList,在循环里add几百万条数据,触发了无数次数组拷贝,垃圾回收压力陡增,接口耗时肉眼可见地翻倍。提前估算容量是把O(n)的拷贝次数从几十上百次压缩到个位数的关键手段。
3.3 动手验证:通过反射观察ArrayList扩容
光看源码不够直观,我平时讲扩容机制时会直接反射拿到内部的elementData数组,观察它在添加过程中容量是怎么变化的。这样连新手都能看到"扩容"发生的确切时机。
import java.lang.reflect.Field; import java.util.ArrayList; public class ArrayListExpandDemo { public static void main(String[] args) throws Exception { ArrayList<Integer> list = new ArrayList<>(); printCapacity(list, "刚初始化"); for (int i = 1; i <= 11; i++) { list.add(i); if (i == 1 || i == 10 || i == 11) { printCapacity(list, "添加第" + i + "个元素后"); } } } private static void printCapacity(ArrayList<?> list, String stage) throws Exception { Field field = ArrayList.class.getDeclaredField("elementData"); field.setAccessible(true); Object[] elementData = (Object[]) field.get(list); System.out.println(stage + ":容量 = " + elementData.length + ", 实际元素个数 = " + list.size()); } }这段代码的运行结果会非常直观:
刚初始化:容量 = 0, 实际元素个数 = 0 添加第1个元素后:容量 = 10, 实际元素个数 = 1 添加第10个元素后:容量 = 10, 实际元素个数 = 10 添加第11个元素后:容量 = 15, 实际元素个数 = 11注意一个细节:JDK 7之后的ArrayList,用无参构造创建时内部数组是空的(DEFAULTCAPACITY_EMPTY_ELEMENTDATA),只有第一次add时才把容量真正设置为10,这属于懒加载策略,主要为了省内存。所以如果你用反射去看刚new出来的空ArrayList,看到的容量是0而不是10,别惊讶,这是正常的。
4. LinkedList:没有容量的概念,长度自然随意
4.1 节点结构与增删原理
LinkedList跟数组是两个物种。它内部定义了一个双向链表的节点类,每个节点除了存数据item,还有两个指针字段:prev指向前一个节点,next指向后一个节点。链表对象本身只维护first(头节点)、last(尾节点)和size三个信息。
往链表尾部添加元素时,执行的是linkLast操作:创建一个新节点,让当前尾节点的next指向它,同时新节点的prev指回旧尾节点,最后更新last和新节点的next为null,size加一。整个过程不需要移动任何已有数据,也不存在数组扩容那种"整体拷贝"的压力。所以从严格意义上说,LinkedList根本不需要"扩容机制",它的"长度"是随着节点创建天然增长的,加多少元素就有多少个节点。
中间插入操作也很直接,拿add(index, element)来说,它会先通过节点遍历找到目标位置,再把前后两个节点的指针重新搭接:
// 伪代码,展示核心逻辑 Node<E> newNode = new Node<>(prev, element, next); prev.next = newNode; next.prev = newNode;注意,找到index这个位置本身不是免费的。LinkedList在定位时有一个聪明的小优化:如果索引小于size / 2,就从头部往后找;否则从尾部往前找,把线性查找的遍历次数至少减半。这也是get(index)的时间复杂度是O(n)但仍然比盲目从头遍历好一些的原因。
4.2 LinkedList的size管理与"长度"特性
LinkedList没有capacity字段,也没有扩容方法,它只有一个size字段配合add/remove操作做增减。这意味着它的长度变化是渐进式的,加入一个节点,size加1;移除一个节点,size减1。你根本不需要担心"容量不足",因为它压根没有容量上限这个概念,唯一的上限就是Integer.MAX_VALUE和实际内存大小。
这一点和ArrayList形成强烈对比。ArrayList有"容量"(capacity)和"大小"(size)两个概念,容量是内部数组的物理长度,size是实际元素个数,二者可能不相等;LinkedList则只有size这一个概念,物理上的节点数和size永远一致。
从内存连续性来看,LinkedList的节点分散在堆内存各个角落,每个节点都是一个独立对象,这意味着它天然适合频繁在头部或尾部增删的场景。比如实现一个队列,你用LinkedList比用ArrayList更自然,因为ArrayList在头部删除元素时需要把后面所有元素全部往前搬,O(n)的代价在数据量大时完全不可接受。
4.3 链表的内存开销与性能取舍
不过LinkedList的好用是有代价的。每个Node对象除了存储数据本身,还要额外存储两个引用字段以及对象头等信息。我在64位JVM上估算过(开启压缩指针的情况),一个空Node大约占用24字节左右,加上数据引用就是32字节上下。如果存的是Integer对象,光节点开销就是数据本身的好几倍。
相比之下,ArrayList的数组是密集排列的,同样存1万个整数,ArrayList的内存开销远小于LinkedList。所以在实际项目中,如果业务场景主要是"随机访问"和"尾部追加",别迷信LinkedList的名字,ArrayList才是更好的选择。只有当你的核心操作是频繁在头部插入、删除,或者你做队列/双端队列场景时,LinkedList(或者更好的ArrayDeque)才值得考虑。
我在一个日志采集组件里就吃过这个亏:起初用LinkedList存储待发送的日志条目,因为心理上觉得"链式结构增删快",结果JVM堆内存飙升,GC频繁。后来换成ArrayList并预估容量,内耗直接降了一个数量级。这就是"看起来灵活"和"实际上合适"之间的鸿沟。
5. 排序实操:Arrays类排序与集合排序
5.1 使用Arrays.sort对数组排序
既然聊到了数组的"不可变长度",就绕不开一个非常实用的配套工具——Arrays工具类。它是操作数组的万能瑞士军刀,排序是其中最常用的功能之一。面试题里"编写一个Java程序,使用Arrays类对数组排序"本质上考察的就是这个。
import java.util.Arrays; public class ArraySortDemo { public static void main(String[] args) { int[] numbers = {32, 12, 45, 7, 23, 99, 1}; System.out.println("排序前:" + Arrays.toString(numbers)); // 对整个数组升序排序,底层是双轴快速排序 Arrays.sort(numbers); System.out.println("排序后:" + Arrays.toString(numbers)); // 对指定区间的元素排序,比如只排索引[1, 5)之间的元素 int[] partial = {5, 3, 8, 1, 9, 2, 7}; Arrays.sort(partial, 1, 6); System.out.println("区间排序后:" + Arrays.toString(partial)); } }Arrays.sort有几个重载需要注意。基本类型数组(int、double等)用的是双轴快速排序,它不需要额外对象,直接在原数组上操作,时间复杂度是O(n log n),适合处理大量数据。引用类型数组(比如String[]、自定义对象数组)则用的是TimSort,这是一种稳定的归并排序变种,它保证相同元素的相对顺序不会被打乱,这在需要保持某种业务顺序时非常重要。
如果你的数组是对象类型、还需要按自定义规则排,可以传一个Comparator。比如按字符串长度排序:
String[] words = {"Java", "C", "Python", "Go", "Rust"}; Arrays.sort(words, (a, b) -> a.length() - b.length()); System.out.println(Arrays.toString(words));这里要注意:基本类型数组不支持传Comparator,只能对引用类型数组用。想对int[]实现类似"从大到小"的顺序,没有捷径,要么把元素转成Integer数组后用Comparator,要么手动改写成双指针翻转顺序。
5.2 ArrayList排序与Collections.sort
ArrayList没有固定的length属性,但它提供了size()方法来感知长度变化。对ArrayList排序有两种常见姿势:一种是调用Collections.sort(list),另一种是JDK 8之后直接调用list.sort(comparator),后者更推荐,因为方法归属更清晰,写起来也更简洁。
import java.util.ArrayList; import java.util.Collections; import java.util.List; public class ListSortDemo { public static void main(String[] args) { ArrayList<Integer> list = new ArrayList<>(); list.add(42); list.add(17); list.add(88); list.add(5); // 方式一:Collections.sort,底层调用 list.sort 完成排序 Collections.sort(list); System.out.println("升序:" + list); // 方式二:直接用 List.sort list.sort((a, b) -> b - a); System.out.println("降序:" + list); } }从内部实现看,Collections.sort最终调用的是List.sort,而List.sort会把集合转成对象数组,调用Arrays.sort(T[], Comparator),排完序后再通过ListIterator把元素写回。这个过程很典型地体现了一个设计思路——ArrayList底层的数组和普通数组在排序层面共享了同一套高效的排序算法,只是多了"转数组—排序—写回"这层壳。
5.3 排序算法选择与应用场景
我实际写业务代码时的选择逻辑很简单,列一个速查表给你参考:
| 数据容器 | 推荐排序方式 | 底层算法 | 特点 |
|---|---|---|---|
| 基本类型数组 | Arrays.sort(arr) | 双轴快排 | 原地排序,速度快,不稳定 |
| 对象数组 | Arrays.sort(arr, comparator) | TimSort | 稳定排序,适合保留原有顺序 |
| ArrayList等集合 | list.sort(comparator) | 转数组+TimSort | 写法简洁,稳定 |
| 大数据量数组 | Arrays.parallelSort(arr) | 并行快排/TimSort | 多线程分治,注意小数据反而更慢 |
parallelSort是我比较想提的一个点,JDK 8引入,当数组元素小于阈值时它会自动退化回普通sort,只有数据量足够大时才真的启用并行排序。我测过在百万级数据上它比普通排序快不少,但小数组上因为线程调度开销反而可能更慢,所以别把它当万能加速器,要根据数据规模决策。
6. 面试高频题与实战避坑清单
6.1 常见问题速查表
关于"长度是否可变",面试官通常不会只问一个结论,他们会从各个角度变着法子考察。我把踩过和见过的典型问题整理成了一张表:
| 问题 | 核心答案 | 易错点 |
|---|---|---|
| 数组的length是属性还是方法? | 属性,arr.length无括号 | 写成arr.length()编译直接报错 |
| ArrayList默认容量是多少? | JDK 7+ 懒加载,第一次add时初始化为10 | 空列表反射看到的是0 |
| ArrayList扩容几倍? | 1.5倍,即old + (old >> 1) | 一次性addAll大量元素时新容量直接等于所需容量 |
| 扩容时旧数据去哪了? | 通过Arrays.copyOf/System.arraycopy拷贝到新数组 | 旧数组会被GC回收,频繁扩容产生垃圾对象 |
| LinkedList需要扩容吗? | 不需要,没有容量概念,靠节点链接自然增长 | 有人会误以为它底层也是数组 |
| LinkedList的get(index)快吗? | O(n),需要从头部或尾部遍历半个链表 | 跟数组的O(1)完全不是一个量级 |
| ArrayList的size()和内部容量区别? | size是已存元素个数,容量是数组物理长度 | 容量 >= size,别混淆 |
| 数组如何"变长"? | 没有原生方案,用Arrays.copyOf或改用ArrayList | 原数组引用不会自动更新,需重新赋值 |
6.2 我在实际开发中踩过、见过的坑
最后说几个真实场景里不会被教科书提到、但非常实际的坑。
第一个坑是Arrays.asList返回的列表长度不可变。很多人以为Arrays.asList("A", "B", "C")返回的是ArrayList,结果往里面add时抛UnsupportedOperationException。原因很简单:Arrays.asList返回的只是一个由数组直接支撑的固定大小列表,它重写了add和remove,直接抛异常。如果你想要一个真正的可变ArrayList,必须这样写:new ArrayList<>(Arrays.asList(...))。
第二个坑是ArrayList扩容引发的ConcurrentModificationException。如果多个线程同时往同一个ArrayList里添加元素,一个线程扩容时修改了modCount,另一个线程正在迭代,迭代器检测到modCount变了,直接抛异常。别以为这只会出现在极端并发下,我在一个多线程任务处理模块里就遇到过,一个线程偶尔打个日志都会触发。解决办法是换成CopyOnWriteArrayList或者在迭代时加锁。
第三个坑是LinkedList当队列用时的内存浪费。如果数据量小还好,数据量大且每个节点都是小对象时,内存压力会非常明显。我在做一个任务调度器时,把待执行任务队列从ArrayList改成LinkedList,结果老年代GC频次骤增,因为任务对象本身很小,Node节点的指针开销比业务数据还大。后来换成ArrayDeque,既保留了双端队列的操作语义,又用数组存储避免了指针开销,内存表现好了很多。
回到"长度是否可变"这个问题本身,我现在有一个更深的体会:Java里没有绝对的"无限长度",只有不同层面对长度的处理策略。数组把长度焊死在创建那一刻,换来了极致的访问性能;ArrayList靠扩容搬运实现了动态长度,代价是偶尔的昂贵拷贝;LinkedList用节点之间的指针链接绕开了"连续内存"的限制,代价是查找变慢和更高的内存开销。理解这三者的核心差异,写代码时选起来就不会纠结了。
最后分享一个我保存了很久的小习惯:任何要频繁增删数据的场景,先预估最终数据量,再决定初始容量或者直接选LinkedList/ArrayDeque;任何要高频按下标访问的场景,一律别用链表。这个习惯帮我省掉过很多次线上事故,也推荐给你。