01-Java基础-集合框架 (Collections)

1. 集合框架顶层结构

Java 的集合框架主要由两大顶层接口构成:CollectionMap,它们是相互独立的。

  • 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 深度对比 (高频面试题)

ArrayListLinkedListList 接口最常用的两个实现类。List 的核心特性是有序、可重复。而这两个实现类的区别在于其底层数据结构,这导致了它们在性能和使用场景上有着本质的不同。

特性

ArrayList

LinkedList

底层数据结构

动态数组 (Object[])

双向链表 (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: 在绝大多数业务场景中,对列表的遍历和随机读取操作远比在中间位置插入/删除要频繁得多。ArrayListO(1) 随机访问性能和更好的 CPU 缓存局部性,使其成为默认的首选。
  • 选择 LinkedList 的场景:
    1. 当你的应用需要大量在列表的头部和尾部进行增删操作时,LinkedList 是最佳选择。
    1. 当你需要实现一个队列 (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() 方法。它们之间有严格的“协定”或“合同”:

  1. equals 相等,hashCode 必相等: 如果 a.equals(b)true,那么 a.hashCode() 必须等于 b.hashCode()
  1. hashCode 不等,equals 必不等: 如果 a.hashCode() 不等于 b.hashCode(),那么 a.equals(b) 必须为 false
  1. hashCode 相等,equals 不一定相等: 如果 a.hashCode() 等于 b.hashCode()a.equals(b) 可能为 true 也可能为 false。这种情况就是哈希冲突。

只重写 equals() 不重写 hashCode() 的问题

这是一个经典的面试陷阱。如果一个类只重写了 equals()(比如根据对象的属性判断相等),而没有重写 hashCode(),就会破坏上述第一条协定。

  • 后果: 导致两个“相等”对象的 hashCode 基本不可能相等。
  • 具体表现: 在 HashMapput(key1, value) 后,用 equals 相等的 key2get,会因为 hashCode 不同而找不到,返回 null

结论:为了保证 HashMapHashSet 等哈希集合的正确性,重写 equals() 时,必须同时重写 hashCode()

3.2 ConcurrentHashMap 原理 (JDK 1.8)

  • 生活比喻: 一个有多功能窗口的自助餐厅。取沙拉的、拿披萨的、打饮料的(不同的哈希桶)互不干扰,只有当两个人同时要拿同一块披萨时,才需要稍微排一下队(锁住当前桶)。这比只有一个窗口的食堂(Hashtable)效率高得多。

ConcurrentHashMap线程安全高性能HashMap 版本,是面试重中之重。

  • 线程安全机制: 采用了更细粒度的 CAS + synchronized 锁。
  • put 流程中的并发控制:
    1. 桶为空时,用 CAS 无锁添加。
    1. 桶不为空,则 synchronized 锁住该桶的头节点,锁的粒度仅限于当前的桶,实现高并发。
  • get 操作: 通常是无锁的,通过 volatile 保证可见性。

三种线程安全 Map 对比

特性

Hashtable

Collections.synchronizedMap

ConcurrentHashMap (JDK 1.8)

锁机制

synchronized 关键字

synchronized 关键字

synchronized + CAS

锁粒度

全局锁 (锁整个对象)

全局锁 (锁整个对象)

分段锁 (锁哈希桶的头节点)

性能

,所有操作互斥

,所有操作互斥

,允许多线程并发写

迭代器

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 对比

特性

HashMap

LinkedHashMap

底层原理

哈希表 (数组+链表/红黑树)

哈希表 + 双向链表

顺序

无序

有序 (按插入顺序或访问顺序)

性能

略高 (无维护链表的开销)

略低 (需维护双向链表)

用例

无需顺序的快速存取

需按插入顺序遍历的场景、实现 LRU 缓存

  • LinkedHashSet / TreeSet: 分别是 Set 接口的另外两个重要实现,底层分别由 LinkedHashMapTreeMap 支持。

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, 修改计数) 的内部变量实现的。

  1. 记录修改次数: modCount (修改计数) 是集合的一个成员变量,每当集合的结构发生改变(如调用 add (添加), remove (移除) 等方法),modCount 的值就会 +1
  1. 创建迭代器时保存状态: 当你通过 collection.iterator() 获取一个迭代器时,迭代器内部会创建一个 expectedModCount (预期修改计数) 变量,并将其值初始化为当前集合的 modCount (修改计数) 值。
  1. 遍历时检查一致性: 在迭代过程中,每次调用迭代器的 next() (获取下一个元素) 方法时,它都会首先检查自己的 expectedModCount (预期修改计数) 是否与集合当前的 modCount (修改计数) 相等。
  1. 抛出异常: 如果不相等,迭代器就认为集合在它不知情的情况下被修改了,于是立刻抛出 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) 执行时,ArrayListmodCount 增加了,但迭代器内部的 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 (ArrayList, HashMap)

