Circular buffers
Ring buffers in depth: wraparound with modulo, head and tail indices, telling full from empty, fixed capacity policies and the trade-offs against a growing queue.
Learning goals
- Compute wrapped indices correctly, including the negative case
- Describe three ways to distinguish a full ring from an empty one
- Choose between rejecting and overwriting when a ring is full
Prerequisites
A ring buffer is a circular conveyor belt with a fixed number of trays: the oldest tray is reused as soon as it has been emptied.
Wraparound modulo
Wrapping means index = (index + 1) % n: the index runs 0, 1, …, n-1, 0, 1, …. The remainder operator does the wrap in one expression, so no separate if (index == n) index = 0 is needed.
Two Java details matter. First, % keeps the sign of the dividend, so -1 % 5 is -1, not 4; when an index can go backwards (for example addFirst on a deque) use Math.floorMod(i - 1, n). Second, when n is a power of two, i & (n - 1) is the same as the floored modulo, even for negative i, and it is cheaper than a division.
| Expression | n = 5 | n = 8 |
|---|---|---|
| (4 + 1) % n | 0 | 5 |
| -1 % n | -1 | -1 |
| Math.floorMod(-1, n) | 4 | 7 |
| (-1) & (n - 1) | n/a (n is not a power of 2) | 7 |
Circular queue: head, tail and a size counter
Interactive · edit the inputdata[4] · head 0 · tail 0 · size 0
An array of 4. head is where the next value leaves, tail is where the next value arrives, size counts the values.
1class RingQueue {2private final int[] data; private int head, tail, size;3RingQueue(int capacity) { data = new int[capacity]; }4boolean offer(int x) {5if (size == data.length) return false; // full6data[tail] = x;7tail = (tail + 1) % data.length; // wrap around8size++; return true;9}10Integer poll() {11if (size == 0) return null; // empty12int x = data[head];13head = (head + 1) % data.length; // wrap around14size--; return x;15}16}
Live state
head
0
tail
0
size
0
Execution trace (dry run table)Show 29 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | head=0 tail=0 size=0 | An array of 4. head is where the next value leaves, tail is where the next value arrives, size counts the values. |
| 2 | L5 | head=0 tail=0 size=0 op=offer(1) | offer(1): there is room. |
| 3 | L6 | head=0 tail=0 size=1 op=offer(1) | data[0] = 1. |
| 4 | L7 | head=0 tail=1 size=1 op=offer(1) | tail = (0 + 1) % 4 = 1. |
| 5 | L5 | head=0 tail=1 size=1 op=offer(2) | offer(2): there is room. |
| 6 | L6 | head=0 tail=1 size=2 op=offer(2) | data[1] = 2. |
| 7 | L7 | head=0 tail=2 size=2 op=offer(2) | tail = (1 + 1) % 4 = 2. |
| 8 | L5 | head=0 tail=2 size=2 op=offer(3) | offer(3): there is room. |
| 9 | L6 | head=0 tail=2 size=3 op=offer(3) | data[2] = 3. |
| 10 | L7 | head=0 tail=3 size=3 op=offer(3) | tail = (2 + 1) % 4 = 3. |
| 11 | L11 | head=0 tail=3 size=3 op=poll() | poll(): there is a value at head. |
| 12 | L12 | head=0 tail=3 size=3 op=poll() returned=1 | x = data[0] = 1. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 13 | L13 | head=1 tail=3 size=2 op=poll() returned=1 | head = (0 + 1) % 4 = 1. Returned 1. |
| 14 | L11 | head=1 tail=3 size=2 op=poll() | poll(): there is a value at head. |
| 15 | L12 | head=1 tail=3 size=2 op=poll() returned=2 | x = data[1] = 2. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 16 | L13 | head=2 tail=3 size=1 op=poll() returned=2 | head = (1 + 1) % 4 = 2. Returned 2. |
| 17 | L5 | head=2 tail=3 size=1 op=offer(4) | offer(4): there is room. |
| 18 | L6 | head=2 tail=3 size=2 op=offer(4) | data[3] = 4. |
| 19 | L7 | head=2 tail=0 size=2 op=offer(4) | tail = (3 + 1) % 4 = 0: past the last index, so it wraps to the start of the array. |
| 20 | L5 | head=2 tail=0 size=2 op=offer(5) | offer(5): there is room. |
| 21 | L6 | head=2 tail=0 size=3 op=offer(5) | data[0] = 5. |
| 22 | L7 | head=2 tail=1 size=3 op=offer(5) | tail = (0 + 1) % 4 = 1. |
| 23 | L5 | head=2 tail=1 size=3 op=offer(6) | offer(6): there is room. |
| 24 | L6 | head=2 tail=1 size=4 op=offer(6) | data[1] = 6. |
| 25 | L7 | head=2 tail=2 size=4 op=offer(6) | tail = (1 + 1) % 4 = 2. |
| 26 | L11 | head=2 tail=2 size=4 op=poll() | poll(): there is a value at head. |
| 27 | L12 | head=2 tail=2 size=4 op=poll() returned=3 | x = data[2] = 3. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 28 | L13 | head=3 tail=2 size=3 op=poll() returned=3 | head = (2 + 1) % 4 = 3. Returned 3. |
| 29 | L16 | head=3 tail=2 size=3 return=[4, 5, 6] | Queue contents from head: [4, 5, 6]; 0 operations rejected. |
Head/tail indices
head is the slot of the oldest value and tail is the slot where the next value will be written (one past the newest). Enqueue writes at tail then advances it; dequeue reads at head then advances it. Neither moves any data.
The live values are data[head], data[head + 1], … taken modulo n, for size values. The number of live values is not tail - head once the indices have wrapped, which is why a ring needs either a separate size, a spare slot, or a different index scheme.
1static int[] snapshot(int[] data, int head, int size) {2int[] live = new int[size];3for (int k = 0; k < size; k++) live[k] = data[(head + k) % data.length]; // oldest first4return live;5}
Circular queue: head, tail and a size counter
Interactive · edit the inputdata[4] · head 0 · tail 0 · size 0
An array of 4. head is where the next value leaves, tail is where the next value arrives, size counts the values.
1class RingQueue {2private final int[] data; private int head, tail, size;3RingQueue(int capacity) { data = new int[capacity]; }4boolean offer(int x) {5if (size == data.length) return false; // full6data[tail] = x;7tail = (tail + 1) % data.length; // wrap around8size++; return true;9}10Integer poll() {11if (size == 0) return null; // empty12int x = data[head];13head = (head + 1) % data.length; // wrap around14size--; return x;15}16}
Live state
head
0
tail
0
size
0
Execution trace (dry run table)Show 29 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | head=0 tail=0 size=0 | An array of 4. head is where the next value leaves, tail is where the next value arrives, size counts the values. |
| 2 | L5 | head=0 tail=0 size=0 op=offer(1) | offer(1): there is room. |
| 3 | L6 | head=0 tail=0 size=1 op=offer(1) | data[0] = 1. |
| 4 | L7 | head=0 tail=1 size=1 op=offer(1) | tail = (0 + 1) % 4 = 1. |
| 5 | L5 | head=0 tail=1 size=1 op=offer(2) | offer(2): there is room. |
| 6 | L6 | head=0 tail=1 size=2 op=offer(2) | data[1] = 2. |
| 7 | L7 | head=0 tail=2 size=2 op=offer(2) | tail = (1 + 1) % 4 = 2. |
| 8 | L5 | head=0 tail=2 size=2 op=offer(3) | offer(3): there is room. |
| 9 | L6 | head=0 tail=2 size=3 op=offer(3) | data[2] = 3. |
| 10 | L7 | head=0 tail=3 size=3 op=offer(3) | tail = (2 + 1) % 4 = 3. |
| 11 | L11 | head=0 tail=3 size=3 op=poll() | poll(): there is a value at head. |
| 12 | L12 | head=0 tail=3 size=3 op=poll() returned=1 | x = data[0] = 1. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 13 | L13 | head=1 tail=3 size=2 op=poll() returned=1 | head = (0 + 1) % 4 = 1. Returned 1. |
| 14 | L11 | head=1 tail=3 size=2 op=poll() | poll(): there is a value at head. |
| 15 | L12 | head=1 tail=3 size=2 op=poll() returned=2 | x = data[1] = 2. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 16 | L13 | head=2 tail=3 size=1 op=poll() returned=2 | head = (1 + 1) % 4 = 2. Returned 2. |
| 17 | L5 | head=2 tail=3 size=1 op=offer(4) | offer(4): there is room. |
| 18 | L6 | head=2 tail=3 size=2 op=offer(4) | data[3] = 4. |
| 19 | L7 | head=2 tail=0 size=2 op=offer(4) | tail = (3 + 1) % 4 = 0: past the last index, so it wraps to the start of the array. |
| 20 | L5 | head=2 tail=0 size=2 op=offer(5) | offer(5): there is room. |
| 21 | L6 | head=2 tail=0 size=3 op=offer(5) | data[0] = 5. |
| 22 | L7 | head=2 tail=1 size=3 op=offer(5) | tail = (0 + 1) % 4 = 1. |
| 23 | L5 | head=2 tail=1 size=3 op=offer(6) | offer(6): there is room. |
| 24 | L6 | head=2 tail=1 size=4 op=offer(6) | data[1] = 6. |
| 25 | L7 | head=2 tail=2 size=4 op=offer(6) | tail = (1 + 1) % 4 = 2. |
| 26 | L11 | head=2 tail=2 size=4 op=poll() | poll(): there is a value at head. |
| 27 | L12 | head=2 tail=2 size=4 op=poll() returned=3 | x = data[2] = 3. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 28 | L13 | head=3 tail=2 size=3 op=poll() returned=3 | head = (2 + 1) % 4 = 3. Returned 3. |
| 29 | L16 | head=3 tail=2 size=3 return=[4, 5, 6] | Queue contents from head: [4, 5, 6]; 0 operations rejected. |
Full vs empty distinction
When the ring is empty, head == tail. When it is completely full, the tail has wrapped all the way around and head == tail again. The indices alone cannot tell the two states apart, so every ring design picks one of three fixes.
The animation uses the second fix: allocate one slot more than the usable capacity and never fill it, so head == tail can only mean empty and (tail + 1) % n == head means full. Compare it with the size-counter version in the previous topic.
| Strategy | Empty test | Full test | Cost |
|---|---|---|---|
| Size counter | size == 0 | size == n | One extra int to maintain |
| One slot always empty | head == tail | (tail + 1) % n == head | Capacity is n - 1 |
| Free-running counters (never wrapped) | head == tail | tail - head == n | Counters can overflow after 2^31 operations |
Circular queue: one slot kept empty to tell full from empty
Interactive · edit the inputdata[5] · head 0 · tail 0 · size 0
An array of 5: 4 usable slots plus one that is never filled. head == tail then can only mean empty.
1class RingQueue {2private final int[] data; private int head, tail; // one slot always stays empty3RingQueue(int capacity) { data = new int[capacity + 1]; }4boolean offer(int x) {5if ((tail + 1) % data.length == head) return false; // full6data[tail] = x;7tail = (tail + 1) % data.length; // wrap around8return true;9}10Integer poll() {11if (head == tail) return null; // empty12int x = data[head];13head = (head + 1) % data.length; // wrap around14return x;15}16}
Live state
head
0
tail
0
size
0
Execution trace (dry run table)Show 29 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | head=0 tail=0 size=0 | An array of 5: 4 usable slots plus one that is never filled. head == tail then can only mean empty. |
| 2 | L5 | head=0 tail=0 size=0 op=offer(1) | offer(1): there is room. |
| 3 | L6 | head=0 tail=0 size=0 op=offer(1) | data[0] = 1. |
| 4 | L7 | head=0 tail=1 size=1 op=offer(1) | tail = (0 + 1) % 5 = 1. |
| 5 | L5 | head=0 tail=1 size=1 op=offer(2) | offer(2): there is room. |
| 6 | L6 | head=0 tail=1 size=1 op=offer(2) | data[1] = 2. |
| 7 | L7 | head=0 tail=2 size=2 op=offer(2) | tail = (1 + 1) % 5 = 2. |
| 8 | L5 | head=0 tail=2 size=2 op=offer(3) | offer(3): there is room. |
| 9 | L6 | head=0 tail=2 size=2 op=offer(3) | data[2] = 3. |
| 10 | L7 | head=0 tail=3 size=3 op=offer(3) | tail = (2 + 1) % 5 = 3. |
| 11 | L11 | head=0 tail=3 size=3 op=poll() | poll(): there is a value at head. |
| 12 | L12 | head=0 tail=3 size=3 op=poll() returned=1 | x = data[0] = 1. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 13 | L13 | head=1 tail=3 size=2 op=poll() returned=1 | head = (0 + 1) % 5 = 1. Returned 1. |
| 14 | L11 | head=1 tail=3 size=2 op=poll() | poll(): there is a value at head. |
| 15 | L12 | head=1 tail=3 size=2 op=poll() returned=2 | x = data[1] = 2. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 16 | L13 | head=2 tail=3 size=1 op=poll() returned=2 | head = (1 + 1) % 5 = 2. Returned 2. |
| 17 | L5 | head=2 tail=3 size=1 op=offer(4) | offer(4): there is room. |
| 18 | L6 | head=2 tail=3 size=1 op=offer(4) | data[3] = 4. |
| 19 | L7 | head=2 tail=4 size=2 op=offer(4) | tail = (3 + 1) % 5 = 4. |
| 20 | L5 | head=2 tail=4 size=2 op=offer(5) | offer(5): there is room. |
| 21 | L6 | head=2 tail=4 size=2 op=offer(5) | data[4] = 5. |
| 22 | L7 | head=2 tail=0 size=3 op=offer(5) | tail = (4 + 1) % 5 = 0: past the last index, so it wraps to the start of the array. |
| 23 | L5 | head=2 tail=0 size=3 op=offer(6) | offer(6): there is room. |
| 24 | L6 | head=2 tail=0 size=3 op=offer(6) | data[0] = 6. |
| 25 | L7 | head=2 tail=1 size=4 op=offer(6) | tail = (0 + 1) % 5 = 1. |
| 26 | L11 | head=2 tail=1 size=4 op=poll() | poll(): there is a value at head. |
| 27 | L12 | head=2 tail=1 size=4 op=poll() returned=3 | x = data[2] = 3. The slot keeps its old number (shown struck out when free): only head and size decide what is live. |
| 28 | L13 | head=3 tail=1 size=3 op=poll() returned=3 | head = (2 + 1) % 5 = 3. Returned 3. |
| 29 | L16 | head=3 tail=1 size=3 return=[4, 5, 6] | Queue contents from head: [4, 5, 6]; 0 operations rejected. |
Fixed capacity
A ring buffer has a fixed capacity chosen up front. When it is full there are two policies. **Reject**: offer returns false and the producer must wait or drop the new value, which is right for work queues. **Overwrite**: the new value replaces the oldest one, which is right when only the latest data matters, such as the last 100 log lines or sensor samples.
The overwriting variant needs no full test at all: write at tail, and if the buffer was full, advance head as well so that the oldest value is dropped.
1class LatestN {2private final int[] data; private int head, size;3LatestN(int capacity) { data = new int[capacity]; }4void add(int x) {5int tail = (head + size) % data.length;6data[tail] = x;7if (size == data.length) head = (head + 1) % data.length; // full: the oldest value is overwritten8else size++;9}10int[] latest() {11int[] out = new int[size];12for (int k = 0; k < size; k++) out[k] = data[(head + k) % data.length];13return out;14}15}
Ring buffer trade-offs
A ring buffer trades flexibility for predictability. Memory is allocated once, no operation ever allocates or copies, and every operation is O(1) in the worst case, which is why rings are used in audio pipelines, network drivers and logging.
The cost is the fixed capacity: choosing it too small drops or rejects data, too large wastes memory. A growing queue such as ArrayDeque never rejects, but a resize copies every element, so one operation can be O(n).
| Ring buffer (fixed) | Growing circular array (ArrayDeque) | |
|---|---|---|
| Capacity | Fixed at creation | Grows when full |
| Worst-case enqueue | O(1) | O(n) at a resize; O(1) amortised |
| Allocation while running | None | Occasional |
| When full | Reject or overwrite | Never full |
| Good for | Streams, buffers between threads, latest-N data | General-purpose queues |
Practice ladder
Now try it yourself
Easy
- Compute (7 + 1) % 8 and Math.floorMod(-3, 8)
- Which indices describe an empty ring?
Medium
- Design a ring buffer that overwrites the oldest value; how do head and size change on a full add?
- Why can a ring with one empty slot hold only n - 1 values?
Advanced
- Implement a ring buffer using two free-running int counters and explain when they overflow
Key takeaways
- Wrap with (i + 1) % n; use Math.floorMod when an index can go negative.
- head == tail is ambiguous: use a size counter, a spare slot or free-running counters.
- A ring buffer never allocates or copies, at the price of a fixed capacity.