Skip to main content

Java Interview Questions for 5 Years Experience (Scenario-Based, with Answers)

Scenario-based Java interview questions for developers with about five years of experience: lost updates, deadlocks, mutable HashMap keys, ThreadLocal leaks, parallel streams, CompletableFuture and production debugging. Eight bugs reproduced in a downloadable Java file with real output, plus 32 answered questions.

At five years of experience, a Java interview stops asking what a keyword means and starts asking what you did when something broke in production. The questions are scenarios: “the counter is lower than the number of requests”, “the map has the entry but get() returns null”, “the service hangs but CPU is idle”. A good answer names the cause, shows you can prove it, and says how you would prevent it. This post gives you both halves. Eight of the classic production bugs are reproduced in one runnable file, ProductionScenarios.java, and the console output below is its real output. After them come 32 scenario questions with short answers, grouped by area. Here is the first bug — the lost update — so you can see the format:
Runnable work = () -> {
    for (int i = 0; i < 500_000; i++) { plain++; atomic.incrementAndGet(); map.merge("hits", 1, Integer::sum); }
};
// 4 threads run `work`
[1] lost update: 4 threads x 500000 increments
    expected              = 2000000
    plain int++ lost some = true
    AtomicInteger         = 2000000
    ConcurrentHashMap.merge = 2000000
Symptom you are asked aboutRoot causeWhere
Counter lower than the real number of requestsint++ is read-modify-write, not atomicLab 1, Q1
Service hangs, CPU idle, threads blockedDeadlock (circular lock wait)Lab 2, Q2
Entry is in the map but get() returns nullMutable object used as a hash keyLab 3, Q8
User A sees user B’s dataThreadLocal never cleared on a pooled threadLab 4, Q4
Parallel stream result has missing elementsShared mutable ArrayList written from many threadsLab 5, Q5
Integer or BigDecimal comparison is “randomly” falseIdentity vs equality, cache range, scaleLab 6, Q11
Async call never times out, exceptions look wrappedCompletableFuture semanticsLab 7, Q6
Wrong value returned, real error vanishedfinally overrides; suppressed exceptionsLab 8, Q17
Versions used here. The lab file is written for JDK 21 or newer and uses no dependencies; the output in this post is from JDK 25.0.4.1. Framework answers refer to Spring Boot 4.1.1 on Spring Framework 7.0.9. Checked 22 October 2026. Two of the labs (lost update and parallel stream) are racy by nature — the numbers vary from run to run, which is the point.

Eight production bugs, reproduced

The excerpts below are cut down from ProductionScenarios.java; run the whole file with java ProductionScenarios.java. Each console block is unedited output from a single run.

Lab 1: why the counter is lower than the request count

The output at the top of the page already shows it: four threads incrementing a plain int 500,000 times each do not reach 2,000,000, because plain++ reads, adds and writes back as three steps and two threads can read the same value. AtomicInteger and ConcurrentHashMap.merge make the whole update atomic and land exactly on 2,000,000. Hint for the interview: say “lost update” and name the two fixes (atomics, or a lock around the whole read-modify-write).

Lab 2: a real deadlock, detected and fixed

transfer-A-to-Btransfer-B-to-ALock ALock Bholds Aholds Bwaits for Bwaits for AFix: one global lock orderEvery thread locks A first,then B. No circular wait.Solid green = lock held. Dashed red = waiting. Two waits that point at each other never resolve.
The classic circular wait that Lab 2 builds, and the one-line fix.
Each thread takes one lock, waits until the other has taken its own, then asks for the second — so both wait forever. The lab asks the JVM itself whether it is deadlocked, then repeats the work with a single lock order:
long[] ids = ManagementFactory.getThreadMXBean().findDeadlockedThreads();
// ...
static void ordered(ReentrantLock a, ReentrantLock b) {
    a.lock(); try { b.lock(); try { /* transfer */ } finally { b.unlock(); } } finally { a.unlock(); }
}
[2] deadlock
    ThreadMXBean.findDeadlockedThreads() -> 2 threads
    with one global lock order, both threads finished: true
In production you find the same thing with jstack <pid> or a thread dump from jcmd <pid> Thread.print, which prints “Found one Java-level deadlock” with the two lock chains. The fix is a consistent lock order, tryLock with a timeout, or removing the second lock altogether.

Lab 3: the entry is there but get() returns null

Key k = new Key("user-1");
m.put(k, "profile");
k.id = "user-2";                    // mutate the key after it is in the map
m.get(k);                           // hash is now different
[3] mutable HashMap key
    size after mutating the key = 1
    get(sameKeyObject)          = null
    get(new Key("user-1"))      = null
