Guide contents

Lesson 60 of 71

Agrona, Aeron, SBE, and Chronicle

Low-latency Java systems are usually built on a small set of open-source libraries rather than on java.util.concurrent. This lesson runs the Agrona building blocks (buffers, ring buffers, idle strategies, agents) and describes the libraries built on them: Aeron for messaging, SBE for encoding, Chronicle for persisted queues and maps, and Netty for TCP. After it, a reader can explain what each library is for and read code that uses them.

Library Purpose In this lesson
Agrona Buffers, ring buffers, idle strategies, agents, primitive collections Runnable
Aeron Messaging over UDP, IPC, with an archive Concepts
SBE Schema-generated binary flyweights Concepts
Chronicle Queue and Map Persisted memory-mapped log and key-value store Concepts
Netty Event-loop TCP and UDP networking Concepts

Only Agrona 2.3.0 is on this course's class path. On JDK 25 its UnsafeBuffer and ring buffers need --add-exports=java.base/jdk.internal.misc=ALL-UNNAMED, which every runnable example passes with a jvm= flag. Code for the other libraries is marked java,ignore because their jars are not available here.

Example 1: UnsafeBuffer over heap and direct memory

UnsafeBuffer is Agrona's fixed-size buffer with absolute-offset get and put methods, like the flyweight in Allocation-Free Code. It can wrap a byte[], a direct ByteBuffer, or a raw address, and unlike ByteBuffer it has no position or limit.

Java
import java.nio.ByteBuffer;
import org.agrona.ExpandableArrayBuffer;
import org.agrona.concurrent.UnsafeBuffer;

public class Buffers {
    public static void main(String[] args) {
        UnsafeBuffer heap = new UnsafeBuffer(new byte[64]);
        heap.putLong(0, 42L);
        heap.putInt(8, 7);
        int len = heap.putStringAscii(12, "EURUSD");   // 4-byte length + bytes
        System.out.println("long=" + heap.getLong(0) + " int=" + heap.getInt(8));
        System.out.println("string=" + heap.getStringAscii(12) + " bytes written=" + len);

        UnsafeBuffer direct = new UnsafeBuffer(ByteBuffer.allocateDirect(64));
        direct.putBytes(0, heap, 0, 22);               // copy between buffers
        System.out.println("copied string=" + direct.getStringAscii(12));

        ExpandableArrayBuffer grow = new ExpandableArrayBuffer(16);
        grow.putLong(100, 9L);                         // grows past capacity
        System.out.println("expandable capacity >= 108: " + (grow.capacity() >= 108));
    }
}
Output
long=42 int=7
string=EURUSD bytes written=10
copied string=EURUSD
expandable capacity >= 108: true

putStringAscii writes a 4-byte length followed by the characters, so "EURUSD" takes 10 bytes. ExpandableArrayBuffer grows on writes past its end, which allocates; it is used for building messages of unknown size, not on a steady-state hot path. UnsafeBuffer checks bounds by default; the system property agrona.disable.bounds.checks=true turns the checks off.

Example 2: a one-to-one ring buffer

OneToOneRingBuffer is a single-producer, single-consumer queue of variable-length messages stored in one UnsafeBuffer. Its capacity is a power of two, and a trailer of counters (head, tail, and others) follows the data area. Each record has a header with its length and a message type id, which tells the reader how to decode the bytes.

Java
import java.nio.ByteBuffer;
import org.agrona.concurrent.UnsafeBuffer;
import org.agrona.concurrent.ringbuffer.OneToOneRingBuffer;
import org.agrona.concurrent.ringbuffer.RecordDescriptor;
import org.agrona.concurrent.ringbuffer.RingBufferDescriptor;

