实用指南:Java 集合解析

一、ArrayList

1. 底层数据结构

ArrayList 的核心是一个Object 类型的数组,名为 elementData。它不是固定大小的,而是随着元素增加动态扩容。

数组初始默认不分配空间(JDK 1.8+),第一次添加元素时才初始化为长度 10。

对象引用(可为 null)。就是数组中每个位置存储的

维护一个 size 变量,表示当前实际存储的元素个数(不是数组长度)。

关键点:数组连续内存 ,随机访问快(O(1)),但插入/删除慢(需移动元素)。

2. 扩容机制

每次调用 add() 方法时,ArrayList 会先检查:当前数组容量是否足够容纳新元素。判断逻辑是:

如果 size + 1 > 当前数组长度,则触发扩容。

例如:数组长度为 10,已存 10 个元素,再 add 第 11 个 → 触发扩容。

扩容方式

  1. 计算新容量

    • 新容量 = 旧容量 + 旧容量右移一位(即 1.5 倍)。
    • 举例:10 → 15,15 → 22
    • 假设 1.5 倍仍不够(比如批量添加大量元素),则直接扩容到所需的最小容量。
  2. 检查最大限制

    • 如果新容量超过 JVM 数组最大长度(Integer.MAX_VALUE - 8),则调用特殊方法处理,可能抛出 OutOfMemoryError。
  3. 创建新数组并复制数据

    • 调用 Arrays.copyOf(),底层使用 System.arraycopy()(native 方法,高效内存复制)。
    • 将旧数组所有元素复制到新数组。
    • elementData 引用指向新数组,旧数组等待 GC 回收。

性能代价:扩容是 O(n) 操作,频繁扩容严重影响性能。

3. 线程安全方面

完全不安全

多线程同时 add 时,两个线程可能读到相同的 size,计算出相同的插入位置,导致一个元素被覆盖。

多线程同时扩容时,可能一个线程刚创建新数组,另一个线程又基于旧数组长度计算新容量,造成数组长度错误或越界异常。

二、LinkedList

1. 底层数据结构

LinkedList 由一个个Node 节点组成双向链表。

每个 Node 包含:当前元素值、指向前一个节点的引用(prev)、指向后一个节点的引用(next)。

LinkedList 对象本身持有两个引用:first(头节点)和 last(尾节点)。

没有容量概念,添加元素就是创建新节点并调整指针。

关键点:链表结构 → 插入/删除快(O(1)),但随机访问慢(O(n))。

2. 扩容机制

没有扩容

每次 add 都是新建一个 Node 对象,然后调整相邻节点的指针。

理论上只受堆内存限制,不会像数组那样得“预留空间”或“复制资料”。

优势:无需担心扩容性能开销。
劣势:每内存不连续,缓存不友好。

3. 线程安全方面

不安全

多线程同时操作头尾指针(first/last)或节点的 next/prev 指针,会导致链表断裂、节点丢失、甚至形成环。

例如:两个线程同时在尾部添加,可能都把新节点的 prev 指向同一个旧尾节点,导致其中一个节点被“跳过”。

三、HashMap(JDK 1.8+)

1. 底层数据结构

HashMap 由一个Node 数组(哈希桶数组)构成,数组每个位置称为一个桶(bucket)。

通过每个桶能够是:

空(null)

一个 Node 对象(无冲突)

一个 Node 链表(哈希冲突)

一个 TreeNode 红黑树(冲突严重时优化)

Node 含有:key 的 hash 值、key、value、指向下一个 Node 的引用。

当同一个桶中链表长度 ≥ 8 且数组总长度 ≥ 64 时,链表会转换为红黑树(提升查找效率从 O(n) 到 O(log n))。

当树中节点数 ≤ 6时,会退化回链表


基于泊松分布统计,链表长度达到 8 的概率极低,此时转树的收益大于维护树的开销。

2. 扩容机制

当 HashMap 中元素数量(size)超过阈值(threshold)时触发扩容。

  • 阈值 = 数组容量 × 负载因子(默认 0.75)。
  • 默认初始容量 16 → 阈值 = 12 → 存入第 13 个元素时扩容。

扩容方式(JDK 1.8 优化版)

  1. 新容量 = 旧容量 × 2(必须是 2 的幂,便于位运算)。
  2. 创建新数组,长度为原数组两倍。
  3. 迁移旧数据(重点优化):

遍历旧数组每个桶。

如果桶为空 → 跳过。

如果桶是单个节点 → 重新计算其在新数组中的位置(hash & (newCap - 1)),直接放入。

如果桶是链表 → 遍历链表,根据节点 hash 值的“新增高位”是否为 0,将节点分为“低位链表”和“高位链表”:

低位链表 → 放入新数组的原索引位置

高位链表 → 放入新数组的原索引 + 旧容量位置

