☰
JUC并发容器全解析:原理、场景与实战避坑指南
2026/9/30 4:41:44 网站建设 项目流程

并发容器,Java并发编程里的那座弹药库

做后端这几年,有个感受特别深:很多并发问题,根本不是锁用得不好,而是容器用错了。从Hashtable一把大锁堵门口,到ConcurrentHashMap把锁细到每个桶,再到CopyOnWriteArrayList用“空间换一致性”,JUC里那十几个并发容器,基本覆盖了你能遇到的所有并发读写场景。这篇文章就把它们逐个拆开来讲,聊聊各自的原理、适用场景,还有我在生产环境里踩过的一些坑,希望能帮面试八股文背得头大的同学,以及正在做高并发服务选型的工程师们,建立一张真正清晰的地图。

1. 并发容器全景图:为什么我们需要一套新容器

先回答一个基础问题:JDK 自带的ArrayList、HashMap、LinkedList,加上Collections.synchronizedXxx()包装出来的同步容器,到底哪里不够用,非要再搞一套?

1.1 同步容器的三大原罪

第一宗罪是锁粒度太粗。Hashtable和Vector内部所有公开方法都加了synchronized,也就是说任何时刻只有一个线程能读或者写。读操作根本不需要加锁的场景下,这种设计直接把并发度削成了零。更麻烦的是复合操作还得自己加外部锁,比如“先检查再更新”这种经典组合,靠单个方法锁根本兜不住。

第二宗罪是迭代时弱一致性缺失。ArrayList在迭代过程中如果另一个线程往里add,会直接抛ConcurrentModificationException。我见过线上服务因为这个异常导致批量任务中断的案例,排查半天才发现是某个同事在遍历时写了脏数据。

第三宗罪是性能天花板低。synchronized在JDK 1.6之后虽然引入了偏向锁、轻量级锁优化,但在高并发争抢下锁膨胀依然难以避免。尤其当你的服务16个核里16个线程都在抢同一把锁时,CPU时间片全耗在阻塞唤醒上了。

基于这三点,JUC(java.util.concurrent)从JDK 1.5开始提供了一套全新的并发容器。它们的设计思路非常一致:能不用锁就不用锁(CAS + volatile),必须用锁就把锁粒度降到最低(分段、桶级、读写分离)。

1.2 JUC并发容器家族速览

先给一张全景图,后面逐个展开。这张表我建议大家保存下来,面试和选型都能用:

容器类型代表实现核心特性适用场景
并发MapConcurrentHashMap桶级锁/CAS,高并发读写缓存、计数、KV存储
并发Map(有序)ConcurrentSkipListMap跳表实现,有序,无锁读排行榜、范围查询
并发ListCopyOnWriteArrayList写时复制,读无锁读多写极少,监听器列表
并发SetCopyOnWriteArraySet / ConcurrentSkipListSet基于上述两种实现白名单、事件订阅
阻塞队列ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue / DelayQueue / PriorityBlockingQueue生产者消费者的桥梁线程池任务队列、消息缓冲
非阻塞队列ConcurrentLinkedQueueCAS无锁,无界高并发入队出队

注意一点:JUC里并没有“并发版HashSet”,CopyOnWriteArraySet内部其实就包了一个CopyOnWriteArrayList。理解了List就理解了Set,不用重复记。

2. ConcurrentHashMap:并发场景下的默认首选

这是面试里出现频率最高的并发容器,没有之一。我招人的时候几乎必问,而且问得很细,因为ConcurrentHashMap的演变史几乎就是Java并发优化的缩影。

2.1 JDK 1.7 分段锁的经典设计

JDK 1.7的ConcurrentHashMap用了分段锁(Segment)。整个Map被分成16个Segment(默认值),每个Segment继承自ReentrantLock,内部维护一个小的HashEntry数组。写入时先通过hash & segmentMask定位到某个Segment,然后只锁住这个Segment,其他Segment依然可以并发读写。

