手写集合与并发组件

共 20 题
📑 题目列表 20 题
#
★★★

1. 手写一个线程安全的队列(锁实现与 CAS 无锁实现),各有什么取舍?

请手写一个线程安全的队列,分别给出基于锁(synchronized/ReentrantLock)的实现和基于 CAS 的无锁实现,并说明二者各自的取舍?

  • 锁实现与 CAS 无锁实现的线程安全机制
  • 阻塞与自旋的区别、吞吐量与公平性
  • 不外借场景下的适用性判断

锁实现(如 LinkedBlockingQueue)用一把锁或读写分离的锁保护头尾指针,入队出队操作原子;优点是简单、可控、天然支持阻塞等待,缺点是锁竞争会带来上下文切换开销。CAS 无锁实现(如 ConcurrentLinkedQueue)用 AtomicReference 配合循环 CAS 更新头尾指针,不阻塞线程,采用自旋重试;优点是高并发下吞吐更高、无死锁、无上下文切换,缺点是实现逻辑复杂、可能出现 ABA 问题(需用版本号或 AtomicStampedReference)、在竞争激烈时自旋浪费 CPU。取舍上:锁适合临界区有阻塞或队列空闲场景多的场景,CAS 适合高并发短临界区、无阻塞要求的场景。

锁的本质是"让出 CPU 等待",CAS 的本质是"原地重试"。锁开销固定但公平可控,CAS 在高并发下能把竞争分散到各线程的自旋上,从而获得更高吞吐。选择时若不要求阻塞式 take/put,优先考虑无锁;若要求元素等待且队列容量有限,则用锁(配合 Condition)更合适。

// 基于锁的简单线程安全队列
public class LockQueue<T> {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    private Node<T> head, tail;
    public void put(T v) {
        lock.lock();
        try {
            if (tail == null) head = tail = new Node<>(v);
            else { tail.next = new Node<>(v); tail = tail.next; }
            notEmpty.signal();
        } finally { lock.unlock(); }
    }
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (head == null) notEmpty.await();
            T v = head.val; head = head.next;
            if (head == null) tail = null;
            return v;
        } finally { lock.unlock(); }
    }
    static class Node<T> { T val; Node<T> next; Node(T v){ val=v; } }
}

// 基于 CAS 的无锁入队(简化)
public class LockFreeQueue<T> {
    private final AtomicReference<Node<T>> head = new AtomicReference<>(new Node<>(null));
    private final AtomicReference<Node<T>> tail = new AtomicReference<>(head.get());
    public void put(T v) {
        Node<T> n = new Node<>(v);
        while (true) {
            Node<T> t = tail.get(), tNext = t.next.get();
            if (tNext != null) { tail.compareAndSet(t, tNext); continue; } // 推进 tail
            if (t.next.compareAndSet(null, n)) { tail.compareAndSet(t, n); return; }
        }
    }
    static class Node<T> { T val; AtomicReference<Node<T>> next = new AtomicReference<>(null); Node(T v){ val=v; } }
}
#
★★★

2. 手写 ConcurrentHashMap 的简化版(分段锁/CAS+链表),要处理哪些并发细节?

请手写一个 ConcurrentHashMap 的简化版,可采用分段锁或 CAS+链表,并说明需要处理哪些并发细节?

  • 分段锁与 CAS 的并发控制策略
  • 扩容、put 覆盖、size 统计的并发安全
  • 可见性与原子性

简化版可用"分段锁":把数组分成多个段,每段一把锁,put/hash 到对应段加锁放入链表,get 无锁读;或用"CAS+链表":空槽位用 CAS 原子写入,非空槽位对链表头加 synchronized 锁。需要处理的并发细节包括:槽位数组引用的可见性(用 volatile)、put 时对空槽的 CAS 防覆盖、链表头节点的加锁、并发 put 时对同一桶的串行化、扩容时对旧表新表的迁移安全、以及 size() 统计时避免重复计数(分段计数或累加锁)。还要处理扩容时读到旧表/新表的问题,一般用 for 循环在成功后重读。

分段锁把全局锁拆成多把,降低锁竞争,是 1.7 的实现思路;CAS+链表是 1.8 的思路,空槽用 CAS、非空槽用 synchronized 锁桶头,粒度更细且锁升级对短临界区更友好。并发细节的核心是"读无锁、写有锁、空槽 CAS、扩容协作"。

public class SimpleConcurrentHashMap<K,V> {
    final int SEGMENTS = 16;
    final Object[] locks = new Object[SEGMENTS];
    final Node<K,V>[] table;
    public SimpleConcurrentHashMap(int capacity) {
        for (int i=0;i<SEGMENTS;i++) locks[i]=new Object();
        table = (Node<K,V>[])new Node[capacity];
    }
    public void put(K k, V v) {
        int h = (k==null)?0:k.hashCode();
        int seg = h & (SEGMENTS-1);
        int idx = h & (table.length-1);
        synchronized(locks[seg]) {
            Node<K,V> n = table[idx];
            if (n==null) { table[idx]=new Node<>(k,v); return; }
            Node<K,V> p=n;
            while(p!=null){ if(p.key.equals(k)){p.val=v;return;} p=p.next; }
            table[idx]=new Node<>(k,v,n);
        }
    }
    public V get(K k) {
        int h=(k==null)?0:k.hashCode();
        int idx=h & (table.length-1);
        for(Node<K,V> n=table[idx];n!=null;n=n.next)
            if(n.key.equals(k)) return n.val;
        return null;
    }
    static class Node<K,V>{ K key; V val; Node<K,V> next;
        Node(K k,V v){key=k;val=v;} Node(K k,V v,Node<K,V> n){key=k;val=v;next=n;} }
}
#
★★★

