Locks, Atomics & Coordination Explained

KOTLIN › JVM Concurrency

Every question about protecting shared mutable state has the same set of answers, and they form a ladder. Climb it from the top and stop at the first rung that works.

1. **Do not share it.** An immutable object needs no protection at all: if nothing can write, nothing can race. 2. **Confine it.** Restrict all access to one thread or one dispatcher, and it is effectively single-threaded. Dispatchers.Default.limitedParallelism(1) does this without owning a thread. 3. **Make the update atomic.** A single value read-modify-written in one indivisible step needs no lock: AtomicInteger, or MutableStateFlow.update { }. 4. **Lock it.** When several fields must change together, or the critical section must suspend, take a Mutex or a synchronized block. 5. **Use a specialised lock.** ReentrantReadWriteLock for read-heavy data, Semaphore to cap concurrency, latches to coordinate startup.

The reason to state the ladder rather than jumping to locks is that locks are the rung with the most failure modes: deadlock, priority inversion, contention, and forgetting to hold one at all. Everything above them is safe by construction.

// Rung 1: nothing to protect
data class Config(val url: String, val timeout: Long)

// Rung 3: one value, one atomic step
private val hits = AtomicInteger()

// Rung 4: two fields that must agree
private val lock = Mutex()

The interview version: **"how do you make this thread-safe" has good answers that are not locks**, and naming them in order is what separates a considered answer from a reflex.

synchronized is the oldest tool and still the most common. Every Java object has an associated **monitor**, and synchronized acquires it.

private val lock = Any()

fun add(item: Item) {
    synchronized(lock) {          // Kotlin's inline function
        items += item
    }
}

@Synchronized                     // annotation: locks on `this`
fun addAlso(item: Item) { items += item }

Three properties to have ready:

**It is reentrant.** A thread already holding a monitor can acquire it again without deadlocking, which is what lets a synchronized method call another synchronized method on the same object.

**Instance versus class.** A synchronized instance method locks this; a static one locks the Class object. Two instances of the same class therefore do not exclude each other, which is a common surprise.

**It creates a happens-before edge.** Releasing a monitor happens-before the next acquisition of it, so everything written inside the block is visible to the next thread that enters. Locks are as much about visibility as exclusion.

The mistakes worth naming:

// Bad: locking on a mutable field means the lock identity can change
synchronized(currentList) { ... }

// Bad: locking on a String or boxed Integer, which may be interned and
// therefore shared with completely unrelated code
synchronized("lock") { ... }

Use a dedicated private val lock = Any(). And never hold a monitor across a suspending call: a coroutine can resume on a different thread, and monitors are owned by threads, so the release would come from a thread that never acquired it.

ReentrantLock is synchronized with the features the keyword cannot express.

private val lock = ReentrantLock()

fun update() {
    lock.lock()
    try { mutate() } finally { lock.unlock() }   // the finally is mandatory
}

fun tryUpdate(): Boolean {
    if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) return false
    try { mutate(); return true } finally { lock.unlock() }
}

Kotlin's withLock extension removes the boilerplate and the chance of forgetting the finally:

lock.withLock { mutate() }

What it buys you over synchronized:

- **tryLock()**, with or without a timeout, so a thread can decline to wait. This is the standard deadlock escape hatch. - **lockInterruptibly()**, so a waiting thread can be interrupted rather than waiting forever. - **Fairness.** ReentrantLock(true) grants the lock in arrival order, preventing starvation at a significant throughput cost. Unfair, the default, is much faster because a thread already running can barge ahead of queued waiters. - **Condition**, which is wait/notify with multiple wait sets, so you can signal "not full" and "not empty" independently rather than waking everyone.

The cost is that the lock is a real object you must release, and an early return inside the block without a finally leaves it held forever. synchronized cannot leak that way, which is why it remains the default for simple exclusion, and ReentrantLock is what you reach for when you need one of the four features above.

