Post

JVM Runtime Questions for Kotlin Candidates: Memory Model, Garbage Collection, JIT, and the Traps Underneath the Language

Senior loops go one layer below the language: what @Volatile actually guarantees, what happens-before means, how synchronized and a coroutine Mutex differ under the hood, why GC pauses happen and what fixes them, why the first call is slow, and the Kotlin-specific runtime traps around boxing, lazy, object initialization, and inner classes. With compiled snippets where behavior can be shown.

JVM Runtime Questions for Kotlin Candidates: Memory Model, Garbage Collection, JIT, and the Traps Underneath the Language

The concurrency post said @Volatile does not fix increments and left it there. The coding posts said Array<Int> boxes and left it there. Senior interviewers do not leave it there. Once a candidate has shown they can write Kotlin, the next question is whether they understand what the JVM does with it, because the incidents an architect gets paged for are memory-model bugs, garbage-collection pauses, and classpath skew, and none of those are visible in the source.

This post is the runtime layer: the memory model and what each synchronization primitive actually buys you, garbage collection well enough to diagnose a pause, the JIT well enough to not be fooled by a benchmark, and the Kotlin-specific runtime traps that Java developers get backwards. Where a snippet can demonstrate behavior, it was compiled and run before publishing.

The Question

  • “What does @Volatile guarantee? What does it not guarantee?”
  • “Explain happens-before.”
  • “What is the difference between synchronized and a coroutine Mutex?”
  • “How does the garbage collector work? What is a stop-the-world pause, and what causes a long one?”
  • “Why is the first call to this function slow and the rest fast?”
  • “Where does this value live: stack or heap? What gets boxed?”
  • “Is == on strings safe? What is interning?”
  • “Why does this throw NoSuchMethodError in production when it compiled fine?”
  • “How can a garbage-collected language leak memory?”
  • “Production is slow. Walk me through diagnosing it.”

What They Are Really Asking

  1. Do you separate visibility, atomicity, and ordering? These are three different guarantees, and every primitive gives a different subset. Candidates who conflate them write @Volatile on a counter and ship a race.
  2. Do you know what happens-before is for? It is the contract that makes one thread’s write visible to another’s read. Without an edge, the JVM promises nothing, and “it worked in testing” is the sound of a missing edge.
  3. Can you reason about a GC pause? Not the flag names. The model: pauses scale with the live set and the allocation rate, and the fix is usually in the code, not the flags.
  4. Do you know the JIT exists? Warm-up, tiers, deoptimization, and why a five-line benchmark is lying to you.
  5. Do you know Kotlin’s runtime differences from Java? Nested classes are static by default. by lazy takes a lock. object initializes under the class-init lock. Int? boxes. These are the questions that separate Kotlin engineers from Java engineers writing Kotlin syntax.
  6. Can you diagnose, not just describe? Symptom, tool, what to look at. That is the production half of the question.

The Gotchas

Gotcha 1: @Volatile on a counter. It guarantees that a write is visible to subsequent reads on other threads, and it forbids certain reorderings around it. It does not make count++ atomic; that is a read, an add, and a write, and two threads can interleave them. Use AtomicInteger, a lock, or a Mutex.

Gotcha 2: Believing == on strings is identity. In Kotlin == is equals. === is identity. Interning means === is sometimes true for equal strings and sometimes not, so code that relies on it is wrong in both directions. Java developers bring the opposite instinct and get it backwards.

Gotcha 3: Not knowing what boxes. Int in a local is a primitive. Int? is a java.lang.Integer. List<Int> holds Integer. A value class is unboxed as a local and boxed the moment it is a generic argument or nullable. Interviewers ask “what is the type of this at runtime,” and the answer is not what the source says.

Gotcha 4: Non-inline lambdas allocate. Every { } passed to a non-inline function is an object, and if it captures a local, that local is wrapped too. inline functions paste the lambda in and allocate nothing, which is why the standard library’s map, filter, and forEach are inline and why your own hot-path helpers should be.

Gotcha 5: by lazy takes a lock by default. The default mode is SYNCHRONIZED: correct under concurrent first access, at the cost of a lock on every access until initialized and a monitor for the lifetime of the delegate. LazyThreadSafetyMode.NONE when the value is confined to one thread; PUBLICATION when racing initializers is acceptable and the result is immutable.

Gotcha 6: object and companion object initialize under the class-init lock. Initialization runs on whichever thread first touches the class, holding a JVM-level lock. Heavy work in an init block stalls that thread, and two classes whose initializers touch each other can deadlock at startup. Keep initializers cheap and acyclic.

