01-Java基础-集合框架 (Collections)
1. 集合框架顶层结构
Java 的集合框架主要由两大顶层接口构成:Collection 和 Map,它们是相互独立的。
Collection接口: 是处理单个元素集合的根接口。它有三个主要的子接口:
List: 有序、可重复的集合。
Set: 无序(通常)、不可重复的集合。
Queue: 先进先出 (FIFO) 的集合。
Map接口: 是处理键值对(key-value)集合的根接口。
它们的继承关系可以简化为如下结构:
Java Collections Framework
├── Collection (接口)
│ ├── List (接口)
│ │ ├── ArrayList (实现类)
│ │ └── LinkedList (实现类)
│ ├── Set (接口)
│ │ ├── HashSet (实现类)
│ │ └── TreeSet (实现类)
│ └── Queue (接口, 先进先出)
│ ├── Deque (接口, 双端队列)
│ │ ├── ArrayDeque (实现类)
│ │ └── LinkedList (实现类, 也实现了List和Deque)
│ └── PriorityQueue (实现类)
│
└── Map (接口)
├── HashMap (实现类)
├── TreeMap (实现类)
└── LinkedHashMap (实现类)
理解这个顶层设计,是掌握集合框架的第一步。
2. 核心接口理论
List (列表)
- 定义: 一个有序的集合,也称为序列。
- 核心特性:
- 有序性: 元素存入和取出的顺序是一致的,每个元素都有其对应的索引。
- 可重复: 允许存入多个内容相同的元素。
- 主要实现类:
ArrayList: 基于动态数组实现,查询快,增删慢。
LinkedList: 基于双向链表实现,增删快,查询慢。
List (列表) - ArrayList vs. LinkedList 深度对比 (高频面试题)
ArrayList 和 LinkedList 是 List 接口最常用的两个实现类。List 的核心特性是有序、可重复。而这两个实现类的区别在于其底层数据结构,这导致了它们在性能和使用场景上有着本质的不同。
|
特性 |
|
|
|
底层数据结构 |
动态数组 ( |
双向链表 (Node) |
|
随机访问 (get) |
O(1) (极快) |
O(N) (较慢) |
|
头部插入/删除 |
O(N) (慢) |
O(1) (极快) |
|
尾部插入 |
O(1) (摊还分析) |
O(1) (极快) |
|
中间插入/删除 |
O(N) (慢) |
O(N) (较慢) |
|
内存占用 |
相对较小,连续内存 |
较大,有额外指针开销 |
详细分析
ArrayList (动态数组实现)
- 优点:
- 查询极快: 由于其底层是数组,内存地址是连续的,可以通过索引直接计算出元素地址,实现
O(1)级别的随机访问 (get(index))。这是它最大的优势。
- 缺点:
- 增删慢:
- 中间插入/删除 (
add(index, e),remove(index)): 需要批量移动后续所有元素,时间复杂度为O(N)。
- 扩容开销: 在末尾添加元素时,如果数组容量不足,会触发扩容。扩容过程需要创建一个更大的新数组,并将所有旧数组元素复制过去,这是一个
O(N)的操作。不过,由于不是每次添加都扩容,所以平均(摊还)时间复杂度仍为O(1)。
LinkedList (双向链表实现)
- 优点:
- 头尾增删极快: 由于其底层是双向链表,它持有指向头部和尾部节点的引用。因此,在列表的头部或尾部进行添加/删除操作 (
addFirst,removeLast等) 只需要修改几个节点的指针,时间复杂度是O(1)。这使它非常适合实现队列 (Queue) 和栈 (Stack)。
- 缺点:
- 查询慢: 无法通过索引直接访问。要
get(index)一个元素,必须从头部或尾部(取决于index更靠近哪一端)开始,沿着链表一个个地遍历,时间复杂度为O(N)。
- 中间增删也需要 O(N): 很多人误以为
LinkedList中间增删快,但这是一个误区。虽然插入/删除节点本身的操作(修改指针)是O(1),但首先你需要遍历找到那个要操作的节点,这个查找过程是O(N)。所以整体时间复杂度仍然是O(N)。
总结与选择建议
- 优先选择
ArrayList: 在绝大多数业务场景中,对列表的遍历和随机读取操作远比在中间位置插入/删除要频繁得多。ArrayList的O(1)随机访问性能和更好的 CPU 缓存局部性,使其成为默认的首选。
- 选择
LinkedList的场景:
- 当你的应用需要大量在列表的头部和尾部进行增删操作时,
LinkedList是最佳选择。
- 当你需要实现一个队列 (Queue) 或栈 (Stack) 时,
LinkedList(或更推荐的ArrayDeque) 是非常合适的。
简单来说:需要高效率的随机访问,用 ArrayList;需要频繁地在头尾增删,用 LinkedList。
Set (集合)
- 定义: 一个不包含重复元素的集合。
- 核心特性:
- 唯一性: 不允许存入重复的元素。
- 无序性: 大多数实现类不保证元素的存取顺序(
LinkedHashSet除外)。
- 主要实现类:
HashSet: 基于HashMap实现,通过哈希表来保证元素的唯一性,存取速度快。
Map (映射)
- 定义: 一个存储**键值对(key-value)**的集合。
- 核心特性:
- 键唯一:
Map中的Key是唯一的,不能重复。
- 值可重复: 不同的
Key可以对应相同的Value。
- 主要实现类:
HashMap: 基于哈希表实现,提供了快速的查找、插入和删除操作。
3. 深度剖析:重要实现类
3.1 HashMap 底层原理 (JDK 1.8)
HashMap 的底层数据结构是 数组 + 链表 / 红黑树。
- 核心思想: 通过哈希算法快速定位数据。
put流程: 计算哈希 -> 定位桶 -> 空桶则直接放入 -> 非空则判断 key 是否相同(相同则覆盖) -> key 不同则追加到链表或红黑树末尾 -> 检查是否需要树化或扩容。
- 树化时机: 当一个哈希桶中的链表长度达到 8,并且数组的总容量大于等于 64 时,这个链表才会转化为红黑树。如果数组长度小于64,会优先选择扩容数组来解决冲突。
- 面试回答:首先,根据 key 的 hashCode() 计算出一个哈希值,并进一步处理后,定位到它在底层数组中的“桶”的位置。
- 如果这个桶是空的,就直接把新的键值对作为一个节点放进去。
- 如果桶不为空,说明发生了哈希冲突,这时会判断桶中的第一个节点的 key 是否与当前 key 相同。
- 如果相同,就用新的 value 覆盖旧的 value。
- 如果不同,就会判断当前桶的数据结构是链表还是红黑树。
- 如果是链表,就会遍历链表,如果找到 key 相同的节点就覆盖 value,如果找不到,就把新的键值对追加到链表的末尾。
- 如果是红黑树,就按照红黑树的方式插入新节点。
- 在将新节点添加到链表后,会检查链表长度。如果链表长度大于等于 8,并且 HashMap 的总容量大于等于64,就会将这条链表转换为红黑树,以提高后续的查询效率。如果容量小于 64,则会优先选择扩容而不是树化。
- 最后,在 put 操作完成后,会检查 HashMap 的总元素数量是否超过了扩容阈值,如果超过,就会进行扩容。
- 关键概念: 加载因子 (0.75), 扩容阈值, 树化阈值 (8)。
hashCode() 与 equals() 的协定
HashMap 能正确工作,完全依赖其 Key 对象正确实现了 hashCode() 和 equals() 方法。它们之间有严格的“协定”或“合同”:
equals相等,hashCode必相等: 如果a.equals(b)为true,那么a.hashCode()必须等于b.hashCode()。
hashCode不等,equals必不等: 如果a.hashCode()不等于b.hashCode(),那么a.equals(b)必须为false。
hashCode相等,equals不一定相等: 如果a.hashCode()等于b.hashCode(),a.equals(b)可能为true也可能为false。这种情况就是哈希冲突。
只重写 equals() 不重写 hashCode() 的问题
这是一个经典的面试陷阱。如果一个类只重写了 equals()(比如根据对象的属性判断相等),而没有重写 hashCode(),就会破坏上述第一条协定。
- 后果: 导致两个“相等”对象的
hashCode基本不可能相等。
- 具体表现: 在
HashMap中put(key1, value)后,用equals相等的key2去get,会因为hashCode不同而找不到,返回null。
结论:为了保证 HashMap、HashSet 等哈希集合的正确性,重写 equals() 时,必须同时重写 hashCode()。
3.2 ConcurrentHashMap 原理 (JDK 1.8)
- 生活比喻: 一个有多功能窗口的自助餐厅。取沙拉的、拿披萨的、打饮料的(不同的哈希桶)互不干扰,只有当两个人同时要拿同一块披萨时,才需要稍微排一下队(锁住当前桶)。这比只有一个窗口的食堂(
Hashtable)效率高得多。
ConcurrentHashMap 是线程安全且高性能的 HashMap 版本,是面试重中之重。
- 线程安全机制: 采用了更细粒度的
CAS+synchronized锁。
put流程中的并发控制:
- 桶为空时,用
CAS无锁添加。
- 桶不为空,则
synchronized锁住该桶的头节点,锁的粒度仅限于当前的桶,实现高并发。
get操作: 通常是无锁的,通过volatile保证可见性。
三种线程安全 Map 对比
|
特性 |
|
|
|
|
锁机制 |
|
|
|
|
锁粒度 |
全局锁 (锁整个对象) |
全局锁 (锁整个对象) |
分段锁 (锁哈希桶的头节点) |
|
性能 |
低,所有操作互斥 |
低,所有操作互斥 |
高,允许多线程并发写 |
|
迭代器 |
Fail-Fast |
Fail-Fast |
弱一致性 (Weakly Consistent) |
|
Null 支持 |
Key/Value 均不允许 |
Key/Value 均允许 (取决于包装的 Map) |
Key/Value 均不允许 |
结论: ConcurrentHashMap 以其优秀的并发性能和设计,成为多线程环境下使用 Map 的首选。
3.3 其他 Map/Set 实现
LinkedHashMap:
- 生活比喻: 就像我们的音乐播放列表。你按什么顺序添加歌曲,它就按什么顺序播放,保证了“先进先出”的顺序。
- 技术实现: 继承自
HashMap,额外维护了一个双向链表,用于记录元素的插入顺序。
TreeMap:
- 生活比喻: 就像一部手机的通讯录。不管你什么时候添加“张三”、“李四”还是“王五”,通讯录总是会按姓氏的字母顺序(A-Z)给你排好,方便你查找。
- 技术实现: 底层是红黑树。能根据键(Key)的自然顺序(要求 Key 实现
Comparable接口)或自定义顺序(构造时传入Comparator)进行排序。
HashMap vs LinkedHashMap 对比
|
特性 |
|
|
|
底层原理 |
哈希表 (数组+链表/红黑树) |
哈希表 + 双向链表 |
|
顺序 |
无序 |
有序 (按插入顺序或访问顺序) |
|
性能 |
略高 (无维护链表的开销) |
略低 (需维护双向链表) |
|
用例 |
无需顺序的快速存取 |
需按插入顺序遍历的场景、实现 LRU 缓存 |
LinkedHashSet/TreeSet: 分别是Set接口的另外两个重要实现,底层分别由LinkedHashMap和TreeMap支持。
4. 迭代器 (Iterator) 与 Fail-Fast 机制 (高频面试题)
这是一个非常经典且高频的 Java 面试题,能很好地考察面试者对 Java 集合底层工作原理的理解深度。
4.1 什么是 Fail-Fast?
Fail-Fast (快速失败) 是 Java 集合(如 ArrayList, HashMap)中的一种错误检测机制。
当一个迭代器正在遍历集合时,如果该集合的结构被迭代器自身以外的方式修改了(例如,另一个线程或当前线程直接调用集合的 add/remove 方法),迭代器会尽最大努力、并立即抛出 ConcurrentModificationException(并发修改异常) 异常。
这种机制的目的是为了尽早地暴露问题,避免在后续操作中因数据不一致而导致无法预期的、非确定性的行为。
4.2 Fail-Fast (快速失败) 的底层原理
ArrayList (动态数组), HashMap (哈希表) 等非并发集合的快速失败机制都是通过一个名为 modCount (modification count, 修改计数) 的内部变量实现的。
- 记录修改次数:
modCount(修改计数) 是集合的一个成员变量,每当集合的结构发生改变(如调用add(添加),remove(移除) 等方法),modCount的值就会+1。
- 创建迭代器时保存状态: 当你通过
collection.iterator()获取一个迭代器时,迭代器内部会创建一个expectedModCount(预期修改计数) 变量,并将其值初始化为当前集合的modCount(修改计数) 值。
- 遍历时检查一致性: 在迭代过程中,每次调用迭代器的
next()(获取下一个元素) 方法时,它都会首先检查自己的expectedModCount(预期修改计数) 是否与集合当前的modCount(修改计数) 相等。
- 抛出异常: 如果不相等,迭代器就认为集合在它不知情的情况下被修改了,于是立刻抛出
ConcurrentModificationException(并发修改异常)。
4.3 常见陷阱与正确解法
面试官常会给出一个在 for-each 循环中直接删除元素的例子。
错误示例:
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
// 在 for-each 循环中直接用 list.remove() 会触发 Fail-Fast
for (String s : list) {
if ("B".equals(s)) {
list.remove(s); // 抛出 ConcurrentModificationException
}
}
原因:for-each 循环的底层就是迭代器。当 list.remove(s) 执行时,ArrayList 的 modCount 增加了,但迭代器内部的 expectedModCount 还是旧值。下一次循环调用 next() 时,检测到两者不一致,于是抛出异常。
正确做法:
在遍历集合时,如果要修改集合,唯一安全的方式是使用迭代器自身的 remove() 方法。
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String s = iterator.next();
if ("B".equals(s)) {
iterator.remove(); // 正确!这个方法会同步更新集合的 modCount 和迭代器的 expectedModCount
}
}
System.out.println(list); // 输出: [A, C]
4.4 对比:Fail-Safe (安全失败) 机制
为了展示更广的知识面,可以主动引出与 Fail-Fast 相对的 Fail-Safe (安全失败) 机制。
- 使用者: 主要用于
java.util.concurrent包下的并发集合,如CopyOnWriteArrayList(写时复制列表)。
- 工作原理: Fail-Safe 的迭代器在创建时会建立一个指向原始数据快照 (Snapshot) 或副本的引用。它遍历的是这个快照,而不是原始集合。
- 特点:
- 不抛出异常: 在遍历快照期间,即使原始集合被修改,迭代器也毫不知情,因此不会抛出
ConcurrentModificationException。
- 数据不一致: 缺点是迭代器无法反映最新的修改,它看到的数据可能是过时的。
|
特性 |
Fail-Fast ( |
Fail-Safe ( |
|
工作方式 |
直接在原集合上工作 |
在原集合的快照上工作 |
|
异常 |
并发修改时,抛出 |
不抛出并发修改异常 |
|
数据一致性 |
强一致性(要么成功,要么异常) |
弱一致性(可能读到旧数据) |
|
性能 |
性能较高 |
迭代器创建开销大(需要复制或快照) |
5. 如何对 List<自定义对象> 排序?
这是一个非常常见的面试题。主要有两种方法:
方法一:实现 Comparable 接口 (自然排序)
让需要排序的类实现 Comparable 接口,并重写 compareTo() 方法。这定义了该对象的“自然排序”规则。
class User implements Comparable<User> {
private int age;
// ...
@Override
public int compareTo(User other) {
// 按年龄升序排序
return this.age - other.age;
}
}
// 排序时直接调用
Collections.sort(userList);
- 优点: 简单直观,一次定义,到处使用。
- 缺点: 排序规则硬编码在类中,不灵活。
方法二:使用 Comparator 接口 (定制排序 - 推荐)
创建一个或多个单独的比较器类,实现 Comparator 接口。这种方式将排序逻辑与业务对象解耦。
class UserAgeComparator implements Comparator<User> {
@Override
public int compare(User u1, User u2) {
return u1.getAge() - u2.getAge();
}
}
// 排序时传入比较器实例
Collections.sort(userList, new UserAgeComparator());
- 使用 Lambda 表达式 (Java 8+ 终极推荐):
// 按年龄升序 userList.sort(Comparator.comparingInt(User::getAge)); // 按年龄降序 userList.sort(Comparator.comparingInt(User::getAge).reversed()); // 先按年龄,再按姓名 userList.sort(Comparator.comparingInt(User::getAge).thenComparing(User::getName));
- 优点: 灵活性极高,可以随时定义和切换排序规则。
7. 队列 (Queue) 与先进先出 (FIFO) 思想
什么是 FIFO?
FIFO (First-In, First-Out) 的思想是 “先进先出”。
这是一种处理和管理项目的规则,核心原则是:最先进入队列的项目,也最先被处理和离开队列。
生活中的例子
理解 FIFO 最直观的方式就是排队。
- 排队买票: 第一个到达售票窗口开始排队的人,会是第一个买到票并离开队伍的人。
- 超市结账: 在收银台前排队的顾客,排在最前面的人最先结账。
与之相对的思想:LIFO
为了更好地理解 FIFO (先进先出),可以对比它的反面——LIFO (Last-In, First-Out) (后进先出)。这通常是 栈 (Stack) 的核心思想,就像一摞盘子,后放的先取。
计算机科学中的应用
FIFO 是 队列 (Queue) 数据结构的根本原则。它的应用非常广泛:
- 任务处理:操作系统中的打印任务队列。
- 并发编程:
BlockingQueue(阻塞队列) 是“生产者-消费者”模型的基石。
- 网络通信:路由器按数据包到达顺序转发。
- 算法:“广度优先搜索”(BFS)的核心就是队列。
Queue 接口
Queue (队列) 接口正是 FIFO (先进先出) 思想在 Java 中的体现。它表示一个先进先出(FIFO)的数据结构,常用于在处理元素之前持有它们。它定义了一组用于操作队列的方法,这些方法通常成对出现,以不同的方式处理操作失败的情况。
核心操作与方法区别
Queue (队列) 接口的核心操作包括插入、移除和检查元素。对于这些操作,通常有两种变体:一种在失败时抛出异常,另一种返回特殊值(如 false 或 null)。
- 插入操作:
如何选择 add() (添加元素)与 offer()(尝试添加元素)
add(E e)(添加元素): 插入元素。如果成功,返回true;如果队列已满(对于有界队列),则抛出IllegalStateException(非法状态异常)。
- 生活化举例: 就像你往一个已经满了的杯子里倒水,水会溢出来,表示操作失败且情况异常。
offer(E e)(尝试添加元素): 插入元素。如果成功,返回true;如果队列已满(对于有界队列),则返回false,表示添加失败但不会抛出异常。
- 生活化举例: 就像你往一个可能满了的杯子里倒水,如果满了,水龙头会自动关闭,告诉你没倒进去,但不会弄洒。
- 使用
add()(添加元素): 当你认为队列满是一种程序错误或异常状态时。你希望通过异常机制来中断当前操作,并强制开发者处理这个“不应该发生”的情况。这符合“快速失败”(Fail-Fast)的原则。
- 使用
offer()(尝试添加元素): 当你认为队列满是一种正常、可预期的临时状态时。这在生产者-消费者模型中非常常见。生产者尝试向队列中放入产品,如果队列满了,它可以优雅地处理,比如等待一会儿再试,或者直接丢弃这个产品,而不需要用try-catch来捕获异常。
- 移除操作:
remove()(移除并返回队首元素): 移除并返回队列头部元素。如果队列为空,则抛出NoSuchElementException(无此元素异常)。
- 生活化举例: 就像你从一个空箱子里拿东西,发现没有东西可拿,你会感到困惑或生气(异常)。
poll()(尝试移除并返回队首元素): 移除并返回队列头部元素。如果队列为空,则返回null。
- 生活化举例: 就像你从一个可能空的箱子里拿东西,如果空的,你只是发现没拿到东西,但不会感到意外。
- 检查操作:
element()(返回队首元素但不移除): 返回队列头部元素但不移除。如果队列为空,则抛出NoSuchElementException(无此元素异常)。
- 生活化举例: 就像你查看一个空箱子里有什么,发现什么都没有,你会感到困惑或生气(异常)。
peek()(尝试返回队首元素但不移除): 返回队列头部元素但不移除。如果队列为空,则返回null。
- 生活化举例: 就像你查看一个可能空的箱子里有什么,如果空的,你只是发现什么都没有,但不会感到意外。
常见实现类及其底层原理
在深入了解具体实现类之前,我们先来认识一下 Deque 接口,它是 Queue 的一个重要扩展。
Deque (双端队列) 接口
Deque (读作 "deck") 是 "Double-Ended Queue" 的缩写,中文常翻译为双端队列。
它是一种非常灵活的队列,其核心特点是:允许你在队列的两端(头部和尾部)进行元素的添加和移除操作。
核心特性和思想
- 两端操作:这是
Deque最主要的特点。你可以:
- 从队列头部添加元素
- 从队列头部移除元素
- 从队列尾部添加元素
- 从队列尾部移除元素
Queue的扩展:Deque接口继承自Queue接口。这意味着所有Queue接口定义的操作(如offer(),poll(),peek(),它们都是操作队列头部)在Deque中也可用。
Stack的替代:Deque可以很方便地模拟 栈 (Stack) 的行为。
push()(入栈):等同于addFirst()(从头部添加)
pop()(出栈):等同于removeFirst()(从头部移除)
peek()(查看栈顶元素):等同于peekFirst()(查看头部元素)
常用方法 (以 First 和 Last 结尾)
Deque 提供了多组方法来操作两端:
- 添加元素:
addFirst(E e)/offerFirst(E e): 在队列头部添加元素。
addLast(E e)/offerLast(E e): 在队列尾部添加元素。
- 移除元素:
removeFirst()/pollFirst(): 移除并返回队列头部元素。
removeLast()/pollLast(): 移除并返回队列尾部元素。
- 查看元素:
getFirst()/peekFirst(): 查看队列头部元素(但不移除)。
getLast()/peekLast(): 查看队列尾部元素(但不移除)。
总结
Deque 接口的出现,让 Java 集合框架在处理元素序列时拥有了极高的灵活性。它能够统一 Queue(先进先出)和 Stack(后进先出)两种数据结构的操作,从而简化了代码,并提供了更高效的实现方式(如 ArrayDeque)。
LinkedList(链表队列):
- 底层原理: 基于双向链表实现。
- 工作原理: 实现了
List和Deque(双端队列) 接口,因此可以作为Queue(先进先出)和Stack(后进先出)使用。
- 特点: 插入和删除效率高(O(1)),随机访问效率低(O(n))。非线程安全。
- 生活化举例: 就像排队买票,新来的人排在队尾,买完票的人从队头离开。
ArrayDeque(数组双端队列):
- 底层原理: 基于可变大小的数组实现,没有容量限制(理论上)。
- 工作原理: 实现了
Deque(双端队列) 接口,可以作为双端队列使用。比LinkedList(链表队列) 在用作队列或栈时性能更好。
- 特点: 插入和删除效率高(O(1)),非线程安全。
- 生活化举例: 就像一个两头都可以进出的隧道,车辆可以从两头进入或离开。
PriorityQueue(优先级队列):
- 底层原理: 基于最小堆(Min-Heap)实现。
- 工作原理: 队列中的元素会根据其自然顺序或构造时提供的
Comparator(比较器) 进行排序。每次出队(poll())的都是优先级最高的元素(最小元素)。
- 特点: 非线程安全。插入和删除操作的时间复杂度为 O(log n)。
- 生活化举例: 就像医院的急诊室,病人不是按先来后到,而是按病情严重程度(优先级)来决定谁先就诊。
BlockingQueue(阻塞队列) 接口及其实现:
BlockingQueue(阻塞队列) 是Queue(队列) 的子接口,支持当队列满或空时,阻塞生产者或消费者线程。常用于生产者-消费者模式。
ArrayBlockingQueue(数组阻塞队列): 基于数组实现,有界阻塞队列。
LinkedBlockingQueue(链式阻塞队列): 基于链表实现,可选有界阻塞队列(默认无界)。
SynchronousQueue(同步队列): 不存储元素的阻塞队列,每个插入操作必须等待一个对应的移除操作。
PriorityBlockingQueue(优先级阻塞队列): 支持优先级的无界阻塞队列。
DelayQueue(延迟队列): 存储Delayed(延迟元素接口) 元素的无界阻塞队列,只有当元素的延迟时间到期时才能从队列中取出。
- 生活化举例: 就像工厂的生产线和仓库。如果仓库满了,生产线会暂停(阻塞生产者);如果仓库空了,运输工人会等待(阻塞消费者)。
8. CopyOnWriteArrayList (写时复制列表) 的底层原理与工作机制
CopyOnWriteArrayList (写时复制列表) 是 java.util.concurrent (Java并发包) 包下的一个线程安全的 List (列表) 实现。它通过“写时复制”的策略来保证线程安全,而不是通过传统的加锁机制。
底层原理
- 数据结构: 内部维护一个
volatile(易失性变量) 数组来存储元素。
- 核心思想: 当对
CopyOnWriteArrayList(写时复制列表) 进行修改操作(add,set,remove(添加、设置、删除) 等)时,它会创建一个新的底层数组,将旧数组的元素复制到新数组中,然后在新数组上执行修改操作,最后将新数组赋值给内部的volatile(易失性变量) 数组。
工作机制
- 读操作 (Read):
- 读操作(如
get(),iterator()(迭代器))不需要加锁。
- 它们总是读取当前
volatile(易失性变量) 数组的快照。这意味着读操作是弱一致性的,可能读到旧的数据,但不会读到不一致的数据。
- 由于读操作不加锁,因此在读多写少的场景下,
CopyOnWriteArrayList(写时复制列表) 具有非常高的并发读取性能。
- 写操作 (Write):
- 写操作(如
add(),set(),remove()(添加、设置、删除))会加锁(通常是ReentrantLock(可重入锁))。
- 在加锁后,它会执行以下步骤:
- 获取当前内部数组的引用。
- 创建一个新的数组,并将旧数组的所有元素复制到新数组中。
- 在新数组上执行修改操作(添加、删除或修改元素)。
- 将内部数组的引用指向这个新数组。
- 释放锁。
- 由于每次修改都会复制整个数组,因此写操作的开销相对较大,尤其是在元素数量很多时。
适用场景
- 读多写少的并发场景:例如,事件监听器列表、配置信息列表等。
- 需要遍历操作不抛出
ConcurrentModificationException(并发修改异常) 的场景:因为迭代器是基于数组的快照,所以即使在遍历过程中有其他线程修改了列表,迭代器也不会受影响。
缺点
- 内存开销大: 每次修改都会复制整个数组,如果列表元素很多,会造成较大的内存浪费。
- 写操作性能低: 复制数组的开销导致写操作性能远低于
ArrayList(数组列表) 或Collections.synchronizedList(同步列表包装器)。
- 数据一致性: 读操作不能保证实时性,可能读到旧版本的数据(弱一致性)。
与 Collections.synchronizedList (同步列表包装器) 的对比
Collections.synchronizedList(同步列表包装器): 通过对所有方法加锁(synchronized)来保证线程安全。读写操作都会阻塞。
CopyOnWriteArrayList(写时复制列表): 读操作无锁,写操作通过复制新数组实现。读写分离,读性能高,写性能低。
选择哪种取决于具体的读写比例和对数据实时性的要求。
6. 核心组件与代码示例
以下代码 LibraryManager.java 演示了 List, Set, Map 的最基本使用。
注意: 关于 HashMap, LinkedHashMap, TreeMap, Hashtable 之间更详细、直观的对比,请参考项目中的 MapExamples.java 文件。
package com.study.basics.collections;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class LibraryManager {
//...
public static void main(String[] args) {
// 1. List: 像一个有序的书架
List<String> bookShelf = new ArrayList<>();
bookShelf.add("Java核心技术");
bookShelf.add("Effective Java");
bookShelf.add("Java核心技术"); // List允许重复
System.out.println("书架上的书(List): " + bookShelf);
System.out.println("书架上第2本书是: " + bookShelf.get(1));
System.out.println("---");
// 2. Set: 像珍本典藏室,不允许重复
Set<String> rareBookRoom = new HashSet<>();
rareBookRoom.add("百年孤独");
rareBookRoom.add("活着");
boolean isAdded = rareBookRoom.add("百年孤独"); // 尝试添加重复的书
System.out.println("珍本典藏室的书(Set): " + rareBookRoom);
System.out.println("第二次添加《百年孤独》是否成功: " + isAdded);
System.out.println("典藏室里有《活着》这本书吗? " + rareBookRoom.contains("活着"));
System.out.println("---");
// 3. Map: 像借书卡记录簿,记录 key-value
Map<String, String> borrowingRecords = new HashMap<>();
borrowingRecords.put("卡号001", "代码整洁之道");
borrowingRecords.put("卡号002", "重构");
borrowingRecords.put("卡号003", "代码整洁之道"); // 不同的卡号可以借同一本书
String book = borrowingRecords.put("卡号001", "设计模式"); // 同一个卡号换了一本书借
System.out.println("借书记录(Map): " + borrowingRecords);
System.out.println("卡号001之前借的是: " + book);
System.out.println("卡号002借的书是: " + borrowingRecords.get("卡号002"));
}
}
---
## 9. CopyOnWriteArrayList 的底层原理与工作机制
`CopyOnWriteArrayList` 是 `java.util.concurrent` 包下的一个线程安全的 `List` 实现。它通过“写时复制”的策略来保证线程安全,而不是通过传统的加锁机制。
### 底层原理
* **数据结构**: 内部维护一个 `volatile` 数组来存储元素。
* **核心思想**: 当对 `CopyOnWriteArrayList` 进行**修改操作**(`add`, `set`, `remove` 等)时,它会**创建一个新的底层数组**,将旧数组的元素复制到新数组中,然后在新数组上执行修改操作,最后将新数组赋值给内部的 `volatile` 数组。
### 工作机制
1. **读操作 (Read)**:
* 读操作(如 `get()`, `iterator()`)**不需要加锁**。
* 它们总是读取当前 `volatile` 数组的快照。这意味着读操作是**弱一致性**的,可能读到旧的数据,但不会读到不一致的数据。
* 由于读操作不加锁,因此在读多写少的场景下,`CopyOnWriteArrayList` 具有非常高的并发读取性能。
2. **写操作 (Write)**:
* 写操作(如 `add()`, `set()`, `remove()`)会**加锁**(通常是 `ReentrantLock`)。
* 在加锁后,它会执行以下步骤:
* 获取当前内部数组的引用。
* 创建一个新的数组,并将旧数组的所有元素复制到新数组中。
* 在新数组上执行修改操作(添加、删除或修改元素)。
* 将内部数组的引用指向这个新数组。
* 释放锁。
* 由于每次修改都会复制整个数组,因此写操作的开销相对较大,尤其是在元素数量很多时。
### 适用场景
* **读多写少**的并发场景:例如,事件监听器列表、配置信息列表等。
* 需要**遍历操作不抛出 `ConcurrentModificationException`** 的场景:因为迭代器是基于数组的快照,所以即使在遍历过程中有其他线程修改了列表,迭代器也不会受影响。
### 缺点
* **内存开销大**: 每次修改都会复制整个数组,如果列表元素很多,会造成较大的内存浪费。
* **写操作性能低**: 复制数组的开销导致写操作性能远低于 `ArrayList` 或 `Collections.synchronizedList`。
* **数据一致性**: 读操作不能保证实时性,可能读到旧版本的数据(弱一致性)。
### 与 `Collections.synchronizedList` 的对比
* **`Collections.synchronizedList`**: 通过对所有方法加锁(`synchronized`)来保证线程安全。读写操作都会阻塞。
* **`CopyOnWriteArrayList`**: 读操作无锁,写操作通过复制新数组实现。读写分离,读性能高,写性能低。
选择哪种取决于具体的读写比例和对数据实时性的要求。
## 6. 核心组件与代码示例
以下代码 `LibraryManager.java` 演示了 `List`, `Set`, `Map` 的最基本使用。
**注意**: 关于 `HashMap`, `LinkedHashMap`, `TreeMap`, `Hashtable` 之间更详细、直观的对比,请参考项目中的 `MapExamples.java` 文件。
```java
package com.study.basics.collections;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class LibraryManager {
//...
public static void main(String[] args) {
// 1. List: 像一个有序的书架
List<String> bookShelf = new ArrayList<>();
bookShelf.add("Java核心技术");
bookShelf.add("Effective Java");
bookShelf.add("Java核心技术"); // List允许重复
System.out.println("书架上的书(List): " + bookShelf);
System.out.println("书架上第2本书是: " + bookShelf.get(1));
System.out.println("---");
// 2. Set: 像珍本典藏室,不允许重复
Set<String> rareBookRoom = new HashSet<>();
rareBookRoom.add("百年孤独");
rareBookRoom.add("活着");
boolean isAdded = rareBookRoom.add("百年孤独"); // 尝试添加重复的书
System.out.println("珍本典藏室的书(Set): " + rareBookRoom);
System.out.println("第二次添加《百年孤独》是否成功: " + isAdded);
System.out.println("典藏室里有《活着》这本书吗? " + rareBookRoom.contains("活着"));
System.out.println("---");
// 3. Map: 像借书卡记录簿,记录 key-value
Map<String, String> borrowingRecords = new HashMap<>();
borrowingRecords.put("卡号001", "代码整洁之道");
borrowingRecords.put("卡号002", "重构");
borrowingRecords.put("卡号003", "代码整洁之道"); // 不同的卡号可以借同一本书
String book = borrowingRecords.put("卡号001", "设计模式"); // 同一个卡号换了一本书借
System.out.println("借书记录(Map): " + borrowingRecords);
System.out.println("卡号001之前借的是: " + book);
System.out.println("卡号002借的书是: " + borrowingRecords.get("卡号002"));
}
}

浙公网安备 33010602011771号