Java · Java Essentials

Java: Collections and Recursion

HashMap inside and its boxing traps, comparators that subtract, TreeMap, PriorityQueue and ArrayDeque, iterating while modifying, LinkedHashMap as an LRU cache, and recursion depth on the JVM.

  • 16 min read
  • 33 practice questions
  • 2 graded problems
On this page
  1. Description
  2. Picking the right collection
  3. How a HashMap finds things
  4. Numbers inside collections
  5. Sorting your own way: comparators
  6. PriorityQueue: the most urgent item first
  7. Stacks and queues: use ArrayDeque
  8. Changing a collection while you loop over it
  9. An LRU cache in six lines
  10. How deep can recursion go?
  11. Common mistakes
  12. Check yourself
  13. Key takeaways
  14. Quick revise
  15. Practice

Description

Almost every Java answer in a coding interview leans on the same few containers: a list to keep things in order, a map to look things up, a set to remember what you've already seen, and a queue to decide what comes next. You've probably used all of them. This lesson is about using them with confidence: picking the right one quickly, and knowing the handful of Java rules that quietly break code that looks perfectly fine.

Here's the kind of bug we mean. You count the words in a document, then check whether two words show up equally often:

Map<String, Integer> counts = new HashMap<>();
for (String w : words) counts.merge(w, 1, Integer::sum);   // add 1 to w's count
boolean same = counts.get(a) == counts.get(b);

Every test you write passes. Then a real document arrives where both words appear 200 times, and same comes back false. The counting is right. What trips it up is a Java rule about how collections store numbers, and by the middle of this lesson you'll know exactly which one.

We'll start simple and get more technical as we go:

  1. Which collection to pick, and what each one costs.
  2. How a HashMap finds things, and why its order looks random.
  3. Why numbers inside collections behave differently (the bug above).
  4. Sorting things your own way, and the PriorityQueue.
  5. Stacks, queues, and changing a collection while you loop over it.
  6. How deep recursion can go before Java runs out of room.

Picking the right collection

Each collection is a different kind of storage, and each one is good at one job:

  • A list is a numbered shelf. Things stay in the order you put them, and you can grab item 7 directly.
  • A set is a guest list. Each name appears once, and the question it answers fast is "is this person on it?"
  • A map is a phone book. You look up a value (a number) by its key (a name).
  • A queue is the line at a coffee shop: first in, first out. A stack is a pile of plates: the last one you put on is the first one you take off.
  • A priority queue is a hospital triage desk. Whoever is most urgent goes next, no matter when they arrived.

When you're not sure which one you need, walk through these questions:

yes

no

the order I added them

sorted by key

no

yes

no

first in, first out

last in, first out

smallest or most urgent first

by position

What does your code need to do?

Look up a value by a key?

Does the key order matter?

HashMap

LinkedHashMap

TreeMap

Only need to know whether you've seen something?

HashSet

How do items come back out?

ArrayDeque used as a queue

ArrayDeque used as a stack

PriorityQueue

ArrayList

yes

no

the order I added them

sorted by key

no

yes

no

first in, first out

last in, first out

smallest or most urgent first

by position

What does your code need to do?

Look up a value by a key?

Does the key order matter?

HashMap

LinkedHashMap

TreeMap

Only need to know whether you've seen something?

HashSet

How do items come back out?

ArrayDeque used as a queue

ArrayDeque used as a stack

PriorityQueue

ArrayList

Interviewers also expect you to know roughly what each operation costs. The notation is simple once you've seen it: O(1) means the time stays the same however big the collection gets, O(log n) means it grows very slowly (about 20 steps for a million items), and O(n) means it may have to walk past every item.

CollectionGood atTypical cost
ArrayListreading by position, adding at the endget(i) O(1), add at the end O(1) on average, contains O(n)
HashMap, HashSetfinding by keyO(1) on average
LinkedHashMap, LinkedHashSetfinding by key, remembering the order you added thingsO(1) on average
TreeMap, TreeSetkeeping keys sorted, finding the nearest keyO(log n)
ArrayDequestacks and queuesO(1) at either end
PriorityQueuealways handing you the smallest itempeek O(1), offer and poll O(log n)

One cost catches people out: list.remove(0) on an ArrayList shifts every other item down a place, so it's O(n). Using an ArrayList as a queue turns a fast algorithm into a slow one. Use an ArrayDeque instead.

How a HashMap finds things