The entry was stored in the bucket for the hash of "user-1". After the mutation, the same object hashes to a different bucket, so the lookup never looks where the entry lives, and a fresh Key("user-1") finds the right bucket but no longer equals the stored object. The entry is still counted in size() but practically unreachable. Use immutable keys (String, records, enums), as explained in the HashMap internals post.

Lab 4: ThreadLocal on a pooled thread

pool.submit(() -> CURRENT_USER.set("user-A")).get();
pool.submit(CURRENT_USER::get).get();          // next request, same pooled thread
// fix: try { CURRENT_USER.set(...); ... } finally { CURRENT_USER.remove(); }
[4] ThreadLocal + thread pool
    next request on the same thread sees: user-A
    after remove() in finally, it sees:     null
A pool reuses threads, so a ThreadLocal value outlives the request that set it. The next request on that thread sees user A’s data — a security bug, not just a leak. Always remove() in finally, or avoid the pattern: on Java 25 ScopedValue is final and ties the value to a scope instead of a thread.

Lab 5: a parallel stream writing into a shared ArrayList

IntStream.range(0, 100_000).parallel().forEach(shared::add);      // broken
List<Integer> safe = IntStream.range(0, 100_000).parallel().boxed().toList();
[5] parallel stream + shared ArrayList
    forEach(shared::add) -> broken (lost elements, size < 100000)
    collect via toList()  -> size 100000
ArrayList.add is not thread-safe; concurrent adds overwrite each other (and sometimes throw ArrayIndexOutOfBoundsException). The rule is to let the stream collect rather than mutate shared state from the pipeline. The same applies to HashMap, counters and any non-thread-safe object captured by a lambda.

Lab 6: equality traps

Integer c = 128, d = 128;   c == d;                 // false: outside the -128..127 cache
new BigDecimal("2.0").equals(new BigDecimal("2.00"));        // false: scale is part of equals
new BigDecimal("2.0").compareTo(new BigDecimal("2.00"));     // 0
[6] equality traps
    Integer 127 == 127 : true
    Integer 128 == 128 : false
    BigDecimal 2.0 equals 2.00    : false
    BigDecimal 2.0 compareTo 2.00 : 0
Integer.valueOf caches -128 to 127, so == works for small values and silently fails for larger ones — use equals. For BigDecimal, equals compares value and scale; use compareTo for numeric equality (and be careful with HashSet<BigDecimal>).

Lab 7: CompletableFuture timeouts and wrapped exceptions

slow.orTimeout(200, TimeUnit.MILLISECONDS)
    .exceptionally(ex -> "fallback after " + ex.getClass().getSimpleName()).get();
[7] CompletableFuture
    fallback after TimeoutException
    get() wraps the cause: java.lang.IllegalStateException: boom
A CompletableFuture has no timeout unless you add one (orTimeout, completeOnTimeout). get() wraps a failure in ExecutionException, so the real exception is getCause(); join() wraps it in CompletionException instead. Also remember the default pool: supplyAsync without an executor runs on the common pool.

Lab 8: finally and suppressed exceptions

static int finallyWins() { try { return 1; } finally { return 2; } }
try (Res r = new Res()) { throw new RuntimeException("work failed"); }   // close() also throws
[8] finally and try-with-resources
    return in try, return in finally -> 2
    primary   : work failed
    suppressed: close failed
A return in finally overrides everything, including a thrown exception, which is why linters flag it. With try-with-resources the exception from the body stays primary and the one from close() is attached with addSuppressed, so you see both in a stack trace; hand-written finally { close(); } would have lost the original.
Going deeper. For the fuller treatment of these areas see the Top 40 Java concurrency questions, the Java memory model explained and HashMap vs ConcurrentHashMap.

32 scenario questions with answers