public class OneToOne {
    public static void main(String[] args) {
        int capacity = 1024;
        UnsafeBuffer storage = new UnsafeBuffer(
                ByteBuffer.allocateDirect(capacity + RingBufferDescriptor.TRAILER_LENGTH));
        OneToOneRingBuffer ring = new OneToOneRingBuffer(storage);

        System.out.println("trailer length: " + RingBufferDescriptor.TRAILER_LENGTH);
        System.out.println("record header: " + RecordDescriptor.HEADER_LENGTH
                + ", alignment: " + RecordDescriptor.ALIGNMENT);
        System.out.println("capacity: " + ring.capacity() + ", max message: " + ring.maxMsgLength());

        UnsafeBuffer msg = new UnsafeBuffer(new byte[16]);
        for (int i = 1; i <= 3; i++) {
            msg.putLong(0, i * 100L);
            ring.write(1, msg, 0, 8);                  // type id 1, 8 bytes
        }
        System.out.println("bytes used: " + ring.size());

        int read = ring.read((type, buf, index, length) ->
                System.out.println("type=" + type + " len=" + length + " value=" + buf.getLong(index)));
        System.out.println("messages read: " + read + ", bytes used: " + ring.size());
    }
}
Output
trailer length: 768
record header: 8, alignment: 8
capacity: 1024, max message: 128
bytes used: 48
type=1 len=8 value=100
type=1 len=8 value=200
type=1 len=8 value=300
messages read: 3, bytes used: 0

The maximum message length of a OneToOneRingBuffer is one eighth of the capacity (128 of 1,024 here). An 8-byte message takes 16 bytes in the ring: the 8-byte record header plus the payload, aligned to 8. write returns false when the ring is full, so the producer decides what to do (retry, drop, or apply back pressure). read calls the handler for each available message and returns the count. The handler receives the ring's own buffer and an offset, so reading is a flyweight access with no copy.

Example 3: zero-copy writes with tryClaim

write copies from a source buffer. tryClaim reserves space in the ring and returns the offset, so the producer encodes directly into the ring and then calls commit. If encoding fails, abort turns the record into padding that the reader skips.

Java
import java.nio.ByteBuffer;
import org.agrona.concurrent.UnsafeBuffer;
import org.agrona.concurrent.ringbuffer.OneToOneRingBuffer;
import org.agrona.concurrent.ringbuffer.RingBufferDescriptor;

public class ClaimCommit {
    public static void main(String[] args) {
        OneToOneRingBuffer ring = new OneToOneRingBuffer(new UnsafeBuffer(
                ByteBuffer.allocateDirect(1024 + RingBufferDescriptor.TRAILER_LENGTH)));

        for (int i = 0; i < 3; i++) {
            int index = ring.tryClaim(2, 12);          // type id 2, 12 bytes
            if (index < 0) throw new IllegalStateException("ring full");
            UnsafeBuffer b = (UnsafeBuffer) ring.buffer();
            b.putLong(index, 5_000L + i);              // price
            b.putInt(index + 8, i == 1 ? -1 : 10);     // quantity
            if (b.getInt(index + 8) < 0) ring.abort(index);   // invalid, discard
            else ring.commit(index);
        }

        int[] seen = {0};
        ring.read((type, buf, index, length) -> {
            seen[0]++;
            System.out.println("price=" + buf.getLong(index) + " qty=" + buf.getInt(index + 8));
        });
        System.out.println("delivered: " + seen[0]);
    }
}
Output
price=5000 qty=10
price=5002 qty=10
delivered: 2

The aborted record never reaches the handler. Between tryClaim and commit the consumer cannot see the record, because tryClaim stores a negative record length, the reader stops at a record whose length is not positive, and commit stores the positive length with a release store.

Example 4: many producers, one consumer

ManyToOneRingBuffer lets several threads write concurrently; producers claim space with a CAS on the tail counter, as in the MPSC queues of Ring Buffers and Lock-Free Queues. The consumer side stays single-threaded. The example prints totals after joining the producers, so the output does not depend on interleaving.

Java
import java.nio.ByteBuffer;
import org.agrona.concurrent.UnsafeBuffer;
import org.agrona.concurrent.ringbuffer.ManyToOneRingBuffer;
import org.agrona.concurrent.ringbuffer.RingBufferDescriptor;