Gotcha 7: Nested classes are static by default; inner captures the outer. The opposite of Java. An inner class listener registered with something long-lived holds its outer instance alive. Java developers writing Kotlin assume the capture is there when it is not, and Kotlin developers writing Java assume it is not there when it is.

Gotcha 8: Compile-time classpath versus runtime classpath. NoSuchMethodError and NoClassDefFoundError at runtime mean the code was compiled against one version of a class and is running against another. The JDK 21 removeLast collision from part two is exactly this. Pin the target with -Xjdk-release on the Kotlin compiler and --release on javac, and keep the Kotlin standard library version consistent across modules.

Gotcha 9: Benchmarking without warm-up. The first thousand calls run in the interpreter. Then a fast compiler. Then, if the method stays hot, the optimizing compiler, which may inline, unroll, and eliminate allocations whose results are never used. A loop that computes something and discards it can be optimized to nothing. Use JMH (Java Microbenchmark Harness), or at minimum warm up and consume the result.

Gotcha 10: Reading a GC pause as “the collector is slow.” Pause length tracks the live set for the old generation and the allocation rate for the young one. Changing collectors moves the cost around; reducing what you allocate and what you keep alive removes it. Look at the allocation profile before the flags.

How to Answer

Step 1: The memory model in three sentences

Threads share one heap, and without a synchronization edge the JVM may keep a write in a register, reorder it, or never make it visible to another thread. Happens-before is the relation that guarantees a write is visible to a read; every synchronization primitive exists to create those edges. Atomicity, visibility, and ordering are separate guarantees, and each primitive gives a different subset.

Then the table, which is the actual answer to “what does X guarantee”:

Primitive Atomicity Visibility Ordering Mutual exclusion Blocks the thread
Plain var No No No No No
@Volatile var Single read or write only Yes Yes, around the access No No
AtomicInteger and friends Yes, per compare-and-set Yes Yes No No; spins on contention
synchronized / ReentrantLock Yes, for the block Yes, at acquire and release Yes Yes Yes, parks on contention
Coroutine Mutex Yes, for the block Yes, at lock and unlock Yes Yes, across suspension points No; suspends the coroutine
Channel / actor Yes, per message Yes, send happens-before receive Yes By construction No; suspends
val set in a constructor n/a Yes, once the constructor finishes Yes n/a n/a

The last row is safe publication, and it is why immutable objects are thread-safe without any synchronization. Say that sentence; it is the one that shows the model is understood rather than the table memorized.

Demonstrated, because it can be:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.concurrent.atomic.AtomicInteger

class Counters {
    var plain = 0
    @Volatile var volatile = 0
    val atomic = AtomicInteger()
    private val lock = Any()
    var locked = 0
        private set
    fun lockedIncrement() { synchronized(lock) { locked++ } }
}

fun hammer(threads: Int, perThread: Int): Counters {
    val c = Counters()
    val workers = List(threads) {
        Thread {
            repeat(perThread) {
                c.plain++
                c.volatile++
                c.atomic.incrementAndGet()
                c.lockedIncrement()
            }
        }
    }
    workers.forEach { it.start() }
    workers.forEach { it.join() }
    return c
}

Run eight threads at a hundred thousand increments each and atomic and locked read exactly 800,000 every time. plain and volatile both read less. On the run that verified this post, the plain counter lost about fifteen thousand increments and the volatile one lost about forty-five thousand: volatility fixed visibility, left the read-modify-write race untouched, and by forcing every write out to memory made the interleaving window easier to hit. That is gotcha 1, observed.

Step 2: synchronized versus Mutex

  synchronized Coroutine Mutex
What it holds A thread A coroutine
On contention The thread parks in the OS; a dispatcher thread is lost until release The coroutine suspends; the dispatcher thread goes on to run others
Across a suspension point Cannot be held; synchronized blocks cannot contain suspend calls Safe; the lock is held by the coroutine across suspension
Reentrant Yes No; re-locking from inside the critical section deadlocks
Happens-before At monitor enter and exit At lock and unlock
Use when Protecting a short section from other threads, no suspension inside Protecting a section that suspends, or when the callers are coroutines

The two sentences: synchronized holds a thread and cannot contain a suspension; Mutex holds a coroutine and can. And Mutex is not reentrant, which is the follow-up every time.

Step 3: Where things live, and what boxes

Source Runtime Notes
val x: Int local Primitive on the stack, or in a register Free
val x: Int? java.lang.Integer on the heap Small values come from a cache; identity is not equality
IntArray Primitive array The one to use for numeric work
Array<Int>, List<Int> Array or list of Integer Boxed; part one’s gotcha 1
value class Meters(val v: Double) as a local or parameter Unboxed double Zero cost
The same value class as Meters? or List<Meters> Boxed wrapper object The cost returns at generic and nullable boundaries
A non-inline lambda A function object; captured locals are wrapped in refs Gotcha 4
An inline fun call site No object; the body is pasted in Enables non-local return, which is a separate interview question
data class copy() A new object every time Cheap for small classes; a heap churn source in loops

