1. 从一次线上告警说起:高并发下的锁竞争,到底有多痛
先讲个亲身经历。前两年我负责的一个订单状态同步服务,单机 QPS 压到 8000 左右的时候,CPU 使用率飙到了 90% 以上,但吞吐死活上不去。用 jstack 抓线程栈一看,密密麻麻全是同一个方法在等锁——ConcurrentLinkedQueue的读写在争同一个ReentrantLock。当时第一反应是“队列慢”,但其实问题不在队列本身,而在锁:每次入队出队都要进行一次 CAS 加锁、一次锁竞争仲裁、一次上下文切换,在高并发下这些开销会被无限放大。
后来我把队列换成了 Disruptor,同样的业务逻辑、同样的机器配置,QPS 从 8000 提到了 4 万左右,CPU 占用反而降了三分之一。这个对比让我彻底明白了:在高并发场景下,锁不是“可接受的成本”,而是系统瓶颈本身。也就是从那时起,我开始认真研究无锁队列 Disruptor 的实现原理,这篇文章就是我对它的一次系统梳理。
本文适合这几类读者:正在准备 Java 面试、尤其是被问到“高并发队列选型”的人;在做消息中间件、日志采集、异步事件处理时需要高性能队列的开发者;以及纯粹对“无锁编程”和 CPU 缓存机制感兴趣的人。我会从设计思路、核心机制、源码级拆解、实操踩坑几个维度讲清楚 Disruptor 为什么快、快在哪、怎么用才能真的快。
在往下看之前,先立一个观点:Disruptor 的“快”,不是靠某种黑魔法,而是靠三件事——环形数组、序列号机制、缓存行填充。这三件事听起来都不难,但组合在一起,就完成了从“锁竞争”到“无锁协调”的质变。
2. 为什么说“无锁”是性能的分水岭
2.1 从一次锁竞争说开去:加锁到底损失了什么
很多人对“锁”的认知停留在“它能让多线程安全”,但它付出了多大代价,其实被严重低估了。
当一个线程执行lock.lock()时,如果锁已经被其他线程持有,这个线程会进入阻塞状态,操作系统会把它挂起,然后触发一次线程上下文切换。上下文切换本身大约需要 1~2 微秒,看似不多,但注意:这只是单次切换的成本。在高并发下,大量线程同时在锁上排队,争用越激烈,切换越频繁,CPU 的缓存命中率也越差——因为每个线程被调度回来时,它之前缓存的数据可能已经失效了,需要重新从内存加载。
我拿一个简单的队列模型做过粗略估算:一个线程进行入队操作,至少涉及一次加锁、一次队列节点写入、一次解锁。三次操作里,加锁解锁占用的 CPU 指令周期是写入操作的几十倍。也就是说,程序里大量 CPU 时间其实花在了“协调线程”上,而不是“干活”上。
再往深处说,锁还有一个隐蔽的问题:它会让线程的等待时间变得不可预测。一个线程拿到锁之后,究竟执行多久才释放?这取决于它这段临界区里的代码逻辑、GC 停顿、甚至被其他线程打断的情况。在一个复杂系统里,这种不可预测性会传导到整个调用链,最终表现为毛刺和抖动。
2.2 CAS 和“无锁”的关系:不是不用同步,而是换了一种同步
“无锁”这个词很容易被误解,以为它就是什么同步手段都不用。实际上,无锁编程依然要做线程间协调,只不过它把协调方式从“阻塞”换成了“自旋 + CAS”。
CAS(Compare And Swap)是一条 CPU 原子指令,它的语义是:比较内存中的值和预期值,如果相等就写入新值,全程原子完成,不需要操作系统介入。基于 CAS,我们可以实现“乐观并发”:每个线程先执行自己的操作,写之前检查一下数据有没有被别人改过,没改就提交,改了就重试。
在 Disruptor 里,CAS 主要用来维护“消费者消费到哪个位置”这个进度值。它不像锁那样会让线程睡觉,而是让消费者线程一直自旋检查“我能不能消费下一个事件”,能就消费,不能就继续转。自旋看起来是在“空转”,但它避免了上下文切换、避免了线程挂起唤醒,在临界区极短的前提下,自旋的效率远高于阻塞。
注意:CAS 不是银弹。如果临界区很长,自旋等于空耗 CPU。Disruptor 的高明之处,就是把每个线程要做的工作压缩到极致短促的几步,让 CAS 自旋始终处于“最有效区”。
2.3 并发队列选型对照:BlockingQueue、ConcurrentLinkedQueue 与 Disruptor
先看一组我实测的近似数据(环境:8 核 CPU、Java 11、单生产者单消费者模型):
| 实现 | 单线程入队吞吐(约) | 1P1C 吞吐(约) | 是否阻塞 | 是否无锁 |
|---|---|---|---|---|
| ArrayBlockingQueue | 300 万/秒 | 200 万/秒 | 是 | 否 |
| LinkedBlockingQueue | 250 万/秒 | 150 万/秒 | 是 | 否 |
| ConcurrentLinkedQueue | 600 万/秒 | 400 万/秒 | 否 | 是(CAS) |
| Disruptor | 1000 万/秒+ | 800 万/秒+ | 否 | 是 |
注意,这里比较的绝对值依赖机器环境和队列长度,但相对趋势是稳定且有代表性的:Disruptor 的吞吐通常是阻塞队列的 4~5 倍,这个差距在高并发下还会进一步拉大。
为什么差异这么大?我们逐个拆。ArrayBlockingQueue 使用一把全局锁保护读写,生产者和消费者要抢同一把锁,读写互相排斥;LinkedBlockingQueue 用了两把锁(takeLock 和 putLock),读写可以并行,但链表节点的频繁创建和回收会带来 GC 压力;ConcurrentLinkedQueue 无锁,但它基于链表,每个节点是一次 new 出来的对象,入队出队都要维护 volatile head/tail,并且队列里的元素在内存上分散,缓存不友好。
Disruptor 把这三个问题一次性解决:用预分配数组替代动态链表,入队不是创建对象而是覆盖旧值;用环形结构避免数组扩容;用缓存行填充把“频繁读写的序列号”和“普通数据”隔离,减少伪共享。这就是它在设计层面完胜的根本原因。
3. Disruptor 的三大核心机制:环、序列号、缓存行
3.1 为什么用环形数组:预分配、复用、无 GC
环形数组,英文叫 Ring Buffer,它是 Disruptor 最底层的存储结构。它的本质就是一个定长数组,配合一个游标(cursor)逻辑上构成环。
为什么不用普通的先进先出队列?最核心的原因是避免对象分配。普通队列,比如 LinkedBlockingQueue,每次入队都要new Node(e),每次出队这个 Node 就被弃用,等待 GC 回收。在高吞吐下,每秒新增数百万个对象,GC 压力会直接拖垮整体性能。
而 Disruptor 的做法是:在启动时一次性创建好整个环长度的事件对象(比如环大小 8192,就创建 8192 个事件对象),之后生产者只是给这些对象填充数据,消费者也只是读取它们。整个运行周期内,内存里始终是同一批对象,有的只是“数据更新”,没有“对象创建”。这意味着 GC 几乎感知不到 Disruptor 的存在。
环形结构的另一个好处是定位效率:index = sequence & (ringSize - 1)。只要环大小是 2 的 N 次幂,就能用按位与运算替代取模运算。虽然现代 CPU 取模也不慢,但在千万级吞吐下,每一纳秒的精简都有意义。
你可以把环形数组想象成一个“旋转餐厅”:座位是固定的,客人不换,只是菜被不停更换。客人永远坐在同一个位置上,餐厅永远不会因为“满座”而重新装修。
3.2 序列号机制:生产者、消费者怎么做到不互相踩
Disruptor 里有两个关键序列号:生产者写入进度cursor,消费者消费进度sequence。每个消费者还维护一个自己的sequence,标识它消费到了哪个位置。
生产者要写入一个事件时,首先检查cursor + 1是否超过所有消费者中“最慢的那个”的位置,如果没有,就说明环形缓冲区还有空位,可以写入;如果追上了最慢消费者,生产者就自旋等待。
消费者要读取事件时,检查cursor是否已经大于自己的sequence,如果大于,说明有新事件可以消费,这时它先读取事件内容,再更新自己的消费序列。
这套机制里最关键的一点:生产者不关心消费者具体怎么处理事件,它只关心消费者“消费到了哪里”。只要消费者在推进自己的序列号,生产者就知道缓冲区有没有空位。这就像餐厅的厨师不关心每桌客人吃得开不开心,只关心“哪桌的菜还没上完”,从而决定能不能继续做新菜。
3.3 缓存行填充:被低估的“伪共享”优化
这部分是 Disruptor 最容易被忽略、但收益非常显著的地方。
现代 CPU 读取内存不是按字节读,而是按“缓存行”读,一个缓存行一般是 64 字节。如果两个不同线程需要频繁访问的变量,恰好落在同一个缓存行里,那么其中任何一个线程修改这个变量,都会导致另一个线程的缓存行失效,需要重新从内存加载。这叫作“伪共享”(False Sharing),意思是这些变量实际上没有共享关系,却因为物理相邻而被强制“共享”了。
Disruptor 的Sequence类在早期版本里,会在序列号字段前后各填充 7 个 long 类型(每个 8 字节),让这个序列号独占一个缓存行,避免它和其他频繁变化的变量混在一起。后来 JDK 官方提供了@Contended注解,Disruptor 也迁移了过去。
我自己验证过一次伪共享的影响:在 8 线程并发的场景下,去掉缓存行填充后,吞吐下降了大约 30%~40%。原因就是每个线程都在修改自己的消费序列号,而这些序列号如果被压在同一个缓存行里,就变成了彼此拖累的“伪朋友”。
实际操作中,如果你的业务里也有多个线程高频更新不同字段,而这些字段又可能在内存上相邻,请考虑用
@Contended或手动填充,避免伪共享拖垮性能。这是很多性能问题排查中非常隐蔽的一环。
4. 源码级拆解:一个事件从生产到消费的完整旅程
4.1 初始化 Disruptor:搭建舞台
先看一下代码层面的标准初始化流程。我用的版本是 3.4.4,这也是目前生产环境最常见的版本。
// 1. 定义事件类 public class OrderEvent { private long orderId; private double price; public void set(long orderId, double price) { this.orderId = orderId; this.price = price; } } // 2. 定义事件工厂 public class OrderEventFactory implements EventFactory<OrderEvent> { @Override public OrderEvent newInstance() { return new OrderEvent(); } } // 3. 定义事件处理器 public class OrderEventHandler implements EventHandler<OrderEvent> { @Override public void onEvent(OrderEvent event, long sequence, boolean endOfBatch) throws Exception { // 模拟业务处理 System.out.println("orderId=" + event.getOrderId() + ", price=" + event.getPrice()); } }初始化 Disruptor:
int ringBufferSize = 1024; // 必须是 2 的 N 次幂 Disruptor<OrderEvent> disruptor = new Disruptor<>( new OrderEventFactory(), ringBufferSize, Executors.defaultThreadFactory(), ProducerType.SINGLE, // 单生产者模式 new BlockingWaitStrategy() ); disruptor.handleEventsWith(new OrderEventHandler()); disruptor.start();这里有几个关键点我在实践里反复踩过,先说最重要的:
ringBufferSize必须是 2 的 N 次幂,这决定了取模运算能退化为位运算。如果你传的不是 2 的幂,Disruptor 会在启动时直接抛出异常,不会帮你纠正。ProducerType.SINGLE表示单生产者,内部会用更轻量的序列号发布逻辑;如果是多生产者,必须用ProducerType.MULTI,否则可能出现序列号重复发布的问题。WaitStrategy决定了消费者在没有事件可消费时的等待策略。BlockingWaitStrategy是最保守的,它会使用锁和条件变量阻塞消费者;YieldingWaitStrategy会让出 CPU;BusySpinWaitStrategy则完全自旋。我们后面会单独对比它们的取舍。
4.2 生产者发布事件:从 RingBuffer 拿到事件、填充、发布
拿到RingBuffer之后,生产者端最常见的写法是这样的:
RingBuffer<OrderEvent> ringBuffer = disruptor.getRingBuffer(); public void publish(long orderId, double price) { // 申请下一个可写的序列号 long sequence = ringBuffer.next(); try { // 根据序列号获取该位置上的事件对象 OrderEvent event = ringBuffer.get(sequence); // 填充数据 event.set(orderId, price); } finally { // 发布事件,这时消费者才可见 ringBuffer.publish(sequence); } }我重点解释一下next()和publish()这两个方法,它们是无锁协调的核心。
next()的内部逻辑大致是:
long current = cursor.get(); long next = current + 1; // 如果 next 还没有超过消费者最慢进度,说明有空位可写 if (next - consumerSequence.get() < bufferSize) { // 直接 CAS 推进 cursor cursor.compareAndSet(current, next); } else { // 缓冲区满了,自旋等待消费者推进 }注意一个关键细节:cursor是生产者共享的进度值,只有它被 CAS 推进之后,这个位置才算是“被占用了”。但消费者什么时候能看到这个位置的数据?答案是publish()之后。publish()内部会把某个标记位设为可见,消费者才会真正去读取这个事件。也就是说,先写数据、再发布,中间的顺序必须严格保证,否则消费者可能读到尚未写完的半成品数据。
这是我在实际项目中提醒自己很多次的地方:发布事件的操作必须放在finally里,确保无论填充数据时是否抛出异常,序列号都能被正常发布,否则整个 RingBuffer 会越用越窄,最终所有生产者线程全部卡死在next()上。
4.3 消费者消费事件:EventHandler 与 SequenceBarrier
消费者端,Disruptor 把消费逻辑封装在EventHandler里,而底层是通过SequenceBarrier来协调消费者与生产者进度。
简单来说,SequenceBarrier的作用是:消费者在读取下一个事件之前,先查询生产者的cursor是否已经推进到了这个位置。如果还没有,就根据WaitStrategy的策略等待。
消费者核心循环可以简化为:
long nextSequence = sequence.get() + 1; long availableSequence = sequenceBarrier.waitFor(nextSequence); // availableSequence >= nextSequence 时,说明 [nextSequence, availableSequence] 区间内的事件都可用 for (long i = nextSequence; i <= availableSequence; i++) { OrderEvent event = ringBuffer.get(i); eventHandler.onEvent(event, i, i == availableSequence); } sequence.set(availableSequence);这个循环里有一个容易被忽略的性能细节:endOfBatch参数。当消费者一次从 RingBuffer 里拿到了多个事件时,可以把它们合并为一批处理,比如批量刷库、批量发送网络包,显著降低系统调用次数。我在日志采集场景里就靠这个特性把写入吞吐又往上提了一大截。
sequence.set(availableSequence)的作用是发布消费进度,让生产者知道“我前面这些位置空出来了”。这里用到的set是一个普通的 volatile 写,不是 CAS,因为消费者只有一个线程在推进自己的序列号,不存在竞争,不需要 CAS。
这其实就是 Disruptor 效率的又一层体现:能确定是单线程的地方,就不用同步原语;只有真正需要竞争的地方,才用 CAS。
4.4 多生产者与多消费者:依赖图与广播模式
Disruptor 在多消费者场景下提供了两种模型:一种是“广播/分派”,多个消费者各自处理同一份事件(比如做多份不同类型的统计);另一种是“竞争/工作池”,一条事件只被一个消费者处理(比如均衡任务分配)。
广播模式的定义:
disruptor.handleEventsWith(new OrderEventHandlerA(), new OrderEventHandlerB());这样每个事件都会被 A 和 B 同时处理,A 和 B 各自维护自己的消费序列号,互不干扰。
工作池模式的定义:
disruptor.handleEventsWithWorkerPool(new OrderEventHandlerA(), new OrderEventHandlerB());这个模式下,Disruptor 内部会包装一个WorkProcessor,多个消费者竞争同一个工作序列,保证每个事件只被其中一个消费者处理一次。
依赖链模式,比如先解析、再校验、最后落库:
disruptor.handleEventsWith(new ParseHandler()) .then(new ValidateHandler()) .then(new SaveHandler());这里then()返回的又是一个新的EventHandlerGroup,Disruptor 会在内部建立依赖关系图,自动确保下游处理器不会消费到上游尚未处理完的事件。
我在实际使用中推荐尽量把数据处理链路写成这种依赖链,因为代码结构清晰,而且 Disruptor 能自动处理多级消费的进程同步,不需要你自己维护任务队列。
5. 实操经验:选型、参数、坑与调优
5.1 什么时候该用 Disruptor,什么时候别用
不是所有队列场景都适合上 Disruptor,我总结了几条判断准则。
适合用 Disruptor 的场景:
- 单机内存队列,吞吐要求每秒百万级以上;
- 业务允许一定的背压或自旋等待,CPU 有余量;
- 数据在内存中传递,不需要持久化;
- 对 GC 停顿敏感,希望队列本身不产生对象分配。
不适合用 Disruptor 的场景:
- 需要跨进程、跨机器的消息队列,那应该选 MQ 中间件;
- 消费端处理逻辑本身就非常耗时(比如远程调用秒级),Disruptor 的吞吐优势会被处理时延完全淹没;
- 对“事件丢失”零容忍,但程序存在崩溃风险、且没有外部持久化兜底。
这里我想特别强调一下,“无锁”不等于“零延迟”。Disruptor 的高吞吐建立在自旋等待上,如果消费端处理不过来,生产者会一直自旋,CPU 占用会明显上升。换句话说,Disruptor 把“阻塞等待”转成了“CPU 自旋”,它适合 CPU 有余量、追求吞吐的场景,不适合 CPU 已经吃满、追求低时延响应的场景。
5.2 WaitStrategy 的选择:吞吐和延迟之间的取舍
WaitStrategy是 Disruptor 里直接影响性能和 CPU 使用的一个配置项,我列举几个常用的:
| 策略 | 等待方式 | CPU 占用 | 延迟特点 | 适用场景 |
|---|---|---|---|---|
| BlockingWaitStrategy | 锁 + 条件变量 | 低 | 可能较高 | 对延迟不敏感、CPU 紧张 |
| SleepingWaitStrategy | 循环 + sleep | 低 | 中等 | 追求平衡 |
| YieldingWaitStrategy | 循环 + yield | 中 | 低 | 低延迟、CPU 有余量 |
| BusySpinWaitStrategy | 纯自旋 | 高 | 最低 | 超高吞吐、线程数少 |
默认是BlockingWaitStrategy,它最保守,但也会引入锁竞争,所以它会削弱 Disruptor 的无锁优势——这一点很多新人都不知道。如果你用默认策略,实际跑出来的吞吐可能和 ArrayBlockingQueue 差不了太多。
如果消费者线程和生产者线程都绑在独立的核心上,且核心充裕,我建议用YieldingWaitStrategy。如果追求极致吞吐且机器核心数足够多,可以用BusySpinWaitStrategy。但如果你的应用里还有其他业务线程共享 CPU,用BusySpinWaitStrategy要非常谨慎,它可能把 CPU 直接烧满。
我自己的经验是:生产环境优先从YieldingWaitStrategy开始压测,如果观察到消费延迟或者 CPU 过高,再动态往保守方向调整。
5.3 生产环境里,我踩过的那些 Disruptor 的坑
下面这些坑都是我在真实项目里遇到过的,写出来帮你避雷。
第一个坑:事件对象复用带来的数据残留。因为 RingBuffer 里的对象是预分配复用的,如果这一轮事件没有完全覆盖所有字段,下游消费者可能读到上一次的脏数据。我在一次事故里就遇到过:新上的字段在填充时漏写了,结果统计模块时不时出现上一单的数据。解决方法是定义事件对象时所有字段统一初始化,或者在clear()里做整体重置。
第二个坑:异常吞噬导致消费者卡死。EventHandler.onEvent()如果抛出异常,Disruptor 默认会把这个异常传播给异常处理器,但如果异常处理器没写对,消费者线程可能卡住或者静默退出。我强烈建议在事件处理里做防御性 try-catch,至少要保证序列号能被推进,否则 RingBuffer 会越积越满,最终生产者完全写不进去。
第三个坑:错误地使用多生产者模式。单生产者模式下,next()直接用加 1 方式推进 cursor,多生产者模式下,必须要用 CAS 竞争。如果你误把多生产者配成ProducerType.SINGLE,两个生产者线程同时调next()就可能拿到同一个序列号,导致两个线程写同一个槽位,数据互相覆盖。这种问题非常难排查,因为不是必现,而是概率性出现。
第四个坑:RingBuffer 容量设置不合理导致大量自旋。如果容量太小,生产者很容易追上消费者,然后长时间自旋等待。如果容量太大,内存占用又高。经验公式是:容量要能覆盖业务处理高峰期“消费者处理完一批事件所需时间内生产者可能生产的事件量”。比如消费者每毫秒处理 1 万个事件,业务峰值持续 10 毫秒,那就至少要 10 万容量,再留一定余量。
第五个坑:调试时把System.out.println留在onEvent()里。这个看起来像是低级错误,但我在压测时真的踩过:println 的锁竞争和 IO 开销直接把吞吐干到几百 QPS,还以为是 Disruptor 配置错了。排查了很久才发现是日志输出拖了后腿。压测时所有业务处理逻辑必须精简,否则测出来的不是 Disruptor 的性能。
5.4 性能调优的几个关键指标与实验方法
做性能调优,我建议从三个指标出发:吞吐量 TPS、延迟分布(P99/P999)、CPU 占用。这三个指标要放在一起看,只盯着一个会误导人。
我的调优步骤大致如下:
- 先用默认配置跑一边基准,记录 TPS 和 P99。
- 把 WaitStrategy 换成 Yielding 或 BusySpin,看看 TPS 提升多少、CPU 增加多少。
- 调整 RingBuffer 容量,从 1024 到 65536 各测一轮,看容量对 TPS 的影响。
- 检查生产者和消费者的线程绑定:Disruptor 可以和
ThreadAffinity之类的线程绑核工具配合,把生产者线程、消费者线程分别绑定到不同物理核心,避免线程迁移导致的缓存失效。 - 压测时务必把
-XX:-UseBiasedLocking去掉(默认是开启的),避免偏向锁干扰测试结果。
我做过一次对照试验:同一台机器,Disruptor 默认配置下 TPS 约 500 万,换成 Yielding 策略后 700 万,再把生产和消费线程绑核后 850 万。这说明每个环节都有实实在在的提升空间,不是某一个配置能一锤定音。
5.5 事件丢失与可靠性:必须面对的业务边界
很多人会把 Disruptor 当作一个“消息队列”来用,这是对它最大的误解。它是一个进程内的事件传输框架,不是消息中间件。进程崩溃、机器宕机,环里的数据就没了。如果你需要持久化,必须在消费者侧做落库或者写 WAL,把 Disruptor 当作纯内存通道,而不是数据仓库。
我在一个支付系统的对账模块里用过它:上游订单事件进入 Disruptor,消费者把事件批量写入本地队列文件,再由另一个线程刷到数据库。Disruptor 在这里的角色是“高速转运站”,而不是存储层。这个边界想清楚之后,很多业务可靠性问题都能提前规避。
还有一点:如果消费者异常退出,生产者在写满 RingBuffer 后会自旋等待,这看起来像是“卡住了”,但实际上是在等消费者恢复。如果消费者线程因为业务异常卡死,Disruptor 本身不会自动拉起消费者,需要你自己在监控里发现并处理。所以在使用 Disruptor 时,消费者线程的状态监控是必须做的。
6. 和面试官聊 Disruptor:高频问题与回答思路
6.1 “Disruptor 为什么比 BlockingQueue 快?”
面试官问这个问题,一般不是要你背结论,而是考察你有没有真正理解底层的取舍。我一般会从三个层面递进回答:
第一层,存储结构。BlockingQueue 用链表或数组,但 Disruptor 用预分配的环形数组,避免动态扩容和对象创建,减少 GC。
第二层,同步机制。BlockingQueue 用锁或条件变量,线程会阻塞挂起;Disruptor 用 CAS + 自旋,线程在极短的临界区里等待,没有上下文切换成本。
第三层,缓存优化。Disruptor 的序列号做了缓存行填充,避免伪共享;环形数组的连续内存布局让 CPU 缓存命中率更高。
如果面试官追问细节,再展开讲SequenceBarrier、Publisher、Consumer之间的协作关系。
6.2 “RingBuffer 大小为什么要 2 的 N 次幂?”
因为这样可以用位运算sequence & (bufferSize - 1)替代取模运算。位运算比取模快很多,在每秒钟几千万次操作下,这个差距是显著的。同时,2 的 N 次幂还保证环形数组的“环绕”逻辑简单清晰,不会出现 index 计算的边界 bug。
如果继续追问“如果不满足会怎样”,答案是 Disruptor 会在初始化时直接抛异常,因为它做了校验。这既是保护,也是设计取舍:牺牲一点点灵活性,换取运行期的极致效率。
6.3 “多生产者是怎么协调的?”
多生产者模式下,Disruptor 内部有一个MultiProducerSequencer,它用 CAS 来竞争序列号。具体过程是:每个生产者通过casCursor尝试把 cursor 从某个值推进到 next 值,如果 CAS 成功,说明这个序列号被当前线程抢到了;抢到之后,就可以往对应槽位写数据,写完后调用publish发布。
这里有一个容易忽略的点:多生产者模式下,发布操作可能乱序。线程 A 抢到序列号 10,线程 B 抢到序列号 11,但 B 先完成写入和发布,A 后发布。Disruptor 内部会维护一个可用的序列号,保证消费者只会看到连续发布的事件序列,从而避免消费者读到“空洞”。这种处理方式在源码里叫“available buffer tracking”,面试时能讲出这点,会显得你研究得比较深。
6.4 “消费者如何知道有没有新事件?”
通过SequenceBarrier.waitFor(sequence)。消费者线程会拿自己下一次要消费的序列号去查询生产者的 cursor,如果 cursor 已经大于等于这个序列号,就说明有事件可读;否则根据 WaitStrategy 等待。
如果面试官继续问“等待会不会浪费 CPU”,那就顺势把 WaitStrategy 的几种策略都讲一遍,重点说明BlockingWaitStrategy和BusySpinWaitStrategy是两种极端,分别适用什么场景,会体现你对运行时行为的理解。
7. 写在最后:无锁不是终点,理解瓶颈才是
断断续续用 Disruptor 也有几年了,回头看它给我的最大启发不是“无锁”这三个字,而是:高性能系统的设计,是沿着硬件特性一路优化出来的。CPU 有缓存行,那就避开伪共享;CAS 比锁便宜,那就尽量自旋;数组比链表缓存友好,那就用连续内存。Design for cache,这句话在 Disruptor 身上体现得淋漓尽致。
如果你只是背住了“Disruptor 快是因为无锁和环形数组”这句话,遇到真正的性能问题时照样无从下手。只有当你亲自把一个用 BlockingQueue 的高并发模块替换成 Disruptor,再用 jstack 观察线程状态、用 perf 看 CPU 周期消耗、用压测工具对比 TPS 之后,你才会真正理解那些设计选择的价值。
我最后再分享一个实操上的小技巧:在正式引入 Disruptor 之前,先用一个 Demo 验证你的核心业务处理逻辑是否足够精简。如果消费者的onEvent()方法里要干一堆远程调用和 IO 操作,那 Disruptor 能帮你的有限,因为瓶颈已经不在队列而在下游。Disruptor 适合的是“生产端和消费端都很快、但交接处需要极其高效”的场景——它负责把交接这件事做到极致,不要指望它能替你优化业务处理本身。
如果你以后在面试里被问到“高并发队列怎么选”,你可以大方地承认:没有万能的队列,只有匹配场景的队列。ArrayBlockingQueue 简单可靠,ConcurrentLinkedQueue 无锁但对缓存不友好,Disruptor 性能卓绝但需要理解它的原理再做选型。真正区分工程师水平的,不是背了多少高性能组件的名字,而是能不能在关键场景里准确判断“瓶颈在哪里、应该换什么、换了之后怎么验证”。