For the locking half of the toolkit: synchronized, or Kotlin's coroutine Mutex, is the default for simple mutual exclusion. ReentrantReadWriteLock beats it for read-heavy, write-rare data, since it lets any number of readers hold the read lock concurrently, while the write lock is exclusive against everyone.

val lock = ReentrantReadWriteLock()
fun read() = lock.readLock().withLock { cache[key] }
fun write(v: String) = lock.writeLock().withLock { cache[key] = v }

One trap: it's reentrant and supports downgrading, acquire read while already holding write, then release write, but upgrading a held read lock straight to a write lock deadlocks, the write lock waits for all readers including yourself. And inside suspend functions, prefer a coroutine Mutex over synchronized: a coroutine can suspend and resume on a different thread, and a thread-owned monitor can't protect across that gap the way a coroutine-aware Mutex, with mutex.withLock { }, can.

Kotlin's coroutine Mutex is the suspend-friendly equivalent of a synchronized block. Unlike JVM locks, Mutex.withLock **suspends** instead of blocking the thread, so other coroutines on the same dispatcher can run while one coroutine holds the lock:

val mutex = Mutex()
var counter = 0

coroutineScope {
    repeat(1000) {
        launch {
            mutex.withLock {
                counter++
            }
        }
    }
}
println(counter) // always 1000

withLock is an inline function that acquires the mutex, runs the block, and releases it -- even if the block throws. It is **not reentrant**: if the same coroutine calls withLock while already holding the lock, it deadlocks. This is by design -- reentrant locks hide bugs where code accidentally relies on being called under a lock.

When to reach for Mutex vs other tools: - **Mutex**: protecting a critical section where the work inside is short and may suspend (e.g. read-modify-write on a shared map) - **AtomicInteger/AtomicReference**: single-value updates with no suspension inside the update - **Confinement** (withContext(singleThread)): when all access to shared state can be routed through one dispatcher - **MutableStateFlow.update {}**: when the shared state is already a StateFlow

The coroutine world has a second primitive people forget, and it answers one of the most common interview questions directly.

**"How would you limit this to five concurrent uploads?"** The answer is Semaphore.

private val limit = Semaphore(permits = 5)

suspend fun upload(file: File) = limit.withPermit {
    api.upload(file)      // at most 5 of these run at once
}

A Mutex is a semaphore with exactly one permit; a Semaphore generalises it to N. Like Mutex, the coroutine version **suspends** rather than blocking, so waiting coroutines cost nothing.

The shape it enables is bounded parallel work:

suspend fun uploadAll(files: List<File>) = coroutineScope {
    val limit = Semaphore(5)
    files.map { file ->
        async { limit.withPermit { api.upload(file) } }
    }.awaitAll()
}

A hundred coroutines are launched, all cheap, and exactly five are ever in flight.

Worth comparing with the dispatcher approach, because an interviewer may ask which is better. Dispatchers.IO.limitedParallelism(5) caps how many coroutines **execute** at once, which is about thread usage. Semaphore(5) caps how many are **inside a section** at once, which works even when the coroutines are all suspended on the network and occupying no threads at all. For "five concurrent requests" the semaphore is the more accurate tool, because suspended requests still count as in flight.

The JVM java.util.concurrent.Semaphore is the same idea with acquire()/release() blocking a thread, and tryAcquire() to decline.

Three JVM coordination primitives round out the toolkit. They are rarely needed in Kotlin, where structured concurrency covers most of it, but they are standard interview material.

**CountDownLatch: a one-shot gate.** Threads wait until a counter reaches zero, and it can never be reset.

val ready = CountDownLatch(3)
repeat(3) { thread { initialise(); ready.countDown() } }
ready.await()          // blocks until all three have counted down
startApplication()

**CyclicBarrier: a reusable meeting point.** N threads each call await() and none proceeds until all have arrived, after which the barrier **resets** for the next round.