这个设计的精妙之处在于:它把锁竞争分散到16把锁上,理论上并发度是16。不过它有个明显的代价——内存占用偏高。每个Segment都是一把独立的锁,加上HashEntry数组的额外开销,同样的数据量比HashMap多吃不少内存。另外,某些全局操作比如size()、containsKey()需要遍历所有Segment,实现复杂度很高。

2.2 JDK 1.8 桶级锁的全面革新

JDK 1.8之后,ConcurrentHashMap直接抛弃了Segment,改用Node数组 + CAS + synchronized的组合。这是我认为Java并发容器里最值得反复咀嚼的一个设计:

  • 写入流程:先计算key的hash定位到数组下标,如果桶为空,直接用CAS写入,无锁完成;如果桶非空,对桶的头节点加synchronized锁,然后走链表或红黑树插入逻辑。
  • 锁粒度:从“16个Segment”细化为“每个桶一个锁”。这意味着不同hash的key可以完全并行写入,极端情况下并发度等于数组长度。
  • 扩容机制:引入了ForwardingNode来标记正在迁移的桶,支持多线程协助扩容(helpTransfer),避免扩容时全表阻塞。

划个重点:JDK 1.8的synchronized锁的其实只是桶的头节点,而不是整个数组。所以读操作即使遇到正在写入的桶,只要头节点没变,依然可以无锁读取。这种“读多写少不阻塞、写写互不干扰”的设计,让它在高并发读场景下表现极其出色。

红黑树转换条件也值得背一下:链表长度≥8且数组长度≥64。为什么是8?因为源码注释里给了概率分析,泊松分布下链表长度到8的概率已经低到千万分之六,而且红黑树节点占用空间是链表节点的两倍,必须平衡查询性能和空间开销。

2.3 size() 统计的巧妙思路

ConcurrentHashMap的size()并不像Hashtable那样直接锁整个表然后数一遍。它的做法是:

  • 正常情况下,通过baseCount变量和CounterCell[]数组分段计数,多个线程更新计数时各自CAS自己的Cell,最后累加所有Cell和baseCount得到近似值。
  • 如果竞争特别激烈,累加过程中数据还在变化,得到的size是一个弱一致性的统计值,并不保证绝对精确。

这就引出一个实用的坑:你不要用concurrentHashMap.size()去做精确的业务判断,比如“map里到了1000条就触发XX”。如果确实需要精确统计,要么用LongAdder自己维护计数,要么引入外部锁串行化操作。

2.4 高频面试点:为什么不能存null

ConcurrentHashMap不允许nullkey和nullvalue,这是HashMap没有的限制。原因很微妙:源码里get()方法无法区分“key不存在返回null”和“key对应的value就是null”。如果用containsKey()去辅助判断,必须先加锁保证原子性,这就破坏了无锁读的设计。所以设计者干脆禁止null,从源头消除歧义。

面试如果被问到这里,可以补充一句:这一设计牺牲了极少数场景的便利性,换取了读路径的无锁和简单,这是典型的“以工程约束换并发性能”的思路。

3. CopyOnWriteArrayList:读多写少的一招鲜

这个容器我一开始挺看不上的,觉得无非就是个add时复制数组,太笨重。直到有个做配置中心的同事跟我说:他们那个配置列表,线上几千个节点每秒要读上万次,但一天下来更新不到十次,用了CopyOnWriteArrayList之后GC压力反而小了,因为读路径完全无锁。

3.1 写时复制的核心原理

CopyOnWriteArrayList内部维护一个volatile Object[] array,所有读操作(get、size、contains)直接读这个数组,不加任何锁。写操作(add、remove、set)则走这样的流程:

  1. 获取ReentrantLock(写锁);
  2. 复制一份新数组,长度+1;
  3. 在新数组上执行修改;
  4. 将新数组通过setArray()发布,替换旧数组引用;
  5. 释放锁。

