Skip to content

Ring Buffer (Circular Buffer): How It Works and Where Java Uses It

A thick ring of frosted glass on plum, seen from directly above: six round hollows at equal spacing; the three hollows at the top and on the right each hold a glowing amber sphere, the three on the left and at the bottom are empty

A ring buffer is a fixed-size array that you use like a circle: once the last slot has been written, you continue with the first one. Two indices – one for writing, one for reading – always move in the same direction, and no element is ever moved.

In this article, you will learn:

  • how a ring buffer works, step by step – and why the two indices alone cannot tell you whether it is full or empty,
  • how to implement a ring buffer in Java yourself,
  • which three variants exist and where the JDK uses them: in ArrayBlockingQueue, ArrayDeque, and MemoryHandler,
  • and what the LMAX Disruptor does differently.

What Is a Ring Buffer?

A ring buffer (also called a “circular buffer”) is an array of fixed length whose slots you can imagine arranged on a circle: after the last slot comes the first one again. Two indices point into the array – the write index to the next free slot, the read index to the oldest element.

The following diagram shows the same ring buffer twice: on the left as a circle, on the right as a flat array. It has six slots; slots 2 to 4 hold the elements 3, 4, and 5. readIndex points to the 3 in slot 2, writeIndex to the free slot 5. The two arrows give the write direction – clockwise in the circle, left to right in the array.

On the left, a ring of six slots labeled 0 to 5 clockwise; slots 2, 3, and 4 contain 3, 4, and 5; an arrow readIndex points to slot 2, an arrow writeIndex to slot 5; in the middle of the ring, an arrow curved clockwise. On the right, the same array flat: six slots in a row, 3, 4, and 5 in slots 2 to 4, with readIndex under slot 2 and writeIndex under slot 5, and an arrow pointing right above them
Circle and array show the same buffer – writing goes clockwise or to the right, and after slot 5 comes slot 0

Elements are inserted at the write index and removed at the read index, in the order in which they were written. A ring buffer is thus a queue following the FIFO principle, with a capacity that is fixed from the start.

This data structure goes by several names that all mean the same thing: “ring buffer,” “circular buffer,” and – for the array itself – “circular array.” A “circular queue” is the queue built on top of such an array.

What do you gain from the circle? Removing an element never shifts the others, and inserting one never allocates memory: the array is created once and only written to after that. Writing and reading therefore always take the same time, no matter how full the buffer is – more on that in the section “Time Complexity of Ring Buffer Operations.”

How a Ring Buffer Works

Writing and Reading with Two Indices

When writing, the new element goes into the slot that writeIndex points to; then writeIndex moves on by one slot. When reading, the element is taken from the slot that readIndex points to – the slot is set to null – and readIndex moves on by one slot.

So both indices move in one direction only. The read index follows the write index, and between the two lie the elements that have not been read yet.

In the diagram above, three elements lie between them: the 3, the 4, and the 5. The 1 and the 2 have already been read – their slots 0 and 1 are free again.

The following diagram shows both operations on the circle, from left to right. On the left, the buffer from the first diagram. In the middle, the same buffer after write(6) has stored the 6 in slot 5 – writeIndex is now on the neighboring slot 0. On the right, the buffer after read() has taken the 3 out of slot 2 – readIndex is now on slot 3:

Three rings of six slots each, side by side. Left, before write(6): 3, 4, and 5 in slots 2 to 4, writeIndex points to slot 5, readIndex to slot 2. Middle, after write(6): additionally the 6 in slot 5, highlighted; writeIndex points to slot 0, readIndex still to slot 2. Right, after read(): slot 2 is empty, 4, 5, and 6 sit in slots 3 to 5; readIndex points to slot 3, writeIndex to slot 0
Writing moves writeIndex ahead, reading moves readIndex after it – both clockwise

On the circle, there is nothing special about write(6): slot 0 is the neighbor of slot 5. In the flat array, the same step becomes a jump – that is what the next section is about.

The Wrap-Around: From the Last Slot Back to 0

What happens in the array when writeIndex is on the last slot? The following diagram shows it. At the top, the buffer from the first diagram, with writeIndex on slot 5. At the bottom, the same buffer after the 6 has been written: it sits in slot 5, and writeIndex has jumped to slot 0:

Two rows of six slots each. Top, before write(6): 3, 4, and 5 in slots 2 to 4, readIndex under slot 2, writeIndex under slot 5. Bottom, after write(6): additionally the 6 in slot 5, highlighted; readIndex still under slot 2, writeIndex now under slot 0
After slot 5 comes slot 0 – an index never reaches the capacity

