A browser's Back button, an undo feature, and the JVM's own method call stack all share one idea: the last thing added is the first thing removed. That structure is a stack, and it earns its name honestly - it behaves like a stack of plates. You add a plate to the top, you take a plate from the top, and you never slide one out from the middle.
Let's build the browser-history version in Java, watch it run, and then go under the hood: where the stack's memory actually comes from, what the JVM's own stack does on every call, and whether the operating system's kernel is involved at all. (Spoiler: mostly not.)
Just three moves
A stack only needs three operations, all at the top:
- push - put an item on top
- pop - take the top item off
- peek - look at the top item without removing it
That restriction is the whole point. Because you can only touch the top, "last in, first out" (LIFO) is guaranteed - no indexing, no searching, no surprises. Here is the full life of a small browser-history stack, animated:
The code
Java has a legacy Stack class, but you should not use it in new code. The official JavaDoc for java.util.Stack says it directly: "A more complete and consistent set of LIFO stack operations is provided by the Deque interface and its implementations, which should be used in preference to this class." Their own example is Deque<Integer> stack = new ArrayDeque<Integer>(); - and that is what we will use.
import java.util.ArrayDeque;
import java.util.Deque;
public class StackDemo {
public static void main(String[] args) {
// The JDK's recommended stack: ArrayDeque used through the Deque interface
Deque<String> history = new ArrayDeque<>();
// push: visit three pages
for (String page : new String[]{"home", "search", "product"}) {
history.push(page);
System.out.println("[push] visited '" + page + "' -> stack now: " + history);
}
// peek: look at the top without removing it
System.out.println("[peek] current page is '" + history.peek()
+ "' (stack unchanged: " + history + ")");
// pop: press the browser Back button until history is gone
while (!history.isEmpty()) {
String left = history.pop();
System.out.println("[pop] pressed Back, left '" + left + "' -> stack now: " + history);
}
System.out.println("[done] stack empty? " + history.isEmpty()
+ " - no more pages to go back to");
}
}
I compiled and ran this on JDK 21 (Temurin 21.0.13) before publishing. This is the real, unedited output:
[push] visited 'home' -> stack now: [home]
[push] visited 'search' -> stack now: [search, home]
[push] visited 'product' -> stack now: [product, search, home]
[peek] current page is 'product' (stack unchanged: [product, search, home])
[pop] pressed Back, left 'product' -> stack now: [search, home]
[pop] pressed Back, left 'search' -> stack now: [home]
[pop] pressed Back, left 'home' -> stack now: []
[done] stack empty? true - no more pages to go back to
Three things to notice:
- Order flipped on the way out. We pushed home, search, product - we popped product, search, home. That reversal is LIFO, and it is exactly why a stack models Back buttons and undo: the most recent action is the first one you reverse.
-
peek changed nothing. The stack printed identically before and after. Use
peekwhen you need the current page; usepopwhen you are leaving it. -
ArrayDeque'stoStringprints the top first.[product, search, home]reads top-to-bottom, which is handy for quick debugging.
Where the stack actually lives: the heap
So where do those pushed strings go? The JVM specification says the heap is "the run-time data area from which memory for all class instances and arrays is allocated" - one big shared region, cleaned up by the garbage collector.
ArrayDeque has no node objects or pointers. It keeps one plain Object[] and two ints, head and tail. A push (which is addFirst underneath) moves head one slot backward and writes your reference into that slot. That is the entire memory cost of a push: one array slot.
This is private implementation detail, so I verified it with reflection instead of trusting the docs - a small program that reads the actual backing array out of a live ArrayDeque (run with --add-opens java.base/java.util=ALL-UNNAMED, since the module system hides these fields). Real output:
new ArrayDeque<>(): one Object[] allocated on the heap, id=1528902577, length=17
push("home"): head=16 tail=0 -> elements[16]=home
push x3: head=14 tail=0 -> elements[head..]=product, search, home (push writes BEFORE head; same array, no new allocation)
pop(): head=15 -> old slot elements[14]=null (nulled so the GC can collect the string)
after 22 pushes: array id=1528902577 -> id=2046562095, length 17 -> 36
same array object? false - full array means: allocate a bigger one, copy across, drop the old one for the GC
Four real behaviors, straight from the running JVM:
-
Construction allocates the deque object plus one array on the heap - the object's own fields (
head,tail, the array reference), the array's header and 17 slots, and later whatever elements you store in it. -
A push is one write into an existing slot - no allocation, no copying, as long as the array has spare capacity. (It writes before
head, which is whyheadwalks backward: 16, then 14. What happens when capacity runs out is the last bullet.) -
A pop nulls the slot it freed. ArrayDeque deliberately overwrites the removed reference with
nullso the garbage collector can reclaim the popped object even though the array still exists. -
Growth means reallocation. When the array fills, ArrayDeque allocates a bigger array, copies everything across, and abandons the old one for the GC. The definitive check in the output is
same array object? false- plain Java reference inequality. The identity hash change (1528902577->2046562095) corroborates that: an object's identity hash stays constant for its lifetime, so differing hashes mean different objects (equal hashes, by contrast, would prove nothing). Either way, identity hashes are identifiers, not memory addresses.
The other stack: the JVM call stack
Your program's stack is a data structure you control. The JVM also keeps a stack of its own for every thread, and you never touch it directly. The spec: "Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. A Java Virtual Machine stack stores frames." A frame holds one method call: its local variables, its return address, and - satisfyingly - "a last-in-first-out (LIFO) stack known as its operand stack". Every Java method runs on top of a tiny private stack of its own.
You can watch this without any tools beyond javap. Here is the real bytecode the compiler produced for the first push in the demo above:
8: aload_1
9: ldc #10 // String home
11: invokeinterface #12, 2 // InterfaceMethod java/util/Deque.push:(Ljava/lang/Object;)V
Read it as three moves: aload_1 pushes the deque reference onto main's operand stack, ldc pushes the constant "home" on top of it, and invokeinterface consumes both to make the call - conceptually, a fresh frame for push() goes onto this thread's JVM stack, and when push returns, its frame pops off. Call, push frame; return, pop frame. LIFO all the way down, animated:
One honest caveat: frames and operand stacks are the JVM specification's conceptual model - a contract for how execution must behave, not a description of the silicon. HotSpot's JIT compiler routinely inlines small calls like this one, so at full speed there may be no separate physical frame for push() at all, and operand-stack values may sit in CPU registers instead of real stack slots. The JVM only has to behave as if the spec's stack exists; the optimized machine code underneath is free to cheat, invisibly. The animation above shows the spec's model, which is exactly what the bytecode means.
How deep can the call stack go? The spec is blunt: "If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a StackOverflowError." The stack size is a real, settable quantity - the -Xss flag. A quick recursion experiment on this machine:
$ java StackDepth
StackOverflowError after 10731 nested calls
$ java -Xss256k StackDepth
StackOverflowError after 3471 nested calls
Treat the exact counts as one machine's experiment, not a promise - they shift between runs because JIT inlining changes how much stack each frame consumes. But the pattern is the lesson: shrink the per-thread stack to 256 KB and the same recursion dies roughly three times sooner. Every frame costs real memory, and the budget is finite.
Does the kernel get involved?
Almost never, and it is worth being precise about this rather than vaguely saying "it goes to the OS."
Everything above happens inside the JVM process, in ordinary user-space memory. A push is a few writes to an array the process already owns. Creating a frame is bookkeeping inside memory the thread already has. No system call is made, so the kernel - the privileged core of the OS that manages hardware, processes, and memory - never enters the story for these operations.
The kernel does appear at a few boundaries:
-
Reserving memory. When the JVM starts (or grows the heap), it asks the kernel for address space with
mmap, which the Linux man page defines as creating "a new mapping in the virtual address space of the calling process". After that, the JVM manages that memory itself - that's why pushes don't need the kernel. - Page faults. The first time a thread touches a freshly reserved page, the CPU traps into the kernel to actually back it with physical memory. This is invisible and automatic, not something your code requests.
- Threads and I/O. Scheduling your thread onto a CPU, and any real file or network I/O, is kernel work.
And the kernel has its own stack, separate from everything here: when a thread does enter the kernel through a system call or interrupt, the CPU switches to a small per-thread kernel stack, used only for that visit. So there are really three stacks in this story - the ArrayDeque data structure on the heap, the JVM call stack of your thread, and the kernel's own stack - and a Java push only ever touches the first two.
Two habits that save real bugs
Check before you pop. Calling pop() on an empty stack throws NoSuchElementException. The while (!history.isEmpty()) guard in the demo is not decoration - it is the standard pattern. If you would rather get a signal than an exception, poll() returns null on an empty stack instead of throwing.
Declare the type as the interface. Writing Deque<String> history = new ArrayDeque<>(); instead of ArrayDeque<String> history = ... means the rest of your code only depends on the stack operations, and you could swap the implementation later without touching it.
Where stacks show up
- Undo/redo and browser history - reverse the most recent action first.
- Every running Java program - the JVM call stack above, plus the operand stack inside each frame.
- Matching and parsing - balanced brackets, HTML tags, and nested expressions are classic stack problems: push an opener, pop when you meet its closer.
- Depth-first search - DFS is a loop with a stack; binary search halves a range, DFS dives down a stack.
All three operations are O(1) - constant time, however big the stack grows. Now you also know why: each one is a pointer move plus, at most, one memory write.
Sources
- JVM Specification, 2.5: Run-Time Data Areas
- JVM Specification, 2.6: Frames
- Java 21 API: java.util.Stack (and why to prefer Deque)
- Java 21 API: java.util.ArrayDeque
- Linux man-pages: mmap(2)
Written with AI assistance; every code example above, including the reflection probe and the bytecode and recursion experiments, was compiled and run on JDK 21 (Temurin 21.0.13) before publishing, and the outputs quoted are real. Both animations are illustrations of JVM behavior, not screen recordings; the frame counts in the recursion experiment are one machine's results and will vary.


Top comments (0)