public class ManyToOne {
    public static void main(String[] args) throws InterruptedException {
        ManyToOneRingBuffer ring = new ManyToOneRingBuffer(new UnsafeBuffer(
                ByteBuffer.allocateDirect(64 * 1024 + RingBufferDescriptor.TRAILER_LENGTH)));
        int producers = 3, perProducer = 100_000;
        long[] sums = new long[producers + 1];         // index = type id
        long[] counts = new long[producers + 1];

        Thread[] threads = new Thread[producers];
        for (int p = 0; p < producers; p++) {
            int typeId = p + 1;
            threads[p] = new Thread(() -> {
                UnsafeBuffer msg = new UnsafeBuffer(new byte[8]);
                for (int i = 1; i <= perProducer; i++) {
                    msg.putLong(0, i);
                    while (!ring.write(typeId, msg, 0, 8)) Thread.onSpinWait();
                }
            });
            threads[p].start();
        }

        long total = (long) producers * perProducer, received = 0;
        while (received < total) {
            int n = ring.read((type, buf, index, length) -> {
                sums[type] += buf.getLong(index);
                counts[type]++;
            });
            received += n;
            if (n == 0) Thread.onSpinWait();
        }
        for (Thread t : threads) t.join();

        for (int t = 1; t <= producers; t++)
            System.out.println("producer " + t + ": count=" + counts[t] + " sum=" + sums[t]);
    }
}
Output
producer 1: count=100000 sum=5000050000
producer 2: count=100000 sum=5000050000
producer 3: count=100000 sum=5000050000

Each producer's messages arrive in the order that producer wrote them, but messages from different producers interleave in an order that changes between runs. Per-producer totals are the same on every run.

Example 5: idle strategies

An idle strategy decides what a polling thread does when a poll found no work. idle(workCount) does nothing when workCount > 0 and otherwise idles in the strategy's way. The table lists the strategies in Agrona; BackoffIdleStrategy moves from spinning to yielding to parking with a growing park time, then reset returns it to spinning.

Strategy When idle Latency CPU when idle
NoOpIdleStrategy Returns at once Lowest 100%
BusySpinIdleStrategy Thread.onSpinWait() Lowest 100%
YieldingIdleStrategy Thread.yield() Low High
BackoffIdleStrategy Spin, then yield, then park with growing period Low at first Falls over time
SleepingIdleStrategy LockSupport.parkNanos(period) Period plus scheduler delay Low
Java
import org.agrona.concurrent.BackoffIdleStrategy;
import org.agrona.concurrent.BusySpinIdleStrategy;
import org.agrona.concurrent.IdleStrategy;
import org.agrona.concurrent.SleepingIdleStrategy;

public class Idling {
    public static void main(String[] args) {
        System.out.println("backoff max spins: " + BackoffIdleStrategy.DEFAULT_MAX_SPINS);
        System.out.println("backoff max yields: " + BackoffIdleStrategy.DEFAULT_MAX_YIELDS);
        System.out.println("backoff min park ns: " + BackoffIdleStrategy.DEFAULT_MIN_PARK_PERIOD_NS);
        System.out.println("backoff max park ns: " + BackoffIdleStrategy.DEFAULT_MAX_PARK_PERIOD_NS);
        System.out.println("sleeping period ns: " + SleepingIdleStrategy.DEFAULT_SLEEP_PERIOD_NS);

        IdleStrategy[] all = { new BusySpinIdleStrategy(), new BackoffIdleStrategy(), new SleepingIdleStrategy() };
        for (IdleStrategy s : all) {
            int polls = 0, work = 0;
            for (int i = 0; i < 1_000; i++) {           // a poll loop that finds work every 10th time
                int found = (i % 10 == 0) ? 1 : 0;
                work += found;
                polls++;
                s.idle(found);
            }
            System.out.println(s.alias() + ": polls=" + polls + " work=" + work);
        }
    }
}
Output
backoff max spins: 10
backoff max yields: 5
backoff min park ns: 1000
backoff max park ns: 1000000
sleeping period ns: 1000
spin: polls=1000 work=100
backoff: polls=1000 work=100
sleep-ns: polls=1000 work=100

