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.
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
@Volatileguarantee? What does it not guarantee?” - “Explain happens-before.”
- “What is the difference between
synchronizedand a coroutineMutex?” - “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
NoSuchMethodErrorin 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
- Do you separate visibility, atomicity, and ordering? These are three different guarantees, and every primitive gives a different subset. Candidates who conflate them write
@Volatileon a counter and ship a race. - 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.
- 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.
- Do you know the JIT exists? Warm-up, tiers, deoptimization, and why a five-line benchmark is lying to you.
- Do you know Kotlin’s runtime differences from Java? Nested classes are static by default.
by lazytakes a lock.objectinitializes under the class-init lock.Int?boxes. These are the questions that separate Kotlin engineers from Java engineers writing Kotlin syntax. - 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:
- Reduce the allocation rate. Boxing, lambdas,
copy(), string concatenation,Pairin loops. The allocation profiler finds them. - Reduce the live set. Unbounded caches, static collections, listeners never removed,
innerclasses on long-lived objects, thread-locals in pooled threads, coroutine scopes never cancelled. - Size the heap for the live set. Roughly two to three times the steady-state live set; in a container, use
-XX:MaxRAMPercentagerather than a fixed-Xmx, and remember the JVM uses memory outside the heap too. - 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
ClassNotFoundExceptionis a checked exception fromClass.forNameor a loader: the class is not on the runtime classpath at all.NoClassDefFoundErroris an error: the class was there at compile time and is missing, or failed to initialize, at runtime. A failingobjectinitializer produces this on every later access.NoSuchMethodErroris the version-skew signature: compiled against a class with the method, running against one without. The JDK 21removeLastcollision, 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
valthread-safe?” Avalassigned in the constructor gets final-field semantics and is safely published when the constructor finishes. Avar, alateinit var, or avalwith a custom getter over mutable state gets none of that. - “Do coroutines bypass the memory model?” No. They run on threads. A plain
varshared between coroutines onDispatchers.Defaultis 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
OutOfMemoryErroris 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.
@Volatilegives two of them. Counters need all three. - Happens-before is the contract. Every primitive is a way to create edges; immutable objects need none.
synchronizedholds a thread and cannot suspend.Mutexholds a coroutine, can, and is not reentrant.Int?, generics, andvalue classat generic boundaries box. Non-inline lambdas allocate.inlinefixes both in hot paths.by lazyis locked by default.objectinitializes under the class-init lock.innercaptures 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.
NoSuchMethodErroris version skew. Compile against the runtime you deploy on.- JFR, thread dumps, heap dumps, GC logs. Say which one for which symptom.
Further Reading
- The Java Language Specification, chapter 17: Threads and Locks, for happens-before as actually defined
- Brian Goetz et al., Java Concurrency in Practice, still the best treatment of the memory model for working engineers
- Kotlin documentation, Inline functions, Inline value classes, and Delegated properties: lazy
- Oracle, HotSpot Virtual Machine Garbage Collection Tuning Guide
- OpenJDK, JMH and the JFR documentation
- The concurrency post for the coroutine side of the same questions