一、引言:队列的"环形革命"
在计算机科学中,队列是最基础的数据结构之一。传统队列用链表实现,每次入队都需要创建新节点,每次出队都要销毁节点------在高并发场景下,频繁的内存分配和垃圾回收会成为性能瓶颈。
环形缓冲区( Ring Buffer / Circular Buffer) 提供了一种全新的思路:用一块固定大小的数组 ,通过指针循环移动实现数据的复用。数组内元素的内存地址是连续的,对CPU缓存友好。当指针到达数组末尾时,自动"绕回"到开头,形成一个逻辑上的环。
核心思想:用空间换时间------预先分配固定内存,通过指针的循环移动实现O(1)的入队出队,避免频繁的内存分配与回收。
环形缓冲区广泛应用于:网络协议 栈 (网卡DMA RingBuffer)、高性能队列 (Disruptor)、音频视频处理 、日志系统(Log4j2)等领域。
二、环形缓冲区的基本原理
2.1 双指针模型
环形缓冲区最经典的实现使用两个指针 :head(读指针)和tail(写指针)。
-
head:指向下一个待读取的位置
-
tail:指向下一个可写入的位置
2.2 空与满的判断
环形缓冲区需要区分"空"和"满"两种状态,因为它们都表现为 head == tail。
常用的解决方案是牺牲一个存储单元:
java
// 空:head == tail
private boolean isEmpty() {
return head == tail;
}
// 满:(tail + 1) % capacity == head
private boolean isFull() {
return (tail + 1) % bufferSize == head;
}
2.3 为什么选择2的幂次作为容量
当容量是2的幂次时,取模运算可以用位运算替代,大幅提升性能:
java
// 取模运算(较慢)
int index = sequence % bufferSize;
// 位运算(更快),前提是 bufferSize = 2^n
int index = sequence & (bufferSize - 1);
HashMap、Disruptor等高性能组件都采用了这一优化。
三、基础实现:有锁版环形缓冲区
3.1 单生产者-单消费者无锁版
在单生产者、单消费者 的特殊场景下,可以实现完全无锁的环形缓冲区。
java
/**
* 单生产者-单消费者无锁环形缓冲区(Ring Buffer)
*
* 设计目标:在单生产者、单消费者的场景下,实现无锁、高吞吐的线程安全队列。
*
* 核心原理(内存可见性保证):
* - 使用 volatile 修饰 head 和 tail,保证读/写指针的可见性
* - 写操作:先写入数据,再移动 tail(tail 的 volatile 写入保证数据对其他线程可见)
* - 读操作:先读取数据,再移动 head(head 的 volatile 写入保证已读位置对其他线程可见)
*
* 关键原则:
* 1. 生产者只修改 tail,消费者只修改 head,两者无重叠,天然避免竞争(无锁)
* 2. 缓冲区大小必须为 2 的幂,使用位运算(& mask)替代取模(%),提升性能
* 3. 牺牲一个位置来区分"满"和"空"(即 tail 永远不会追上 head)
*
* 适用场景:
* - 日志异步写入
* - 消息队列(单生产者单消费者)
* - 数据传输管道(如 Netty 的 RingBuffer)
*
* 不适用场景:
* - 多生产者或多消费者(需要额外的并发控制)
* - 需要动态扩容的场景
*
* 性能:在单线程下,该设计的吞吐量远高于 BlockingQueue(无锁、无上下文切换)
*/
public class SingleThreadRingBuffer<T> {
// ==================== 核心存储结构 ====================
/**
* 底层数组:存储元素
* 用 Object[] 实现泛型,避免创建泛型数组的开销
* 元素访问通过数组下标,内存局部性好
*/
private final Object[] buffer;
/**
* 缓冲区总大小(2 的幂次)
*/
private final int bufferSize;
/**
* 掩码:用于位运算计算下标(等同于 bufferSize - 1)
* 例如:bufferSize=8 → mask=7 → (index & 7) 等价于 (index % 8)
*/
private final int mask;
// ==================== 读/写指针(用 volatile 保证可见性) ====================
/**
* 读指针:消费者读取的位置
* volatile 保证消费者移动 head 后,生产者能立即看到(通过内存屏障)
*/
private volatile int head = 0;
/**
* 写指针:生产者写入的位置
* volatile 保证生产者移动 tail 后,消费者能立即看到
*
* 注意:tail 永远指向下一个写入位置(即尾部+1),而不是最后一个元素
*/
private volatile int tail = 0;
// ==================== 构造器 ====================
/**
* 构造环形缓冲区
* @param size 期望的容量(会向上取整到 2 的幂)
*
* 为什么要求容量为 2 的幂?
* - 位运算替代取模:index & (size-1) 比 index % size 快得多
* - 位运算在 CPU 层面是一条指令,取模可能是多条指令
* - 在高速缓冲(如 L1/L2 缓存)中,2 的幂大小有助于对齐内存访问
*/
public SingleThreadRingBuffer(int size) {
this.bufferSize = ceilToPowerOfTwo(size);
this.buffer = new Object[this.bufferSize];
this.mask = this.bufferSize - 1;
}
// ==================== 核心操作方法 ====================
/**
* 入队(生产者调用)
*
* 执行顺序(关键!不能颠倒):
* 1. 检查队列是否已满(tail + 1 == head 表示满)
* 2. 写入数据到 buffer[tail](先写数据)
* 3. 更新 tail 指针(后移动指针)------保证消费者看到完整的元素
*
* 为什么必须先写数据再移动 tail?
* - 如果先移动 tail 再写数据,消费者可能看到一个"空"的槽位(因为数据还没写入)
* - 内存模型保证:tail 的 volatile 写入会确保之前的所有普通写入(buffer[tail] = element)对消费者可见
* (happens-before 规则:volatile 写之前的操作对后续 volatile 读可见)
*
* @param element 要入队的元素(不能为 null,但此处未强制检查)
* @return true 入队成功,false 队列已满
*/
public boolean put(T element) {
// 计算下一个写入位置(tail + 1)& mask
int nextTail = (tail + 1) & mask;
// 检查是否已满:下一个写入位置等于 head 表示满(牺牲一个位置)
// 为什么牺牲一个位置?因为 head == tail 用来表示"空",所以无法用 head == tail 表示"满"
// 因此必须牺牲一个位置,使得 tail + 1 == head 时才认为满
if (nextTail == head) {
return false; // 队列已满,拒绝写入(非阻塞)
}
// 先写入数据到当前 tail 位置
buffer[tail] = element;
// 更新 tail 指针(volatile 写入,保证对其他线程可见)
tail = nextTail;
return true;
}
/**
* 出队(消费者调用)
*
* 执行顺序:
* 1. 检查队列是否为空(head == tail 表示空)
* 2. 从 buffer[head] 读取数据(先读数据)
* 3. 将已读取的位置置 null(帮助 GC)
* 4. 更新 head 指针(后移动指针)------保证生产者能及时看到空位
*
* 为什么先读数据再移动 head?
* - 如果先移动 head 再读数据,可能导致生产者以为空位被释放,从而覆盖消费者还没读完的数据
* - head 的 volatile 写入保证:消费者读完数据后,生产者才能看到空位并写入
*
* @return 队列头部的元素,若队列为空返回 null
*/
@SuppressWarnings("unchecked")
public T get() {
// 检查是否为空
if (head == tail) {
return null; // 队列为空
}
// 从当前 head 位置读取数据
T result = (T) buffer[head];
// 清空已读取的位置,帮助 GC 回收
buffer[head] = null;
// 更新 head 指针(volatile 写入,保证生产者可见)
head = (head + 1) & mask;
return result;
}
// ==================== 辅助方法 ====================
/**
* 计算大于等于 n 的最小 2 的幂
*
* 例如:
* - n=10 → 16
* - n=16 → 16
* - n=3 → 4
*
* 实现:利用 Integer.highestOneBit(n) 获取最高位,判断是否等于 n
* 如果等于,说明本身是 2 的幂;否则左移一位得到下一个 2 的幂
*/
private static int ceilToPowerOfTwo(int n) {
int highestBit = Integer.highestOneBit(n);
// 如果 n 已经是 2 的幂,返回 n;否则返回 highestBit << 1
return highestBit == n ? n : highestBit << 1;
}
// ==================== 扩展监控方法 ====================
/**
* 获取当前队列大小(元素数量)
*
* 注意:head 和 tail 是 volatile 的,这里读取时不会造成阻塞,
* 但由于是两次独立读取,可能在并发下有小幅偏差(但不影响正确性)
*/
public int size() {
// tail - head 可能是负数?不会,因为 tail 永远在 head 前面(环形),
// 这里的减法在逻辑上是 (tail - head + bufferSize) % bufferSize
// 但由于我们采用环形设计,且 tail 和 head 都是单调递增(没有取模),
// 实际大小 = tail - head,当绕回时,这个差值可能是负数,需要用 mask 修正
// 正确的计算方式:
int diff = tail - head;
if (diff < 0) {
diff += bufferSize;
}
return diff;
}
/**
* 获取剩余可用容量
*/
public int remainingCapacity() {
return bufferSize - 1 - size();
}
}
为什么这个实现是线程安全的?
关键在于读写分离 :写线程只修改tail,读线程只修改head,两者互不干扰。写操作先写数据、再移动tail ,读操作先读数据、再移动head。这种顺序保证了读线程永远不会读到未写入的数据。
3.2 线程安全版:基于synchronized
对于多生产者-多消费者场景,需要加锁保护:
java
/**
* 线程安全的环形缓冲区(多生产者-多消费者)
* 使用 synchronized 保证线程安全
*/
public class SynchronizedRingBuffer<T> {
private final Object[] elements;
private int writePos = 0; // 写指针
private int available = 0; // 可用元素数量
public SynchronizedRingBuffer(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("容量必须为正数");
}
elements = new Object[capacity];
}
/**
* 入队:队列满时阻塞等待
*/
public synchronized void put(T elem) throws InterruptedException {
// 队列满则等待
while (available == elements.length) {
wait();
}
// 写入数据,移动写指针
elements[writePos] = elem;
writePos = (writePos + 1) % elements.length;
available++;
// 唤醒等待的消费者
notifyAll();
}
/**
* 出队:队列空时阻塞等待
*/
@SuppressWarnings("unchecked")
public synchronized T take() throws InterruptedException {
// 队列空则等待
while (available == 0) {
wait();
}
// 计算读指针位置
int readPos = (writePos - available + elements.length) % elements.length;
T result = (T) elements[readPos];
elements[readPos] = null;
available--;
notifyAll();
return result;
}
}
四、(了解)源码阅读:ArrayBlockingQueue的环形数组
ArrayBlockingQueue是JDK中基于环形数组 实现的阻塞队列。它使用takeIndex和putIndex两个指针,配合count字段区分空和满。
4.1 核心成员变量
java
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
// 存储元素的数组(环形数组)
final Object[] items;
// 下一个待取出的元素索引(读指针)
int takeIndex;
// 下一个待插入的元素索引(写指针)
int putIndex;
// 队列中元素数量
int count;
// 全局锁:所有操作共用这一把锁
final ReentrantLock lock;
// 队列不为空的条件(用于消费者等待)
private final Condition notEmpty;
// 队列不满的条件(用于生产者等待)
private final Condition notFull;
}
4.2 入队操作:enqueue
java
/**
* 入队:将元素放入环形数组
* 调用前必须持有锁
*/
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
// 1. 将元素放入当前位置
items[putIndex] = x;
// 2. 移动写指针,到达末尾则绕回到0
if (++putIndex == items.length) {
putIndex = 0;
}
// 3. 元素数量+1
count++;
// 4. 唤醒等待的消费者
notEmpty.signal();
}
4.3 出队操作:dequeue
java
/**
* 出队:从环形数组中取出元素
* 调用前必须持有锁
*/
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
// 1. 取出当前位置的元素
E x = (E) items[takeIndex];
// 2. 将当前位置置空(帮助GC)
items[takeIndex] = null;
// 3. 移动读指针,到达末尾则绕回到0
if (++takeIndex == items.length) {
takeIndex = 0;
}
// 4. 元素数量-1
count--;
// 5. 唤醒等待的生产者
notFull.signal();
return x;
}
4.4 put和take的阻塞机制
put方法在队列满时阻塞,take方法在队列空时阻塞:
java
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 队列满则挂起,等待消费者唤醒
while (count == items.length) {
notFull.await();
}
enqueue(e);
} finally {
lock.unlock();
}
}
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 队列空则挂起,等待生产者唤醒
while (count == 0) {
notEmpty.await();
}
return dequeue();
} finally {
lock.unlock();
}
}
设计特点 :一把锁(ReentrantLock)配合两个条件(notEmpty和notFull),实现了生产者和消费者的阻塞与唤醒。
五、(了解)源码阅读:Disruptor的RingBuffer
Disruptor被誉为"队列之王",其核心就是环形缓冲区RingBuffer 。与传统的双指针环形缓冲区不同,Disruptor的RingBuffer只维护一个序号(cursor),指向下一个可写入的位置。
5.1 消除伪共享:缓存行填充
伪共享(False Sharing)是多核CPU中的"无声性能杀手"。当多个线程修改的变量位于同一个缓存行(Cache Line,通常64字节) 时,一个线程的修改会导致其他核心的整个缓存行失效。
Disruptor通过在频繁访问的字段前后添加无意义的填充字段来解决这个问题:
java
/**
* 填充基类:在RingBufferFields前填充7个long
* 确保RingBufferFields的属性独占缓存行
*/
abstract class RingBufferPad {
protected long p1, p2, p3, p4, p5, p6, p7;
}
/**
* RingBuffer的核心字段
* 通过继承RingBufferPad,在字段前后都有填充
*/
abstract class RingBufferFields<E> extends RingBufferPad {
private static final int BUFFER_PAD;
private static final long REF_ARRAY_BASE;
private static final int REF_ELEMENT_SHIFT;
private static final Unsafe UNSAFE = Util.getUnsafe();
// 实际存储数据的数组
private final Object[] entries;
protected final int bufferSize;
protected final int indexMask;
// sequencer被频繁访问,后续还有7个long的填充(在RingBuffer中定义)
}
5.2 预分配对象:消除GC压力
Disruptor在初始化时通过EventFactory预先创建好所有槽位的对象 ,之后只是复用这些对象:
java
public RingBuffer(EventFactory<E> eventFactory, int bufferSize, ...) {
// 创建数组
this.entries = new Object[bufferSize];
// 预先填充所有槽位
for (int i = 0; i < bufferSize; i++) {
entries[i] = eventFactory.newInstance();
}
this.indexMask = bufferSize - 1;
}
生产者获取槽位后,直接填充已有对象的数据,而非创建新对象:
java
// 生产者发布事件
long sequence = ringBuffer.next(); // 1. 获取下一个可用序号
try {
LongEvent event = ringBuffer.get(sequence); // 2. 获取已存在的对象
event.setValue(data); // 3. 填充数据(复用对象)
} finally {
ringBuffer.publish(sequence); // 4. 发布,使数据对消费者可见
}
这种设计消除了频繁的对象创建和垃圾回收,大幅降低了GC压力。
5.3 序号(Sequence)与无锁设计
Disruptor使用单调递增的序号 来追踪生产和消费的进度。Sequence是一个经过缓存行填充的long类型包装:
java
/**
* Sequence:带缓存行填充的long值
* 通过Unsafe的CAS操作实现无锁更新
*/
class Sequence extends RhsPadding {
// 实际的值存储在 value 字段中
// 前后都有填充字段,避免伪共享
}
生产者通过CAS竞争下一个可写的序号:
java
public long next() {
// 自旋 + CAS 获取下一个序号
long current = cursor.get();
long next = current + 1;
// 检查是否覆盖了未被消费的数据
// ...
// CAS更新cursor
while (!cursor.compareAndSet(current, next)) {
// 重试
}
return next;
}
5.4 为什么Disruptor如此之快?
|-----------|-----------------------------|
| 优化手段 | 说明 |
| 环形数组 | 内存预分配,无GC压力,数组连续内存对CPU缓存友好 |
| 无锁设计 | 使用CAS + 内存屏障,避免锁竞争和上下文切换 |
| 消除伪共享 | 缓存行填充,每个核心变量独占缓存行 |
| 序号机制 | 用单调递增序号替代head/tail指针,减少指针争用 |
| 预分配对象 | 槽位对象复用,消除对象创建和GC |
Disruptor的吞吐量比传统的ArrayBlockingQueue高出1-2个数量级。
六、实战:基于环形缓冲区的日志系统
下面用环形缓冲区实现一个简单的异步日志系统,演示生产-消费模型的实际应用:
java
import java.util.concurrent.atomic.AtomicLong;
/**
* 基于环形缓冲区的异步日志系统
* 生产者(业务线程)将日志放入缓冲区立即返回
* 消费者(后台线程)批量写入磁盘
*/
public class AsyncLogger {
// 日志级别
public enum Level { DEBUG, INFO, WARN, ERROR }
// 日志事件
public static class LogEvent {
private long timestamp;
private Level level;
private String message;
public void set(long timestamp, Level level, String message) {
this.timestamp = timestamp;
this.level = level;
this.message = message;
}
@Override
public String toString() {
return String.format("[%s] %s - %s", level, timestamp, message);
}
}
// 环形缓冲区
private final LogEvent[] buffer;
private final int mask;
private final AtomicLong writeSequence = new AtomicLong(0);
private final AtomicLong readSequence = new AtomicLong(0);
private volatile boolean running = true;
private final Thread consumerThread;
public AsyncLogger(int bufferSize, String name) {
int size = ceilToPowerOfTwo(bufferSize);
this.buffer = new LogEvent[size];
this.mask = size - 1;
// 预分配所有事件对象
for (int i = 0; i < size; i++) {
buffer[i] = new LogEvent();
}
// 启动消费者线程
this.consumerThread = new Thread(this::consume, "AsyncLogger-" + name);
this.consumerThread.setDaemon(true);
this.consumerThread.start();
}
/**
* 异步写日志:生产者调用,立即返回
*/
public void log(Level level, String message) {
if (!running) return;
long seq = writeSequence.getAndIncrement();
int index = (int) (seq & mask);
// 复用预分配的对象
LogEvent event = buffer[index];
event.set(System.currentTimeMillis(), level, message);
}
/**
* 消费者:后台批量处理日志
*/
private void consume() {
while (running) {
try {
long read = readSequence.get();
long write = writeSequence.get();
if (read >= write) {
// 没有新日志,等待
Thread.sleep(1);
continue;
}
// 批量处理
int count = 0;
while (read < write && count < 100) {
int index = (int) (read & mask);
LogEvent event = buffer[index];
// 实际场景:写入文件或网络
System.out.println(event);
read++;
count++;
}
readSequence.set(read);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
public void shutdown() {
running = false;
consumerThread.interrupt();
}
private static int ceilToPowerOfTwo(int n) {
int highestBit = Integer.highestOneBit(n);
return highestBit == n ? n : highestBit << 1;
}
// 使用示例
public static void main(String[] args) throws InterruptedException {
AsyncLogger logger = new AsyncLogger(1024, "test");
// 模拟100个并发请求写日志
for (int i = 0; i < 100; i++) {
logger.log(Level.INFO, "请求处理完成: " + i);
}
Thread.sleep(1000); // 等待日志刷盘
logger.shutdown();
}
}
七、总结
环形缓冲区是一种精巧而高效的数据结构,它的核心价值在于:
1. 内存效率:固定大小的数组,元素复用,无GC压力。
2. 缓存友好:连续内存地址,充分利用CPU缓存预取机制。
3. 高性能:在单生产者-单消费者场景下可实现完全无锁;配合CAS和缓存行填充,可达到极致的吞吐量。
4. 多样性 :从JDK的ArrayBlockingQueue到LMAX的Disruptor,从有锁到无锁,从单消费到多消费,环形缓冲区有着丰富的实现形态。
|----------------------|---------------|---------|
| 实现 | 锁机制 | 适用场景 |
| 基础环形缓冲区 | 无锁(单生产者-单消费者) | 简单高性能场景 |
| ArrayBlockingQueue | ReentrantLock | 通用多线程场景 |
| Disruptor | CAS + 内存屏障 | 极致性能场景 |
环形缓冲区不仅是数据结构层面的优化,更是硬件感知编程的典范------它深刻理解了CPU缓存、内存布局、伪共享等底层机制,并将这些理解转化为代码层面的设计决策。
一句话总结:环形缓冲区用"空间换时间"的哲学,通过内存复用和指针循环,在高并发场景下实现了传统队列无法企及的性能表现。