The constants come from the Agrona 2.3.0 jar on the class path. The loop shows the calling pattern only; the effect of each strategy is in latency and CPU use, which are measured with the tools from Measuring Latency, not printed here.

Example 6: agents and AgentRunner

An Agent is a unit of work with a doWork() method that returns how much work it did. An AgentRunner calls doWork() in a loop on its own thread and passes the result to an idle strategy. This is the duty-cycle model that Aeron's media driver and clients use: one thread polls, does a bounded amount of work, and idles when there is none.

Java
import java.nio.ByteBuffer;
import org.agrona.concurrent.Agent;
import org.agrona.concurrent.AgentRunner;
import org.agrona.concurrent.BackoffIdleStrategy;
import org.agrona.concurrent.UnsafeBuffer;
import org.agrona.concurrent.ringbuffer.OneToOneRingBuffer;
import org.agrona.concurrent.ringbuffer.RingBufferDescriptor;

public class Agents {
    static final class Consumer implements Agent {
        final OneToOneRingBuffer ring;
        volatile long count, sum;
        Consumer(OneToOneRingBuffer ring) { this.ring = ring; }
        public int doWork() {
            return ring.read((type, buf, index, length) -> {
                sum += buf.getLong(index);
                count++;                                // single writer: this thread
            }, 64);                                     // at most 64 messages per cycle
        }
        public String roleName() { return "consumer"; }
    }

    public static void main(String[] args) {
        OneToOneRingBuffer ring = new OneToOneRingBuffer(new UnsafeBuffer(
                ByteBuffer.allocateDirect(4096 + RingBufferDescriptor.TRAILER_LENGTH)));
        Consumer consumer = new Consumer(ring);
        AgentRunner runner = new AgentRunner(new BackoffIdleStrategy(), Throwable::printStackTrace, null, consumer);
        AgentRunner.startOnThread(runner);

        UnsafeBuffer msg = new UnsafeBuffer(new byte[8]);
        int n = 200_000;
        for (int i = 1; i <= n; i++) {
            msg.putLong(0, i);
            while (!ring.write(1, msg, 0, 8)) Thread.onSpinWait();
        }
        while (consumer.count < n) Thread.onSpinWait();
        runner.close();                                 // stops the loop and joins the thread

        System.out.println("count=" + consumer.count + " sum=" + consumer.sum);
        System.out.println("expected sum=" + (long) n * (n + 1) / 2);
        System.out.println("closed: " + runner.isClosed());
    }
}
Output
count=200000 sum=20000100000
expected sum=20000100000
closed: true

The limit of 64 messages per doWork call keeps one cycle short, so an agent that has several duties (read input, send output, check timers) does each of them often. An AgentRunner thread can be pinned to a core with an affinity library, as described in Allocation-Free Code.

Example 7: an allocation-free pipeline

The last runnable example combines the pieces with the allocation meter from the previous lesson: a producer encodes orders with tryClaim, a consumer decodes them in place, and both run on one thread so the measured bytes belong to the whole pipeline.

Java
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
import org.agrona.MutableDirectBuffer;
import org.agrona.collections.Long2LongHashMap;
import org.agrona.concurrent.MessageHandler;
import org.agrona.concurrent.UnsafeBuffer;
import org.agrona.concurrent.ringbuffer.OneToOneRingBuffer;
import org.agrona.concurrent.ringbuffer.RingBufferDescriptor;

