RxJava 4.x: Streamable, Virtual Threads, and the Future of Reactive Java
Prerequisites
- ›Article 2: subscribe-chain-and-operator-anatomy
- ›Article 4: scheduler-system-and-threading-model
- ›Article 5: backpressure-from-theory-to-implementation
- ›Java 21+ virtual threads and structured concurrency concepts
RxJava 4.x: Streamable, Virtual Threads, and the Future of Reactive Java
RxJava 4.x isn't just a version bump — it's a rethinking of what reactive programming means in a world where threads are cheap. The Java 26 baseline unlocks virtual threads, which eliminate the fundamental motivation behind reactive's non-blocking model: conserving scarce OS threads. Rather than abandoning reactive, RxJava 4.x embraces both paradigms: the existing push-based Flowable for high-throughput pipelines, and a new pull-based Streamable for imperative code that can block freely on virtual threads.
What Changed in 4.x: Java 26 Baseline and Flow Publisher
The most visible change is the elimination of the external Reactive Streams dependency. In RxJava 3.x, Flowable implemented org.reactivestreams.Publisher. In 4.x, it implements java.util.concurrent.Flow.Publisher directly:
public abstract non-sealed class Flowable<@NonNull T> implements Publisher<T>,
FlowableDocBasic<T> {
From Flowable.java#L159-L161. The Publisher here is java.util.concurrent.Flow.Publisher, part of the JDK since Java 9. This means zero runtime dependencies.
The non-sealed keyword is new — Flowable now permits extension via the sealed interface FlowableDocBasic. The module-info.java requires only java.management (for ThreadMXBean access in scheduler pool management).
Other Java modernizations throughout the codebase:
- Records:
FlatMapConfig,Streamer.HiddenStreamer,Streamer.StreamerFinishViaDisposableContainerCanceller - Sealed interfaces:
FlowableDocBasic sealed permits Flowable - Pattern matching:
if (crash instanceof RuntimeException ex)in Streamable var: Used liberally in 4.x code for local type inferenceAutoCloseable:Disposable extends AutoCloseable,Streamer extends AutoCloseable
Streamable<T>: The Async Enumerable Pattern
Streamable at Streamable.java#L35-L48 is described in its Javadoc as "the holographically emergent IAsyncEnumerable of the Java world." It's RxJava's answer to C#'s IAsyncEnumerable<T> — a pull-based async iteration pattern designed for virtual threads.
public interface Streamable<@NonNull T> {
Streamer<T> stream(@NonNull DisposableContainer cancellation);
default Streamer<T> stream() {
return stream(new CompositeDisposable());
}
}
Where Flowable pushes items to a Subscriber, Streamable creates a Streamer that the consumer pulls from. This inversion fundamentally changes the programming model.
The Streamer interface at Streamer.java#L34-L64 defines three core operations:
public interface Streamer<@NonNull T> extends AutoCloseable {
CompletionStage<Boolean> next(@NonNull DisposableContainer cancellation);
T current();
CompletionStage<Void> finish(@NonNull DisposableContainer cancellation);
}
The protocol is: call next(), which returns a CompletionStage<Boolean>. If it completes with true, call current() to get the value. If false, the stream is done. Call finish() for cleanup.
sequenceDiagram
participant Consumer
participant Streamer
participant Source
Consumer->>Streamer: next(cancellation)
Streamer->>Source: Pull next item
Source-->>Streamer: Item available
Streamer-->>Consumer: CompletionStage → true
Consumer->>Streamer: current()
Streamer-->>Consumer: item value
Consumer->>Streamer: next(cancellation)
Streamer->>Source: Pull next item
Source-->>Streamer: Source exhausted
Streamer-->>Consumer: CompletionStage → false
Consumer->>Streamer: finish(cancellation)
Streamer-->>Consumer: CompletionStage → void
But the real power shows in the convenience methods. The awaitNext() method at Streamer.java#L169-L171 blocks the virtual thread until the next item is available:
default boolean awaitNext() {
return await(next());
}
And the await static method provides the async/await bridge:
static <T> T await(CompletionStage<T> stage, DisposableContainer canceller) {
var f = stage.toCompletableFuture();
var d = Disposable.fromFuture(f, true);
try (var _ = canceller.subscribe(d)) {
return f.join(); // Blocks the virtual thread, not the carrier
}
}
The f.join() call blocks, but on a virtual thread this is essentially free — the carrier thread is released to run other virtual threads while waiting. This is what makes Streamable practical: you write imperative blocking code, and the runtime handles concurrency.
Tip:
Streamableshines when your processing logic is inherently sequential and imperative — reading a file line by line, iterating database results with cursor-based pagination, or any use case where push-basedFlowableforces you to think in callbacks. If your workload is CPU-bound computation on streams,Flowablewith operator fusion will be faster.
VirtualGenerator and VirtualEmitter: Imperative Meets Reactive
The bridge between imperative code and reactive streams is VirtualGenerator. At VirtualGenerator.java#L25-L33:
@FunctionalInterface
public interface VirtualGenerator<T> {
void generate(VirtualEmitter<T> emitter) throws Throwable;
}
And VirtualEmitter.java#L23-L37:
public interface VirtualEmitter<T> {
void emit(T item) throws Throwable;
DisposableContainer canceller();
}
Usage is remarkably simple — write a blocking loop:
Flowable<String> lines = Flowable.virtualCreate(emitter -> {
try (var reader = new BufferedReader(new FileReader("data.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
emitter.emit(line); // Blocks if downstream isn't ready
}
}
});
The emit() call is the key innovation. It blocks the virtual thread until the downstream subscriber has requested capacity. This provides natural backpressure without the programmer needing to think about request(n) protocol — the blocking call is the backpressure mechanism.
flowchart LR
subgraph VirtualThread ["Virtual Thread"]
GEN[VirtualGenerator] --> EMIT[emitter.emit item]
EMIT -->|"blocks until demand"| EMIT
end
subgraph ReactiveWorld ["Reactive Pipeline"]
FV[FlowableVirtualCreateExecutor] --> OPS[.map .filter .observeOn]
OPS --> SUB[Subscriber]
end
EMIT -->|"bridges to"| FV
SUB -->|"request n"| FV
FV -->|"unblocks emit"| EMIT
Streamable.create() at Streamable.java#L131-L135 connects the same VirtualGenerator to the pull-based model:
static <T> Streamable<T> create(VirtualGenerator<T> generator) {
return Flowable.virtualCreate(generator).toStreamable();
}
Currently this bridges through Flowable — the // FIXME native implementation comment suggests a direct implementation is planned.
Modern Java Features in the API
Sealed Interfaces for Documentation Splitting
The Flowable class is over 21,000 lines long. To manage this, 4.x uses sealed interfaces to split documentation across files while keeping the API on a single class.
public sealed interface FlowableDocBasic<T> permits Flowable {
This interface declares methods like map() and filter() with their full Javadoc. Flowable implements FlowableDocBasic and provides the actual implementations. The sealed permits Flowable constraint ensures no other class can implement the interface — it's purely a code organization technique, not an extension point.
classDiagram
class FlowableDocBasic~T~ {
<<sealed>>
+map(Function): Flowable
+filter(Predicate): Flowable
Full Javadoc here
}
class Flowable~T~ {
<<non-sealed>>
Implements all methods
21000+ lines
}
FlowableDocBasic <|.. Flowable : permits
Records for Configuration
FlatMapConfig uses a Java record to bundle operator configuration:
public record FlatMapConfig(boolean delayErrors, int maxConcurrency, int bufferSize) {
public FlatMapConfig() {
this(false, Flowable.bufferSize(), Flowable.bufferSize());
}
}
Multiple constructors provide convenience overloads — from default configuration to full customization. The record's compact constructor validates parameters:
public FlatMapConfig(boolean delayErrors, int maxConcurrency, int bufferSize) {
ObjectHelper.verifyPositive(maxConcurrency, "maxConcurrency");
ObjectHelper.verifyPositive(bufferSize, "bufferSize");
// ...
}
Disposable extends AutoCloseable
From Disposable.java#L28-L46:
public interface Disposable extends AutoCloseable {
void dispose();
boolean isDisposed();
default void close() {
dispose();
}
}
This enables try-with-resources for subscription management — particularly valuable with Streamable where the Streamer lifecycle is pull-based and explicit:
try (var streamer = streamable.stream()) {
while (streamer.awaitNext()) {
process(streamer.current());
}
} // finish() called automatically via close()
The Convergence of Push and Pull
RxJava 4.x positions itself at the intersection of two reactive programming paradigms:
-
Push-based (
Flowable): The producer drives the pace, with backpressure as the safety valve. Optimal for high-throughput pipelines where operator fusion and lock-free queues matter. -
Pull-based (
Streamable): The consumer drives the pace, blocking virtual threads naturally. Optimal for imperative code that needs to interact with async sources.
The bridge methods — Streamable.toFlowable() and Flowable.toStreamable() — allow seamless conversion between the two models. This means you can start with imperative pull-based code on virtual threads and drop into the optimized push-based pipeline when you need the performance of operator fusion and drain loops.
This dual model is RxJava 4.x's answer to the question: "Do we still need reactive libraries when virtual threads exist?" The answer is nuanced — virtual threads solve the thread scarcity problem, but they don't solve the composition, error handling, and stream processing problems that RxJava's operator library addresses. The two models are complementary, not competing.
What's Next
We've explored the entire RxJava 4.x feature set — from the subscribe chain through drain loops, schedulers, backpressure, and the new virtual thread integration. In Part 7, the final article, we'll examine how the project keeps all of this honest: the testing infrastructure that validates 21,000-line classes, the 24 meta-tests that enforce codebase conventions, TestScheduler for deterministic time testing, and the CI pipeline that catches problems before they merge.