Introduction

Java OutOfMemoryError: Metaspace occurs when the JVM runs out of native memory for storing class metadata, method information, constant pool data, and JIT optimization structures. Metaspace replaced PermGen in Java 8 and stores class definitions, method metadata, interned strings, and reflection data in native memory outside the Java heap. Common causes include dynamic class generation without limits (CGLIB proxies, ASM bytecode generation), framework misconfiguration creating excessive proxy classes, classloader leaks preventing unloaded classes from being garbage collected, hot deployment/redeployment without proper cleanup in application servers, excessive use of reflection caching metadata, string interning abusing String.intern(), JNI native code registering classes without cleanup, Groovy/Scala dynamic language scripts generating unique classes, and JVM Metaspace limits set too low for application needs. The fix requires identifying classloader leaks, configuring appropriate Metaspace limits, tuning framework class generation, and implementing proper undeploy cleanup. This guide provides production-proven troubleshooting for Metaspace OOM errors across standalone applications, application servers, and cloud deployments.

Symptoms

  • java.lang.OutOfMemoryError: Metaspace in logs
  • Application server deployment fails with Metaspace error
  • Memory grows continuously during hot redeploy cycles
  • JVM crashes with native memory exhaustion
  • GC runs frequently but cannot reclaim Metaspace
  • High number of loaded classes (jstat shows class count growing)
  • Application works initially but fails after multiple deploys
  • Dynamic proxy generation slows down over time
  • Reflection-heavy operations cause memory pressure
  • Native memory tracking shows Class section growing

Common Causes

  • Classloader leak: old classloader still referenced after undeploy
  • Excessive CGLIB/Hibernate proxy class generation
  • Groovy scripts compiled to unique classes per execution
  • -ASM/Javassist bytecode generation without caching
  • String.intern() overuse filling interned string table
  • Application server hot deploy without proper cleanup
  • JVM Metaspace limit too low (-XX:MaxMetaspaceSize)
  • Memory leak in native code (JNI) registering classes
  • Framework scanning generating excessive metadata
  • JIT compilation generating large optimization data

Step-by-Step Fix

### 1. Diagnose Metaspace issues

Check current Metaspace usage:

```bash # Enable Metaspace logging # Add JVM flags: # -XX:+PrintMetaspaceStatistics # -XX:TraceClassLoading # -XX:TraceClassUnloading

# Run application and check logs for: # Metaspace: min=1048576, max=134217728, used=52428800, reserved=67108864

# Check loaded class count jstat -class <pid> 1000 10

# Output: # Loaded Classes # 12345 12300 # Loaded vs Unloaded # # If Loaded keeps growing, classes not being unloaded

# Check Metaspace via jcmd jcmd <pid> VM.metaspace

# Output shows: # Metaspace: # Used: 50 MB # Free: 10 MB # Reserved: 64 MB # Capacity: 58 MB

# Check with jmap jmap -heap <pid> | grep -A 10 "Metaspace"

# Or use jhsdb (Java 9+) jhsdb jmap --heap --pid <pid> ```

Enable native memory tracking:

```bash # Start JVM with native memory tracking java -XX:NativeMemoryTracking=summary -jar application.jar

# Or for detailed tracking (more overhead) java -XX:NativeMemoryTracking=detail -jar application.jar

# Check native memory usage jcmd <pid> VM.native_memory summary

# Output: # Native Memory Tracking: # Total: reserved=2GB, committed=1GB # # - Java Heap (reserved=1GB, committed=512MB) # - Class (reserved=128MB, committed=64MB) # - Thread (reserved=32MB, committed=32MB) # - Code (reserved=256MB, committed=128MB) # - GC (reserved=128MB, committed=64MB) # - Other (reserved=64MB, committed=32MB) # - Arena (reserved=32MB, committed=16MB) # # Class section shows Metaspace usage

# Detailed view jcmd <pid> VM.native_memory detail

# Shows breakdown of Class memory: # - Class space for metadata # - Class hierarchy data # - Method data # - Constant pool entries ```

Identify loaded classes:

```bash # Dump all loaded classes jcmd <pid> VM.class_hierarchy

# Or jmap -clstats <pid> > classloader-stats.txt

# Count classes by package jcmd <pid> VM.class_hierarchy | \ grep "java\|com\|org" | \ cut -d' ' -f2 | \ sort | uniq -c | sort -rn | head -20

# Find dynamically generated classes jcmd <pid> VM.class_hierarchy | grep -i "proxy\|cglib\|enhancer\|$$"

# Heap dump analysis for classloader leaks jmap -dump:format=b,file=heap.hprof <pid>

# Analyze with Eclipse MAT (Memory Analyzer Tool) # Open heap.hprof in MAT # Run "Classloader Leak Detection" query # Look for: # - Multiple classloaders for same application # - Classes holding references to classloader # - ThreadLocal leaks holding class references ```

### 2. Configure Metaspace limits

Set appropriate Metaspace size:

```bash # JVM flags for Metaspace configuration

# Minimum initial Metaspace size -XX:MetaspaceSize=128m

# Maximum Metaspace size (critical - set appropriately) -XX:MaxMetaspaceSize=512m

# Compressed class space size (for compressed oops) -XX:CompressedClassSpaceSize=128m

# Example configurations:

# Small application (minimal frameworks) -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=256m

# Medium application (Spring Boot, Hibernate) -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m

# Large application (multiple frameworks, dynamic proxies) -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=1024m

# Application server with multiple deployments -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=768m

# Check current settings java -XX:+PrintFlagsFinal -version | grep -i metaspace

# Output: # uintx MetaspaceSize = 134217728 {=128MB} # uintx MaxMetaspaceSize = 18446744073709551615 {=unlimited} # uintx CompressedClassSpaceSize = 1073741824 {=1GB} ```

Application server Metaspace configuration:

```bash # Tomcat - setenv.sh or setenv.bat export JAVA_OPTS="$JAVA_OPTS -XX:MetaspaceSize=128m" export JAVA_OPTS="$JAVA_OPTS -XX:MaxMetaspaceSize=512m"

# Or CATALINA_OPTS export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=128m" export CATALINA_OPTS="$CATALINA_OPTS -XX:MaxMetaspaceSize=512m"

# JBoss/WildFly - standalone.conf export JAVA_OPTS="$JAVA_OPTS -XX:MetaspaceSize=256m" export JAVA_OPTS="$JAVA_OPTS -XX:MaxMetaspaceSize=768m"

# WebLogic - setDomainEnv.sh export USER_MEM_ARGS="-Xms512m -Xmx2g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m"

# Jetty - jetty.sh export JAVA_OPTIONS="$JAVA_OPTIONS -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m"

# For Docker deployments # Dockerfile ENV JAVA_TOOL_OPTIONS="-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m"

# Or docker run docker run -e JAVA_TOOL_OPTIONS="-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m" myapp ```

### 3. Fix classloader leaks

Detect classloader leaks:

```java // Classloader leak detection utility public class ClassLoaderLeakDetector {

public static void detectLeaks() { System.gc(); // Suggest GC

Thread.sleep(1000); // Wait for GC

// Get all classloaders via instrumentation // Or use JMX

int loadedClassCount = ManagementFactory.getClassLoadingMXBean() .getLoadedClassCount(); long totalLoadedClassCount = ManagementFactory.getClassLoadingMXBean() .getTotalLoadedClassCount(); long unloadedClassCount = ManagementFactory.getClassLoadingMXBean() .getUnloadedClassCount();

System.out.println("Loaded: " + loadedClassCount); System.out.println("Total Loaded: " + totalLoadedClassCount); System.out.println("Unloaded: " + unloadedClassCount);

// If unloaded is much less than total loaded, classes not being unloaded if (totalLoadedClassCount - unloadedClassCount > loadedClassCount * 1.5) { System.err.println("WARNING: Potential classloader leak detected!"); } } }

// Common classloader leak patterns:

// 1. ThreadLocal not removed // BAD: ThreadLocal holds reference to class static ThreadLocal<Formatter> formatter = ThreadLocal.withInitial(Formatter::new);

// FIX: Remove ThreadLocal when done formatter.remove(); // Call in finally block or cleanup

// 2. Static reference to classloaded object // BAD: Static field holds instance static List<MyClass> cache = new ArrayList<>();

// FIX: Clear cache on undeploy public static void clearCache() { cache.clear(); }

// 3. Thread not stopped // BAD: Background thread keeps running Thread worker = new Thread(() -> { while (true) { doWork(); } }); worker.start();

// FIX: Stop thread on undeploy volatile boolean running = true; Thread worker = new Thread(() -> { while (running) { doWork(); } }); public void stop() { running = false; worker.interrupt(); }

// 4. JDBC driver not deregistered // FIX: Deregister drivers on undeploy Enumeration<Driver> drivers = DriverManager.getDrivers(); while (drivers.hasMoreElements()) { Driver driver = drivers.nextElement(); try { DriverManager.deregisterDriver(driver); } catch (SQLException e) { // Log but continue } } ```