When you call put("austin", 3), the map asks the key for its hashCode(), a number worked out from the key's contents. That number picks a slot in an internal array. The slots are called buckets. Later, get("austin") works out the same number, goes straight to the same bucket, and uses equals() to pick the right entry in case a few keys landed there together. There's no searching through the whole map, which is why lookups are so fast.

Two practical rules follow from this.

A HashMap doesn't keep any order. Looping over it walks the buckets, so the order looks random and can even change as the map grows. It's sneaky with small positive Integer keys: their hash code is the number itself, so they often come out looking sorted, until a negative key shows up and the "sorted" output falls apart. When order matters, say so with the type: LinkedHashMap keeps the order you added things, and TreeMap keeps keys sorted.

Never change a key after you put it in a map. The entry sits in the bucket picked by the key's old hash code. Change a field that the hash code depends on, and the map looks in the wrong bucket: neither the changed key nor a fresh copy of the old one can find the entry again. Use keys that can't change, like String, Integer or a record with final fields.

TreeMap is the one to reach for when you need "nearest" answers. Picture a calendar of booked appointments, where you ask "what's the latest booking at or before 6pm?":

TreeMap<Integer, String> bookings = new TreeMap<>(Map.of(2, "b", 6, "f", 9, "i"));
bookings.floorKey(6);      // 6: the largest key <= 6
bookings.lowerKey(6);      // 2: the largest key < 6
bookings.ceilingKey(7);    // 9: the smallest key >= 7
bookings.higherKey(9);     // null: nothing is bigger than 9

The "or equal" versions are floor and ceiling; the strict ones are lower and higher.

Read more - what happens when a HashMap fills up

When a HashMap is more than 75% full (its load factor), it doubles its array and moves every entry into the new one. Each move is cheap on average, but filling a map with a million entries resizes it 17 times along the way. If you know the size up front, HashMap.newHashMap(1_000_000) (Java 19 and later) sizes it once. Watch out for new HashMap<>(1_000_000): that number is the array size, not the entry count, so the map still resizes once when it passes 75%. The deep dive covers the internals.

Numbers inside collections

Collections can't hold a plain int, only objects. So Java quietly wraps each int in an Integer object on the way in (autoboxing) and unwraps it on the way out (unboxing). You rarely notice, except in three places.

1. == compares the objects, not the numbers. For objects, == asks "are these the very same object?" Java keeps one shared Integer object for each value from -128 to 127 and reuses it, so small equal numbers happen to be the same object. From 128 up, each boxing makes a new object:

Integer a = 127, b = 127, c = 128, d = 128;
a == b;          // true: both point at the one shared 127
c == d;          // false: two different objects that both hold 128
c.equals(d);     // true: same number

That's the bug from the start of the lesson. With counts below 128, counts.get(a) == counts.get(b) compared two shared objects and worked. At 200 it compared two different objects. The fix is counts.get(a).equals(counts.get(b)), or storing the counts in int variables before comparing them.

2. A missing key gives you null. get returns null when a key isn't in the map, and unboxing null throws a NullPointerException:

counts.put(w, counts.get(w) + 1);            // NullPointerException the first time w appears
counts.merge(w, 1, Integer::sum);            // works: starts at 1, then adds 1 each time
counts.put(w, counts.getOrDefault(w, 0) + 1);   // also works

3. remove(1) on a list of numbers removes position 1. A List has two remove methods, one taking a position and one taking an object. A plain 1 is an int, so Java picks the position:

List<Integer> ids = new ArrayList<>(List.of(10, 20, 30));
ids.remove(1);                   // removes the item at index 1, so ids is [10, 30]
ids.remove(Integer.valueOf(10)); // removes the value 10, so ids is [30]
Read more - null values in maps

A HashMap lets you store null as a value, but putIfAbsent, computeIfAbsent and merge all treat a key mapped to null as if it were missing. So null can't safely mean "I checked, and there's nothing here". Use containsKey to tell the cases apart, or store Optional.empty(). The newer collections refuse null outright: Map.of, List.of and ConcurrentHashMap throw, and so does ArrayDeque.add(null).

Sorting your own way: comparators

A comparator answers one question about two items: which one comes first? It returns a negative number if the first item goes first, a positive number if the second does, and zero for a tie. Only the sign matters, not the size.

That makes subtraction tempting, and it's a trap:

(x, y) -> x[1] - y[1]                       // looks neat, breaks on big numbers
(x, y) -> Integer.compare(x[1], y[1])       // always right

An int can only hold values up to about 2.1 billion. Past that, the result wraps around to a negative number, which is called overflow. So 2147483647 - (-2147483647) doesn't give a big positive number; it wraps to -2, and the sort puts those two items the wrong way round. Small test data never hits this, which is exactly why it slips into interviews. Integer.compare never overflows.

