POJO-actor v1.0: A Lightweight Actor Model Library for Java

Published: (December 22, 2025 at 02:51 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

Overview

The actor model is a programming paradigm where independent entities (actors) communicate solely through message passing, eliminating locks and the complexities of shared‑state concurrency.

Historically, actor frameworks relied on OS threads, limiting the number of actors to the number of CPU cores. With the introduction of virtual threads in Java 21, even a modest laptop can now host tens of thousands of actors simultaneously.

Project repository:

Architecture

POJO‑actor implements a simplified actor model using modern Java features. With only ~800 lines of code it delivers a practical, performant solution.

ComponentDescription
ActorSystemManages actor lifecycle and configurable work‑stealing thread pools
ActorRefReference to an actor exposing tell() (fire‑and‑forget) and ask() (request‑response) APIs
Virtual ThreadsEach actor runs on its own virtual thread for lightweight message handling
Work‑Stealing PoolsHeavy computations are delegated to configurable thread pools
Zero ReflectionBuilt entirely with standard JDK APIs → GraalVM Native Image ready

Quick Start

Maven Dependency

<com.scivicslab>
    <artifactId>POJO-actor</artifactId>
    <version>1.0.0</version>
</com.scivicslab>

Note: The library will be published to Maven Central soon. Until then, install it locally:

git clone https://github.com/scivicslab/POJO-actor
cd POJO-actor
./mvnw install

Basic Usage

import com.scivicslab.pojoactor.ActorSystem;
import com.scivicslab.pojoactor.ActorRef;
import java.util.concurrent.CompletableFuture;

// Define a simple POJO
class Counter {
    private int count = 0;

    void increment() { count++; }
    int getValue()   { return count; }
}

// Create an actor system (4 threads for CPU‑intensive tasks)
ActorSystem system = new ActorSystem("mySystem", 4);
ActorRef counter = system.actorOf("counter", new Counter());

// Send messages
counter.tell(c -> c.increment());                     // fire‑and‑forget
CompletableFuture<Integer> result = counter.ask(c -> c.getValue()); // request‑response

int value = result.get(); // → 1

system.terminate();

Any POJO Can Become an Actor

You don’t need to redesign your code for the actor model. Any existing Java object— even standard library classes—can instantly become an actor.

import com.scivicslab.pojoactor.ActorSystem;
import com.scivicslab.pojoactor.ActorRef;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;

ActorSystem system = new ActorSystem("listSystem");
ActorRef<ArrayList<String>> listActor = system.actorOf("myList", new ArrayList<>());

// Mutate the list
listActor.tell(list -> list.add("Hello"));
listActor.tell(list -> list.add("World"));
listActor.tell(list -> list.add("from"));
listActor.tell(list -> list.add("POJO-actor"));

// Query the list
CompletableFuture<Integer> sizeResult = listActor.ask(list -> list.size());
System.out.println("List size: " + sizeResult.get()); // → 4

CompletableFuture<String> first = listActor.ask(list -> list.get(0));
System.out.println("First element: " + first.get()); // → Hello

CompletableFuture<String> joined = listActor.ask(list -> String.join(" ", list));
System.out.println(joined.get()); // → Hello World from POJO-actor

system.terminate();

Benefits

  • Retrofit existing codebases without architectural changes
  • Protect any object with actor‑based thread safety
  • Scale incrementally by converting objects to actors as needed
  • Reuse existing POJOs without modifications

Massive Actor Scalability

Virtual threads enable POJO‑actor to handle thousands of actors efficiently. The example below creates 10 000 Counter actors:

import com.scivicslab.pojoactor.ActorSystem;
import com.scivicslab.pojoactor.ActorRef;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;

ActorSystem system = new ActorSystem("massiveSystem", 4); // 4 CPU threads for computation
List<ActorRef<Counter>> actors = new ArrayList<>();

// Create 10 000 actors
for (int i = 0; i > futures = new ArrayList<>();
for (ActorRef<Counter> actor : actors) {
    futures.add(actor.tell(c -> c.increment()));
}

// Wait for all messages to be processed
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();

// Verify each counter was incremented once
for (ActorRef<Counter> actor : actors) {
    int value = actor.ask(c -> c.getValue()).get();
    assert value == 1;
}

System.out.println("Successfully processed messages for 10 000 actors!");
system.terminate();

What this demonstrates

  • Creation of thousands of actors without exhausting threads
  • Efficient message processing thanks to virtual threads
  • Simple, type‑safe interaction with any POJO

Enjoy building highly concurrent, lightweight applications with POJO‑actor!

Advanced Usage

Parallel Matrix Multiplication

POJO‑actor excels at parallel computation tasks. The example below demonstrates distributed matrix multiplication using work‑stealing pools for heavy computations:

// Create large matrices for multiplication
final int matrixSize = 400;
final int blockSize = 100;
double[][] matrixA = new double[matrixSize][matrixSize];
double[][] matrixB = new double[matrixSize][matrixSize];

// Create ActorSystem with 4 CPU threads for heavy computation
ActorSystem system = new ActorSystem("matrixSystem", 4);
List<CompletableFuture<double[][]>> futures = new ArrayList<>();

// Divide matrix into blocks and assign to different actors
for (int blockRow = 0; blockRow < matrixSize; blockRow += blockSize) {
    for (int blockCol = 0; blockCol < matrixSize; blockCol += blockSize) {
        Calculator calculator = new Calculator(); // hypothetical block calculator
        ActorRef<Calculator> actor = system.actorOf(
            String.format("block_%d_%d", blockRow, blockCol), calculator);

        // Light operation: Initialize actor with block coordinates (uses virtual thread)
        actor.tell(calc -> calc.initBlock(matrixA, matrixB, blockRow, blockCol)).get();

        // Heavy computation: Matrix multiplication (uses work‑stealing pool)
        CompletableFuture<double[][]> blockSum = actor.ask(
            calc -> calc.calculateBlock(),          // CPU‑intensive matrix multiplication
            system.getWorkStealingPool()           // Delegate to work‑stealing pool
        );

        futures.add(blockSum);
    }
}

// Wait for all parallel calculations to complete
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();

Key Performance Points

  • Light operationsinitBlock() only sets references, so virtual threads are perfect.
  • Heavy computationcalculateBlock() performs the actual multiplication; a work‑stealing pool is essential.
  • Virtual threads – Handle only setter/getter‑level operations, with virtually no CPU load.
  • CPU control – Only 4 CPU threads handle all heavy work, regardless of the number of actors.
  • Responsiveness – Actors stay responsive to light messages while heavy work runs.
  • Scalability – Thousands of actors can be created without exhausting system resources.

Custom Thread Pools

ActorSystem system = new ActorSystem("system");

// Add additional work‑stealing pools
system.addWorkStealingPool(8);  // CPU‑intensive tasks
system.addWorkStealingPool(2);  // I/O‑bound tasks

// Use a specific thread pool
counter.tell(c -> c.increment(), system.getWorkStealingPool(1));

Actor Hierarchies

ActorRef parent = system.actorOf("parent", new ParentActor());
ActorRef child  = parent.createChild("child", new ChildActor());

GraalVM Native Image Support

Many traditional actor frameworks rely on reflection for message routing, serialization, and dynamic proxy generation, which makes native‑image compilation difficult. POJO‑actor uses no reflection and only modern JDK features, so it compiles to GraalVM native images without extra configuration.

# Compile to native image
native-image -jar target/POJO-actor-1.0.0-fat.jar -o pojo-actor-native

# Run native executable
./pojo-actor-native

No additional configuration files or reflection hints are required.

Performance

  • Startup Time – Near‑instant with native compilation.
  • Memory Usage – Minimal heap allocation thanks to the POJO‑based design.
  • Throughput – High message‑processing rates with virtual threads.
  • Scalability – Efficient work‑stealing thread pools for parallel tasks.

Performance Best Practices

Understanding when to use virtual threads versus work‑stealing pools is crucial for optimal performance.

Light Operations — Use Default Virtual Threads

// Fast operations that don't block or consume much CPU
counter.tell(c -> c.increment());
counter.tell(c -> c.setName("newName"));
listActor.tell(list -> list.add("item"));

CompletableFuture<Integer> size = listActor.ask(list -> list.size());

Heavy Computations — Delegate to Work‑Stealing Pools

ActorSystem system = new ActorSystem("system", 4); // 4 CPU threads for heavy work

// CPU‑intensive calculations should use the work‑stealing pool
CompletableFuture<Result> result = calculator.ask(
    c -> c.performMatrixMultiplication(),
    system.getWorkStealingPool()
);

// I/O‑bound or blocking calls should also use the work‑stealing pool
CompletableFuture<Data> data = dataProcessor.ask(
    p -> p.readLargeFile(),
    system.getWorkStealingPool()
);

Why This Matters

  • Virtual threads are ideal for lightweight message passing and state changes.
  • Work‑stealing pools handle CPU‑intensive tasks without blocking virtual threads.
  • This separation prevents heavy computations from degrading the actor system’s responsiveness.

You can control CPU core usage by configuring the size of the work‑stealing pool:

// Example: Mixed workload with proper thread‑pool usage
ActorSystem system = new ActorSystem("mixedSystem", 4);
ActorRef processor = system.actorOf("processor", new DataProcessor());

Example Usage

// Create an actor system
ActorSystem system = ActorSystem.create();

// Create a POJO actor
DataProcessor processor = system.actorOf(new DataProcessor());

// Light operation – uses a virtual thread
processor.tell(p -> p.updateCounter());

// Heavy operation – uses the work‑stealing pool
CompletableFuture<AnalysisResult> heavyResult = processor.ask(
    p -> p.performComplexAnalysis(largeDataset),
    system.getWorkStealingPool()
);

// The actor remains responsive to light messages while heavy computation runs in the background
processor.tell(p -> p.logStatus()); // This won’t be blocked by the heavy computation

Requirements

  • Java 21 or higher
  • Maven 3.6+

Dependencies

  • Runtime: JDK standard library only
  • Testing: JUnit 5, Apache Commons Math (test scope only)

Building

# Compile and test
mvn clean test

# Build JAR
mvn clean package

# Generate Javadoc
mvn javadoc:javadoc

Acknowledgments

POJO‑actor was inspired by Alexander Zakusylo’s actr library, which pioneered the POJO‑based actor model approach in Java. While actr introduced many excellent concepts, POJO‑actor extends and improves upon them with:

  • Message‑ordering guarantee: Unlike actr, POJO‑actor ensures that messages sent to an actor are processed in the order they were sent.
  • Modern Java features: Built with Java 21+ virtual threads and modern concurrency patterns.
  • Enhanced thread‑pool management: actr used real threads for actors, limiting scalability to the CPU core count and causing performance issues with heavy computations. POJO‑actor uses virtual threads for actors and delegates heavy computations to configurable work‑stealing pools, allowing thousands of actors while controlling CPU core usage.

We also acknowledge Comedy.js, a Node.js actor framework that inspired POJO‑actor’s basic architecture design—particularly the ActorSystem and ActorRef concepts. While Comedy.js uses one process or one real thread per actor, POJO‑actor leverages Java’s virtual threads to enable thousands of lightweight actors.

Back to Blog

Related posts

Read more »