Memory Management and JVM Internals

A complete guide to how the JVM works beneath your code: architecture, heap and stack memory, garbage collection algorithms, reference types, memory leaks, the string pool, and JVM tuning flags.

JVM Architecture

When you run a Java program, you are not running it directly on the CPU. You are running it inside the Java Virtual Machine, an abstraction layer that makes Java programs portable across operating systems and hardware architectures. The JVM has three major subsystems, and understanding their roles helps you understand nearly every performance and memory characteristic of Java applications.

The three JVM subsystems

Each subsystem has a distinct responsibility. Problems in each one produce different symptoms.

  • Class Loader subsystem:Reads .class files from disk or the network, verifies their bytecode for safety, and links them into the running JVM. It operates in three phases: loading, linking (verification, preparation, resolution), and initialization. The bootstrap, extension, and application class loaders form a delegation hierarchy.
  • Runtime Data Areas:The JVM's memory regions: heap (objects), method area/Metaspace (class metadata), JVM stacks (one per thread, holds call frames), native method stacks, and the PC register (current instruction pointer per thread).
  • Execution Engine:Converts bytecode to machine instructions and executes them. The interpreter runs bytecode directly. The JIT (Just-In-Time) compiler identifies hot code paths and compiles them to native machine code for much faster execution. The garbage collector manages heap memory.

Inspecting the JVM at Runtime

Java

Reading memory sizes, available processors, JVM version, and class loader hierarchy programmatically.

Heap vs Stack Memory

Java uses two primary memory regions, and the distinction between them is one of the most important concepts in understanding how the runtime behaves.

The stack is per-thread memory. Every time a method is called, the JVM pushes a new frame onto the calling thread's stack. That frame contains the method's local variables and its return address. When the method returns, the frame is popped off and its memory is instantly reclaimed. Stack allocation and deallocation are extremely fast: it is just a pointer increment and decrement. The stack is limited in size (typically a few hundred kilobytes to a few megabytes). Exceeding it, usually by unbounded recursion, produces a StackOverflowError.

The heap is shared across all threads. Every object created with new lives on the heap, as do their instance variables. The heap can grow large (configured with -Xmx), but allocations are slower than stack allocations and objects must eventually be reclaimed by the garbage collector. Variables on the stack that hold object references point into the heap. The variable (the reference) is on the stack; the actual object data is on the heap.

Stack vs Heap comparison

Knowing where data lives explains performance characteristics and common errors.

  • Stack / Thread-local:Method call frames, local primitive variables (int, double, boolean), local reference variables (the reference itself, not the object). Automatically reclaimed when method returns.
  • Heap / Shared:All objects created with new, all instance fields, all elements of arrays. Reclaimed by the garbage collector when no live references exist.
  • StackOverflowError:Stack limit exceeded, almost always caused by infinite or excessively deep recursion.
  • OutOfMemoryError: Java heap space:Heap limit reached. Either the application needs more memory (-Xmx) or it has a memory leak.
  • Escape analysis:A JIT optimization that detects when an object never leaves the method that created it. Such an object can be allocated on the stack or in registers, avoiding GC pressure entirely.

Stack and Heap in Practice

Java

Demonstrating where variables live, StackOverflowError from recursion, and measuring heap usage before and after allocation.

Method Area and Metaspace

Every class loaded into the JVM has metadata: its name, its superclass, its interfaces, the bytecode of its methods, its constant pool (a table of literals, method names, and type references), and information about its fields. All of this must live somewhere in memory. That place is the method area.

In Java 7 and earlier, the method area was implemented as a fixed-size region of the heap called PermGen (Permanent Generation). Its size was configured with -XX:MaxPermSize. When applications loaded many classes or used heavy code generation (ORM frameworks, application servers, dynamic proxies), PermGen would fill up and produce a notoriously unhelpful OutOfMemoryError: PermGen space error.

Java 8 replaced PermGen with Metaspace. The critical difference is location and growth strategy. Metaspace lives in native memory, outside the Java heap. It grows automatically as more classes are loaded. There is no fixed ceiling unless you set one with -XX:MaxMetaspaceSize. Without a limit, the JVM will use as much native memory as the OS allows, which eliminates most PermGen-related errors. Class metadata in Metaspace is garbage collected when the class loader that loaded the class is itself garbage collected, which typically happens with dynamic class loading in OSGi or application server contexts.

PermGen vs Metaspace and Class Loading

Java

Reading Metaspace usage through management beans and observing loaded class counts.

Garbage Collection Concepts

Java manages heap memory automatically. You allocate objects; the garbage collector figures out which ones are no longer needed and reclaims their memory. The fundamental question the GC must answer is: which objects are still in use?

The answer is based on reachability. The GC starts from a set of root references: local variables in thread stacks, static fields, and references held by the JVM itself. It traverses all references from those roots, marking every object it can reach. Any object not marked after this traversal is unreachable and eligible for collection. This mark-then-sweep approach is the foundation of all modern GC algorithms.