val barrier = CyclicBarrier(4) { println("phase complete") }
// each of 4 workers: computePhase(); barrier.await()

The distinction interviewers want: a latch counts **events** and is one-shot, so the waiters and the counters can be different threads. A barrier counts **threads** and is reusable, so every participant both arrives and waits.

**Semaphore: permits**, covered a moment ago.

The Kotlin equivalents are usually simpler and worth naming:

// Instead of a latch: structured concurrency already waits
coroutineScope {
    repeat(3) { launch { initialise() } }
}   // returns only when all three have finished
startApplication()

That is the honest answer to "would you use a CountDownLatch": you would know what it is, and in coroutine code coroutineScope or joinAll expresses the same intent without a blocking wait or a counter to get wrong.

Atomics give you lock-free updates for a single value, and the mechanism underneath is worth being able to describe.

private val counter = AtomicInteger(0)

counter.incrementAndGet()      // ++counter: returns the NEW value
counter.getAndIncrement()      // counter++: returns the OLD value
counter.addAndGet(5)
counter.updateAndGet { it * 2 }
counter.compareAndSet(expected = 5, newValue = 6)

The primitive is **compare-and-set**, a single CPU instruction that atomically checks whether a memory location still holds the value you expected and, if so, writes the new one. Because the check and the write are one instruction, no other thread can slip between them, which is exactly the gap that makes count++ unsafe.

Failure is expected rather than exceptional, so the pattern is a retry loop:

// What updateAndGet does internally
while (true) {
    val current = get()
    val next = transform(current)
    if (compareAndSet(current, next)) return next   // else someone beat us: retry
}

This is **optimistic**: assume no contention, detect the collision, try again. Under low contention it is much faster than a lock because there is no blocking and no context switch. Under **high** contention it is worse, because threads burn CPU retrying.

LongAdder is the answer for that case. Instead of one hot value it keeps per-thread cells and sums them on read, trading an exact instantaneous value for far less contention:

private val hits = LongAdder()
hits.increment()        // no CAS contention: threads touch different cells
hits.sum()              // approximate while writes are in progress

Use AtomicLong when you read often, LongAdder when you write often from many threads, which is the usual shape for metrics.

Compare-and-set has a subtle failure mode with a memorable name: the **ABA problem**.

CAS checks that a value is unchanged, but "unchanged" and "never changed" are not the same thing. A value can go from A to B and back to A while you were not looking, and your compareAndSet(A, ...) will succeed as though nothing happened.

// Thread 1 reads head = nodeA, is descheduled
// Thread 2 pops nodeA, pops nodeB, pushes nodeA back
// Thread 1 resumes: compareAndSet(nodeA, nodeA.next) succeeds
//   ... but nodeA.next is nodeB, which is no longer in the stack

The state passed the check and is nevertheless wrong. This bites lock-free data structures, particularly stacks and queues built on linked nodes, where a recycled node can reappear at the same address.

The standard fix is to version the reference so an identical value with a different stamp fails the comparison:

val ref = AtomicStampedReference(nodeA, 0)
val stamp = IntArray(1)
val current = ref.get(stamp)
ref.compareAndSet(current, next, stamp[0], stamp[0] + 1)   // stamp must match too

AtomicMarkableReference is the lighter version, carrying a single boolean rather than a counter.

Two honest qualifications. In managed languages this matters much less than in C, because the garbage collector will not recycle a node while you still hold a reference to it, which removes the most common route to ABA. And you will almost certainly never write a lock-free stack in an Android app. It is asked because being able to explain it demonstrates that you understand what CAS actually checks, rather than treating "atomic" as a synonym for "correct".

Locks bring the failure mode that gets its own interview question. **Deadlock** needs four conditions to hold simultaneously, and breaking any one prevents it:

1. **Mutual exclusion**: a resource cannot be shared. 2. **Hold and wait**: a thread holds one lock while waiting for another. 3. **No preemption**: a lock cannot be taken away from its holder. 4. **Circular wait**: a cycle of threads each waiting on the next.