This jump is the whole trick of the ring buffer. In code, there are three ways to write it:

  1. Comparison: The index is incremented; if it then equals the length of the array, it is set to 0. This is what the JDK does, e.g., in ArrayBlockingQueue: if (++putIndex == items.length) putIndex = 0;
  2. Modulo: The new index is (index + 1) % capacity. For every index below capacity - 1, that is index + 1; for capacity - 1 itself, it is 0. This is how the MemoryHandler from java.util.logging calculates: its next write slot is (start + count) % buffer.length.
  3. Bitmask: If the capacity is a power of two, say 8, then (index + 1) & (capacity - 1) gives the same result as modulo: the mask capacity - 1 consists of ones only in binary (for 8: 0b111), and a bitwise AND with it cuts off all higher bits – 8 (0b1000) becomes 0. This is what the LMAX Disruptor does, which is why it accepts only powers of two as buffer size.

All three ways lead to the same result; they differ in what the CPU executes: a comparison, a division, or a bitwise AND. I recommend the comparison: it works with any capacity, and the JDK does the same in ArrayDeque and ArrayBlockingQueue. The bitmask only pays off when the capacity is a power of two anyway.

Full or Empty? Three Answers

When the buffer is full, writeIndex points to the slot that holds the oldest element – that is, to the same slot as readIndex. When the buffer is empty, readIndex has caught up with writeIndex: both point to the same slot again.

The following diagram shows both cases. On the left, a full buffer with the elements 3 to 8 – after the 6 in slot 5, the 7 and the 8 were written into slots 0 and 1 –, on the right an empty buffer. In both buffers, readIndex and writeIndex are on slot 2; the indices alone do not reveal the difference:

Two rings of six slots each. On the left, slots 2 to 5 contain the values 3 to 6 and slots 0 and 1 the values 7 and 8; below it says “full: size = 6”. On the right, all six slots are empty; below it says “empty: size = 0”. In both rings, an arrow labeled readIndex and writeIndex points to slot 2
Same indices, different state – only the counter tells full from empty

Three solutions have established themselves, and you will find all three in the JDK:

  1. A counter. Besides the two indices, the buffer keeps track of the number of elements: size == 0 means empty, size == capacity means full. This is what ArrayBlockingQueue does with its field count – and what I do in the implementation in the section “Implementing a Ring Buffer in Java.”
  2. One slot stays free. The buffer is never filled completely; writeIndex always points to an empty slot. Then readIndex == writeIndex unambiguously means “empty,” and the buffer is full when the slot after writeIndex is the slot readIndex. The price is one additional slot in the array – ArrayDeque pays this price and allocates an array of 17 slots for 16 elements.
  3. Check the slot. When both indices point to the same slot, its content decides: if it is null, the buffer is empty, otherwise full. This only works if null is not a valid element – ArrayDeque and ArrayBlockingQueue both reject null. ArrayDeque needs this check even though it keeps one slot free: addLast() first writes the new element into the last free slot and grows the array afterwards. At that moment the array is full, and head == tail would look like “empty” – so grow() checks the slot elements[head] before it moves the content apart.

I recommend the counter. It keeps the code most readable and makes size() a field access – for the price of one additional int field.

Implementing a Ring Buffer in Java

A ring buffer needs only an array, two indices, and a counter. The following class RingBuffer shows this minimal form – without thread safety: if several threads access it at the same time, you need a lock as in ArrayBlockingQueue.

You can also find the code, together with unit tests, in the class RingBuffer in the GitHub repository for this article:

public class RingBuffer<E> {

  private final Object[] elements;
  private int readIndex;
  private int writeIndex;
  private int size;

  public RingBuffer(int capacity) {
    if (capacity < 1) {
      throw new IllegalArgumentException("Capacity must be 1 or higher");
    }
    elements = new Object[capacity];
  }

  public void write(E element) {
    if (isFull()) {
      throw new IllegalStateException("The ring buffer is full");
    }
    elements[writeIndex] = element;
    writeIndex = next(writeIndex);
    size++;
  }

  public E read() {
    if (isEmpty()) {
      throw new NoSuchElementException("The ring buffer is empty");
    }

    @SuppressWarnings("unchecked")
    E element = (E) elements[readIndex];

    elements[readIndex] = null;
    readIndex = next(readIndex);
    size--;
    return element;
  }

  public boolean isEmpty() {
    return size == 0;
  }

  public boolean isFull() {
    return size == elements.length;
  }

  public int size() {
    return size;
  }

  private int next(int index) {
    index++;
    if (index == elements.length) {
      index = 0;
    }
    return index;
  }
}

The methods one by one:

  • write() first checks whether the buffer is full, stores the element in the slot that writeIndex points to, moves the index on, and increments the counter.
  • read() checks whether the buffer is empty, takes the element from the slot that readIndex points to, sets the slot to null – so the garbage collector can collect the element once nobody else holds a reference to it –, moves the index on, and decrements the counter.
  • next() is the one place where the indices wrap around: the comparison from the section “The Wrap-Around: From the Last Slot Back to 0.” If you want to try modulo or the bitmask, you change only this method.
  • isEmpty(), isFull(), and size() read the counter.