For anything with more than one sort key, build the comparator from pieces:

record Task(int priority, String name) {}

Comparator.comparingInt(Task::priority).thenComparing(Task::name)
// priority low to high, then name A to Z

Comparator.comparingInt(Task::priority).reversed().thenComparing(Task::name)
// priority high to low, then name A to Z: [2a, 2c, 1a, 1b]

Comparator.comparingInt(Task::priority).thenComparing(Task::name).reversed()
// reverses BOTH: [2c, 2a, 1b, 1a]

reversed() flips everything built before it, so put it right after the key you want reversed.

One limit: comparators only work on objects. Arrays.sort(scores, Collections.reverseOrder()) doesn't compile when scores is an int[]. Sort it normally and read it from the end, or copy it into an Integer[] first.

Read more - stable sorting

A stable sort keeps items that compare equal in the order they started in. Sorting objects (List.sort, Collections.sort, Arrays.sort on an object array) is stable, so you can sort by name and then by priority, and tasks with the same priority stay in name order. Sorting an int[] isn't stable, but it can't matter: two equal ints are indistinguishable.

PriorityQueue: the most urgent item first

A PriorityQueue always hands you its smallest item: peek() looks at it and poll() takes it out. For "largest first", give it a reversed comparator: new PriorityQueue<>(Comparator.reverseOrder()).

Inside, it's a heap: an array kept just organized enough that the smallest item is always at the front. Everything behind the front is only partly ordered, which leads to three surprises:

  • Printing it doesn't show sorted order. new PriorityQueue<>(List.of(5, 1, 4, 2, 3)) prints [1, 2, 4, 5, 3]. Only the first item is guaranteed. To see everything in order, poll() until it's empty.
  • Ties come out in no particular order. If two tasks have the same priority and their order matters, add a tie-breaker, such as the order they arrived in.
  • You can't update an item's priority in place. Changing a field of an item already inside breaks the heap, and remove(item) is slow (O(n)).

The standard workaround shows up in Dijkstra's shortest-path algorithm. When a node gets a better distance, push a new entry and ignore the old one when it comes out:

while (!pq.isEmpty()) {
    int[] top = pq.poll();                    // {node, distance}
    if (top[1] > dist[top[0]]) continue;      // an outdated entry: skip it
    // ... visit top[0] ...
}

Stacks and queues: use ArrayDeque

ArrayDeque is both your stack and your queue. As a stack, use push, pop and peek. As a queue, use offer to add at the back and poll to take from the front.

You'll see java.util.Stack in older code, but skip it. It's built on the old Vector class, it locks on every single call, and it lets you reach into the middle with get(i), which a stack shouldn't allow. Even its own documentation recommends Deque instead.

One difference worth remembering: on an empty deque, pop() throws an exception while poll() returns null.

Changing a collection while you loop over it

Say an app in Austin sends order updates to a list of listeners, and a listener unsubscribes itself as soon as it gets a message:

for (Listener l : listeners) {
    l.onEvent(e);           // this listener removes itself from listeners...
}                           // ...and the loop throws ConcurrentModificationException

A for-each loop runs on an iterator, and the iterator notices the list changed behind its back. Here are three safe ways to do it:

for (Listener l : new ArrayList<>(listeners)) l.onEvent(e);   // loop over a copy

listeners.removeIf(Listener::isClosed);                       // let the list do the removing

Iterator<Listener> it = listeners.iterator();                 // remove through the iterator
while (it.hasNext()) if (it.next().isClosed()) it.remove();

Don't count on the exception, though. If you remove the second-to-last item, the loop simply ends early, skipping the last item without any error. The check is a best effort.

A related surprise: Arrays.asList(1, 2, 3) gives you a fixed-size list. You can set items, but add and remove throw UnsupportedOperationException. Wrap it in new ArrayList<>(...) when you need to grow it. List.of(...) is stricter still: you can't change it at all.

An LRU cache in six lines

An LRU cache (least recently used) has a fixed size. When it's full, it throws out the item nobody has touched for the longest. It's a classic interview question, and Java's LinkedHashMap can almost do it for you:

class Lru<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    Lru(int capacity) { super(16, 0.75f, true); this.capacity = capacity; }    // true: order by last access
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;                                               // checked after every put
    }
}

The true makes every get move that entry to the end, so the front is always the least recently used. Without it, the map keeps insertion order and you get a FIFO cache instead. Most interviewers want you to build the hash map and linked list by hand, but saying "this is exactly what LinkedHashMap does" shows you know the library.

