Erlang Actors vs Java Virtual Threads: What Staroletov (2026) Teaches Us About Concurrency Design
Java finally has lightweight threads. Erlang has had them since 1986. But the gap between the two is not about raw concurrency numbers — it is about what happens when things go wrong.
In his 2026 Erlang Workshop paper “Modern Erlang Teaching from Code Refactoring to Actor System Verification,” Sergey Staroletov draws a direct contrast between Erlang’s actor model and Java’s modern virtual threads (Project Loom). His point is not that one is faster. It is that both enable high concurrency, but only one bakes fault isolation and hierarchical supervision into the language design from the ground up.
This article unpacks that contrast — and why it matters for anyone choosing a concurrency model today.
The concurrency renaissance
For decades, the standard answer to “I need more concurrency” was “spawn more OS threads.” That answer collapsed under its own weight:
- Each OS thread reserves 1–8 MB of stack by default.
- Context switching costs microseconds and pollutes CPU caches.
- Ten thousand threads is already a struggle on commodity hardware.
Two independent communities attacked this problem from opposite directions:
- Java kept the thread abstraction but made it cheap. Project Loom (finalized in JDK 21) introduced virtual threads: JVM-managed threads multiplexed onto a small pool of carrier threads.
- Erlang never had heavyweight threads to begin with. The BEAM virtual machine has always scheduled processes — not OS threads — in user space, with preemption based on reduction counts.
On paper, both now support millions of concurrent units of execution. So why does Staroletov insist the models are fundamentally different?
What Java virtual threads actually are
A virtual thread in Java is a Thread object backed by the JVM rather than the OS. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier thread and parks it on the heap. The carrier is freed to run another virtual thread.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
})
);
}
This is a genuine leap forward. A million virtual threads sleeping for one second complete in roughly one second — not a million. The API is familiar: it is still Thread, still Runnable, still ExecutorService. Migration cost is near zero.
But look closely at what a virtual thread is: it is a task, not an actor. It does not have a mailbox. It does not have a PID. It cannot receive messages from another thread without an external queue. It shares mutable memory with every other thread in the JVM. And when it throws an uncaught exception, that exception propagates to whatever submitted it — usually an ExecutorService, which may swallow it, log it, or hand it to an UncaughtExceptionHandler you forgot to configure.
Virtual threads make threads cheap. They do not make failure cheap.
What Erlang processes actually are
An Erlang process is not a thread. It is a first-class language construct with three properties baked into the runtime:
- Its own mailbox — a queue of messages, addressed by PID.
- Its own heap — no shared mutable state with any other process.
- Its own failure domain — a crash in one process does not corrupt another.
-module(counter).
-export([start/0, increment/1, get/1]).
start() ->
spawn(fun() -> loop(0) end).
loop(N) ->
receive
{increment, From} ->
From ! {ok, N + 1},
loop(N + 1);
{get, From} ->
From ! {value, N},
loop(N)
end.
increment(Pid) ->
Pid ! {increment, self()},
receive {ok, New} -> New end.
get(Pid) ->
Pid ! {get, self()},
receive {value, N} -> N end.
The memory math is the headline number Staroletov cites: an Erlang process costs about 326 words — roughly 2.6 KB on a 64-bit system. Not kilobytes of stack. Not megabytes of reserved memory. A few kilobytes of heap and a small control block. A single BEAM node can hold millions of these comfortably.
The BEAM scheduler runs entirely in user space. It preempts processes based on a reduction count — a measure of work done, not wall-clock time — which means a process cannot starve others by running a tight loop. No OS involvement, no thread priorities, no priority inversion.
But the real story is not the memory. It is what happens when one of those processes crashes.
The real difference: fault isolation
Here is the same failure scenario in both models.
Java virtual thread:
executor.submit(() -> {
var conn = socket.accept();
var data = parse(conn); // throws ParseException
process(data);
});
The ParseException unmounts the virtual thread. The exception is captured by the Future returned from submit(). If nobody calls future.get(), the exception is silently lost. If the task was submitted to a shared executor, the executor may log it — or may not, depending on configuration. Meanwhile, the socket is leaked, the conn is leaked, and any state partially written during parse() is now inconsistent.
To handle this properly, you need:
- A
try/catcharound every task body. - A resource-management strategy (
try-with-resourcesaroundconn). - A policy for what to do with the failed task (retry, log, alert).
- A strategy for restarting the failed component.
Erlang process:
handle_conn(Socket) ->
case gen_tcp:accept(Socket) of
{ok, Conn} ->
case parse(Conn) of
{ok, Data} -> process(Data);
{error, Reason} -> exit({parse_failed, Reason})
end;
{error, Reason} ->
exit({accept_failed, Reason})
end.
If parse/1 crashes, the process dies. Its mailbox is discarded. Its heap is garbage-collected as a unit. The socket is closed by the runtime because the process that owned it is gone. Nothing else in the system is affected.
And critically: a supervisor is watching.
Hierarchical supervision: OTP as a design pattern
Erlang’s “let it crash” philosophy is not about ignoring failures. It is about making failures local and recoverable through a supervision tree.
-module(conn_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
SupFlags = #{strategy => one_for_one,
intensity => 5,
period => 10},
ChildSpec = #{id => conn_handler,
start => {conn_handler, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker},
{ok, {SupFlags, [ChildSpec]}}.
This supervisor says: “If the conn_handler worker dies, restart it. If it dies more than five times in ten seconds, escalate to my own supervisor — because something is fundamentally wrong.”
That is the entire failure-handling story: one line of configuration replaces pages of Java exception-handling boilerplate. The supervisor tree is not an afterthought bolted onto the runtime. It is the runtime’s organizing principle.
Java has no equivalent in the language. You can build one — libraries like Akka do exactly this — but you are building it on top of a runtime that was not designed for it. Staroletov’s point is that this difference is not incidental. It is architectural.
What virtual threads do not give you
To be fair to Project Loom, it solves the problem it was designed to solve: thread-per-request scalability without rewriting blocking code. That is a massive win for the Java ecosystem, and Staroletov acknowledges it.
But the comparison he draws is deliberately precise. Virtual threads give you:
- Cheap concurrency.
- Familiar
ThreadAPI. - Seamless integration with existing
java.util.concurrent.
They do not give you:
-
Isolated failure domains. A virtual thread shares the JVM heap with every other thread. A
StackOverflowErrororOutOfMemoryErrortakes down the whole process, not one task. - Mailboxes. Communication is still through shared memory or external queues, not first-class message passing.
- Supervision trees. Failure recovery is an application-level concern, not a runtime primitive.
- Location transparency. A virtual thread cannot send a message to a thread on another node without a serialization layer you write yourself.
Erlang gives you all four as language features. That is the difference Staroletov is pointing at.
The pedagogical angle
The contrast appears in Staroletov’s course at a specific moment. After students have refactored imperative Erlang into functional Erlang, and after they have written recursive functions with multiple accumulators, the course introduces lightweight processes through a live demonstration: a TCP server handling thousands of simultaneous connections on modest hardware.
The pedagogical payoff is the memory comparison. When students see that an Erlang process costs 2.6 KB while an OS thread costs megabytes, the actor model stops being an abstract concept and becomes a concrete engineering trade-off. When they then see a supervisor restart a crashed worker automatically, the “let it crash” philosophy stops being a slogan and becomes a design pattern they can reason about.
The Java comparison enters as a foil, not as a competitor. Staroletov uses modern Java virtual threads to show that high concurrency is now table stakes. What is not table stakes — what still requires deliberate architectural choices in Java — is fault isolation and hierarchical supervision. Erlang gives them to you on day one because the language was designed around them.
So which should you use?
The honest answer is: it depends on what you are building.
| You need… | Lean toward |
|---|---|
| Millions of blocking I/O tasks, existing Java codebase | Java virtual threads |
| Fault-tolerant distributed systems, telecom-grade uptime | Erlang/OTP |
| Shared-memory parallelism on large datasets | Java (or any JVM language) |
| Location transparency and hot code reloading | Erlang/OTP |
| Familiar tooling, huge library ecosystem | Java |
| Supervision trees as a first-class primitive | Erlang/OTP |
Virtual threads are a concurrency primitive. Erlang processes are a system architecture. They are not competing answers to the same question — they are answers to different questions that happen to overlap.
Staroletov’s contribution is to make that distinction visible to students before they commit to a mental model. That is what good teaching does: it does not tell you which tool to use. It tells you what each tool is actually for.
Conclusion
Java virtual threads closed the concurrency gap. They did not close the fault-tolerance gap. Erlang’s actor model offers something virtual threads structurally cannot: failure as a first-class, isolatable, supervisable event — not an exception that escapes to an executor.
Staroletov (2026) uses this contrast not to declare a winner, but to sharpen a pedagogical point. When students see both models side by side, they stop asking “which is faster?” and start asking “what kind of failure do I want my system to survive?” That is the question that actually determines the architecture.
Main reference: STAROLETOV, Sergey. Modern Erlang Teaching from Code Refactoring to Actor System Verification. In: Proceedings of the 25th ACM SIGPLAN International Workshop on Erlang (Erlang ‘26), 2026. DOI: 10.1145/3830434.3830946
Have you migrated a Java service to virtual threads, or an Erlang service to something else? What surprised you about the failure semantics? Let me know in the comments.
Top comments (0)