The Java memory model, in the sense most developers search for, is the way a running JVM divides its memory into the heap for objects, one stack per thread for method calls, metaspace for class data and the code cache for compiled code. Each area has its own size limit, its own JVM flag and its own error when it runs full.
We need this model when we size a container, tune garbage collection, or read an error such as OutOfMemoryError: Metaspace or StackOverflowError, because the fix depends on which area ran out.
The following example creates a few values and shows where the JVM keeps each one on JDK 25.
int count = 3; // 3, a local variable in the stack frame
String city = "Pune"; // "Pune", reference on the stack, String object in the heap
List<String> cities = new ArrayList<>(List.of(city)); // [Pune], the ArrayList object is on the heap
long maxHeap = Runtime.getRuntime().maxMemory(); // 1568669696 bytes here, a quarter of the RAM the JVM sees
Notice that only the primitive and the references live in the stack frame of the method, whereas every object goes to the heap. We first look at all memory areas together and continue with the heap, thread stacks, metaspace and code cache with their flags and errors. A short section explains the other meaning of “Java memory model”, the thread visibility rules from the Java Language Specification.
1. JVM Memory Areas at a Glance
The Java Virtual Machine Specification defines the run-time data areas that every JVM has, and HotSpot, the JVM inside OpenJDK and Oracle JDK, implements them as concrete memory regions. Some areas are created when the JVM starts and are shared by all threads. Others are created for each thread and removed when the thread ends.