3. 手写无锁队列/栈(Michael-Scott Queue 或 Treiber Stack)并用 AtomicReference/CAS 实现,ABA 问题如何出现与解决?

请手写一个无锁栈(Treiber Stack)或无锁队列(Michael-Scott Queue),用 AtomicReference/CAS 实现,并说明 ABA 问题如何出现与解决?

  • CAS 无锁栈/队列的 push/pop 实现
  • ABA 问题的成因
  • 版本号与 AtomicStampedReference 解决 ABA

Treiber 栈用 AtomicReference 指向栈顶,push 时用 CAS 把新节点顶到栈顶,pop 时 CAS 把栈顶换为其 next。ABA 问题:线程 A 读到栈顶值为 X,另一线程 B 把 X 弹走、又压回一个相同地址的 X(或其他操作使其回到 X),A 的 CAS 判断"仍是 X"就成功了,但实际栈结构已变化,会导致错误。解决方式:用 AtomicStampedReference 携带版本号(stamp),CAS 时同时比较引用和版本号;或每次操作递增版本号。Michael-Scott 队列是双端(head/tail)的 CAS 队列,需要处理 tail 延迟推进等细节。

ABA 的本质是"值相同但状态已变",单纯的引用比较无法区分。版本号让每次写入都递增,即使引用值回到原地址,版本号不同,CAS 也会失败。除非用无锁的 GC 安全环境(如 JVM 的垃圾回收保证节点不被复用,通常不会 ABA),否则必须用版本号。

public class TreiberStack<T> {
    private final AtomicReference<Node<T>> top = new AtomicReference<>();
    public void push(T v) {
        Node<T> n = new Node<>(v);
        while (true) {
            Node<T> t = top.get();
            n.next = t;
            if (top.compareAndSet(t, n)) return;
        }
    }
    public T pop() {
        while (true) {
            Node<T> t = top.get();
            if (t == null) return null;
            if (top.compareAndSet(t, t.next)) return t.val;
        }
    }
    static class Node<T> { T val; Node<T> next; Node(T v){ val=v; } }
}

// 用版本号解决 ABA
public class AbaSafeStack<T> {
    private final AtomicStampedReference<Node<T>> top = new AtomicStampedReference<>(null, 0);
    public void push(T v) {
        Node<T> n = new Node<>(v);
        while (true) {
            int[] stamp = new int[1];
            Node<T> t = top.get(stamp);
            n.next = t;
            if (top.compareAndSet(t, n, stamp[0], stamp[0]+1)) return;
        }
    }
    static class Node<T> { T val; Node<T> next; Node(T v){val=v;} }
}
#
★★★

4. 手写一个 LRU 缓存(哈希表 + 双向链表)并分析 get/put 的时间复杂度与并发安全方案?

请手写一个 LRU 缓存,基于哈希表 + 双向链表实现,并分析 get/put 的时间复杂度与并发安全方案?

  • 哈希表定位 + 双向链表维护访问顺序
  • O(1) 的 get/put
  • 加锁与缓存淘汰的并发安全

LRU 用 HashMap 记录 key→节点,双向链表按访问时间从最近到最久排列。get 时命中则把节点移到链表头(O(1));put 时若已存在则更新值并移到头,若不存在则插入头,若超容量则删除链表尾节点并同步删除哈希表项,全部 O(1)。并发安全:可用 synchronized 或 ReentrantLock 包住整个操作,也可用细粒度锁或分段;LinkedHashMap 的 accessOrder 模式配合 removeEldestEntry 即可实现 LRU。注意双向链表需要维护 prev/next 指针,删除节点 O(1)。

HashMap 提供 O(1) 定位,双向链表提供 O(1) 的插入/删除(单链表删除需要前驱指针,双向链表无需遍历)。淘汰最久未用即链表尾。并发场景下若用全局锁,简单但竞争大;LinkedHashMap 不是线程安全,需外部同步。

public class LRUCache<K,V> {
    private final Map<K, Node<K,V>> map;
    private final int capacity;
    private final Node<K,V> head = new Node<>(null,null); // 哨兵
    private final Node<K,V> tail = new Node<>(null,null);
    public LRUCache(int cap) {
        capacity = cap; map = new HashMap<>();
        head.next = tail; tail.prev = head;
    }
    public synchronized V get(K key) {
        Node<K,V> n = map.get(key);
        if (n == null) return null;
        moveToHead(n); return n.val;
    }
    public synchronized void put(K key, V val) {
        Node<K,V> n = map.get(key);
        if (n != null) { n.val = val; moveToHead(n); return; }
        n = new Node<>(key, val);
        map.put(key, n); addToHead(n);
        if (map.size() > capacity) {
            Node<K,V> last = removeTail();
            map.remove(last.key);
        }
    }
    private void addToHead(Node<K,V> n){
        n.next = head.next; n.prev = head;
        head.next.prev = n; head.next = n;
    }
    private void moveToHead(Node<K,V> n){
        n.prev.next = n.next; n.next.prev = n.prev; addToHead(n);
    }
    private Node<K,V> removeTail(){
        Node<K,V> n = tail.prev; n.prev.next = tail; tail.prev = n.prev; return n;
    }
    static class Node<K,V>{ K key; V val; Node<K,V> prev,next; Node(K k,V v){key=k;val=v;} }
}
#
★★★

