Runnable vs Thread, protecting shared resources, how synchronized works (monitors, reentrancy, memory effects), method vs block synchronization, volatile and its limits, single-thread deadlock, checking lock ownership, synchronized vs ReentrantLock, exceptions inside synchronized blocks, all the ways to synchronize, and preventing deadlocks.
Published September 25, 2026
Concurrency answers at this level must be precise. "volatile makes it thread-safe" or "synchronized is slow" will be challenged. For each tool, know what it guarantees (mutual exclusion, visibility, ordering), and what it doesn't.
Runnable and extending Thread?Short answer: Implementing Runnable (or Callable) separates the task from the thread that runs it. Your class can still extend something else, and the same task can run on a raw thread, an ExecutorService or a virtual thread. Extending Thread ties the task to one thread object, uses up your single superclass, and can't be submitted to a thread pool as-is.
Key points to cover:
Runnable/Callable tasks to executors, or use Thread.ofVirtual() / Executors.newVirtualThreadPerTaskExecutor() (Java 21).Learn it in depth → Introduction to Java Threads
Short answer: Choose the lightest tool that makes every access atomic and visible:
AtomicLong/LongAdder, ConcurrentHashMap, BlockingQueue.synchronized or a Lock), covering all reads and writes of that state.class Inventory {
private final Map<String, Integer> stock = new HashMap<>(); // guarded by 'this'
public synchronized boolean reserve(String sku, int qty) { // check-then-act made atomic
int available = stock.getOrDefault(sku, 0);
if (available < qty) return false;
stock.put(sku, available - qty);
return true;
}
public synchronized int available(String sku) { return stock.getOrDefault(sku, 0); } // reads locked too
}
Common trap: synchronising only the writes. Unsynchronised reads can see stale or half-updated state.
synchronized work?Short answer: Every Java object has an intrinsic monitor lock. Entering a synchronized block or method acquires the monitor of the lock object:
this for instance methods;Class object for static methods;Other threads trying to acquire the same monitor block (state BLOCKED) until it's released on exit, whether the exit is normal or through an exception.
Key points to cover:
monitorenter/monitorexit, and methods carry the ACC_SYNCHRONIZED flag. Uncontended locking is cheap on modern JVMs.synchronized pins its carrier thread. JDK 24 removed most of this pinning.Learn it in depth → Synchronized and Locks
synchronized method and a synchronized block?Short answer: A synchronized method locks this (or the class) for the whole method. A synchronized block locks only the critical section, on any object you choose. That allows a smaller scope, and separate locks for independent state.
class Stats {
private final Object countsLock = new Object();
private final Object latencyLock = new Object();
private long requests; private long totalMillis;
void record(long millis) {
String line = format(millis); // done outside any lock
synchronized (countsLock) { requests++; }
synchronized (latencyLock) { totalMillis += millis; }
log(line); // I/O outside the lock
}
}
Key points to cover:
synchronized methods, anyone who can reference the object can lock it too.Short answer: Blocks can reduce contention, because the lock is held only for the shared-state update, not during I/O, logging or computation. That improves throughput under load. Methods are simpler to read and review. Choose blocks when part of the method doesn't touch shared state, or when independent state can use separate locks.
Common trap: splitting locks so much that an invariant spanning two fields is no longer protected. Fields that must stay consistent with each other need the same lock.
volatile, and what does it guarantee?Short answer: volatile guarantees visibility and ordering for a single variable. Every read sees the most recent write by any thread, and a volatile write happens-before subsequent reads, so writes made before it are also visible to a thread that reads the volatile. Reads and writes of volatile long/double values are also atomic. It doesn't give mutual exclusion.
class ConfigHolder {
private volatile Config current = Config.load(); // safe publication of immutable snapshots
Config get() { return current; }
void reload() { current = Config.load(); } // readers see either the old or the new, never half-built
}
Learn it in depth → Volatile and the Java Memory Model
volatile replace synchronization?Short answer: Only for single, independent reads and writes: flags, and publishing an immutable reference. It can't make compound operations atomic: count++ (read, modify, write), check-then-act, or keeping two variables consistent. Those need locks or atomic classes.
private volatile int hits;
void hit() { hits++; } // ❌ lost updates under contention
private final AtomicInteger safeHits = new AtomicInteger();
void safeHit() { safeHits.incrementAndGet(); } // ✅ an atomic CAS loop
private final LongAdder fastHits = new LongAdder(); // ✅ better under heavy contention
Short answer: Not with intrinsic locks, because they're reentrant, so a thread never blocks on a monitor it already holds. But a single thread can block itself forever:
StampedLock write lock, a Semaphore(1) or a hand-rolled lock all behave this way.Thread.currentThread().join().Future. The second task can never start.ExecutorService single = Executors.newSingleThreadExecutor();
single.submit(() -> {
Future<?> inner = single.submit(() -> "never runs");
return inner.get(); // blocks forever: the only worker is busy waiting
});
Short answer:
Thread.holdsLock(obj) tells you whether the current thread holds obj's monitor. It's useful in assertions (assert Thread.holdsLock(this);).ReentrantLock, use isHeldByCurrentThread(), plus isLocked(), getHoldCount() and getQueueLength().jcmd <pid> Thread.print, jstack), or use ThreadMXBean. They show which monitors each thread holds, and which it's waiting for.synchronized and ReentrantLock?Short answer:
synchronized | ReentrantLock | |
|---|---|---|
| Acquire/release | Automatic (block scope) | Explicit lock()/unlock() in try/finally |
| Try without blocking / with timeout | No | tryLock(), tryLock(timeout) |
| Interruptible waiting | No | lockInterruptibly() |
| Fairness option | No | new ReentrantLock(true) |
| Multiple wait conditions | One wait set per object (wait/notify) | Many Conditions (newCondition()) |
| Introspection | Minimal | isLocked, getQueueLength, … |
| Risk | Can't forget to unlock | Forgetting unlock() leaks the lock |
private final ReentrantLock lock = new ReentrantLock();
boolean transfer(Account a, Account b, long amt) throws InterruptedException {
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) return false; // back off instead of waiting forever
try { a.debit(amt); b.credit(amt); return true; }
finally { lock.unlock(); }
}
Key points to cover:
synchronized for simplicity. Reach for ReentrantLock when you need timeouts, interruptibility, fairness or multiple conditions. ReadWriteLock and StampedLock suit read-heavy data.synchronized block?Short answer: The monitor is released automatically as the exception propagates out of the block. The compiler generates an exception handler that runs monitorexit, so other threads aren't blocked forever. The shared state, however, may be left half-updated. Structure the code so it's consistent even when a step fails, or roll back in catch.
Key points to cover:
Lock, release is your job. Always unlock() in finally.Short answer:
synchronized methods and blocks.ReentrantLock, ReentrantReadWriteLock, StampedLock.volatile, for visibility.AtomicInteger, AtomicReference, LongAdder (lock-free CAS).Semaphore, CountDownLatch, CyclicBarrier, Phaser, Exchanger.ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue.ThreadLocal.CompletableFuture, actor or message passing.Learn it in depth → Concurrent Utilities & Coordination
Short answer: A deadlock is two or more threads each holding a lock the other needs, and all waiting forever. It requires four conditions: mutual exclusion, hold and wait, no preemption, and circular wait. Break any one of them:
tryLock(timeout), then back off and retry.void transfer(Account from, Account to, long amount) {
Account first = from.id() < to.id() ? from : to; // consistent order: no circular wait
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
Key points to cover:
ReentrantLock cycles. ThreadMXBean.findDeadlockedThreads() finds them programmatically.Learn it in depth → Deadlock, Starvation & Livelock
Q: What is a livelock? A: Threads keep changing state in response to each other, for example both backing off and retrying in lockstep, so no progress is made, although nothing is blocked. Randomised backoff breaks the symmetry.
Q: What is starvation? A: A thread never gets the CPU time or lock it needs, because others keep taking it. For example, unfair locks under heavy contention, or low-priority threads. Fair locks or bounded work queues help.
Q: Why should you never synchronize on a String literal or a boxed Integer?
A: They're shared: interned strings, and cached Integers from −128 to 127. Unrelated code that locks on the same value then contends on, or deadlocks with, yours. Use a private final Object lock.
Q: What's the double-checked locking bug, and how is it fixed?
A: Without volatile, another thread can see a non-null reference to an object whose constructor hasn't finished yet (because of reordering). Declaring the field volatile fixes it. The holder-class idiom avoids the problem entirely.