Memory Leaks in Minecraft Plugins: Find Them Before They Crash You

Memory Leaks in Minecraft Plugins: Find Them Before They Crash You

A plugin memory leak does not announce itself. The server runs fine for a week, then restarts start taking longer, tick times creep upward, and one morning the console is full of java.lang.OutOfMemoryError: Java heap space. This guide exists to break that cycle: it shows how to prove a leak is real, capture evidence while the server is still sick, and name the object that is holding memory it should have released.

It is written for server owners, administrators, and plugin developers who can read a log and run a command but have never opened a heap dump. The measurements come from our own lab: Paper 26.2 on OpenJDK 25.0.4.1, 16 vCPU, a 1 GB heap with -Xms1G -Xmx1G -XX:+UseG1GC, an empty world, and one purpose-built plugin that kept a 1 MiB byte[] in a static list every tick. That deliberately crude leak filled the heap and threw an OutOfMemoryError about 35 seconds after launch, which makes it a useful teaching case, not a realistic one.

Is It a Leak or Just a Small Heap?

Those two problems look identical from the console and have opposite fixes, so settle the question before you touch a flag. A leak means live objects survive garbage collection because something still references them. An undersized heap means the objects are genuinely needed and there is simply not enough room. The distinguishing signal is what the heap looks like after a full collection.

Watch for this cluster of symptoms over days, not minutes:

  • Post-GC heap climbing. Record used memory right after a full GC at hour 1 and again at hour 6. A healthy server returns to roughly the same floor. A leaking one returns higher every time.
  • MSPT spikes that grow in length. Long pauses show up as tick time outliers before they show up as player complaints. Our MSPT and tick rate guide explains how to read those numbers.
  • OutOfMemoryError: Java heap space in logs/latest.log, often preceded by a burst of java.lang.OutOfMemoryError variants from unrelated threads.
  • Time to OOM shrinking after each restart. Eight days, then three, then six hours. This is the strongest leak signal there is, because it means the baseline itself is growing.

Capture the Evidence Before You Restart

# find the server PID (Linux)
jps -l | grep -i paper

# 1. current heap occupancy and collector state
jcmd 12345 GC.heap_info

# 2. class histogram, biggest consumers first
jcmd 12345 GC.class_histogram | head -30

# 3. full heap dump for offline analysis
jcmd 12345 GC.heap_dump /srv/dumps/leak-$(date +%H%M).hprof

Run GC.heap_info three times, ten minutes apart, and write down used and committed. Run GC.class_histogram at the same moments. The histogram is a snapshot of live objects by class, and a class whose byte total grows while everything else is flat is your prime suspect. On a live production server you can also take the dump through spark with /spark heap --live, which forces a collection first so the file contains only reachable objects and is much smaller.

A dump taken after the OutOfMemoryError is usually worthless. By then the JVM has thrashed, half the threads have died inside exception handlers, and the heap you capture is a wreck rather than the state that caused the failure. Our lab server did exactly this: it never exited cleanly on its own and had to be killed after the OOM. Dump at 70 to 85 percent heap usage, while the server is still answering, and keep the file. Oracle’s JDK troubleshooting guide documents these diagnostics and the flags that make them automatic.

Read the Heap Like an Engineer

Open the .hprof in Eclipse Memory Analyzer, not in a text editor and not in a spreadsheet. MAT is free, handles multi-gigabyte dumps, and answers the one question that matters: which object is keeping all this memory alive? Let it build its index, then work in this order.

  • Leak suspects report. MAT’s heuristic groups the largest retained sets. It is right often enough to be the first click and wrong often enough that you should verify by hand.
  • Dominator tree. Sorted by retained size, this shows the objects whose removal would free the most memory. A single HashMap at the top with a huge retained size is the classic signature.
  • Path to GC Roots. Right-click the suspect, exclude weak and soft references, and read the chain back to a static field. This is what turns “something is big” into “this plugin’s static cache is big”.

Keep shallow size and retained size separate in your head. Shallow size is the object itself, a few dozen bytes for a map entry. Retained size is everything that dies with it, which is where the gigabytes live. A 300 MB dominator with a 48-byte shallow size is not a contradiction; it is a container.

The Leak Patterns That Cause Most OutOfMemoryErrors

Plugin leaks are rarely exotic. They come from the same small set of lifecycles being handled incorrectly, and each pattern leaves a recognizable fingerprint in a dump.

PatternWhat you see in the dumpFix
Static Map or List used as a cacheOne HashMap or ArrayList dominating retained size, reachable from a static fieldBound it, add expiry, or use Caffeine
Listener or task not unregistered in onDisablePlugin classloader alive after reload, listener list holding your instanceCancel tasks and unregister handlers on disable
Per-player data never removed on quitPlayerData objects growing with unique player countRemove entries in PlayerQuitEvent
Unbounded cache of chunks, blocks, or NMS handlesCraftWorld, LevelChunk, or BlockPos arrays retainedCap entries and drop references on world unload
Repeating task capturing a player or plugin instanceRunnable holding a Player reference long after logoutUse UUIDs, or cancel the task on quit

