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.
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));
}
}long=42 int=7
string=EURUSD bytes written=10
copied string=EURUSD
expandable capacity >= 108: trueputStringAscii 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.
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());
}
}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: 0The 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.
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]);
}
}price=5000 qty=10
price=5002 qty=10
delivered: 2The 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.
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]);
}
}producer 1: count=100000 sum=5000050000
producer 2: count=100000 sum=5000050000
producer 3: count=100000 sum=5000050000Each 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 |
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);
}
}
}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=100The 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.
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());
}
}count=200000 sum=20000100000
expected sum=20000100000
closed: trueThe 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.
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));
}
}orders: 1000000
bytes/order: 0
exposure of account 3: 6250000Long2LongHashMap 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.
// 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:
- Write an XML schema with message types, fixed-length fields, repeating groups, and variable-length data at the end.
- Run the SBE tool at build time; it generates encoder and decoder flyweight classes.
- On the hot path,
wrapan encoder over aMutableDirectBufferat an offset (for example a ring-buffer claim) and set fields; on the receiving side,wrapa 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. OneToOneRingBufferis SPSC andManyToOneRingBufferis MPSC; both store variable-length records with an 8-byte header in one buffer with a trailer of counters.tryClaim,commit, andabortencode in place without a copy;writereturningfalsemeans 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
UnsafeBufferneeds--add-exports=java.base/jdk.internal.misc=ALL-UNNAMED. UnsafeBufferwraps 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.AgentRunnerrunsdoWork()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.
