面试追问地图
| 主问题 | 必讲关键点 | 下一层追问 |
|---|---|---|
| ArrayList | 数组、扩容、随机访问 | 扩容倍数、删除成本、线程不安全位置 |
| LinkedList | 双向链表、节点开销 | 为什么实际使用少、缓存局部性 |
| CopyOnWriteArrayList | 写时复制、读无锁 | 内存开销、弱一致性、适用读写比例 |
| HashMap put/get | hash 扰动、桶定位、链表/红黑树 | resize、阈值 8/6、容量为何为 2 次幂 |
| HashMap 并发问题 | 数据竞争、覆盖、可见性 | JDK7 扩容环、JDK8 仍为何不安全 |
| ConcurrentHashMap | CAS + synchronized、节点级锁 | size 统计、扩容协助、复合操作原子性 |
| HashSet | HashMap Key、去重契约 | 可变对象、equals/hashCode 错误 |
| TreeMap/TreeSet | 红黑树、比较器 | compareTo 与 equals 不一致会怎样 |
| fail-fast | modCount 检测 | 为什么不是线程安全保证、如何安全删除 |
| LinkedHashMap | 双向链表、访问顺序 | LRU 实现、removeEldestEntry |
| Queue/Deque | 队列、双端队列、优先级堆 | ArrayDeque vs LinkedList、堆实现 |
| PriorityQueue | 二叉堆、最小堆顶 | 不保证遍历顺序、PriorityBlockingQueue |
| 不可变集合 | List.of、List.copyOf、unmodifiableList | 原集合变化是否影响视图 |
回答源码题时应能画出桶数组结构,并说明正常路径、扩容路径和并发边界。
一、List
ArrayList 和 LinkedList 的性能差异?
频次 ★★★★ · 难度 🟡
快答
- ArrayList:随机访问 O(1),插删 O(n)
- LinkedList:随机访问 O(n),插删 O(1)
- 实际使用:ArrayList 通常更快(cache locality)
深答
| 操作 | ArrayList | LinkedList |
|---|---|---|
| get(i) | O(1) | O(n) |
| add(E) | O(1)* | O(1) |
| add(i, E) | O(n) | O(1) |
| remove(i) | O(n) | O(1) |
| remove(E) | O(n) | O(n) |
*ArrayList.add() 摊销 O(1),最坏 O(n)(扩容)
底层实现对比:
// ArrayList:数组
private transient Object[] elementData;
private int size;
public E get(int index) {
rangeCheck(index);
return elementData(index); // 直接数组访问,O(1)
}
// LinkedList:双向链表
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
public E get(int index) {
checkElementIndex(index);
return node(index).item; // 需要遍历,O(n)
}
// LinkedList.node() 的实现:从中点开始二分查找,仍是 O(n)
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}实际性能对比(有缓存行的影响):
即使大量插删,ArrayList 由于 cpu 缓存 的优势,实际性能仍可能优于 LinkedList。
场景:100 万元素,头部插入 100 次
ArrayList:
- 时间复杂度:100 * 1000000 = 1 亿次移动
- 但内存连续,缓存效率高
LinkedList:
- 时间复杂度:100 次(链表插删理论上快)
- 但内存离散,缓存 miss 多,GC 压力大
- 实际性能反而不如 ArrayList
建议:
- 默认用 ArrayList
- 仅当大量头部插删时,考虑用 LinkedList
- 不要在中间频繁插删
通用概念:复杂度模型假设「每次内存访问代价相同」,而这个假设早已不成立——真正的差距来自局部性:数组连续预取,链表每个节点一次可能的 cache miss。
ArrayList 扩容机制
难度 🟡
- 初始容量 10,首次 add 时才真正分配数组(懒加载)
- 扩容公式:
newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍 - 用
Arrays.copyOf()创建新数组并复制元素 - 1.5 倍的设计充分利用移位操作,减少运算次数
- 实践:已知数据量时用
new ArrayList<>(n)指定初始容量,避免边加边扩容的反复复制
通用概念:add() 单次最坏 O(n)(触发复制)、摊还后 O(1)——复制的代价由此前那 n 次 O(1) 的 add 预付。按倍数扩容是这个结论成立的前提,按固定量扩容会退化成 O(n)。同一模式:Redis 渐进式 rehash、单调栈每个元素进出栈各一次。
ArrayList 为什么不是线程安全的?
难度 🟡
add() 方法分为三步:判断扩容 → elementData[size] = e → size++。多线程下会出现:
- 值为 null:两个线程同时往同一位置赋值,但只执行了一次 size++
- 索引越界:线程 A 判断不需要扩容,但线程 B 先完成了 size++ 导致越界
- size 不符:
size++非原子操作,多线程竞态导致计数丢失
ArrayList 和 Vector 的区别
难度 🟢
| 特性 | ArrayList | Vector |
|---|---|---|
| 线程安全 | ❌ | ✅(synchronized) |
| 扩容倍数 | 1.5 倍 | 2 倍(可指定增长因子) |
| 性能 | 高 | 低(同步开销) |
| 初始容量 | 懒加载(首次 add 分配) | 默认 10 |
Vector(以及 Collections.synchronizedList)属于”全方法加锁”的老一代同步方案,现代并发场景按读写比例选 CopyOnWriteArrayList(见下节)或并发队列,不要再答 Vector。
CopyOnWriteArrayList 适合什么场景?
频次 ★★★ · 难度 🟡
是什么:写时复制(Copy-On-Write)——写操作(add/set/remove)加锁后复制整个底层数组,在副本上改完再一次性替换引用;读操作完全无锁,读的是当时的旧数组。
为什么这么设计:把读写分离做到极致——读多写少的场景(事件监听器列表、黑白名单、路由配置)读路径零开销;代价是每次写都 O(n) 复制、瞬时内存翻倍,且迭代器是创建时刻的快照(弱一致),遍历期间的写入看不到。
常见追问
- 为什么它的迭代器不抛 ConcurrentModificationException?→ 迭代的是快照数组,结构性修改发生在新数组上,modCount 那套机制根本不适用
- 大列表频繁写会怎样?→ 每写复制一份,分配压力大、Young GC 频繁,写多场景是灾难——它是”读多写少”专用,不是通用线程安全 List
- 和
Collections.synchronizedList怎么选?→ 后者读也要加锁但写便宜;读远多于写选 COW,读写均衡选同步包装,写多考虑换数据结构
通用概念:写时复制复制的是整个数组,粒度极粗,所以只适合几乎不写的场景;fork 复制的是 4KB 的页,同一思想粒度差几个数量级。
二、HashMap
HashMap 的底层实现和扩容机制?
频次 ★★★★★ · 难度 🔴
快答
- 数组 + 链表/红黑树结构
- 初始容量 16,load factor 0.75
- 链表长度达到 8 转红黑树,小于 6 转回链表
- 扩容时容量翻倍,所有元素需要重新计算位置
深答
HashMap 采用数组 + 链表 + 红黑树的结构:
// JDK 8 HashMap 的数组
transient Node<K,V>[] table;
// Node 节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}put 方法的流程:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. 初始化或扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算数组下标,插入新节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 3. 处理哈希冲突:遍历链表或红黑树
Node<K,V> e; K k;
if (p.hash == hash && ((k = p.key) == key || key != null && key.equals(k)))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表,找到插入位置或更新值
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度达到 TREEIFY_THRESHOLD(8) 时转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || key != null && key.equals(k)))
break;
p = e;
}
}
if (e != null) { // 存在相同的 key,更新 value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 4. 更新 size 和检查是否需要扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}扩容机制(resize):
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 容量翻倍,阈值也翻倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 创建新数组,rehash 所有元素
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 链表拆分:根据高位 bit 决定去新的位置还是原位置
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null) loHead = e;
else loTail.next = e;
loTail = e;
}
else {
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}关键点:
hash(key)使用高位异或,减少哈希冲突(n - 1) & hash等价于hash % n(n 是 2 的幂)- 扩容时,链表元素根据
(hash & oldCap)决定位置 - 时间复杂度:get/put/remove 平均 O(1),最坏 O(n)(全是哈希冲突)
常见追问
- 为什么负载因子是 0.75?→ 时间与空间的折中:太小频繁扩容浪费空间,太大碰撞率飙升。0.75 下桶内节点数近似泊松分布,长链概率已经极低
- 为什么转红黑树阈值是 8、退化阈值是 6?→ 泊松分布下单桶挂满 8 个节点的概率约千万分之六——正常业务几乎撞不到,撞到说明 hash 质量差或被哈希碰撞攻击,转树把最坏情况从 O(n) 压到 O(log n);退化阈值取 6 留出滞后带,防止节点数在 7、8 之间抖动时反复树化/退化
- 扩容为什么必须翻倍?→ 保持容量是 2 的幂,
(n-1) & hash的取模等价才成立;而且翻倍后元素新下标只取决于 hash 新增的那一位——(hash & oldCap) == 0留原位,否则去原位置 + oldCap,一次遍历把链表拆成高低两条,不用重算任何 hash(见上面 resize 源码)
HashMap 的 get 过程?
难度 🟡
快答
- 计算 key 的 hash 值,通过
(n-1) & hash定位数组下标 - 先检查首节点是否匹配,再判断是链表遍历还是红黑树查找
深答
- 调用
hash(key)扰动计算 hash 值 (n-1) & hash定位桶位置,检查首节点- 首节点 hash 和 key 匹配直接返回
- 有下一个节点:如果是
TreeNode走红黑树getTreeNode();否则do-while遍历链表 - 找不到返回 null
HashMap 为什么容量是 2 的幂?
频次 ★★★★ · 难度 🟡
- 位运算等价取模:
hash & (length-1)等价于hash % length,前提是 length 为 2 的幂,位运算远快于除法 - 减少哈希碰撞:
length-1二进制全 1,能”接住”扰动后均匀的 hash 分布;若不是 2 的幂,部分索引位永远用不到,浪费空间且增加碰撞 - 扩容优化:扩容为 2 倍时,不需要重新算 hash,只需要看 hash 的某一位是 0 还是 1 来决定元素去原位置还是
原位置 + oldCap - 实践:预估容量按
期望元素数 / 0.75 + 1传入构造器(Guava 的Maps.newHashMapWithExpectedSize就是这么算的),HashMap 会自动取到不小于它的 2 的幂
HashMap 多线程下会出什么问题?
频次 ★★★★ · 难度 🔴
- JDK 7:头插法 + 并发扩容 → 迁移时两个线程交错操作同一条链表,next 指针互指成环,随后 get 走到环上死循环,CPU 100%(经典线上事故)
- JDK 8:扩容改尾插(resize 源码里的 lo/hi 双链尾插),不再成环;但依然不是线程安全的——并发 put 同一个桶会丢更新(互相覆盖)、
++size竞态导致计数错、树化过程有竞态、修改无 happens-before 保证(可见性) - 结论:JDK 8 只是把故障从”死循环”降级成”数据错误”,多线程写必须用 ConcurrentHashMap
常见追问
- JDK 7 为什么会成环?→ 头插法迁移会逆序重建链表;线程 A 挂起前记住了
e → next,线程 B 完成迁移后链表已反转,A 恢复后按旧指针继续头插,形成A ↔ B互指 - 为什么 JDK 8 不顺手做成线程安全?→ 同步有代价,单线程使用不该为此买单;线程安全是 ConcurrentHashMap 的职责,各管各的
HashMap 和 Hashtable 的区别
难度 🟢
| 特性 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | ❌ | ✅(全方法 synchronized) |
| null key/value | 支持(null key 只能一个) | 不支持 |
| 初始容量 | 16 | 11 |
| 扩容 | 2 倍 | 2n+1 |
| 性能 | 高 | 低(已淘汰) |
三、ConcurrentHashMap
ConcurrentHashMap 如何保证线程安全?
频次 ★★★★★ · 难度 🔴
快答
- JDK 7:Segment 分段锁,一个 Segment 一把锁
- JDK 8:CAS + synchronized,锁定哈希桶而不是整个分段
- size() 方法变为非强一致,多次计数取最后一次结果
深答
JDK 7 分段锁:
// 默认 16 个 Segment
static final int DEFAULT_SEGMENTS = 16;
// 继承 ReentrantLock
static class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
// ... put/get/remove 操作都在 Segment 级别加锁
}缺点:分段数固定,扩容困难,竞争激烈时仍然有瓶颈。
JDK 8 优化(CAS + synchronized):
// 不再使用 Segment,直接用数组 + 链表/红黑树
transient volatile Node<K,V>[] table;
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 1. 计算哈希值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 2. CAS 操作:如果桶是空的,直接插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
// 3. 如果正在扩容,帮助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 4. synchronized 锁定该桶(不是整个表)
else {
V oldVal = null;
synchronized (f) {
// 再次检查
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// 链表操作
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
}
}
if (binCount != 0) {
// 链表转红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}CAS 原子操作:
// 使用 unsafe 类进行 CAS 操作
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}get 方法无锁:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
// 不需要加锁,直接读取(因为 table 是 volatile 的)
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}size() 的弱一致性:
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
// 不能保证强一致性,因为在 size() 执行期间可能有其他线程在修改关键改进:
- 读操作无需加锁(volatile 保证可见性)
- 写操作只锁定哈希桶而不是整个表
- 支持并发扩容
- 初始化时使用 CAS 避免竞争
常见追问
- 单个操作线程安全,复合操作呢?→ 不安全。“先 get 再 put”的检查-再更新会被穿插,要用
putIfAbsent/computeIfAbsent/merge这类原子复合方法 - 为什么 key/value 都不允许 null?→ 并发语义下
get(key) == null无法区分”不存在”和”值就是 null”(HashMap 单线程可以用 containsKey 二次确认,并发下二次确认已经过期)
ConcurrentHashMap JDK 1.7 vs 1.8 的区别
频次 ★★★★ · 难度 🟡
| 特性 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | Segment 数组 + HashEntry 链表 | Node 数组 + 链表/红黑树 |
| 线程安全 | Segment 分段锁(ReentrantLock) | CAS + synchronized |
| 锁粒度 | 段级别 | 桶头节点级别 |
| 扩容 | 每个 Segment 独立扩容 | 整体扩容,支持多线程协助 |
| 并发度 | 默认 16 | 更高 |
ConcurrentHashMap 为什么同时用 CAS 和 synchronized?
难度 🟡
- 空槽位写入:用 CAS 乐观更新(hash 扰动后碰撞概率低,少量自旋即可完成)
- 哈希碰撞时:用 synchronized 加锁处理(碰撞频繁意味着线程竞争激烈,CAS 自旋效率低,synchronized 更合适)
四、Set 与有序容器
HashSet 的实现原理
难度 🟢
- 底层基于 HashMap 实现,所有元素作为 key 存入,value 统一为一个
PRESENT常量 - 通过 key 保证唯一性,但不保证有序
- 如需有序:
TreeSet(按值排序)或LinkedHashSet(按插入顺序)
常见追问
- 把可变对象放进 HashSet 后修改参与 hashCode 的字段会怎样?→ 对象”失踪”:存的时候按旧 hash 落桶,改完后按新 hash 查找定位到别的桶,contains 返回 false、remove 删不掉,还会导致重复添加——哈希容器的 key 必须不可变(或至少不改参与 hash 的字段)
TreeMap 和 TreeSet:什么时候用红黑树容器?
频次 ★★ · 难度 🟡
是什么:红黑树实现的有序 Map/Set。key 按 Comparable/Comparator 排序,增删查 O(log n);核心价值是 HashMap 给不了的有序能力——有序遍历、范围查询(subMap/headMap)、最近匹配(floorKey/ceilingKey/firstKey)。
选型:需要排序或范围操作才用 TreeMap/TreeSet,否则 HashMap/HashSet 的 O(1) 更快。典型场景:按时间戳范围取数据、一致性哈希环(TreeMap.ceilingEntry 找顺时针第一个节点)。
常见追问
- compareTo 与 equals 不一致会怎样?→ TreeMap 判定”同一个 key”只看
compareTo == 0,不调 equals。经典例子是 BigDecimal:0.1和0.10在 HashSet 里是两个元素(equals 连标度比),在 TreeSet 里是一个(compareTo 数值相等)——同一组对象换个容器语义就变,见Java基础”BigDecimal”一节 - TreeMap 的 key 为什么不能是 null?→ 定位要调 compareTo,null 没法参与比较(HashMap 允许一个 null key,hash 直接按 0 处理)
- 为什么用红黑树而不是 AVL?→ 红黑树是”近似平衡”,插删触发的旋转次数更少(均摊 O(1)),读写均衡的容器更划算;AVL 更严格平衡、查询略快但写代价高
LinkedHashMap 实现 LRU 缓存
频次 ★★★★ · 难度 🟡 · 高频:字节/美团
LinkedHashMap 通过 accessOrder 和 removeEldestEntry 实现 LRU(最近最少使用)淘汰。算法题 146. LRU 缓存 的手写双向链表版就是在模仿 LinkedHashMap 的行为。
核心原理:
accessOrder = true:每次 get/put 访问过的节点移到链表尾部(最新访问)removeEldestEntry(Map.Entry):插入后回调,size() > capacity时返回 true 淘汰链表头部(最久未访问)
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder=true
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // 超过容量淘汰队头
}
}为什么这样就能实现 LRU?
- 每次访问(get/put)将该条目移到双向链表尾部
removeEldestEntry在插入后自动检查容量- 超容量时移除链表头部(最久未访问的条目)
- 全部操作 O(1),无需手动管理指针
横向对比:
| 方案 | 实现复杂度 | 性能 | 面试推荐 |
|---|---|---|---|
| LinkedHashMap | 几行代码 | O(1) | 日常可用 |
| 手写双向链表 + Map | 50+ 行 | O(1) | 算法题考核 |
| 继承 + 重写 | 最简洁 | O(1) | 体现对框架的熟悉 |
关联题: 146. LRU 缓存 手写版;460. LFU 缓存 按频率淘汰(用 LinkedHashSet 代替 LinkedHashMap)
五、Queue / Deque / PriorityQueue
频次 ★★★ · 难度 🟢
接口与实现类
Queue (FIFO)
├── LinkedList — 双向链表实现的队列/双端队列
├── PriorityQueue — 基于数组的二叉堆(默认小顶堆)
├── ArrayDeque — 循环数组实现的双端队列(推荐替代 LinkedList)
└── DelayQueue — 延迟队列,元素到期才可取出ArrayDeque vs LinkedList
| 维度 | ArrayDeque | LinkedList |
|---|---|---|
| 底层 | 循环数组 | 双向链表 |
| 随机访问 | ❌(不支持,这是 Deque 不是 List) | ❌(O(n),但实现了 List 接口) |
| 头尾插入/删除 | O(1),扩容时均摊 | O(1) |
| 内存开销 | 连续内存、缓存友好 | 每个节点有前后指针 + 对象头开销 |
| null | ❌ 不允许 | ✅ 允许 |
结论:作为队列/栈,ArrayDeque 优先于 LinkedList——性能更好、内存更省。LinkedList 的优势是同时实现了 List 和 Deque 两个接口。
PriorityQueue(优先队列)
是什么:基于数组存储的二叉堆,默认小顶堆(peek() 返回最小元素),通过 Comparator 可改为大顶堆。
核心特性:
- 底层
Object[],逻辑上是完全二叉树:queue[n]的子节点是queue[2n+1]和queue[2n+2] - 插入(
offer()):尾部追加 → 上浮(siftUp),O(log n) - 删除堆顶(
poll()):堆顶替换为尾部 → 下沉(siftDown),O(log n) - 删除任意元素(
remove(Object)):找到元素 O(n) + 下沉/上浮 O(log n),总 O(n) - 非线程安全,线程安全版用
PriorityBlockingQueue - 扩容:小于 64 时翻倍 +2,否则扩容 50%
// 大顶堆:取前 k 个最小元素
Queue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);常见追问:
- PriorityQueue 和 TreeMap 怎么选?→ PriorityQueue 只保证堆顶有序,适合动态 Top-K;TreeMap 全局有序,适合范围查询和排序遍历
- 堆排序和 PriorityQueue 的关系?→ PriorityQueue 的
poll()依次取出的就是升序序列,等价于堆排序,但用额外数组存储结果,不是原地排序 - 如何实现延迟队列?→
DelayQueue包装PriorityQueue,元素实现Delayed.getDelay(),poll()时检查过期时间
BlockingQueue(阻塞队列)
频次 ★★★★ · 难度 🟡
是什么:支持在队列空时阻塞消费者、队列满时阻塞生产者的线程安全队列。常用于生产者-消费者模型。
常用实现类:
| 实现类 | 底层 | 有界/无界 | 锁策略 | 特性 |
|---|---|---|---|---|
| ArrayBlockingQueue | 循环数组 | 有界 | 单锁(共用锁) | 容量固定,支持公平/非公平 |
| LinkedBlockingQueue | 单向链表 | 可选有界 | 双锁(takeLock/putLock) | 默认 Integer.MAX_VALUE,吞吐高 |
| SynchronousQueue | 无容量 | 0 容量 | 双栈/双队列 | 每个 put 必须等一个 take,直传不缓存 |
| PriorityBlockingQueue | 二叉堆 | 无界 | 单锁 | PriorityQueue 的线程安全版 |
| DelayQueue | 封装 PriorityQueue | 无界 | 单锁 | 元素到期才可取出 |
| LinkedTransferQueue | 链表 | 无界 | CAS(无锁) | 支持 tryTransfer 直接交付 |
ArrayBlockingQueue vs LinkedBlockingQueue:
- Array 容量固定,且使用同一把锁管理入队出队;Linked 双锁分离,入队出队互不阻塞,吞吐更高
- Array 预分配连续内存,GC 友好;Linked 每个节点是独立对象,节点数多时 GC 压力大
- 生产上做有界队列推荐 ArrayBlockingQueue(可控 + 省内存),但高并发吞吐场景要考虑双锁优势
SynchronousQueue 的特殊性:
- 内部不存储任何元素,
put()必须阻塞到另一个线程take(),反之亦然。适合”直接交付”场景——C3P0/Druid 连接池的等待队列、Executors.newCachedThreadPool 用它 - 相当于”线程之间的握手”,吞吐比有缓冲队列低但延迟更低
队列满/空时的行为(offer vs put vs add):
| 方法 | 满时 | 空时 |
|---|---|---|
add(e) | 抛 IllegalStateException | — |
offer(e) | 返回 false | — |
offer(e, t, unit) | 阻塞 t 时间后返回 false | — |
put(e) | 阻塞直到有空间 | — |
poll() | — | 返回 null |
poll(t, unit) | — | 阻塞 t 时间后返回 null |
take() | — | 阻塞直到有元素 |
remainingCapacity() | 可用容量 | — |
常见追问:
- ArrayBlockingQueue 的公平锁会影响什么?→ 公平模式下,等待最久的线程优先获得锁,减少饥饿但降低吞吐(CAS 次数更多)
- LinkedBlockingQueue 为什么用双锁?→ 入队操作只改 tail,出队只改 head,两把锁并发执行互不阻塞,提高吞吐。这是与 Array(同一把锁)的核心差异
- 无界队列有什么风险?→ 生产者速度 > 消费者时队列无限膨胀,可能 OOM(
newFixedThreadPool+ LinkedBlockingQueue 不设上限的经典陷阱)
六、遍历与 fail-fast
List 遍历中能修改元素吗?
频次 ★★★ · 难度 🟡
- 普通 for 循环:可以
set()替换值 - foreach 循环:
add/remove会抛ConcurrentModificationException(modCount 变化),但list.set()不会 - Iterator:推荐用
Iterator.remove()或ListIterator.set()安全修改
fail-fast 机制是怎么实现的:集合内部用 modCount 记录结构性修改次数(增删算,set 不算);迭代器创建时把它抄成 expectedModCount,每次 next() 比对,不一致立刻抛 CME。foreach 是 Iterator 的语法糖,所以行为一致。
它不是线程安全保证:fail-fast 是”尽力而为的 bug 探测器”——单线程 foreach 里 remove 也会触发;反过来多线程下也可能恰好错过检测。真正的并发安全靠并发容器:CopyOnWriteArrayList / ConcurrentHashMap 的迭代器是 fail-safe(快照或弱一致),不抛 CME,代价是可能读到旧数据。
安全删除的三种姿势:it.remove()(会同步 expectedModCount)、list.removeIf(predicate)(内部迭代)、并发场景直接用并发容器。
PriorityQueue 是如何实现优先级队列的?
频次 ★★ · 难度 🟡
是什么:PriorityQueue 基于二叉堆(默认最小堆)实现的无界优先级队列。offer/poll 时间复杂度 O(log n),peek 为 O(1)。默认自然顺序(元素实现 Comparable),或构造时传入 Comparator。
底层存储:Object[] 数组,queue[0] 是最小元素(堆顶),父子关系为 parent=(i-1)/2、left=2*i+1、right=2*i+2。
为什么不保证遍历顺序:iterator() 返回的是数组顺序,不是优先级顺序——要按优先级取出必须用 poll()。
常见追问:PriorityQueue 是线程安全的吗?→ 不是,并发场景用 PriorityBlockingQueue(基于堆的阻塞队列,take() 在队列空时阻塞等待)。
LinkedHashSet 和 HashSet 的区别是什么?
是什么:LinkedHashSet 继承 HashSet,底层基于 LinkedHashMap(维护插入顺序的双向链表)。HashSet 不保证迭代顺序,LinkedHashSet 按插入顺序迭代。
性能:LinkedHashSet 增删操作比 HashSet 略慢(维护链表开销),但迭代遍历更快(按链表顺序无需扫描数组空位)。
ListIterator 和 Iterator 的区别是什么?
是什么:ListIterator 是 Iterator 的子接口,专为 List 设计,支持双向遍历(hasPrevious() / previous())、在迭代中修改(set() 替换上次返回的元素,add() 在当前位置插入)、获取索引(nextIndex() / previousIndex())。Iterator 只能单向遍历 + remove。
如何实现一个不可变的集合?
是什么:JDK 提供了三种方式创建不可变集合:
Collections.unmodifiableList(list):包装原集合,修改操作抛UnsupportedOperationException,但原集合变化仍会反映到包装视图List.copyOf(collection)(Java 10+):创建独立副本,原集合后续变化不影响List.of(e1, e2, e3)(Java 9+):直接创建不可变集合,null 元素会抛 NPE
为什么需要不可变集合:线程安全(天然,不需要同步)、防御性编程(表明”这个集合不应该被修改”)、作为常量共享。