The table gives the size flag and the error for each area, which is what we look for when an app fails. The defaults are for a 64-bit Linux JDK 25.
| Area | Holds | Shared or per thread | Size flag (default) | Error when full |
|---|---|---|---|---|
| Heap | Objects, arrays, String pool, static field values | Shared | -Xmx (25% of RAM) | OutOfMemoryError: Java heap space |
| JVM stack | Frames with local variables and partial results | Per platform thread | -Xss (1 MB) | StackOverflowError |
| Metaspace | Class metadata, method bytecode, constant pools | Shared, native memory | -XX:MaxMetaspaceSize (no limit) | OutOfMemoryError: Metaspace |
| Code cache | Machine code from the JIT compiler | Shared, native memory | -XX:ReservedCodeCacheSize (240 MB) | Warning, the JIT stops compiling |
| Native method stack | Frames of native (JNI) methods | Per thread | Same as the thread stack | StackOverflowError |
| PC register | Address of the current bytecode instruction | Per thread | Fixed | None |
2. Heap Memory
The heap holds every object and array the app creates, and the garbage collector frees the objects that are no longer reachable. Since Java 7, the heap also holds the String pool and the values of static fields. The JVM reserves the heap at startup and grows it between the initial size (-Xms) and the maximum size (-Xmx).
Without -Xmx, JDK 25 sets the maximum heap to 25% of the memory it sees, which is the physical RAM or the memory limit of the container. This default is set by -XX:MaxRAMPercentage=25, so a Spring Boot application in a 2 GB container gets a 512 MB heap and can fail with OutOfMemoryError while 1.5 GB of the container stays unused.
# fixed heap size
java -Xms1g -Xmx1g -jar app.jar
# heap as a share of the container memory limit
java -XX:MaxRAMPercentage=75 -jar app.jar
In containers, we set the heap with -XX:MaxRAMPercentage and leave about a quarter of the memory for metaspace, the code cache, thread stacks and other native memory. A heap equal to the container limit gets the process killed by the operating system, because the other areas need memory too.
2.1. Young and Old Generation
Most objects in a typical app live for a short time, such as the request and response objects of one HTTP call. The heap of the default G1 collector and of the Serial and Parallel collectors is therefore split into generations, so the collector can clean the short-lived objects often and cheaply.
- The young generation holds new objects. It has one eden space, where the JVM allocates objects, and two survivor spaces, where objects that survived a collection wait.
- The old generation holds objects that survived several young collections. An object moves there after at most 15 collections (-XX:MaxTenuringThreshold=15), or earlier when the survivor space is full.
- Large objects, at least half a G1 region in size, go into humongous regions in the old generation, because copying them between survivor spaces would cost too much.
We can list the heap areas of the running JVM with the MemoryPoolMXBean API. With the default G1 collector on JDK 25, the heap has three pools, and the other pools belong to native memory.
List<String> heapPools = ManagementFactory.getMemoryPoolMXBeans().stream().filter(pool -> pool.getType() == MemoryType.HEAP).map(MemoryPoolMXBean::getName).toList(); // [G1 Eden Space, G1 Old Gen, G1 Survivor Space]
List<String> nonHeapPools = ManagementFactory.getMemoryPoolMXBeans().stream().filter(pool -> pool.getType() == MemoryType.NON_HEAP).map(MemoryPoolMXBean::getName).toList(); // [CodeHeap 'non-nmethods', Metaspace, CodeHeap 'profiled nmethods', Compressed Class Space, CodeHeap 'non-profiled nmethods']
The GC log shows the generations at work. In the trimmed G1 log of a small program with a 128 MB heap, each young collection empties the eden regions, a few objects stay in the survivor regions, and the old regions grow as long-lived objects get promoted.
$ java -Xmx128m -Xlog:gc,gc+heap=info SessionCache
[0.036s][info][gc,heap] GC(0) Eden regions: 14->0(42)
[0.036s][info][gc,heap] GC(0) Survivor regions: 0->1(2)
[0.036s][info][gc,heap] GC(0) Old regions: 2->2
[0.036s][info][gc ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 15M->1M(96M) 1.086ms
[0.081s][info][gc,heap] GC(3) Eden regions: 50->0(50)
[0.081s][info][gc,heap] GC(3) Survivor regions: 6->6(7)
[0.081s][info][gc,heap] GC(3) Old regions: 2->5
[0.081s][info][gc ] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 57M->10M(96M) 2.577ms
The collectors and their algorithms are covered in our guide to Java garbage collection algorithms. JDK 25 also added compact object headers (-XX:+UseCompactObjectHeaders), which shrink the header of every object from 12 or 16 bytes to 8 bytes on 64-bit platforms and so fit more objects into the same heap. The option is off by default.
3. Thread Stacks
Each platform thread gets its own JVM stack when it starts. Every method call pushes a frame onto the stack, and the frame holds the local variables, the method parameters and the intermediate results of the bytecode. When the method returns, the JVM removes the frame, so stack memory needs no garbage collection.
A primitive local variable lives in the frame itself. For an object, the frame holds only the reference, and the object lives in the heap. That is why two threads can share an object but never share a local variable.
public class PriceCalculator {
public double total(double price, int quantity) {
double net = price * quantity;
List<String> lines = new ArrayList<>();
lines.add("net=" + net);
return net * 1.18;
}
}
double total = new PriceCalculator().total(100.0, 2); // 236.0
While total() runs, its frame holds price, quantity, net and the reference lines. The ArrayList object and its String elements are on the heap and become unreachable when the frame is removed.
3.1. Stack Size and StackOverflowError
The stack has a fixed maximum size, 1 MB by default on 64-bit Linux, which we change with -Xss. A recursion that goes too deep fills the stack and throws a StackOverflowError. The number of frames that fit depends on the frame size, so a small recursive method gets far more frames than one with many local variables.
| Thread stack size | Frames before StackOverflowError (small recursive method, JDK 25) |
|---|---|
| -Xss256k | 3,644 |
| Default (1 MB) | 23,306 |
| -Xss4m | 101,948 |
For example, a service that parses JSON documents with a recursive tree walker works with every test file, but a customer uploads a document nested 50,000 levels deep and the parser thread dies with a StackOverflowError. Raising -Xss for the whole JVM makes every thread bigger. We can give a larger stack only to the parser thread, or rewrite the walker with an explicit ArrayDeque so the depth no longer depends on the stack.
Thread parser = Thread.ofPlatform().name("parser").stackSize(4L * 1024 * 1024).unstarted(() -> {}); // unstarted thread "parser" that requests a 4 MB stack
The stackSize() value is a request that the JVM may ignore on some platforms, so we test it on the target system.
3.2. Virtual Thread Stacks
Virtual threads, final since Java 21, do not get a fixed native stack. The JVM stores their frames in the heap as stack chunk objects, as described in JEP 444, and copies them to the stack of a carrier thread while the virtual thread runs. So a million virtual threads cost heap memory that grows and shrinks with their call depth, not a million times 1 MB.
4. Metaspace and Class Metadata
Metaspace holds the data the JVM keeps about each loaded class, such as the field and method layout, the bytecode of the methods and the runtime constant pool. It is the HotSpot implementation of the method area from the JVM specification. Since JDK 8 (JEP 122), metaspace lives in native memory outside the heap and replaced the Permanent Generation (PermGen).
Metaspace has no upper limit by default and grows as the app loads classes. A class’s metadata is freed only when the garbage collector unloads its class loader, which happens for application servers that redeploy apps and for code that creates classes at runtime, such as dynamic proxies or scripting engines.
- -XX:MaxMetaspaceSize sets an upper limit, which turns a class loader leak into a clear error instead of a slowly growing process.
- -XX:MetaspaceSize sets the size at which the first metaspace-triggered garbage collection runs (about 21 MB on JDK 25).
- -XX:CompressedClassSpaceSize limits the part of metaspace that holds class structures addressed by compressed pointers (1 GB by default).
When the limit is reached, the JVM throws OutOfMemoryError: Metaspace. The trimmed output comes from a program that keeps defining hidden classes with -XX:MaxMetaspaceSize=32m.
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.base/java.lang.ClassLoader.defineClass0(Native Method)
at java.base/java.lang.System$1.defineClass(System.java:2051)
Old startup scripts often still pass the PermGen flags. JDK 25 no longer knows them and refuses to start.
$ java -XX:MaxPermSize=256m -jar app.jar
Unrecognized VM option 'MaxPermSize=256m'
Error: Could not create the Java Virtual Machine.
| PermGen (JDK 7 and earlier) | Metaspace (JDK 8 and later) | |
|---|---|---|
| Location | Part of the Java heap | Native memory |
| Default maximum | Fixed, set by -XX:MaxPermSize | No limit |
| Flags | -XX:PermSize, -XX:MaxPermSize | -XX:MetaspaceSize, -XX:MaxMetaspaceSize |
| Typical error | OutOfMemoryError: PermGen space | OutOfMemoryError: Metaspace |
5. Code Cache
The JVM starts by interpreting bytecode and compiles frequently called methods to machine code with its JIT compilers, which compile code while the app runs. The code cache is the native memory area that stores this compiled code. Since JDK 9 (JEP 197), it is split into three segments, one for JVM-internal code, one for lightly optimized (profiled) methods and one for fully optimized methods, which are the three CodeHeap pools in the earlier pool list.
The default reserved size with tiered compilation is 240 MB (-XX:ReservedCodeCacheSize). When the code cache runs full, the JVM prints a warning and stops compiling new methods, so the app keeps running but slows down in the interpreter. Large apps with many classes, such as a monolith with hundreds of Spring beans, sometimes need a bigger code cache, and the NMT output in section 6 shows how much is in use.
6. Other Native Memory and Container Sizing
Each thread also has a native method stack for code written in C and called through JNI, and a PC (program counter) register that holds the address of the bytecode instruction the thread runs. Both are small. The larger native consumers are the garbage collector’s own data structures, direct NIO buffers and the stacks of many platform threads.
Native Memory Tracking (NMT) reports how much each area uses. We start the JVM with -XX:NativeMemoryTracking=summary and read the report with jcmd <pid> VM.native_memory summary. The trimmed output comes from a small program on JDK 25 with -Xmx512m, where reserved is the address space and committed is the memory in use.
Total: reserved=2012764059, committed=166471579
- Java Heap (reserved=536870912, committed=100663296)
- Class (reserved=1073819832, committed=209080)
- Thread (reserved=13687880, committed=691272)
- Code (reserved=253725160, committed=7817704)
- GC (reserved=48512946, committed=39993266)
- Metaspace (reserved=67121432, committed=602392)
For a container, the memory limit must cover the sum of all committed areas, not only the heap. A web app with a 1.5 GB heap, 150 MB of metaspace, 50 MB of code cache, 200 threads with 1 MB stacks and 100 MB of GC data needs about 2 GB before counting direct buffers. So -XX:MaxRAMPercentage=75 is a common starting point, which we adjust after reading NMT in a load test.
7. The Other Java Memory Model (JLS Chapter 17)
The name “Java memory model” has a second, formal meaning. Section 17.4 of the Java Language Specification defines when a write by one thread becomes visible to a read in another thread, through rules called happens-before relations. It says nothing about heap or stack layout.
The happens-before rules matter for concurrent code. For example, a background job checks a stop flag that another thread sets. Without volatile, the JIT compiler may keep the flag in a register or move the read out of the loop, and the job never sees the change. A write to a volatile field happens before every later read of it, so the job stops.
public class JobControl {
private volatile boolean stopped;
public void stop() {
stopped = true;
}
public boolean isStopped() {
return stopped;
}
}
JobControl control = new JobControl();
control.stop();
boolean stopped = control.isStopped(); // true
Starting a thread, joining it, entering and leaving a synchronized block and the classes of java.util.concurrent create the same visibility guarantees. Our Java concurrency tutorial covers them, and volatile and thread safety explains the keyword in detail.
8. Java Memory Model FAQs
Memory questions often start with an error message in production or with an interview question about where a particular value is stored.
8.1. Is Metaspace Part of the Heap?
No. Metaspace is native memory that the JVM allocates outside the Java heap, so -Xmx does not limit it, and a heap dump does not contain it. Only the Class objects that represent classes in Java code live on the heap.
8.2. Where Are Static Variables Stored in Java?
The values of static fields are stored on the heap, inside the java.lang.Class object of their class, since JDK 7. The field definitions, such as the name and the type, are part of the class metadata in metaspace. A static field that refers to an object keeps that object reachable as long as the class is loaded, which is a common cause of memory leaks in caches.
8.3. What Is the Default Heap Size in Java 25?
The default maximum heap is 25% of the memory the JVM sees, and the default initial heap is about 1.6% of it (-XX:InitialRAMPercentage=1.5625). In a container, the JVM uses the container memory limit instead of the host memory. We can print the values with java -XX:+PrintFlagsFinal -version and look for MaxHeapSize and InitialHeapSize.
$ java -XX:+PrintFlagsFinal -version | grep -E " (MaxHeapSize|InitialHeapSize) "
size_t InitialHeapSize = 98566144 {product} {ergonomic}
size_t MaxHeapSize = 1568669696 {product} {ergonomic}
8.4. What Is the Difference Between Stack and Heap Memory in Java?
The stack belongs to one thread and holds method frames with local variables, and its memory is freed when a method returns. The heap is shared by all threads and holds objects, and the garbage collector frees it. A stack that runs full throws StackOverflowError, whereas a full heap throws OutOfMemoryError.
8.5. Where Is the String Pool Stored?
The String pool has been part of the heap since JDK 7, and before that it was in PermGen. String literals and strings returned by intern() are pool entries, and the garbage collector can remove entries that nothing refers to. More details are in our article on the String constant pool.
9. Conclusion
The JVM splits its memory into a shared heap for objects, one stack per platform thread, metaspace for class metadata and a code cache for JIT-compiled code. Each area has its own flag (-Xmx, -Xss, -XX:MaxMetaspaceSize, -XX:ReservedCodeCacheSize) and its own error, so the error message tells us which area to look at first.
On JDK 25, the heap defaults to 25% of the available memory and uses young and old generations with the default G1 collector, metaspace is unlimited native memory, and virtual threads keep their frames on the heap. For containers, we size the whole process with NMT, not only the heap. The visibility rules of JLS chapter 17 share the name but belong to concurrent programming.
10. References
- JVM Specification SE 25, 2.5 Run-Time Data Areas
- The java Command (JDK 25)
- JEP 122 Remove the Permanent Generation
- JEP 197 Segmented Code Cache
- JEP 519 Compact Object Headers
- JEP 444 Virtual Threads
- Java Language Specification SE 25, 17.4 Memory Model
Happy Learning !!
Hi ,
I want to know
if two tomcat running on same machine then will they use same heap area or different ?
different
HI..
i want to know about how application developed by one program can be viewed by different kind of client like(tab,mobile,pc…)
Hi Lokesh,
Could you explain why Native method stacks are needed and what is the main purpose of it in memory model of java.
Heap memory contains three components. Young, Old and Perm generation.
Logically, Method Area and runtime constant pool is also part of Heap memory’s Perm Gen – which Java people has planned to remove in JAVA 8. They are planning to have new structure ( Metaspace) in Java 8 instead of Perm Gen.
Correct me if I am wrong.
Regards, Uzair
You are absolutely right. It has already happened with latest release of JDK 8.
Hi Lokesh,
Thanks for your posts on Java. These are very clear and easy to understand. Will you pls explain more on Metaspace ? Is there any change in behavior for Metaspace from Perm Gen (like GC collection or any other in particular). As per my understanding, there will not be any major, in behavior.
Regards, Uzair
There are some big differences between them e.g. memory size limit etc. I will cover them soon in my next tutorial.
Will you please update the same for Java SE 8
Hi Ram, I don’t think there is any difference in terms of memory structuring inside JVM. It’s still same in java 8 as it was in java 7. Only major difference is removal of PermGen and introduction of Metaspace, but thats how JVM manages objects inside heap and method area. Please let me know if I am missing anything.
Hi Lokesh,
your blog awesome ! recently i got some requirements like i need to retrieve information from windows registry such as software/ hardware information So we will achieve this using java. can please guide me. do i need to use jRejestry jars ?
Absolutely no hands on experience before in this area. You are at your own this time.
thanks for yours quick response :)