Java集合框架中List与Map的深入解析
1. 集合框架中的List实现
在Java的集合框架里,List接口有三种具体实现,它们在执行接口定义的各种操作的速度以及面对并发修改时的表现各不相同。与Set和Queue不同,List没有子接口来明确功能行为上的差异。下面我们来详细了解这三种实现。
1.1 ArrayList
数组是Java语言的一部分,语法使用起来很方便,但它一旦创建就无法调整大小,这一关键缺点使得List实现越来越受欢迎,因为List实现(如果可调整大小的话)可以无限扩展。实际上,最常用的List实现是ArrayList,它由数组支持。
标准的ArrayList实现将List元素存储在连续的数组位置中,第一个元素始终存储在数组的索引0处。它需要一个足够大(有足够容量)的数组来容纳元素,同时还需要一种方法来跟踪“已占用”位置的数量(即List的大小)。如果ArrayList的大小增长到与它的容量相等,尝试添加另一个元素时,就需要用一个更大的数组来替换原来的数组,这个新数组要能够容纳旧内容和新元素,并且还要有进一步扩展的空间(标准实现实际上使用的新数组长度是旧数组的两倍)。这导致其摊还成本为O(1)。
ArrayList的性能在“随机访问”操作上反映了数组的性能:set和get操作的时间复杂度是常数。不过,数组实现的缺点在于在任意位置插入或删除元素,因为这可能需要调整其他元素的位置。例如,以下代码创建了一个包含三个元素的ArrayList:
List<Character> charList = new ArrayList<Character>();
Collections.addAll(charList, 'a', 'b', 'c');
如果要移除数组中索引为1的元素,实现必须保留其余元素的顺序,并确保数组的占用区域仍然从索引0开始。因此,索引为2的元素必须移动到索引1,索引为3的元素移动到索引2,依此类推。这个操作的时间复杂度与列表的大小成正比(尽管由于这个操作通常可以在硬件中实现,常数因子较低)。
另外,有人可能会疑惑为什么ArrayList的实现没有选择使用循环数组。虽然循环数组的add和remove方法只有在索引参数为0时才表现出更好的性能,但这是一个非常常见的情况,而且使用循环数组的开销很小。实际上,Heinz Kabutz在The Java Specialists’ Newsletter中提出了循环数组列表的概要实现。原则上,ArrayList仍有可能以这种方式重新实现,这可能会为许多现有的Java应用程序带来实际的性能提升。还有一种可能的替代方案是将循环的ArrayDeque进行改造以实现List的方法。如果你的应用程序中,列表开头元素的插入和删除性能比随机访问操作更重要,那么可以考虑使用Deque接口,并利用其高效的ArrayDeque实现。
除了标准的集合框架构造函数外,ArrayList还有一个构造函数允许你选择初始容量的值,使其足够大,以容纳集合的元素,避免频繁的创建 - 复制操作。默认构造函数创建的ArrayList的初始容量是10,用另一个集合的元素初始化的ArrayList的初始容量是该集合大小的110%。ArrayList的迭代器是快速失败的。
1.2 LinkedList
之前我们在讨论Deque实现时提到过LinkedList。如果你的应用程序大量使用随机访问,那么不建议将LinkedList作为List实现,因为列表必须在内部进行迭代才能到达所需位置,平均而言,位置相关的add和remove操作的时间复杂度是线性的。不过,LinkedList在除列表末尾之外的任何位置添加和删除元素方面比ArrayList有性能优势,对于LinkedList来说,这些操作的时间复杂度是常数,而非循环数组实现则需要线性时间。
1.3 CopyOnWriteArrayList
之前我们遇到过CopyOnWriteArraySet,它是一种设计用于提供线程安全和快速读取访问的集合实现。CopyOnWriteArrayList是具有相同设计目标的List实现。这种线程安全与快速读取访问的结合在一些并发程序中很有用,特别是当一组观察者对象需要频繁接收事件通知时。代价是支持集合的数组必须被视为不可变的,因此每当对集合进行任何更改时,都会创建一个新的副本。如果观察者集合的更改很少发生,那么这个代价可能不算太高。
CopyOnWriteArraySet实际上将其所有操作委托给CopyOnWriteArrayList的一个实例,利用后者提供的原子操作addIfAbsent和addAllAbsent,使Set的add和addAll方法能够避免向集合中引入重复元素。除了两个标准构造函数外,CopyOnWriteArrayList还有一个额外的构造函数,允许使用提供的数组元素作为其初始内容来创建它。它的迭代器是快照迭代器,反映了列表在创建时的状态。
1.4 List实现的性能比较
以下表格展示了不同List类在一些示例操作上的性能比较:
| 操作 | ArrayList | LinkedList | CopyOnWriteArrayList |
| ---- | ---- | ---- | ---- |
| get | O(1) | O(n) | O(1) |
| add | O(1) | O(1) | O(n) |
| contains | O(n) | O(n) | O(n) |
| next | O(1) | O(1) | O(1) |
| remove(0) | O(n) | O(1) | O(n) |
| iterator.remove | O(n) | O(1) | O(n) |
在选择List实现时,首先要考虑的问题是你的应用程序是否需要线程安全。如果需要,在写入列表相对不频繁的情况下,可以使用CopyOnWriteArrayList;否则,需要在ArrayList或LinkedList周围使用同步包装器。对于大多数列表应用程序,选择通常在ArrayList和LinkedList之间(无论是否同步)。具体的决策取决于列表在实际中的使用方式。如果set和get操作占主导,或者元素的插入和删除主要在列表末尾,那么ArrayList是最佳选择;如果应用程序需要在迭代过程中频繁在列表开头插入和删除元素,LinkedList可能更合适。如果你不确定,可以对每种实现进行性能测试。对于单线程代码,如果插入和删除操作确实在列表开头,Java 6的一个替代方案是使用Deque接口,并利用其高效的ArrayDeque实现。对于相对不频繁的随机访问,可以使用迭代器,或者使用toArray方法将ArrayDeque元素复制到数组中。
2. 集合框架中的Map接口
Map接口是集合框架中的主要接口之一,也是唯一不继承自Collection的接口。它定义了一组键值关联所支持的操作,这些操作可以分为以下四个组,大致与Collection的四个操作组平行:添加元素、移除元素、查询集合内容以及提供集合内容的不同视图。
2.1 Map的操作方法
- 添加关联
V put(K key, V value) // 添加或替换键值关联,返回旧值(如果键存在,可能为null);否则返回null
void putAll(Map<? extends K,? extends V> m) // 将提供的映射中的每个键值关联添加到接收方
这些操作是可选的,在不可修改的映射上调用这些操作会导致UnsupportedOperationException。
- 移除关联
void clear() // 移除该映射中的所有关联
V remove(Object key) // 移除与给定键的关联(如果存在),返回与该键关联的值,否则返回null
Map.remove的签名与Collection.remove类似,它接受一个Object类型的参数而不是泛型类型。这些移除操作也是可选的。
- 查询映射内容
V get(Object k) // 返回与k对应的的值,如果k不是键,则返回null
boolean containsKey(Object k) // 如果k是键,则返回true
boolean containsValue(Object v) // 如果v是值,则返回true
int size() // 返回关联的数量
boolean isEmpty() // 如果没有关联,则返回true
对于允许null键或值的Map实现,containsKey和containsValue的参数可以为null。不允许null的实现如果接收到null参数,会抛出NullPointerException。与Collection的size方法一样,能报告的最大元素计数是Integer.MAX_VALUE。
- 提供键、值或关联的集合视图
Set<Map.Entry<K, V>> entrySet() // 返回关联的Set视图
Set<K> keySet() // 返回键的Set视图
Collection<V> values() // 返回值的Collection视图
这些方法返回的集合由映射支持,因此对它们的任何更改都会反映在映射本身中,反之亦然。实际上,通过视图只能进行有限的更改:元素可以直接或通过视图的迭代器移除,但不能添加;如果尝试添加,会得到UnsupportedOperationException。移除一个键会移除对应的单个键值关联;移除一个值只会移除映射到它的其中一个关联,该值可能仍然作为与不同键的关联的一部分存在。如果在迭代过程中对支持的映射进行并发修改,视图的迭代器将变得未定义。
2.2 Map方法的使用示例
以任务管理为例,之前基于优先队列的待办事项管理器存在一个问题,即优先队列无法保留元素添加的顺序(除非将其纳入优先级排序,例如作为时间戳或序列号)。为了避免这个问题,我们可以使用一系列FIFO队列,每个队列分配一个单一的优先级。Map非常适合保存优先级和任务队列之间的关联,特别是EnumMap,它是一种专门用于使用枚举成员作为键的高效Map实现。
以下是一个示例代码,假设使用单线程客户端,并使用一系列ArrayDeque作为实现:
import java.util.ArrayDeque;
import java.util.EnumMap;
import java.util.Map;
import java.util.Queue;
enum Priority {
HIGH, MEDIUM, LOW
}
class Task {
// 任务类的具体实现
}
public class TaskManager {
public static void main(String[] args) {
Map<Priority,ArrayDeque<Task>> taskMap =
new EnumMap<Priority,ArrayDeque<Task>>(Priority.class);
for (Priority p : Priority.values()) {
taskMap.put(p, new ArrayDeque<Task>());
}
Task mikePhone = new Task();
Task databaseCode = new Task();
taskMap.get(Priority.MEDIUM).add(mikePhone);
taskMap.get(Priority.HIGH).add(databaseCode);
Queue<Task> highPriorityTaskList = taskMap.get(Priority.HIGH);
// 从高优先级任务队列中获取任务
Task task = highPriorityTaskList.poll();
}
}
为了展示Map的其他方法的使用,我们扩展这个示例,假设有些任务是可计费的,能为我们赚钱。可以定义一个Client类,并创建一个从任务到客户端的映射:
class Client {
String name;
public Client(String name) {
this.name = name;
}
public void bill(Task task) {
// 计费逻辑
}
}
Task interfaceCode = new Task();
Client acme = new Client("Acme Corp.");
Map<Task,Client> billingMap = new HashMap<Task,Client>();
billingMap.put(interfaceCode, acme);
Task t = interfaceCode;
Client client = billingMap.get(t);
if (client != null) {
client.bill(t);
}
当完成了与客户Acme Corp.签订的所有工作后,可以移除与Acme关联的映射条目:
Collection<Client> clients = billingMap.values();
for (Iterator<Client> iter = clients.iterator() ; iter.hasNext() ; ) {
if (iter.next().equals(acme)) {
iter.remove();
}
}
// 更简洁的方式
clients.removeAll(Collections.singleton(acme));
这两种方式的时间复杂度都是O(n),在Sun的当前实现中,常数因子相似。
2.3 Map的实现
集合框架为Map提供了八种实现。这里我们将讨论HashMap、LinkedHashMap、WeakHashMap、IdentityHashMap和EnumMap。对于构造函数,Map实现的一般规则与Collection实现类似。除EnumMap外,每个实现至少有两个构造函数,以HashMap为例:
public HashMap()
public HashMap(Map<? extends K,? extends V> m)
第一个构造函数创建一个空映射,第二个构造函数创建一个包含提供的映射m中键值映射的映射。映射m的键和值的类型必须与要创建的映射的键和值的类型相同(或为其子类型)。使用第二个构造函数与使用默认构造函数创建一个空映射,然后使用putAll方法添加映射m的内容具有相同的效果。此外,标准实现还有其他用于配置目的的构造函数。
通过以上对List和Map的详细介绍,我们可以根据具体的应用场景和需求,选择合适的集合实现,以提高程序的性能和效率。
3. 不同Map实现的详细分析
3.1 HashMap
HashMap 是最常用的 Map 实现之一。它基于哈希表实现,通过键的哈希码来存储和检索元素,因此在大多数情况下,基本操作(如 put 和 get)的时间复杂度为 O(1)。不过,在哈希冲突较多的情况下,性能可能会有所下降。
以下是一个简单的 HashMap 使用示例:
import java.util.HashMap;
import java.util.Map;
public class HashMapExample {
public static void main(String[] args) {
Map<String, Integer> hashMap = new HashMap<>();
hashMap.put("apple", 1);
hashMap.put("banana", 2);
hashMap.put("cherry", 3);
Integer value = hashMap.get("banana");
System.out.println("The value for key 'banana' is: " + value);
}
}
在这个示例中,我们创建了一个 HashMap,存储了水果名称和对应的编号。然后通过键 “banana” 获取了对应的值。
3.2 LinkedHashMap
LinkedHashMap 是 HashMap 的子类,它维护了一个双向链表,用于记录元素的插入顺序或访问顺序。这使得它可以按照插入顺序或访问顺序迭代元素,非常适合需要保留元素顺序的场景。
以下是一个 LinkedHashMap 的使用示例:
import java.util.LinkedHashMap;
import java.util.Map;
public class LinkedHashMapExample {
public static void main(String[] args) {
Map<String, Integer> linkedHashMap = new LinkedHashMap<>();
linkedHashMap.put("apple", 1);
linkedHashMap.put("banana", 2);
linkedHashMap.put("cherry", 3);
for (Map.Entry<String, Integer> entry : linkedHashMap.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
}
}
在这个示例中,元素将按照插入的顺序被迭代输出。
3.3 WeakHashMap
WeakHashMap 使用弱引用作为键。当键所引用的对象被垃圾回收时,对应的键值对会自动从 WeakHashMap 中移除。这在需要自动清理不再使用的键值对的场景中非常有用,例如缓存。
以下是一个 WeakHashMap 的使用示例:
import java.util.WeakHashMap;
import java.util.Map;
public class WeakHashMapExample {
public static void main(String[] args) {
Map<Object, String> weakHashMap = new WeakHashMap<>();
Object key = new Object();
weakHashMap.put(key, "Value");
key = null; // 释放对键对象的引用
System.gc(); // 建议垃圾回收
System.out.println("Size of WeakHashMap: " + weakHashMap.size());
}
}
在这个示例中,当键对象的引用被释放后,经过垃圾回收,WeakHashMap 中的对应键值对会被自动移除。
3.4 IdentityHashMap
IdentityHashMap 比较键时使用的是引用相等(==)而不是对象相等(equals())。这意味着即使两个对象的内容相同,但只要它们不是同一个对象,IdentityHashMap 会将它们视为不同的键。
以下是一个 IdentityHashMap 的使用示例:
import java.util.IdentityHashMap;
import java.util.Map;
public class IdentityHashMapExample {
public static void main(String[] args) {
Map<String, Integer> identityHashMap = new IdentityHashMap<>();
String key1 = new String("key");
String key2 = new String("key");
identityHashMap.put(key1, 1);
identityHashMap.put(key2, 2);
System.out.println("Size of IdentityHashMap: " + identityHashMap.size());
}
}
在这个示例中,虽然 key1 和 key2 的内容相同,但由于它们是不同的对象,IdentityHashMap 会将它们作为两个不同的键存储。
3.5 EnumMap
EnumMap 是专门为使用枚举类型作为键设计的 Map 实现。它内部使用数组来存储元素,因此性能非常高,并且只允许使用枚举类型的键。
以下是一个 EnumMap 的使用示例:
import java.util.EnumMap;
import java.util.Map;
enum Color {
RED, GREEN, BLUE
}
public class EnumMapExample {
public static void main(String[] args) {
Map<Color, String> enumMap = new EnumMap<>(Color.class);
enumMap.put(Color.RED, "Red color");
enumMap.put(Color.GREEN, "Green color");
enumMap.put(Color.BLUE, "Blue color");
for (Map.Entry<Color, String> entry : enumMap.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
}
}
在这个示例中,我们使用 Color 枚举作为键,存储了对应的颜色描述。
4. 选择合适的集合实现
在实际开发中,选择合适的集合实现对于程序的性能和效率至关重要。以下是一个选择集合实现的流程图:
graph TD;
A[是否需要线程安全?] -->|是| B[是否写操作较少?];
B -->|是| C[使用 CopyOnWriteArrayList 或 ConcurrentMap 实现];
B -->|否| D[使用同步包装器];
A -->|否| E[选择非线程安全实现];
E --> F[是否主要是随机访问?];
F -->|是| G[使用 ArrayList 或 HashMap];
F -->|否| H[是否需要保留元素顺序?];
H -->|是| I[使用 LinkedList 或 LinkedHashMap];
H -->|否| J[根据其他需求选择合适实现];
另外,为了更直观地比较不同集合实现的特点,我们可以参考以下表格:
| 集合类型 | 线程安全 | 插入/删除性能 | 随机访问性能 | 元素顺序 | 适用场景 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| ArrayList | 否 | 末尾操作快,中间操作慢 | 快 | 插入顺序 | 主要进行随机访问,插入删除主要在末尾 |
| LinkedList | 否 | 中间操作快 | 慢 | 插入顺序 | 频繁在中间插入删除元素 |
| CopyOnWriteArrayList | 是 | 慢 | 快 | 插入顺序 | 读多写少的并发场景 |
| HashMap | 否 | 平均快 | 快 | 无顺序 | 快速查找和插入,不关心元素顺序 |
| LinkedHashMap | 否 | 平均快 | 快 | 插入或访问顺序 | 需要保留元素顺序的场景 |
| WeakHashMap | 否 | 平均快 | 快 | 无顺序 | 自动清理不再使用的键值对 |
| IdentityHashMap | 否 | 平均快 | 快 | 无顺序 | 根据引用相等来存储键 |
| EnumMap | 否 | 快 | 快 | 枚举顺序 | 使用枚举作为键的场景 |
通过这个表格和流程图,我们可以根据具体的需求和场景,更准确地选择合适的集合实现,从而提高程序的性能和可维护性。
综上所述,Java 集合框架提供了丰富的集合实现,每种实现都有其独特的特点和适用场景。在开发过程中,我们需要根据具体的需求,仔细分析和选择合适的集合实现,以达到最佳的性能和效率。同时,合理使用集合的各种方法和特性,也能让我们的代码更加简洁和高效。
超级会员免费看
935

被折叠的 条评论
为什么被折叠?