关键点在于第4步:array是volatile修饰的,新数组发布后,所有线程下一次读就能看到最新版本。旧数组没人引用后自然被GC回收。

3.2 适用场景与代价

写时复制的代价非常直观:每次写操作都要全量复制数组。如果你的容器里有1万个元素,每写一次就要复制1万个引用,代价相当可观。

所以它的适用条件非常苛刻:

  • 读操作极多,写操作极少(比例至少是几十比一);
  • 容器元素量不能太大,我个人的经验值是不要超过几千个,超过一万慎用;
  • 对弱一致性可以接受,写完之后其他线程可能还要过一会儿才能读到。

最常见的落地场景是监听器列表、配置项列表、黑白名单。比如Spring的事件监听器注册表就是这种容器。

3.3 迭代器的弱一致性陷阱

CopyOnWriteArrayList的迭代器(COWIterator)是在创建时对数组做的一次快照,所以迭代过程永远不会抛ConcurrentModificationException,也看不到迭代期间新增的元素。

这个特性有时候会坑人。比如你用迭代器去遍历并判断“如果包含X就移除”,结果拿到的是一个旧版本快照,你remove掉的元素其实在新版本里仍然存在——因为迭代器根本不允许remove(),会直接抛UnsupportedOperationException,等你真正写代码的时候才会发现。

我的建议是:如果要用它做“遍历+更新”的操作,直接改成for循环用下标读,或者先收集要移除的元素,最后统一调removeAll。

4. BlockingQueue 家族:生产者消费者的基础设施

阻塞队列是并发容器里最“工程化”的一类。它们不只是存数据,还承担着流量缓冲、削峰填谷、线程间协作的职责。可以说,Java线程池的底层就是一堆BlockingQueue在撑着。

4.1 ArrayBlockingQueue vs LinkedBlockingQueue

ArrayBlockingQueue底层是有界数组,创建时必须指定容量,无法扩容。它内部用一把ReentrantLock加两个Condition(notEmpty、notFull)来控制生产和消费。入队时如果队列满,生产者线程在notFull上等待;出队时如果队列空,消费者在notEmpty上等待。

LinkedBlockingQueue底层是链表,默认容量是Integer.MAX_VALUE,所以如果不传容量,它就是个无界队列。它用两把锁(takeLock和putLock)分别保护出队和入队操作,生产者和消费者可以同时工作,吞吐量通常高于ArrayBlockingQueue。

这两者的差异我做了个对比:

对比维度ArrayBlockingQueueLinkedBlockingQueue
底层结构数组,容量固定链表,容量可选
锁机制一把锁+两个Condition两把锁+各自Condition
内存占用预分配数组,元素本身占内存,无节点开销每个元素多一个Node节点
吞吐量低一些(锁竞争更集中)高一些(读写锁分离)
场景推荐对容量有严格要求,防止OOM高吞吐,可接受有界或无界

一个非常重要的工程建议:线程池的阻塞队列,如果没有特殊原因,都用有界队列。我在生产里见过太多因为用了无界LinkedBlockingQueue导致内存被积压消息填满,触发Full GC甚至OOM的案例。限流和熔断都是下游服务的事,不要让你的线程池变成无限缓冲。

4.2 SynchronousQueue:不存储的传递

SynchronousQueue很特别:它内部根本不存储任何元素。put操作必须等待另一个线程take,否则一直阻塞;take也必须等待put。它的作用就是“直接交接”。

Executors.newCachedThreadPool()用的就是SynchronousQueue,线程池收到任务后如果没有空闲线程,就新建线程来接收,任务永远不会积压。也正因为如此,SynchronousQueue适合处理短平快的任务,不适合需要缓冲的突发流量。

如果你在面试时被问到“SynchronousQueue和LinkedBlockingQueue的区别”,一句话就能得分:前者是线程与线程之间的直接传递通道,不缓存;后者是缓冲仓库,可以存一批再慢慢消费。

4.3 DelayQueue 和延迟任务