Spring application classloader fix:

```java // Spring Boot - configure for hot deploy scenarios

// application.properties # Reduce class generation spring.cglib.proxy.target-class=false

# Limit component scanning # Be specific about base packages @ComponentScan(basePackages = {"com.example.service", "com.example.repository"})

// Clear Spring caches on undeploy @Component public class CacheCleaner implements ApplicationListener<ContextClosedEvent> {

@Override public void onApplicationEvent(ContextClosedEvent event) { // Clear CGLIB caches org.springframework.cglib.core.DefaultGeneratorStrategy.CACHE.clear();

// Clear reflection caches org.springframework.util.ReflectionUtils.clearCache();

// Clear proxy caches org.springframework.aop.framework.ProxyProcessorSupport.clearProxyCache(); } }

// Hibernate - limit proxy generation // hibernate.properties # Use better proxy strategy hibernate.bytecode.provider=bytebuddy

# Enable second-level cache to reduce proxy recreation hibernate.cache.use_second_level_cache=true hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory ```

Application server undeploy cleanup:

```java // Web application cleanup listener import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener;

public class CleanupListener implements ServletContextListener {

@Override public void contextDestroyed(ServletContextEvent sce) { // Called before application undeploy

// 1. Stop all background threads ThreadManager.stopAll();

// 2. Clear all static caches CacheManager.clearAll();

// 3. Deregister JDBC drivers deregisterJDBCDrivers();

// 4. Clear ThreadLocals clearThreadLocals();

// 5. Close all connections ConnectionPool.closeAll();

// 6. Shutdown executors ExecutorServiceManager.shutdown();

// 7. Clear MBean registrations MBeanManager.unregisterAll(); }

private void deregisterJDBCDrivers() { Enumeration<Driver> drivers = DriverManager.getDrivers(); while (drivers.hasMoreElements()) { Driver driver = drivers.nextElement(); try { DriverManager.deregisterDriver(driver); System.out.println("Deregistered JDBC driver: " + driver); } catch (SQLException e) { System.err.println("Failed to deregister: " + driver); } } }

private void clearThreadLocals() { // Clear ThreadLocals for current thread // Prevents memory leak in thread pool scenarios } }

// Register in web.xml // <listener> // <listener-class>com.example.CleanupListener</listener-class> // </listener> ```

### 4. Fix dynamic class generation

Limit CGLIB proxy generation:

```java // BAD: Creating new proxy class for each call public Object createProxy(Object target) { // Each call generates new class Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(target.getClass()); enhancer.setCallback(new MyInterceptor()); return enhancer.create(); }

// CORRECT: Cache proxy classes private static final Map<Class<?>, Class<?>> proxyCache = new ConcurrentHashMap<>();

public Object createProxy(Object target) { Class<?> targetClass = target.getClass();

// Reuse proxy class from cache Class<?> proxyClass = proxyCache.computeIfAbsent(targetClass, key -> { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(key); enhancer.setCallbackFactory(new MyCallbackFactory()); return enhancer.createClass(); });

// Create instance of cached proxy class return proxyClass.getDeclaredConstructor() .newInstance(); }

// Or use Spring's proxy infrastructure which caches // @Transactional, @Async, etc. use cached proxies ```

Limit Groovy script compilation:

```groovy // BAD: Each script execution compiles new class class ScriptRunner { def runScript(String scriptText) { // Compiles new class every time! GroovyShell shell = new GroovyShell() return shell.evaluate(scriptText) } }

// CORRECT: Cache compiled scripts class ScriptRunnerCached { private final Map<String, Script> scriptCache = new ConcurrentHashMap<>() private final GroovyClassLoader classLoader = new GroovyClassLoader()

def runScript(String scriptKey, String scriptText) { Script script = scriptCache.computeIfAbsent(scriptKey, key -> { return classLoader.parseClass(scriptText).newInstance() }) return script.run() }

// Clear cache when needed void clearCache() { scriptCache.clear() classLoader.clearCache() } }

// Or use GroovyScriptEngine with caching GroovyScriptEngine engine = new GroovyScriptEngine("/path/to/scripts") // Engine caches compiled scripts internally ```

### 5. Fix string interning issues

Avoid excessive String.intern():