Java's heap is organized into generations. Most objects die young: you create a temporary list inside a method, the method returns, and the list is immediately garbage. The Young generation collects frequently and quickly. Objects that survive multiple young collections graduate to the Old generation (also called Tenured), which is collected less often with longer pauses. This generational hypothesis, "most objects die young," is what makes GC practical for interactive and server applications.

GC algorithms: a progression

Each collector makes different trade-offs between throughput, latency, and memory overhead. The right choice depends on your application's requirements.

  • Serial GC (-XX:+UseSerialGC):Uses a single thread for both young and old generation collection. Everything pauses during GC. Designed for small applications and single-CPU environments.
  • Parallel GC (-XX:+UseParallelGC):Uses multiple threads for collection, reducing total GC time at the cost of still-significant stop-the-world pauses. Maximizes throughput. Default in Java 8.
  • CMS (-XX:+UseConcMarkSweepGC):Concurrent Mark-Sweep. Runs most GC work concurrently with the application to reduce pause times. Deprecated in Java 9, removed in Java 14.
  • G1 (-XX:+UseG1GC):Garbage First. Divides the heap into equal-sized regions. Collects the most garbage-dense regions first to meet a configurable pause time target. Default since Java 9.
  • ZGC (-XX:+UseZGC):Sub-millisecond pauses regardless of heap size (terabytes). Available as production-ready from Java 15. Ideal for latency-sensitive applications with large heaps.
  • Shenandoah:Similar low-latency goals to ZGC. Available in OpenJDK builds. Performs concurrent compaction to avoid fragmentation without pausing.

GC Monitoring and GC-Related Events

Java

Reading GC information through management beans, simulating allocation pressure, and observing collection counts.

Strong, Soft, Weak, and Phantom References

Java's default reference is a strong reference: any object reachable through a strong reference will not be collected. But sometimes you want to hold a reference to an object in a way that does not prevent the GC from reclaiming it when memory is needed, or when no other code cares about it. The java.lang.ref package provides three weaker reference types, each with different collection semantics.

The four reference strengths

The GC treats each type differently. Choosing the right one for the problem at hand prevents both memory leaks and premature collection.

  • Strong reference (default):Object is never collected while any strong reference exists. This is what you get with every ordinary variable assignment.
  • SoftReference<T>:Object is kept as long as memory is adequate. The JVM promises to clear all soft references before throwing OutOfMemoryError. Ideal for memory-sensitive caches: entries stay in memory until the heap fills up.
  • WeakReference<T>:Object is collected in the very next GC cycle if no strong or soft references exist. Use for canonical mappings and caches where you do not want the cache entry to prevent collection. WeakHashMap uses this for its keys.
  • PhantomReference<T>:The weakest form. get() always returns null. The reference is enqueued into a ReferenceQueue after the object is finalized. Used for scheduling cleanup actions for native or off-heap resources without relying on the deprecated finalize().

Soft, Weak, and Phantom References

Java

Demonstrating collection behaviour with WeakReference and SoftReference, and using a ReferenceQueue for cleanup notification.

Memory Leaks in Java

The common assumption is that garbage collection eliminates memory leaks. It eliminates one kind of leak: forgetting to free allocated memory when you are done with it. Java collects objects automatically, so that particular mistake is impossible. However, a different kind of leak is very much possible: holding a strong reference to an object longer than necessary, preventing the GC from reclaiming it.

From the GC's perspective, an object that you are no longer semantically using but that is still reachable through some reference chain is as alive as one you are actively using. The GC cannot know the difference between "I am still going to use this" and "I forgot to remove this from the list." Memory leaks in Java are therefore really unintentional reference retention.

The symptoms of a leak are gradual heap growth over time, increasing GC pressure and frequency, and eventually an OutOfMemoryError after the application has been running for a while. The longer the application runs, the more memory it uses, even if the number of active objects is stable.

Common causes of Java memory leaks

All of these share the same root cause: a strong reference to an object outliving its useful life.

  • Static collections:Storing objects in a static List, Map, or Set and never removing them. Static fields are GC roots; their contents are always reachable.
  • Listeners not deregistered:Adding an event listener to a publisher makes the publisher hold a reference to the listener. If the listener is never deregistered, it and all objects it references are kept alive for as long as the publisher lives.
  • ThreadLocal not removed:In thread-pool environments, ThreadLocal values survive to the next request handled by the same thread. Calling remove() when the request completes is mandatory.
  • Inner class holding outer reference:A non-static inner class or anonymous class holds an implicit reference to its enclosing instance. If the inner class instance lives longer than intended, it keeps the outer object alive.
  • Caches without eviction:A cache that grows without bound. Adding a size limit, TTL, or using WeakHashMap prevents unbounded accumulation.
  • Unclosed streams and connections:While not a heap leak per se, failing to close file handles and database connections exhausts OS-level resources and can indirectly cause heap issues.

Memory Leak Patterns and Fixes

Java

A static cache growing without eviction, a listener that is never deregistered, and the corrected patterns.

finalize() and System.gc()