One trap: in an access-ordered LinkedHashMap, get changes the map (it moves the entry), so for (K k : map.keySet()) map.get(k) throws ConcurrentModificationException. Loop over entrySet() instead.

How deep can recursion go?

Each method call gets a small block of memory, called a frame, for its parameters and local variables. Frames sit on the thread's stack, which has a fixed size: typically 1 to 2 MB, depending on the platform. When recursion goes too deep, the stack fills up and Java throws StackOverflowError.

For a small method that's usually somewhere in the tens of thousands of calls. In this course's sandbox, a one-line recursive method got about 35,000 calls deep. So a depth-first search over a graph shaped like a long chain of 100,000 nodes will crash, even though the algorithm is correct.

Java also never turns tail calls into loops. Rewriting sum(n) as sum(n - 1, total + n) doesn't save any stack, unlike in some other languages.

The fix that works everywhere is to keep your own stack in an ArrayDeque:

Deque<Integer> stack = new ArrayDeque<>();
boolean[] seen = new boolean[n];
stack.push(start);
while (!stack.isEmpty()) {
    int node = stack.pop();
    if (seen[node]) continue;
    seen[node] = true;
    for (int next : graph.get(node)) if (!seen[next]) stack.push(next);
}

Competitive programmers sometimes run the recursion on a new thread created with a bigger stack instead: new Thread(null, task, "solver", 1L << 29) asks for 512 MB. That works, but in production code and in interviews, the loop is the answer people want to hear. And catching StackOverflowError is never a fix: the work is lost, and your data may be half updated.

Common mistakes

MistakeWhat happensDo this instead
== on two Integer values from a collectionworks below 128, fails aboveequals, or compare as int
map.get(k) + 1NullPointerException for a new keymerge(k, 1, Integer::sum) or getOrDefault
list.remove(i) meaning the value iremoves by positionlist.remove(Integer.valueOf(i))
Relying on HashMap orderorder changes, especially with negative keysLinkedHashMap or TreeMap
A comparator written as a - bwrong order for big or negative valuesInteger.compare(a, b)
reversed() at the end of a chainevery key gets reversedput it right after the key you mean
Printing a PriorityQueue to check its orderlooks unsortedpoll() items one at a time
Removing from a list inside its own for-each loopConcurrentModificationException, or a skipped itemremoveIf, the iterator, or a copy
Recursion 100,000 levels deepStackOverflowErrora loop with an ArrayDeque

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. Why does counts.get(a) == counts.get(b) work for counts of 127 but fail for 128?

Show answer

The counts are Integer objects, and == checks whether two objects are the same object. Java reuses one shared object for each value from -128 to 127, so equal small counts are the same object. 128 and up get separate objects. Compare with equals, or as ints.

2. You need the first booking at or after 3pm, from bookings stored by time. Which collection and method?

Show answer

A TreeMap keyed by time, and ceilingKey(15) (or ceilingEntry). It finds the smallest key that is greater than or equal to the one you give it, in O(log n). A HashMap would have to check every key.

3. In Dijkstra's algorithm, a node already in the PriorityQueue gets a shorter distance. What do you do?

Show answer

Push a second entry with the new distance. When an entry comes out of the queue with a distance bigger than the best one you know, skip it. PriorityQueue can't update an item in place, and remove(item) is O(n).

4. Which LinkedHashMap setting turns it into an LRU cache, and what else do you need?

Show answer

new LinkedHashMap<>(16, 0.75f, true): the true orders entries by last access, so get moves an entry to the end. Then override removeEldestEntry to return size() > capacity.

Key takeaways

  • Pick the collection by the job: HashMap to look up, TreeMap for sorted or nearest keys, ArrayDeque for stacks and queues, PriorityQueue for smallest first.
  • A HashMap has no order. Ask for one with LinkedHashMap or TreeMap.
  • Numbers in collections are objects: compare with equals, count with merge, and remove values with Integer.valueOf.
  • Comparators use Integer.compare, never subtraction, and reversed() goes right after the key it's meant for.
  • A PriorityQueue only promises its first item.
  • Don't change a collection inside its own for-each loop.
  • Deep recursion needs a loop with your own stack.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the collection

