← Blog

Interview prep

Java Interview Questions: Collections, Concurrency, the Memory Model and Records

The Java questions interviewers keep asking, answered with short code we compiled and ran on JDK 17, plus the trade-offs behind HashMap, concurrency and modern language features.

Matthew M

16 min read

A calm, softly lit desk by a window with a coffee cup and a closed notebook, shallow depth of field, no text visible.

The Java interview questions that come up most are == versus equals, the equals and hashCode contract, how HashMap works, thread safety with synchronized, volatile and atomics, the Java memory model, and modern features such as records and sealed types. A strong answer gives the rule, a small example, and the trade-off. We compiled and ran the samples below on JDK 17.

Version note. Java 17 and Java 21 are the long-term-support releases most job posts name, and Java 25 is the newest LTS, released in September 2025. We compiled every sample on 17. Features that arrived in 21, such as pattern matching in switch and virtual threads, are described in prose and were not compiled here, so treat those paragraphs as checked against the language specification, not against our own run.

What is the difference between == and equals(), and what is the string pool?

== compares references for objects: do these two variables point to the same object? equals() compares logical content, as defined by the class. Strings make the distinction visible. With String s1 = 'hi' and s2 = 'hi' (literals), s1 == s2 is true because both refer to one interned instance in the string pool. With s3 = new String('hi'), s1 == s3 is false, s1.equals(s3) is true, and s1 == s3.intern() is true again. We printed true, false, true, true for those four checks.

Boxed integers are the follow-up. Integer i1 = 127 and i2 = 127 compare equal with ==, but Integer i3 = 128 and i4 = 128 do not: we printed true, then false, with equals() returning true. Autoboxing uses valueOf, which caches values from -128 to 127. The lesson to state is simple: never compare boxed values or strings with ==. Also say that strings are immutable, so s.concat('b') leaves s unchanged (we confirmed s was still 'a'), and that StringBuilder is the right tool for repeated appends in a loop.

What is the equals and hashCode contract?

If two objects are equal according to equals(), they must return the same hashCode(). The reverse need not hold, since different objects can share a hash code. A good equals is reflexive, symmetric, transitive, consistent and returns false for null.

Breaking the contract breaks hash-based collections. We defined a class that overrode equals but not hashCode. After adding new BadKey(1) to a HashSet, contains(new BadKey(1)) printed false, because the two instances had different identity-based hash codes and landed in different buckets. Adding hashCode that returned Integer.hashCode(id) made the same check print true. The failure is probabilistic in principle, since a collision could hide the bug, which is what makes it nasty.

Mutable keys are the other classic. We used a list as a HashMap key, stored a value, then added an element to the list. Lookup with the same list object returned null, and lookup with List.of(1) returned false for containsKey, because the stored entry was filed under the old hash. The rule: keys should be immutable, or at least never change while in the map. Records, covered below, give you correct equals and hashCode for free.

How does HashMap work internally?

HashMap keeps an array of buckets. The key's hashCode is spread and masked to choose a bucket index; equal hashes in the same bucket form a linked chain, and lookups call equals along the chain. The default capacity is 16 and the default load factor is 0.75, so when the number of entries exceeds 12 the table doubles and entries are redistributed.

Since Java 8, a bucket whose chain grows to 8 entries is converted into a balanced tree, provided the table has at least 64 buckets; otherwise the table resizes instead. That turns the worst-case lookup from O(n) into O(log n) for keys that are comparable. Average get and put stay O(1). Say these numbers confidently because interviewers check them.

Other details that come up: HashMap permits one null key and null values (hm.put(null, 1) then hm.get(null) returned 1 for us), is not thread-safe, and has no ordering guarantee. LinkedHashMap preserves insertion order (we saw [z, a] for keys inserted as z then a). TreeMap sorts by key in O(log n) per operation and gave [a, z] for the same data. ConcurrentHashMap rejects null keys and values.

Which collection should you choose, and why?

  • ArrayList: random access O(1), append amortised O(1), insert or remove in the middle O(n). The default choice for lists. Capacity grows by about 1.5 times.
  • LinkedList: O(1) insert at a known node but O(n) to find it, with poor cache locality. It rarely beats ArrayList in practice.
  • ArrayDeque: the right stack and queue. Faster than Stack and LinkedList for those uses, and it rejects nulls: adding null threw NullPointerException in our run.
  • PriorityQueue: a binary min-heap by default. poll returns the smallest element, and iterating or printing the queue does not give sorted order in general. Building one from 5, 1, 3 gave poll 1, and only polling repeatedly guarantees ascending order.
  • HashSet and TreeSet: the same trade-off as their map cousins, hash for speed and tree for sorted order.
  • List.of and Set.of: immutable. List.of(1).add(2) threw UnsupportedOperationException.
  • Arrays.asList: fixed size but writable. set(0, 9) worked and add threw UnsupportedOperationException.

The comparison question 'ArrayList versus LinkedList' deserves a rehearsed sentence: LinkedList's O(1) insertion only helps when you already hold the node, and pointer chasing makes traversal slower on modern hardware, so default to ArrayList or ArrayDeque and measure before changing.