5. 手写 HashMap,数组+链表(+红黑树)的 put/get/resize 实现要点

请手写 HashMap 的核心实现,说明数组 + 链表(+ 红黑树)的 put/get/resize 实现要点?

  • 数组索引计算与哈希扰动
  • put 的冲突处理与链表/树化
  • resize 的扩容与重新哈希

HashMap 底层是 Node 数组,put 时先扰动 hash 再与 (length-1) 取模得数组下标;若该槽为空直接放入,否则沿链表查找,若 key 已存在则覆盖 value,否则插入链表尾部;当链表长度超过 8 且表长达到 64 时树化为红黑树。get 时定位下标后沿链表/树查找 key。resize 扩容为 2 倍,旧表元素重新计算下标迁移,1.8 用高低位拆分(原索引或原索引+旧容量)。要点:默认容量 16、负载因子 0.75、树化阈值 8、反树化 6、容量必须是 2 的幂。

2 的幂保证 (n-1)&hash 等价于取模且更快,也便于扩容时高低位拆分。树化是为解决极端哈希冲突下的退化,把 O(n) 查找降到 O(log n)。

public class SimpleHashMap<K,V> {
    static final int DEFAULT_CAP = 16;
    Node<K,V>[] table;
    int size;
    public SimpleHashMap(){ table = new Node[DEFAULT_CAP]; }
    static int hash(Object key){ int h = key.hashCode(); return h ^ (h>>>16); }
    public void put(K k, V v) {
        int i = hash(k) & (table.length-1);
        Node<K,V> n = table[i];
        if (n == null) { table[i] = new Node<>(hash(k),k,v,null); size++; return; }
        for (Node<K,V> p=n; p!=null; p=p.next) {
            if (p.key.equals(k)) { p.val=v; return; }
            if (p.next==null) { p.next=new Node<>(hash(k),k,v,null); size++; return; }
        }
    }
    public V get(K k) {
        Node<K,V> n = table[hash(k) & (table.length-1)];
        for (; n!=null; n=n.next) if (n.key.equals(k)) return n.val;
        return null;
    }
    static class Node<K,V>{ int hash; K key; V val; Node<K,V> next;
        Node(int h,K k,V v,Node<K,V> n){hash=h;key=k;val=v;next=n;} }
}
#
★★

6. 手写一个支持过期淘汰的本地缓存(TTL+LRU 组合)?

请手写一个支持过期淘汰的本地缓存,将 TTL 与 LRU 组合起来?

  • TTL 记录与过期判断
  • LRU 淘汰与容量控制
  • 过期清理与并发安全

可在 LRU 基础上为每个节点记录写入时间戳(或过期时间),get 时先判断是否过期,过期则删除并返回 null;同时可启动一个后台线程定时清理全部过期项,或惰性删除(访问时清理)。TTL 处理"时间过期",LRU 处理"容量淘汰",两者互补。并发安全用 synchronized 保护整个操作,或用 ConcurrentHashMap + 定时清理。注意:仅靠 LRU 无法清理"长期未访问但已过期"的项,需要主动清理线程。

TTL 解决"数据本身的时效性",LRU 解决"内存容量有限"。组合时需在 get 时检查过期(惰性),并配合定时清理处理从未再访问的过期项,避免内存泄漏。

public class TTLLRUCache<K,V> {
    private final Map<K, Node<K,V>> map = new HashMap<>();
    private final int capacity;
    private final long ttlMillis;
    private final Node<K,V> head = new Node<>(null,null,0), tail = new Node<>(null,null,0);
    public TTLLRUCache(int cap, long ttl){ capacity=cap; ttlMillis=ttl; head.next=tail; tail.prev=head; }
    public synchronized V get(K key) {
        Node<K,V> n = map.get(key);
        if (n==null) return null;
        if (System.currentTimeMillis()-n.ts > ttlMillis) { remove(n); map.remove(key); return null; }
        moveToHead(n); return n.val;
    }
    public synchronized void put(K key, V val){
        Node<K,V> n = map.get(key);
        if(n!=null){ n.val=val; n.ts=System.currentTimeMillis(); moveToHead(n); return; }
        n=new Node<>(key,val,System.currentTimeMillis());
        map.put(key,n); addToHead(n);
        if(map.size()>capacity){ Node<K,V> last=tail.prev; last.prev.next=tail; tail.prev=last.prev; map.remove(last.key); }
    }
    // 后台定时清理
    public synchronized void evictExpired(){
        for (Iterator<Node<K,V>> it = map.values().iterator(); it.hasNext();) {
            Node<K,V> n = it.next();
            if (System.currentTimeMillis()-n.ts > ttlMillis) { remove(n); it.remove(); }
        }
    }
    private void addToHead(Node<K,V> n){ n.next=head.next; n.prev=head; head.next.prev=n; head.next=n; }
    private void moveToHead(Node<K,V> n){ n.prev.next=n.next; n.next.prev=n.prev; addToHead(n); }
    private void remove(Node<K,V> n){ n.prev.next=n.next; n.next.prev=n.prev; }
    static class Node<K,V>{ K key; V val; long ts; Node<K,V> prev,next; Node(K k,V v,long t){key=k;val=v;ts=t;} }
}
#
★★

