ExerciseProduction incident
Production incident
The redeploy that ate the host
45 minsenior4–15 yrs
A real incident: symptom first, cause hidden, tradeoff at the end.
The incident
A long-running application server hot-deploys plugins. Each deploy builds a
fresh class loader and loads the plugin's classes through it, so a new
version can replace an old one without restarting the JVM.
The host runs out of memory after about a week:
- the heap is healthy, and a dump taken at the time shows nothing unusual
- the JVM never throws OutOfMemoryError; the kernel kills the process
- loaded class count climbs with every deploy and never comes down
Under Java 7 this crashed within the hour with OutOfMemoryError: PermGen
space, which is how the same bug was found and fixed twice before. Nobody
recognised it this time, because the failure looks completely different.
Find everything holding the superseded deploys, fix undeploy() so a
superseded deploy is fully released, and then explain why the heap dump was
a dead end and what you would have run instead.
What this teaches
- A class loader is released all at once or not at all — loader, classes, statics
- A Class object is a hard reference to its loader, so caching one pins the deploy
- A ThreadLocal on a pooled thread pins whatever it holds, including a loader
- Class metadata lives in Metaspace since Java 8, so it is invisible in a heap dump
- Removing PermGen's ceiling removed the alarm, not the bug
Starter
Starter.javaOpen in playground
import java.io.*;
import java.lang.ref.*;
import java.util.*;
/**
* Incident reproduction: the redeploy that ate the host.
*
* A long-running application server hot-deploys plugins. Each deploy builds
* a fresh class loader and loads the plugin's classes through it, so a new
* version can replace an old one without restarting the JVM.
*
* The host runs out of memory after about a week. The symptoms:
*
* - the heap is healthy, and a heap dump taken at the time shows nothing
* unusual
* - the JVM never throws OutOfMemoryError; the kernel kills the process
* - loaded class count climbs with every deploy and never comes down
*
* Under Java 7 this crashed in an hour with OutOfMemoryError: PermGen space,
* which is how the same bug was found and fixed twice before. Nobody
* recognised it this time, because the failure looks completely different.
*
* TASKS
* 1. Run it. How many of the four deploys released their class loader?
* 2. Find what is holding them. There is more than one thing.
* 3. Fix undeploy() so a superseded deploy is fully released.
* 4. In a comment: a heap dump showed nothing. Say why, and name the
* jcmd command you would have reached for instead.
*/
public class Starter {
/** The "plugin" class. Each deploy loads its own private copy of this. */
public static class Plugin {
public static String describe() {
return "plugin";
}
}
static final String PLUGIN = "Starter$Plugin";
/**
* A deploy's class loader. It loads Plugin itself rather than delegating,
* which is what gives each deploy an independent copy — and what makes
* the loader worth collecting once the deploy is superseded.
*/
static final class DeployLoader extends ClassLoader {
final int version;
DeployLoader(ClassLoader parent, int version) {
super(parent);
this.version = version;
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (!name.equals(PLUGIN)) {
return super.loadClass(name, resolve);
}
synchronized (getClassLoadingLock(name)) {
Class<?> already = findLoadedClass(name);
if (already != null) {
return already;
}
try (InputStream in = getParent().getResourceAsStream(PLUGIN + ".class")) {
byte[] bytes = in.readAllBytes();
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
}
/* ── the two things that hold on, both added for good reasons ── */
/** Added so the admin console could list what had ever been deployed. */
static final List<Class<?>> DEPLOY_HISTORY = new ArrayList<>();
/** Added so a request could find the plugin handling it. */
static final ThreadLocal<Class<?>> ACTIVE_PLUGIN = new ThreadLocal<>();
static DeployLoader deploy(int version) throws Exception {
DeployLoader loader = new DeployLoader(Starter.class.getClassLoader(), version);
Class<?> plugin = loader.loadClass(PLUGIN);
plugin.getMethod("describe").invoke(null);
DEPLOY_HISTORY.add(plugin);
ACTIVE_PLUGIN.set(plugin);
return loader;
}
/**
* DEFECT: dropping the reference to the loader is not enough. Something
* else still reaches it, and a class loader is released all at once or
* not at all.
*/
static void undeploy(int version) {
// nothing to do — the loader goes out of scope
}
public static void main(String[] args) throws Exception {
List<WeakReference<ClassLoader>> probes = new ArrayList<>();
System.out.println("── four deploys ──");
for (int version = 1; version <= 4; version++) {
DeployLoader loader = deploy(version);
probes.add(new WeakReference<>(loader));
undeploy(version);
loader = null;
System.out.println(" deployed and superseded v" + version);
}
int released = 0;
for (WeakReference<ClassLoader> probe : probes) {
if (collected(probe)) released++;
}
System.out.println();
System.out.println("deploys made : " + probes.size());
System.out.println("class loaders released : " + released);
System.out.println("still pinned in memory : " + (probes.size() - released));
System.out.println();
System.out.println("every superseded deploy released : " + (released == probes.size()));
System.out.println(released == probes.size() ? "PASS" : "FAIL");
}
/**
* System.gc() is a hint, so asking once proves nothing. This asks
* repeatedly with allocation pressure in between, and gives up after a
* bounded number of attempts so a leak cannot hang the build.
*/
static boolean collected(WeakReference<?> ref) {
for (int attempt = 0; attempt < 50 && ref.get() != null; attempt++) {
System.gc();
byte[] churn = new byte[1 << 20];
if (churn.length < 0) System.out.print("");
}
return ref.get() == null;
}
}Run it locally:
cd exercises/java/jvm/metaspace-vs-permgen/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out StarterHints
Hint 1
Dropping the loader variable is not the same as dropping everything that reaches something the loader loaded. List what still points inward.
Hint 2
DEPLOY_HISTORY holds Class objects. What does a Class reference?
Hint 3
ACTIVE_PLUGIN is a ThreadLocal. Which thread ran the deploy, and when does that thread die in a server?
Hint 4
The admin console needs to list past deploys. Does it need the Class, or something you can derive from it that holds nothing?
Done when
- All four superseded deploys are released
- The admin console still has its deploy history
- A comment explains why a heap dump showed nothing
- A comment names the jcmd command that would have diagnosed it in one step
Solution
Show the solution — try it yourself first
Solution.javaOpen in playground
import java.io.*;
import java.lang.ref.*;
import java.util.*;
/**
* Solution: the redeploy that ate the host.
*
* A class loader is released all at once or not at all. It references every
* class it loaded, each of those references it back, and every instance
* references its class — so one reference from outside pins the entire
* deploy: all its classes, all their static fields, and everything those
* reach.
*
* Two things were holding on here, both added for good reasons:
*
* DEPLOY_HISTORY kept the Class object so an admin console could list what
* had been deployed. A Class is a hard reference to its loader.
*
* ACTIVE_PLUGIN is a ThreadLocal, so the value stays attached to whichever
* thread ran the deploy — and in a server that is a pooled thread which
* never dies. Same mechanism as the ThreadLocal leak in
* /java/jvm/memory-leaks, with a class loader on the end of it instead of
* a tenant id.
*
* Dropping the loader variable released neither, which is the lesson:
* "nothing points at the loader" is not the same as "nothing points at
* anything the loader loaded".
*
* WHY THE HEAP DUMP SHOWED NOTHING
* What is retained is class metadata, and since Java 8 that lives in
* Metaspace — native memory, outside -Xmx and outside a heap dump. The
* heap really was healthy. `jcmd <pid> VM.classloader_stats` would have
* shown four live loaders in a process that had deployed four times,
* which is the whole diagnosis in one command.
*
* Under Java 7 the same leak filled a fixed PermGen and threw
* OutOfMemoryError: PermGen space within the hour. Removing the ceiling
* did not fix the bug, it removed the alarm.
*/
public class Solution {
/** The "plugin" class. Each deploy loads its own private copy of this. */
public static class Plugin {
public static String describe() {
return "plugin";
}
}
static final String PLUGIN = "Solution$Plugin";
/**
* A deploy's class loader. It loads Plugin itself rather than delegating,
* which is what gives each deploy an independent copy — and what makes
* the loader worth collecting once the deploy is superseded.
*/
static final class DeployLoader extends ClassLoader {
final int version;
DeployLoader(ClassLoader parent, int version) {
super(parent);
this.version = version;
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (!name.equals(PLUGIN)) {
return super.loadClass(name, resolve);
}
synchronized (getClassLoadingLock(name)) {
Class<?> already = findLoadedClass(name);
if (already != null) {
return already;
}
try (InputStream in = getParent().getResourceAsStream(PLUGIN + ".class")) {
byte[] bytes = in.readAllBytes();
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
}
/* ── the two things that hold on, both added for good reasons ── */
/** Added so the admin console could list what had ever been deployed. */
static final List<Class<?>> DEPLOY_HISTORY = new ArrayList<>();
/** Added so a request could find the plugin handling it. */
static final ThreadLocal<Class<?>> ACTIVE_PLUGIN = new ThreadLocal<>();
static DeployLoader deploy(int version) throws Exception {
DeployLoader loader = new DeployLoader(Solution.class.getClassLoader(), version);
Class<?> plugin = loader.loadClass(PLUGIN);
plugin.getMethod("describe").invoke(null);
DEPLOY_HISTORY.add(plugin);
ACTIVE_PLUGIN.set(plugin);
return loader;
}
/**
* FIX: release everything that reaches into the deploy, not just the
* loader variable.
*
* Keeping the admin console working is still possible — it just cannot
* keep Class objects. Record the name and the version, which are plain
* strings and hold nothing.
*/
static void undeploy(int version) {
for (Class<?> deployed : DEPLOY_HISTORY) {
HISTORY_NAMES.add("v" + version + " " + deployed.getName());
}
DEPLOY_HISTORY.clear();
ACTIVE_PLUGIN.remove();
}
/** What the admin console actually needed: names, not Class objects. */
static final List<String> HISTORY_NAMES = new ArrayList<>();
public static void main(String[] args) throws Exception {
List<WeakReference<ClassLoader>> probes = new ArrayList<>();
System.out.println("── four deploys ──");
for (int version = 1; version <= 4; version++) {
DeployLoader loader = deploy(version);
probes.add(new WeakReference<>(loader));
undeploy(version);
loader = null;
System.out.println(" deployed and superseded v" + version);
}
int released = 0;
for (WeakReference<ClassLoader> probe : probes) {
if (collected(probe)) released++;
}
System.out.println();
System.out.println("history kept, holding nothing : " + HISTORY_NAMES.size() + " entries");
System.out.println();
System.out.println("deploys made : " + probes.size());
System.out.println("class loaders released : " + released);
System.out.println("still pinned in memory : " + (probes.size() - released));
System.out.println();
System.out.println("every superseded deploy released : " + (released == probes.size()));
System.out.println(released == probes.size() ? "PASS" : "FAIL");
}
/**
* System.gc() is a hint, so asking once proves nothing. This asks
* repeatedly with allocation pressure in between, and gives up after a
* bounded number of attempts so a leak cannot hang the build.
*/
static boolean collected(WeakReference<?> ref) {
for (int attempt = 0; attempt < 50 && ref.get() != null; attempt++) {
System.gc();
byte[] churn = new byte[1 << 20];
if (churn.length < 0) System.out.print("");
}
return ref.get() == null;
}
}Stretch
Add a fifth deploy that registers a shutdown hook from inside the plugin,
and watch it pin the loader through a route neither of the first two fixes
covers. Then write the general rule you would give the team for anything a
plugin is allowed to register with the container — the answer is not "never
register anything".