Java中的List: 理解与实践
在Java编程语言中,List是一种被广泛使用的集合类型,它提供了一种灵活的方式来存储和操作有序的元素序列。List是Java集合框架(Java Collections Framework)的一部分,是一个接口,提供了一系列标准的方法来对元素进行增加、删除、检索和遍历操作。
List的核心特性
- 有序性:List中的元素按照插入的顺序进行存储,可以通过元素的索引(位置)来访问它们。
- 元素唯一性:List允许添加重复的元素,即两个或更多的元素可以有相同的值。
- 动态扩展:列表的大小不是固定的,它可以根据需要动态地增加或减少元素。
List的实现类
ArrayList
基于动态数组实现,默认初始容量是10,动态数组允许元素的随机访问,即可以通过索引直接访问任何位置的元素。
动态数组(Dynamic Array)是一个可以根据需要进行自动扩容的数据结构。与普通数组相同。然而,与传统的数组(其大小在创建时固定)不同,动态数组在添加更多元素时可以自动地扩大其容量。
-
随机访问:动态数组支持快速的随机访问,这意味着可以在常数时间内访问任何索引的元素,即时间复杂度为O(1)。
-
自动扩容:当数组中的元素填满所有可用空间时,动态数组可以自动进行扩容以适应更多的元素。这通常涉及以下步骤:
- 创建一个更大的新数组(通常是旧数组大小的两倍)。
- 将旧数组中的所有元素复制到新数组中。
- 释放旧数组,并将引用指向新数组。
-
添加/删除元素:向动态数组添加元素通常是一个快速的操作,特别是在数组的末尾添加。但是,如果数组已经满了,那么添加操作需要进行一次扩容,这将涉及到额外的内存分配和元素复制。平均而言,添加操作的时间复杂度是O(1),但在最坏情况下会是O(n)。删除元素也是一种支持的操作,但可能涉及到移动元素以填补被删除元素留下的空隙。
-
内存利用率:由于动态数组可能会在其容量达到上限之前就进行扩容,它可能不会始终使用所有分配的内存。这意味着它可能在某些时候会有额外的空间开销。
ArrayList
扩容过程的基本步骤:
- 检查容量:每次添加元素时,
ArrayList
都会检查内部数组是否足够大,可以容纳新的元素。如果不够大,那么就需要进行扩容。 - 计算新容量:
ArrayList
计算新数组的大小。新容量的计算方式可能依赖于具体的实现,但通常是当前数组容量的1.5到2倍。在OpenJDK中,它是旧容量的1.5倍(即旧容量加上旧容量右移一位,newCapacity = oldCapacity + (oldCapacity >> 1)
)。 - 创建新数组:
ArrayList
创建一个新的更大的数组,大小为计算出的新容量。 - 复制元素:将原有数组中的所有元素复制到新数组中。这通常使用
System.arraycopy()
方法实现,它是一个原生方法,可以快速地将数据从一个数组复制到另一个数组。 - 更新引用:
ArrayList
将内部数组的引用更新为新数组。 - 添加新元素:现在新数组已经有足够的空间,
ArrayList
将新元素添加到数组中。
模拟ArrayList
扩容过程的简化代码示例:
public static void main(String[] args) {
// 假定初始容量为5,仅为示例
Object[] elements = new Object[5];
int size = 0; // ArrayList的当前元素数量
// 添加元素,模拟添加过程中的扩容
for (int i = 0; i < 10; i++) {
// 添加10个元素,超出初始容量
if (size == elements.length) {
// 计算新容量:旧容量 + 旧容量的一半
int newCapacity = elements.length + (elements.length >> 1);
// 创建新数组
elements = Arrays.copyOf(elements, newCapacity);
}
// 添加新元素
elements[size++] = "Element " + i;
}
System.out.println(Arrays.toString(elements));
}
LinkedList
基于双向链表实现,没有固定大小的内部数组,提供了优秀的顺序访问和中间插入/删除操作的性能。
双向链表(Doubly Linked List)是一种数据结构,它由一系列节点(Node)组成,每个节点包含数据以及两个指针,分别指向前一个节点和后一个节点。这种结构允许双向遍历,即可以从头节点遍历到尾节点,也可以从尾节点遍历到头节点。
每个节点通常包含以下部分:
- 数据(Data):存储的元素或值。
- 前驱指针(Prev):指向链表中上一个节点的指针。
- 后继指针(Next):指向链表中下一个节点的指针。
双向链表的第一个节点称为头节点(Head),最后一个节点称为尾节点(Tail)。在双向链表的头节点中,前驱指针通常指向 null
,表示没有前一个节点。同样,在尾节点中,后继指针指向 null
,表示没有下一个节点。
双向链表的优势在于它可以轻松地向两个方向遍历,并且在链表中间插入或删除节点时,可以更高效地进行,因为可以直接访问任何节点的前驱和后继。这在单向链表中需要更多的步骤,因为你只能从头节点开始按顺序遍历。
双向链表的简单实现:
public class DoublyLinkedListNode<T> {
T data;
DoublyLinkedListNode<T> prev;
DoublyLinkedListNode<T> next;
public DoublyLinkedListNode(T data) {
this.data = data;
this.prev = null;
this.next = null;
}
}
public class DoublyLinkedList<T> {
private DoublyLinkedListNode<T> head;
private DoublyLinkedListNode<T> tail;
public DoublyLinkedList() {
head = null;
tail = null;
}
// 在链表尾部添加节点
public void append(T data) {
DoublyLinkedListNode<T> newNode = new DoublyLinkedListNode<>(data);
// 链表为空
if (tail == null) {
head = newNode;
tail = newNode;
} else {
tail.next = newNode;
newNode.prev = tail;
tail = newNode;
}
}
// 在链表头部添加节点
public void prepend(T data) {
DoublyLinkedListNode<T> newNode = new DoublyLinkedListNode<>(data);
// 链表为空
if (head == null) {
head = newNode;
tail = newNode;
} else {
newNode.next = head;
head.prev = newNode;
head = newNode;
}
}
}
双向链表相对于数组和单向链表来说,在插入和删除节点时可能更加高效,因为不需要重新排列整个数据结构。然而,它也具有额外的内存开销,因为每个节点都包含两个额外的指针。在实际的软件开发中,选择哪种数据结构取决于具体的应用场景和性能需求。
单向链表(Singly Linked List)是一种线性数据结构,由一系列节点(Node)组成,每个节点包含两部分:数据域和指向下一个节点的指针。这种结构允许顺序访问其元素,从头节点(Head)开始,一直到尾节点(Tail)结束。尾节点的指针指向 null
,标记着链表的结束。
每个节点通常包含以下部分:
- 数据(Data):存储的元素或值。
- 指针(Next):指向链表中下一个节点的指针。
单向链表的头节点是链表的起点,它是访问链表中其他节点的入口。在单向链表中,由于每个节点只包含指向下一个节点的指针,所以你不能直接反向遍历,只能从头节点开始,按顺序向后遍历。
单向链表的简单实现:
public class SinglyLinkedListNode<T> {
T data;
SinglyLinkedListNode<T> next;
public SinglyLinkedListNode(T data) {
this.data = data;
this.next = null;
}
}
public class SinglyLinkedList<T> {
private SinglyLinkedListNode<T> head;
public SinglyLinkedList() {
this.head = null;
}
// 在链表的末尾添加一个新的节点
public void append(T data) {
if (head == null) {
head = new SinglyLinkedListNode<>(data);
return;
}
SinglyLinkedListNode<T> current = head;
while (current.next != null) {
current = current.next;
}
current.next = new SinglyLinkedListNode<>(data);
}
// 在链表头部添加一个新的节点
public void prepend(T data) {
SinglyLinkedListNode<T> newHead = new SinglyLinkedListNode<>(data);
newHead.next = head;
head = newHead;
}
// 删除具有特定值的节点
public void remove(T data) {
if (head == null) return;
if (head.data.equals(data)) {
head = head.next;
return;
}
SinglyLinkedListNode<T> current = head;
while (current.next != null) {
if (current.next.data.equals(data)) {
current.next = current.next.next;
return;
}
current = current.next;
}
}
}
Vector
和ArrayList类似,默认初始容量是10,也会在需要时自动增长其容量,但是它是线程安全的。
每种实现都有其特定的使用场景。如果你需要频繁的随机访问元素,那么ArrayList
将是最合适的,因为它提供了优秀的随机访问性能。但是,如果你需要在List中插入和删除元素,特别是在List的开头或中间,LinkedList
会更加适合,因为它的插入和删除操作不需要像数组那样进行大量的元素移动。
List的基本操作
add(E e)
:将指定的元素添加到列表的末尾。add(int index, E element)
:在列表的指定位置插入指定元素。remove(Object o)
:移除列表中首次出现的指定元素(如果存在)。remove(int index)
:移除列表中指定位置的元素。get(int index)
:返回列表中指定位置的元素。set(int index, E element)
:用指定元素替换列表中指定位置的元素。size()
:返回列表中的元素个数。isEmpty()
:如果列表不包含元素,则返回true。contains(Object o)
:如果列表包含指定元素,则返回true。clear()
:移除列表中的所有元素。
List的迭代
- 使用传统的
for
循环通过索引访问。 - 使用增强的
for-each
循环进行迭代。 - 使用迭代器(Iterator)。
List的线程安全问题
在多线程环境中使用List时,开发者需要注意线程安全的问题。ArrayList
和LinkedList
并不是线程安全的,如果需要在多线程环境下访问和修改List,可以考虑使用Vector
或者Collections.synchronizedList
方法来包装非线程安全的List:
import java.util.Collections;
import java.util.List;
import java.util.ArrayList;
public class SynchronizedListExample {
public static void main(String[] args) {
// 创建一个同步的List
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
// 添加元素
syncList.add("Synchronized");
syncList.add("List");
// 访问元素需要同步块
synchronized (syncList) {
for (String item : syncList) {
System.out.println(item);
}
}
}
}
然而,在高并发场景下,Vector
和Collections.synchronizedList
的性能可能不是最优的。在这种情况下,可以考虑使用CopyOnWriteArrayList
,它是java.util.concurrent
包提供的一个线程安全的List实现,它通过在修改操作(如add、set)时创建底层数组的副本来实现线程安全。
CopyOnWriteArrayList
的核心思想是,每当对列表进行修改操作(添加、设置或删除元素)时,它都会创建并使用内部数组的一个新副本。因此,任何写入操作都不会影响到原始数组,从而保证了迭代器不会看到这些变化,避免了并发修改异常。
这里是CopyOnWriteArrayList
的一些主要特点:
- 线程安全:
CopyOnWriteArrayList
内的所有可变操作(add、set、remove等)都是通过创建内部数组的新副本来实现的,从而避免了线程间的冲突。 - 读写分离:读操作(如get、iterator、size等)是在原有数组的基础上进行的,而写操作则在复制的新数组上执行。这种机制称为“写时复制”(Copy-on-Write)。
- 迭代器一致性:迭代器返回的是写操作发生时的数组快照。因此,在迭代器创建之后的写操作不会反映在迭代器上,保证了迭代器不会抛出
ConcurrentModificationException
。迭代器也不支持修改操作,remove
、set
和add
方法会抛出UnsupportedOperationException
。 - 内存和性能考虑:由于每次修改都涉及创建数组的副本,对于大的列表或频繁修改的情况,
CopyOnWriteArrayList
可能会带来显著的内存和性能开销。 - 适用场景:
CopyOnWriteArrayList
适合于列表大小相对固定,但需要在多线程环境中经常遍历、读取和枚举的应用场景。如果列表经常发生变化,或者列表非常大,使用CopyOnWriteArrayList
可能不是最好的选择。
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteArrayListExample {
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Element 1");
list.add("Element 2");
list.add("Element 3");
// 创建一个迭代器,它不会反映在迭代过程中加入的新元素
for (String element : list) {
System.out.println(element);
// 这里的添加操作不会影响迭代器
list.add("Element 4");
}
// 最终列表的内容
System.out.println("Final list: " + list);
}
}
尽管在迭代过程中向CopyOnWriteArrayList
添加了新元素,迭代器仍然只会遍历开始迭代时列表的原始内容。最终的输出将包括新添加的元素。
List的高级用法
除了基本操作,List还提供了一系列高级操作,如排序、查找和转换等。
- 排序:可以使用
Collections.sort
方法对List进行排序。 - 查找:可以使用
Collections.binarySearch
方法在已排序的List中快速查找元素。 - 转换:可以使用
toArray
方法将List转换为数组,或者使用stream
方法进行更复杂的数据转换和操作。
// 对List进行排序
Collections.sort(fruits);
// 使用二分查找法查找元素
int index = Collections.binarySearch(fruits, "Cherry");
// 将List转换为数组
String[] fruitsArray = fruits.toArray(new String[0]);
// 使用Stream API进行过滤
List<String> filteredFruits = fruits.stream()
.filter(f -> f.startsWith("A"))
.collect(Collectors.toList());
List与Java 8的流
Java 8引入了流(Streams),这为在List上进行复杂的查询和转换操作提供了新的可能性。你可以使用流来执行过滤、映射、排序和其他聚合操作,通常配合lambda表达式使用。
使用流的一个例子:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class StreamListExample {
public static void main(String[] args) {
// 创建一个List实例
List<String> items = new ArrayList<>();
items.add("One");
items.add("Two");
items.add("Three");
// 使用流过滤和输出元素
List<String> filteredItems = items.stream()
.filter(s -> s.contains("T"))
.collect(Collectors.toList());
System.out.println("Filtered Items:");
filteredItems.forEach(System.out::println);
}
}
性能考量
在决定使用哪种List实现之前,需要考虑以下几个性能相关的因素:
- 随机访问:如果你需要频繁地通过索引访问元素,
ArrayList
通常是更好的选择,因为它提供常数时间复杂度的随机访问能力。 - 插入和删除:如果你的应用场景中经常在List中间插入或删除元素,
LinkedList
可能会提供更好的性能,因为它只需要改变节点的指针。 - 内存占用:
ArrayList
可能会预留一些空间以减少扩容操作的频率,这可能导致一定程度的内存浪费。相比之下,LinkedList
对于每个元素都需要额外的内存来维护节点之间的链接。 - 线程安全:如果在多线程环境中使用List,应该考虑线程安全的问题。
Vector
是同步的,但是通常建议使用Collections.synchronizedList
或CopyOnWriteArrayList
来获得线程安全的List。
常见问题
-
List是否可以存储基本数据类型?:List不能存储基本数据类型,例如
int
、char
等,但可以存储它们的包装类,如Integer
、Character
等。 -
如何将List转换为数组?:可以使用
toArray()
方法将List转换为数组。import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; public class ListStreamExample { public static void main(String[] args) { List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "Apricot"); // 使用Stream API对List进行过滤和转换 List<String> filteredFruits = fruits.stream() .filter(f -> f.startsWith("A")) // 过滤出以"A"开头的水果 .sorted() // 按自然顺序排序 .collect(Collectors.toList()); // 收集为List System.out.println("Filtered and Sorted Fruits: " + filteredFruits); } }
-
并发修改异常(ConcurrentModificationException): 当尝试在遍历
List
的过程中改变其结构(例如添加或删除元素)时,可能会抛出ConcurrentModificationException
。要避免这个问题,可以使用迭代器的remove()
方法来删除元素,或者采用Java 8及以上版本的新特性,如removeIf()
方法。对于并发环境,可以考虑使用线程安全的集合类型,如CopyOnWriteArrayList
。 -
类型安全问题: 自Java 5起,Java引入了泛型,使得可以创建特定类型的
List
(例如List<String>
)。尽量避免使用原始类型(raw types),如简单的List
,因为这会导致类型转换问题和运行时错误。 -
忘记使用泛型: 始终使用泛型来声明和初始化
List
,这有助于编译时类型检查,并减少在运行时出现ClassCastException
的风险。 -
使用不当的equals和hashCode: 当使用包含自定义对象的
List
时,确保正确重写这些对象的equals()
和hashCode()
方法,这对于列表的搜索和去重操作至关重要。 -
不正确的遍历和删除: 在
for
循环中遍历并删除元素可能导致跳过某些元素或抛出ConcurrentModificationException
。使用迭代器或Java 8的removeIf
方法可以安全地删除元素。
手写ArrayList
import java.util.Arrays;
public abstract class SimpleArrayList<E> implements java.util.List<E> {
/**
* 默认的初始容量
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 内部用来存储元素的数组
*/
private Object[] elements;
/**
* 列表的当前元素数量
*/
private int size;
public SimpleArrayList() {
// 初始化内部数组
elements = new Object[DEFAULT_CAPACITY];
}
// 确保内部数组有足够的容量来存储新元素
private void ensureCapacity() {
if (size >= elements.length) {
// 如果当前元素数量达到数组容量,需要扩容
// 通常扩展到旧容量的1.5倍
int newCapacity = elements.length + (elements.length >> 1);
// 复制旧数组元素到新的扩容后的数组
elements = Arrays.copyOf(elements, newCapacity);
}
}
@Override
public boolean add(E e) {
// 确保有足够容量添加新元素
ensureCapacity();
// 将元素添加到数组末尾,并递增大小
elements[size++] = e;
return true;
}
@Override
public E get(int index) {
// 检查索引范围
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
// 返回请求索引处的元素
return (E) elements[index];
}
@Override
public E remove(int index) {
// 保存要删除的元素
E oldValue = get(index);
// 计算要移动的元素数目
int numMoved = size - index - 1;
if (numMoved > 0) {
// 将删除元素后的所有元素向前移动一个位置
System.arraycopy(elements, index + 1, elements, index, numMoved);
}
// 清除引用并递减大小
elements[--size] = null;
// 返回被删除的元素
return oldValue;
}
@Override
public int size() {
// 返回列表当前元素数量
return size;
}
// ... 其他 List 接口方法的实现(略)
@Override
public boolean isEmpty() {
return size == 0;
}
// ... 实现 Comparable, Serializable 等接口和方法(略)
}
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!