Demonstrated, because it surprises people:

1
2
3
4
5
6
7
8
9
10
11
12
13
fun boxedIdentity(): List<Boolean> {
    val a: Int? = 127
    val b: Int? = 127
    val c: Int? = 128
    val d: Int? = 128
    return listOf(a === b, c === d, c == d)          // true, false, true on HotSpot with the default cache
}

fun stringIdentity(): List<Boolean> {
    val literal = "kotlin"
    val built = String(charArrayOf('k', 'o', 't', 'l', 'i', 'n'))
    return listOf(literal == built, literal === built, literal === built.intern())   // true, false, true
}

The boxed 127s are the same object because Integer caches small values. The 128s are not. The strings are equal, not identical, until one is interned. The lesson for both: == for value, === only when identity is the point, and never rely on caching or interning to make === true.

Step 4: Initialization, lazy, and the class-init lock

1
2
3
4
5
6
7
8
9
10
11
12
13
object Config {
    val loadedAt: Long = System.nanoTime()             // runs under the class-init lock, on the first thread that touches Config
}

class Service {
    val cheap by lazy(LazyThreadSafetyMode.NONE) { expensiveButConfined() }      // no lock: caller guarantees one thread
    val shared by lazy { expensiveAndShared() }                                    // SYNCHRONIZED: safe, locked until initialized
    val racy by lazy(LazyThreadSafetyMode.PUBLICATION) { expensiveImmutable() }   // may run twice; first result wins

    private fun expensiveButConfined() = 1
    private fun expensiveAndShared() = 2
    private fun expensiveImmutable() = 3
}

What to say: an object is a class with a static instance, initialized on first access by the JVM’s class-initialization protocol, which is thread-safe and holds a lock while it runs. That is why a Kotlin object is a correct singleton with no code, and why an init block that does I/O stalls whichever thread got there first. by lazy is the per-property version, with the lock optional.

Step 5: Inner versus nested

1
2
3
4
5
6
class Screen {
    private val data = IntArray(1_000_000)

    class Nested { fun describe() = "no reference to Screen" }        // static by default
    inner class Inner { fun size() = data.size }                       // holds a reference to this Screen
}

A Nested instance can outlive its Screen. An Inner instance keeps the Screen, and its million-element array, alive for as long as the Inner is reachable. Register an Inner as a listener on something long-lived and you have the classic garbage-collected leak. The Java default is the other way around, and that is exactly why the question gets asked of Kotlin candidates.

Step 6: Garbage collection

flowchart LR
    subgraph Heap
        direction LR
        E[Eden] -->|survive a young GC| S[Survivor]
        S -->|survive several| O[Old generation]
    end
    M[Metaspace: class metadata] -.-> Heap
    A[Allocation: bump the pointer in a thread-local buffer] --> E

The model in four sentences: allocation is a pointer bump and is nearly free. Most objects die young, so the young generation is collected often and cheaply by copying the few survivors. Objects that survive get promoted to the old generation, which is collected rarely and expensively. A stop-the-world pause is when every application thread is stopped at a safepoint so the collector can work, and its length depends on how much is live, not how much is dead.

Collector Pause goal Throughput Heap sizes When
Serial Long Highest per core Small Single-core containers, tiny services
Parallel Long High Any Batch jobs where throughput matters and pauses do not
G1 Medium, target-driven Good Medium to large The default. Most services
ZGC Sub-millisecond Slightly lower Large to very large Latency-sensitive services with big heaps
Shenandoah Sub-millisecond Slightly lower Large Same niche as ZGC; availability depends on the JDK build

What actually fixes a pause, in order:

  1. Reduce the allocation rate. Boxing, lambdas, copy(), string concatenation, Pair in loops. The allocation profiler finds them.
  2. Reduce the live set. Unbounded caches, static collections, listeners never removed, inner classes on long-lived objects, thread-locals in pooled threads, coroutine scopes never cancelled.
  3. Size the heap for the live set. Roughly two to three times the steady-state live set; in a container, use -XX:MaxRAMPercentage rather than a fixed -Xmx, and remember the JVM uses memory outside the heap too.
  4. Then, and only then, change the collector or its pause target.

Step 7: The JIT

Tier What runs When
Interpreter Bytecode, slowly, while counting First calls
C1 Quick compilation with light optimization, still profiling After a few hundred to thousand invocations
C2 Full optimization: inlining, loop unrolling, escape analysis, speculative devirtualization Hot methods that stay hot
Deoptimization Back to the interpreter when a speculation fails, such as a new subclass appearing Any time

Three things to say. Warm-up is real, so the first request after deploy is slow and a load test that measures the first minute is measuring the interpreter. Escape analysis can eliminate an allocation entirely if the object never leaves the method, which is why “boxing is expensive” is sometimes false in a hot loop and always true across a method boundary that the JIT did not inline. And a benchmark that discards its result can be optimized to nothing, which is why JMH exists and why an interviewer who hears “I timed it with System.nanoTime” will ask what you did with the result.

Step 8: Classloading and version skew

  • ClassNotFoundException is a checked exception from Class.forName or a loader: the class is not on the runtime classpath at all.
  • NoClassDefFoundError is an error: the class was there at compile time and is missing, or failed to initialize, at runtime. A failing object initializer produces this on every later access.
  • NoSuchMethodError is the version-skew signature: compiled against a class with the method, running against one without. The JDK 21 removeLast collision, a Kotlin standard library mismatch between modules, and a shaded dependency are the three usual causes.

Prevention is compiling against the runtime you will run on: -Xjdk-release=17 for the Kotlin compiler, --release 17 for javac, one Kotlin version across every module, and a dependency-conflict report in the build.

Step 9: The diagnosis playbook

Symptom First tool What to look for
High CPU, low throughput Thread dump, then a sampling profiler such as async-profiler Which frames are hot; a spinning loop; excessive GC threads
Latency spikes at intervals GC log (-Xlog:gc*) and safepoint log Pause length and cause; time-to-safepoint; promotion failures
Memory grows until OutOfMemoryError Heap dump on OOM (-XX:+HeapDumpOnOutOfMemoryError), then a dominator-tree view What holds the most retained memory and who references it
Threads stuck, throughput zero Thread dump jstack reports detected deadlocks by name; look for BLOCKED and WAITING clusters
Slow first minute after deploy JIT compilation log, or just wait Warm-up; consider a warm-up phase before taking traffic
NoSuchMethodError in production Runtime classpath listing The version actually loaded versus the one compiled against
Everything, over time JFR (Java Flight Recorder), always on at low overhead Allocation profile, GC, lock contention, exceptions, in one recording

Say JFR by name. It is built in, it is cheap enough to run in production, and mentioning it tells the interviewer you have looked at a real system rather than a textbook.

Follow-Up Questions to Expect

  • “Is val thread-safe?” A val assigned in the constructor gets final-field semantics and is safely published when the constructor finishes. A var, a lateinit var, or a val with a custom getter over mutable state gets none of that.
  • “Do coroutines bypass the memory model?” No. They run on threads. A plain var shared between coroutines on Dispatchers.Default is a data race. Mutex, channels, and the coroutine machinery itself create happens-before edges; the code between them does not.
  • “What is a safepoint?” A point where a thread’s state is known well enough for the runtime to stop it: method returns, loop back-edges. Time-to-safepoint is why a long counted loop without a safepoint check can extend a pause for every thread.
  • “How big should the heap be?” Two to three times the live set, measured, not guessed. In containers use a percentage of the container’s memory, and leave room for metaspace, threads, and native buffers.
  • “ZGC or G1?” G1 unless you have measured pauses that violate a latency budget and a heap large enough that ZGC’s concurrent work pays for itself. Say “measured.”
  • “Which OutOfMemoryError is it?” Java heap space is the live set. Metaspace is classes, usually a classloader leak in a hot-redeploy environment. GC overhead limit exceeded is the collector running constantly for almost nothing, which is the heap-space case about to happen.

Key Takeaways

  • Atomicity, visibility, and ordering are three guarantees. @Volatile gives two of them. Counters need all three.
  • Happens-before is the contract. Every primitive is a way to create edges; immutable objects need none.
  • synchronized holds a thread and cannot suspend. Mutex holds a coroutine, can, and is not reentrant.
  • Int?, generics, and value class at generic boundaries box. Non-inline lambdas allocate. inline fixes both in hot paths.
  • by lazy is locked by default. object initializes under the class-init lock. inner captures the outer; nested does not.
  • GC pauses scale with the live set and the allocation rate. Fix the code before the flags.
  • The JIT warms up, inlines, and eliminates dead work. Benchmarks need JMH.
  • NoSuchMethodError is version skew. Compile against the runtime you deploy on.
  • JFR, thread dumps, heap dumps, GC logs. Say which one for which symptom.

Further Reading

This post is licensed under CC BY 4.0 by the author.