Grouped as concurrency (1–7), collections and data (8–12), JVM and performance (13–16), language and design (17–24) and Spring and production (25–32). Labs 1 to 8 are referenced where an answer is shown above.
1. Scenario: a request counter is consistently lower than the real request count. Why, and how do you fix it? counter++ is three steps (read, add, write) and two threads can read the same value, so one update is lost. Lab 1 loses updates with a plain int and gets exactly 2,000,000 with AtomicInteger or ConcurrentHashMap.merge. Fix with an atomic, LongAdder for heavily contended counters, or a lock around the whole read-modify-write. volatile alone does not help, because it gives visibility but not atomicity.
2. Scenario: the service hangs, CPU is idle and requests time out. How do you find a deadlock? Take a thread dump (jcmd <pid> Thread.print or jstack); the JVM reports “Found one Java-level deadlock” and shows which thread holds and waits for which lock. Lab 2 does the same programmatically with ThreadMXBean.findDeadlockedThreads(). Prevent it with one global lock order, tryLock(timeout), or by not holding two locks at once. Mention that database deadlocks look similar but are detected by the database.
3. When would you use virtual threads, and what are their limits? For many concurrent blocking tasks (HTTP calls, JDBC, message handling): one virtual thread per task is cheap, so you stop sizing pools around blocking. They do not speed up CPU-bound work. Since JDK 24 (JEP 491) synchronized no longer pins the carrier thread, so the old advice to rewrite every synchronized block mostly no longer applies; native frames can still pin. Do not pool virtual threads, and bound access to scarce resources (a database) with a semaphore. Deeper: virtual threads vs platform threads and diagnosing pinning.
4. Scenario: users occasionally see another user’s data. What do you check first? A ThreadLocal that is set per request but never cleared on a pooled thread (Lab 4: the next request on the same thread still sees user-A). Clear it in finally with remove(), or use a filter that guarantees cleanup. Also check shared mutable singletons and caches keyed without the user. On Java 25, ScopedValue (final) binds a value to a scope, so there is nothing to forget to clear.
5. Scenario: a parallel stream returns fewer elements than expected. What went wrong? The pipeline wrote into a non-thread-safe collection (Lab 5). Streams should produce results through the terminal operation (toList(), collect), not by mutating shared state. Also question whether parallel helps at all: it needs a large, CPU-bound, easily splittable source, and it shares the common pool, so a blocking task inside it can starve other work. Streams in depth: Top 50 Stream API questions.
6. Scenario: an async call to a slow service never returns. How do you add a timeout and handle failures with CompletableFuture? orTimeout fails the future with TimeoutException; completeOnTimeout supplies a default; exceptionally or handle recovers (Lab 7). get() throws ExecutionException with the real cause inside; join() throws CompletionException. Pass your own Executor instead of relying on the common pool, and remember that a timeout does not cancel the underlying work — it only stops waiting for it.
7. How do you size a thread pool, and what happens when its queue fills up? CPU-bound work: about the number of cores. Blocking work: cores × (1 + wait time / compute time), or use virtual threads and stop guessing. A ThreadPoolExecutor grows to the core size, then fills its queue, and only then adds threads up to the maximum; with an unbounded queue it never grows past the core size and memory becomes the limit. Use a bounded queue and a deliberate rejection policy, and always shut pools down (ExecutorService done right).
8. Scenario: the entry is in the HashMap, but get() returns null. Why? The key was mutated after insertion, so its hash code changed and lookups go to a different bucket (Lab 3: size stays 1, both lookups return null). Use immutable keys: String, records, enums, or classes whose fields used in equals/hashCode are final. See how HashMap works internally.
9. HashMap, Hashtable, synchronizedMap or ConcurrentHashMap — which and why? HashMap when only one thread touches it. ConcurrentHashMap for shared use: lock-free reads, fine-grained updates, atomic merge/computeIfAbsent, no null keys or values. Hashtable and Collections.synchronizedMap lock the whole map and still need external synchronization for compound actions. Even ConcurrentHashMap does not make “check then put” atomic — use its atomic methods. Full comparison: HashMap vs ConcurrentHashMap.
10. Scenario: you get ConcurrentModificationException while looping. Cause and fixes? You structurally modified a collection (add or remove) while an iterator or enhanced for was walking it; the fail-fast iterator notices the changed modCount. It can also happen with a single thread. Fixes: iterator.remove(), removeIf, collecting changes and applying them afterwards, or a concurrent collection if threads are involved. The exception is best-effort detection, not a thread-safety guarantee.
11. Scenario: an Integer or BigDecimal comparison is false when it “should” be true. Explain. == compares references; Integer.valueOf caches -128 to 127, so 127 == 127 is true and 128 == 128 is false (Lab 6). BigDecimal.equals includes the scale, so 2.0 and 2.00 are not equal while compareTo returns 0. Rules: compare boxed values with equals, money with BigDecimal and compareTo, and whenever you override equals override hashCode with the same fields.
12. How do you choose between ArrayList, LinkedList, ArrayDeque, TreeMap and LinkedHashMap? ArrayList by default (cache-friendly, O(1) index). LinkedList almost never — ArrayDeque is the better queue and stack. TreeMap or TreeSet when you need sorted order or range queries. LinkedHashMap for insertion order, or access order plus removeEldestEntry for a small LRU cache. EnumMap for enum keys. Be ready to state the complexity and the memory cost. More: Top 40 Java collections questions.
13. Scenario: production throws OutOfMemoryError. What is your process? First read the message: Java heap space, GC overhead limit exceeded, Metaspace and unable to create native thread are different problems. Run with -XX:+HeapDumpOnOutOfMemoryError, open the dump in Eclipse MAT, and look at the dominator tree and the path to GC roots of the biggest retained objects. Then decide whether it is a leak (growth that never levels off) or simply an undersized heap. Full walkthrough: debugging OutOfMemoryError.
14. What are the common causes of memory leaks in Java? Static collections that only grow, caches without eviction, listeners or callbacks never deregistered, ThreadLocal values on long-lived threads, unclosed resources, and class-loader leaks in redeployed apps. Java has garbage collection, so a “leak” means objects are still reachable that should not be. Show you would prove it with a heap dump, not guess.
15. Which garbage collector would you choose, and why? G1 is the default and the right answer unless measurements say otherwise. Choose by goal: throughput (Parallel), balanced with predictable pauses (G1), very low pause on large heaps (ZGC — from JDK 24 only the generational mode exists), Shenandoah as the alternative low-pause collector. Say that you would measure with GC logs (-Xlog:gc*) and JFR before changing anything. Background: Java memory management and GC.
16. Scenario: one JVM is at 100% CPU. How do you find the hot code? Find the busy thread (top -H -p <pid>), convert its id to hex, and match it with nid= in a thread dump; take several dumps to see whether it stays in the same frames. For a proper answer use Java Flight Recorder, which samples with low overhead: JFR and Mission Control profiling. Check GC activity too — a GC storm looks like CPU load with no hot application code.
17. Does finally always run? What does try-with-resources add? Almost always: it is skipped only if the JVM exits (System.exit, crash) or the thread never finishes. A return in finally overrides the try’s return value and swallows exceptions, so never do it. Try-with-resources closes resources in reverse order and records exceptions thrown by close() as suppressed on the primary exception instead of masking it. Prefer it to hand-written finally cleanup.
18. When would you use a record, and what can it not do? For immutable data carriers: DTOs, map keys, query results, value objects. You get the constructor, accessors, equals, hashCode and toString for free, and a compact constructor for validation. A record cannot extend a class, cannot have instance fields beyond its components, and is shallowly immutable (a List component can still be mutated unless you copy it). Not a good JPA entity. Guide: Java records.
19. How do sealed types and pattern-matching switch make code safer? A sealed hierarchy lists its permitted subtypes, so a switch over it can be checked for exhaustiveness by the compiler: add a new subtype and every switch that misses it stops compiling, instead of failing at runtime. Combine with records and record patterns to deconstruct values. Avoid a default branch on sealed switches, because it hides exactly that safety. Details: sealed classes and pattern matching.
20. Where do streams stop being the right tool? When the logic needs checked exceptions, several mutable accumulators, early exits with complex state, or when a plain loop is clearer. Streams are lazy, single-use, and debugging a long pipeline is harder. For custom intermediate operations such as windowing, Gatherers (final in Java 24) fill a long-standing gap: Stream Gatherers. Practice: Streams API guide.
21. How do you make a class truly immutable? Final class (or record), private final fields, no setters, and defensive copies of mutable inputs and outputs (List.copyOf in the constructor, unmodifiable views or copies from getters). Collections.unmodifiableList is only a view over the original, so later changes to the source still show through; List.copyOf takes a snapshot. Immutable objects are automatically thread-safe and safe as map keys.
22. How do you design exception handling for a service? Throw specific, meaningful exceptions near the failure; do not catch what you cannot handle; never swallow an exception silently; wrap with the cause so the stack trace survives. Use unchecked exceptions for programming and unrecoverable errors, checked ones only where the caller can realistically recover. Translate to HTTP responses in one place (@RestControllerAdvice or ProblemDetail), and log once, not at every layer.
23. When is Optional misused? As a field type, a method parameter, or in collections (return an empty collection instead). Also optional.get() without a check and orElse(expensiveCall()), which always evaluates the argument — use orElseGet. Optional is meant as a return type that says “there may be no result”. Prefer map, flatMap and orElseThrow over isPresent() plus get().
24. Your team is on Java 21. What do you gain by moving to Java 25, and how do you do it safely? Notable gains: virtual threads no longer pin on synchronized (JDK 24), generational-only ZGC, Scoped Values and Stream Gatherers final, Compact Object Headers for smaller heaps, and faster startup work such as AOT caching. Do it in steps: build and test on 25 in CI first, fix deprecation and removal warnings, check libraries and agents for compatibility, run a canary, and compare GC and latency metrics before and after. Overview: Java 21 to 25 features.
25. Scenario: @Transactional (or @Cacheable) seems to be ignored. Why? Spring applies it through a proxy, so a call from one method to another method in the same class (self-invocation) bypasses the proxy and the advice never runs. The same applies to private or final methods and to objects not created by Spring. Fix by moving the method to another bean, injecting the proxy, or restructuring. Same trap, same fix: self-invocation in Spring cache. More Spring questions: Spring Boot 4 interview questions.
26. Scenario: a list endpoint issues hundreds of SQL queries. What is it and how do you fix it? The N+1 select problem: one query loads N parents, then each lazy association triggers another query. Confirm by logging SQL or counting statements in a test. Fix with JOIN FETCH, an entity graph, batch fetching (@BatchSize), or a DTO projection that selects only what the endpoint needs. Do not “fix” it by switching everything to eager fetching.
27. How do you design retries safely? Retry only transient failures, with a limit, exponential backoff and jitter. Retrying is only safe if the operation is idempotent, so use an idempotency key for payments and other writes. Never retry at every layer (the multiplied attempts become a retry storm). Spring Framework 7 ships @Retryable in core; see @Retryable, @ConcurrencyLimit and Resilience4j.
28. A downstream service is slow. How do you stop it taking your service down? Set timeouts on every remote call (connect and read), cap concurrency with a bulkhead, open a circuit breaker after repeated failures so you fail fast, and return a fallback or a clear error. Without a timeout, threads pile up waiting and the slow dependency becomes your outage. Implementation: Resilience4j circuit breaker with Spring Boot.
29. How does a Spring Boot app shut down gracefully on Kubernetes? On SIGTERM Kubernetes has already started removing the pod from endpoints, so enable server.shutdown=graceful with a shutdown timeout, make the readiness probe fail first, and keep terminationGracePeriodSeconds longer than the timeout. In-flight requests finish; new ones go elsewhere. Configuration and probes: Kubernetes probes and graceful shutdown.
30. How do you trace one request across logs and services? Generate or accept a correlation (trace) id at the edge, put it in the logging context (MDC), include it in every log line, and propagate it on outgoing calls. In practice use Micrometer Tracing with OpenTelemetry so ids propagate automatically. Remember MDC is thread-bound, so with async code you must pass it along explicitly. See Micrometer and OpenTelemetry observability.
31. What is your testing strategy for a Spring Boot service? Many fast unit tests (JUnit 6, plain objects, no Spring), fewer slice tests (@WebMvcTest, @DataJpaTest), and a small number of full integration tests against real dependencies using Testcontainers instead of mocks for the database. Add contract tests where services meet. Avoid testing implementation details; assert behaviour. Keep tests deterministic: no sleeps, inject clocks.
32. How do you change a database schema with zero downtime? Use expand and contract: (1) add the new column or table in a backward-compatible way, (2) deploy code that writes both and reads the new one, (3) backfill in batches, (4) deploy code that uses only the new structure, (5) drop the old one later. Never rename or drop a column in the same release that stops using it, because old and new instances run side by side during a rolling deploy. Run migrations with Flyway or Liquibase.