Fail-Safe (CopyOnWriteArrayList(写时复制列表))

工作方式

直接在原集合上工作

在原集合的快照上工作

异常

并发修改时,抛出 ConcurrentModificationException(并发修改异常)

不抛出并发修改异常

数据一致性

强一致性(要么成功,要么异常)

弱一致性(可能读到旧数据)

性能

性能较高

迭代器创建开销大(需要复制或快照)

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) 数据结构的根本原则。它的应用非常广泛:

  1. 任务处理:操作系统中的打印任务队列。
  1. 并发编程BlockingQueue (阻塞队列) 是“生产者-消费者”模型的基石。
  1. 网络通信:路由器按数据包到达顺序转发。
  1. 算法:“广度优先搜索”(BFS)的核心就是队列。

Queue 接口

Queue (队列) 接口正是 FIFO (先进先出) 思想在 Java 中的体现。它表示一个先进先出(FIFO)的数据结构,常用于在处理元素之前持有它们。它定义了一组用于操作队列的方法,这些方法通常成对出现,以不同的方式处理操作失败的情况。

核心操作与方法区别

Queue (队列) 接口的核心操作包括插入、移除和检查元素。对于这些操作,通常有两种变体:一种在失败时抛出异常,另一种返回特殊值(如 falsenull)。

  1. 插入操作:

如何选择 add() (添加元素)与 offer()(尝试添加元素)

    • add(E e) (添加元素): 插入元素。如果成功,返回 true;如果队列已满(对于有界队列),则抛出 IllegalStateException (非法状态异常)。
    • 生活化举例: 就像你往一个已经满了的杯子里倒水,水会溢出来,表示操作失败且情况异常。
    • offer(E e) (尝试添加元素): 插入元素。如果成功,返回 true;如果队列已满(对于有界队列),则返回 false,表示添加失败但不会抛出异常。
    • 生活化举例: 就像你往一个可能满了的杯子里倒水,如果满了,水龙头会自动关闭,告诉你没倒进去,但不会弄洒。
    • 使用 add()(添加元素): 当你认为队列满是一种程序错误或异常状态时。你希望通过异常机制来中断当前操作,并强制开发者处理这个“不应该发生”的情况。这符合“快速失败”(Fail-Fast)的原则。
    • 使用 offer()(尝试添加元素): 当你认为队列满是一种正常、可预期的临时状态时。这在生产者-消费者模型中非常常见。生产者尝试向队列中放入产品,如果队列满了,它可以优雅地处理,比如等待一会儿再试,或者直接丢弃这个产品,而不需要用 try-catch 来捕获异常。
  1. 移除操作:
    • remove() (移除并返回队首元素): 移除并返回队列头部元素。如果队列为空,则抛出 NoSuchElementException (无此元素异常)。
    • 生活化举例: 就像你从一个空箱子里拿东西,发现没有东西可拿,你会感到困惑或生气(异常)。
    • poll() (尝试移除并返回队首元素): 移除并返回队列头部元素。如果队列为空,则返回 null
    • 生活化举例: 就像你从一个可能空的箱子里拿东西,如果空的,你只是发现没拿到东西,但不会感到意外。
  1. 检查操作:
    • element() (返回队首元素但不移除): 返回队列头部元素但不移除。如果队列为空,则抛出 NoSuchElementException (无此元素异常)。
    • 生活化举例: 就像你查看一个空箱子里有什么,发现什么都没有,你会感到困惑或生气(异常)。
    • peek() (尝试返回队首元素但不移除): 返回队列头部元素但不移除。如果队列为空,则返回 null
    • 生活化举例: 就像你查看一个可能空的箱子里有什么,如果空的,你只是发现什么都没有,但不会感到意外。

常见实现类及其底层原理

在深入了解具体实现类之前,我们先来认识一下 Deque 接口,它是 Queue 的一个重要扩展。

Deque (双端队列) 接口

Deque (读作 "deck") 是 "Double-Ended Queue" 的缩写,中文常翻译为双端队列

它是一种非常灵活的队列,其核心特点是:允许你在队列的两端(头部和尾部)进行元素的添加和移除操作。

