COW、无锁队列、跳表,并发全家福一次认全
提到并发容器,很多人只想到ConcurrentHashMap。其实java.util.concurrent包里还有一整套为多线程打磨的容器:写时复制的 List、无锁链表队列、跳表 Map/Set、各种阻塞队列。这一篇把"并发容器全家福"铺开,讲清每个的底层思路和适用边界,让你在选型时胸有成竹。
一、为什么不能用同步包装就完事?
上一讲提过Collections.synchronizedList/Map/Set能给普通容器加锁,但它是"整表一把锁",迭代还要你手动同步,高并发下吞吐差。并发容器(JCU 包)走的是另一条路:用更细的锁、CAS、写时复制、无锁算法,在不牺牲安全的前提下保住并发度。下面逐个看。
二、CopyOnWriteArrayList:读多写少的读锁自由
原理复习:写时复制整份数组,读无锁。
CopyOnWriteArrayList<String>list=newCopyOnWriteArrayList<>();list.add("a");// 复制整份数组,O(n),贵Stringv=list.get(0);// 无锁,O(1),快for(Strings:list){...}// 迭代器拿快照,绝不 CME适用:监听器列表、配置项、白名单、路由表这类"初始化后极少改、天天大量读"的数据。绝对别用于高频写(每次写都拷全数组,内存和 CPU 双爆)。CopyOnWriteArraySet就是基于它实现的 Set(去重能力来自底层 List 的addIfAbsent包含检查)。
三、ConcurrentLinkedQueue:无锁的 CAS 链表队列
高并发下不想用阻塞队列(不想让线程挂起)、又要线程安全的 FIFO,就用它:
ConcurrentLinkedQueue<Task>q=newConcurrentLinkedQueue<>();q.offer(task);// CAS 入队,不阻塞Taskt=q.poll();// CAS 出队,空返回 null底层是Michael-Scott 算法的无锁单向链表,入队出队全程用 CAS 自旋,不阻塞任何线程。特性:
- 无界、不允许 null、迭代弱一致、size() 弱一致(要遍历计数,O(n))。
- 高并发非阻塞场景下吞吐优于锁队列;但单线程或低并发时,因 CAS 自旋开销反而可能不如 ArrayDeque。
- 适合"高并发投递、消费者自己决定何时取"的解耦场景,比如日志异步收集、事件总线。
四、ConcurrentHashMap:并发 Map 标杆
前文专篇讲过。锁定级:CAS + 锁桶头,读无锁,弱一致迭代,分段计数。并发键值存储首选,缓存、计数器、去重表都靠它。记住它不允许 null、size 弱一致、用computeIfAbsent做"缺失才计算"的缓存。
五、ConcurrentSkipListMap / ConcurrentSkipListSet:有序并发
TreeMap线程不安全,要"并发 + 有序"就上跳表:
ConcurrentSkipListMap<Integer,String>map=newConcurrentSkipListMap<>();map.put(10,"a");map.put(20,"b");map.floorKey(15);// 10map.subMap(10,20);// 范围视图底层是跳表(Skip List)——在有序链表上"加多层索引",查找平均 O(log n),且天然适合无锁并发(相比红黑树,跳表的范围操作和后继定位在 CAS 下更易实现、更少重平衡)。
特性:
- 实现了
ConcurrentNavigableMap,支持subMap、headMap、floorKey、ceilingKey等(同 TreeMap 的能力)。 - key 必须可比较,不允许 null。
- 比
ConcurrentHashMap慢一档(有排序开销),但换来了"并发下也能范围/邻近查询"。 ConcurrentSkipListSet= 基于它的 Set。
适用:并发环境下的排行榜、时间线、区间统计——既要线程安全又要按 key 排序或取前驱后继。
六、阻塞队列家族(并发容器的另一半)
前文队列篇讲过,BlockingQueue的五个实现本身就是并发容器:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue。它们是生产者-消费者、线程池任务队列的主力,这里不再赘述,只强调一句:线程池稳定性直接取决于队列选型(无界队列 OOM、SynchronousQueue 炸线程)。
七、选型速查表(收藏级)
| 并发需求 | 单线程(非并发) | 并发容器 |
|---|---|---|
| List 读多写少 | ArrayList | CopyOnWriteArrayList |
| List 读写均衡 | ArrayList | synchronizedList(或外部锁) |
| Map 键值 | HashMap | ConcurrentHashMap |
| Map 有序 | TreeMap | ConcurrentSkipListMap |
| Set 去重 | HashSet | 无并发 Set 标杆,可用 ConcurrentHashMap.newKeySet() 或 CopyOnWriteArraySet |
| 队列 FIFO 非阻塞 | ArrayDeque | ConcurrentLinkedQueue |
| 队列 阻塞传递 | LinkedList 作 Queue | 各 BlockingQueue |
| 栈 | ArrayDeque | 无专门并发栈,用 ConcurrentLinkedDeque(双端) |
小技巧:
ConcurrentHashMap.newKeySet()能直接拿到一个并发安全的Set(JDK 8 起),底层就是 CHM 的 key 视图,比自己包CopyOnWriteArraySet更轻量,适合高并发去重。
八、弱一致性:并发容器的"潜规则"
几乎所有 JUC 容器(CHM、COW、跳表、并发队列)的迭代器都是弱一致的:遍历开始后,其他线程的增删可能反映、也可能不反映到本次遍历,但绝不抛ConcurrentModificationException。这和HashMap/ArrayList的 fail-fast(一改就抛 CME)正相反。
这带来两个务实影响:
- 别在遍历时依赖"一定看到最新写入"——并发下没有"即时可见"的保证。要强一致请加锁或用
synchronized。 - 边遍历边修改是安全的(尤其 COW),不会崩。这是并发容器比同步包装好用的一大原因。
九、常见误用
- 高频写还用 CopyOnWriteArrayList:每次写复制全数组。日志上报若 QPS 高,应换
ConcurrentLinkedQueue或批量落盘。 - 把 ConcurrentHashMap 当精确计数器:
size()弱一致。要并发计数请用LongAdder(JUC 专为高并发计数设计,分段累加,比AtomicLong在高争用下快得多)。 - 用 ConcurrentHashMap 存可变对象当缓存却不控制失效:CHM 不替你做过期淘汰,要做带 TTL 的缓存请用 Caffeine/Guava Cache,或自己加
DelayQueue清理。 - size()/isEmpty() 当业务判据:弱一致,临界判断要用外部同步或换
LongAdder/AtomicInteger。
十、面试追问
- Q:ConcurrentHashMap 和 ConcurrentSkipListMap 怎么选?不需要排序、追求极致并发和 O(1) 查找 → CHM;需要 key 排序、范围查询、前驱后继 → 跳表。后者慢一档但能力互补。
- Q:为什么跳表比红黑树更适合并发?红黑树插入要旋转、结构调整复杂,CAS 下极易冲突;跳表只在局部层级改指针、冲突面小,无锁实现简单且范围遍历高效。
- Q:CopyOnWriteArrayList 的迭代器能反映写入吗?不能,它持有创建时的数组快照,弱一致。但这正是它遍历安全、不抛 CME 的原因。
总结
并发容器全家福:CopyOnWriteArrayList/Set(读多写少、写时复制)、ConcurrentLinkedQueue(无锁 CAS 队列)、ConcurrentHashMap(并发 Map 标杆)、ConcurrentSkipListMap/Set(并发有序、跳表)、各 BlockingQueue(阻塞传递)。记住"弱一致迭代、size 不精确、CAS/写时复制/跳表"三条主线,按"读写比例、是否需要排序、是否跨线程"三问选型,就能在并发集合里不踩坑。HashMap 的并发版是 CHM,TreeMap 的并发版是跳表,ArrayList 读多写少版是 COW——整条映射关系一眼可推。