The array has the type Object[] because new E[capacity] is not allowed in Java; the cast in read() is nevertheless safe, since write() only lets elements of type E in.

The following example writes three elements into a buffer of capacity 3, reads two of them, and writes two more – those land in slots 0 and 1 while “c” still sits in slot 2 (→ code on GitHub):

RingBuffer<String> buffer = new RingBuffer<>(3);

buffer.write("a");
buffer.write("b");
buffer.write("c");
System.out.println("read: " + buffer.read());
System.out.println("read: " + buffer.read());

buffer.write("d"); // lands in slot 0
buffer.write("e"); // lands in slot 1
System.out.println("full: " + buffer.isFull());

while (!buffer.isEmpty()) {
  System.out.println("read: " + buffer.read());
}

The program prints the following:

read: a
read: b
full: true
read: c
read: d
read: e

The elements come out in the order in which they were written – the wrap-around from slot 2 to slot 0 is invisible from the outside.

A version of the same idea with enqueue() and dequeue() behind a queue interface is the BoundedArrayQueue from the article “Implementing a Queue Using an Array” – there I also show how the array grows when the queue is not supposed to stay bounded.

Variants of the Ring Buffer

What happens when the buffer is full and another element is to be written? There are three answers, and each has its representative in the JDK.

Fixed Buffer: ArrayBlockingQueue Rejects or Waits

The first answer: the buffer does not accept the element. ArrayBlockingQueue recognizes a full queue by its counter – count == items.length – and reacts differently depending on the method: offer() returns false, add() throws an IllegalStateException with the message “Queue full,” and put() makes the calling thread wait until a slot becomes free. offer() with a timeout waits at most for the given time and returns false if no slot has become free by then.

In the source code (JDK 27), the waiting is a loop: as long as count == items.length holds, put() calls notFull.await(). Every removal – via take() or poll() – signals the condition notFull and thereby wakes up a waiting producer. The condition belongs to the one ReentrantLock of the class; why producers and consumers can slow each other down because of it, I show in the section “Is ArrayBlockingQueue Thread-Safe?” of the article about ArrayBlockingQueue.

Growing Buffer: ArrayDeque Allocates a Larger Array

The second answer: the buffer grows. ArrayDeque has no capacity limit – when the array is full, the class allocates a larger one and copies the elements over.

The array is full as soon as head and tail point to the same slot after an insertion. Then the insert method – e.g., addLast() – immediately calls grow(). An array with fewer than 64 slots grows by its length plus 2 – the 17 slots of the default constructor become 36 –, a larger one by half.

If the content currently runs past the end of the array and continues at slot 0, grow() moves the part from head to the old end of the array to the end of the new array, so the order is preserved. How a growing array is copied for a deque, I show with diagrams in the article “Implementing a Deque Using an Array”; the characteristics of the class are in the article about ArrayDeque.

Overwriting Buffer: The Oldest Values Drop Out

The third answer: the new element overwrites the oldest one. The buffer is then never full in the sense of “accepts nothing more” – it always holds the most recent elements, at most as many as it has slots. This is the right variant for everything where only the last n values matter to you: the last 1,000 log lines, the sensor readings of the last minute, the last positions of a game character.

The following diagram shows the full buffer from the previous diagram before and after write(9): the 9 replaces the 3 – the oldest element – in slot 2, and both indices move on to slot 3. The 4 is now the oldest element:

Two rows of six slots each. Top, before write(9): 7, 8, 3, 4, 5, 6 in slots 0 to 5, readIndex and writeIndex both under slot 2. Bottom, after write(9): slot 2 holds the 9, highlighted, the other slots unchanged; readIndex and writeIndex both under slot 3
Overwriting means: read index and write index move on together

In code, that is one more if. The class OverwritingRingBuffer extends RingBuffer and overrides only write() (→ code on GitHub):

public class OverwritingRingBuffer<E> extends RingBuffer<E> {

  public OverwritingRingBuffer(int capacity) {
    super(capacity);
  }

  @Override
  public void write(E element) {
    if (isFull()) {
      read(); // discards the oldest element
    }
    super.write(element);
  }
}

If the buffer is full, write() reads the oldest element and discards it – readIndex moves on, and one slot becomes free. Then the inherited method writes the new element as usual.

If you write five numbers into a buffer of capacity 3, the last three remain (→ code on GitHub):

RingBuffer<Integer> buffer = new OverwritingRingBuffer<>(3);

for (int i = 1; i <= 5; i++) {
  buffer.write(i);
}
System.out.println("size: " + buffer.size());

while (!buffer.isEmpty()) {
  System.out.println("read: " + buffer.read());
}

The output:

size: 3
read: 3
read: 4
read: 5

The JDK has an overwriting ring buffer, too – not in java.util, but in java.util.logging. The MemoryHandler keeps log entries in an array of LogRecords, a “circular buffer” according to its Javadoc. When the buffer is full, the new entry overwrites the oldest one, and start – the index of the oldest entry – moves on by one slot.

This way, the MemoryHandler always holds the most recent entries. It passes them on to another handler only once an entry with at least the level pushLevel arrives – SEVERE by default. When an error occurs, the up to 1,000 entries before it (the buffer’s default size) therefore end up in the log as well, without you having to log everything all the time.

LMAX Disruptor: The Best-Known Java Ring Buffer

Outside the JDK, the best-known ring buffer in Java is the LMAX Disruptor – a library by the trading venue LMAX Exchange for passing events between threads (Maven coordinates com.lmax:disruptor, version 4.0.0 as of October 2026). Its core is the class RingBuffer, according to its Javadoc a “ring based store of reusable entries.”

The Disruptor does three things differently from the buffers in this article:

  • The entries are pre-allocated. On creation, the RingBuffer fills every slot with an empty event object via an EventFactory. A producer does not allocate a new object but copies its data into the fields of the existing event – in the example from the Disruptor documentation, a long that it reads from a ByteBuffer: event.set(buffer.getLong(0)). Because the events live as long as the Disruptor does, this produces no garbage for the garbage collector. That only holds as long as the data is copied as values: if the producer merely stores a reference to an object it allocated for this purpose in the event, it produces the same garbage as with an ArrayBlockingQueue.
  • Instead of two indices, there are sequence numbers. A producer claims the next sequence number with next() – a long that keeps counting up –, fills the entry, and releases it with publish(). Every consumer keeps its own Sequence, up to which it has read; this is how the Disruptor knows which entries not all consumers have read yet and must therefore not be overwritten.
  • The capacity must be a power of two. The constructor checks this with Integer.bitCount(bufferSize) != 1 and otherwise throws an IllegalArgumentException (“bufferSize must be a power of 2”). The slot for a sequence number is then sequence & indexMask with indexMask = bufferSize - 1 – the bitmask from the section “The Wrap-Around: From the Last Slot Back to 0.”

If you only want to hand elements from one thread to another, an ArrayBlockingQueue is the simpler choice. The Disruptor pays off when the latency per event and every object allocation during operation matter – that is what it was built for.

Time Complexity of Ring Buffer Operations

You can find an introduction to time complexity in the article “Big O Notation and Time Complexity – Easily Explained”.

Writing and reading each access one slot and move one index on – no matter how many elements the buffer holds. Their time complexity is O(1). The same applies to isEmpty(), isFull(), and size() when the buffer keeps a counter.

If the buffer grows, as with ArrayDeque, copying the elements when growing costs O(n) once. Because the array grows proportionally to its size each time, this happens rarely, and averaged over many insert operations, it stays at amortized O(1).

Searching for or removing an element in the middle, on the other hand, costs O(n): the array has to be traversed, and when removing, everything behind the gap has to be moved up – ArrayBlockingQueue.remove(Object) does exactly that.

Summary and Outlook

A ring buffer is an array with two indices that jump back to 0 once they have reached the end of the array. Writing and reading never move an element, and as long as the buffer does not have to grow, they never allocate memory – that is why a ring buffer sits underneath ArrayBlockingQueue, ArrayDeque, and the MemoryHandler of the logging package, and why the LMAX Disruptor made it the core of its design.

Three points to take away:

  • Whether the buffer is full or empty, the indices alone do not tell you. You need a counter, a slot that stays free, or a look into the slot both indices point to.
  • When the buffer is full, you have three options: reject or wait (ArrayBlockingQueue), grow (ArrayDeque), or overwrite the oldest element (MemoryHandler, OverwritingRingBuffer).
  • The wrap-around from the last slot back to 0 is best written as a comparison; the bitmask only pays off with a capacity that is a power of two anyway.

How to turn the ring buffer into a queue with a growing array, I show in the article “Implementing a Queue Using an Array” – and how to insert and remove at both ends, in the article “Implementing a Deque Using an Array.”

Did you take something away from this article? With a review on my ProvenExpert profile, you help other developers assess whether these articles are worth reading – and you help me understand which content is most useful to you.

👉 Leave a review

Want Even More Knowledge?

My blog features many articles on Java, software architecture, and performance — from foundational concepts to advanced patterns.

If you want to go deeper, check out my trainings: hands-on, easy to understand, and directly applicable to your day-to-day project work. Instead of theory, I teach principles that help you write code that is better, more maintainable, and more performant in the long run.

Explore the Java Trainings

Become a Better Java Developer

My free newsletter keeps you ahead. Modern Java: new versions & features, performance, and JVM insights – once a month.

Search