Fail-fast iterators

Most non-concurrent collections throw ConcurrentModificationException if the collection is structurally modified during iteration by anything other than the iterator itself. There is a famous quirk. With a list [1, 2, 3], removing 2 inside a for-each loop did not throw for us and left [1, 3], because after the removal the iterator's hasNext check saw the cursor equal to the new size and quietly stopped. Removing 1 in the same loop did throw. The reliable fixes are Iterator.remove, removeIf (which produced [1, 3] cleanly), or building a new list. The fail-fast behaviour is best-effort, so never depend on the exception for program logic.

What do synchronized, volatile and atomic classes each guarantee?

synchronized gives mutual exclusion and visibility: only one thread holds a given monitor at a time, and when a thread releases the monitor, its writes become visible to the next thread that acquires it. volatile gives visibility and ordering for a single variable, but not atomicity of compound actions. Atomic classes such as AtomicInteger use compare-and-set hardware instructions to make read-modify-write operations atomic without a lock.

The demonstration interviewers want: four threads each increment a counter 100,000 times, so the right total is 400,000. We ran that with four counters. The plain static int total and the volatile int total both came out lower than 400,000, as expected, because counter++ is a read, an add and a write, and threads interleave between them. volatile does not fix that. AtomicInteger.incrementAndGet gave exactly 400000, and a synchronized block gave exactly 400000. The exact short totals vary run to run, so test with a less-than check, not a fixed number.

Trade-off: an atomic is faster than a lock under low contention and has no deadlock risk, but it only covers one variable. When two fields must change together, you need a lock or an immutable state object swapped atomically.

Deadlock in one paragraph

A deadlock needs mutual exclusion, hold-and-wait, no pre-emption and circular wait. The usual cure is breaking circular wait by acquiring locks in one global order, such as always locking the lower account number first in a transfer. tryLock with a timeout is another option, and a thread dump (jstack or jcmd Thread.print) will report the cycle.

How do ExecutorService, Future and CompletableFuture differ?

Create threads with new Thread only for toy examples. In production code you submit tasks to an ExecutorService, which owns a pool of threads. executor.submit(() -> 6 * 7) returns a Future whose get() blocks until the answer, 42 for us. Always call shutdown, then awaitTermination, or the pool's non-daemon threads can keep the JVM alive; we printed true from awaitTermination.

CompletableFuture lets you compose asynchronous steps without blocking. supplyAsync(() -> 'a') combined with another supplyAsync(() -> 'b') through thenCombine produced 'ab'. For errors, exceptionally(t -> -1) turned a failed future into -1, while calling get() on the failed one threw ExecutionException whose cause was the original IllegalStateException. The trade-off versus plain Future is expressiveness: chaining replaces blocking get calls, but stack traces and error handling become harder to read, and the default pool is the shared ForkJoinPool, which is a poor home for blocking calls.

On Java 21 and later, virtual threads (JEP 444) are the other answer. They are cheap threads managed by the JVM, created with Executors.newVirtualThreadPerTaskExecutor(), and they let blocking-style code scale to many concurrent tasks. They help I/O-bound work, not CPU-bound work, and code that pins the carrier thread, for example in older synchronized blocks around blocking calls, can lose the benefit. We did not compile virtual-thread code on our JDK 17.

ConcurrentHashMap is the standard shared map. Its merge method updates atomically: counting the words in 'a b a c a' with merge(w, 1, Integer::sum) produced {a=3, b=1, c=1}. Mention that compound check-then-act code such as if (!map.containsKey(k)) map.put(k, v) is still a race, and putIfAbsent or computeIfAbsent is the correct single call.

What is the Java memory model?

The Java Memory Model defines when one thread's writes are guaranteed visible to another. It is expressed as happens-before relationships. An unlock of a monitor happens-before every later lock of the same monitor. A write to a volatile variable happens-before every later read of it. Thread.start happens-before the started thread's first action, and a thread's last action happens-before another thread's successful join on it. Without such an edge, the compiler and CPU may reorder operations and a thread may read a stale value.

Classic follow-ups: the double-checked locking singleton only works when the instance field is volatile, since otherwise another thread could see a partly constructed object. Final fields get special treatment: if an object is constructed properly and does not leak this from its constructor, other threads see its final fields correctly initialised even through a data race. That is one more reason to prefer immutable classes.

Heap, stack and garbage collection

Each thread has its own stack holding frames with local variables and references. Objects live on the shared heap. Class metadata lives in metaspace, which replaced the permanent generation in Java 8. A StackOverflowError means a thread's stack ran out, usually from runaway recursion, while OutOfMemoryError means the heap (or another area) is exhausted.

G1 has been the default collector since Java 9, aimed at predictable pauses on larger heaps. ZGC aims at very short pauses, and its generational mode arrived in Java 21. Be ready to say what you would reach for: measure with GC logs first, then tune heap size or pick a collector, and look for object retention such as unbounded static caches before blaming the collector.

What do records, sealed types and pattern matching change?