DelayQueue本质上是一个用PriorityQueue实现的无界阻塞队列,元素必须实现Delayed接口,核心是getDelay(TimeUnit)和compareTo两个方法。队列出队时,只有getDelay返回小于等于0的元素才能被取出,否则线程等待。

它在实际项目里最常见的用法是订单超时关闭和定时任务调度。比如下单后30分钟未支付就自动取消,你可以把订单对象放进DelayQueue,起一个消费者线程循环take(),取出来就执行关单逻辑。

不过要提醒一点:DelayQueue的take()是阻塞的,如果有多个消费者线程,只有一个能取到到期的任务,其他线程继续等待。如果你需要“到期后全部执行”,就得换ScheduledThreadPoolExecutor或者Quartz这类调度框架。

5. 其他并发容器:ConcurrentLinkedQueue 与 ConcurrentSkipListMap

这两个容器虽然用得不如前面几个频繁,但在特定场景下是不可替代的。

5.1 ConcurrentLinkedQueue:无锁队列

ConcurrentLinkedQueue是基于CAS实现的无界非阻塞队列。它的核心思想是:用volatile修饰头尾节点,入队时通过CAS更新尾节点的next指针,出队时通过CAS更新头节点。整个过程不需要加锁,理论上在高并发下没有线程阻塞和唤醒的开销。

它的适用场景是那种入队出队非常频繁,但队列不能阻塞的场景,比如Netty的某些事件循环、日志异步写入。但它也有明显的局限:没有put/take这种阻塞方法,队列空时消费者得自己poll轮询或自旋,CPU空转比较严重。所以如果你的消费者需要阻塞等待,还是用LinkedBlockingQueue更合适。

还有一个容易踩的坑:ConcurrentLinkedQueue的size()方法不是O(1)的,它需要遍历整个链表来计数。如果你要频繁调size()判断队列长度,或者用它做容量判断,性能会很差。建议自己维护一个AtomicInteger计数器,或者干脆选其他容器。

5.2 ConcurrentSkipListMap:有序并发Map

ConcurrentSkipListMap是基于**跳表(SkipList)**实现的有序并发Map。跳表的本质是一个多层链表:最底层是完整的有序链表,上层是稀疏的“索引”节点,查找时从顶层开始逐层向下逼近目标位置,平均时间复杂度O(log n)。

它和ConcurrentHashMap最大的区别就是天然有序,而且支持范围查询(subMap、headMap、tailMap)。比如排行榜功能,需要按分数从高到低取前10名,ConcurrentSkipListMap可以直接拿到子区间。

它的put操作用了乐观CAS + 失败重试的无锁算法,读操作完全无锁,并发性能在“数据规模较大、读多写少”的场景下很能打。不过它每个节点都有多个层级的指针,内存占用比HashMap明显大,所以小数据量场景没必要上它。

6. 并发容器选型实战指南

聊完每个容器,最后给一份实战选型清单,并分享几个调优和排查经验。

6.1 按业务场景选容器
业务场景推荐容器理由
高频KV读写缓存ConcurrentHashMap桶级锁,读写并行度高
有序排行榜/范围查询ConcurrentSkipListMap天然有序 + 并发安全
监听器/配置列表CopyOnWriteArrayList读多写少,无锁读极快
生产者消费者缓冲LinkedBlockingQueue/ArrayBlockingQueue阻塞队列天然适合
线程间直接传递SynchronousQueue零缓冲,不做库存
延迟任务处理DelayQueue原生支持到期出队
日志/异步事件ConcurrentLinkedQueue无锁,高吞吐
白名单/布隆过滤器CopyOnWriteArraySet基于COW,小集合安全

这里有一个容易混淆的点:并发容器不解决所有一致性问题。ConcurrentHashMap的get和put本身是原子的,但“先get再put”这种复合操作,在并发下依然可能出现错乱。如果需要“不存在才写入”的原子语义,得用putIfAbsent或compute这类原子方法,而不是自己写判断。