You needUseMain cost
Items by positionArrayListget(i) O(1), add at the end O(1) on average
Look up by key, no orderHashMap / HashSetO(1) on average
Look up by key, keep the order you addedLinkedHashMap / LinkedHashSetO(1) on average
Sorted keys, nearest keyTreeMap / TreeSetO(log n)
Stack or queueArrayDequeO(1) at either end
Smallest (or most urgent) firstPriorityQueuepeek O(1), offer/poll O(log n)

Avoid: list.remove(0) on an ArrayList as a queue (O(n) each time), and java.util.Stack (legacy, synchronized).

Snippets worth knowing by heart

counts.merge(w, 1, Integer::sum);                          // count without a null check
counts.getOrDefault(w, 0);                                 // read a count that may be missing
ids.remove(Integer.valueOf(20));                           // remove the VALUE 20, not index 20
list.sort(Comparator.comparingInt(Task::priority)
                    .thenComparing(Task::name));           // multi-key sort
new PriorityQueue<>(Comparator.reverseOrder());            // largest first
map.floorKey(x); map.ceilingKey(x);                        // nearest key <= x, >= x (TreeMap)
list.removeIf(Listener::isClosed);                         // remove while "looping"
new LinkedHashMap<>(16, 0.75f, true);                      // access order: the base of an LRU cache

The traps

  • == on Integers compares objects. It works for -128 to 127 only (the cache). Use equals.
  • map.get(k) + 1 throws NullPointerException for a missing key.
  • list.remove(1) on a List<Integer> removes index 1.
  • HashMap order is bucket order and changes when the table grows. Ask for order with LinkedHashMap or TreeMap.
  • Mutating a key after put strands its entry in the old bucket.
  • Comparators that subtract overflow. Use Integer.compare.
  • reversed() flips everything before it in the chain.
  • Arrays.sort(int[], comparator) doesn't compile. Sort and read backwards, or box to Integer[].
  • Printing a PriorityQueue shows heap order, not sorted order. Ties come out in any order.
  • No decrease-key: push a new entry and skip stale ones when polled.
  • Removing inside a for-each loop throws ConcurrentModificationException (or silently skips the last item).
  • Arrays.asList is fixed size; List.of is fully immutable.
  • Recursion dies in the tens of thousands of frames. Java never removes tail calls. Use an ArrayDeque as your own stack.

Staff-level points, one line each

  • Bucket index = (h ^ (h >>> 16)) & (n - 1): a power-of-two table, high bits folded in, never negative.
  • A bucket longer than 8 (in a table of 64+) becomes a red-black tree; Comparable keys keep it O(log n).
  • Doubling at 75% full: 17 resizes from 16 to a million entries. new HashMap<>(n) takes a capacity; HashMap.newHashMap(n) (Java 19+) takes an entry count.
  • A resize splits each bucket by one more hash bit, so iteration order can change with no change to the keys.
  • Boxing costs about 16 bytes per Integer and 32 per HashMap node (64-bit HotSpot, compressed references). Hot paths use int[] or primitive-map libraries.
  • Object sorts are TimSort (stable); primitive sorts are dual-pivot quicksort. An inconsistent comparator can make TimSort throw "Comparison method violates its general contract!".
  • TreeMap/TreeSet treat compare == 0 as the same key: a comparator that ties distinct items drops them.
  • new PriorityQueue<>(collection) heapifies in O(n); remove(Object) is O(n).
  • Fail-fast iterators compare modCount on next(). Best effort, not thread safety. Recursive computeIfAbsent throws ConcurrentModificationException.
  • Stack depth depends on frame size and JIT state, so it differs between methods, machines and runs.
  • Java 21: getFirst, getLast, reversed(), pollFirstEntry on every ordered collection (SequencedCollection, SequencedMap).

Say it in an interview

  • "I'd use a TreeMap here because I need the nearest key, which is O(log n)."
  • "PriorityQueue has no decrease-key, so I'll push duplicates and skip stale entries."
  • "This recursion could be 10^5 deep, so I'll use an explicit stack."
  • "LinkedHashMap with access order is already an LRU list; I can also build the map plus doubly linked list by hand."

Practice

This skill comes with 33 multiple-choice questions that tell you which misconception a wrong answer reveals and 2 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java: Collections and Recursion free

Book a Meeting Room

EasyFreeCoding problemJava

A coworking space in Dublin lets members book its one meeting room. Each request asks for the time range [start, end): it includes start and stops just before end, so a meeting from 10 to 20 and another from 20 to 30 don't clash.

Top Words in Customer Reviews

MediumProCoding problemJava

A support team in Austin wants a quick view of what customers write about most. Given the words from a batch of reviews (already lowercased and split), return the k most frequent words.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java: Collections and Recursion free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime