Revision note (2026-09-15). The earlier version of this article said publishing was non-blocking while its code used ringBuffer.next(), which waits when the ring buffer is full. Its benchmark timed only the publish loop, so the number said nothing about when log lines reached the output. It also omitted that Log4j 2's asynchronous loggers are already built on the Disruptor. This version is rebuilt around tests that reproduce each behavior, pinned to Disruptor 4.0.0.
Read this first: you probably do not need to build it
Log4j 2's asynchronous loggers use the LMAX Disruptor internally. If your goal is "logging should not stall my request threads", add the Disruptor jar, set log4j2.contextSelector to the async selector, and you get a Disruptor-backed logger with configurable queue-full policy and years of production use. Logback's AsyncAppender uses a BlockingQueue instead, with a neverBlock option and level-based discarding.
Build your own when you have a reason those frameworks cannot satisfy: a fixed message schema you want to serialize without formatting strings, a custom sink such as a shared-memory segment, or a hard requirement to control exactly what happens on overflow. The rest of this article is that build, with its failure modes measured, so you can judge whether the reasons are strong enough.
Tested versions:
| Component | Version |
|---|---|
| com.lmax:disruptor | 4.0.0 (current release; the 3.4.x line ended at 3.4.4) |
| Java | 17 (Amazon Corretto 17.0.14) |
| Build | Gradle 8.8, JUnit 5.10 |
Disruptor 4.0.0 requires Java 11 or newer and removed the Executor-based constructors; the ThreadFactory constructors used here exist in both 3.4.x and 4.0.0.
The core: a pre-allocated slot and one consumer
The ring buffer holds mutable LogEvent objects that are created once and reused forever. The producer claims a slot, overwrites the fields, and publishes. The consumer formats and writes, then clears the references so a slot never keeps a large message alive.
public final class LogEvent {
public enum Level { TRACE, DEBUG, INFO, WARN, ERROR }
private Level level;
private String message;
private String threadName;
private long epochMillis;
public void set(Level level, String message) {
this.level = level;
this.message = message;
this.threadName = Thread.currentThread().getName();
this.epochMillis = System.currentTimeMillis();
}
public void clear() {
this.level = null;
this.message = null;
this.threadName = null;
this.epochMillis = 0L;
}
// getters omitted
}
The logger wires a Disruptor with a multi-producer sequencer, a blocking wait strategy for the consumer, and two overflow policies.
public enum OverflowPolicy { BLOCK, DROP }
public final class AsyncLogger implements AutoCloseable {
private final Disruptor<LogEvent> disruptor;
private final RingBuffer<LogEvent> ringBuffer;
private final OverflowPolicy overflowPolicy;
private final LongAdder dropped = new LongAdder();
private final CountDownLatch consumerStarted = new CountDownLatch(1);
public AsyncLogger(int bufferSize, LogSink sink, OverflowPolicy overflowPolicy, WaitStrategy waitStrategy) {
ThreadFactory threadFactory = runnable -> new Thread(runnable, "async-logger");
this.overflowPolicy = overflowPolicy;
this.disruptor = new Disruptor<>(LogEvent::new, bufferSize, threadFactory, ProducerType.MULTI, waitStrategy);
this.disruptor.setDefaultExceptionHandler(new ContinueOnErrorHandler());
this.disruptor.handleEventsWith(new SinkHandler(sink));
this.ringBuffer = disruptor.start();
awaitConsumerStart(); // explained below
}
public boolean log(LogEvent.Level level, String message) {
long sequence;
if (overflowPolicy == OverflowPolicy.DROP) {
try {
sequence = ringBuffer.tryNext();
} catch (InsufficientCapacityException e) {
dropped.increment();
return false;
}
} else {
sequence = ringBuffer.next(); // blocks while the buffer is full
}
try {
ringBuffer.get(sequence).set(level, message);
} finally {
ringBuffer.publish(sequence); // always publish, or every later slot waits on this one
}
return true;
}
public void shutdown(long timeout, TimeUnit unit) throws TimeoutException {
disruptor.shutdown(timeout, unit);
}
}
The finally around publish is not decoration. A claimed sequence that is never published blocks the consumer at that slot forever, because the consumer waits for sequences in order.
Behavior 1: next() blocks the caller when the buffer is full
Test nextBlocksTheCallerWhileTheRingBufferIsFull: ring size 4, a sink that parks the consumer on a latch inside write(). Four log() calls succeed and remainingCapacity() reads 0. The fifth log() call is submitted from another thread and does not return within 500 ms. Releasing the latch lets it complete; the measured block time is at least the 500 ms we waited.
Inside MultiProducerSequencer.next(int), the producer increments the cursor and then loops with LockSupport.parkNanos(1) while the wrap point is ahead of the slowest gating sequence. That loop is the "blocking" the earlier article denied. It is not a lock, but the calling thread makes no progress until the consumer frees a slot.
Behavior 2: tryNext() refuses instead of waiting
Test tryNextDropsImmediatelyWhenTheRingBufferIsFull: same setup with OverflowPolicy.DROP. The fifth call returns false in under 100 ms, the drop counter reads 1, and after the consumer is released only the four accepted events reach the sink. RingBuffer.tryNext() throws InsufficientCapacityException when no slot is free; the exception is a pre-allocated singleton with no stack trace, so the drop path is cheap.
What a dropped log line costs you depends entirely on what you were logging. The benchmark section shows how many lines the drop policy discards under sustained load.
Behavior 3: shutdown() drains, but only what it can see
Test shutdownDrainsEveryPublishedEventBeforeReturning: 50,000 events with ring size 1024, then shutdown(30, SECONDS). The sink counts 50,000 writes and at least one flush. Disruptor.shutdown(timeout, unit) busy-spins while hasBacklog() is true and then halts the consumer.
Now the failure case, test shutdownBeforeTheConsumerThreadStartsDiscardsEverythingSilently. Disruptor.start() only schedules the consumer thread. ConsumerRepository.hasBacklog() skips consumers whose isRunning() is false, and BatchEventProcessor.isRunning() is false until the thread reaches run(). So if shutdown() is called before the consumer thread has started, there is no visible backlog, shutdown() returns at once, and it halts the processor. When the thread finally runs, it finds the halted state and exits without processing anything.
The test makes this deterministic with a ThreadFactory that sleeps 300 ms before running the processor: three events are published, shutdown(5, SECONDS) returns in under 250 ms with no TimeoutException, and the processed count stays at 0. Three log lines are gone and nothing reported it.
This is why the constructor above ends with awaitConsumerStart(): the handler's onStart() callback counts down a latch and the constructor waits for it. An early version of this lab hit the race in a unit test that published four events and closed the logger immediately; the sink had written nothing.
Behavior 4: the default exception handler halts the consumer
Test defaultFatalExceptionHandlerHaltsTheConsumerForever: a raw Disruptor with ring size 4 whose handler throws on one message. The default FatalExceptionHandler logs and rethrows, BatchEventProcessor exits its loop, and the consumer is gone. The test then fills the ring: tryNext() throws InsufficientCapacityException and only the one event before the failure was ever processed. With next() instead of tryNext(), every producer thread would now block forever on a logger.
The logger above installs a handler that counts the failure and continues (sinkFailureIsCountedAndTheConsumerKeepsRunning: one poisoned message, three of four events still written). For logging, losing one line beats stopping the application.
Benchmark: what was measured, and what the numbers do not say
The earlier article timed a loop of one million log() calls and called the result the logging speed. That measures how fast the producer can hand off, not when the lines exist. This benchmark reports both boundaries:
- publish: the caller returns from the last
log()call. - drained: the consumer has written and flushed the last line, measured by
shutdown()returning and a final flush.
Every mode writes the same formatted line to the same kind of buffered temporary file on the consumer side, 200,000 events per repetition, 2 warm-up repetitions discarded, 5 measured, median reported. Environment: Java 17.0.14, macOS on aarch64, 11 CPUs, ring size 8192, BlockingWaitStrategy.
| Mode | publish ms (median) | drained ms (median) | publish ns/event | dropped per rep |
|---|---|---|---|---|
| sync-file (caller writes directly) | 59.5 | 59.5 | 298 | 0 |
| queue-file (ArrayBlockingQueue 8192 + consumer thread) | 82.4 | 84.1 | 412 | 0 |
| disruptor-block (next()) | 43.7 | 44.7 | 219 | 0 |
| disruptor-drop (tryNext()) | 4.3 | 5.6 | 22 | about 171,000 of 200,000 |
How to read this:
- The Disruptor hand-off is cheaper than the blocking queue here, and cheaper than the synchronous buffered write by roughly a quarter. With a fast local file as the sink, that is the whole gain.
- Drained time is almost equal to publish time in every asynchronous mode because a buffered file write keeps up with the producer. The asynchronous design buys you nothing when the sink is fast; it buys you isolation when the sink is slow, which this run does not exercise.
- The drop mode looks 10 times faster and it is a trap: it discarded about 85 percent of the events per repetition. A ring of 8192 fills in a few milliseconds when one thread publishes as fast as it can, and
tryNext()then refuses everything until the consumer catches up. Any "async logging is N times faster" claim must state its drop count. - The queue mode allocates a new event object per message because a queue cannot safely reuse slots; the Disruptor mode reuses pre-allocated slots. Part of the difference is allocation, not the ring algorithm.
Not measured: request latency percentiles under a real workload, behavior with a slow sink such as a network appender, durability across a crash, and GC pauses. A one-thread producer on a laptop is not a trading system. Treat the table as a check on the mechanism, not as capacity planning.
Ordering, durability, and what an async logger cannot promise
Within one ring buffer, events are consumed in sequence order, so a single-producer stream keeps its order. With several producer threads the order is the order in which they claimed sequences, which is not the order in which they called log() if a thread is descheduled between next() and publish().
Durability is bounded by the ring. Any line still in a slot when the process dies is lost. shutdown() drains only on a clean exit, and only if the consumer is running, as behavior 3 shows.
Alternatives, in the order to consider them
- Log4j 2 asynchronous loggers: Disruptor-based, mature, with the
AsyncQueueFullPolicyknob for overflow behavior. - Logback
AsyncAppender: queue-based, simpler, withneverBlockand level-based discarding. - A custom Disruptor logger like this one: when you control the schema and the sink and can live with the responsibilities above.
- Virtual threads (final in Java 21, not Java 19) help I/O-bound request handling; they do not change what happens when a single log sink cannot keep up.
Reproduce it
gradle test # 6 tests, all behaviors above
gradle run --args="200000 5 2" # benchmark: events, measured reps, warm-up reps
Sources
- LMAX Disruptor on GitHub
- Disruptor 4.0.0 RingBuffer API: next() and tryNext()
- Disruptor 4.0.0 sources on Maven Central (the
hasBacklogandisRunningbehavior in behavior 3 is read fromConsumerRepositoryandBatchEventProcessorin this jar) - Log4j 2: Asynchronous Loggers
- Logback: AsyncAppender
- JEP 444: Virtual Threads (final in JDK 21)