The classic shape is two locks taken in opposite orders:

// Thread 1
synchronized(accountA) { synchronized(accountB) { transfer() } }
// Thread 2
synchronized(accountB) { synchronized(accountA) { transfer() } }

Each holds one and waits for the other, forever.

**Lock ordering** is the standard fix, and it attacks circular wait. Define a global order and always acquire in it:

fun transfer(from: Account, to: Account, amount: Long) {
    val (first, second) = if (from.id < to.id) from to to else to to from
    synchronized(first) { synchronized(second) { /* ... */ } }
}

Now no cycle can form, because every thread acquires the lower id first.

**tryLock with a timeout** attacks hold-and-wait instead: a thread that cannot get the second lock releases the first and retries, breaking the standoff at the cost of a possible livelock if everyone backs off in step.

To **diagnose** one, take a thread dump. The JVM detects monitor cycles and prints "Found one Java-level deadlock" with the participating threads and the locks each holds and wants. On Android, adb shell kill -3 <pid> writes it to the log, and an ANR trace file contains the same information, which is how you distinguish a deadlock from a merely slow main thread.

Two failure modes neighbour deadlock and are worth being able to distinguish, because interviewers ask for the difference.

**Livelock**: threads are not blocked, they are actively running, and still make no progress because each keeps reacting to the other.

// Both threads politely back off, forever
while (true) {
    if (lock1.tryLock()) {
        if (lock2.tryLock()) { work(); break }
        lock1.unlock()          // give way and retry
    }
    // both retry at the same instant, collide again, back off again
}

That is the risk of using tryLock to avoid deadlock: you trade a permanent block for a possible permanent retry. The fix is randomised backoff, so the two threads stop colliding in lockstep, exactly as with network retries.

**Starvation**: a thread makes no progress because others keep taking the resource first. An unfair lock permits **barging**, where a thread that happens to be running acquires the lock ahead of threads already queued, which is faster overall but can leave a queued thread waiting indefinitely under sustained load.

ReentrantLock(true)   // fair: FIFO, no starvation, significantly slower

A related Android-specific case is **priority inversion**: a low-priority background thread holds a lock the main thread needs, and because it is in a background cgroup with a small CPU share it is slow to release it, so the UI stalls waiting on work the scheduler is deliberately throttling. That is a good reason not to share a lock between the main thread and low-priority background work.

To summarise: **deadlock** blocks forever, **livelock** runs forever without progress, **starvation** makes progress for some threads but never for one.

The whole toolkit as one decision, which is how it will be examined.

**Choosing a primitive:**

| Situation | Reach for | |---|---| | Value never changes after construction | immutability, nothing else | | All access can go through one dispatcher | limitedParallelism(1) confinement | | One value, one read-modify-write | AtomicInteger, updateAndGet | | One value that is already UI state | MutableStateFlow.update { } | | Many writers, reads are rare | LongAdder | | Several fields must change together | synchronized or Mutex | | Critical section must suspend | coroutine Mutex (never synchronized) | | Many readers, rare writer | ReentrantReadWriteLock | | Need a timeout or interruptible wait | ReentrantLock.tryLock | | Cap concurrent operations at N | Semaphore(N) | | Wait for N one-off events | CountDownLatch, or just coroutineScope |

**The rules that prevent the classic bugs:**

- Never hold a synchronized monitor across a suspending call: monitors are owned by threads and a coroutine can resume on a different one. - Never upgrade a read lock to a write lock; downgrading is fine. - Mutex is not reentrant, by design. - Acquire multiple locks in a consistent global order. - volatile gives visibility and ordering, never atomicity.

The senior signal is not knowing all eleven rows. It is starting at the top of the ladder rather than the bottom: reaching for immutability and confinement before reaching for a lock, and being able to say why a lock is the rung with the most ways to go wrong.

Back to Locks, Atomics & Coordination