ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JUC并发容器全解析:原理、场景与实战避坑指南

JUC并发容器全解析:原理、场景与实战避坑指南 并发容器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时间片全耗在阻塞唤醒上了。基于这三点JUCjava.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 高频面试点为什么不能存nullConcurrentHashMap不允许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则走这样的流程获取ReentrantLock写锁复制一份新数组长度1在新数组上执行修改将新数组通过setArray()发布替换旧数组引用释放锁。关键点在于第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 LinkedBlockingQueueArrayBlockingQueue底层是有界数组创建时必须指定容量无法扩容。它内部用一把ReentrantLock加两个ConditionnotEmpty、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有序并发MapConcurrentSkipListMap是基于**跳表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核数1IO密集型 CPU核数*2”来定线程数再反推队列容量是一个不错的起点。6.3 常见问题与排查实录问题一ConcurrentHashMap在JDK 1.7下偶发死循环。这是老版本transfer方法在并发扩容时的bugJDK 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还是业务逻辑的复合操作。把容器选对很多时候比堆机器更管用。希望这篇文章能帮你把这张地图装进脑子里不管是应付面试还是落地选型都能少走一些弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进