7. 手写 CopyOnWrite 思想的读写容器,说明何时复制何时共享?

请手写一个基于 CopyOnWrite 思想的读写容器,并说明何时复制、何时共享?

  • CopyOnWrite 的写时复制机制
  • 读快照与写新数组
  • 适用场景与代价

CopyOnWrite 容器内部持有一个 volatile 数组引用,读操作直接读该数组(无锁、无阻塞);写操作加锁后复制一份新数组,在新数组上修改,再用 volatile 写回引用。这样读操作永远看到一份"快照",不受并发写影响。何时共享:读操作、以及写完成后新数组引用发布时共享;何时复制:每次写操作(add/remove/set)都复制。代价是写操作开销大、内存占用翻倍,适合读多写少、数据量小、一致性要求为"弱一致"(迭代器是快照)的场景。

volatile 保证数组引用更新对读线程可见,写时复制保证读写互不阻塞。因其"写复制"代价,写入频繁时性能差;迭代器遍历的是创建时的数组快照,不反映后续修改。

public class CopyOnWriteList<E> {
    private volatile Object[] array = new Object[0];
    public synchronized void add(E e) {
        Object[] old = array;
        Object[] neo = Arrays.copyOf(old, old.length+1);
        neo[old.length] = e;
        array = neo; // volatile 发布,读线程可见
    }
    @SuppressWarnings("unchecked")
    public E get(int i) { return (E) array[i]; } // 无锁读
    public int size() { return array.length; }
}
#
★★

8. 手写跳表(SkipList)并说明与红黑树在并发场景下的实现取舍?

请手写一个跳表(SkipList),并说明与红黑树在并发场景下的实现取舍?

  • 跳表的分层结构与查找
  • 与红黑树的复杂度对比
  • 并发下的无锁实现取舍

跳表是多层有序链表,底层含全部元素,上层是稀疏索引,查找时从顶层向下逐层定位,平均 O(log n)。插入时用随机数决定新节点的层数,并发场景下可用 CAS 无锁实现(ConcurrentSkipListMap 即如此),因为插入只影响局部节点指针,CAS 更新指针即可;而红黑树需要旋转与重着色,改变全局结构,很难无锁化。因此并发场景下跳表更适合无锁实现,红黑树一般用锁。复杂度上两者平均都是 O(log n),但跳表实现简单、局部性好、适合并发。

红黑树自平衡需要整树视角的旋转,难以用 CAS 局部化;跳表指针更新只涉及相邻节点,天然适合 CAS 无锁。这是 ConcurrentSkipListMap 选择跳表而非红黑树的根本原因。

import java.util.concurrent.ThreadLocalRandom;
public class SimpleSkipList {
    static final int MAX_LEVEL = 16;
    class Node { int val; Node[] next; Node(int v,int l){ val=v; next=new Node[l]; } }
    Node head = new Node(Integer.MIN_VALUE, MAX_LEVEL);
    int level = 1;
    public boolean contains(int v) {
        Node cur = head;
        for (int i=level-1;i>=0;i--) {
            while (cur.next[i]!=null && cur.next[i].val<v) cur=cur.next[i];
        }
        cur = cur.next[0];
        return cur!=null && cur.val==v;
    }
    public void add(int v) {
        int lvl = 1;
        while (ThreadLocalRandom.current().nextInt(2)==0 && lvl<MAX_LEVEL) lvl++;
        Node[] update = new Node[MAX_LEVEL];
        Node cur = head;
        for (int i=level-1;i>=0;i--) {
            while (cur.next[i]!=null && cur.next[i].val<v) cur=cur.next[i];
            update[i]=cur;
        }
        Node n = new Node(v, lvl);
        for (int i=0;i<lvl;i++) { n.next[i]=update[i].next[i]; update[i].next[i]=n; }
        if (lvl>level) level=lvl;
    }
}
#
★★

9. 手写一个原子计数器(CAS 自旋)并对比 LongAdder 的 cell 分段思路,什么场景下后者吞吐更高?

请手写一个基于 CAS 自旋的原子计数器,并对比 LongAdder 的 cell 分段思路,说明什么场景下后者吞吐更高?

  • CAS 自旋计数器的实现
  • LongAdder 的 cell 数组分段
  • 竞争激烈时的吞吐差异

