Java 集合(一)List
在 Java 中,主要存在以下三种类型的集合:Set、List 和 Map,按照更加粗略的划分,可以分为:Collection 和 Map,这些类型的继承关系如下图所示:
Collection
是集合 List、Set、Queue 等最基本的接口Iterator
即迭代器,可以通过迭代器遍历集合中的数据Map
是映射关系的基本接口
本文将主要介绍有关 List
集合的相关内容,ArrayList
和 LinkedList
是在实际使用中最常使用的两种 List
,因此主要介绍这两种类型的 List
本文的 JDK 版本为 JDK 17
ArrayList
具体的使用可以参考 List
接口的相关文档,在此不做过多的介绍
初始化
ArrayList
存在三个构造函数,用于初始化 ArrayList
-
ArrayList()
对应的源代码如下所示:
// 默认的空元素列表 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 实际存储元素的数组 transient Object[] elementData; public ArrayList() { // 调用次无参构造函数时,首先将元素列表指向空的列表,在之后的扩容操作中再进行进一步的替换 this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }
-
ArrayList(int)
对应的源代码如下所示:
private static final Object[] 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); } }
-
ArrayList(Collection<? extend E>)
对应的源代码:
public ArrayList(Collection<? extends E> c) { Object[] a = c.toArray(); if ((size = a.length) != 0) { if (c.getClass() == ArrayList.class) { /* 如果添加的集合和 ArrayList 相同,那么直接修改当前的数据列表的引用 因此在某些情况下对于该数组的修改会出现一些奇怪的问题, 因为对于当前数组元素的修改也会导致原数组元素的改变 */ elementData = a; } else { /* 想比较之下,复制一个数组会更加安全,缺点在于执行速度不是那么的好 */ elementData = Arrays.copyOf(a, size, Object[].class); } } else { // 如果集合中元素的个数为 0,那么替换为空数组 elementData = EMPTY_ELEMENTDATA; } }
添加元素
添加元素是比较重要的部分,特别是 ArrayList
的自动扩容机制
在 ArrayList
中存在以下三个重载函数:
add(E, Object[], int)
add(E)
add(int, E)
一般情况下,都会调用 add(E)
的重载方法完成添加元素的功能,具体的源代码如下所示:
public boolean add(E e) {
modCount++; // 记录当前的数组的修改次数,适用于并发环境下的检测
add(e, elementData, size);
return true;
}
添加时调用重载函数 add(E, Object[], int)
,对应的源代码如下所示:
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow(); // 比较关键的地方在这,在这里完成自动扩容
elementData[s] = e;
size = s + 1;
}
而 add(int, E)
的目的是向数组中指定的位置插入对应的元素,对应的源代码如下所示:
public void add(int index, E element) {
rangeCheckForAdd(index); // 首先,检查插入的索引位置是否合法
modCount++; // 记录当前的修改次数
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow(); // 此时的数组长度不够,需要进行扩容
// 将扩容后(已经复制了原有数组的数据)的数组按照指定的 index 分开复制到原有数组中
System.arraycopy(elementData, index,
elementData, index + 1,
s - index);
// 修改 index 位置的数组元素
elementData[index] = element;
size = s + 1;
}
扩容
grow()
扩容的实现的源代码如下所示:
private Object[] grow() {
return grow(size + 1);
}
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 判断当前爱你的数组元素是否被修改过
// 首先,计算扩容后的数组的大小
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, // 最少需要增长的空间
oldCapacity >> 1 /* 每次增大的默认空间大小 ,为 0.5 倍的旧空间大小*/ );
// 然后将创建原有的数据元素数组的副本对象,再将数据填入到副本数组中,完成扩容操作
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
/*
由于没有被修改过,那么直接扩容到目标大小即可,
DEFAULT_CAPACITY=10,这是为了提供一个最小的初始容量,以免扩容机制过于频繁造成的性能损失
*/
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
扩容时关键的是在于新的容量的计算,具体的源代码如下所示:
// jdk.internal.util.ArraysSupport
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
/*
参照上文中 grow 中传入的参数,最小大小扩容到原来数组大小的 1.5 倍,
如果 minGrwoth 大于 oldCapacity,则以 minGrowth 为准
*/
int prefLength = oldLength + Math.max(minGrowth, prefGrowth);
// 加法操作可能会导致整形变量溢出
if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) {
return prefLength;
} else {
// 如果溢出的话则调用 hugeLength 进行计算
return hugeLength(oldLength, minGrowth);
}
}
/*
之所以最大值为 Integer.MAX_VALUE - 8 是由于有些 JVM 的实现会限制其不会达到 Integer.MAX_VALUE
*/
public static final int SOFT_MAX_ARRAY_LENGTH = Integer.MAX_VALUE - 8;
private static int hugeLength(int oldLength, int minGrowth) {
int minLength = oldLength + minGrowth;
if (minLength < 0) { // 这里溢出的说明确实是没有足够的空间可以进行分配了
throw new OutOfMemoryError(
"Required array length " + oldLength + " + " + minGrowth + " is too large");
} else if (minLength <= SOFT_MAX_ARRAY_LENGTH) { // 小于最大限制,直接使用即可
return SOFT_MAX_ARRAY_LENGTH;
} else {
// 这种情况说明在 SOFT_MAX_ARRAY_LENGTH — Integer.MAX_VALUE 之间,依旧是一个有效的 int 值
return minLength;
}
}
扩容完成之后,将元素直接放入到末尾位置,完成元素的插入
移除元素
在 ArrayList
中移除元素也存在两个重载函数:
remove(int)
:移除指定位置的元素remove(Object)
:移除列表中的指定元素(依据 Object 的equals
方法)
remove(int)
方法对应的源代码如下所示:
public E remove(int index) {
Objects.checkIndex(index, size); // 检查删除的索引位置是否是有效的
final Object[] es = elementData;
// 获取这个索引位置的旧有元素
@SuppressWarnings("unchecked") E oldValue = (E) es[index];
fastRemove(es, index); // 移除该索引位置的元素
return oldValue;
}
比较关键的 fastRemove
方法对应的源代码如下所示:
private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
// 直接调用 native 方法将 index 后的元素向前移动一个位置
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
// 最后一个位置现在已经是无效的,设置为 null 帮助 gc
es[size = newSize] = null;
}
remove(Object)
对应的源代码如下所示:
public boolean remove(Object o) {
final Object[] es = elementData;
final int size = this.size;
int i = 0;
/*
这一步的目的是找到对应的元素位置的索引
可以看到,在找到第一个元素后就不会继续向后找了,
因此使用该方法是需要注意这一点
*/
found: {
if (o == null) {
for (; i < size; i++)
if (es[i] == null)
break found;
} else {
for (; i < size; i++)
if (o.equals(es[i]))
break found;
}
return false;
}
// 同 remove(int) 的移除元素一致
fastRemove(es, i);
return true;
}
值得注意的是,在实际使用的过程中,由于 Java “自动装箱/拆箱” 机制的存在,如果此时恰好列表的元素类型为 Integer
,那么在调用 remove
方法时将会自动完成拆箱调用 remove(int)
重载方法。可以显示地通过传入 Integer
对象来避免自动拆箱错误地调用 remove(int)
,如 list.remove(Integer.valueOf(1))
就会正确地调用 remove(object )
方法
收缩列表
由于扩容机制的存在,因此出现列表很长,但是数据元素不多的情况是可能的。ArrayList
并不会自动调用收缩列表的方法来收缩列表的长度,但是我们自己可以显示地通过调用 trimToSize
方法来收缩列表。
trimToSize
对应的源代码如下所示:
public void trimToSize() {
modCount++;
if (size < elementData.length) {
elementData = (size == 0)
? EMPTY_ELEMENTDATA
: Arrays.copyOf(elementData, size);
}
}
LinkedList
除了 ArrayList
之外,对于 List
接口的实现类,LinkedList
可能是使用得比较多的实现类。和 ArrayList
的实现不同,LinkedList
的底层数据结构是双向链表,具体的节点的定义如下所示:
private static class Node<E> {
E item; // 实际数据元素
Node<E> next; // 后继节点指针
Node<E> prev; // 前驱节点指针
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
添加元素
同样德,从 List
接口过来的两个重载方法:
add(E)
:添加一个元素到列表的末尾add(int, E)
:添加一个元素到指定的索引位置
其中, add(E)
对应的源代码如下所示:
public boolean add(E e) {
linkLast(e);
return true;
}
// 链接元素到链表的末尾
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
这个就是简单的双向链表的插入操作,不做过多的介绍。
add(int, E)
对应的源代码如下所示:
public void add(int index, E element) {
checkPositionIndex(index); // 首先,检查插入的索引位置是否合法
// 如果插入的位置就是在末尾,那么直接链接到末尾即可
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
比较关键的是确定插入位置的元素节点,即 node(index)
方法的实现,对应的源代码如下所示:
Node<E> node(int index) {
// 如果索引小于列表长度的一般,则从头开始查找位置节点
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
// 否则,从尾部节点开始,向前查找到对应的位置节点
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
最后,将对应的元素插入到找到的元素节点之前即可完成该项操作
移除元素
依旧是从 List
接口带过来的重载方法:
remove(int)
:移除指定位置的元素remove(Object)
:移除第一个出现的指定元素
remove(int)
对应的源代码如下所示:
public E remove(int index) {
checkElementIndex(index); // 检查索引位置是否合法
/*
首先通过 node(index) 方法找到对应的索引位置的节点,
然后移除它的链接即可(注意头节点和尾节点的变化)
*/
return unlink(node(index));
}
E unlink(Node<E> x) {
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
// 移除前驱节点,注意前驱节点为 null 的情况
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
// 移除后继节点,注意后继节点为 null 的情况
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null; // 设置为 null 方便 gc
size--;
modCount++;
return element;
}
remove(Object)
对应的源代码如下所示:
public boolean remove(Object o) {
/*
简单来讲就是遍历整个链表,找到要移除的元素,
然后取消它在链表中的链接即可
unlink 方法在上文有所介绍
*/
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
两者的比较
在实际使用中,ArrayList
的使用次数要比 LinkedList
的次数要高。这是由于使用 List
更加倾向于保存数据,然后获取数据的情况要多一些。
其实,在教科书上会提及链表和数组的比较,诸如:链表使用了额外的空间来维护节点的前后指针、插入和删除都在常数的时间复杂度内完成。但是实际上,这两者的操作性能不会有太大的区别,甚至可能 LinkedList
的插入操作的性能还不及 ArrayList
(因为要找到插入的位置节点);同样的,ArrayList
对于空间的利用率在某些情况下可能还没有 LinkedList
高,这是由于扩容时默认会扩容到原来的 1.5 倍,有时那 0.5 倍的额外空间是完全没有被使用的。
按照本人的使用情况,一般在选取 List
的实现时会采用 ArrayList
作为具体的实现类,因为 ArrayList
在转换为数组的这个过程会比 LinkedList
更加便捷。而 LinkedList
不仅仅实现了 List
接口,而且还实现了 Queue
、Deque
等其它的接口,因此 LinkedList
一般会作为这些接口的默认实现类来使用
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构