Read OSS

RxJava 4.x: Streamable, Virtual Threads, and the Future of Reactive Java

Advanced

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 inference
  • AutoCloseable: 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: Streamable shines 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-based Flowable forces you to think in callbacks. If your workload is CPU-bound computation on streams, Flowable with 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.

FlowableDocBasic.java#L24:

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.