JavaAdvanced

Java Interview: Concurrency, Memory Model & the JVM

The senior round: threads and executors, synchronized vs volatile vs atomics, the Java Memory Model, deadlock, CompletableFuture, plus JVM memory areas, garbage collection, and how to reason about a memory leak.

4 sections · ~38 min · 5-question quiz (pass ≥ 70%)

1Threads, Executors, and Why You Never Call new Thread()

Thread lifecycle: NEW → RUNNABLE → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED. start() spawns a new thread; calling run() directly just executes on the current thread — a classic trick question.

Never manage raw threads in production code. Thread creation is expensive and unbounded thread creation is how services fall over. Use an ExecutorService:

ExecutorService pool = Executors.newFixedThreadPool(8);

Future<Integer> f = pool.submit(() -> expensiveComputation());
Integer result = f.get();               // blocks until done

pool.shutdown();                        // stop accepting; finish queued work
pool.awaitTermination(30, TimeUnit.SECONDS);

Runnable vs Callable: Runnable.run() returns void and cannot throw checked exceptions; Callable.call() returns a value and can throw. submit() takes either and hands back a Future.

Sizing the pool: CPU-bound work → about the number of cores. IO-bound work → higher, because threads spend most of their time blocked.

Virtual threads (Java 21) are the modern answer for IO-bound workloads — millions of cheap threads, no pooling needed:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> callRemoteApi());
}

Mentioning virtual threads and why they help (blocking IO no longer pins an OS thread) is a strong senior signal.

2synchronized, volatile, and Atomics — the Real Differences

The interviewer wants to hear that you know these solve two different problems: atomicity and visibility.

The bug:

private int count = 0;
public void increment() { count++; }    // NOT atomic: read, add, write

Two threads can read 5, both write 6, and one increment vanishes.

synchronized — mutual exclusion and visibility. Only one thread holds the monitor; on release, its writes are flushed and visible to the next acquirer.

public synchronized void increment() { count++; }   // locks on the instance

volatile — visibility and ordering only, no atomicity. volatile int count; count++ is still broken. Its correct use is a flag:

private volatile boolean running = true;   // another thread's write is seen promptly
while (running) { doWork(); }

Without volatile, the JIT may hoist the read out of the loop and spin forever.

Atomics — lock-free atomicity via compare-and-swap:

AtomicInteger count = new AtomicInteger();
count.incrementAndGet();                // atomic, no lock, no contention stall

The Java Memory Model in one sentence: without synchronisation, one thread's writes have no guaranteed visibility to another, because of CPU caches, compiler reordering, and JIT optimisation. synchronized, volatile, final fields, and Thread.start/join all establish happens-before edges that make writes visible.

Deadlock needs four conditions simultaneously: mutual exclusion, hold-and-wait, no preemption, circular wait. Break any one — most practically, by imposing a global lock ordering so every thread acquires locks in the same sequence, or by using tryLock with a timeout.

3Concurrent Collections and CompletableFuture

Never share a plain HashMap or ArrayList across threads. Concurrent writes can corrupt internal structure.

Need Use
Shared map ConcurrentHashMap — per-bin locking
Producer/consumer queue LinkedBlockingQueue / ArrayBlockingQueue
Read-heavy list CopyOnWriteArrayList
Counter AtomicLong or LongAdder (better under high contention)
Wait for N tasks CountDownLatch
Limit concurrent access Semaphore

ConcurrentHashMap atomic idioms — the individual methods are atomic, but a get-then-put pair is not:

map.computeIfAbsent(key, k -> expensiveLoad(k));   // atomic; runs the loader once
map.merge(key, 1, Integer::sum);                    // atomic counter increment

CompletableFuture — composing async work without blocking:

CompletableFuture<User>    user  = CompletableFuture.supplyAsync(() -> loadUser(id));
CompletableFuture<Profile> prof  = CompletableFuture.supplyAsync(() -> loadProfile(id));

CompletableFuture<View> view = user.thenCombine(prof, View::new)   // run in parallel
    .exceptionally(ex -> View.empty());                            // handle failure

view.thenAccept(this::render);        // non-blocking callback

Key methods: thenApply (transform), thenCompose (flatMap — chain another future), thenCombine (join two), allOf (wait for many), exceptionally / handle (recover).

Calling .get() or .join() blocks the calling thread and throws away the benefit — that is the mistake the question is fishing for.

4JVM Memory, Garbage Collection, and Leaks

Runtime memory areas:

  • Heap — all objects. Split into Young (Eden + two Survivor spaces) and Old generation. Shared across threads.
  • Stack — one per thread; holds frames with local variables and primitives. Overflow → StackOverflowError (usually runaway recursion).
  • Metaspace — class metadata. Native memory since Java 8 (it replaced PermGen).
  • PC register and native method stack — per thread.

Generational GC. Most objects die young, so the collector scans the small Young generation frequently (a cheap minor GC) and promotes survivors to the Old generation, which is collected rarely and expensively (major/full GC).

Collectors: G1 is the default since Java 9 — region-based with pause-time targets. ZGC and Shenandoah are low-latency, sub-millisecond collectors for large heaps. Parallel GC maximises throughput at the cost of longer pauses.

Reachability decides collection. An object is eligible when no GC root (stack locals, static fields, active threads, JNI references) can reach it. Reference strength: strong → soft (cleared under memory pressure) → weak (cleared at next GC) → phantom.

finalize() is deprecated and unreliable — never rely on it for cleanup. Use try-with-resources / AutoCloseable.

How memory leaks happen in a garbage-collected language — the interview's favourite paradox. Objects stay reachable but are never used again:

  1. A static Map used as a cache that nothing ever evicts.
  2. Listeners or callbacks registered and never removed.
  3. A non-static inner class holding an implicit reference to its outer instance.
  4. Mutating a key after inserting it into a HashMap — the entry becomes unreachable by lookup but is still held by the map.
  5. Unclosed streams, connections, or thread pools.

Diagnosing one: take a heap dump (jmap, or -XX:+HeapDumpOnOutOfMemoryError), open it in Eclipse MAT or VisualVM, sort by retained size, and follow the path back to the GC root. Naming that workflow is worth more than reciting GC algorithms.

Ready to test yourself?

Sign in to take the quiz, track progress, and earn a certificate.

Sign in