BEAM’s Hot Code Swapping vs. the JVM’s HotSwap Ceiling
Erlang can replace a running module without stopping anything. Java’s built-in HotSwap can only edit method bodies. Why the gap? Let’s find out.
Why This Question Keeps Coming Up
Every Java developer has felt the pain of a restart. You change one line, wait for the build, and then wait again for the app to boot. Meanwhile, Erlang developers have been swapping live code in running telecom systems for decades. So, naturally, people ask: why can’t Java just do this?
The short answer is that the two platforms made different bets about code and state. In this article, we will compare both designs. Moreover, we will test them ourselves instead of trusting folklore.
How the BEAM Swaps Code
Two Versions, Side by Side
According to the Erlang reference manual, code replacement happens at module level, and a module can exist in two variants: current and old. Both are valid and may run concurrently. In other words, loading a new version does not stop anyone.
The Fully Qualified Call
So how does a running process move to the new code? It makes a fully qualified call, such as ticker:loop(N). The manual states that such calls always refer to current code. A local call, by contrast, stays in the old version. Consequently, a long-running loop upgrades itself the next time it calls itself the right way.
There is one safety rule. If a third version is loaded, the code server purges the old one, and processes still lingering in it are terminated. Therefore, real systems upgrade carefully, one step at a time.
See It Run
Let’s prove it with a tiny counter process. First, save this as ticker.erl:
cat > ticker.erl <<'EOF'
-module(ticker).
-export([start/0, loop/1]).
start() -> register(ticker, spawn(fun() -> loop(0) end)).
loop(N) ->
receive
{From, tick} -> From ! {v1, N + 1}, ticker:loop(N + 1)
end.
EOF
Next, run the script below. It needs only Erlang, and it swaps v1 for v2 while the process stays alive.
erl -noshell -eval '
{ok, _} = compile:file("ticker.erl"), code:load_file(ticker), ticker:start(),
Ask = fun() -> ticker ! {self(), tick}, receive R -> R end end,
io:format("before: ~p ~p~n", [Ask(), Ask()]),
os:cmd("sed -i s/v1/v2/ ticker.erl"),
{ok, _} = compile:file("ticker.erl"), code:load_file(ticker),
io:format("after: ~p ~p~n", [Ask(), Ask()]),
halt().'
On Erlang/OTP 25, we got before: {v1,1} {v1,2} and after: {v1,3} {v2,4}. Notice two things. First, the counter kept going from 2 to 3 to 4, so no state was lost. Second, the first reply after the swap still said v1. The process was already waiting in the old code, so it answered once more there, then jumped to v2 through the qualified call. It is a small detail, yet it shows the mechanism clearly.
Under Load
We also ran a heavier test. A client sent 3,000 requests, one every couple of milliseconds, and we reloaded the module halfway through.

As the chart shows, the answers moved from v1 to v2 within a single 250 ms bucket. Importantly, no bucket is empty.
The JVM’s HotSwap Ceiling
The JVM does have a redefinition feature. However, its rules are strict. The JDK bug JDK-8192936 quotes the JVM TI specification: a redefinition may change method bodies, the constant pool and attributes, but it must not add, remove or rename fields or methods, change method signatures or modifiers, or change inheritance. Furthermore, the Instrumentation javadoc change notes that instances of a redefined class are not affected.
Again, we checked this ourselves. Using Instrumentation.redefineClasses on OpenJDK 21, we tried six edits to one small class.
| Edit | Result | JVM message |
|---|---|---|
| Change a method body | Accepted | none |
| Add a method | Rejected | attempted to add a method |
| Remove a method | Rejected | attempted to delete a method |
| Change a signature | Rejected | attempted to delete a method |
| Add a field | Rejected | attempted to change the schema (add/remove fields) |
| Change a modifier | Rejected | attempted to change method modifiers |

Only one of six edits worked. That is the ceiling in practice.
Why the Designs Differ
Why does the BEAM manage this while the JVM cannot? The reason is how each one treats state. Java objects carry their fields in a fixed memory layout, and live instances point at their class. Adding a field would mean rewriting every existing object. Erlang processes, on the other hand, hold state as ordinary data passed through a loop, so the runtime never has to reshape anything. The developer decides how to migrate the data.
| Question | BEAM | JVM HotSwap |
|---|---|---|
| Unit of replacement | Whole module | Method bodies only |
| Versions alive at once | Two (current and old) | One class definition |
| Who handles state changes? | The developer | Not possible (schema change) |
This is also why tools like JRebel and DCEVM exist. They work around the ceiling instead of lifting it, so you may want to evaluate them for development. They are not part of the JDK.
Quick checklist before you rely on hot swapping
- Do you need to change fields or signatures? Then plan a restart or a tool beyond HotSwap.
- On the BEAM, do your loops make fully qualified calls?
- Have you planned how state migrates between versions?
- Do you load at most two versions at a time?
What We Have Learned
To sum up, the BEAM swaps code at module level, keeps two versions alive, and lets processes upgrade through fully qualified calls. In our test, 3,000 requests went through a live swap with none lost. The JVM, by contrast, accepted only one of six edits in our test, because its object layout is fixed. Consequently, the real difference is not engineering effort but how each platform models state. Therefore, Java cannot simply copy Erlang. It would have to change what an object is.

