前阵子在给一组热点写入服务做压测时,遇到一个非常反直觉的现象:服务里已经做了读写锁分离、分桶加锁、批量提交这些常规优化,TPS到了三万左右就死活上不去了。用perf和JFR把热点拉出来一看,CPU时间根本不在业务代码上,而是集中在一个看起来人畜无害的散列表的读和写上面。后来我把存储结构从标准的拉链散列表换成了缓存友好版本,同样的并发场景下吞吐几乎翻了一倍,锁的代码一行没改。这篇文章就来说清楚背后的原因:CPU高速缓存和数据结构的内存布局,到底是怎么悄悄决定并发性能的。
这篇文章适合谁看?如果你在写高并发服务、在做中间件或存储引擎、或者单纯想搞明白为什么网上那些“并发编程八股文”说得头头是道但实际压测就是达不到预期,那你应该认真读一遍。不会有特别多难懂的概念,但我尽量把CPU缓存、缓存行、伪共享这些底层机制和散列表的实际内存布局串起来讲。
1. 同一个散列表,为什么并发一上来就崩了
1.1 一次压测暴露的真相:锁不是唯一瓶颈
当时手上的服务是一个KV查询的中间层,底层维护一个比较大的HashMap做热点缓存,写多读少,写操作会更新几个热点key。为了降低锁竞争,我把HashMap拆成了16个segment,每个segment一把独立的锁,理论上有16倍的并发提升。可是压测结果完全没有线性扩展,16个segment和4个segment相比几乎没有区别。
我一开始也以为是锁的粒度不够细,后来用perf top看了内核态的采样,发现大量的CPU时间并不是在锁等待上,而是在sync_sync之前的一堆cache miss stall上。换句话说,线程并没有在等锁,而是在等内存。
这让我重新审视了一个基础问题:对于散列表这种数据结构,真正决定并发上限的往往不是线程等待锁的时间,而是CPU执行指令时等待数据从内存搬进高速缓存的时间。锁竞争只是你看到的结果,缓存一致性协议导致的“缓存行抖动”才是幕后黑手。
1.2 散列表读取的热点究竟卡在哪里
先看一个标准的拉链法散列表长什么样。它本质上是一个数组,数组每个坑位放一个桶,桶里是一条链表。
code复制bucket[0] -> node_1 -> node_2 -> null
bucket[1] -> null
bucket[2] -> node_3 -> null
查找一个key时,首先根据哈希值算出桶下标,这一步是O(1),很痛快。然后沿着链表一个个比对key。这一步要命了——链表的每个节点是独立new出来的对象,它们在堆内存里是东一个西一个的,没有任何相邻关系。
当CPU按顺序访问node_1、node_2、node_3时,每次都要做一次指针间接寻址。这个间接寻址意味着CPU必须先去访问那块内存,才能拿到下一个节点的内存地址。如果不走运,这块内存不在缓存里,CPU就不得不陷入停顿,等待几百个周期,直到内存把数据搬到高速缓存。
在单线程环境下,这个停顿顶多让一次查询慢几十纳秒,你感知不到。但在多线程并发环境下,多个线程同时对同一个散列表做读写,情况完全不同:不同线程频繁修改散列表结构时,那些共享的相邻内存区域会在多核之间来回“bounce”,导致缓存行反复失效。最终表现就是:越并发,内存等待越严重,CPU利用率虚高,但实际吞吐死活上不去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CPU高速缓存才是散列表性能的真正裁判
2.1 三级缓存架构与局部性原理
现代CPU不会直接去内存里读数据,而是先把数据搬到离CPU核心更近的高速缓存里。这里有个简单的层次模型:
| 层级 | 典型大小 | 延迟(大约) | 说明 |
|---|---|---|---|
| L1 Cache | 32KB ~ 64KB | 约1ns | 每个核心私有,极快 |
| L2 Cache | 256KB ~ 1MB | 约3~4ns | 每 |
