Java Top

I just announced the new Learn Spring course, focused on the fundamentals of Spring 5 and Spring Boot 2:

>> CHECK OUT THE COURSE

1. Overview

In this tutorial, we'll learn how to implement a Ring Buffer in Java.

2. Ring Buffer

Ring Buffer (or Circular Buffer) is a bounded circular data structure that is used for buffering data between two or more threads. As we keep writing to a ring buffer, it wraps around as it reaches the end.

2.1. How It Works

A Ring Buffer is implemented using a fixed-size array that wraps around at the boundaries.

Apart from the array, it keeps track of three things:

  • the next available slot in the buffer to insert an element,
  • the next unread element in the buffer,
  • and the end of the array – the point at which the buffer wraps around to the start of the array

The mechanics of how a ring buffer handles these requirements vary with the implementation. For instance, the Wikipedia entry on the subject shows a method using four-pointers.

We'll borrow the approach from Disruptor‘s implementation of the ring buffer using sequences.

The first thing we need to know is the capacity – the fixed maximum size of the buffer. Next, we'll use two monotonically increasing sequences:

  • Write Sequence: starting at -1, increments by 1 as we insert an element
  • Read Sequence: starting at 0, increments by 1 as we consume an element

We can map a sequence to an index in the array by using a mod operation:

arrayIndex = sequence % capacity

The mod operation wraps the sequence around the boundaries to derive a slot in the buffer:

Let's see how we'd insert an element:

buffer[++writeSequence % capacity] = element

We are pre-incrementing the sequence before inserting an element.

To consume an element we do a post-increment:

element = buffer[readSequence++ % capacity]

In this case, we perform a post-increment on the sequence. Consuming an element doesn't remove it from the buffer – it just stays in the array until it's overwritten.

2.2. Empty and Full Buffers

As we wrap around the array, we will start overwriting the data in the buffer. If the buffer is full, we can choose to either overwrite the oldest data regardless of whether the reader has consumed it or prevent overwriting the data that has not been read.

If the reader can afford to miss the intermediate or old values (for example, a stock price ticker), we can overwrite the data without waiting for it to be consumed. On the other hand, if the reader must consume all the values (like with e-commerce transactions), we should wait (block/busy-wait) until the buffer has a slot available.

The buffer is full if the size of the buffer is equal to its capacity, where its size is equal to the number of unread elements:

size = (writeSequence - readSequence) + 1
isFull = (size == capacity)

If the write sequence lags behind the read sequence, the buffer is empty:

isEmpty = writeSequence < readSequence

The buffer returns a null value if it's empty.

2.2. Advantages and Disadvantages

A ring buffer is an efficient FIFO buffer. It uses a fixed-size array that can be pre-allocated upfront and allows an efficient memory access pattern. All the buffer operations are constant time O(1), including consuming an element, as it doesn't require a shifting of elements.

On the flip side, determining the correct size of the ring buffer is critical. For instance, the write operations may block for a long time if the buffer is under-sized and the reads are slow. We can use dynamic sizing, but it would require moving data around and we'll miss out on most of the advantages discussed above.

3. Implementation in Java

Now that we understand how a ring buffer works, let's proceed to implement it in Java.

3.1. Initialization

First, let's define a constructor that initializes the buffer with a predefined capacity:

public CircularBuffer(int capacity) {
    this.capacity = capacity;
    this.data = (E[]) new Object[capacity];
    this.readSequence = 0;
    this.writeSequence = -1;
}

This will create an empty buffer and initialize the sequence fields as discussed in the previous section.

3.3. Offer

Next, we'll implement the offer operation that inserts an element into the buffer at the next available slot and returns true on success. It returns false if the buffer can't find an empty slot, that is, we can't overwrite unread values.

Let's implement the offer method in Java:

public boolean offer(E element) {
    boolean isFull = (writeSequence - readSequence) + 1 == capacity;
    if (!isFull) {
        int nextWriteSeq = writeSequence + 1;
        data[nextWriteSeq % capacity] = element;
        writeSequence++;
        return true;
    }
    return false;
}

So, we're incrementing the write sequence and computing the index in the array for the next available slot. Then, we're writing the data to the buffer and storing the updated write sequence.

Let's try it out:

@Test
public void givenCircularBuffer_whenAnElementIsEnqueued_thenSizeIsOne() {
    CircularBuffer buffer = new CircularBuffer<>(defaultCapacity);

    assertTrue(buffer.offer("Square"));
    assertEquals(1, buffer.size());
}

3.4. Poll