红黑树 → 同样按高位拆分,若拆分后树节点数 ≤ 6,则退化为链表。就是倘若桶

JDK 1.8 优化核心

  1. 无需重新计算每个元素的 hash。
  2. 利用“旧容量”的二进制最高位来判断新位置,迁移高效。
  3. 避免了 JDK 1.7 的头插法导致的多线程下链表成环死循环问题。

3. 线程安全方面

不安全

多线程同时 put:

可能两个线程同时写入同一个桶,导致数据覆盖。

扩容时,多个线程同时迁移数据,可能导致链表结构破坏、形成环、数据丢失。

避免了死循环。就是即使是 JDK 1.8 的尾插法,也无法保证线程安全,只

4. JDK 1.7 vs 1.8 关键区别

特性JDK 1.7JDK 1.8+
数据结构数组 + 链表数组 + 链表 + 红黑树
扩容迁移头插法(易成环)尾插法 + 高低位链表(安全高效)
Hash 算法复杂扰动(9次运算)便捷扰动(1次:h ^ (h >>> 16))
链表转树不支持支持(≥8 且 cap≥64)

四、ConcurrentHashMap(JDK 1.8)

1. 底层数据结构

基本结构与 HashMap 一致:数组 + 链表/红黑树

关键区别在于线程安全机制,完全重构。

2. 线程安全原理(JDK 1.8)

采用 分段锁思想的极致优化,结合三种技术:

2.1 CAS(Compare and Swap)

  • 当某个桶为空(null)时,尝试通过 CAS 操作直接放入新节点。
  • 无锁管理,性能极高。
  • 要是 CAS 失败(说明有其他线程刚放了节点),则进入下一步。

2.2 synchronized 锁住桶的头节点

  • 若是桶非空,则对这个桶的第一个节点(头节点)加 synchronized 锁。
  • 锁粒度极小(只锁一个桶,而不是整个 Map 或 Segment)。
  • 在锁内完成链表遍历、插入、或树操作。
  • 其他线程访问其他桶时完全不受影响。

2.3 volatile + Unsafe 保证可见性

  • 数组引用 table 是 volatile 的,保证多线程间可见。
  • 运用 Unsafe 类进行底层原子操作,高效安全。

核心思想读处理完全无锁,写操作只锁单个桶,高并发下性能接近无锁。


3. 扩容机制

ConcurrentHashMap 的扩容是多线程协作式的,极大提升效率:

  1. 当一个线程发现需要扩容时,会创建一个新数组(nextTable),容量为原数组 2 倍。
  2. 该线程开始迁移数据,并在原数组对应位置放置一个ForwardingNode(转发节点),表示此桶正在迁移中。
  3. 其他线程在 put 或 get 时,如果遇到 ForwardingNode,会主动协助迁移,而不是等待。
  4. 每个线程负责迁移一段连续的桶(如 16 个),避免冲突。
  5. 所有桶迁移完成后,将 table 引用指向新数组,扩容结束。

优势:充分利用多核 CPU,并行迁移,缩短扩容停顿时间。

五、CopyOnWriteArrayList

1. 底层数据结构

核心是一个 volatile 修饰的 Object 数组,保证多线程间的可见性。

所有“写处理”(add、set、remove)必须先获取一个ReentrantLock 独占锁

所有“读操作”(get、iterator)完全无锁,直接读取当前数组。

2. 写操作机制(写时复制)

  1. 加锁(确保同一时间只有一个线程能写)。
  2. 获取当前数组的引用。
  3. 创建一个新数组,长度 = 旧数组长度 + 1(add 处理)。
  4. 将旧数组所有元素复制到新数组。
  5. 在新数组的指定位置放入新元素。
  6. 将集合的数组引用原子性地指向新数组(volatile 保证其他线程立即可见)。
  7. 释放锁。

关键点:旧数组不会被修改,读线程始终看到一个“完整一致”的快照。

3. 读操作与迭代器

get(index):直接返回当前数组对应位置的元素,无锁,极快。

iterator():返回的是创建迭代器那一刻的数组快照

即使其他线程修改了集合,迭代器遍历的仍是旧数据,不会抛 ConcurrentModificationException→ 弱一致性,可能读不到最新数据 → 适用于容忍短暂不一致的场景。

4. 适用场景与缺点

适用场景

  1. 读操作远远多于写操控(如:系统配置列表、监听器列表、黑白名单)。
  2. 读操作不能容忍阻塞(要求高吞吐、低延迟)。
  3. 信息总量不大(避免复制开销过大)。

缺点

  1. 内存占用高:写操作时同时存在新旧两个数组。
  2. GC 压力大:旧数组成为垃圾,频繁写处理导致频繁 GC。
  3. 数据延迟:读线程可能读到旧数据。
  4. 不适合实时性要求高的写运行
posted @ 2025-09-18 18:31  wzzkaifa  阅读(15)  评论(0)    收藏  举报