面试追问地图

主问题必讲关键点下一层追问
ArrayList数组、扩容、随机访问扩容倍数、删除成本、线程不安全位置
LinkedList双向链表、节点开销为什么实际使用少、缓存局部性
CopyOnWriteArrayList写时复制、读无锁内存开销、弱一致性、适用读写比例
HashMap put/gethash 扰动、桶定位、链表/红黑树resize、阈值 8/6、容量为何为 2 次幂
HashMap 并发问题数据竞争、覆盖、可见性JDK7 扩容环、JDK8 仍为何不安全
ConcurrentHashMapCAS + synchronized、节点级锁size 统计、扩容协助、复合操作原子性
HashSetHashMap Key、去重契约可变对象、equals/hashCode 错误
TreeMap/TreeSet红黑树、比较器compareTo 与 equals 不一致会怎样
fail-fastmodCount 检测为什么不是线程安全保证、如何安全删除
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)

深答

操作ArrayListLinkedList
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] = esize++。多线程下会出现:

  • 值为 null:两个线程同时往同一位置赋值,但只执行了一次 size++
  • 索引越界:线程 A 判断不需要扩容,但线程 B 先完成了 size++ 导致越界
  • size 不符size++ 非原子操作,多线程竞态导致计数丢失

ArrayList 和 Vector 的区别

难度 🟢

特性ArrayListVector
线程安全✅(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 定位数组下标
  • 先检查首节点是否匹配,再判断是链表遍历还是红黑树查找

深答

  1. 调用 hash(key) 扰动计算 hash 值
  2. (n-1) & hash 定位桶位置,检查首节点
  3. 首节点 hash 和 key 匹配直接返回
  4. 有下一个节点:如果是 TreeNode 走红黑树 getTreeNode();否则 do-while 遍历链表
  5. 找不到返回 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 的区别

难度 🟢

特性HashMapHashtable
线程安全✅(全方法 synchronized)
null key/value支持(null key 只能一个)不支持
初始容量1611
扩容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.7JDK 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.10.10 在 HashSet 里是两个元素(equals 连标度比),在 TreeSet 里是一个(compareTo 数值相等)——同一组对象换个容器语义就变,见Java基础”BigDecimal”一节
  • TreeMap 的 key 为什么不能是 null?→ 定位要调 compareTo,null 没法参与比较(HashMap 允许一个 null key,hash 直接按 0 处理)
  • 为什么用红黑树而不是 AVL?→ 红黑树是”近似平衡”,插删触发的旋转次数更少(均摊 O(1)),读写均衡的容器更划算;AVL 更严格平衡、查询略快但写代价高

LinkedHashMap 实现 LRU 缓存

频次 ★★★★ · 难度 🟡 · 高频:字节/美团

LinkedHashMap 通过 accessOrderremoveEldestEntry 实现 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)日常可用
手写双向链表 + Map50+ 行O(1)算法题考核
继承 + 重写最简洁O(1)体现对框架的熟悉

关联题: 146. LRU 缓存 手写版;460. LFU 缓存 按频率淘汰(用 LinkedHashSet 代替 LinkedHashMap)


五、Queue / Deque / PriorityQueue

频次 ★★★ · 难度 🟢

接口与实现类

Queue (FIFO)
├── LinkedList       — 双向链表实现的队列/双端队列
├── PriorityQueue    — 基于数组的二叉堆(默认小顶堆)
├── ArrayDeque       — 循环数组实现的双端队列(推荐替代 LinkedList)
└── DelayQueue       — 延迟队列,元素到期才可取出

ArrayDeque vs LinkedList

维度ArrayDequeLinkedList
底层循环数组双向链表
随机访问❌(不支持,这是 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)/2left=2*i+1right=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

为什么需要不可变集合:线程安全(天然,不需要同步)、防御性编程(表明”这个集合不应该被修改”)、作为常量共享。