CAS 计数器用 AtomicLong 的 while 循环 CAS 累加,单点竞争,高并发下所有线程争抢同一个变量,CAS 失败率高、吞吐受限。LongAdder 把计数拆分为 base 和 Cell[] 数组,每个线程映射到自己的 Cell 上累加,互不争抢,最后求和时把 base 与所有 Cell 相加。在写竞争激烈(写多)的场景,LongAdder 吞吐显著更高,因为把竞争分散到多个 cell;但读需要遍历所有 cell 求和,若读频繁则 AtomicLong 更合适。读多写少用 AtomicLong,写多读少用 LongAdder。

单点 CAS 在高竞争下大量自旋浪费 CPU;分段后每个线程命中自己的 cell,减少冲突。LongAdder 牺牲了读的简单性换取写的吞吐,是"空间换时间、分散竞争"的典型。

public class AtomicCounter {
    AtomicLong value = new AtomicLong();
    public long inc() {
        while (true) {
            long cur = value.get();
            long neo = cur + 1;
            if (value.compareAndSet(cur, neo)) return neo;
        }
    }
}
// LongAdder 简化思路:多个 cell,线程分散累加
public class LongAdderLike {
    long base;
    long[] cells = new long[4];
    public void add(int v) {
        int i = (int)(Thread.currentThread().getId() & (cells.length-1));
        cells[i] += v; // 简化:用各自 cell,减少竞争
    }
    public long sum() { long s=base; for(long c:cells) s+=c; return s; }
}
#
★★

10. 手写线程安全的 HashMap 简化版(分段锁 vs CAS+链表)并说明与 ConcurrentHashMap 的差距?

请手写一个线程安全的 HashMap 简化版,对比分段锁与 CAS+链表两种方式,并说明与 ConcurrentHashMap 的差距?

  • 分段锁与 CAS+链表的实现思路
  • 与真实 ConcurrentHashMap 的功能差距
  • 扩容、size、计数器等缺失能力

分段锁版把数组分成若干段,每段一把锁,只有落到同一段的操作才互斥;CAS+链表版空槽用 CAS、非空槽对链表头加锁。与真实 ConcurrentHashMap 的差距:简化版通常没有并发扩容(多线程协助迁移)、没有 CounterCell 的高并发 size 统计、没有红黑树树化、没有 ForwardingNode 标记、没有对迁移中读取的处理,也没有 LongAdder 分段计数。这些差异导致简化版在效率、扩展性和一致性上不如生产版本。

生产版 ConcurrentHashMap 是精心调优的复杂结构,简化版只体现核心思想。面试写简化版时点明"这是教学简化,实际还有扩容协作、树化、计数分段等"更能体现理解深度。

// 分段锁版(16 段)
public class SegmentedMap<K,V> {
    final int SEGMENTS = 16;
    final Object[] locks = new Object[SEGMENTS];
    final Map<K,V>[] segs = new Map[SEGMENTS];
    public SegmentedMap(){ for(int i=0;i<SEGMENTS;i++){ locks[i]=new Object(); segs[i]=new HashMap<>(); } }
    public void put(K k, V v){ int s = (k.hashCode() & 0x7fffffff) % SEGMENTS; synchronized(locks[s]){ segs[s].put(k,v); } }
    public V get(K k){ int s = (k.hashCode() & 0x7fffffff) % SEGMENTS; return segs[s].get(k); }
}
#
★★

11. 手写 BlockingQueue,lock+condition 与锁分段两种实现的差异如何?

请手写一个 BlockingQueue,说明 lock+condition 与锁分段两种实现的差异?

  • ReentrantLock + Condition 实现 put/take 阻塞
  • 锁分段对并发度的提升
  • 两者的适用场景

lock+condition 实现:用一把锁和两个 Condition(notEmpty、notFull),put 时若队列满则 notFull.await(),放入后 notEmpty.signal();take 时若空则 notEmpty.await(),取走后 notFull.signal()。锁分段:把队列分成多个独立子队列,每段有自己的锁和容量,put/hash 到某段,这样并发可分散到多个段,提高吞吐。差异:lock+condition 简单、容量共享、天然有序;锁分段并发度高但实现复杂、需要处理段间容量与顺序。LinkedBlockingQueue 就是 lock+condition 实现(头尾两把锁)。

单锁实现简单可靠,但在高并发下 put 和 take 争同一把锁;锁分段用空间换并发。选择取决于对吞吐与实现复杂度的要求。

public class BlockingQueueBounded<T> {
    private final Object[] items;
    private int putIndex, takeIndex, count;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    private final Condition notFull = lock.newCondition();
    public BlockingQueueBounded(int cap){ items = new Object[cap]; }
    public void put(T v) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) notFull.await();
            items[putIndex] = v;
            if (++putIndex == items.length) putIndex = 0;
            count++;
            notEmpty.signal();
        } finally { lock.unlock(); }
    }
    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) notEmpty.await();
            T v = (T) items[takeIndex];
            items[takeIndex] = null;
            if (++takeIndex == items.length) takeIndex = 0;
            count--;
            notFull.signal();
            return v;
        } finally { lock.unlock(); }
    }
}
#
★★

12. 手写 ArrayList,扩容策略、迭代器与 fail-fast 的简化实现

请手写 ArrayList 的简化实现,说明扩容策略、迭代器与 fail-fast 机制?

  • 动态数组扩容与 System.arraycopy
  • 迭代器实现与 modCount
  • fail-fast 快速失败

