本文将深入分析Java中两个重要的数据结构:ArrayList
和LinkedList
,通过查看源码,我们将探讨它们的内部实现、扩容机制、迭代器实现以及它们之间的区别。我们将以JDK源码为基础进行分析,帮助您更好地理解这两种数据结构的实现细节和使用场景。
ArrayList
提供了以下几个构造方法:
1.无参构造函数:创建一个初始容量为10的空列表。
当我们使用无参构造函数创建一个 ArrayList 对象时,底层的数组实际上是在第一次添加元素时才会被创建,且默认大小为 0。当添加第一个元素时,ArrayList 会将数组大小设置为默认容量 10,然后将元素添加到数组中。如果再添加更多元素,当数组大小不足以容纳这些元素时,ArrayList 会动态地进行扩容,每次扩容后数组大小会增加 50%。
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
2.指定初始容量的构造函数:创建一个指定初始容量的空列表。
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);
}
}
3.基于已有集合的构造函数:创建一个包含指定集合元素的列表。
当我们使用 ArrayList(Collection c)
构造函数创建对象时,底层会使用传入集合的大小作为数组的容量。
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray可能返回不是Object[]类型的数组
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// 如果集合为空,初始化为空数组
this.elementData = EMPTY_ELEMENTDATA;
}
}
4.add(Object o)
方法的扩容
在 ArrayList 中,每次添加元素时,都会检查当前数组容量是否足够。如果不足以容纳新元素,则会进行扩容。首次扩容时,容量会增加到 10;之后,每次扩容都会增加为原来容量的 1.5 倍。
具体的扩容方式是:先将当前数组大小右移 1 位,得到数组大小一半的数,再将这个数与当前数组相加,得到扩容后的大小。
ArrayList
的扩容机制在grow()方法中实现,当添加元素时,如果当前数组容量不足以容纳新元素,则会触发扩容。
扩容后的容量计算主要为如下:
int newCapacity = oldCapacity + (oldCapacity >> 1);
这表示新容量将是旧容量的1.5倍。
以下为grow(int minCapacity)
方法完整代码
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
if (newCapacity - MAX_ARRAY_SIZE > 0) {
newCapacity = hugeCapacity(minCapacity);
}
elementData = Arrays.copyOf(elementData, newCapacity);
}
grow() 方法在 ensureCapacityInternal() 方法中被调用,以确保在添加新元素时有足够的空间。
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
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);
}
}
5.addAll(Collection c)
方法的扩容
在 ArrayList
的 addAll(Collection c)
方法中,扩容逻辑与 add(Object o)
类似。如果添加的集合中没有元素,扩容后的容量为 Math.max(10, 实际元素个数)
;如果已有元素,扩容后的容量为 Math.max(原容量 1.5 倍, 实际元素个数)
。
public boolean addAll(Collection<? extends E> c) {
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew);
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
通过分析 ArrayList 的源码,我们可以看到其扩容机制的实现细节。这种动态扩容方式使得 ArrayList 在存储元素时更加灵活,但也可能在扩容过程中产生一定的性能开销。因此,在使用 ArrayList 时,合理预估初始容量可以提高性能,避免频繁的扩容操作。
在深入了解 Java 集合中的迭代器之前,我们先介绍一下 Fail-Fast 和 Fail-Safe 的概念,这将有助于理解不同迭代器的行为特点。
Fail-Fast 机制是指,在迭代集合的过程中,如果检测到集合被修改,就会立即抛出 ConcurrentModificationException 异常,终止迭代。这种机制通过对集合的修改次数进行检测来发现集合是否被修改的。具体来说,当使用 Fail-Fast 迭代器遍历一个集合时,迭代器会记录下集合的修改次数 modCount 的值。每当集合发生修改操作时,modCount 的值就会增加。在每一次迭代操作之前,迭代器都会检查当前的 modCount 值是否等于迭代开始时的 modCount 值,如果不相等,就说明在迭代过程中集合被修改了,就会抛出 ConcurrentModificationException 异常,终止迭代。Fail-Fast 迭代器基于迭代器遍历过程中对集合内容的检查来实现,它假设在迭代期间其他线程不会修改集合。一旦其他线程对集合进行了修改,就会抛出异常,提醒程序员集合在多个线程之间被修改,从而保障了遍历的安全性和一致性。
Fail-Safe 机制与 Fail-Fast 不同,其迭代器不会在迭代过程中抛出 ConcurrentModificationException 异常,而是在遍历开始时创建集合的一个副本,并在副本上进行遍历。由于是在副本上进行操作,因此即使其他线程修改了原集合,也不会影响迭代器的遍历结果。Fail-Safe 迭代器的优点是能够避免遍历出不一致的结果,同时也不会抛出异常导致程序终止。但是它的缺点是需要在内存中存储一个集合的副本,如果集合比较大,会占用较多的内存资源。
现在我们已经了解了 Fail-Fast 和 Fail-Safe 的原理,接下来将结合 Java 源码,深入探讨 ArrayList 和 LinkedList 的迭代器实现。
ArrayList
实现了Fail-Fast
迭代器,通过内部类Itr来实现:
private class Itr implements Iterator<E> {
int cursor; // 下一个元素的索引
int lastRet = -1; // 上一个返回元素的索引
int expectedModCount = modCount;
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
在Itr
类中,checkForComodification()
方法用于检查modCount
(修改计数)是否等于expectedModCount
,以检测集合是否在迭代过程中被修改。如果检测到修改,将抛出ConcurrentModificationException
异常。
LinkedList
是基于双向链表实现的列表。它不需要连续内存空间,因此在插入和删除操作上具有更高的性能。
LinkedList
提供了以下几个构造方法:
public LinkedList() {
}
2.基于已有集合的构造函数
:创建一个包含指定集合元素的列表。
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
LinkedList
的内部节点由内部类Node表示:
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;
}
}
每个Node
包含一个元素和指向前一个和后一个节点的指针。
LinkedList
实现了Fail-Fast
迭代器,通过内部类ListItr来实现:
private class ListItr implements ListIterator<E> {
private Node<E> lastReturned;
private Node<E> next;
private int nextIndex;
private int expectedModCount = modCount;
// ...
}
与ArrayList
的迭代器类似,LinkedList
的迭代器也使用checkForComodification()方法来检测集合是否在迭代过程中被修改。如果检测到修改,将抛出ConcurrentModificationException异常。
在删除元素时,ArrayList和LinkedList的性能差异较大。
对于ArrayList,删除元素会触发数组元素的移动。具体来说,当删除一个元素时,需要将其后面的所有元素向前移动一个位置。因此,删除元素的时间复杂度为O(n),其中n为需要移动的元素个数。
对于LinkedList,删除元素只需要改变前后节点的指针即可。因此,在已知节点位置的情况下,删除元素的时间复杂度为O(1)。
根据上述源码分析和操作特性,我们可以总结出以下性能对比:
随机访问:ArrayList具有较好的随机访问性能,时间复杂度为O(1);LinkedList在随机访问时需要沿着链表遍历,时间复杂度为O(n)。
插入/删除:ArrayList在尾部插入/删除性能较好,时间复杂度为O(1);在其他部分插入/删除时,需要移动大量元素,时间复杂度为O(n)。LinkedList在已知节点位置的情况下,插入/删除操作的时间复杂度为O(1)。
内存消耗:ArrayList使用连续的内存空间,当数组容量不足时需要扩容,可能导致额外的内存消耗。LinkedList使用非连续内存空间,额外存储了前后节点的指针,因此每个元素的内存开销相对较大。
根据上述分析,我们可以得出以下适用场景:
如果需要频繁访问、查找元素,且插入/删除操作较少,可以选择ArrayList。
如果需要频繁插入/删除元素,特别是在列表的中间位置进行操作,可以选择LinkedList。
然而,在实际应用中,我们还需要考虑其他因素,例如内存限制、数据量大小等。因此,在选择合适的数据结构时,需要综合考虑多种因素,权衡性能和资源消耗。
根据源码分析,我们可以总结出ArrayList
和LinkedList
的以下区别:
1.ArrayList
基于数组实现,需要连续内存空间;LinkedList
基于双向链表实现,无需连续内存空间。
2,ArrayList
具有较好的随机访问性能;LinkedList
在随机访问时需要沿着链表遍历,性能较差。
3.ArrayList
在尾部插入、删除性能较好,其它部分插入、删除性能较差;LinkedList
在插入、删除操作上性能较好,尤其是在链表中间部分。
4. ArrayList
需要扩容,而LinkedList
不需要。
ArrayList可能存在内存浪费问题,当数组容量远大于实际元素数量时;LinkedList由于每个节点都需要额外的前后指针,其内存占用也相对较高。
综合考虑,我们可以在不同的使用场景中选择合适的数据结构:
当需要频繁访问列表中的元素时,建议使用ArrayList。
当需要频繁进行插入、删除操作时,尤其是在列表中间部分,建议使用LinkedList。
当内存空间紧张时,可以根据具体情况权衡ArrayList和LinkedList的优缺点。
通过以上源码分析和对比,希望能够帮助您更好地理解ArrayList和LinkedList的内部实现细节和使用场景,从而在实际应用中做出明智的选择。
除了ArrayList和LinkedList,Java还提供了其他集合实现,如Vector和CopyOnWriteArrayList等。这些集合具有不同的性能特点和使用场景,例如:
Vector:与ArrayList类似,但实现了线程安全。由于线程安全的开销,其性能通常低于ArrayList。
CopyOnWriteArrayList:线程安全的ArrayList实现,适用于读操作远多于写操作的场景。在写操作时,会复制整个数组,因此写操作的性能开销较大。
在选择集合实现时,除了考虑基本的性能特点和使用场景外,还需要根据实际需求考虑线程安全、并发性能等因素。
Java集合中的大多数实现都不是线程安全的,例如ArrayList、LinkedList、HashMap等。在多线程环境下使用这些集合时,需要注意同步控制。通常有以下几种方法来保证线程安全:
使用线程安全的集合实现:如Vector、CopyOnWriteArrayList、ConcurrentHashMap等。这些集合实现了线程安全,但可能存在性能开销。
使用同步封装器:Collections类提供了一些方法,如Collections.synchronizedList()、Collections.synchronizedMap()等,可以将非线程安全的集合封装为线程安全的集合。但这些方法实现的同步控制粒度较大,可能导致性能下降。
手动同步:在访问集合时,使用synchronized关键字或其他同步工具(如ReentrantLock)进行同步控制。这种方法需要用户自己管理同步逻辑,容易出错,但可以实现更细粒度的同步控制,提高性能。
在使用Java集合时,需要注意垃圾回收的影响。在集合中存储大量对象时,可能会导致垃圾回收器的负担加重,影响应用性能。为了避免这种问题,可以采取以下策略:
及时清理无用对象:在使用集合存储对象时,确保不再使用的对象被及时清理,避免内存泄漏。例如,在集合中存储监听器对象时,需要在不再需要监听时将其从集合中移除。
使用弱引用:在某些场景下,可以使用弱引用(WeakReference)来替代强引用,让垃圾回收器在需要时回收对象。例如,可以使用WeakHashMap来实现缓存,当内存不足时,垃圾回收器可以自动清理缓存中的对象。
总结
本文深入分析了Java中ArrayList和LinkedList的源码实现、性能特点和使用场景。同时,讨论了线程安全、垃圾回收等与集合相关的问题。希望通过这篇博客,帮助大家更好地理解Java集合的实现原理和使用方法,为实际开发提供参考。