public class Pipeline implements MessageHandler {
    static final com.sun.management.ThreadMXBean MX =
            (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
    static long allocated() { return MX.getThreadAllocatedBytes(Thread.currentThread().threadId()); }

    static final int ORDER = 1, LEN = 20;               // account 8, price 8, qty 4
    final OneToOneRingBuffer ring = new OneToOneRingBuffer(new UnsafeBuffer(
            ByteBuffer.allocateDirect(64 * 1024 + RingBufferDescriptor.TRAILER_LENGTH)));
    final Long2LongHashMap exposure = new Long2LongHashMap(64, 0.65f, 0L);   // missing value 0

    void send(long account, long price, int qty) {
        int i = ring.tryClaim(ORDER, LEN);
        MutableDirectBuffer b = ring.buffer();
        b.putLong(i, account);
        b.putLong(i + 8, price);
        b.putInt(i + 16, qty);
        ring.commit(i);
    }

    public void onMessage(int type, MutableDirectBuffer b, int i, int length) {
        long account = b.getLong(i);
        exposure.put(account, exposure.get(account) + b.getLong(i + 8) * b.getInt(i + 16));
    }

    void cycle(int n) {
        for (int k = 0; k < n; k++) {
            send(k & 15, 100, 1);
            if ((k & 255) == 255) ring.read(this);       // drain every 256 orders
        }
        ring.read(this);
    }

    public static void main(String[] args) {
        Pipeline p = new Pipeline();
        for (int w = 0; w < 50; w++) p.cycle(100_000);
        p.exposure.clear();

        int n = 1_000_000;
        long before = allocated();
        p.cycle(n);
        long bytes = allocated() - before;

        System.out.println("orders: " + n);
        System.out.println("bytes/order: " + Math.round((double) bytes / n));
        System.out.println("exposure of account 3: " + p.exposure.get(3));
    }
}
Output
orders: 1000000
bytes/order: 0
exposure of account 3: 6250000

Long2LongHashMap is Agrona's open-addressing primitive map; the third constructor argument is the value returned for a missing key. The handler is the Pipeline object itself, not a lambda that captures state, so no object is created per read call.

Aeron: messaging

Aeron is a messaging library for unicast and multicast UDP and for shared-memory IPC between processes on one host. Its parts:

Concept Meaning
Media driver A process (or embedded thread) that owns the network sockets and shared-memory log buffers; clients talk to it through memory-mapped files
Publication A sender on a channel and stream id; offer copies a message into the log buffer, tryClaim encodes in place
Subscription A receiver; poll(handler, limit) delivers fragments, the same duty-cycle shape as ring.read
Channel A URI such as aeron:udp?endpoint=host:port or aeron:ipc
Back pressure offer returns a negative code such as BACK_PRESSURED or NOT_CONNECTED instead of blocking
Archive Records streams to disk and replays them from a position
Cluster Raft-based replicated state machine built on Aeron and the archive

IPC channels avoid the network stack and suit process-to-process hops on one host; UDP channels cross machines and handle loss with NAK-based retransmission. The sketch below shows the shape of the API; it is not compiled because Aeron is not on the class path.

Java
// Not compiled: Aeron is not on the checker's class path.
try (MediaDriver driver = MediaDriver.launchEmbedded();
     Aeron aeron = Aeron.connect(new Aeron.Context().aeronDirectoryName(driver.aeronDirectoryName()));
     Publication pub = aeron.addPublication("aeron:ipc", 10);
     Subscription sub = aeron.addSubscription("aeron:ipc", 10)) {
    long result = pub.offer(buffer, 0, length);      // negative = not sent
    int fragments = sub.poll((buf, offset, len, header) -> handle(buf, offset, len), 10);
}

SBE: Simple Binary Encoding

SBE is a FIX Trading Community standard for binary message encoding, and real-logic/simple-binary-encoding is its Java implementation. The workflow:

