Stack Data Structure Explained — How It Works, Why It Exists, and When to Use It
Every time you hit Ctrl+Z to undo a mistake in your text editor, a stack is working behind the scenes. Every time your browser takes you back to the previous page, a stack is involved. Every time a function calls another function in any programming language on the planet, the computer uses something called a call stack to keep track of where to return. Stacks are not some exotic academic concept — they are the invisible scaffolding holding up software you use every single day.
The problem stacks solve is beautifully simple: sometimes you need to remember things in reverse order. Think about reversing a word. You read it left to right, but you need to output it right to left. Or think about checking if brackets in a formula are balanced — every opening bracket needs a matching closing bracket in the right order. If you tried to solve these problems with a plain list, you'd constantly be fighting the data structure. A stack makes these problems feel trivial because it is shaped exactly like the problem.
By the end of this article you'll understand exactly what a stack is, how to build one from scratch in Java, how the built-in Java Stack works, when to reach for a stack in real problems, and the exact mistakes beginners make that lead to bugs and crashes. You'll also walk away with solid answers to the interview questions that come up again and again when stacks are on the table.
What Is a Stack and Why Does the LIFO Rule Matter?
A stack is a linear data structure that follows one strict rule: the last element added is the first element removed. This rule has a name — LIFO, which stands for Last In, First Out. Every single operation on a stack flows from this one rule, so if you understand LIFO deeply, everything else will make sense automatically.
Think about a browser's back button again. You open Google (page 1), click a link to Wikipedia (page 2), then click another link to a Wikipedia article (page 3). Now you hit back. You go to page 2 — not page 1. Hit back again, you get to page 1. The browser didn't remember pages in alphabetical order or by size — it remembered them in the order you visited, and it unwinds them in reverse. That's LIFO.
A stack only exposes three core operations to you. Push adds an item to the top. Pop removes the item from the top. Peek (sometimes called top) lets you look at the top item without removing it. That's the entire interface. The power of a stack doesn't come from complexity — it comes from constraint. By refusing to let you touch anything except the top, it forces you into a pattern that maps perfectly onto a whole family of real problems.
import java.util.Stack; public class StackConceptDemo { public static void main(String[] args) { // Think of this as a physical stack of plates Stack<String> browserHistory = new Stack<>(); // PUSH — visiting pages, each one goes on top browserHistory.push("google.com"); // bottom of the stack browserHistory.push("wikipedia.org"); // sits on top of google browserHistory.push("java-docs.com"); // sits on top of wikipedia System.out.println("Current page (top of stack): " + browserHistory.peek()); // peek() reads the top item WITHOUT removing it System.out.println("\n--- Hitting the back button 3 times ---"); // POP — removes and returns the top item each time System.out.println("Back: " + browserHistory.pop()); // java-docs.com leaves first System.out.println("Back: " + browserHistory.pop()); // wikipedia.org leaves next System.out.println("Back: " + browserHistory.pop()); // google.com leaves last // Stack is now empty — checking before popping prevents a crash System.out.println("\nIs history empty? " + browserHistory.isEmpty()); } }
--- Hitting the back button 3 times ---
Back: java-docs.com
Back: wikipedia.org
Back: google.com
Is history empty? true
Building a Stack From Scratch (So You Actually Understand It)
Java's built-in Stack class is fine, but building one yourself from scratch is the single best way to truly understand what's happening under the hood. Once you've done it, you'll never be confused by stack internals again.
Under the hood, a stack is almost always built on top of an array or a linked list. We'll use an array here because arrays are the most visual — you can picture the slots filling up one by one. The key insight is that we maintain a variable called topIndex that always points to the position of the most recently added element. Every push moves topIndex up by one. Every pop moves it down by one. Peek just reads whatever is at topIndex without moving it.
We also need to handle two failure cases. If you pop from an empty stack, there's nothing to remove — that's called an underflow. If you push to a full stack (when using a fixed-size array), there's no room — that's called an overflow. Production-grade stacks handle both gracefully. Our implementation will throw clear, descriptive exceptions so you know exactly what went wrong.
public class CustomStack { private int[] elements; // The underlying array that holds our data private int topIndex; // Points to the index of the current top element private int capacity; // Maximum number of elements this stack can hold // Constructor — sets up an empty stack with a fixed maximum size public CustomStack(int capacity) { this.capacity = capacity; this.elements = new int[capacity]; this.topIndex = -1; // -1 means the stack is empty (no valid index yet) } // PUSH — adds a new value on top of the stack public void push(int value) { if (topIndex == capacity - 1) { // topIndex has reached the last array index — no more room throw new RuntimeException("Stack Overflow: stack is full, cannot push " + value); } topIndex++; // Move the pointer up one slot elements[topIndex] = value; // Place the value in that slot System.out.println("Pushed: " + value + " (topIndex is now " + topIndex + ")"); } // POP — removes and returns the top value public int pop() { if (topIndex == -1) { // topIndex is -1, meaning there's nothing to remove throw new RuntimeException("Stack Underflow: stack is empty, nothing to pop"); } int removedValue = elements[topIndex]; // Grab the top value before we move the pointer topIndex--; // Move the pointer down — the old slot is now 'forgotten' System.out.println("Popped: " + removedValue + " (topIndex is now " + topIndex + ")"); return removedValue; } // PEEK — returns the top value WITHOUT removing it public int peek() { if (topIndex == -1) { throw new RuntimeException("Stack is empty, nothing to peek at"); } return elements[topIndex]; // Just read it — don't touch topIndex } // isEmpty — returns true if the stack has no elements public boolean isEmpty() { return topIndex == -1; } // size — returns how many elements are currently in the stack public int size() { return topIndex + 1; // topIndex is zero-based, so size is always one more } // --- DEMO --- public static void main(String[] args) { CustomStack stack = new CustomStack(5); // A stack that fits 5 integers System.out.println("=== Pushing three values ==="); stack.push(10); stack.push(25); stack.push(47); System.out.println("\nTop element right now: " + stack.peek()); System.out.println("Stack size: " + stack.size()); System.out.println("\n=== Popping all values ==="); stack.pop(); stack.pop(); stack.pop(); System.out.println("\nIs the stack empty? " + stack.isEmpty()); // Uncomment this to see the underflow exception in action: // stack.pop(); } }
Pushed: 10 (topIndex is now 0)
Pushed: 25 (topIndex is now 1)
Pushed: 47 (topIndex is now 2)
Top element right now: 47
Stack size: 3
=== Popping all values ===
Popped: 47 (topIndex is now 1)
Popped: 25 (topIndex is now 0)
Popped: 10 (topIndex is now -1)
Is the stack empty? true
A Real Problem Stacks Solve — Checking Balanced Brackets
Here's the moment where stacks go from interesting theory to genuinely useful tool. One of the most classic stack problems — and one interviewers love — is checking whether brackets in an expression are balanced.
Consider the expression: {[()]}. Every opening bracket has a matching closing bracket in the right order. That's balanced. Now consider {[(])} — the square bracket closes before the round bracket does. That's not balanced, and any compiler would reject it.
Here's why a stack is the perfect tool: every time you see an opening bracket, you push it. Every time you see a closing bracket, you peek at the top of the stack. If the top is the matching opener, pop it and continue. If it isn't, the expression is invalid. If you reach the end and the stack is empty, every opener was matched. This algorithm works because the most recently opened bracket must always be the next one closed — which is exactly the LIFO pattern.
This same logic powers syntax highlighting in every IDE, HTML/XML validators, and expression evaluators in calculators and compilers.
import java.util.Stack; public class BracketBalanceChecker { public static boolean isBalanced(String expression) { Stack<Character> openBrackets = new Stack<>(); for (int i = 0; i < expression.length(); i++) { char currentChar = expression.charAt(i); // If it's an opening bracket, push it — we'll need to match it later if (currentChar == '(' || currentChar == '[' || currentChar == '{') { openBrackets.push(currentChar); } // If it's a closing bracket, check whether it matches the last opener else if (currentChar == ')' || currentChar == ']' || currentChar == '}') { // A closing bracket with nothing to match it — immediately invalid if (openBrackets.isEmpty()) { return false; } char lastOpener = openBrackets.pop(); // Grab and remove the most recent opener // Check for a mismatch — the wrong type of closing bracket appeared if (currentChar == ')' && lastOpener != '(') return false; if (currentChar == ']' && lastOpener != '[') return false; if (currentChar == '}' && lastOpener != '{') return false; } // Any other character (letters, digits, operators) — just skip it } // If the stack is empty, every opener was matched correctly // If the stack still has items, some openers were never closed return openBrackets.isEmpty(); } public static void main(String[] args) { String[] testCases = { "{[()]}", // balanced — all pairs match correctly "{[(])}", // NOT balanced — ] closes before ) "(((", // NOT balanced — three openers, no closers "", // empty string — trivially balanced "[{()}]()", // balanced — multiple groups, all correct "}{", // NOT balanced — closer appears before opener }; for (String expression : testCases) { String result = isBalanced(expression) ? "BALANCED" : "NOT BALANCED"; System.out.printf("%-20s -> %s%n", "\"".concat(expression).concat("\""), result); } } }
"{[(])}" -> NOT BALANCED
"(((" -> NOT BALANCED
"" -> BALANCED
"[{()}]()" -> BALANCED
"}{", -> NOT BALANCED
Java's Built-In Stack vs. Deque — Which One Should You Actually Use?
Java has a built-in Stack class in java.util. It works fine and it's easy to use — but there's a catch that trips up a lot of developers, especially in interviews.
Stack in Java extends Vector, which is a legacy class from Java 1.0. This means it's synchronized (thread-safe) by default, which sounds nice but adds unnecessary overhead in single-threaded applications. It also inherits all of Vector's methods, which means you can call things like stack.get(2) or stack.remove(0) — operations that completely bypass LIFO and break the whole point of using a stack.
The Java documentation itself recommends using ArrayDeque when you need a stack. ArrayDeque implements the Deque interface and is faster, more memory-efficient, and doesn't expose non-stack methods in the typical usage pattern. You use push(), pop(), and peek() exactly as you would with Stack, but you get better performance under the hood.
The comparison table below breaks down the key differences so you can make the right choice for any situation.
import java.util.ArrayDeque; import java.util.Deque; import java.util.Stack; public class StackVsDequeDemo { public static void main(String[] args) { // --- Using the legacy java.util.Stack --- System.out.println("=== java.util.Stack ==="); Stack<Integer> legacyStack = new Stack<>(); legacyStack.push(100); legacyStack.push(200); legacyStack.push(300); System.out.println("Top (peek): " + legacyStack.peek()); // 300 System.out.println("Popped: " + legacyStack.pop()); // 300 System.out.println("Size: " + legacyStack.size()); // 2 // --- Using the recommended ArrayDeque as a stack --- // Declare as Deque<Integer> — this is the interface, not the implementation System.out.println("\n=== ArrayDeque (recommended) ==="); Deque<Integer> modernStack = new ArrayDeque<>(); modernStack.push(100); // push() adds to the FRONT of the deque — mimicking a stack top modernStack.push(200); modernStack.push(300); System.out.println("Top (peek): " + modernStack.peek()); // 300 System.out.println("Popped: " + modernStack.pop()); // 300 System.out.println("Size: " + modernStack.size()); // 2 // Performance test — pushing 1 million integers System.out.println("\n=== Performance Comparison ==="); int iterations = 1_000_000; long legacyStart = System.currentTimeMillis(); Stack<Integer> perfLegacy = new Stack<>(); for (int i = 0; i < iterations; i++) { perfLegacy.push(i); } long legacyTime = System.currentTimeMillis() - legacyStart; long modernStart = System.currentTimeMillis(); Deque<Integer> perfModern = new ArrayDeque<>(); for (int i = 0; i < iterations; i++) { perfModern.push(i); } long modernTime = System.currentTimeMillis() - modernStart; System.out.println("Stack (1M pushes): " + legacyTime + "ms"); System.out.println("ArrayDeque (1M pushes): " + modernTime + "ms"); System.out.println("ArrayDeque is faster because it avoids synchronization overhead."); } }
Top (peek): 300
Popped: 300
Size: 2
=== ArrayDeque (recommended) ===
Top (peek): 300
Popped: 300
Size: 2
=== Performance Comparison ===
Stack (1M pushes): 87ms
ArrayDeque (1M pushes): 41ms
ArrayDeque is faster because it avoids synchronization overhead.
| Feature / Aspect | java.util.Stack | ArrayDeque (as Stack) |
|---|---|---|
| Recommended for new code? | No — legacy class | Yes — official recommendation |
| Thread-safe? | Yes (synchronized) | No (use ConcurrentLinkedDeque if needed) |
| Performance (single-threaded) | Slower — sync overhead | Faster — no sync overhead |
| Inherits random-access methods? | Yes (via Vector — breaks LIFO) | No — clean stack interface |
| Allows null elements? | Yes | No — throws NullPointerException |
| Underlying data structure | Array (Vector) | Resizable circular array |
| Time complexity: push/pop/peek | O(1) amortized | O(1) amortized |
| Memory usage | Higher (Vector overhead) | Lower — more efficient |
| Introduced in Java version | Java 1.0 | Java 6 |
🎯 Key Takeaways
- LIFO is the one rule that defines a stack — the last item pushed is always the first item popped. If your problem involves reversing order, undoing actions, or matching pairs, a stack is almost certainly the right tool.
- Every core stack operation — push, pop, and peek — runs in O(1) constant time. It doesn't matter if your stack has 3 items or 3 million items; those operations are always instant.
- In Java, prefer 'Deque
stack = new ArrayDeque<>()' over 'new Stack<>()'. ArrayDeque is faster, leaner, and recommended by the Java documentation itself — and it won't let you accidentally call random-access methods that break LIFO. - Always guard pop() and peek() with an isEmpty() check. A single unguarded pop() on an empty stack throws an unchecked exception and crashes your program — it's one of the most common runtime bugs beginners hit with stacks.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Popping from an empty stack — Symptom: EmptyStackException crashes your program at runtime — Fix: Always call isEmpty() before calling pop() or peek(). Get in the habit of writing 'if (!stack.isEmpty()) { stack.pop(); }' — never call pop() blindly.
- ✕Mistake 2: Using java.util.Stack and calling get() or elementAt() — Symptom: Code compiles and runs but produces wrong results because LIFO order is bypassed — Fix: Switch to 'Deque
stack = new ArrayDeque<>()' immediately. ArrayDeque only exposes push/pop/peek in stack usage, so you can't accidentally break the LIFO contract. - ✕Mistake 3: Confusing push direction in ArrayDeque — Symptom: Elements come out in the wrong order when you mix offerFirst/offerLast with push — Fix: When using ArrayDeque as a stack, use ONLY push(), pop(), and peek(). Never mix in offer(), poll(), or add() — those are queue operations that work from the other end and will destroy your ordering.
Interview Questions on This Topic
- QWhat is the difference between a stack and a queue, and can you give a real-world example of each? (Interviewers are checking whether you understand LIFO vs FIFO intuitively — not just the definitions. Mention the undo history for a stack and a print queue or ticket system for a queue.)
- QHow would you implement two stacks using a single fixed-size array, without allocating extra space? (This is a classic space-optimization problem. The trick is to grow one stack from index 0 upward and the other from the last index downward. They collide only when the array is truly full.)
- QWhat happens when you call pop() on an empty stack in Java, and how would you prevent it in production code? (They want to see that you know EmptyStackException is thrown, and that the right pattern is isEmpty() guard before every pop() — or using Optional as a return type for a safer custom implementation.)
Frequently Asked Questions
What is a stack data structure in simple terms?
A stack is a collection of items where you can only add or remove from one end — the top. Think of it like a stack of plates: you always place a new plate on top and always take from the top first. The technical term for this behavior is LIFO — Last In, First Out.
What are stacks used for in real programming?
Stacks appear everywhere in software. Your browser's back button uses a stack of visited pages. Every programming language uses a call stack to track which function to return to after the current one finishes. Text editors use a stack to implement undo. Compilers use stacks to parse expressions and check that brackets are balanced.
Is a stack faster than an array or ArrayList for adding and removing elements?
For adding and removing at one specific end (the top), a stack is O(1) — constant time, regardless of size. An ArrayList is also O(1) amortized for adding at the end, but it gives you access to all positions, which invites bugs. The stack wins not just on performance (they're comparable) but on correctness — by restricting access to the top only, it prevents an entire class of ordering bugs that don't exist with a plain list.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.