ArrayList 底层是 Object[],默认容量 10,扩容时按 1.5 倍(oldCapacity + oldCapacity>>1)扩容,用 System.arraycopy 复制旧元素到新数组。迭代器持有 expectedModCount 快照,每次 next 时检查 modCount 是否变化,发生变化则抛 ConcurrentModificationException(fail-fast)。任何结构性修改(add/remove)都会使 modCount++。注意:fail-fast 是"尽力而为"的检测,不是强一致保证。

1.5 倍扩容在"扩容次数"与"空间浪费"间平衡;System.arraycopy 是 native 的快速内存拷贝。fail-fast 通过 modCount 与 expectedModCount 比对,让并发修改能在迭代早期暴露,而不是静默出错。

public class SimpleArrayList<E> {
    private Object[] data;
    private int size;
    private int modCount;
    public SimpleArrayList(){ data = new Object[10]; }
    public void add(E e) {
        if (size == data.length) {
            int newCap = data.length + (data.length >> 1); // 1.5 倍
            data = Arrays.copyOf(data, newCap);
        }
        data[size++] = e;
        modCount++;
    }
    @SuppressWarnings("unchecked")
    public E get(int i){ return (E)data[i]; }
    public Iterator<E> iterator() {
        return new Iterator<E>() {
            int cursor = 0, expected = modCount;
            public boolean hasNext(){ return cursor < size; }
            public E next() {
                if (expected != modCount) throw new ConcurrentModificationException();
                return (E)data[cursor++];
            }
        };
    }
}
#
★★

13. 手写 CopyOnWriteArrayList 的迭代器语义,为什么迭代器基于不可变快照、弱一致性体现在哪,与普通 ArrayList 迭代器的差异如何?

请手写 CopyOnWriteArrayList 的迭代器语义,说明为什么迭代器基于不可变快照、弱一致性体现在哪,以及与普通 ArrayList 迭代器的差异?

  • 迭代器基于快照数组
  • 弱一致性(不抛异常、不反映最新修改)
  • 与 ArrayList 迭代器 fail-fast 的对比

CopyOnWriteArrayList 的迭代器在构造时持有当前数组引用的快照,遍历时使用该快照,因此遍历期间其他线程的修改不会反映到迭代器中,也不会抛 ConcurrentModificationException。这就是"弱一致性":迭代器看到的是创建时的数据快照,可能过期,但不会因并发修改出错。与之对比,ArrayList 迭代器是 fail-fast,遍历期间若集合被结构性修改就抛异常。CopyOnWriteArrayList 迭代器自身不支持 add/remove(会抛 UnsupportedOperationException),因为要保证不可变快照语义。

快照迭代器用"最终一致性"换取"永不失败",适合读多写少、遍历期间不关心最新数据的场景。代价是遍历可能读到旧数据。

public class SnapshotIterator<E> implements Iterator<E> {
    private final Object[] snapshot; // 创建时快照
    private int cursor;
    public SnapshotIterator(Object[] arr){ snapshot = arr; }
    public boolean hasNext(){ return cursor < snapshot.length; }
    @SuppressWarnings("unchecked")
    public E next(){ return (E) snapshot[cursor++]; }
}
#
★★

14. 手写 ConcurrentLinkedQueue 的非阻塞入队,原子 CAS 更新 tail 的延迟指针(hop)优化,为什么入队操作需要分两步?

请手写 ConcurrentLinkedQueue 的非阻塞入队,说明原子 CAS 更新 tail 的延迟指针(hop 与延迟更新)优化,为什么入队操作需要分两步?

  • CAS 入队的两步操作
  • tail 延迟推进的 hop 优化
  • 失败自旋与帮助推进

ConcurrentLinkedQueue 入队需要两步:先 CAS 把新节点挂到队尾节点的 next 上,再 CAS 推进 tail 指向新节点。这两步之间是非原子的,所以 tail 可能滞后于实际队尾(延迟更新)。为了减少 tail 的 CAS 竞争,采用 hop 优化:tail 不是每入队一次就推进,而是当 tail 与真正队尾的距离大于阈值(源码中为 1,即下一个节点非空时)才推进,从而减少对 tail 的 CAS 次数。入队时若发现 tail 落后(tail.next 非空),会先尝试向前推进 tail 帮助其他线程完成入队,再把自己的节点挂上去,保证不丢节点。

"两步"把"挂节点"和"推进 tail"分离,使多个线程可以并发在尾部挂节点,只有推进 tail 时才竞争。帮助推进机制保证即使某个线程在处理中暂停,其他线程也能完成 tail 的推进,避免死锁于半完成状态。

public class CLQSimplified<T> {
    private final AtomicReference<Node<T>> head = new AtomicReference<>(new Node<>(null));
    private final AtomicReference<Node<T>> tail = new AtomicReference<>(head.get());
    public boolean offer(T v) {
        Node<T> n = new Node<>(v);
        while (true) {
            Node<T> t = tail.get();
            Node<T> tNext = t.next.get();
            if (tNext != null) { // tail 落后,帮忙推进
                tail.compareAndSet(t, tNext);
                continue;
            }
            if (t.next.compareAndSet(null, n)) { // 第一步:挂节点
                tail.compareAndSet(t, n); // 第二步:推进 tail(hop 优化,不必每次都做)
                return true;
            }
        }
    }
    static class Node<T> { T val; AtomicReference<Node<T>> next = new AtomicReference<>(null); Node(T v){ val=v; } }
}
#
★★