The finalize() method was Java's original mechanism for running cleanup code before an object is garbage collected. You could override it in any class and the GC would call it on eligible objects before reclaiming them. In practice it caused more problems than it solved: finalization is unpredictable, objects can be "resurrected" during finalization, the finalizer thread can lag behind the GC and cause a backlog of uncollected objects, and finalization delays the actual memory reclamation by at least one additional GC cycle. It was deprecated in Java 9 and is removed in Java 18. The modern replacement is the Cleaner API or PhantomReference with a ReferenceQueue.

System.gc() is a hint to the JVM that now might be a good time to run garbage collection. The JVM is free to ignore this hint entirely. In production code, calling System.gc() is almost always wrong. It can trigger expensive full GC cycles at unpredictable moments, interfering with the JVM's own carefully tuned collection strategy. The one legitimate use is in performance profiling and benchmarking, where you want the heap in a known state before measuring, and in tests that verify reference collection behaviour.

Modern Cleanup with Cleaner (replacing finalize)

Java

Using java.lang.ref.Cleaner to schedule a cleanup action when an object becomes unreachable.

String Pool and Interning

Strings are the most commonly created objects in most Java applications. To reduce memory usage, the JVM maintains a string pool, also called the string intern pool. When you write a string literal in source code, the compiler stores it in the class's constant pool. At class load time the JVM checks the string pool: if an equal string already exists, both references point to the same object. If not, the string is added to the pool.

This is why the == operator can return true for two string literals but must not be used for general string comparison. Two literal strings with the same content share a pooled object, but a string created with new String("hello") always creates a new heap object outside the pool. This is also why you should always compare strings with equals(): it compares the character content, not the object identity.

The String.intern() method manually adds a string to the pool. If the pool already contains an equal string, the pooled reference is returned. Calling intern() on a string created at runtime puts it in the pool so future comparisons with == work. In Java 7 the string pool was moved from PermGen into the main heap, so interned strings are subject to normal GC. In Java 8+, Metaspace handles class metadata but the string pool is still on the heap.

String Pool and Interning

Java

Reference identity vs. content equality, the effect of new String(), intern(), and string literal sharing.

JVM Tuning Basics

The JVM has sensible defaults that work for most applications, but long-running server applications, high-throughput batch jobs, and latency-sensitive systems often benefit from explicit tuning. All JVM options are passed on the command line at startup and fall into three categories: standard options (-), non-standard options ( -X), and advanced options ( -XX). The non-standard and advanced options are JVM-specific and can change between versions.

Essential JVM tuning flags

These are the flags you will encounter most often. Always profile before tuning, and change one variable at a time.

  • -Xms<size>:Initial heap size. The JVM allocates this much memory at startup. Setting Xms equal to Xmx prevents the JVM from spending time growing the heap and avoids heap-resize pauses. Example: -Xms512m
  • -Xmx<size>:Maximum heap size. The JVM never allocates more than this. OutOfMemoryError is thrown if allocation would exceed it. Example: -Xmx2g
  • -Xss<size>:Thread stack size. Reduce this if you create many threads to save memory; increase it if you hit StackOverflowError in deep recursive code. Example: -Xss256k
  • -XX:+UseG1GC:Enables the G1 garbage collector. Default in Java 9+. Good for most server applications with heaps above 4 GB.
  • -XX:MaxGCPauseMillis=<n>:Target maximum GC pause time in milliseconds for G1 and ZGC. The GC tries to stay within this budget. Example: -XX:MaxGCPauseMillis=200
  • -XX:MaxMetaspaceSize=<size>:Limits the size of Metaspace. Without this, Metaspace grows until the OS refuses. Useful for catching class-loader leaks early. Example: -XX:MaxMetaspaceSize=256m
  • -XX:+PrintGCDetails / -Xlog:gc:Enables GC logging. Essential for diagnosing GC problems. In Java 9+ use -Xlog:gc:file=gc.log.
  • -XX:+HeapDumpOnOutOfMemoryError:Writes a heap dump to disk when OOM occurs. The dump can be analyzed with tools like Eclipse MAT or VisualVM to find the leak.

Reading JVM Configuration at Runtime

Java

Printing effective heap settings, GC information, and memory pool details through management MXBeans.

Quiz - Test Your Knowledge

Ten questions covering the JVM architecture, heap and stack memory, Metaspace, garbage collection algorithms and stop-the-world pauses, reference types, memory leaks, the string pool and interning, and JVM tuning flags. Read each option carefully before selecting your answer.

Knowledge Check

1. What is stored on the Java stack, and what is stored on the heap?

2. What is Metaspace, and how does it differ from the old PermGen?

3. What does it mean for an object to be "eligible for garbage collection"?

4. What is a "stop-the-world" pause in garbage collection?

5. What is the difference between a WeakReference and a SoftReference?

6. Which of the following is a classic cause of a memory leak in Java?

7. What does String.intern() do?

8. What is the G1 garbage collector designed to optimize?

9. What does the JVM flag -Xmx512m configure?

10. What is a PhantomReference used for?