Made by VeraStack Labs
The road test drill
The few things everyone gets asked.
Before a driving test, a good instructor does not teach you new roads. They drill the same short list again and again: mirrors, signals, the junction everyone gets wrong. The test is mostly those few moments.
Java Collections interviews work the same way. You already know the collections. What is left is a short list of questions that keep coming up, and a handful of traps that catch people who know the material but never saw the trap. This lesson is that list.
There are three parts to it.
- Fail-fast and fail-safe. What happens when a collection changes while you are looping over it, and which collections cope.
- A look at the concurrent collections. The ones built for several threads (separate strands of work running at the same time) sharing one collection.
- The traps. Small, specific, and each one has been asked in a real interview.
You will not meet much that is new. The point is to see it all together, and to be able to say it out loud.
Remember: an interview answer is usually one idea, one example, and one cost. Practise saying them in that order.
Stop, or read a snapshot
Two ways to cope with a change mid-loop.
Lesson 5 showed that an ArrayList iterator stops with a ConcurrentModificationException if the list changes behind its back. Interviewers have a name for that behaviour: fail-fast. The iterator fails the moment it notices a problem, instead of carrying on with wrong data.
The opposite is fail-safe. A fail-safe iterator never complains, because it does not read the live collection at all. It reads a private snapshot (a frozen copy of how things were), so changes made while looping simply do not affect it. The right-hand side of the picture shows it: CopyOnWriteArrayList copies its array on every change, and the loop carries on with the old one.
Neither is better. They are a trade. Fail-fast costs nothing extra and shouts about bugs. Fail-safe never complains, but you pay for the copies, and your loop may be looking at old data.
Two notes to keep you accurate. Fail-fast is a best effort: Java does not promise to catch every change, so use it as a bug detector, not a feature. And "fail-safe" is the common interview word, not an official Java term. The Java documents describe these as iterators over a "snapshot".
Safe loops, shared maps
CopyOnWriteArrayList, ConcurrentHashMap, and nulls.
Every block below can be pasted into jshell. First, a fail-safe loop.
List<String> names = new CopyOnWriteArrayList<>(List.of("Asha", "Ravi", "Meera"));
for (String name : names) {
if (name.equals("Asha")) {
names.add("Kiran"); // would throw on an ArrayList; here it is allowed
}
System.out.println("saw " + name);
}
System.out.println(names); // the new name is in the list, but the loop never saw itPaste into jshell to try it
saw Asha
saw Ravi
saw Meera
[Asha, Ravi, Meera, Kiran]Adding "Kiran" inside the loop would crash an ArrayList. Here it is fine. The list has the new name, and the loop never saw it, because it was reading the older array. Use this for lists that are read all the time and changed rarely, such as a list of listeners that get notified. Writing is slow, because each change copies the whole array.
Now the second concurrent collection. When many threads change one ordinary HashMap at once, the result is unpredictable: updates get lost, and in the worst case the map is damaged. A ConcurrentHashMap is built for it.
Map<String, Integer> visits = new ConcurrentHashMap<>();
Thread[] workers = new Thread[4];
for (int i = 0; i < workers.length; i++) {
workers[i] = new Thread(() -> {
for (int n = 0; n < 1000; n++) {
visits.merge("home", 1, Integer::sum); // one step that cannot be interrupted halfway
}
});
workers[i].start();
}
for (Thread worker : workers) {
worker.join(); // wait for everyone to finish before reading
}
System.out.println(visits.get("home"));Paste into jshell to try it
4000Four threads each added 1 to the same key a thousand times, and none was lost. The key is merge: it reads, combines and stores as one step that cannot be cut in half by another thread. A line like map.put(key, map.get(key) + 1) is three steps, and another thread can sneak in between them even on a ConcurrentHashMap. When you need an update to be safe, use merge, compute or computeIfAbsent.
You do not need more than this for a first interview. Here is the short map of the rest:
Collections.synchronizedList(list)wraps any list so each call takes a lock (a "one at a time" gate). It is simple and slow under heavy use, and looping over it still needs you to hold the lock yourself.ConcurrentLinkedQueueis a queue for many threads.BlockingQueue(such asLinkedBlockingQueue) makes a thread wait when the queue is empty or full. It is the standard tool for handing work from one thread to another.Vector,HashtableandStackare old, locked classes from before the collections framework. Don't use them in new code.
The traps
Now the junctions everyone gets wrong. First, nulls. Collections disagree about whether null (the "no object" value) is allowed.
Map<String, String> hash = new HashMap<>();
hash.put(null, "allowed"); // a HashMap accepts one null key
System.out.println(hash.get(null));
try {
new TreeMap<String, String>().put(null, "x"); // a sorted map cannot compare null
} catch (NullPointerException e) {
System.out.println("TreeMap refuses a null key");
}
try {
new ArrayDeque<String>().offer(null); // null is how a deque says "empty"
} catch (NullPointerException e) {
System.out.println("ArrayDeque refuses null");
}
try {
new ConcurrentHashMap<String, String>().put("k", null);
} catch (NullPointerException e) {
System.out.println("ConcurrentHashMap refuses null");
}Paste into jshell to try it
allowed
TreeMap refuses a null key
ArrayDeque refuses null
ConcurrentHashMap refuses nullA HashMap takes one null key. A TreeMap can't compare null with anything. ArrayDeque reserves null to mean "empty". ConcurrentHashMap refuses it because get returning null would not tell you whether the key was missing or held null, and with several threads there is no safe way to check. List.of, Set.of and Map.of refuse too, as lesson 10 showed.
Second, the remove trap from lesson 3, which interviewers love because the code looks innocent.
List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30));
numbers.remove(1); // an int, so this means "remove the item at index 1"
System.out.println(numbers);
numbers.remove(Integer.valueOf(10)); // an Integer object, so "remove the value 10"
System.out.println(numbers);Paste into jshell to try it
[10, 30]
[30]remove(1) removed the item at index 1, which was 20. To remove the value 10, you must pass an Integer object, here made by Integer.valueOf(10). A list of numbers has two remove methods, and an int argument picks the wrong one.
Third, comparing boxed numbers, from lesson 2.
Integer a = 1000;
Integer b = 1000;
// == asks "the same object?", and the answer for boxed numbers is not
// something to rely on. equals asks "the same number?", every time.
System.out.println(a.equals(b));Paste into jshell to try it
true== asks "are these the very same object?". For boxed numbers like Integer, whether two equal numbers are the same object depends on how Java handles small values, so == can give you true in one test and false in another. Do not rely on it. equals asks "do they hold the same number?" and always answers correctly. Use equals for every object, including Integer, String and your own classes.
Fourth, a comparator that goes wrong. It looks natural to write a - b in a compare method, and it fails on large numbers.
int big = Integer.MAX_VALUE;
int small = -1;
// Subtracting can overflow and wrap round to a negative number...
System.out.println(big - small < 0); // "big is less than small": wrong
// ...so compare with Integer.compare, which never overflows.
System.out.println(Integer.compare(big, small) > 0);Paste into jshell to try it
true
trueThe first line says "Integer.MAX_VALUE minus -1 is negative", so a - b reports that the biggest int is less than -1. The sum is too big for an int, so it wraps round to a large negative number. This is called overflow. Integer.compare(a, b) never overflows, which the second line shows. Always use it.
Last, a quiet one: subList.
List<Integer> numbers = new ArrayList<>(List.of(0, 1, 2, 3, 4, 5));
List<Integer> middle = numbers.subList(1, 3); // a window onto items 1 and 2, not a copy
System.out.println(middle);
middle.clear(); // clearing the window removes them from the real list
System.out.println(numbers);Paste into jshell to try it
[1, 2]
[0, 3, 4, 5]subList(1, 3) is not a copy. It is a window onto the original list, so clearing the window removed those items from the real list. The same is true of Arrays.asList, keySet() and values() on a map. If you want an independent copy, wrap it: new ArrayList<>(list.subList(1, 3)).
Remember: remove(int) takes an index. == is for identity, equals is for content. a - b can overflow. Windows write through to the original.
A snapshot, built by hand
Why copy-on-write is safe, and slow to write.
The idea behind CopyOnWriteArrayList is small enough to write in a few lines.
static class TinyCopyOnWriteList<T> implements Iterable<T> {
private Object[] items = new Object[0];
int size() {
return items.length;
}
void add(T value) {
// Never change the array in use: build a new one and swap it in.
Object[] bigger = Arrays.copyOf(items, items.length + 1);
bigger[items.length] = value;
items = bigger;
}
@Override
public java.util.Iterator<T> iterator() {
Object[] snapshot = items; // the iterator keeps the array as it is right now
return new java.util.Iterator<>() {
private int position = 0;
@Override
public boolean hasNext() {
return position < snapshot.length;
}
@Override
@SuppressWarnings("unchecked")
public T next() {
if (position >= snapshot.length) {
throw new java.util.NoSuchElementException();
}
return (T) snapshot[position++];
}
};
}
}Paste into jshell to try it
Go through it in order.
- The array in
itemsis never changed once built. Every change makes a new one. addcopies the old array into one slot bigger, puts the new item in the extra slot, and then swapsitemsto point at the new array. The old array is left exactly as it was.iteratortakessnapshot = itemsright away. That one line is the whole trick: the iterator keeps the array that existed when it was created, and nothing can ever change that array afterwards.- So the iterator needs no version number and no check. It cannot be surprised.
TinyCopyOnWriteList<String> names = new TinyCopyOnWriteList<>();
names.add("Asha");
names.add("Ravi");
for (String name : names) {
names.add(name + "!"); // safe: the loop is reading an older array
}
System.out.println(names.size());Paste into jshell to try it
4The loop added a name for each name it read, and it didn't loop forever, because it only walked the two names that were there when it started.
The real ConcurrentHashMap takes a different route. It does not copy anything. It splits its buckets into small groups, locks only the group an update touches, and uses special instructions for the simplest changes, so different threads working on different keys rarely wait for one another.
What safety costs
n is the number of items.
| Collection | Read | Write | Looping while it changes |
|---|---|---|---|
ArrayList | O(1) by index | O(1) at the end, O(n) in the middle | Fail-fast: may throw |
CopyOnWriteArrayList | O(1) by index | O(n), every write copies the array | Fail-safe: reads a snapshot |
HashMap | O(1) on average | O(1) on average | Fail-fast: may throw |
ConcurrentHashMap | O(1) on average | O(1) on average | Never throws; may or may not show a change made during the loop |
Collections.synchronizedList | As the list inside, plus a lock | As the list inside, plus a lock | You must hold the lock while looping |
The trade in one sentence: the copy-on-write list makes reading free and writing expensive, so it is for data that is read much more than it is written. A ConcurrentHashMap is the general choice when many threads share a map.
Three problems, three traps
Remove by value, compare safely, count in threads.
Problem 1
Remove by value
Write removeAllOf, which removes every item equal to value from the list, in place. For [3, 1, 3, 2, 3] and value = 3 the list becomes [1, 2]. For [10, 20, 30] and value = 1, nothing is removed, because no item equals 1.
Start here
static void removeAllOf(List<Integer> numbers, int value) {
throw new UnsupportedOperationException("remove every item equal to value, not the item at index value");
}Paste into jshell to try it
Stuck?
Show a solution
static void removeAllOf(List<Integer> numbers, int value) {
// removeIf compares values. numbers.remove(value) would treat an int as an index.
numbers.removeIf(n -> n == value);
}Paste into jshell to try it
removeIf takes a rule, so there is no overload to confuse, and it removes every match in one pass: O(n) on an ArrayList. Inside the rule, n == value compares an Integer that Java has unboxed with an int, so it compares numbers and not objects. The same goal could be reached with a loop of remove(Integer.valueOf(value)) until it returns false, but that searches again from the start every time, which is O(n squared).
Problem 2
Biggest first, for any numbers
Write sortedDescending, which returns a new list with the numbers biggest first and leaves the list you are given alone. It must work for any int, including Integer.MAX_VALUE and Integer.MIN_VALUE.
Start here
static List<Integer> sortedDescending(List<Integer> numbers) {
throw new UnsupportedOperationException("a new list, biggest first, safe for huge numbers");
}Paste into jshell to try it
Stuck?
Show a solution
static List<Integer> sortedDescending(List<Integer> numbers) {
List<Integer> copy = new ArrayList<>(numbers); // leave the caller's list alone
// Integer.compare(b, a), not b - a, which can overflow.
Comparator<Integer> biggestFirst = (a, b) -> Integer.compare(b, a);
copy.sort(biggestFirst);
return copy;
}Paste into jshell to try it
Swapping the arguments to Integer.compare(b, a) reverses the order, so the biggest sorts first. The subtraction b - a looks equivalent and breaks exactly when the two numbers are far apart, for example MIN_VALUE and MAX_VALUE, so the sort could put them in the wrong order with no warning. Copying first leaves the caller's list as it was. The sort takes O(n log n). Comparator.reverseOrder() is also correct here.
Problem 3
Count in several threads
Write countInThreads, which takes several batches of words, counts the words in each batch in its own thread, and returns one map of how many times each word appeared across all batches. It must not return until every thread has finished.
Start here
static Map<String, Integer> countInThreads(List<List<String>> batches) {
throw new UnsupportedOperationException("one thread per batch, one shared map, wait for all");
}Paste into jshell to try it
Stuck?
Show a solution
static Map<String, Integer> countInThreads(List<List<String>> batches) {
Map<String, Integer> counts = new ConcurrentHashMap<>(); // safe for many threads at once
List<Thread> threads = new ArrayList<>();
for (List<String> batch : batches) {
Thread thread = new Thread(() -> {
for (String word : batch) {
counts.merge(word, 1, Integer::sum);
}
});
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
try {
thread.join(); // do not read the map until every thread is done
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while waiting", e);
}
}
return counts;
}Paste into jshell to try it
The map is shared, so it has to be one built for threads, and merge makes each update a single step that no other thread can interrupt. Starting all the threads before joining any of them is what lets them run at the same time. Skipping join would return a map that is still being filled. The total work is the same as counting in one thread, O(total words), but it can be spread across processor cores. join throws a checked InterruptedException, so the code restores the interrupted flag and rethrows.
The round-up, in questions
Say each answer in a breath, then give the cost.
- "Fail-fast versus fail-safe?" Fail-fast iterators (
ArrayList,HashMap) throwConcurrentModificationExceptionif the collection changes during the loop. Fail-safe ones (CopyOnWriteArrayList,ConcurrentHashMap) never throw, because they read a snapshot or tolerate change. Add that fail-fast is best effort. - "How does fail-fast work?" The collection counts its changes, and the iterator remembers the count it started with and compares it on each step. That was lesson 5's tiny list.
- "When would you use
CopyOnWriteArrayList?" A list read very often and changed rarely, shared between threads. Writes copy the array, so they are O(n). - "
HashMapversusConcurrentHashMap, andHashtable?"HashMapis not safe for threads.ConcurrentHashMapis, and it is fast because it locks only part of the table.Hashtableis the old locked version: don't use it. - "Why does
ConcurrentHashMaprefusenull?" Agetthat returnsnullcould mean "no such key" or "the value isnull", and with several threads you cannot check safely afterwards. - "What is wrong with
list.remove(i)on aList<Integer>?" Anintpicks the index version ofremove. For the value, passInteger.valueOf(x), or useremoveIf. - "
==orequalsforInteger?"equals.==compares identity and gives inconsistent answers for boxed numbers. - "Why not
a - bin a comparator?" It can overflow. UseInteger.compare. - "Is
subLista copy?" No. It is a window onto the original, so changes write through. Copy it if you need independence. - Say the cost. "Fail-fast costs nothing but throws. Copy-on-write makes reads free and writes O(n). A concurrent hash map keeps lookups O(1) on average and lets many threads work at once."
Updated 2026-10-17
Made by VeraStack Labs