Finally, we'll implement the poll operation that retrieves and removes the next unread element. The poll operation doesn't remove the element but increments the read sequence.

Let's implement it:

public E poll() {
    boolean isEmpty = writeSequence < readSequence;
    if (!isEmpty) {
        E nextValue = data[readSequence % capacity];
        readSequence++;
        return nextValue;
    }
    return null;
}

Here, we're reading the data at the current read sequence by computing the index in the array. Then, we're incrementing the sequence and returning the value, if the buffer is not empty.

Let's test it out:

@Test
public void givenCircularBuffer_whenAnElementIsDequeued_thenElementMatchesEnqueuedElement() {
    CircularBuffer buffer = new CircularBuffer<>(defaultCapacity);
    buffer.offer("Triangle");
    String shape = buffer.poll();

    assertEquals("Triangle", shape);
}

4. Producer-Consumer Problem

We've talked about the use of a ring buffer for exchanging data between two or more threads, which is an example of a synchronization problem called the Producer-Consumer problem. In Java, we can solve the producer-consumer problem in various ways using semaphores, bounded queues, ring buffers, etc.

Let's implement a solution based on a ring buffer.

4.1. volatile Sequence Fields

Our implementation of the ring buffer is not thread-safe. Let's make it thread-safe for the simple single-producer and single-consumer case.

The producer writes data to the buffer and increments the writeSequence, while the consumer only reads from the buffer and increments the readSequence. So, the backing array is contention-free and we can get away without any synchronization.

But we still need to ensure that the consumer can see the latest value of the writeSequence field (visibility) and that the writeSequence is not updated before the data is actually available in the buffer (ordering).

We can make the ring buffer concurrent and lock-free in this case by making the sequence fields volatile:

private volatile int writeSequence = -1, readSequence = 0;

In the offer method, a write to the volatile field writeSequence guarantees that the writes to the buffer happen before updating the sequence. At the same time, the volatile visibility guarantee ensures that the consumer will always see the latest value of writeSequence.

4.2. Producer

Let's implement a simple producer Runnable that writes to the ring buffer:

public void run() {
    for (int i = 0; i < items.length;) {
        if (buffer.offer(items[i])) {
           System.out.println("Produced: " + items[i]);
            i++;
        }
    }
}

The producer thread would wait for an empty slot in a loop (busy-waiting).

4.3. Consumer

We'll implement a consumer Callable that reads from the buffer:

public T[] call() {
    T[] items = (T[]) new Object[expectedCount];
    for (int i = 0; i < items.length;) {
        T item = buffer.poll();
        if (item != null) {
            items[i++] = item;
            System.out.println("Consumed: " + item);
        }
    }
    return items;
}

The consumer thread continues without printing if it receives a null value from the buffer.

Let's write our driver code:

executorService.submit(new Thread(new Producer<String>(buffer)));
executorService.submit(new Thread(new Consumer<String>(buffer)));

Executing our producer-consumer program produces output like below:

Produced: Circle
Produced: Triangle
  Consumed: Circle
Produced: Rectangle
  Consumed: Triangle
  Consumed: Rectangle
Produced: Square
Produced: Rhombus
  Consumed: Square
Produced: Trapezoid
  Consumed: Rhombus
  Consumed: Trapezoid
Produced: Pentagon
Produced: Pentagram
Produced: Hexagon
  Consumed: Pentagon
  Consumed: Pentagram
Produced: Hexagram
  Consumed: Hexagon
  Consumed: Hexagram

5. Conclusion

In this tutorial, we've learned how to implement a Ring Buffer and explored how it can be used to solve the producer-consumer problem.

As usual, the source code for all the examples is available over on GitHub.

Java bottom

I just announced the new Learn Spring course, focused on the fundamentals of Spring 5 and Spring Boot 2:

>> CHECK OUT THE COURSE
2 Comments
Oldest
Newest
Inline Feedbacks
View all comments
MrjR Ellent
MrjR Ellent
4 months ago

Regarding making the ring buffer thread safe – what about the memory visibility of the object in the buffer ? If we create a new object every time or a constant string as you do in your test code on github it works of course but if we use preallocated objects this doesn’t work even in single consumer producer case right ?

Loredana Crusoveanu
1 month ago
Reply to  MrjR Ellent

Hello,
Our use of fixed length or predefined buffer in the implementation makes it pre-allocated by default which works as expected for the producer-consumer case. Memory visibility of the buffer object is ensured by the volatile keyword.
Cheers!

Comments are closed on this article!