```java // BAD: Interning all strings fills intern pool public void processUserInput(String input) { // Each unique string goes to permgen/metaspace String interned = input.intern(); store(interned); }

// BAD: Interning generated strings public void parseData(String[] data) { for (String item : data) { // Generated strings fill intern pool String key = item.trim().toLowerCase().intern(); map.put(key, item); } }

// CORRECT: Only intern truly shared strings public void processSharedConfig(String configKey) { // Only intern if truly shared across application // and limited set of keys if (KNOWN_CONFIG_KEYS.contains(configKey)) { configKey = configKey.intern(); } store(configKey); }

// CORRECT: Use regular string references public void parseDataFixed(String[] data) { for (String item : data) { // Regular string - GC can collect when no references String key = item.trim().toLowerCase(); map.put(key, item); } }

// Check intern pool usage // JVM flag: -XX:+PrintStringTableStatistics // Output shows number of entries and memory usage ```

### 6. Monitor Metaspace

Set up Metaspace monitoring:

```java // JMX monitoring for Metaspace import java.lang.management.ManagementFactory; import java.lang.management.MemoryMXBean; import java.lang.management.MemoryUsage;

public class MetaspaceMonitor {

private final MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();

public void checkMetaspace() { MemoryUsage usage = memoryBean.getNonHeapMemoryUsage();

System.out.println("Non-Heap Used: " + usage.getUsed() / 1024 / 1024 + " MB"); System.out.println("Non-Heap Committed: " + usage.getCommitted() / 1024 / 1024 + " MB"); System.out.println("Non-Heap Max: " + usage.getMax() / 1024 / 1024 + " MB");

// Alert if approaching limit double usagePercent = (double) usage.getUsed() / usage.getMax() * 100; if (usagePercent > 80) { System.err.println("WARNING: Metaspace usage at " + usagePercent + "%"); } } }

// Prometheus metrics for Metaspace // Add JMX exporter to JVM // -javaagent:/path/to/jmx_prometheus_javaagent.jar=9404:config.yaml

// config.yaml rules: - pattern: 'java.lang<type=NonHeapMemoryUsage><(.*)>' name: jvm_memory_$1 type: GAUGE labels: type: nonheap

// Grafana dashboard query # jvm_memory_used{type="nonheap"} # jvm_memory_committed{type="nonheap"} # jvm_memory_max{type="nonheap"} ```

Alerting rules:

```yaml # Prometheus alerting rules for Metaspace

groups: - name: jvm-metaspace rules: - alert: MetaspaceUsageHigh expr: | jvm_memory_used{type="nonheap"} / jvm_memory_max{type="nonheap"} > 0.8 for: 5m labels: severity: warning annotations: summary: "Metaspace usage high on {{ $labels.instance }}" description: "Metaspace is {{ $value | humanizePercentage }} full"

  • alert: MetaspaceUsageCritical
  • expr: |
  • jvm_memory_used{type="nonheap"} / jvm_memory_max{type="nonheap"} > 0.9
  • for: 2m
  • labels:
  • severity: critical
  • annotations:
  • summary: "Metaspace usage critical on {{ $labels.instance }}"
  • description: "Metaspace is {{ $value | humanizePercentage }} full - OOM imminent"
  • alert: ClassloaderLeakSuspected
  • expr: |
  • increase(jvm_classes_loaded[1h]) > 1000
  • for: 1h
  • labels:
  • severity: warning
  • annotations:
  • summary: "Potential classloader leak on {{ $labels.instance }}"
  • description: "Loaded {{ $value }} classes in the last hour"
  • `

Prevention

  • Set appropriate -XX:MaxMetaspaceSize based on application needs
  • Clear caches and static references during application undeploy
  • Stop all background threads before undeploy
  • Deregister JDBC drivers, MBeans, and other registered components
  • Cache dynamically generated proxy classes
  • Limit String.intern() usage to truly shared constants
  • Use classloader leak detection tools before production deploy
  • Monitor class count and Metaspace usage in production
  • Avoid hot deploy in production - use blue/green deployment
  • Document framework configurations that affect class generation
  • **OutOfMemoryError: Java heap space**: Heap memory exhausted
  • **OutOfMemoryError: GC overhead limit exceeded**: GC running constantly
  • **OutOfMemoryError: unable to create native thread**: Thread limit reached
  • **NoClassDefFoundError**: Class was loaded but became unavailable
  • **ClassNotFoundException**: Class not found on classpath