Common interview questions

  • “Tell me about a production issue you debugged.” Pick one of the eight labs, then give the symptom, the evidence you collected (thread dump, heap dump, JFR), the root cause and the fix, in that order.
  • “How would you find out why the app is slow?” Measure first: metrics, then a profile, then a hypothesis. Do not start by changing code.
  • “What changed for you between Java 8 and Java 25?” Lambdas and streams, records, sealed types and pattern matching, virtual threads, and a new GC and release cadence.
  • “What is the hardest bug you have fixed?” Concurrency bugs are the usual answer; explain how you reproduced it, since that is the hard part.

More practice: the Java interview preparation guide, Top 40 concurrency questions, Top 40 collections questions, Top 50 Stream API questions and 30 String programs.

Common mistakes

  • Reciting definitions at a 5-year level. Interviewers expect “what broke, how did you know, what did you change”.
  • Guessing at performance problems instead of naming the tool (jcmd, JFR, MAT, GC logs) you would use to measure.
  • Claiming volatile or ConcurrentHashMap makes compound actions atomic.
  • Repeating outdated advice, such as “avoid synchronized with virtual threads” on JDK 24 and later.
  • Describing a technology without its trade-offs; every answer above includes when not to use it.
Should you memorize all 32 answers? No. Reproduce two or three of the labs yourself, because a story you actually debugged survives follow-up questions that a memorized answer does not. The lab file is linked at the top of this post.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.