拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Disruptor无锁队列原理与实战:从锁竞争到毫秒级吞吐

Disruptor无锁队列原理与实战:从锁竞争到毫秒级吞吐

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 吞吐(约)是否阻塞是否无锁
ArrayBlockingQueue300 万/秒200 万/秒是否
LinkedBlockingQueue250 万/秒150 万/秒是否
ConcurrentLinkedQueue600 万/秒400 万/秒否是(CAS)
Disruptor1000 万/秒+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 占用。这三个指标要放在一起看,只盯着一个会误导人。

我的调优步骤大致如下:

  1. 先用默认配置跑一边基准,记录 TPS 和 P99。
  2. 把 WaitStrategy 换成 Yielding 或 BusySpin,看看 TPS 提升多少、CPU 增加多少。
  3. 调整 RingBuffer 容量,从 1024 到 65536 各测一轮,看容量对 TPS 的影响。
  4. 检查生产者和消费者的线程绑定:Disruptor 可以和ThreadAffinity之类的线程绑核工具配合,把生产者线程、消费者线程分别绑定到不同物理核心,避免线程迁移导致的缓存失效。
  5. 压测时务必把-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 性能卓绝但需要理解它的原理再做选型。真正区分工程师水平的,不是背了多少高性能组件的名字,而是能不能在关键场景里准确判断“瓶颈在哪里、应该换什么、换了之后怎么验证”。

返回列表