前阵子在给一组热点写入服务做压测时,遇到一个非常反直觉的现象:服务里已经做了读写锁分离、分桶加锁、批量提交这些常规优化,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 | 每个核心私有 |
| L3 Cache | 8MB ~ 64MB | 约10~20ns | 多个核心共享 |
| 内存(RAM) | 好几GB及以上 | 约80~120ns | 最慢,且带宽有限 |
你看着这个延迟差可能觉得区区100倍的差距无所谓,但在高频访问场景里,一次cache miss带来的停顿足以让CPU流水线空转几百个周期。如果这种空转在整个程序里占比很高,性能就会断崖式下降。
CPU之所以能干得快,靠的是两个局部性原理。时间局部性:你刚访问过的数据,可能很快还会再用;空间局部性:你访问了一块数据,可能很快会访问它周围的数据。所以CPU每次从内存读数据都是按“缓存行”为最小单位,一次搬64字节过来。也就是说,即使你只读了一个4字节的int,CPU也会把相邻的60字节一起拉进缓存。
数据结构设计得好不好,核心看它能不能让CPU的每次搬入都“物有所值”:尽量一次搬入多个马上要用到的数据。
2.2 缓存行和伪共享如何影响散列表
缓存行是理解很多并发性能怪象的关键。一个缓存行通常是64字节,存储着连续的内存区域。在多核系统里,多个核心各自持有同一块内存区域的副本,它们之间通过缓存一致性协议(通常是MESI)来同步状态。当某个核心往自己的缓存行里写数据时,其他核心如果也持有同一个缓存行,这些缓存行就会全部被置为Invalid状态,其他核心下次读取只能重新去内存搬。
这个机制本身是为了保证一致性,但它带来一个臭名昭著的问题:伪共享(False Sharing)。
举个例子,Java里如果定义了一个类:
java复制class Counter {
public volatile long countA;
public volatile long countB;
}
线程1疯狂修改countA,线程2疯狂修改countB。从逻辑上看,这俩变量毫无关系,各自加各自的锁不就行了。但物理上,countA和countB落在同一个64字节缓存行里。线程1每写一次countA,整个缓存行被标记为Modified;线程2读countB时发现自己的缓存行Invalid,被迫重新同步,即便countB根本没被改过。两个线程就这样互相拖累,性能可以下降一个数量级。
散列表在并发下的困境和这个高度相似。多个线程在访问散列表的哈希桶数组时,如果它们的桶下标恰好落在附近,就很容易共享同一个缓存行。即使它们操作的是完全不同的节点,也会因为缓存行失效而被迫等待。这才是并发性能上不去的底层原因。
3. 拉链散列表的缓存劣势
3.1 传统拉链法的内存布局分析
传统的拉链式散列表,以Java 8之前的HashMap为例,它的存储结构大致是:
java复制static class Node<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
数组里存的是Node引用,而Node对象分散在堆里。这里有两个致命问题:
第一,数组和节点是两段独立的内存区域,通过引用串起来。数组是连续的,访问它的缓存友好度还不错;但数组中每个坑位里存的仅仅是一个4字节或8字节的指针,真正有用的key和value全在堆里那些孤立的节点对象中。CPU在查询时,先读数组拿指针,然后再去访问节点对象,需要踩两次不同的缓存行。
第二,链表的每个节点在堆中分配时,内存地址大概率不连续。虽然JVM的-XX:+UseTLAB能让同一线程分配的对象相对靠近,但多线程并发创建节点时,节点分散在所有线程的TLAB、Eden区里,毫无空间局部性可言。
3.2 指针跳跃为什么致命
沿着链表找节点的过程,学术上叫“指针追逐”(pointer chasing)。由于链表节点在内存中不连续,你无法预读下一块需要的数据。CPU的分支预测器和硬件预取器对这种访问模式基本无能为力——它预测不了你要跳到哪个地址上去。
这种情况下,查找耗时基本等于“节点数 × cache miss平均延迟”。假设链表平均长度是4,内存延迟是100ns,那么一次不命中缓存就把查询成本推到400ns以上。这在单机高频调用场景里是致命的,因为散列表理论上应该是O(1)的查找,结果一个不小心就退化成了“O(n)的内存阻塞”。
我之前在优化一个网关路由表时,曾把链表长度压到平均不到2,但依然觉得卡顿。后来一测才发现,链表虽然短,但每个节点的内存地址相隔甚远,cache miss的比例极高。单纯控制链表长度根本不够,必须从内存布局下手。
3.3 对并发的影响放大效应
单线程下,指针跳跃只是拖慢速度;在并发下,它会被放大成灾难。
当一个哈希桶的链表被多个线程同时遍历或插入时,这些线程会反复读取和修改同一批节点。节点对象在堆里的内存地址是固定的,多个核心要对这些节点做同步,最直接的结果就是缓存行在物理核心之间频繁转移。你想象一下这样一个场景:16个线程同时访问散列表,它们并不存在真正的数据竞争——比如有的在读桶A的链表,有的在读桶C的链表——但桶A和桶C的下标相邻,数组部分的缓存行是同一个。于是,谁都别想舒服地只用本地缓存,每一次读取都可能在L3甚至内存层面做一次全量同步。
这就是为什么我最初分16个segment锁几乎没效果的原因:锁竞争解决了,但缓存行抖动没有解决。线程们不是争同一把锁,而是争同一个缓存行。
4. 缓存友好的拉链散列表应该怎么设计
4.1 连续内存存储:用数组模拟链表
既然指针跳跃和对象不连续是最大痛点,解法就是:把链表节点放进一块连续的大数组,用数组下标代替指针。
具体来说,用几个平行数组来存储散列表的全部“节点”:
c复制#define MAX_NODES 1000000
int next[MAX_NODES]; // 下一个节点的下标,相当于 Node.next
uint64_t keys[MAX_NODES]; // 键
uint64_t vals[MAX_NODES]; // 值
int bucket[BUCKET_COUNT]; // 每个桶的头节点下标,-1表示空桶
int free_head; // 空闲链表头
int nodes_count; // 已用节点数
在这种布局里,查询时先读取bucket[i],拿到的是整数下标,再根据下标去keys和vals数组里取数据。整个过程完全不用真实指针,纯粹是数组寻址。更关键的是,keys数组是连续的,vals数组是连续的。当你访问第k个key时,第k+1个key极大概率已经在同一个缓存行里了。如果链表节点是通过递增下标串联的,那么沿着next走的时候,后续几个节点大概率落在连续的几个缓存行内,硬件预取器终于可以发挥作用。
这就是一个典型的“缓存友好化”改造。它没改变拉链法的逻辑,只是把链的内存位置从堆里收拢到几块连续空间。
4.2 键值紧凑排列与桶内线性探测/二分
光把节点放连续还不够,还得让每个桶内部的节点尽量保持在物理相邻的小区域内。这样查询一个桶时,一次缓存行搬入就能覆盖桶里前半截链表。
更极端的做法是放弃纯粹的节点数组,把整个桶做成一个小数组,相当于“拉链法+开放寻址法的混合”。比如:
- 每个哈希桶分配一个固定的头部槽位(可能4~8个slot),直接内联在桶数组里;
- 冲突少时,直接在桶内线性探测,完全不跳出桶;
- 冲突超过阈值,再用溢出链表/溢出数组承接。
这样做的收益是什么?一个64字节缓存行能放下8个uint64_t的key,如果每个桶只有少量元素,整个桶的数据可以一次性装进一个缓存行里。查询时只需要一次缓存行加载,就能完成整个桶的比较,速度极其恐怖。
我在一个自研的会话缓存里就是这种设计:桶数组每个索引位置存放固定大小的“小组”,小组前面是槽位计数,后面是key数组和value数组。实测下来,在低冲突场景(负载因子控制在0.5以下),查询延迟比Java HashMap低了约40%,而且这个差距在CPU缓存更大的服务器上会更明显。
4.3 分布式填充与缓存行对齐
连续内存解决的是“读路径加速”,但并发场景下写路径还有一个坑:多个线程写相邻桶的时候,还是会踩同一个缓存行。要治这个病,得靠“错峰填充”。
最直接的办法是给每个桶加padding,让不同核心经常访问的桶落在不同的缓存行里。比如Java用@Contended注解,C/C++可以手动加__attribute__((aligned(64)))。但注意,padding会白白占用内存,桶特别多时开销巨大。通常只对全局的“热点桶”做这种处理,而不是对每个桶都对齐。
另一个办法是把桶数组分成多个“带状区域”,每个线程绑定访问其中的某个子区域,降低跨核共享的概率。比如在无锁实现里按线程ID取模分配到不同的内存块,每个线程只操作它那块的哈希桶。这样天然减少伪共享,代价是负载均衡要靠哈希分布来保证。
5. 并发场景下的落地:无锁插入与优雅扩容
5.1 CAS无锁插入机制
用连续数组替代指针节点还有一个隐藏优点:可以用原子变量直接管理节点分配。核心思路是把“找空节点”和“发布节点”拆成两个原子操作。
| 步骤 | 操作 | 做什么 |
|---|---|---|
| 1 | FetchAndAdd | 从一个原子计数器拿到下一个空闲节点下标,类似于AtomicInteger.getAndIncrement |
| 2 | 写入数据 | 把key、value、next分别写入keys[i]、vals[i]、next[i] |
| 3 | CAS发布 | 把bucket[hash]从旧的头节点old替换成新节点下标,compareAndSet(old, i) |
这里的关键是第2步和第3步之间的顺序。因为步骤3用CAS发布时,会带上一个内存屏障,保证第2步写入的数据对所有线程可见。换句话说,别的线程看到新节点出现在桶里之前,一定能看到key和value已经被正确写入。这正是无锁数据结构里最常见的“先写完再发布”模式。
使用这种结构的插入流程,不需要加锁,也不需要停掉整个散列表让其他线程等待。在高并发写入场景下,它通常能比带锁版本吞吐提升50%以上,因为核心瓶颈从“等待锁释放”变成了“CAS冲突率”。而CAS冲突率可以通过增加桶数量、分散哈希分布来进一步压低。
5.2 并发扩容的两种可靠方案
散列表总有装满的一天,扩容是最危险的操作。如果此时还有其他线程在并发读写,你直接rehash,轻则丢数据,重则让其他线程读到中间状态。这里分享两种我在实践中验证过可靠的方案。
方案一:分段迁移
把整个桶数组划分成若干段,每次只迁移其中一段。迁移时先给该段加锁,把该段所有链表节点重新哈希到新的桶数组中。其他段的读写不受影响,仍然走旧表。只有当所有段都迁移完,才把全局的持有数组引用切换成新表。
这个方案的优点是扩容过程中服务基本不中断,缺点是逻辑复杂度高,要处理好“读旧表+迁移中”的一致性问题。一般会引入版本号或引用切换来保证原子性。
方案二:双数组滚动替换(Copy-on-Write)
新表先一次性构建好,构建期间读操作全部走旧表。构建完成后,用一个volatile引用原子地指向新表。所有后续读写都操作新表,旧表等所有持有它的线程读完后被GC回收。
这个方案在Java里非常自然,因为对象引用更新是原子的,配合volatile或AtomicReference就能实现。缺点是需要双份内存,扩容期间内存占用翻倍。如果存储的是大对象,可能会触发OOM,因此要控制好触发时机。
我在做网关路由表时用的就是双数组方案,因为路由表本身只有几万条记录,双份内存根本不是问题。真正要注意的是:扩容时必须让新表完全构建完成再发布,绝不能边发布边构建。
5.3 实测数据对比和取舍建议
为了让你对“缓存友好拉链散列表”提升多少有个直观感受,我拿一个简单的压测数据表出来。环境是8核16线程,数据量200万个key,平均链表长度约4,读写比例7:3,基准是Java的ConcurrentHashMap:
| 实现方案 | 每秒请求数(QPS) | P99系统(微秒) | 每次请求平均缓存miss次数 |
|---|---|---|---|
| 标准拉链散列表+分段锁 | 约3.1万 | 约62 | 8.4 |
| 标准拉链散列表+全局锁 | 约1.2万 | 约130 | 9.1 |
| 数组模拟链表+分段锁 | 约4.8万 | 约38 | 3.9 |
| 数组模拟链表+CAS无锁 | 约6.2万 | 约24 | 2.7 |
从数据能看到,把链表节点收拢进连续数组之后,即便还用分段锁,QPS也能上涨50%以上。这说明提升主要来自cache miss减少,而不是锁的改进。如果再上CAS无锁写入,又能挤出一部分性能。
但这里要泼一盆冷水:缓存友好的拉链散列表并不是万能的。如果你的负载特征是极少量热点key被反复读写,那瓶颈不在散列表的存储布局,而在热点key本身的原子更新和互斥上。这种情况应该先做key拆分,比如把热点计数按线程分片,再异步merge,而不是想着优化散列表结构。散列表优化解决的是“全表遍历/随机访问”的cache miss问题,解决不了“同一把数据锁打架”的问题。
6. 踩坑清单与工程化建议
6.1 伪共享的隐形坑
我最初改造连续数组版本时,因为过于关注查询性能,忽略了并发写路径上的伪共享。结果压测时发现,CAS无锁插入版本在某些场景下反而比分段锁还慢。
排查下来,问题出在nodes_count这个共享原子变量上。所有线程插入前都要先GetAndAdd拿下标,这个操作本身不阻塞,但所有线程都在反复修改同一个内存地址,导致该缓存行在核间疯狂转移。解决办法是按线程分片分配节点:每个线程拥有自己的空闲节点池,只操作自己的计数器。这样就把全局的原子计数器冲突,降级成了每个线程本地计数器,彻底关掉了伪共享源头。
6.2 扩容时机和负载因子的选择
缓存友好的散列表通常不会容忍链表太长,因为链表一长,连续内存的优势会被链式查找抵消。我的经验是:
- 负载因子控制在0.5~0.7之间,太高容易退化成链表遍历,太低浪费内存。
- 扩容时应提前预估峰值容量,尽量做到“一上来就分配足够大的表”,避免在高并发期间频繁触发扩容。
- 如果无法预知容量,建议按2倍步长扩容,但每次扩容前做一个“软状态标记”,让所有写线程感受到扩容信号后,分批停顿配合,而不是所有线程同时冲过去resize。
6.3 什么时候不该用拉链散列表
最后说几句心里话。有时候你根本不需要拉链散列表。如果键空间很大但实际元素很少,直接用开放寻址散列表反而更合适,因为它能最大化利用连续内存,查询时基本一次缓存行加载就能搞定。如果写入极少、查询极多,排序数组加二分查找也很有竞争力。如果数据量小到只有几十上百条,线性扫描甚至比任何散列表都快。
技术选型不是越潮越好,而是要让数据结构的内存访问模式和你的实际工作负载尽量匹配。这也是为什么我强烈建议在写高并发代码之前,先搞清楚CPU高速缓存是怎么回事。你越理解硬件的运作方式,越能明白那些经典数据结构里的设计取舍——它们每一处都不是凭空拍脑袋定出来的。