6.2 参数调优与内存考量
  • 初始化容量:ConcurrentHashMap默认初始容量16,如果你的数据量大概率超过这个值,建议直接指定较大容量,避免频繁扩容影响性能。扩容在并发场景是重量级操作,虽然支持多线程协助,但能规避就规避。
  • 负载因子:ConcurrentHashMap的负载因子是固定的0.75,不像HashMap可以自己设置。设计者认为这个值是性能和安全性的最佳平衡点。
  • 线程池队列容量:ArrayBlockingQueue的容量,建议结合“最大线程数、平均任务处理耗时”一起算。用网上流传的“CPU密集型 = CPU核数+1,IO密集型 = CPU核数*2”来定线程数,再反推队列容量,是一个不错的起点。
6.3 常见问题与排查实录

问题一:ConcurrentHashMap在JDK 1.7下偶发死循环。这是老版本transfer方法在并发扩容时的bug,JDK 1.8已经通过ForwardingNode和helpTransfer修复了。如果你的服务还在跑JDK 1.7,建议尽快升级,不要在这种老版本上赌运气。

问题二:CopyOnWriteArrayList写频繁导致GC压力。我见过一个日志系统,把每个请求的日志都add到COW列表里,结果Full GC每几分钟一次。解决方案很简单:日志这种高频写场景根本不该用COW,换成ConcurrentLinkedQueue或者直接用日志框架自己的异步队列。

问题三:无界队列导致内存OOM。这个前面提过,核心教训是:凡是用线程池,队列最好显式指定容量。即使你觉得业务量很小,也要考虑到上游抖动带来的瞬时洪峰。

问题四:迭代时移除元素报错。如果你在遍历CopyOnWriteArrayList时用迭代器remove(),会抛UnsupportedOperationException。正确做法是收集需要删除的元素,循环结束后统一removeAll,或者用ConcurrentHashMap的computeIfPresent做流式处理。

6.4 面试突击:三个必会的深水区问题

面试官问并发容器,至少有三个层次。第一层是背区别,第二层是讲原理,第三层是用场景说服他。给大家三道高频深水区题和答题思路:

1. ConcurrentHashMap的读操作需要加锁吗?不需要。JDK 1.8里读操作直接读volatile修饰的Node数组引用,Node的val和next也都是volatile,保证可见性。即使有其他线程正在写同一个桶,读线程看到的要么是旧链表,要么是正在构建的新链表,都不会读到中间态。

2. ConcurrentHashMap为什么用synchronized而不是ReentrantLock?锁粒度已经细化到桶了,理论上每个桶的竞争概率很低。synchronized在低竞争下开销极小,而且JDK 1.6之后性能已经追上ReentrantLock,代码还更简洁。JUC作者在源码注释里明确说过,这是基于性能测试的选择。

3. 假设有一台16核32G的服务器,线程池的队列选ArrayBlockingQueue还是LinkedBlockingQueue?容量设多少?建议结合业务特性分析:如果任务是IO密集型,线程池核心线程可以设32左右,队列容量设在1000~5000之间,选LinkedBlockingQueue或ArrayBlockingQueue都行;如果是CPU密集型计算任务,核心线程数16左右,队列容量建议小一点(100~1000),用ArrayBlockingQueue更稳妥。关键不是选哪个,而是你有没有意识到用有界队列这个点。

我做Java后端这些年,最大的体会是:并发容器不是孤立的类库,它们是一套设计哲学。ConcurrentHashMap教你把锁做细,CopyOnWriteArrayList教你读写分离,BlockingQueue教你流控和协作。真正遇到线上问题了,不要一上来就换容器,先想清楚你的瓶颈是锁竞争、内存、GC还是业务逻辑的复合操作。把容器选对,很多时候比堆机器更管用。希望这篇文章能帮你把这张地图装进脑子里,不管是应付面试还是落地选型,都能少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询