Java集合框架完全指南:从入门到源码,一篇讲透List、Set、Map
集合,是每个Java程序员的必修课。无论你是刚入行的新手,还是工作多年的老鸟,集合框架都是每天都要打交道的工具。但是你真的了解它们吗?
ArrayList和LinkedList的区别,你能说出几条?HashMap的扩容机制,你能讲清楚吗?ConcurrentHashMap为什么线程安全?这篇文章,我会从最基础的用法开始,逐步深入到源码原理,带你彻底搞懂Java集合框架。
一、什么是集合框架
在讲具体实现之前,我们先理解一下:为什么需要集合?
假设你要存储一组学生的姓名。用数组可以这样写:
java
String[] students = new String[10]; students[0] = "张三";
但数组有个明显的限制:长度固定。如果你不确定会有多少个学生,数组就不太合适了。
集合就是用来解决这个问题的。它是Java提供的一套动态存储对象的容器,可以根据需要自动扩容,还提供了丰富的操作接口。
整个集合框架分为两大体系:
-
Collection:存储单个元素的集合
-
Map:存储键值对的集合
下面我们分别讲解。
二、Collection 体系详解
Collection是所有单元素集合的根接口,它有三个主要的子接口:List、Set、Queue。
2.1 List:有序可重复的序列
List的特点是:元素有顺序、可以重复、可以通过索引访问。
2.1.1 ArrayList
底层实现:数组
ArrayList就是Java对动态数组的实现。当你创建一个ArrayList时,它内部会创建一个数组来存储元素。
java
// 创建一个ArrayList
List<String> list = new ArrayList<>();
// 添加元素
list.add("苹果"); // 追加到末尾
list.add(0, "香蕉"); // 在指定位置插入
// 获取元素
String fruit = list.get(0);
// 修改元素
list.set(1, "橘子");
// 删除元素
list.remove(0); // 按索引删除
list.remove("苹果"); // 按元素删除
// 获取大小
int size = list.size();
// 判断是否包含
boolean hasApple = list.contains("苹果");
// 遍历方式1:普通for循环
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
// 遍历方式2:增强for循环
for (String s : list) {
System.out.println(s);
}
// 遍历方式3:forEach + Lambda(Java 8)
list.forEach(s -> System.out.println(s));
核心特点:
-
随机访问速度极快,通过索引
get(i)和set(i, e)的时间复杂度是O(1) -
在末尾添加元素
add(e)很快,平均时间复杂度O(1) -
在中间插入或删除元素较慢,因为需要移动后续元素,时间复杂度O(n)
扩容机制:
当数组容量不够时,ArrayList会自动扩容。默认初始容量是10,每次扩容为原来的1.5倍。
java
// 你可以指定初始容量,避免频繁扩容 List<String> list = new ArrayList<>(100);
使用场景:
-
频繁通过索引访问元素
-
主要在末尾添加元素
-
不需要线程安全
一个常见误区:很多人认为ArrayList只适合数据量小的场景。实际上,ArrayList的查询性能非常优秀,即使在百万级数据下,随机访问也是毫秒级响应。
2.1.2 LinkedList
底层实现:双向链表
LinkedList内部是由一个个节点连接而成的链表。每个节点包含三个部分:前一个节点的引用、当前节点的值、后一个节点的引用。
java
LinkedList<String> list = new LinkedList<>();
// 添加元素(ArrayList也有的方法)
list.add("苹果"); // 追加到末尾
list.add(0, "香蕉"); // 插入到指定位置
// LinkedList特有的方法
list.addFirst("草莓"); // 在头部添加
list.addLast("西瓜"); // 在尾部添加
// 获取元素
String first = list.getFirst();
String last = list.getLast();
// 删除元素
list.removeFirst();
list.removeLast();
核心特点:
-
在头部和尾部添加、删除元素非常快,时间复杂度O(1)
-
在中间位置插入或删除,需要先遍历找到该位置,时间复杂度O(n)
-
随机访问较慢,需要从头或尾遍历,时间复杂度O(n)
实现Queue和Deque接口:
LinkedList同时实现了Queue和Deque接口,可以作为队列或双端队列使用。
java
// 作为队列使用(FIFO)
Queue<String> queue = new LinkedList<>();
queue.offer("任务1"); // 入队
queue.offer("任务2");
String task = queue.poll(); // 出队
// 作为栈使用(LIFO)
Deque<String> stack = new LinkedList<>();
stack.push("方法1"); // 压栈
stack.push("方法2");
String method = stack.pop(); // 弹栈
使用场景:
-
需要频繁在头部和尾部操作
-
实现队列或栈结构
-
元素数量变化频繁,但不需要随机访问
2.1.3 ArrayList vs LinkedList 深度对比
| 维度 | ArrayList | LinkedList |
|---|---|---|
| 底层数据结构 | 数组 | 双向链表 |
| 内存占用 | 连续内存,可能浪费少量空间(预留容量) | 分散内存,每个节点多存储两个指针 |
| 尾部添加 | O(1) 均摊 | O(1) |
| 头部添加 | O(n) | O(1) |
| 中间插入 | O(n) | O(n)(遍历成本高,但插入本身O(1)) |
| 随机访问get/set | O(1) | O(n) |
| 按值删除 | O(n)(先找再删) | O(n)(先找再删) |
实际测试结论:
-
对于百万级数据,ArrayList的遍历速度约是LinkedList的3-5倍
-
在头部频繁插入时,LinkedList有明显优势
-
大部分业务场景下,ArrayList表现更好,因为现代CPU对连续内存的缓存命中率更高
选型建议:
-
默认选择ArrayList
-
只有当你确定需要频繁在头部操作,或需要实现队列时,才考虑LinkedList
2.1.4 Vector(了解即可)
Vector是Java早期的动态数组实现,和ArrayList非常相似,但它是线程安全的。
java
Vector<String> vector = new Vector<>();
vector.add("元素");
与ArrayList的区别:
-
Vector的方法都加了
synchronized,线程安全但性能较差 -
Vector扩容时默认翻倍,ArrayList是1.5倍
在实际开发中,基本不再使用Vector。如果需要线程安全的List,可以使用:
java
List<String> list = Collections.synchronizedList(new ArrayList<>());
或者使用CopyOnWriteArrayList(适用于读多写少的场景)。
2.2 Set:元素不可重复的集合
Set的特点是:元素不重复。它就像数学中的集合,可以执行并集、交集等操作。
2.2.1 HashSet
底层实现:HashMap
HashSet是最常用的Set实现。你可能很好奇:Set是单元素集合,为什么底层是Map?答案是:HashSet把元素作为Map的Key,Value用一个固定的占位对象填充。
java
Set<String> set = new HashSet<>();
// 添加元素
set.add("苹果");
set.add("香蕉");
set.add("苹果"); // 返回false,添加失败,因为已存在
// 删除元素
set.remove("苹果");
// 判断是否包含
boolean hasBanana = set.contains("香蕉");
// 获取大小
int size = set.size();
// 遍历
for (String s : set) {
System.out.println(s);
}
核心特点:
-
元素无序(不保证遍历顺序和插入顺序一致)
-
元素不重复
-
允许一个null值
-
添加、删除、查找的时间复杂度都是O(1)(假设哈希函数良好)
如何保证元素不重复:
当你调用add(e)时,HashSet会调用HashMap的put(e, PRESENT)方法。HashMap判断两个key是否相等的规则是:
-
先比较两个key的hashCode是否相等
-
如果hashCode相等,再比较equals是否相等
因此,存入HashSet的元素必须正确重写hashCode和equals方法。
使用场景:
-
需要去重,不关心顺序
-
快速查找某个元素是否存在
2.2.2 LinkedHashSet
底层实现:LinkedHashMap
LinkedHashSet是HashSet的子类,它在HashSet的基础上维护了一个双向链表,用来记录元素的插入顺序。
java
Set<String> set = new LinkedHashSet<>();
set.add("香蕉");
set.add("苹果");
set.add("橘子");
System.out.println(set); // 输出:[香蕉, 苹果, 橘子](保持插入顺序)
核心特点:
-
维护插入顺序(或访问顺序,需配置)
-
性能略低于HashSet
-
其他特性和HashSet一样
使用场景:
-
需要去重,同时需要保持元素的插入顺序
2.2.3 TreeSet
底层实现:TreeMap(红黑树)
TreeSet是有序的Set实现。元素会按照自然顺序,或者你指定的Comparator进行排序。
java
// 自然顺序(数字从小到大,字母从A到Z)
Set<Integer> numbers = new TreeSet<>();
numbers.add(5);
numbers.add(1);
numbers.add(3);
System.out.println(numbers); // 输出:[1, 3, 5]
// 自定义排序(倒序)
Set<String> names = new TreeSet<>((a, b) -> b.compareTo(a));
names.add("张三");
names.add("李四");
names.add("王五");
System.out.println(names); // 输出:[王五, 李四, 张三]
TreeSet提供了一些额外的方法:
java
TreeSet<Integer> set = new TreeSet<>(); set.add(1); set.add(3); set.add(5); set.add(7); set.add(9); // 获取第一个和最后一个 int first = set.first(); // 1 int last = set.last(); // 9 // 获取大于等于给定值的最小元素 int ceiling = set.ceiling(4); // 5 // 获取小于等于给定值的最大元素 int floor = set.floor(4); // 3 // 获取严格大于给定值的最小元素 int higher = set.higher(5); // 7 // 获取严格小于给定值的最大元素 int lower = set.lower(5); // 3 // 获取子集 Set<Integer> subSet = set.subSet(3, 8); // [3, 5, 7]
核心特点:
-
元素自动排序
-
元素不可重复
-
不能包含null(除非Comparator允许)
-
添加、删除、查找的时间复杂度都是O(log n)
使用场景:
-
需要元素自动排序
-
需要进行范围查找(如查询某个区间的数据)
-
需要获取最大/最小值
2.3 Queue:队列
Queue是Java中的队列接口,遵循FIFO(先进先出)原则。
2.3.1 ArrayDeque
ArrayDeque是双端队列(Deque)的实现,底层使用循环数组。
java
// 作为队列使用
Queue<String> queue = new ArrayDeque<>();
queue.offer("第一个人");
queue.offer("第二个人");
queue.offer("第三个人");
String first = queue.poll(); // 第一个人
String peek = queue.peek(); // 第二个人(查看不移除)
// 作为双端队列使用
Deque<String> deque = new ArrayDeque<>();
deque.addFirst("头部元素");
deque.addLast("尾部元素");
String head = deque.removeFirst();
String tail = deque.removeLast();
核心特点:
-
比LinkedList更节省内存(不需要存储节点指针)
-
不允许存储null
-
作为队列使用时,性能优于LinkedList
-
不支持并发访问
使用场景:
-
实现队列(FIFO)
-
实现栈(LIFO)—— 推荐用ArrayDeque替代Stack类
2.3.2 PriorityQueue
PriorityQueue是优先级队列,元素按照优先级出队,而不是按照插入顺序。
java
// 最小堆(默认) PriorityQueue<Integer> minHeap = new PriorityQueue<>(); minHeap.offer(5); minHeap.offer(1); minHeap.offer(3); minHeap.offer(8); System.out.println(minHeap.poll()); // 1(最小值) System.out.println(minHeap.poll()); // 3 System.out.println(minHeap.poll()); // 5 // 最大堆(自定义比较器) PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a); maxHeap.offer(5); maxHeap.offer(1); maxHeap.offer(3); System.out.println(maxHeap.poll()); // 5(最大值)
核心特点:
-
底层是二叉堆(默认最小堆)
-
时间复杂度:入队O(log n)、出队O(log n)、查看队首O(1)
-
不保证元素顺序,只保证队首是最大或最小
-
不允许null
-
不是线程安全的
使用场景:
-
任务调度(优先级高的先执行)
-
求Top K元素
-
数据流中的中位数问题
三、Map 体系详解
Map存储的是键值对(Key-Value),通过Key可以快速找到对应的Value。Key必须唯一,Value可以重复。
3.1 HashMap
HashMap是最常用的Map实现,也是面试的重点。
java
Map<String, Integer> map = new HashMap<>();
// 添加或修改
map.put("苹果", 5); // 苹果对应5
map.put("香蕉", 3);
map.put("苹果", 10); // 覆盖,苹果变成10
// 获取
Integer count = map.get("苹果"); // 10
Integer defaultValue = map.getOrDefault("橘子", 0); // 0
// 检查是否存在
boolean hasApple = map.containsKey("苹果"); // true
boolean hasValue = map.containsValue(10); // true
// 删除
map.remove("香蕉");
// 遍历方式1:遍历Entry(推荐)
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println(key + " = " + value);
}
// 遍历方式2:遍历Key
for (String key : map.keySet()) {
Integer value = map.get(key);
}
// 遍历方式3:遍历Value
for (Integer value : map.values()) {
System.out.println(value);
}
// 遍历方式4:forEach + Lambda(Java 8)
map.forEach((key, value) -> System.out.println(key + " = " + value));
核心原理
底层数据结构:
Java 8之后,HashMap的底层是:数组 + 链表 + 红黑树
-
默认初始容量:16
-
默认负载因子:0.75
-
当链表长度达到8且数组长度达到64时,链表转换为红黑树
-
当红黑树节点数降为6时,退化为链表
put方法的执行流程:
-
计算key的hash值
-
根据hash值计算数组索引位置
-
如果该位置为空,直接放入
-
如果该位置不为空,判断是链表还是红黑树,分别处理
-
如果key已存在,覆盖旧值
-
如果key不存在,插入新节点
-
如果size超过阈值,进行扩容
为什么负载因子是0.75:
这是时间和空间的平衡。负载因子越大,空间利用率越高,但哈希冲突概率增加,查询效率降低。0.75是经过大量测试得出的最优值。
为什么容量必须为2的幂:
因为计算索引时用的是(n - 1) & hash,当n为2的幂时,n-1的低位全是1,这样hash值的高位也能参与运算,减少哈希冲突。
扩容机制
当元素个数超过容量 * 负载因子时,HashMap会扩容为原来的2倍。
扩容时,旧数组中的元素需要重新计算索引位置。Java 8对此做了优化:重新计算后,元素要么留在原位置,要么移动到原位置 + 原容量的位置,不需要重新计算hash。
使用场景:
-
绝大多数需要键值对的场景
-
不需要线程安全
-
不关心元素顺序
3.2 LinkedHashMap
LinkedHashMap继承自HashMap,它在HashMap的基础上维护了一个双向链表,用来记录键值对的顺序。
java
// 保持插入顺序
Map<String, Integer> map = new LinkedHashMap<>();
map.put("香蕉", 3);
map.put("苹果", 5);
map.put("橘子", 2);
System.out.println(map.keySet()); // 输出:[香蕉, 苹果, 橘子]
两种顺序模式:
-
插入顺序(默认):按照元素插入的顺序遍历
-
访问顺序:按照元素被访问的顺序遍历,最近访问的放到最后
java
// 使用访问顺序模式
Map<String, Integer> lruMap = new LinkedHashMap<>(16, 0.75f, true);
lruMap.put("A", 1);
lruMap.put("B", 2);
lruMap.put("C", 3);
lruMap.get("A"); // 访问A,A被移到最后
System.out.println(lruMap.keySet()); // 输出:[B, C, A]
实现LRU缓存:
利用LinkedHashMap的访问顺序模式,可以轻松实现LRU(最近最少使用)缓存:
java
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(maxSize, 0.75f, true);
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
}
// 使用
LRUCache<String, String> cache = new LRUCache<>(3);
cache.put("key1", "value1");
cache.put("key2", "value2");
cache.put("key3", "value3");
cache.put("key4", "value4"); // key1被移除
使用场景:
-
需要保持元素的插入顺序
-
实现LRU缓存
3.3 TreeMap
TreeMap是基于红黑树实现的有序Map。键会自动排序(自然顺序或自定义顺序)。
java
// 自然顺序
Map<String, Integer> map = new TreeMap<>();
map.put("张三", 25);
map.put("李四", 30);
map.put("王五", 28);
System.out.println(map.keySet()); // [张三, 李四, 王五](按拼音排序)
// 自定义顺序(按照年龄降序排序键)
TreeMap<Integer, String> ageMap = new TreeMap<>((a, b) -> b - a);
TreeMap提供了丰富的方法:
java
TreeMap<Integer, String> map = new TreeMap<>();
map.put(1, "a");
map.put(3, "c");
map.put(5, "e");
map.put(7, "g");
map.put(9, "i");
// 获取第一个和最后一个
Map.Entry<Integer, String> first = map.firstEntry(); // 1=a
Map.Entry<Integer, String> last = map.lastEntry(); // 9=i
// 获取小于等于给定key的最大key
Map.Entry<Integer, String> floor = map.floorEntry(4); // 3=c
// 获取大于等于给定key的最小key
Map.Entry<Integer, String> ceiling = map.ceilingEntry(4); // 5=e
// 获取子Map(前闭后开)
SortedMap<Integer, String> subMap = map.subMap(3, 8); // {3=c,5=e,7=g}
为什么使用红黑树:
红黑树是一种自平衡二叉查找树,能够保证:
-
查找、插入、删除的时间复杂度都是O(log n)
-
能够保持元素的有序性
使用场景:
-
需要键自动排序
-
需要进行范围查找(如查询某个区间的数据)
-
需要获取最大键、最小键
3.4 ConcurrentHashMap
ConcurrentHashMap是线程安全的HashMap实现。
核心特点:
-
读操作无锁,写操作只锁当前桶
-
支持高并发读写
-
不允许null的key和value
与Hashtable的区别:
-
Hashtable是直接在方法上加
synchronized,锁整个表,并发性能差 -
ConcurrentHashMap使用更精细的锁机制,并发性能好
java
// 创建ConcurrentHashMap
Map<String, Integer> map = new ConcurrentHashMap<>();
// 线程安全的各种操作
map.put("key", 1);
map.get("key");
map.remove("key");
// 原子操作
map.putIfAbsent("key", 100); // 如果key不存在才放入
// 并发安全的遍历
map.forEach((key, value) -> System.out.println(key + "=" + value));
使用场景:
-
多线程环境下需要使用Map
-
性能要求较高的场景
四、Collections工具类
Collections是Java提供的集合工具类,提供了很多实用的静态方法。
java
// 排序(List必须是可排序的,元素实现了Comparable)
Collections.sort(list);
// 自定义排序
Collections.sort(list, (a, b) -> b.compareTo(a));
// 反转
Collections.reverse(list);
// 随机打乱
Collections.shuffle(list);
// 二分查找(必须先排序)
int index = Collections.binarySearch(list, "查找的元素");
// 最值
Integer max = Collections.max(list);
Integer min = Collections.min(list);
// 线程安全包装
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
// 不可变集合
List<String> immutableList = Collections.unmodifiableList(list);
Set<String> immutableSet = Collections.unmodifiableSet(set);
Map<String, String> immutableMap = Collections.unmodifiableMap(map);
// Java 9+ 简化的不可变集合
List<String> list = List.of("a", "b", "c");
Set<String> set = Set.of("a", "b", "c");
Map<String, String> map = Map.of("k1", "v1", "k2", "v2");
// 空集合
List<String> emptyList = Collections.emptyList();
Set<String> emptySet = Collections.emptySet();
Map<String, String> emptyMap = Collections.emptyMap();
// 频率和替换
int frequency = Collections.frequency(list, "a");
Collections.replaceAll(list, "旧值", "新值");
五、选型速查表
| 需求场景 | 推荐集合 | 理由 |
|---|---|---|
| 存储列表,频繁随机访问 | ArrayList | O(1)的随机访问,内存连续 |
| 存储列表,频繁头尾操作 | LinkedList/ArrayDeque | 头尾操作O(1) |
| 实现队列 | ArrayDeque | 性能好,内存省 |
| 实现栈 | ArrayDeque | 比Stack好 |
| 元素去重,不关顺序 | HashSet | O(1)的查找性能 |
| 元素去重,需保持顺序 | LinkedHashSet | 保持插入顺序 |
| 元素去重,需排序 | TreeSet | 自动排序 |
| 键值对,不关顺序 | HashMap | 通用性能最好 |
| 键值对,需保持插入顺序 | LinkedHashMap | 保持插入顺序 |
| 键值对,需排序 | TreeMap | 自动排序 |
| 键值对,多线程环境 | ConcurrentHashMap | 高并发安全 |
| 优先级任务 | PriorityQueue | 按优先级处理 |
| LRU缓存 | LinkedHashMap | 内置访问顺序模式 |
六、常见面试题速答
Q1:ArrayList和LinkedList的区别?
底层:数组 vs 双向链表;随机访问:O(1) vs O(n);头尾操作:O(n) vs O(1);内存:连续 vs 分散。
Q2:HashSet如何保证元素不重复?
底层是HashMap,元素作为key存入,value是固定对象。HashMap通过hashCode和equals判断key是否重复。
Q3:HashMap的扩容机制是什么?
当size > 容量 * 0.75时,容量翻倍,重新计算每个元素的位置。Java 8优化后,元素要么在原位置,要么在原位置+原容量。
Q4:ConcurrentHashMap为什么线程安全?
JDK 1.8中,使用CAS + synchronized,只锁住当前桶的头节点,读操作无锁,并发性能好。
Q5:TreeMap和HashMap的区别?
TreeMap有序(红黑树),HashMap无序;TreeMap时间复杂度O(log n),HashMap O(1);TreeMap支持范围查询,不支持null key(除非特殊处理)。
Q6:LinkedHashMap如何实现LRU?
构造函数中设置accessOrder=true,开启访问顺序模式,重写removeEldestEntry方法,当size超过限制时移除最老元素。
Q7:为什么HashMap的容量必须是2的幂?
为了用按位与运算替代取模运算,提高性能,同时让哈希值的高位也能参与索引计算,减少冲突。
Q8:fail-fast和fail-safe的区别?
fail-fast:检测到并发修改立即抛异常(如HashMap迭代时);fail-safe:在副本上操作,不抛异常(如ConcurrentHashMap)。
七、写在最后
集合框架是Java编程的基础,也是面试的重点。掌握好它们,不仅能写出更高效的代码,也能在面试中更有底气。
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发。
更多推荐
所有评论(0)