Two patterns deserve extra suspicion because they survive reloads. A plugin that forgets to cancel its repeating tasks in onDisable keeps its own classloader reachable, so /reload confirm leaks an entire copy of the plugin each time. The Paper plugin lifecycle docs and the Bukkit scheduler reference both spell out that registration and cancellation are paired obligations. If your reload workflow is part of the problem, auditing and trimming plugins is the cheaper first step.

Fix, Verify, and Prevent Regressions

Once MAT names the retainer, the fix is usually a few lines. Make the container bounded, remove entries on the lifecycle event that ends their usefulness, and close anything that holds a native resource.

  • Bound every cache. A Map keyed by player UUID with no eviction is a leak with a delay. Caffeine gives you maximumSize and expireAfterAccess in one line.
  • Remove on quit. Handle PlayerQuitEvent for every per-player structure you own, not just the obvious profile map.
  • Cancel in onDisable. Store task IDs from runTaskTimer and cancel them, then unregister listeners.
  • Prefer weak references for observer-style registrations where the holder should not keep the target alive.
  • Update the plugin. Many leaks ship fixed in later builds; check the issue tracker before writing a patch.

Then prove it. Restart, run a soak test for 24 to 48 hours with realistic player churn, and capture GC.class_histogram at hour 1 and hour 24. The fixed server should return to the same post-GC floor at both points; the leaking one will not. Our lab run gave the reference shape: 419,206,328 bytes of byte[] at 24.8 seconds on a 1,048,576K heap, against 749,765K used after a collection moments earlier. A flat line where that staircase used to be is your proof.

Pros and Cons: Restart-First vs Diagnose-First

Both approaches have a place, and the right answer depends on whether players are online right now.

  • Restart-first, pro: it restores service in under a minute and keeps your community happy during a peak-hour failure.
  • Restart-first, con: it destroys the evidence, resets your baseline, and guarantees the same failure returns. Repeated restarts train players to expect instability.
  • Diagnose-first, pro: a dump plus two histograms usually names the culprit in under an hour, and the fix is permanent.
  • Diagnose-first, con: a dump pauses the server, dumps can reach several gigabytes, and reading MAT takes practice.

A workable compromise: capture GC.heap_info, a histogram, and a dump in that order, then restart. You get the evidence and the uptime, and you analyze offline while the server is healthy again.

Common Mistakes to Avoid

  • Raising -Xmx as a fix. It only delays the OOM, and it makes every full GC pause longer and every dump bigger. Raise heap when the post-GC floor is stable but too high; fix code when the floor climbs.
  • Dumping after the OOM. The heap is already corrupted by failed threads. Dump at 70 to 85 percent usage instead.
  • Reading the histogram as a verdict. It shows a class growing, not who retains it. Confirm with the dominator tree and Path to GC Roots.
  • Reloading plugins to test a fix. Reloads leak classloaders in ways that mimic real leaks and ruin your comparison. Restart cleanly.

Frequently Asked Questions

How do I know a Minecraft plugin is leaking memory?

Check used heap right after a full GC at two points hours apart. If the post-GC floor keeps climbing while player count and worlds are stable, and time to OutOfMemoryError shrinks after each restart, something is retaining objects it should release. Confirm with a class histogram and a heap dump.

Should I take a heap dump after an OutOfMemoryError?

Usually no. By then threads are failing and the heap is a wreck, so the dump rarely shows the state that caused the failure. Take it while the server is still responding, at roughly 70 to 85 percent heap usage, and analyze it offline in Eclipse MAT.

How much memory should a Minecraft server have?

Enough that the live set after a full GC sits well below the maximum, typically under 60 percent of -Xmx with your normal player count. If that floor is stable, adding memory helps. If the floor climbs every hour, more memory only delays the crash.

Do plugin reloads cause memory leaks?

Frequently, yes. Reloading without cancelling tasks and unregistering listeners keeps the old classloader reachable, which leaks an entire copy of the plugin per reload. Restart the server to test a fix, and treat repeated reloads as a leak source in their own right.

Measure the Floor, Then Fix the Code

Memory leaks in Minecraft plugins are diagnosable. Prove the leak by watching the post-GC floor, capture a dump while the server still answers, and let the dominator tree tell you which collection is holding the growth. Then bound it, remove entries on quit, cancel tasks on disable, and soak the fix for two days.

Start today with one command: run jcmd <pid> GC.class_histogram on your production server and save the output with a timestamp. That baseline is the thing you will wish you had the next time the console goes red. If you want to read tick times alongside it, our guide to spark reports covers the rest of the picture. The spark documentation has the heap command details you need to go further.

Generative AI was used to research and add structure to the original content so I can inform you as best as possible. All content has been reviewed by me.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Search

Minecraft Server Tips and Tricks