并发容器源码解析:从CAS到锁的Java线程安全设计
1. 源码阅读前先给并发容器建个坐标系我一直觉得并发容器类是Java并发编程里最值得啃的一块硬骨头。它不像锁那样直观也不像线程池那样有清晰的配置参数它的精妙之处全藏在“性能”和“正确性”的权衡里。我自己读JDK源码那段时间最大的感受就是如果一上来就扎进ConcurrentHashMap的代码里十有八九会迷路——不是看不懂代码而是看不懂“为什么非要这样设计”。所以这一篇我打算换一种讲法不按类逐个罗列而是沿着“设计动机 → 实现机制 → 使用边界”这条线把并发容器类的核心逻辑拆开。本文会重点覆盖ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、ArrayBlockingQueue与LinkedBlockingQueue这几组典型容器面向的读者是已经熟悉Java基础集合与多线程语法、想深入理解底层原理的开发者。不管你是为了面试、为了排查线上的诡异问题还是纯粹想提升内功这篇都值得慢慢读。1.1 并发编程三大难题与容器的应对思路并发容器解决的核心问题说白了就是三个原子性、可见性、有序性。普通容器之所以在并发场景下出问题也基本是这三件事没做好。以HashMap为例两个线程同时put可能因为头插法导致链表成环这是“原子性”缺失一个线程修改了数据另一个线程读到的却是旧值这是“可见性”缺失指令重排导致对象“发布”了一半就被另一个线程拿到这是“有序性”缺失。并发容器的设计本质上就是在用不同的技术组合来同时满足这三个条件。所以你会看到几个反复出现的基础构件volatile解决可见性和部分有序性常被拿来修饰容器的关键状态字段比如数组的引用、队列的头部节点。它比锁便宜得多因为volatile写只要求冲刷本地缓存不要求阻塞其他线程。CASCompare And Swap解决原子性替代轻量级的锁操作。一个CAS指令在x86上对应一条cmpxchg比进入内核态抢锁快好几个数量级。但CAS不是万能的它最怕“高争用”——线程多了以后大量CAS失败反而白白消耗CPU。synchronizedJDK给并发容器在上层做兜底的手段。JDK6以后synchronized引入了偏向锁、轻量级锁、重量级锁的升级路径在争用不激烈时开销已经非常低这直接改变了并发容器里“能用锁就别硬CAS”的设计思路。这三样东西的组合方式决定了每个容器的性格。有的走“无锁路线”全程CAS有的走“锁分段路线”把竞争拆散有的走“快照路线”用不可变吸收竞争。理解了这个坐标系再去看每个类的源码就顺了。1.2 并发容器的四大阵营从总览到选型在深入源码前先把常见并发容器归个类。我习惯把它们分成四个阵营阵营代表类核心手段适合场景并发MapConcurrentHashMap数组链表红黑树、CASSynchronized高频读写键值对需要全局键视图快照容器CopyOnWriteArrayList / CopyOnWriteArraySet写时复制、不可变数组读多写极少遍历频繁而修改罕见无锁队列ConcurrentLinkedQueueCAS更新头尾指针高吞吐、无界、不要求阻塞语义阻塞队列ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue锁条件队列生产者消费者模型需要背压机制这四类容器解决的问题域是重叠的但取舍逻辑完全不同。CopyOnWriteArrayList和ConcurrentLinkedQueue都能做到“读不加锁”但前者靠“复制”后者靠“无锁CAS”ConcurrentHashMap和ConcurrentLinkedQueue都用了CAS但前者还混入了synchronized做兜底后者则全程无锁。这种差异不是随便定的而是由访问模式决定的。有一个容易混淆的点先提醒一下ConcurrentHashMap和Hashtable最大的区别不是“线程安全”与否而是“锁的粒度”。Hashtable是全局一把锁读也要锁ConcurrentHashMap是锁分散到桶上读无锁。这种粒度差异在低并发下不明显在高并发多线程读写时就是数量级上的性能差距。1.3 两个容易先踩的思维陷阱读源码之前我观察到新手常有两个思维误区提前纠正能少走弯路。第一个误区是把“线程安全”理解成“绝对安全”。并发容器保证的是“单个方法调用的原子性”不是“复合操作的原子性”。比如map.putIfAbsent(key, 1)是原子的但if (!map.containsKey(key)) { map.put(key, value); }不是。很多人线上出问题恰恰是把这两个当成一回事。这个观念不建立起来看多少个类的源码都会执行错。第二个误区是迷信“无锁就一定快”。无锁在低争用下确实快但在高争用下CAS自旋会加剧缓存行颠簸反而比锁更慢。ConcurrentLinkedQueue是全程无锁的但它的size()方法O(n)遍历全链表很多场景下压根不该调用。读源码时一定要带着“这条路线的代价是什么”的问题去读而不是只记住“它很牛逼”这个结论。有了这个坐标系下面逐个拆解就有章法了。2. ConcurrentHashMap从分段锁到CASSynchronized的演进逻辑ConcurrentHashMap是并发容器里源码最复杂、设计也最值得学习的类。每次版本迭代它都代表着JDK对并发性能的最新理解。我读的是JDK8之后的版本JDK7的版本也翻过两者放一起对比才能真正看懂设计的取舍。2.1 为什么JDK7的分段锁设计被推倒JDK7的ConcurrentHashMap用的是分段锁Segment机制。内部维护一个Segment数组每个Segment本身是一把ReentrantLock底下再管着若干HashEntry桶。put时先定位到Segment然后锁住整个Segmentget时通过volatile读HashEntry链表不加锁。这套设计在JDK7时代是很好的锁粒度是“一段”而不是“一整张表”多线程操作不同段时互不干扰。但它的短板也很明显。第一是扩容时的全局影响。JDK7的扩容发生在Segment内部本质上每次扩容只影响一个段这点还行。但问题是Segment数组本身定长16段内再扩容无法突破“段”这个层级。如果数据分布不均匀某个段的链表会特别长put的耗时就会恶化锁的竞争也会集中到某一段上。第二是空间浪费。每个Segment继承了ReentrantLock锁对象本身有开销整个Map要初始化Segment数组即使只有一个键值对也要分配16个段。这在Redis这类大内存系统做缓存时会明显浪费内存。第三是size()的准确性。JDK7计算size需要先无锁累加每个Segment的count如果前后两次累加不一致说明有并发修改就加锁再算。这个逻辑在并发写频繁时可能导致锁全表两次以上——写性能瞬间崩塌。所以JDK8直接推倒了分段锁换成桶粒度锁 CAS synchronized的组合底层是Node数组每个桶是链表或红黑树put时先CAS插入空桶非空桶再对桶头节点加synchronized锁。这个转变不是简单的“换锁”而是把锁的粒度从“段”缩小到“单个桶”同时用CAS完成最贵的“初始化空桶”操作把无锁和锁的各自优势都用上了。2.2 put流程CAS撞树与Synchronized兜底JDK8的put流程是理解整个类的钥匙。核心逻辑在putVal方法里我把主干拆成几步final V putVal(K key, V value, boolean onlyIfAbsent) { // 1. key和value都不允许为null // 2. 计算hashspread()把高位扰动到低位让散列更均匀 // 3. 死循环里判断 // a. 如果table还没初始化先初始化initTable // b. 如果当前位置为null用CAS直接放入新节点 // c. 如果当前位置是ForwardingNode迁移标记帮忙迁移 // d. 否则synchronized锁住头节点遍历链表/红黑树插入 }这里最反直觉的是大部分put根本不需要加锁。当桶是空的时候一个CAS就能完成只有桶里已经有数据发生哈希冲突时才需要锁住这个桶的头节点去操作链表或树。也就是说锁的是“冲突的那一小片区域”不是整张表。CAS在这里承担了一个特别重要的角色——初始化。多个线程同时put到同一个空桶只有一个CAS能成功失败的线程会进入下一次循环此时桶已经有了头节点转而进入synchronized路径。这个“先CAS后锁”的顺序很有讲究CAS省去了加锁的开销synchronized兜底了CAS处理不了的多节点场景。当链表节点数超过8且数组长度≥64时链表会转成红黑树。这里有两个细节值得注意。一是TREEIFY_THRESHOLD 8不是随便定的它参考了泊松分布的推算——在随机散列下链表长度达到8的概率已经极小超过8说明哈希质量异常或者数据分布失衡。二是树化不是为了提高查询性能——在8个节点以内链表遍历和红黑树查找的差距微乎其微树化的真正目的是防止极端情况下链表无限变长引发的最坏复杂度。2.3 get为什么不加锁get是ConcurrentHashMap性能优越的一个核心体现。我们先看它做了什么public V get(Object key) { NodeK,V[] tab; NodeK,V e, p; int n, eh; K ek; int h spread(key.hashCode()); if ((tab table) ! null (n tab.length) 0 (e tabAt(tab, (n - 1) h)) ! null) { // 直接读不加锁 } return null; }get全程无锁的底气来自两点Node的val和next字段被声明为volatile以及table引用本身也是volatile。volatile保证了“我们写入的数据对其他线程可见”和“读的时候不会拿到半初始化状态”。具体来说put操作在synchronized块里改完了链表节点后锁释放的瞬间会冲刷所有修改到主内存之后任意线程再通过volatile读拿到头节点就能顺着next指针读到已经构造完整的节点。有人会问既然链表节点的next也是volatile那读的时候链表被并发修改怎么办答案是读到的是某个时间点的快照可能不是最新值但一定是合法的、完整的值。这种“弱一致性”正是并发容器和同步容器比如Collections.synchronizedMap的重要区别同步容器保证“每次读都看到最新值但代价是读写都串行”ConcurrentHashMap放弃“强一致”换取“读永不阻塞”。这个取舍对企业级应用来说太划算了。绝大多数业务是读多写少读不阻塞意味着吞吐量几乎可以线性扩展而弱一致在绝大多数业务场景下可以接受。只有像“先检查后写入且严格依赖读最新值”这种逻辑才需要额外同步而这种场景本来就应该用显式锁来保证。2.4 扩容多线程协助搬运ConcurrentHashMap的扩容是我认为全类最精妙的部分也是面试最爱追问的“helpTransfer机制”。当插入导致桶数量超过阈值0.75的负载因子会触发扩容把数组翻倍。JDK8的扩容不是由某个线程单独完成而是多线程协作每个线程认领一段连续的桶区间把这段桶里的节点迁移到新的数组上。迁移完成的桶会被打上ForwardingNode标记后续任何线程的读写操作如果撞上这个标记就知道该去新数组找数据。这里有几个关键设计迁移的最小单位是一批连续的桶默认MIN_TRANSFER_STRIDE 16每个线程按stride分批认领认领通过CAS更新transferIndex完成避免多个线程迁移同一批桶。迁移时用synchronized锁住桶头节点防止正在迁移的桶被并发读写破坏。迁移完成后旧数组的该桶位置放一个ForwardingNode内部持有nextTable引用读操作遇到它就直接转向新数组。这个设计的巧妙之处在于扩容不再是一次“全表STW”而是一次“软迁移”。读操作遇到ForwardingNode时只是多跳一次不需要阻塞写操作遇到ForwardingNode时先帮忙做一段迁移再插入把扩容成本和正常读写混在一起避免“扩容瞬间卡死”。我自己看线上监控时遇到大Map扩容往往只会出现很小的耗时抖动就是这个机制在起作用。2.5 size()的“差不多”哲学ConcurrentHashMap的size()返回的是一个近似值这个事实很多开发者不知道。它是通过累加每个桶的baseCount和CounterCell数组得到的而不是维护一个全局精确的计数器。public int size() { long n sumCount(); return (n 0L) ? 0 : (n Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n; }sumCount()会遍历CounterCell数组做累加。问题在于累加过程中其他线程可能正在修改计数所以最终结果大概率“差一点点”。JDK官方的设计思路是并发场景下精确计数需要锁而锁的代价远大于“差一点点”的误差。绝大多数业务缓存容量判断、水位监测根本不需要精确到个位数的size所以选择性能优先。但有一个坑必须注意不要用size()做严格的业务判断。比如“size0就做某事”在高并发下可能因为误差导致误判。如果必须精确更可靠的方案是维护一个AtomicLong计数器在put/remove的同样位置更新它——代价是有额外的CAS但保证精确。或者干脆换一种设计用mappingCount()判断是否为空它返回long取值范围更宽也更灵活。3. CopyOnWriteArrayList读写分离的快照策略看完了并发Map里锁与CAS的配合接下来看一条完全不同的路线用不可变性替代同步。CopyOnWriteArrayList是这条路线最纯粹的代表。3.1 不可变数组的快照世界观CopyOnWriteArrayList的核心思想极其简单读操作不加锁直接读写操作先复制一份底层数组在副本上改替换原数组引用。数组本身是用volatile修饰的所以替换后其他线程立刻能看到新版本。private transient volatile Object[] array; public boolean add(E e) { synchronized (lock) { Object[] es getArray(); int len es.length; es Arrays.copyOf(es, len 1); es[len] e; setArray(es); return true; } }注意这里写操作用了一把锁内部是ReentrantLockJDK8之后改用synchronized。也就是说写锁只保护写读完全不参与锁竞争。这样一个简单的机制解决了三个并发难题原子性写操作持有锁复制、修改、替换这一整段是原子的。可见性array字段是volatile替换后对所有线程可见。有序性数组在完全构造好之后才发布读线程不可能看到半初始化数组。这种“先完整构造再一瞬间切换”的思路不仅在并发容器里用在业务系统里做配置热更新也常见——本质都是用不可变对象原子引用替换消除并发冲突。3.2 写操作为什么贵CopyOnWriteArrayList的性能模型是“读极快、写极慢”慢的程度取决于数组大小每次写操作都要完整复制整个数组时间复杂度O(n)。频繁写时复制带来的内存分配和GC压力是巨大的。数组越大写操作的延迟和内存消耗越不成比例。举个实际例子一个ArrayBlockingQueue的底层数组是固定大小写只需要O(1)而CopyOnWriteArrayList哪怕只有100个元素每次add也要复制100个引用。所以CopyOnWriteArrayList的设计者从来没打算让它成为“万能数组”它的定位非常清晰——读多写极少的场景。具体来说适合的场景是黑名单、白名单、配置项列表、监听器注册表。这些场景的特点是修改频率极低可能每小时几次但读取频率极高每秒钟几千次而且经常需要遍历全量。CopyOnWriteArrayList完美契合。不适合的场景包括高频写入的日志列表、不断追加的缓存数据、数据量很大的业务集合。这些场景用CopyOnWriteArrayList内存和CPU双双爆炸远不如ConcurrentLinkedQueue或者加锁的ArrayList来得实在。3.3 迭代器与弱一致性CopyOnWriteArrayList的迭代器非常有意思——它迭代的始终是创建迭代器那个时刻的数组快照。public IteratorE iterator() { return new COWIteratorE(getArray(), 0); } static final class COWIteratorE implements ListIteratorE { private final Object[] snapshot; // 遍历时只读snapshot不受后来写操作影响 }这个设计带来的效果是迭代过程中即使其他线程修改了原list迭代器也不会抛ConcurrentModificationException也不会看到修改后的数据。这在某些场景下是优点——比如边遍历边有其他线程在更新迭代器依然稳定可用但在另一些场景下是巨大的坑如果你依赖“迭代过程中看到最新数据”做判断这里会静默失效。我见过一个线上事故一段代码用CopyOnWriteArrayList保存在线用户列表后台线程定期遍历列表踢掉超时用户同时用户登录的线程在往列表里加人。结果被踢的用户在遍历之后又回来了遍历快照里没有他所以没踢到导致“踢人失效”。这个问题的根子不在容器本身而在选型错误——这种场景需要的是强一致性视图不是快照。3.4 什么场景匹配什么场景别碰结合上面的分析我总结了一张CopyOnWriteArrayList的决策表方便你快速判断该不该用它因素适合用别用读频率极高每秒上千次一般写频率极低每分钟几次以内每秒几十次集合大小小千级以内大万级以上遍历需求经常需要全量遍历偶发、单点读写一致性需求能接受弱一致必须强一致一个提醒CopyOnWriteArraySet是它套了个Set壳内部就是CopyOnWriteArrayList去重所以特性完全一致别因为它叫Set就觉得“更安全”。4. ConcurrentLinkedQueue无锁队列的哨兵节点艺术如果说ConcurrentHashMap的招牌是“锁粒度优化”那ConcurrentLinkedQueue就是“完全无锁”的教科书。它是并发队列里唯一全程无锁的常用实现也是理解CAS如何构造数据结构的绝佳案例。4.1 从LinkedQueue到并发版的变革普通单链队列LinkedList的入队操作是往tail后面追加节点出队是head往后移。并发后最直观的困难是tail和head可能被多个线程同时修改随意更新指针会产生竞争。简单粗暴的加锁方案是每次入队/出队都锁全队列但这样就把并发度降为零。ConcurrentLinkedQueue的做法是用CAS更新tail和head指针允许不必要的CAS失败后重试直到成功。它的核心结构非常简洁private transient volatile NodeE head; private transient volatile NodeE tail; public ConcurrentLinkedQueue() { head tail new NodeE(null); // 哨兵节点初始时指向同一个空节点 }关键点在于head和tail都指向一个哨兵节点dummy node这个节点的item为null。为什么需要一个哨兵因为空队列的head和tail如果都是null就无法区分“空队列”和“两个指针都指向同一节点”的状态。哨兵节点让入队和出队在空队列状态下也能正确地操作指针。4.2 offer循环的核心逻辑offer的完整逻辑可以简化为一段自旋CASpublic boolean offer(E e) { final NodeE newNode new NodeE(Objects.requireNonNull(e)); for (NodeE t tail, p t;;) { NodeE q p.next; if (q null) { // p是最后一个节点尝试CAS把next从null改成newNode if (NEXT.compareAndSet(p, null, newNode)) { // 成功后如果p已经不等于t了说明tail滞后尝试更新tail if (p ! t) TAIL.compareAndSet(this, t, newNode); return true; } // CAS失败说明其他线程抢先插入了循环重试 } else if (p q) { // 出现“自引用节点”说明p已经离开链表被出队没收了要重新找tail p (t ! (t tail)) ? t : head; } else { // 正常情况沿着next链往后走 p (p ! t t ! (t tail)) ? t : q; } } }这段代码初看很绕但拆开看就清晰了。正常情况下从tail出发沿着next往后走找到真正的尾节点CAS把next从null改成新节点。中间判断p q是为了处理“已被移除的节点”——出队操作会把节点的next指向自己形成一个“自引用”遍历时如果碰到这种节点必须跳过否则就死循环了。这个循环的退出条件只有一个CAS成功。所有的判断分支都是为了在并发竞争下找到正确的插入位置。这意味着offer的延迟取决于竞争程度竞争少时一次CAS就完成竞争激烈时可能多次重试但它永远不会阻塞也不会死锁。4.3 tail与head的“滞后更新”ConcurrentLinkedQueue有一个反直觉的设计tail和head并不是每次操作后都立刻更新到最新。它们允许“滞后”其他线程通过沿着next链一遍遍寻找真正的尾节点来补偿这种滞后。为什么这么做因为如果每次offer都立刻CAS更新tail那么多个线程同时offer时tail的CAS必然有大量失败白白增加竞争。允许tail滞后可以在多线程环境下让CAS成功率显著提高——用短暂的遍历成本换更低的争用率。这是典型的空间换时间、延迟换吞吐的设计思想。同样的逻辑也用在出队上。poll的代码里head节点的item会被置为null然后CAS更新head到下一个节点而旧的head会被标记成“自引用”成为可以被回收的垃圾节点。这个设计的代价是当并发程度低时每次操作可能要多走几步沿链表多跳两三个节点但当并发程度高时整体吞吐量远比每次都精确更新指针要高。把这两者对比就能看出ConcurrentLinkedQueue的适用场景广而告之的高频入出队而不是需要严格O(1)延迟的队列。4.4 size()隐藏的陷阱这里必须单独说说size()因为它可能是并发容器里最坑的一个方法。ConcurrentLinkedQueue的size()不是维护一个计数器而是遍历整个链表计数public int size() { long count 0; for (NodeE p first(); p ! null; p succ(p)) { if (p.item ! null) // 跳过item为null的节点 count; // 防止遍历时间过长超过Integer.MAX_VALUE就抛异常 } return (int)Math.min(count, Integer.MAX_VALUE); }两个问题立刻暴露出来时间复杂度O(n)队列越大size()越慢。如果你在日志系统里每来一条日志就调一次size()判断是否达到批量上送阈值那么当队列长度上升后光size()的开销就能把性能拖垮。结果不准确遍历过程中元素可能被并发添加或移除你拿到的可能不是任何时刻的真实size。所以我强烈建议高并发下如果确实需要“队列长度”信息要么额外维护一个AtomicLong计数器在offer/poll成功后递增/递减要么干脆用isEmptry()它只需看head.next是否为nullO(1)代替size()0的判断。5. ArrayBlockingQueue与LinkedBlockingQueue阻塞队列的锁策略对照阻塞队列是另一个大阵营。它们的核心是“生产者-消费者”模型put在队列满时阻塞等待take在队列空时阻塞等待。两种实现看起来差不多但锁策略截然不同理解了这点你才能真正答上“线程池用哪种队列更好”这类问题。5.1 单锁双条件与双锁双条件先看ArrayBlockingQueue的结构final Object[] items; int takeIndex; // 出队位置 int putIndex; // 入队位置 int count; final ReentrantLock lock; private final Condition notEmpty; // 出队线程等待的条件 private final Condition notFull; // 入队线程等待的条件它的锁是一把ReentrantLock但配了两个ConditionnotEmpty和notFull。take时如果队列为空线程把自己挂到notEmpty上等待put时如果队列满了线程挂到notFull上等待。一把锁管两个条件意思是入队和出队实际上是在抢同一把锁同一时刻只能有一个线程在执行put或take的核心代码。再看LinkedBlockingQueueprivate final ReentrantLock takeLock new ReentrantLock(); private final Condition notEmpty takeLock.newCondition(); private final ReentrantLock putLock new ReentrantLock(); private final Condition notFull putLock.newCondition();它有两把锁putLock管入队takeLock管出队。入队线程和出队线程可以同时进行互不阻塞——只要一个在队尾加节点一个在队头取节点它们修改的是不同的字段尾节点的next vs 头节点的next完全不会冲突。所以直观上LinkedBlockingQueue的并发度更高。在多生产者多消费者的场景下吞吐量通常优于ArrayBlockingQueue。但这不是绝对的——ArrayBlockingQueue的底层数组是连续内存CPU缓存更友好在单生产者单消费者或者低并发场景下反而可能更快。5.2 take与put的等待与唤醒细节看一段浓缩版的ArrayBlockingQueue.take逻辑注意它的等待/唤醒顺序public E take() throws InterruptedException { final ReentrantLock lock this.lock; lock.lockInterruptibly(); try { while (count 0) notEmpty.await(); // 队列空挂起等待 E x dequeue(); // 取出队头元素 notFull.signal(); // 唤醒一个等待入队的线程 return x; } finally { lock.unlock(); } }这里有一个重要细节判断while (count 0)用的是循环不是if。这是为了应对“伪唤醒”——即使没有线程调用signal等待线程也可能被操作系统意外唤醒。循环保证唤醒后重新检查条件不满足就继续睡。唤醒用的是signal()而不是signalAll()这是另一个值得留意的优化点。signal()只唤醒一个等待线程避免惊群效应所有线程同时醒来然后大部分又重新睡下。对于notFull队列唤醒一个生产者就够了——因为一次take只腾出一个空位唤醒多个生产者只会让它们争抢同一个空位。put的逻辑对称队列满时notFull.await()插入成功后notEmpty.signal()。LinkedBlockingQueue的take和put分别用不同的锁所以它的signal是跨锁的。一个有意思的边界情况是take正在等notEmpty时put线程插入元素后调用notEmpty.signal()这个signal不需要持有takeLock是安全的——因为signal只是把线程从等待队列移到锁等待队列实际唤醒并获取锁的过程交给AQS处理。5.3 公平锁参数与吞吐量实测两个类都支持在构造时传入fair参数new ArrayBlockingQueue(capacity, true)是公平锁new LinkedBlockingQueue(capacity, true)也是公平锁公平锁的意义在于线程按照请求锁的顺序排队避免一个线程反复被插队而饿死。代价是性能降低——公平锁需要维护一个FIFO等待队列每次锁请求都可能进入排队。大多数业务场景用非公平锁就够了只有在你明确要求“让生产者按顺序把任务交给消费者”这种严苛顺序时才需要开公平锁。实测里我用过一个案例8个生产者线程、4个消费者线程队列容量1000。LinkedBlockingQueue在非公平锁下的吞吐量比ArrayBlockingQueue高约20%~30%但把队列容量调小到10时ArrayBlockingQueue反而更稳因为链表节点频繁创建回收的开销在容量小的时候不明显而数组的缓存友好性开始显现。对比维度ArrayBlockingQueueLinkedBlockingQueue底层结构定长数组单向链表默认Integer.MAX_VALUE可视为无界锁数量1把锁2个条件2把锁各自1个条件入队与出队能否并行不能抢同一把锁能各自独立的锁内存形态连续数组缓存友好节点分散GC压力更大是否必须指定容量是可不指定默认无界高并发典型吞吐低并发下有优势多生产者多消费者下更稳这里顺带提醒一个规范问题队列容量不要设成无界。LinkedBlockingQueue默认容量是Integer.MAX_VALUE相当于无界。如果生产速度长期高于消费速度内存会被无限撑大最终OOM。线程池用LinkedBlockingQueue时标准做法是必须显式传一个合理的容量来设置边界。6. 源码之外的实战课迭代、统计与性能边界源码读到这里你可能会有一个感觉每个容器类都有一两个“隐藏的坑”。这些坑不在方法签名上只有看实现才出得来。最后整理几条我在工程里实实在在踩过的希望能帮你少走弯路。6.1 弱一致迭代器的双面性ConcurrentHashMap的迭代器是弱一致的迭代开始后如果其他线程修改了map迭代器既不会抛异常也不保证能看到所有修改。这一点很多开发者在写“遍历删除”时栽过跟头。如果在迭代过程中删除元素可能删除不干净如果在修改时遍历并基于遍历结果做统计统计结果可能不准确。正确的删除姿势是使用forEach配合removemap.forEach((k, v) - { if (k.startsWith(temp_)) { map.remove(k); // 这里remove不会改动迭代器内部的遍历路径 } });或者更安全地先收集需要删除的key列表统一remove。ConcurrentHashMap的forEach实现原理是内部保存traversal状态每处理完一批桶如果发现map被并发修改会重新检查并尝试继续遍历未处理的桶。它不保证强一致但保证不会漏掉太多。这套机制的源码值得单独开一篇这里先把使用经验提出来。6.2 “顺便”做统计的代价有类写法看着聪明实际是性能大坑if (map.size() 100 map.get(key) ! null) { ... }这里map.size()在ConcurrentHashMap里的开销是O(n)累加在ConcurrentLinkedQueue里是O(n)遍历在CopyOnWriteArrayList里是维护好的volatile count倒还行。但如果这段代码在高频请求路径上每次请求都触发一次O(n)遍历后果可想而知。我自己之前在一个消息中间件里就写过类似代码用ConcurrentLinkedQueue.size()判断队列积压数超过阈值就告警。结果队列达到几万条时每秒钟调用size()的线程把CPU占满了大半最终导致消费线程饥饿。排查时一看到火焰图里size()占了大头立刻换成维护AtomicLong计数器的方案性能直接恢复。统计信息这种事该计数时就在写路径上维护好别指望读路径上的临时计算能扛住压力。6.3 容器选型与监控建议最后一张选型决策表是我在团队里做技术评审时反复用的业务诉求推荐容器排除理由高频KV读写需要全局键视图ConcurrentHashMapConcurrentSkipListMap有排序需求才用读多写极少的配置/黑白名单CopyOnWriteArrayList/Set写多时内存爆炸消息队列需要阻塞与背压ArrayBlockingQueue 或 LinkedBlockingQueueSynchronousQueue需要直接交接语义高吞吐无界队列放弃阻塞ConcurrentLinkedQueuesize()调用成本高需要有序遍历ConcurrentSkipListMap其他Map无法保证顺序监控方面有两个指标值得盯锁等待时间和CAS自旋次数。前者可以通过JMX暴露ArrayBlockingQueue的锁状态后者比较难直接观测但可以在火焰图里看到sun.misc.Unsafe.compareAndSwap的占比。如果CAS占比长期高企说明容器选型可能不合适该考虑换锁或换容器。我个人的习惯是并发容器上线前先用JMH写个基准测试模拟生产流量的并发度这个话题后面细说。不要用“感觉”判断哪个快数据摆出来再选型基本不会出大错。源码读完最终还是要回到“帮业务解决问题”这个落脚点——这也是我写这篇长文的初衷。

相关新闻

低查重AI教材生成:知识重建、实操流程与避坑实录

低查重AI教材生成:知识重建、实操流程与避坑实录

1. 为什么教材编写成了最“磨人”的内容生产任务 我最早接触教材编写,是在某家职业培训机构的教研部门。那时候团队接了一个内部课程教材的编写任务,看着不复杂——把一门课的教案、PPT、练习整理成一本像样的学生手册。结果真正动手才发现,教…

2026/10/17 20:24:31 阅读更多 →
MATLAB零基础入门教程:从界面到脚本与绘图

MATLAB零基础入门教程:从界面到脚本与绘图

MATLAB 这玩意儿,刚接触时真能让人原地劝退。第一次打开,满屏英文菜单、乱七八糟的面板,命令行里光标一闪一闪,好像你在键盘上敲什么都可能报错。但其实把它想简单一点就行:MATLAB 就是一个“计算器 编程本 画图板”…

2026/10/17 20:24:31 阅读更多 →
fnm 安装配置指南:让 OpenCode 在 PowerShell 里顺畅跑起来

fnm 安装配置指南:让 OpenCode 在 PowerShell 里顺畅跑起来

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 20:23:30 阅读更多 →

最新新闻

每日安全情报报告 · 2026-04-19:用 TaoToken 统一 Key 打通多源威胁情报聚合

每日安全情报报告 · 2026-04-19:用 TaoToken 统一 Key 打通多源威胁情报聚合

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 21:08:17 阅读更多 →
2023同等学力申硕计算机:离散数学与组合数学真题拆解

2023同等学力申硕计算机:离散数学与组合数学真题拆解

简介:2023年同等学力申硕计算机科学与技术数学科目真题与解析文档,适用于备考国考的考生,以及需要系统巩固离散数学、集合论、图论等基础理论的本硕学生。内容覆盖全称与存在量词逻辑转换、幂集交集运算、完全二叉树叶子数与高度关系、正整数…

2026/10/17 21:08:17 阅读更多 →
单分类kNN实现设备异常检测:原理、Matlab代码与阈值调参实践

单分类kNN实现设备异常检测:原理、Matlab代码与阈值调参实践

做设备状态监测这类需求时,最容易卡住的位置就是:手里有大量正常样本,但异常样本几乎没有。你说数据不平衡,其实连"不平衡"都算不上——压根只有一类数据。普通的kNN分类器在这种场景里直接失灵,因为它需要一…

2026/10/17 21:08:17 阅读更多 →
RRU5268(3500MHz)射频验收与工程部署避坑指南

RRU5268(3500MHz)射频验收与工程部署避坑指南

简介:华为RRU5268(3500MHz)是5G RAN 7.1解决方案中的关键射频模块,本PDF文档系统整理了该设备的技术规格和使用要点。文档面向网络规划工程师、现场工程师与系统工程师,重点解决5G站点规划、设备配置、功率优化及安装维…

2026/10/17 21:08:17 阅读更多 →
MyBatis传List调Oracle存储过程批量插入实战

MyBatis传List调Oracle存储过程批量插入实战

简介:这份资源面向使用 Mybatis 与 Oracle 的 Java 后端开发者,聚焦于如何将 List 类型参数传入 Oracle 存储过程以实现批量插入数据,解决 Mybatis 批处理难以实时返回主键、foreach 循环集合长度受限、事务回滚不便等痛点。资源以 PDF 形式呈…

2026/10/17 21:08:17 阅读更多 →
Ajax异步请求中DOM元素显隐控制的工程实践

Ajax异步请求中DOM元素显隐控制的工程实践

这阵子一直在维护一个后台前端页面,列表筛选、详情弹窗、导出按钮这些交互,全都得等接口回包之后才能决定界面怎么展示。正好JS系列写到第三篇,这次想认真聊一个看起来很小、但实际上很容易翻车的问题:在ajax请求过程中&#xff0…

2026/10/17 21:07:17 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →