"把 ArrayBlockingQueue 换成 Disruptor,吞吐提升一个数量级"——这类结论被反复引用,却很少附带前提。Disruptor 快的真正原因不是"用了 CAS",而是它先消除了锁、再消除了伪共享、最后消除了对象分配。三者缺一,收益就没了。这篇从瓶颈出发,讲清它为什么快、什么时候不快。
1. 瓶颈先定位:队列慢在哪一层
| 瓶颈 | 机制 | 表现 |
|---|---|---|
| 锁竞争 | 入队/出队共用一把锁,线程休眠与唤醒 | 线程数上升,吞吐反而下降 |
| 伪共享 | 头尾指针或相邻计数器落在同一缓存行 | 大量缓存行失效(cache miss) |
| 内存屏障 | 每次可见性保证都刷新写缓冲 | CPU 利用率高但有效吞吐低 |
| 对象分配 | 每个事件都是一个新对象 | Young GC 频繁,停顿抖动 |
| 上下文切换 | 阻塞唤醒导致线程状态切换 | 延迟长尾明显 |
ArrayBlockingQueue 用单锁 + 两个 Condition,前三条都会命中。LinkedBlockingQueue 改成双锁(putLock/takeLock),缓解了入队出队互斥,但仍有节点对象分配和链表指针跳转的开销。
2. Disruptor 的四个设计
| 设计 | 替代了什么 | 效果 |
|---|---|---|
| 环形数组预分配 | 每次 new 事件对象 |
零分配,GC 压力归零 |
| 序号 + 屏障代替锁 | 入队出队的互斥 | 无锁,只有 CAS 与屏障 |
依赖关系图(gatingSequences) |
消费者之间的协调 | 支持多级流水线 |
| 批量消费 | 逐条取 | 摊薄序号检查开销 |
// 骨架:预分配 + 序号申领 + 发布
RingBuffer<Event> ring = RingBuffer.createSingleProducer(Event::new, 1024, strategy);
long seq = ring.next(); // ① 申领一个序号(CAS 或普通递增)
try {
Event e = ring.get(seq); // ② 拿到已预分配的对象,直接改字段
e.setValue(v);
} finally {
ring.publish(seq); // ③ 发布,消费者才可见
}
第③步的 publish 是新手的坑:忘了它,消费者永远看不到该事件,而生产者继续往前覆盖序号——表现为"消费者卡住 + 数据凭空消失"。
next() 的语义是申领,不是"插入"。序号只能单调递增,超过容量时对容量取模,落到哪个槽位就是哪个槽位。
3. waitStrategy:四种等待策略的取舍
| 策略 | 机制 | CPU 占用 | 延迟 | 适用 |
|---|---|---|---|---|
BlockingWaitStrategy |
锁 + Condition |
低 | 高(微秒级,含唤醒) | CPU 紧张、延迟不敏感 |
BusySpinWaitStrategy |
纯自旋 | 极高(独占核) | 最低 | 绑核、极致低延迟 |
YieldingWaitStrategy |
自旋 + Thread.yield() |
高 | 低 | 线程数 < 核数 |
SleepingWaitStrategy |
自旋 + LockSupport.parkNanos |
低 | 中(有睡眠阶梯) | 通用折中,常用默认 |
// 典型选择:延迟与 CPU 的折中
WaitStrategy strategy = new SleepingWaitStrategy(); // 通用
// 绑核 + 独占 CPU 的撮合场景才用
WaitStrategy ultraLow = new BusySpinWaitStrategy();
YieldingWaitStrategy 的前提是"线程数严格小于可用核数",否则 yield 之后拿不到 CPU,延迟反而比 Sleeping 更差。BusySpin 同理,超配线程时会互相抢核。
4. 生产者类型与序号申领
| 类型 | 申领方式 | 冲突处理 | 吞吐 |
|---|---|---|---|
createSingleProducer |
普通 long 自增 |
无冲突 | 最高 |
createMultiProducer |
AtomicLong / Sequencer CAS |
多生产者竞争 | 低于单生产者 |
createMultiProducer(..., 批量) |
按批申领 | 冲突概率下降 | 接近单生产者 |
// 单生产者:没有 CAS,只有一次普通写 + 屏障
RingBuffer<Event> one = RingBuffer.createSingleProducer(Event::new, 4096);
// 多生产者:必须用多生产者版本,否则序号会冲突
RingBuffer<Event> many = RingBuffer.createMultiProducer(Event::new, 4096,
new SleepingWaitStrategy());
选错类型的后果很隐蔽:用单生产者版本配多线程写入,序号会重复分配,两个线程改同一个槽位,数据互相覆盖且不报错。
5. 伪共享与缓存行填充
环形数组的 Sequence 和消费者的 Sequence[] 会被频繁并发读写。若两个 Sequence 落在同一 64 字节缓存行,一方的写会让另一方的缓存行失效,性能断崖。
// 手动填充(Disruptor 内部用 @Contended 或 padding 字段实现)
class PaddedSequence {
long p1, p2, p3, p4, p5, p6, p7; // 7 × 8 = 56 字节填充
volatile long value;
long p9, p10, p11, p12, p13, p14, p15;
}
JDK 8 起可以用 @sun.misc.Contended,但要加 -XX:-RestrictContended 才能对用户类生效。按 JDK 17 核对:这个参数仍然有效,只是 sun.misc 包在编译期需要特殊处理,工程上更常见的是手写 padding。
6. SequenceBarrier 与消费依赖
SequenceBarrier 是"消费者能看到哪个位置"的闸门:它由一组 gatingSequences 构成,消费者只能推进到所有依赖都已处理完的最小序号。
// 两级流水线:handlerB 依赖 handlerA
SequenceBarrier barrierA = ring.newBarrier();
SequenceBarrier barrierB = ring.newBarrier(handlerA.getSequences());
ring.addGatingSequences(handlerB.getSequences());
BatchEventProcessor<Event> pa = new BatchEventProcessor<>(ring, barrierA, handlerA);
BatchEventProcessor<Event> pb = new BatchEventProcessor<>(ring, barrierB, handlerB);
new Thread(pa).start();
new Thread(pb).start();
| 配置 | 语义 | 用途 |
|---|---|---|
| 不传依赖 | 可与生产者并行消费 | 独立消费者(如日志落盘) |
传 handlerA.getSequences() |
必须等 A 处理完 | 串行流水线 |
多个 gatingSequences |
等最慢的那个 | 菱形依赖汇合 |
addGatingSequences 必须覆盖所有消费者,否则环形数组会误判"可以覆盖",把未消费的事件冲掉。
7. 队列选型对照
| 队列 | 延迟量级 | 是否持久化 | 能否重放 | 堆积能力 |
|---|---|---|---|---|
ArrayBlockingQueue |
微秒 | 否 | 否 | 受容量限制 |
LinkedBlockingQueue |
微秒 | 否 | 否 | 近似无界(危险) |
Disruptor |
亚微秒(典型量级) | 否 | 否 | 容量即上限,写满覆盖 |
| MQ(Kafka/RocketMQ) | 毫秒 | 是 | 是 | 磁盘级 |
判断只需回答三个问题:延迟是否在微秒级敏感?需不需要重启后重放?消费失败能不能重试?
三个"是"里有任何一个指向"需要持久化/重放",Disruptor 就该出局——它把事件放在堆内数组里,进程一挂全丢。
8. 无锁的正确性边界
无锁不等于"没有同步",它只是把同步点从锁换成了序号。要证明没有丢失,需要三个不变量同时成立:
| 不变量 | 内容 | 破坏后果 |
|---|---|---|
| 单调性 | 生产者序号严格递增、无重复 | 槽位被覆盖 |
| 可见性 | publish 前对槽位的写必须先于序号发布 |
消费者读到半写状态 |
| 闸门完整性 | 所有消费者都注册进 gatingSequences |
未消费数据被覆盖 |
这里的线性一致性点在 publish(seq):在此之前的写对消费者不可见,之后才可见。所以"先改字段、再 publish"的顺序不能颠倒,中间也不能抛异常导致 publish 被跳过——这正是 try/finally 的意义。
多生产者模式下,线性一致性点前移到序号 CAS 成功的那一刻:CAS 决定了谁拿到哪个槽位,后续对该槽位的写由"已获得序号"这一事实隔离。
9. 排错与坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 消费者完全不动 | 生产者漏了 publish |
检查 try/finally |
| 数据错乱、字段串值 | 预分配对象里有可变状态未重置 | 每轮消费后清空或整体替换 |
| 吞吐远低于预期 | 容量不是 2 的幂,取模退化为除法 | 容量取 1024/4096 |
| 偶发停摆 | 消费者抛异常,BatchEventProcessor 终止 |
处理器内 try/catch,不让异常外溢 |
| CPU 100% 但没产出 | BusySpin 超配线程 |
换 Sleeping/Yielding |
| 高并发下计数错乱 | 单生产者版本被多线程使用 | 换 createMultiProducer |
两个最致命的问题值得展开:
消费者异常导致整个环停摆:BatchEventProcessor.run() 捕获异常后会退出循环并停止推进序号。序号不动,生产者很快被闸门挡住,整条链路静止。处理器内部必须自己兜住异常。
容量必须是 2 的幂:Disruptor 用位运算替代取模(seq & (capacity - 1))。容量传 1000 不会报错,但序号映射会算错,事件落错槽位——这类 bug 只在特定序号下复现,极难排查。
10. 边界:Disruptor 不适用的场景
| 场景 | 结论 | 原因 |
|---|---|---|
| 跨进程/跨机房 | 不适用 | 它只在单 JVM 内 |
| 需要消息持久化 | 不适用 | 堆内数组,无落盘 |
| 消费失败要重试 | 不适用 | 无 ack/重投语义 |
| 生产速率远高于消费 | 不适用 | 写满即覆盖,天然丢弃 |
| 事件量小、延迟不敏感 | 收益不足 | 复杂度成本 > 性能收益 |
| 单机极低延迟撮合/风控 | 适用 | 无锁 + 预分配的核心优势 |
| 日志/指标的异步落盘 | 适用 | 可丢弃、单机、量可控 |
一句话:Disruptor 是"把队列做成内存里的流水线",不是"更快的 MQ"。把它当缓冲层用来吸收堆积,方向就错了——它的容量就是上限,超过就覆盖。
11. 自测
- Disruptor 快的三个来源分别消除了哪类开销?
publish漏掉的后果是什么?为什么必须放在try/finally里?- 四种
waitStrategy中,哪一种要求线程数严格小于核数? - 用单生产者版本配多线程写入会出现什么现象?
- 环形数组容量为什么必须是 2 的幂?传 1000 会怎样?
- 判断"该用 Disruptor 还是 MQ"的三个问题是什么?