  1. Write an XML schema with message types, fixed-length fields, repeating groups, and variable-length data at the end.
  2. Run the SBE tool at build time; it generates encoder and decoder flyweight classes.
  3. On the hot path, wrap an encoder over a MutableDirectBuffer at an offset (for example a ring-buffer claim) and set fields; on the receiving side, wrap a decoder and read fields in schema order.

Generated flyweights read fields at fixed offsets, as in Example 4 of Allocation-Free Code, so encoding and decoding allocate nothing. A message header carries the template id, schema id, and version, which lets a decoder reject or skip unknown messages. Binary Protocols covers the encoding ideas.

Chronicle Queue and Chronicle Map

Chronicle Queue (OpenHFT) is a persisted append-only log stored in memory-mapped files, rolled into one file per cycle (for example per day). A writer appends through an appender; readers use tailers that keep their own index, so many readers in many processes read the same queue at their own pace. Because the data is in the page cache, a write is visible to readers on the same host without a network hop, and the files serve as a journal for replay and audit.

Chronicle Map is an off-heap key-value store, also backed by memory-mapped files, that several processes on one host can share. Keys and values are serialized into off-heap memory, which keeps large maps out of the GC heap (ByteBuffer and Memory-Mapped Files).

Netty for low-latency TCP

Netty is an event-loop networking framework built on NIO selectors (NIO Selectors). On Linux it has native transports (epoll, io_uring) that avoid parts of the JDK selector, pooled direct ByteBuf buffers with reference counting, and a pipeline of handlers per channel. Low-latency settings include TCP_NODELAY to turn off Nagle's algorithm, pooled allocators, and a small number of event-loop threads. Netty fits request-response TCP protocols such as FIX gateways and exchange connections; Aeron fits streams between internal services.

What each library is for

Need Library
Off-heap and on-heap buffers with absolute offsets Agrona UnsafeBuffer
In-process queue between threads, variable-length messages Agrona ring buffers, or the Disruptor for fixed event objects
Polling threads with configurable idling Agrona Agent, AgentRunner, idle strategies
Primitive maps and sets Agrona collections, fastutil
Messages between processes or machines Aeron (IPC, UDP)
Recording and replaying message streams Aeron Archive, Chronicle Queue
Replicated state machine Aeron Cluster
Binary message encoding with flyweights SBE
Persisted journal shared by processes on one host Chronicle Queue
Off-heap shared map Chronicle Map
TCP gateways, request-response protocols Netty
Pinning threads to cores OpenHFT Affinity

Interview points

  • Agrona supplies the building blocks of Aeron: UnsafeBuffer, ring buffers, idle strategies, agents, and primitive collections.
  • OneToOneRingBuffer is SPSC and ManyToOneRingBuffer is MPSC; both store variable-length records with an 8-byte header in one buffer with a trailer of counters.
  • tryClaim, commit, and abort encode in place without a copy; write returning false means full, and the caller chooses the back-pressure policy.
  • An idle strategy trades CPU for wake-up latency; backoff spins, then yields, then parks.
  • The agent duty cycle does bounded work per call so one thread can serve several duties.
  • Aeron separates the media driver from clients; publications return negative codes for back pressure instead of blocking.
  • SBE generates flyweights from an XML schema; fields are read at fixed offsets without allocation.
  • Chronicle Queue is a memory-mapped persisted log with independent tailers; Chronicle Map is an off-heap shared map.
  • Netty is the usual choice for TCP gateways; Aeron for internal streams.

Summary

  • On JDK 25, Agrona's UnsafeBuffer needs --add-exports=java.base/jdk.internal.misc=ALL-UNNAMED.
  • UnsafeBuffer wraps arrays, direct buffers, or addresses, with absolute offsets and bounds checks by default.
  • Ring-buffer storage is a power-of-two capacity plus RingBufferDescriptor.TRAILER_LENGTH.
  • read(handler, limit) delivers messages as offsets into the ring's own buffer.
  • AgentRunner runs doWork() in a loop and passes the work count to an idle strategy.
  • A tryClaim-based pipeline with a primitive map runs at 0 bytes per order after warmup.
  • Aeron, SBE, Chronicle, and Netty are described here; they are not on this course's class path.