从源码角度理解Java面试中的线程安全集合 面试场上当被问到“线程安全集合”时大多数人会脱口而出ConcurrentHashMap、CopyOnWriteArrayList这些名字但稍微追问一下“ConcurrentHashMap在JDK 8中怎么保证并发安全为什么它不让你用null作为key”就立刻卡壳。背概念只能帮你通过初筛真正拉开差距的是你对源码的熟悉程度。从底层看穿这些集合的每一次CAS、每一把锁、每一次自旋才是面试官真正期待的东西。ConcurrentHashMap分段锁的终结与CAS红黑树的崛起先聊最核心的ConcurrentHashMap。JDK 7版本用的是分段锁Segment继承自ReentrantLock每个Segment管理一个HashEntry数组理论上支持16个线程同时写。但JDK 8完全推翻了这种设计——分段锁被废弃转而采用CAS synchronized 红黑树的激进方案。为什么因为分段锁在线程竞争不激烈时性能反而下降而且查找时需要两次hash先定位Segment再定位桶内存占用更大。JDK 8的源码里put操作的逻辑清晰得可怕。它会先通过spread()方法二次hash然后死循环里用tabAt()通过Unsafe.getObjectVolatile获取当前桶的头节点。如果头节点为null就尝试用CAS把当前Node放进去casTabAt(tab, i, null, new NodeK,V(hash, key, value, null))。这一步是无锁的性能极高。如果CAS失败说明桶里已经有节点了这时会用synchronized锁住那个桶的头节点。注意这里锁的粒度从“Segment”缩小到了“单个桶”并发度瞬间提升了一个数量级。锁内部再判断节点类型如果是链表就遍历插入如果链表长度超过8且数组长度超过64就转化为红黑树TreeBin。红黑树插入时也使用相同的锁但树结构本身支持O(log n)的查找效率。另一个关键点是size()方法。JDK 7里size()会连续两次不加锁统计如果不一致就锁所有Segment非常笨重。JDK 8引入了CounterCell数组和baseCount变量put成功后用CAS更新baseCount冲突时用CounterCell分散计数。最终size()是通过累加baseCount和所有CounterCell的值得到不锁任何桶所以即使高并发下调用size()也不会阻塞写操作。面试高频题为什么ConcurrentHashMap不允许key或value为null答案藏在源码的putVal方法第一行if (key null || value null) throw new NullPointerException();。这是为了支持并发下的containsKey等操作——如果允许null无法区分“不存在”和“值为null”这两种情况而HashMap在单线程中可以用containsKey来分辨但并发环境下containsKey也需要锁为了简化语义直接禁止null。这个设计决策体现了“用确定性换取性能”的经典思路。CopyOnWriteArrayList写时复制——读多写少场景的极致优化说到读多写少的场景CopyOnWriteArrayListCOW是面试中的常客。它的思想极其简单每次修改add、set、remove时都会复制整个底层数组然后在新数组上操作最后用volatile变量替换旧数组引用。读操作完全不加锁直接读当前数组。源码中add方法的实现public boolean add(E e) { final ReentrantLock lock this.lock; lock.lock(); try { Object[] elements getArray(); int len elements.length; Object[] newElements Arrays.copyOf(elements, len 1); newElements[len] e; setArray(newElements); return true; } finally { lock.unlock(); } }每次添加都复制一份O(n)的数组这就是COW的性能代价。写操作必须加锁但锁只保护“复制替换”这个原子操作读操作完全不受影响。注意读操作是通过getArray()获取当前引用而这个引用被volatile修饰保证了可见性读线程看到的是最新的数组引用吗不一定——如果写线程刚刚setArray但尚未从该方法返回读线程可能读到旧数组但这是允许的因为COW保证弱一致性。它不能保证你立即看到最新写入的数据但保证最终会看到。这个集合最致命的弱点是内存占用如果list很大且频繁写每次复制的数组会造成大量内存垃圾频繁触发GC。所以面试官常问“如果有一个需要频繁更新的配置列表适合用COW吗”答案是否定的应该考虑使用ReadWriteLock或ConcurrentHashMap来替代。另一个值得深挖的点是COW中迭代器的弱一致性。传统的ArrayList在迭代时如果其他线程修改了结构会抛出ConcurrentModificationException。但COW的迭代器使用的是创建迭代器时的数组快照后续无论其他线程怎么修改迭代器遍历的还是老数组所以永远不会抛异常但也不会看到新数据。这种“快照迭代器”非常适合遍历时不需要实时更新、但需要高并发的场景比如事件监听器列表。ConcurrentLinkedQueue无锁队列的CAS链式操作并发队列中ConcurrentLinkedQueueCLQ是一个典型的无锁Lock-Free队列基于Michael-Scott算法实现。它内部有两个volatile修饰的原子引用head和tail。入队操作是一个循环CAS过程绝不阻塞线程。看offer方法的简化流程创建一个新节点。获取当前尾节点t和尾节点的next节点p。如果p为null说明t就是真正的尾节点用CAS将t.next指向新节点casNext(t, null, newNode)。如果CAS成功再尝试用CAS把tail指向新节点这个操作不是每次都执行而是通过“hops”优化减少CAS次数。如果CAS失败或p不为null就说明tail滞后了需要继续向后遍历。这里面最妙的是“滞后tail”的设计。如果每次入队都更新tail会导致大量CAS冲突。CLQ允许tail指向倒数第二个甚至更靠前的节点入队时先尝试把新节点链到真正的尾节点成功后再“懒”更新tail。这种设计在低竞争下性能极佳但问题在于当多个线程并发入队时一个线程可能读到过期的tail引用需要循环向前推进。所以offer方法里有个内部的for (NodeE t tail, p t;;)死循环直到成功为止。出队操作poll同样使用类似逻辑先获取head如果head的item不为null就CAS置空如果head.next不为null就帮“推进”head。因为无锁整个队列操作都避免了阻塞吞吐量在高并发下优于锁队列但缺点是有ABA问题需要额外用版本号解决吗CLQ中的引用是单向链接ABA问题不直接影响正确性因为CAS只比较引用值不关心中间状态但可能会导致tail指向一个已经被删除的节点——不过通过遍历机制能避免。实际上ReentrantLock的Lock-Free实现中常配合AtomicStampedReference但CLQ没有用因为它的算法已经保证了即使tail被修改过循环也能自动修复。面试官常问CLQ如何保证线程安全答案就是所有操作入队、出队、head/tail更新都基于Unsafe提供的CAS原子操作配合volatile的内存语义构成无阻塞的线程安全。BlockingQueue锁与条件变量的交响乐BlockingQueue家族在ThreadPoolExecutor中扮演核心角色。以ArrayBlockingQueue为例它的源码展示了锁 两个Condition的经典配合。内部维护一个可重入锁lock和两个ConditionnotEmpty、notFull。put操作public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock this.lock; lock.lockInterruptibly(); try { while (count items.length) notFull.await(); enqueue(e); } finally { lock.unlock(); } }notFull.await()会释放锁并加入等待队列直到其他线程take后调用notFull.signal()唤醒。这里的while循环是重中之重——因为被唤醒后可能再次不满足条件虚假唤醒或者被其他线程抢先所以必须用while二次检查。LinkedBlockingQueue则使用两把锁takeLock和putLock。take锁用于从队列头部取元素put锁用于在尾部加元素。这样生产者和消费者可以同时进行只要队列不为空且不满。这种分离锁的设计显著提高了吞吐量。但注意count变量的更新必须使用AtomicInteger以保证同时修改时的可见性和原子性。面试高频对比ArrayBlockingQueue和LinkedBlockingQueue——前者基于数组是有界且容量固定的锁是单锁读写竞争同一把锁后者基于链表默认无界可能导致内存溢出但有两把锁并发度高。再进阶一点SynchronousQueue不是真正的容器它不存储元素而是直接交付内部使用TransferStack或TransferQueue实际上就是CSP中的“握手”模式put线程必须等待take线程反之亦然。最后不得不提DelayQueue它内部使用PriorityQueue但出队时需要通过迭代检查每个元素的delay是否到期。它的源码中展示了如何用Leader/Followers模式来优化等待只有一个线程leader在等待第一个到期的元素其他线程都进入Condition等待避免多个线程同时轮询浪费CPU。ConcurrentSkipListMap跳表与锁的默契当需要排序的并发Map时ConcurrentSkipListMapCSLM是无二之选。底层基于跳表skiplist数据结构这是一种用空间换时间的概率平衡树。每个节点拥有前向指针和上下指针索引层查找时从最高层向右向下跳跃平均时间复杂度为O(log n)。CSLM的并发控制非常精巧它没有使用全局锁而是采用细粒度的锁 CAS。每个节点上的操作插入、删除都会先通过CAS修改节点引用如果失败则重新读取重试。具体到源码doPut方法内部会用死循环找到插入位置然后用CAS设置新节点同时更新索引层。更新索引层时如果并发冲突会通过“标记删除”节点引入一个特殊的marker节点来确保删除操作和插入操作不互相干扰。一个核心设计是findNode方法会主动清理被标记删除的节点。在查找过程中如果遇到被标记的节点会试图通过CAS帮助删除它类似于ConcurrentHashMap中的“协助迁移”。这种“帮助他人就是帮助自己”的思想是无锁编程的重要模式。面试常被问ConcurrentSkipListMap和ConcurrentHashMap的区别前者保持元素有序按自然顺序或指定Comparator后者无序前者读操作也是无阻塞的但写操作的CAS重试次数可能更高前者占用内存更多因为索引层。面试制胜从源码到实战的思维升级看完这些源码细节你应该明白一件事面试官问线程安全集合其实是在考察你对并发基础的理解深度。CAS、volatile、锁分段、条件变量、Copy-on-Write……这些概念单独拎出来都能讲但只有当你真正读过源码才能体会到它们如何被有机组合成一个严谨的类。比如面试官问“synchronized在ConcurrentHashMap里是锁桶那多个线程同时put到不同桶会被阻塞吗”答案是“不会”因为synchronized锁的是头节点对象不同的桶有不同的头节点所以不同桶的put操作可以并发进行。这比JDK 7的分段锁粒度更细但引入synchronized而不是ReentrantLock是因为JDK 8对synchronized做了偏向锁、轻量级锁的优化在低竞争下几乎无消耗。再比如CopyOnWriteArrayList的弱一致性问题如果一个线程遍历的同时另一个线程修改了list遍历线程看到的是旧数据。这在某些场景比如遍历事件监听器列表并执行回调是安全的——因为回调执行期间旧列表的监听器不会因为新加入的监听器而“丢失”你只是暂时看不到新监听器。但如果要求强一致性COW就不合适。最后不要忽视Unsafe类的存在。几乎每一个高性能并发集合都依赖Unsafe提供的array element访问和CAS操作。比如Unsafe.putObjectVolatile和Unsafe.compareAndSwapObject。虽然官方不推荐直接使用但理解这些底层能力你在回答“你如何自己设计一个线程安全链表”这样的开放问题时就能说出“用CAS加volatile配合迭代器快照”之类的高级方案。线程安全集合的源码不是背出来的是分析出来的。每读一个类的核心方法我建议你画出它内部的线程交互图哪些步骤是无锁的哪些用了锁失败重试机制是什么复制的代价有多大当你能把这些问题画清楚了面试时就不再需要临时回忆而是能信手拈来地引出源码中的一行关键代码。从今天开始别再只记结论。打开IDE在ConcurrentHashMap的putVal方法里打个断点跑一个多线程demo看看它怎么自旋怎么扩容。真正懂代码的人从来不慌。