Profiling a monolith to find CPU and memory bottlenecks is often framed as a "gotcha" problem, but the real trick is understanding that the monolith isn’t a single entity, but a collection of independent, yet interacting, processes that share resources.

Let’s say you’ve got a Java monolith running in Kubernetes, and you’re seeing elevated CPU usage and occasional OutOfMemory errors. The core issue is that the JVM, your application code, and the underlying OS are all competing for CPU and memory, and when one part of the application spikes, it can starve others, leading to cascading failures.

Common Causes and Fixes

1. Unbounded Thread Pools

  • Diagnosis: Check your application logs for thread dump analysis or use a tool like jstack to see if you have a massive number of threads. In Kubernetes, you can get a rough idea by looking at kubectl top pod <pod-name>.
    kubectl exec <pod-name> -- jstack <thread-id> > thread_dump.txt
    
    Then, analyze thread_dump.txt for an excessive number of RUNNABLE or BLOCKED threads.
  • Fix: Explicitly configure bounded thread pools for critical operations. For example, in Spring Boot with Tomcat, you’d set server.tomcat.threads.max=200 and server.tomcat.threads.min-idle=50 in your application.properties or application.yml. For executor services in Java, use Executors.newFixedThreadPool(int nThreads).
  • Why it works: Bounded thread pools prevent the application from creating an infinite number of threads, which consume CPU for context switching and memory for stack frames, effectively capping the system’s ability to handle concurrent requests and preventing resource exhaustion.

2. Excessive Heap Allocation and GC Pressure

  • Diagnosis: Enable JVM garbage collection logging. In your JVM arguments, add:
    -Xlog:gc*:<log-file-path>
    
    Then, analyze the logs for frequent, long Full GCs (e.g., Pause Young, Pause Old). Tools like GCViewer can help visualize this. Also, monitor heap usage with tools like jstat -gcutil <pid> 1s.
  • Fix: Optimize object creation. Identify hot spots in your code that create many short-lived objects, especially within tight loops. Consider object pooling or reusing objects where possible. Tune garbage collector settings if necessary, but optimization is usually preferred. For instance, if you’re using the G1 collector, you might adjust -XX:MaxGCPauseMillis=200 to aim for shorter pause times, but this is an advanced tuning step.
  • Why it works: Reducing unnecessary object creation lowers the rate at which the heap fills up, thereby reducing the frequency and duration of garbage collection cycles, which consume significant CPU and can pause application threads.

3. Memory Leaks

  • Diagnosis: Take heap dumps at different points in time. Use jmap -dump:live,format=b,file=heapdump.hprof <pid>. Compare these dumps using tools like Eclipse Memory Analyzer Tool (MAT) or VisualVM. Look for objects that are growing in number and retaining significant amounts of memory unexpectedly. Common culprits are static collections that are never cleared, listeners that aren’t unregistered, or thread-locals that are not cleaned up.
  • Fix: Systematically identify and remove the source of the leak. If a static Map is growing, ensure there’s a mechanism to remove entries when they are no longer needed. If it’s an unclosed resource, ensure try-with-resources or explicit close() calls are used.
  • Why it works: Memory leaks prevent garbage collection from reclaiming memory that is no longer in use, leading to gradual or rapid heap growth that eventually causes OutOfMemoryError or severe GC thrashing. Fixing leaks ensures memory is returned to the JVM.

4. Inefficient Database Queries / N+1 Problem

  • Diagnosis: Enable SQL logging in your application framework (e.g., Hibernate’s show_sql and format_sql properties). Monitor your database’s slow query logs. Use application performance monitoring (APM) tools like New Relic or Datadog to identify database call patterns. Look for repeated identical queries or a large number of small queries executed in quick succession for a single logical operation.
  • Fix: Optimize queries using eager fetching (e.g., JOIN FETCH in JPA/Hibernate) to retrieve related data in a single query, or implement batching for inserts/updates. Cache frequently accessed, rarely changing data.
  • Why it works: Inefficient database interactions, especially the N+1 select problem, cause excessive I/O and CPU load on both the application server (for processing many small results) and the database, leading to performance degradation and resource contention.

5. High CPU Usage in Native Code / External Libraries

  • Diagnosis: Use JVM profiling tools like async-profiler or JProfiler. These tools can profile both Java code and native code (including JNI calls and OS interactions). Look for methods consuming a disproportionate amount of CPU time. If native code is the culprit, you might see high CPU usage attributed to libraries like native image processing libraries, SSL/TLS implementations, or even garbage collector threads themselves.
  • Fix: If it’s a library issue, consider upgrading to a newer version, as performance bugs are often fixed. If it’s your own native code, optimize it. If it’s a bug in a third-party library, you might need to find a workaround or report it. For GC threads, tuning GC parameters or increasing heap size might alleviate pressure.
  • Why it works: Inefficient or resource-intensive native code can bypass JVM optimizations and directly consume CPU, becoming a bottleneck that regular Java profiling might miss.

6. Resource Contention within the Monolith

  • Diagnosis: Use system-level monitoring tools like top, htop, or vmstat on the host machine (or within the Kubernetes node if you have access). Observe CPU steal time if running in a virtualized environment. Within the container, use docker stats or kubectl top pod. Look for high CPU usage not tied to specific application threads but rather system processes or kernel activities. Also, monitor I/O wait times (iowait in top).
  • Fix: Optimize I/O operations. Ensure your application isn’t performing excessive disk reads/writes. If it is, consider caching or asynchronous I/O. If the issue is CPU contention with other processes on the same host, consider resource requests and limits in Kubernetes to ensure your pod gets its allocated CPU.
  • Why it works: High I/O wait times indicate the CPU is idle waiting for disk or network operations, which can be a bottleneck. Resource contention at the host level means your application is not getting the CPU it needs, or is being starved by other processes.

After fixing these, your next likely error will be related to network saturation or a different, more obscure, application-specific bottleneck.

Want structured learning?

Take the full Monolith course →