How the JVM Actually Lays Out Arrays in Memory
Why an array of Integers takes about five times the memory of an array of ints, and what happens in the heap.
In this article (5 sections)
Before you start: you should know that arrays are numbered from 0 (so numbers[3] is the fourth item), that new creates an object, and that a byte is 8 bits. Everything else is explained below.
Java hides memory addresses by default. Most of the time, that’s a blessing: you instantiate an array (create it), append items, pass it to a method, and never think about pointers (variables that hold the memory address of something, like a street address instead of the house) or memory offsets (how far past the start of something a piece of data sits).
Until you scale throughput (how much work your program finishes per second) or inspect a heap dump (a snapshot file listing every object in memory).
Then you notice something baffling: an array holding one million Integer wrappers takes about 20 megabytes of heap, while an array holding one million primitive int values takes 4 (measured on OpenJDK 25 with default settings).
Two terms there need a plain definition. A primitive such as int stores just the number itself. An Integer is an object that wraps one number inside it, and Java converts between the two automatically, which is called boxing. The heap is the large area of memory where Java keeps every object you create with new. That is a five-to-one gap, and the section called The Numbers in Practice, further down, shows where every byte goes.
To understand why custom data structures perform well or fail under memory pressure, you have to look past the Java syntax and see how the HotSpot JVM actually organizes bytes in RAM. The JVM (Java Virtual Machine) is the program that runs your compiled Java code and manages its memory, HotSpot is the most common JVM implementation, and RAM is the computer’s working memory.
Stack Frames vs The JVM Heap
Think of the stack as a sticky note on your desk: you jot down what you need for the current task and throw the note away when the task ends. The heap is a warehouse: bigger items are stored there and found later by a shelf address.
In Java terms, a thread is one line of execution in a running program. Each time a thread calls a method, the JVM sets aside a small block of memory for that call, called a stack frame, and the frames pile up in order on the thread’s call stack. When a thread executes a Java method, the JVM divides working memory between two distinct regions, the stack (the frames) and the heap (the shared warehouse of objects):
THREAD STACK (Local execution) JVM HEAP (Shared Objects)
+-----------------------------+ +----------------------------------+
| process() frame | | int[] Object Allocation |
| | | |
| primitiveVal = 42 | | Header (Mark Word + Klass Word) |
| numbersRef -----[0x7A4F00]----------> | Length: 4 |
| | | [ 10 | 20 | 30 | 40 ] |
+-----------------------------+ +----------------------------------+
Local primitive variables (int, double, boolean) live directly inside the stack frame slot. When the method returns, the frame pops off the call stack (is removed from the top of the pile) and that memory is immediately reclaimed without any garbage collection involvement. Garbage collection is the JVM’s automatic cleanup that finds heap objects nothing uses any more and frees their memory.
Reference variables (int[], String, custom objects) hold a memory address. A reference is Java’s word for a pointer. An address is normally 8 bytes (64 bits), but by default the JVM shrinks it to 4 bytes (32 bits) using a feature called Compressed OOPs (compressed ordinary object pointers), which works while the heap stays below roughly 32 gigabytes. This article assumes that default, so every reference below is 4 bytes. That address points to a block of memory allocated inside the shared JVM heap.
Reading the diagram above: primitiveVal is a local variable, so the number 42 sits right in the frame. numbersRef is also a local variable, but it holds only the address 0x7A4F00 (the 0x prefix means the number is written in hexadecimal, base 16). The array itself, including its length, sits in the heap at that address. When the method returns, both locals vanish from the stack, but the array stays in the heap until the garbage collector finds that nothing points to it.
Contiguous Memory: The Primitive Array Layout
A primitive array is like a row of numbered lockers, all the same size, built side by side. Contiguous means exactly that: back to back with no gaps, like seats in one row of a theater.
When you run:
int[] numbers = new int[4];
The HotSpot JVM allocates a single contiguous slice of bytes on the heap (a byte is the unit memory is counted in, and an int takes 4 of them):
Memory Offset | Field / Content
----------------|----------------------------------------------
0x00 - 0x07 | Mark Word (8 bytes: GC metadata, locks, hash)
0x08 - 0x0B | Klass Word (4 bytes under Compressed OOPs)
0x0C - 0x0F | Array Length = 4 (4 bytes)
0x10 - 0x13 | numbers[0] (4 bytes)
0x14 - 0x17 | numbers[1] (4 bytes)
0x18 - 0x1B | numbers[2] (4 bytes)
0x1C - 0x1F | numbers[3] (4 bytes)
How to read the table: the first column is the offset, the number of bytes past the start of the object, written in hexadecimal (0x10 is 16). Every object begins with a header, a fixed block the JVM uses for its own bookkeeping. You never write to it, but it costs space. The Mark Word holds things like garbage collection information, locks, and the object’s hash code. The Klass Word records which class the object belongs to. For an array the header is 8 + 4 + 4 = 16 bytes (mark word, class word, length), and the elements start right after it, at offset 0x10.
Because 32-bit integers sit right next to each other in physical RAM, reading numbers[3] doesn’t require scanning through previous elements. Every element is the same size, so the address of element 3 is computed directly from the start of the array rather than found by counting up from element 0.
The CPU calculates the memory address in hardware:
Here BaseAddress is where the array begins, 16 is the header size from the table above, and 4 is the byte size of each int. Multiplying by the index jumps straight to element . Worked example: for , the offset is , which is 0x1C in hexadecimal, exactly where numbers[3] sits in the table.
This is why random indexing in an array is strictly . O(1), or constant time, means the work does not grow as the array gets bigger: getting item 3 and item 3,000,000 takes the same number of steps.
There is a second benefit. The CPU keeps recently used memory in a small, very fast storage area called a cache, and it loads memory from RAM in fixed-size chunks called cache lines (commonly 64 bytes). When the CPU reads numbers[0], its hardware prefetcher pulls the entire 64-byte cache line into the L1 cache (the smallest and fastest level), so numbers[1], numbers[2] and the rest of that chunk are already waiting. Items stored next to each other usually arrive together, which makes sequential reads very fast.
The Indirection Trap: Object Reference Arrays
Indirection means reaching a value through a middleman, like a contact list that holds addresses instead of the people. An object array is that list: each locker holds a note saying where to find the real item, and each real item lives somewhere else.
Now contrast primitive arrays with an array of objects:
String[] names = new String[3];
names[0] = "Alice";
names[1] = "Bob";
It’s tempting to think this allocates strings contiguously in RAM. It doesn’t.
String[] is an array of pointers:
STACK HEAP
+----------+ +-----------------------------------------------+
| names |--------->| String[] Array (Container) |
+----------+ | Length: 3 |
| [0]: 0x9B10 --------------------+ |
| [1]: 0x9C40 ------------------+ | |
| [2]: null | | |
+-------------------------------|-|-------------+
| |
| +--> [ String: "Alice" ]
+----> [ String: "Bob" ]
Each index in names stores only a 4-byte pointer (a reference, using the Compressed OOPs size described earlier). The actual String instances live somewhere else in heap memory, often scattered in unrelated places. null in slot [2] means that slot holds no reference yet: it points at nothing.
Walkthrough: new String[3] creates three empty slots, all null. The two assignment lines create the strings "Alice" and "Bob" elsewhere in the heap and store their addresses in slots 0 and 1. The array holds addresses only, never the text itself.
When you loop through names, the CPU has to follow each pointer to a new address, jumping across cache lines and causing CPU cache misses. A cache miss happens when the CPU needs data that is not in its cache and has to fetch it from slower main memory.
The Numbers in Practice
Start with one element. In a primitive array, each element is a 4-byte number sitting in its locker. In a boxed array, each element is a 4-byte note in the locker (the pointer) plus a separate 16-byte Integer object somewhere else. That is 4 bytes versus 4 + 16 = 20 bytes per element, a ratio of 5 to 1. Multiply by one million and you get the figures in the code below.
Here is what one million elements look like in JVM heap memory:
public class MemoryFootprint {
public static void main(String[] args) {
int size = 1_000_000;
// Primitive int array:
// 16 bytes header + (1,000,000 * 4 bytes) ≈ 4.0 MB total.
int[] rawNumbers = new int[size];
// Boxed Integer array:
// 16 bytes array header + (1,000,000 * 4 bytes pointers) = 4.0 MB
// PLUS 1,000,000 separate Integer objects (16 bytes each) = 16.0 MB
// Total footprint: ~20.0 MB, measured with allocated-bytes counters.
Integer[] boxedNumbers = new Integer[size];
for (int i = 0; i < size; i++) {
boxedNumbers[i] = i;
}
}
}
Walkthrough: new Integer[size] creates only the million pointer slots, all null at first. Each boxedNumbers[i] = i; line makes Java box the int (automatically convert it to an Integer), which creates a new Integer object on the heap and stores its address in slot i. Every Integer is an object, so it carries its own header (12 bytes: the 8-byte mark word and a 4-byte class word) plus the 4 bytes of the number, which makes 16 bytes. The array itself costs 16 bytes of header plus 4 million bytes of pointers, about 4 MB. The million objects add 16 million bytes, about 16 MB. Together that is about 20 MB, against about 4 MB for the int array.
That 5x memory bloat isn’t just wasted RAM. With compressed references off, an Integer grows to 24 bytes and the ratio to 6x.
It also means the Garbage Collector has to track, mark, and sweep one million individual objects during a collection cycle instead of inspecting a single contiguous byte buffer. Marking means finding which objects are still in use, and sweeping means freeing the ones that are not.
What Comes Next
Understanding contiguous memory allocation and reference pointer indirection is the foundation for everything we will build in this series.
In Part 2, we dissect how the JVM calculates equals() and hashCode() contract rules, why hash collisions (terms we define in Part 2) degrade memory lookups, and how that shapes custom collection implementations.
💬 Discussion & Errata
Have questions, feedback, or spotted a bug in this breakdown? Reach out directly:
Part 2: Why You Must Always Override equals() and hashCode() Together in Java
Continue to Part 2 →