A record (final in Java 16) is a concise immutable data carrier. record Point(int x, int y) {} generates the constructor, accessors, equals, hashCode and toString. We printed Point[x=1, y=2], and new Point(1, 2).equals(new Point(1, 2)) was true. A compact constructor can validate: ours threw IllegalArgumentException for a negative x. Records are shallowly immutable, so a list component can still be changed from the outside unless you copy it defensively.

A sealed interface (final in Java 17) lists the types allowed to implement it, using permits. sealed interface Shape permits Circle, Square, with both implementations as records, tells the compiler the hierarchy is closed. We wrote an area method using instanceof patterns (Java 16): if (s instanceof Circle c), then Math.PI * c.r() * c.r(), else if (s instanceof Square q), then q.s() * q.s(). It printed 9.0 for a square of side 3.

In Java 21, switch can match patterns directly, so the same logic becomes a switch over the sealed type with one case per record and no default. Because the hierarchy is sealed, the compiler checks exhaustiveness: add a new permitted type and every switch that forgot it stops compiling. That is the trade-off to highlight against an open class hierarchy, where new subclasses silently fall through to a default branch. The cost is that sealed hierarchies must be known in advance, which suits domain models and result types, not plug-in systems.

Smaller modern features worth one line each: var infers local variable types and does not make Java dynamically typed; text blocks (triple-quoted strings) keep their line structure, with incidental indentation removed; switch expressions with arrows return a value and do not fall through; Optional is for return values, not fields or parameters.

Checked versus unchecked exceptions, and the finally traps

Checked exceptions extend Exception but not RuntimeException, and the compiler forces callers to catch or declare them. Unchecked exceptions are RuntimeException and Error subclasses. A defensible position: use checked exceptions for recoverable conditions that callers realistically handle, and unchecked ones for programming errors. Many modern libraries lean unchecked, and lambdas are awkward with checked exceptions.

Two puzzles come up regularly. First, a finally block that returns overrides the try block's return: our method with try { return 1; } finally { return 2; } returned 2, and it also swallows any exception thrown in the try. Second, try-with-resources closes resources automatically and records close failures as suppressed exceptions. When the body threw RuntimeException('body') and close() threw IllegalStateException('close'), the caught exception's message was 'body' and getSuppressed()[0] held 'close'.

A few numeric and generics questions

  • Integer overflow wraps silently: Integer.MAX_VALUE + 1 printed -2147483648. The old binary search midpoint bug follows from this; use low + (high - low) / 2, or the unsigned shift (low + high) >>> 1. In our test, (MAX + MAX) / 2 printed -1.
  • Modulo with negatives: -7 % 3 is -1 while Math.floorMod(-7, 3) is 2.
  • 0.1 + 0.2 == 0.3 is false for doubles. Use BigDecimal for money, built from strings.
  • Type erasure: generic type arguments do not exist at runtime. new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass() printed true, which is why you cannot write new T[] or check instanceof List<String>.
  • Interface versus abstract class: interfaces can have default methods since Java 8 but no instance state; abstract classes can hold fields and constructors. Prefer interfaces for contracts and composition for reuse.

How do you practise explaining Java out loud?

Most of these answers are short when spoken: a rule, an example and a trade-off, about thirty seconds each. The hard part is saying them calmly while someone watches you code. Rehearse by explaining your reasoning as you write, as described in the post on narrating a live coding problem under a timer.

URHIRED AI offers a Live coding surface and mock sessions for that rehearsal, and you speak or type every answer yourself. The pricing page lists current plans.

Where to go next

Frequently asked questions

What Java interview questions are asked most often?

Expect == versus equals, the equals and hashCode contract, how HashMap works, ArrayList versus LinkedList, synchronized versus volatile versus atomics, the Java memory model, garbage collection basics, and exceptions. For newer roles, records, sealed types and pattern matching appear, along with virtual threads in Java 21 and later.

Why do you need to override hashCode when you override equals?

Hash-based collections choose a bucket from hashCode before calling equals. If two equal objects return different hash codes, they land in different buckets, so contains and get can miss objects that are equal. The contract says equal objects must have equal hash codes. In our test, a HashSet lookup failed until hashCode was added.

Does volatile make a counter thread-safe in Java?

No. volatile guarantees that writes are visible to other threads and are not reordered across that variable, but counter++ is still a read, an add and a write. Concurrent increments can be lost. Use AtomicInteger, a lock, or a synchronized block for compound updates. In our run, four threads with a volatile counter fell short of the correct total.

Which Java version should I study for interviews?

Study Java 17 and 21 at least, since most job posts name an LTS release, and know that Java 25 is the newest LTS. The features most worth knowing are records, sealed types, pattern matching for instanceof and switch, text blocks, and virtual threads. Check the target company's stack, because many still run Java 11 or 17.

What changed in HashMap in Java 8?

Buckets that grow to eight or more entries are converted from linked lists to balanced trees, provided the table has at least 64 buckets. Otherwise the table resizes instead. This improves worst-case lookup from O(n) to O(log n) when many keys collide. Average performance of get and put stays O(1), with default capacity 16 and load factor 0.75.

← All posts

Ready for the live round?

Create a free account, then download the desktop app.

No credit card required·Works with all meeting platforms·macOS and Windows