15. 手写并发计数器,AtomicLong 与 LongAdder 的分段思路对比,读多写多场景的适用边界如何?

请手写并发计数器,对比 AtomicLong 与 LongAdder 的分段思路,说明读多写多场景的适用边界?

  • AtomicLong 单点 CAS
  • LongAdder 分段 cell
  • 读多写多的场景取舍

AtomicLong 用单变量 CAS,读 O(1) 且很便宜,写在高竞争下 CAS 失败率高。LongAdder 用 base+Cell[] 分段,写分散到各自 cell 竞争小,但 sum() 需遍历所有 cell,读成本高。适用边界:读多写少(统计类、配置计数)用 AtomicLong,读成本低;写多读少(高频递增的计数器、流量统计)用 LongAdder,写吞吐高。若读写都频繁,需权衡:读多就用 AtomicLong,写多且读不频繁用 LongAdder。LongAdder 也适合"最终一致"的计数,不适合要求每次读到精确最新值的场景。

本质是"读写成本的权衡"。AtomicLong 读便宜写贵,LongAdder 写便宜读贵。选择取决于读与写哪个更频繁、对读的实时性要求。

// 读多写少:AtomicLong
long inc(){ return counter.incrementAndGet(); }
// 写多读少:LongAdder
adder.add(1); long sum = adder.sum();
#
★★

16. 手写分段锁 Map 的锁粒度选择,桶级锁 vs 段级锁的扩容安全与并发度,锁粒度与内存开销如何权衡?

请手写分段锁 Map,说明桶级锁与段级锁的锁粒度选择,分析扩容安全与并发度,以及锁粒度与内存开销的权衡?

  • 桶级锁与段级锁的粒度
  • 扩容时的安全性
  • 锁粒度与内存开销权衡

桶级锁(如 1.8 的 synchronized 锁链表头)粒度最细,无冲突的操作完全并行,并发度最高,但每个桶都要处理锁,实现复杂;段级锁(如 1.7 的 Segment 数组)把若干桶归为一段,一段一把锁,并发度 = 段数,实现简单但同一段内仍会竞争。扩容时:段级锁扩容只需锁住该段,其他段可并发读写;桶级锁扩容需处理多线程迁移,通过 ForwardingNode 标记和 helpTransfer 协作。锁粒度越细并发度越高,但锁对象越多、内存开销越大、实现越复杂;段级锁用少量锁换简单。权衡:追求并发度用桶级,追求简单用段级。

锁粒度与并发度成正比,与内存/复杂度成正比。1.7 到 1.8 的演进就是从段级到桶级,配合 CAS 空槽,降低了锁竞争。扩容与锁粒度强相关:段级锁扩容只影响该段,桶级锁扩容需跨线程协作。

// 段级锁:段数 = 并发度
public class SegMap<K,V> {
    final int SEGMENTS = 16;
    final Object[] locks = new Object[SEGMENTS];
    final Map<K,V>[] segs = new Map[SEGMENTS];
    public SegMap(){ for(int i=0;i<SEGMENTS;i++){ locks[i]=new Object(); segs[i]=new HashMap<>(); } }
    // 桶级锁思路:synchronized(table[hash]) 锁单个桶
}
#

17. 手写一个阻塞队列(take/put 的等待与唤醒)并说明与 LinkedBlockingQueue 的差异?

请手写一个阻塞队列,实现 take/put 的等待与唤醒,并说明与 LinkedBlockingQueue 的差异?

  • 条件等待与唤醒
  • 与 LinkedBlockingQueue 的差异
  • 有界与无界

阻塞队列用 lock + condition,put 满时 await 等待非满,take 空时 await 等待非空;放入后 signal 非空,取出后 signal 非满。与 LinkedBlockingQueue 的差异:LinkedBlockingQueue 基于链表节点,容量可设(有界),链表不用循环数组,且用头尾两把锁(putLock 锁 tail、takeLock 锁 head)提高并发度;手写版若用单锁则并发度低。另外 LinkedBlockingQueue 支持 offer 超时、peek、drainTo 等更丰富的 API。

手写版体现核心的等待唤醒机制;LinkedBlockingQueue 通过头尾分锁让 put 与 take 可并发进行,是重要的工程优化。

public class BlockingQueue<T> {
    private final Deque<T> deque = new LinkedList<>();
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    private final int capacity;
    public BlockingQueue(int cap){ capacity = cap; }
    public void put(T v) throws InterruptedException {
        lock.lock();
        try {
            while (deque.size() == capacity) notEmpty.await();
            deque.addLast(v);
            notEmpty.signal();
        } finally { lock.unlock(); }
    }
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (deque.isEmpty()) notEmpty.await();
            T v = deque.removeFirst();
            notEmpty.signal();
            return v;
        } finally { lock.unlock(); }
    }
}
#

18. 手写环形缓冲(Ring Buffer)并用 volatile 指针实现单生产者单消费者无锁队列,容量为何取 2 的幂?

请手写环形缓冲(Ring Buffer),用 volatile 指针实现单生产者单消费者无锁队列,并说明容量为何取 2 的幂?

  • 环形缓冲的读写指针
  • 单生产者单消费者的无锁可用性
  • 2 的幂容量的位运算取模

环形缓冲用固定长度数组,读写两边各自维护指针,单生产者单消费者场景下读写指针不会同时被多线程修改,因此用 volatile 就能保证可见性,无需加锁。容量取 2 的幂是为了用位运算 (index & (capacity-1)) 代替取模运算,性能更高;同时保证指针回绕正确。判空条件是 readIndex == writeIndex,判满条件是 (writeIndex+1) & mask == readIndex。

单生产者单消费者下,写者只改 writeIndex、读者只改 readIndex,各自单写者,volatile 保证可见性即可无锁。2 的幂容量让回绕用位运算实现,且放过一个槽位用于区分空/满。

public class RingBuffer<T> {
    private final Object[] buf;
    private final int mask;
    private volatile int readIndex = 0, writeIndex = 0;
    public RingBuffer(int capacity){ // capacity 必须是 2 的幂
        this.buf = new Object[capacity];
        this.mask = capacity - 1;
    }
    public boolean offer(T v) {
        if (((writeIndex + 1) & mask) == readIndex) return false; // 满
        buf[writeIndex & mask] = v;
        writeIndex++;
        return true;
    }
    @SuppressWarnings("unchecked")
    public T poll() {
        if (readIndex == writeIndex) return null; // 空
        T v = (T) buf[readIndex & mask];
        readIndex++;
        return v;
    }
}
#

19. 手写 DelayQueue 简化版(优先队列 + 条件等待)并说明与 ScheduledThreadPoolExecutor 的关系?

请手写一个 DelayQueue 简化版,用优先队列 + 条件等待实现,并说明与 ScheduledThreadPoolExecutor 的关系?

  • 优先队列 + 延迟比较
  • 队首未到期时的等待
  • 与 ScheduledThreadPoolExecutor 的关系

DelayQueue 内部用 PriorityQueue 按"到期时间"排序,元素实现 Delayed 接口(getDelay 返回剩余时间,compareTo 按时长排序)。take 时若队首到期则取出,否则等待队首剩余时间(awaitNanos)。与 ScheduledThreadPoolExecutor 的关系:ScheduledThreadPoolExecutor 内部用 DelayedWorkQueue(本质是 DelayQueue 的变体)作为任务队列,任务封装成 ScheduledFutureTask(实现 Delayed),到期后由工作线程取出执行。所以 DelayQueue 是定时任务调度的基础。

优先队列保证队首总是最近到期的任务;条件等待让线程在队首未到期时休眠而非忙等。ScheduledThreadPoolExecutor 复用这套"延迟队列"机制实现定时与周期执行。

public class DelayQueue<E extends Delayed> {
    private final PriorityQueue<E> q = new PriorityQueue<>();
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition available = lock.newCondition();
    public E take() throws InterruptedException {
        lock.lock();
        try {
            for (;;) {
                E first = q.peek();
                if (first == null) available.await();
                else {
                    long delay = first.getDelay(TimeUnit.NANOSECONDS);
                    if (delay <= 0) return q.poll();
                    available.awaitNanos(delay); // 等待到队首到期
                }
            }
        } finally { lock.unlock(); }
    }
    public void put(E e){ lock.lock(); try { q.offer(e); available.signal(); } finally { lock.unlock(); } }
}
#

20. 手写对象池,借用/归还、池满策略与泄漏检测

请手写一个对象池,说明借用/归还、池满策略与泄漏检测?

  • 借用 acquire 与归还 release
  • 池满与池空策略
  • 泄漏检测(借用超时/归还校验)

对象池维护一个空闲对象的集合,acquire 时取出可用对象(池空则新建或等待),release 时归还对象(池满则丢弃或扩容)。池满策略:归还时若池已满则丢弃对象或阻塞;池空获取时常见策略是新建对象(无界)或阻塞等待归还。泄漏检测:为每个借出的对象记录借用时间与借用者,归还时校验该对象确实是借出的且未被重复归还;可定时扫描超时未归还的对象,告警或回收。还可提供"归还前重置状态"的回调,避免脏状态复用。

对象池的核心是复用昂贵对象(连接、线程、缓冲区)减少创建开销。池满与池空策略决定资源上限与等待行为;泄漏检测防止借出对象未归还导致资源耗尽。

public class ObjectPool<T> {
    private final Deque<T> idle = new ArrayDeque<>();
    private final Set<T> borrowed = new HashSet<>();
    private final Supplier<T> factory;
    private final int maxIdle;
    ObjectPool(Supplier<T> f, int max){ factory=f; maxIdle=max; }
    public synchronized T acquire() {
        T obj = idle.poll();
        if (obj == null) obj = factory.get(); // 池空新建
        borrowed.add(obj);
        return obj;
    }
    public synchronized void release(T obj) {
        if (!borrowed.remove(obj)) throw new IllegalStateException("非法归还"); // 泄漏检测
        if (idle.size() < maxIdle) idle.push(obj); // 池满则丢弃
    }
}