核心特性和思想

  1. 两端操作:这是 Deque 最主要的特点。你可以:
    • 从队列头部添加元素
    • 从队列头部移除元素
    • 从队列尾部添加元素
    • 从队列尾部移除元素
  1. Queue 的扩展Deque 接口继承自 Queue 接口。这意味着所有 Queue 接口定义的操作(如 offer(), poll(), peek(),它们都是操作队列头部)在 Deque 中也可用。
  1. Stack 的替代Deque 可以很方便地模拟 栈 (Stack) 的行为。
    • push() (入栈):等同于 addFirst() (从头部添加)
    • pop() (出栈):等同于 removeFirst() (从头部移除)
    • peek() (查看栈顶元素):等同于 peekFirst() (查看头部元素)

常用方法 (以 FirstLast 结尾)

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)。

  1. LinkedList (链表队列):
    • 底层原理: 基于双向链表实现。
    • 工作原理: 实现了 ListDeque (双端队列) 接口,因此可以作为 Queue(先进先出)和 Stack(后进先出)使用。
    • 特点: 插入和删除效率高(O(1)),随机访问效率低(O(n))。非线程安全。
    • 生活化举例: 就像排队买票,新来的人排在队尾,买完票的人从队头离开。
  1. ArrayDeque (数组双端队列):
    • 底层原理: 基于可变大小的数组实现,没有容量限制(理论上)。
    • 工作原理: 实现了 Deque (双端队列) 接口,可以作为双端队列使用。比 LinkedList(链表队列) 在用作队列或栈时性能更好。
    • 特点: 插入和删除效率高(O(1)),非线程安全。
    • 生活化举例: 就像一个两头都可以进出的隧道,车辆可以从两头进入或离开。
  1. PriorityQueue (优先级队列):
    • 底层原理: 基于最小堆(Min-Heap)实现。
    • 工作原理: 队列中的元素会根据其自然顺序或构造时提供的 Comparator (比较器) 进行排序。每次出队(poll())的都是优先级最高的元素(最小元素)。
    • 特点: 非线程安全。插入和删除操作的时间复杂度为 O(log n)。
    • 生活化举例: 就像医院的急诊室,病人不是按先来后到,而是按病情严重程度(优先级)来决定谁先就诊。
  1. BlockingQueue (阻塞队列) 接口及其实现:
    • BlockingQueue (阻塞队列) 是 Queue (队列) 的子接口,支持当队列满或空时,阻塞生产者或消费者线程。常用于生产者-消费者模式。
    • ArrayBlockingQueue (数组阻塞队列): 基于数组实现,有界阻塞队列。
    • LinkedBlockingQueue (链式阻塞队列): 基于链表实现,可选有界阻塞队列(默认无界)。
    • SynchronousQueue (同步队列): 不存储元素的阻塞队列,每个插入操作必须等待一个对应的移除操作。
    • PriorityBlockingQueue (优先级阻塞队列): 支持优先级的无界阻塞队列。
    • DelayQueue (延迟队列): 存储 Delayed (延迟元素接口) 元素的无界阻塞队列,只有当元素的延迟时间到期时才能从队列中取出。
    • 生活化举例: 就像工厂的生产线和仓库。如果仓库满了,生产线会暂停(阻塞生产者);如果仓库空了,运输工人会等待(阻塞消费者)。

8. CopyOnWriteArrayList (写时复制列表) 的底层原理与工作机制

CopyOnWriteArrayList (写时复制列表) 是 java.util.concurrent (Java并发包) 包下的一个线程安全的 List (列表) 实现。它通过“写时复制”的策略来保证线程安全,而不是通过传统的加锁机制。

底层原理

  • 数据结构: 内部维护一个 volatile (易失性变量) 数组来存储元素。
  • 核心思想: 当对 CopyOnWriteArrayList (写时复制列表) 进行修改操作add, set, remove (添加、设置、删除) 等)时,它会创建一个新的底层数组,将旧数组的元素复制到新数组中,然后在新数组上执行修改操作,最后将新数组赋值给内部的 volatile (易失性变量) 数组。

工作机制

  1. 读操作 (Read):
    • 读操作(如 get(), iterator() (迭代器))不需要加锁
    • 它们总是读取当前 volatile (易失性变量) 数组的快照。这意味着读操作是弱一致性的,可能读到旧的数据,但不会读到不一致的数据。
    • 由于读操作不加锁,因此在读多写少的场景下,CopyOnWriteArrayList (写时复制列表) 具有非常高的并发读取性能。
  1. 写操作 (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"));
    }
}

  

posted @ 2025-12-24 11:08  我是刘瘦瘦  阅读(1)  评论(0)    收藏  举报