ArrayBlockingQueue 在 8 核机器上跑到几十万吞吐就会开始抖,而 Disruptor 在同机上能稳定高出接近一个数量级。差距不在"用了数组",而在于三件事:没有锁、没有额外对象分配、没有伪共享。这三条都不是玄学,可以逐条指出实现位置。
这篇按"队列慢在哪 → Ring Buffer 怎么编号 → 序号怎么同步 → 依赖图怎么排 → API 怎么写 → 什么时候不该用"的顺序拆。
1. ArrayBlockingQueue 慢在哪
它把生产者和消费者放在同一把独占锁下:
public void put(E e) throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length) notFull.await(); // 满则等待
enqueue(e);
} finally { lock.unlock(); }
}
| 代价 | 来源 | 后果 |
|---|---|---|
| 锁竞争 | 生产/消费共用 ReentrantLock |
核多了反而更慢 |
| 线程挂起 | notFull.await() / 条件队列 |
上下文切换,微秒级起跳 |
| 伪共享 | head、tail、count 相邻字段 |
两个 CPU 反复使对方的缓存行失效 |
| 对象分配 | 每个入队元素背后一个 Node |
高吞吐下 GC 压力明显 |
伪共享最隐蔽:head 被消费者写、tail 被生产者写,落在同一条 64 字节缓存行上时,两个核会不停把这行在缓存间来回抢。JDK 8 的 @sun.misc.Contended 主要用在 Striped64、Thread 这类热点字段上,队列本身并没有;JDK 9 后注解成内部 API,普通代码用不上。
2. Ring Buffer 的序号机制
Ring Buffer 是一个启动时一次性分配好的数组,容量必须是 2 的幂。它不存"第几个元素",而是靠一个只增不减的序号定位:
long sequence = ringBuffer.next(); // 申领序号
try {
Event e = ringBuffer.get(sequence); // 按序号取下标处的预分配对象
e.setValue(sequence);
} finally {
ringBuffer.publish(sequence); // 发布,消费者才可见
}
// 序号到下标的换算
int index = (int) sequence & (bufferSize - 1); // 位运算替代取模
| 设计 | 收益 |
|---|---|
| 容量为 2 的幂 | & (size-1) 替代取模,一条指令 |
| 事件对象预分配 | 稳态零分配,GC 压力接近 0 |
| 序号单调递增 | 用 long 比较判断可用性,天然解决环绕问题 |
| 生产与消费分离序号 | 不需要同一把锁 |
预分配带来一个容易踩的约束:Ring Buffer 里事件对象的字段会被反复覆盖,消费者拿到的是同一批对象。想留存数据必须自己复制出去,否则读到的是后续写入的值。
3. CAS 与内存屏障
序号的可见性不靠锁,靠 Unsafe/VarHandle 的原子操作与有序写。
// 多生产者:用 CAS 抢序号
protected final boolean casProducerCursor(long expected, long next) {
return UNSAFE.compareAndSwapLong(this, CURSOR_OFFSET, expected, next);
}
// 单生产者:不需要 CAS,但发布要保证顺序
protected final void publish(long sequence) {
UNSAFE.putOrderedLong(this, CURSOR_OFFSET, sequence); // store-store,不刷缓存但保序
}
| 操作 | 语义 | 用在哪 |
|---|---|---|
compareAndSetLong |
全屏障,失败可重试 | 多生产者抢序号 |
putOrderedLong(lazySet) |
只保证写顺序 | 单生产者发布序号 |
volatile 读写 |
全屏障 | 自旋检查与读 cursor |
putOrderedLong 是这里的关键优化:事件的字段已经写好(普通写),发布序号只需要保证"先写数据、后写序号"的顺序,不需要立刻把整条缓存行推给其他核。这一步在单生产者场景下省掉了大量内存屏障指令。
4. SequenceBarrier 与依赖图
消费者拿到的不只是一个游标,而是一组"我依赖谁"的信息。SequenceBarrier 负责回答"下一个可处理的序号是多少":
long next = barrier.waitFor(processed + 1); // 等依赖的序号都推进到该值
EventHandler[] handlers = ...;
// 消费者 D 依赖 A、B、C:D 的 barrier 里挂着这三个 sequence
| 概念 | 作用 |
|---|---|
Sequence |
每个消费者自己的进度(CAS 递增,padding 防伪共享) |
SequenceBarrier |
组合多个依赖序号,提供 waitFor |
gatingSequences |
生产者反向追踪"最慢的消费者",用于判断环是否满 |
| 菱形依赖图 | 多级处理链,同层可并行、跨层串行 |
这才是它真正的价值:不只是队列,而是可编排的处理链。等待关系显式声明,不需要中间队列搬数据,事件在同一份内存上被多个处理器依次改写。
5. 生产者模式:单生产者与多生产者
| 模式 | 创建方式 | 抢序号 | 适用 |
|---|---|---|---|
| 单生产者 | RingBuffer.createSingleProducer(...) |
无 CAS,直接读 cursor+1 |
一个线程投递(最常见) |
| 多生产者 | RingBuffer.createMultiProducer(...) |
CAS 抢序号,失败重试 | 多个线程投递 |
单生产者下投递路径只剩"读 cursor → 写字段 → 有序写 cursor",全是本地操作;多生产者多了 CAS 争用,吞吐下降但通常仍优于加锁队列。选错模式是安全性问题:多线程投递却用单生产者 API,序号会被覆盖。
6. 等待策略:延迟与 CPU 的兑换
消费者等待新事件的方式直接决定延迟与 CPU 占用:
| 策略 | 做法 | 延迟 | CPU |
|---|---|---|---|
BusySpinWaitStrategy |
死循环自旋 | 最低 | 占满一个核 |
YieldingWaitStrategy |
自旋若干次后 Thread.yield() |
低 | 高 |
SleepingWaitStrategy |
失败后先自旋再 LockSupport.parkNanos(1) |
中 | 低 |
BlockingWaitStrategy |
ReentrantLock + 条件变量 |
高(微秒级抖动) | 最低 |
TimeoutBlockingWaitStrategy |
带超时的阻塞 | 中高 | 低 |
LiteBlockingWaitStrategy |
轻量阻塞 | 中 | 低 |
原则:延迟敏感场景用 Yielding/BusySpin,并接受它吃掉一两个核;通用总线用 Sleeping/Blocking。 用 BusySpin 却只给 1 个 CPU,消费者自旋会把生产者挤死——"换了反而更慢"多半是这个原因。
7. 伪共享:为什么要在字段间塞无用字段
Sequence 类的实现里塞了大量 long 占位:
abstract class RingBufferPad { protected long p1, p2, p3, p4, p5, p6, p7; } // 左侧填充
abstract class RingBufferFields<E> extends RingBufferPad { /* 数据字段 */ }
public final class RingBuffer<E> extends RingBufferFields<E> { protected long p1..p7; } // 右侧填充
目的只有一个:让 cursor 独占缓存行,不与相邻字段共享。左侧 7 个 long 加右侧 7 个 long,正好各占 56 字节以上,把 64 字节的缓存行撑开。
| 手段 | 适用 | 注意 |
|---|---|---|
| 手写 padding 字段 | 任何 JDK | 代码丑,但最可靠 |
@Contended(jdk.internal.vm.annotation) |
JDK 8+ | JDK 17 上是内部 API,需 --add-exports(搭配 -XX:-RestrictContended) |
| 对象分离 | 通用做法 | 把热字段放进独立对象,靠对象布局天然隔开 |
JDK 17 上想用 @Contended 相当麻烦:注解在 java.base 内部的 jdk.internal.vm.annotation 包里,普通模块要 --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED 才能引用,且默认受 RestrictContended 限制。Disruptor 自带 padding 类,正是为了避开这个坑。
8. API 骨架
// 1. 事件与工厂:事件对象会被复用,构造器不要写业务初始化
public class LongEvent { private long value; public void set(long v) { this.value = v; } }
EventFactory<LongEvent> factory = LongEvent::new;
// 2. 消费者
EventHandler<LongEvent> handler = (event, sequence, endOfBatch) ->
System.out.println("seq=" + sequence + " value=" + event.value);
// 3. Ring Buffer + 等待策略(容量必须是 2 的幂)
int bufferSize = 1024;
RingBuffer<LongEvent> ringBuffer = RingBuffer.createSingleProducer(
factory, bufferSize, new YieldingWaitStrategy());
// 4. 用 Disruptor 门面组装处理链
Disruptor<LongEvent> disruptor = new Disruptor<>(factory, bufferSize,
DaemonThreadFactory.INSTANCE, ProducerType.SINGLE, new BlockingWaitStrategy());
disruptor.handleEventsWith(handler);
disruptor.handleEventsWith(handlerA, handlerB) // 同层并行
.then(handlerC); // 下一层依赖 A、B
disruptor.start();
// 5. 投递
ringBuffer.publishEvent((event, sequence) -> event.set(sequence));
五个组件各有各的坑:工厂里做重初始化、handler 抛异常让 BatchEventProcessor 停摆、容量写成非 2 的幂、手动 get(sequence) 越界、等待策略与 CPU 配额不匹配。
EventHandler 抛异常时消费者线程默认会终止,链路静默停摆而生产者还在写。要在 handler 内兜住,或配 ExceptionHandler。
9. 量级与适用边界
公开基准给出的量级:单生产者单消费者、纯内存事件,Disruptor 在千万次/秒级别,ArrayBlockingQueue 通常低一个数量级;BusySpin 下尾部延迟可压到亚微秒。数字随机器与事件大小变化很大,只在自己环境比才有意义。
| 场景 | Disruptor 合适吗 | 理由 |
|---|---|---|
| 低延迟事件处理(撮合、行情) | 合适 | 延迟敏感 + 单机高吞吐 |
| 日志异步落盘(Log4j2) | 合适 | 生产者多、消费串行、可丢弃策略 |
| 通用任务队列 | 不合适 | 没有动态扩容、没有优先级语义 |
| 大量阻塞 IO 任务 | 不合适 | 消费者不该阻塞,会拖垮整条链 |
| 需要可靠投递/持久化 | 不合适 | 环内数据在内存,进程挂即丢 |
它最容易被误用的地方是当"高性能线程池队列":Disruptor 的消费者是固定线程、串行推进的,任务耗时不均会直接堵塞后续事件;有阻塞 IO 的场合,它比 ThreadPoolExecutor 差得多。
10. 排错:五种常见现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 消费者完全不工作 | handler 抛异常导致线程退出 | 加 ExceptionHandler,或 handler 内兜异常 |
生产者长期阻塞在 next() |
环满且消费者未推进 | 查消费者是否卡在阻塞 IO |
| 吞吐远低于预期 | 队列容量太小、等待策略不匹配 | 加大容量,换 Yielding |
| CPU 100% 但吞吐不高 | BusySpin 空转 |
换 Sleeping 或 Blocking |
| 数据错乱 / 序号重复 | 多线程投递却用单生产者 API | 改 createMultiProducer |
11. 自测
ArrayBlockingQueue的四个开销分别来自哪里?- Ring Buffer 容量为什么必须是 2 的幂?
putOrderedLong与compareAndSetLong在语义上差在哪?SequenceBarrier解决的是什么问题?BusySpinWaitStrategy换来的延迟代价是什么?- 为什么 Disruptor 不适合当通用任务队列?