20 deep questions — memory, GC, threads, bytecode, performance
Objects live in one area, method calls in another, class definitions in a third.
Three main memory areas:
1. HEAP (shared across all threads, GC-managed):
Young Gen → Eden (new objects born here) + Survivor (survived 1+ GC)
Old Gen → long-lived objects promoted from Young Gen after ~15 GC cycles
2. STACK (one per thread, ~1MB each):
Each method call pushes a frame: local variables + operand stack + return address. Frames popped on return. Not GC'd.
3. METASPACE (off-heap, replaced PermGen in Java 8):
Class definitions, method bytecode, constant pools. Grows dynamically in native memory.
Heap: all objects live here. Shared across all threads. Managed by garbage collector. Divided into Young Generation (new objects, collected frequently) and Old Generation (survived multiple GC cycles, collected rarely). Size controlled by -Xms (initial) and -Xmx (maximum).
Stack: one per thread. Stores method call frames — each frame contains local variables, operand stack, and return address. Fixed size (~1MB default, configurable with -Xss). Too-deep recursion fills the stack → StackOverflowError. Not garbage collected — frames popped when method returns.
Metaspace: class metadata (bytecode, method info, constant pool). Lives outside the heap in native memory. Replaced PermGen (Java 7 and earlier) which had a fixed size and caused OutOfMemoryError: PermGen space. Metaspace grows dynamically (limit with -XX:MaxMetaspaceSize). ClassLoader leaks can still cause Metaspace exhaustion.
Other areas: Code Cache (JIT-compiled native code), Direct ByteBuffers (off-heap memory for NIO), Thread-local allocations (TLAB — thread-local allocation buffer, fast allocation without synchronization).
System.gc()GC needs a consistent view of the heap. How? Briefly pause all threads.
How GC determines what to collect:
GC starts from "roots":
- Local variables on thread stacks
- Static fields
- Active threads themselves
Traverses all references from roots → marks reachable objects.
Everything NOT reachable = garbage → freed.
val a = Account("A1") // a is root → Account reachable → NOT collected
a = null // no root points to Account → garbage → collected
Stop the world (STW): during certain GC phases, ALL application threads must pause. Why? GC needs a consistent snapshot of the heap — if threads keep modifying objects during GC, the reachability analysis would be wrong. Analogy: counting people in a room is hard if they keep entering and leaving.
Modern GC collectors minimize STW:
Collector STW Pause Best For Serial GC Long (100ms+) Small apps, single core Parallel GC Medium Throughput (batch processing) G1 GC (default) ~10ms General purpose (Java 9+ default) ZGC <1ms Low-latency (fintech, trading) Shenandoah <1ms Similar to ZGC (Red Hat) // Enable ZGC: java -XX:+UseZGC -jar app.jar
G1 GC (Garbage First): divides heap into regions (~2048 regions of 1-32MB). Collects regions with most garbage first (hence "Garbage First"). Concurrent marking (runs alongside app threads). Only pauses briefly for final marking and compaction. Default since Java 9.
ZGC: designed for <1ms pauses regardless of heap size (works with multi-terabyte heaps). Uses colored pointers and load barriers. Almost all work is concurrent — minimal STW. Ideal for latency-sensitive fintech services where a 10ms GC pause causes SLA violations.
For the interview: "G1 is the default, good for most workloads with ~10ms pauses. For latency-sensitive financial services, ZGC provides sub-millisecond pauses. The choice depends on whether you optimize for throughput (Parallel GC) or latency (ZGC)."
Most objects die young (request-scoped data). Few survive to old age (cached data, singletons).
The weak generational hypothesis: most objects are short-lived. A request handler creates DTOs, strings, lists — all garbage after the response is sent. Only a few objects live forever (Spring beans, caches, connection pools).
Object lifecycle in generational GC: 1. Object created → goes to EDEN (part of Young Gen) val dto = TransferDto(...) // born in Eden 2. Eden fills up → MINOR GC runs (fast, ~1-5ms) - Scans only Young Gen (small area → fast) - dto is unreachable? → freed immediately - dto still reachable? → moved to SURVIVOR space 3. Object survives N minor GCs → PROMOTED to Old Gen - Spring singleton: survives every minor GC → eventually Old Gen - Typical threshold: 15 minor GC cycles (configurable) 4. Old Gen fills up → MAJOR GC runs (slow, ~50-200ms) - Scans entire Old Gen (large area → slow) - Frees long-lived objects that finally became unreachable - This is the expensive "stop the world" people worry about
Why this design is efficient:
• Minor GC: scans small area (Young Gen = ~1/3 of heap), most objects are dead → very fast, ~1-5ms.
• Major GC: scans large area (Old Gen = ~2/3 of heap), most objects are alive → slow, but runs rarely.
• Without generational GC: every GC scans the entire heap every time → always slow.
Tuning implications:
• If your app creates many short-lived objects (typical web service) → make Young Gen bigger (-XX:NewRatio=2) → more objects die in minor GC, fewer promotions to Old Gen → fewer major GCs.
• If major GCs are too frequent → you have a memory leak (objects accumulating in Old Gen) or heap is too small (-Xmx).
• Monitor: GC logs (-Xlog:gc), VisualVM, Grafana + Micrometer metrics for GC pause times and frequency.
First call: interpreted (slow). After 10,000 calls: compiled to native (fast). This is "warm-up."
The compilation pipeline:
Kotlin source → kotlinc → .class bytecode → JVM loads bytecode
At runtime, JVM has two execution modes:
1. INTERPRETER (cold start):
Reads bytecode instruction by instruction.
Slow but starts immediately. No compilation delay.
2. JIT COMPILER (after warm-up):
JVM monitors which methods are "hot" (called frequently).
After ~10,000 invocations → JIT compiles to native machine code.
Subsequent calls run native code → 10-100x faster.
Timeline:
Call #1-100: Interpreted. Slow. ~100ns per operation
Call #100-10000: C1 compiled. Better. ~20ns per operation
Call #10000+: C2 compiled. Fast. ~5ns per operation
(aggressive optimizations)
Two JIT compilers:
C1 (Client compiler): fast compilation, modest optimizations. Kicks in early (~1,500 invocations). Gets you to "good enough" performance quickly.
C2 (Server compiler): slow compilation, aggressive optimizations. Kicks in later (~10,000 invocations). Inlining, loop unrolling, escape analysis, dead code elimination. Produces highly optimized native code.
Tiered compilation (default since Java 8): start with interpreter → C1 for quick wins → C2 for peak performance. Best of both worlds.
JIT optimizations that matter:
// Inlining: replaces method call with method body
fun add(a: Int, b: Int) = a + b
val result = add(1, 2)
// After JIT inlining: val result = 1 + 2 → val result = 3
// Escape analysis: object doesn't escape method → allocate on stack, not heap
fun calculate(): Int {
val point = Point(1, 2) // JIT sees: point never leaves this method
return point.x + point.y // → allocates on stack, no GC needed!
}
// Dead code elimination: unreachable code removed
if (false) { complexCalculation() } // JIT removes entirely
Why this matters for Kubernetes/serverless:
• Cold start: first requests hit the interpreter → slow (3-5 seconds for Spring Boot).
• Warm-up: after ~30-60 seconds, JIT has compiled hot paths → peak performance.
• Solution for cold start: GraalVM native image (AOT compilation — no warm-up needed) or CRaC (Checkpoint/Restore — snapshot a warm JVM).
10,000 threads = 10GB. 10,000 coroutines = 2MB. Same work, 5000x less memory.
JVM THREAD: OS Thread (kernel-managed) Stack: ~1MB (fixed) × 10,000 threads = 10GB RAM! Context switch: ~1-10μs (involves OS kernel) Creation: ~1ms Scheduling: OS scheduler // Thread.sleep(1000) → OS thread blocked for 1 second // Thread does NOTHING but exists and consumes 1MB KOTLIN COROUTINE: Continuation object (~200B) × 10,000 coroutines = 2MB RAM! Runs on thread pool (e.g., 8 threads for 10K coroutines) "Context switch": ~100ns (just swapping Continuation objects) Creation: ~0.01ms Scheduling: Kotlin runtime // delay(1000) → coroutine suspended, thread FREED for other work // After 1s → coroutine resumed, possibly on a different thread
The key insight — what happens during I/O:
// Thread model — 10,000 concurrent DB queries:
for (i in 1..10000) {
Thread {
val result = db.query("SELECT ...") // blocks thread for 50ms
}.start()
}
// 10,000 OS threads created. 10GB RAM. OS scheduler overwhelmed.
// Most threads just WAITING for DB response. Wasteful.
// Coroutine model — 10,000 concurrent DB queries:
coroutineScope {
(1..10000).map {
async(Dispatchers.IO) {
db.suspendingQuery("SELECT ...") // suspends coroutine
}
}.awaitAll()
}
// 64 OS threads (Dispatchers.IO pool). 2MB for coroutine state.
// Coroutine suspends → thread handles another coroutine.
// Same throughput, 5000x less memory.
How coroutines work under the hood:
Kotlin compiler transforms a suspend fun into a state machine. Each suspension point becomes a state. The continuation object holds the current state and local variables. When the coroutine suspends, the continuation is saved (~200 bytes). When it resumes, the continuation is loaded and execution continues from the right state. The underlying thread is free the entire time.
Analogy: threads = dedicated waiter per table (10,000 tables = 10,000 waiters). Coroutines = 8 waiters serving 10,000 tables (waiter takes order → moves to next table while kitchen cooks → comes back with food).
Change one line of executor config → existing blocking code becomes lightweight. No rewrite needed.
Virtual Threads solve the same problem as coroutines — but differently:
// Before Virtual Threads (Java <21):
ExecutorService exec = Executors.newFixedThreadPool(200);
exec.submit(() -> {
var result = db.query("SELECT ..."); // blocks OS thread
});
// 200 OS threads. 200MB RAM. Limited concurrency.
// With Virtual Threads (Java 21+):
ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();
exec.submit(() -> {
var result = db.query("SELECT ..."); // blocks VIRTUAL thread
// OS thread automatically freed during blocking I/O!
});
// 1,000,000 virtual threads possible. ~200MB RAM.
// SAME CODE. Only executor changed. Zero migration.
Comparison with Kotlin coroutines:
Feature Virtual Threads Kotlin Coroutines Migration cost Zero (change executor) Need suspend/await rewrite Structured concurrency No Yes (coroutineScope) Cancellation Manual (interrupt) Automatic (parent cancels children) Type system No marker suspend keyword in signature Dispatcher control No (JVM decides) Yes (IO, Default, Main) Backpressure No built-in Flow, Channel Blocking code Works automatically Need withContext(IO) Language Java (any JVM lang) Kotlin only
Virtual Threads advantage: existing Java codebase with millions of lines of blocking code? Change one line → instant benefit. No need to add suspend everywhere, no need to replace synchronized with Mutex, no need to change Thread.sleep to delay.
Coroutines advantage: structured concurrency is a safety net. If a parent scope is cancelled, ALL children are cancelled automatically. No orphaned tasks. No resource leaks. This requires language support (suspend marker, coroutineScope) that Virtual Threads don't have.
For the interview: "For a new Kotlin project, coroutines — structured concurrency and type-safe suspension are worth the migration cost. For migrating a large Java codebase, Virtual Threads — zero code changes, immediate benefit."
List<String> and List<Int> are both just List in bytecode. Kotlin workaround: reifiedYou can't do if (list is List<String>) at runtime because the generic type is gone.
Why type erasure exists: backward compatibility. When Java 5 added generics in 2004, they had to work with existing bytecode compiled without generics. Solution: generics exist only at compile time. At runtime, List<String> is just List. Old code and new code produce compatible bytecode.
// At compile time:
val strings: List<String> = listOf("a", "b")
val ints: List<Int> = listOf(1, 2)
// At runtime (bytecode):
val strings: List = listOf("a", "b") // type parameter GONE
val ints: List = listOf(1, 2) // same — just List
// This means:
if (strings is List<String>) // ❌ COMPILE ERROR — can't check at runtime
if (strings is List<*>) // ✅ OK — can check it's a List, just not of what
Kotlin's workaround — reified type parameters:
// Regular generic — erased:
fun <T> isType(value: Any): Boolean {
return value is T // ❌ ERROR — T erased at runtime
}
// Reified — preserved via inlining:
inline fun <reified T> isType(value: Any): Boolean {
return value is T // ✅ WORKS! T is known at compile time and inlined
}
// How it works:
isType<String>("hello")
// Compiler inlines to: "hello" is String → true
// The actual type is substituted at compile time, no runtime erasure
// Real-world use — Jackson deserialization:
inline fun <reified T> String.fromJson(): T {
return objectMapper.readValue(this, T::class.java)
// T::class.java only works with reified!
}
val user = jsonString.fromJson<User>() // clean API
Limitation: reified only works with inline functions. Can't have reified on a class or regular function. The compiler must be able to substitute the actual type at the call site.
C# comparison: C# preserves generic types at runtime (reification). List<string> and List<int> are different types at runtime. No erasure problem. This is because .NET generics were designed from scratch, not retrofitted like Java's.
"hello" === "hello" is true — same interned objectThis is why === on string literals returns true in Kotlin — same object in the string pool.
// String literals are automatically interned:
val a = "hello" // JVM: is "hello" in string pool? No → create and store
val b = "hello" // JVM: is "hello" in string pool? Yes → reuse!
a == b // true — equals() compares content
a === b // true — same object! Both point to pooled "hello"
// String constructor creates NEW object (NOT interned):
val c = String("hello".toCharArray())
a == c // true — same content
a === c // FALSE — c is a new object, not from pool
// Manual interning:
val d = String("hello".toCharArray()).intern()
a === d // true — intern() adds to pool, returns pooled instance
Where the string pool lives: since Java 7, the string pool is in the heap (previously in PermGen). This means interned strings are garbage collected when no longer referenced. Size configurable with -XX:StringTableSize (default ~60,013 buckets).
Why this matters for Kotlin == vs ===:
In Java, == is reference check. Beginners write if (name == "John") which works with literals (interned → same reference) but breaks with runtime strings. This is Java's #1 beginner bug.
Kotlin fixes this: == always calls .equals(). === is explicit reference check. You can't accidentally use the wrong one.
Compact Strings (Java 9+): strings containing only Latin-1 characters are stored as byte[] (1 byte per char) instead of char[] (2 bytes per char). Reduces memory by ~50% for ASCII strings. Transparent to your code — same API, less memory.
HashMap and ConcurrentHashMap?Two threads writing to HashMap simultaneously can corrupt internal structure. ConcurrentHashMap prevents this.
What happens when two threads write to HashMap simultaneously:
// Thread 1: map.put("A", 1) → modifies internal array
// Thread 2: map.put("B", 2) → modifies same array concurrently
Possible outcomes:
1. Lost update — one put overwrites the other
2. Corrupted linked list — infinite loop during get()
3. ArrayIndexOutOfBoundsException during resize
4. Silently wrong data — map.get("A") returns null or wrong value
// These are NOT exceptions you can catch — they're silent data corruption.
ConcurrentHashMap — how it achieves thread safety (Java 8+):
// Internal structure: array of "bins" (buckets)
// Each bin protected independently
Thread 1: put("A", 1) → locks bin #7 (where "A" hashes to)
Thread 2: put("B", 2) → locks bin #3 (where "B" hashes to)
// Both proceed in parallel! Only same-bin operations serialize.
Thread 1: put("A", 1) → locks bin #7
Thread 2: put("C", 3) → C also hashes to bin #7 → WAITS for Thread 1
// Same bin → sequential. Different bins → parallel.
Key differences:
Feature HashMap ConcurrentHashMap Thread safe NO YES (bucket-level) Null keys 1 null key allowed NO null keys/values Iteration Fail-fast Weakly consistent Performance (single) Slightly faster Slightly slower Performance (multi) BREAKS Excellent
When to use which:
• HashMap: single-threaded access only. Local variable in a function. Kotlin collections (mapOf(), mutableMapOf()) use HashMap internally.
• ConcurrentHashMap: shared mutable state across threads/coroutines. Our InMemoryAccountRepository used it. Singletons, caches, registries.
• Collections.synchronizedMap(): wraps HashMap with synchronized on every operation. Simpler but slower than ConcurrentHashMap — every operation is exclusive, even reads. Avoid.
volatile keyword?i++CPU cores have their own caches. Thread 1 writes flag = true. Without volatile, Thread 2 might read false from its stale cache.
The CPU cache problem:
CPU Core 1 (Thread 1) Main Memory CPU Core 2 (Thread 2) Cache: flag=true ← write flag=??? read → Cache: flag=false // Thread 1 sets flag=true → stored in Core 1's cache // Thread 2 reads flag → gets false from Core 2's stale cache! // Without volatile, the JVM doesn't guarantee when cache syncs With volatile: // Write to volatile → immediately flushes to main memory // Read from volatile → always reads from main memory, not cache // Both threads see the same value
What volatile does and does NOT do:
@Volatile var flag = false // Kotlin syntax
// ✅ SAFE — single read or single write:
flag = true // atomic write, visible to all threads
if (flag) { ... } // reads latest value
// ❌ NOT SAFE — compound operations:
@Volatile var counter = 0
counter++ // this is: read counter → add 1 → write counter
// THREE operations. Another thread can read between them.
// Use AtomicInteger instead!
When to use volatile:
• Flags: @Volatile var running = true — one thread sets to false, others check and stop. Simple flag, no compound operation.
• Double-checked locking: singleton initialization pattern (though Kotlin object handles this automatically).
When to use AtomicInteger/AtomicReference instead:
• Any compound operation: increment, compare-and-set, update-and-get.
// Kotlin — atomic operations:
val counter = AtomicInteger(0)
counter.incrementAndGet() // atomic read + increment + write
counter.compareAndSet(5, 6) // atomic: if value is 5, set to 6
// In coroutines — use Mutex for complex operations:
val mutex = Mutex()
mutex.withLock { balance -= amount } // suspends, doesn't block
StackOverflowError?tailrec or iterative loopfun f(n: Int): Int = n * f(n-1) — what happens for n = 1,000,000?
// Each function call pushes a frame onto the stack:
fun factorial(n: Int): Int {
if (n <= 1) return 1
return n * factorial(n - 1) // recursive call → new frame
}
factorial(5):
Stack: [factorial(5)] [factorial(4)] [factorial(3)] [factorial(2)] [factorial(1)]
← 5 frames, ~500 bytes total. Fine.
factorial(100000):
Stack: [frame] [frame] [frame] ... [frame] ← 100,000 frames
~1MB total → STACK OVERFLOW!
Stack is full. Error: java.lang.StackOverflowError
Fix 1: Kotlin's tailrec:
// Tail recursion: recursive call is the LAST operation
tailrec fun factorial(n: Int, acc: Long = 1): Long {
if (n <= 1) return acc
return factorial(n - 1, n * acc) // tail position — last thing done
}
// Compiler transforms to a while loop:
fun factorial(n: Int, acc: Long = 1): Long {
var n = n; var acc = acc
while (n > 1) { acc = n * acc; n = n - 1 }
return acc
}
// Zero stack growth. Works for n = 1,000,000.
Fix 2: Iterative approach:
fun factorial(n: Int): Long {
var result = 1L
for (i in 2..n) result *= i
return result
}
// No recursion → no stack growth. Always safe.
Stack size configuration: -Xss2m sets thread stack to 2MB (default ~1MB). Larger stack = deeper recursion possible, but also more memory per thread. 10,000 threads × 2MB = 20GB just for stacks. Usually better to fix the recursion than increase stack size.
Note: coroutines are NOT affected by stack overflow the same way — they store state in heap objects, not on the thread stack. A coroutine can suspend and resume millions of times without stack growth.
OutOfMemoryError: Java heap space?GC runs, can't free enough space, runs again, still can't → gives up → OOM.
What happens before OOM:
1. Application allocates objects 2. Heap fills up 3. GC runs (minor GC → major GC → full GC) 4. GC frees some objects... but not enough 5. Application tries to allocate more 6. GC runs again... frees almost nothing 7. JVM detects: spending >98% of time in GC, freeing <2% of heap 8. Throws OutOfMemoryError: Java heap space
Common causes:
// 1. MEMORY LEAK — objects referenced but never used:
class EventBus {
val listeners = mutableListOf<Listener>() // only add, never remove!
fun subscribe(l: Listener) { listeners.add(l) }
// After 1M subscribes → 1M listeners in memory → OOM
}
// 2. Loading entire DB result into memory:
val allTransfers = repository.findAll() // 100M rows → OOM!
// Fix: use pagination, streaming, or database-side aggregation
// 3. Large caches without eviction:
val cache = HashMap<String, LargeObject>() // grows forever
// Fix: use Caffeine cache with maxSize and TTL
// 4. ThreadLocal not cleaned:
threadLocal.set(largeData)
// ... request processed ...
// threadLocal.remove() FORGOTTEN → data stays for thread's lifetime
// Thread pool reuses threads → data accumulates
// 5. String concatenation in loop:
var result = ""
for (i in 1..1000000) { result += "x" } // creates 1M String objects
// Fix: use StringBuilder or buildString { }
Diagnosing OOM:
// Enable heap dump on OOM:
java -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof -jar app.jar
// Analyze with:
// 1. Eclipse MAT (Memory Analyzer Tool) — shows dominator tree
// 2. VisualVM — live monitoring
// 3. IntelliJ profiler — built-in
// Key question: "Which objects consume the most memory?"
// → Dominator tree shows: ArrayList holding 5M EventListener objects
// → Found the leak!
Prevention: set reasonable -Xmx, use bounded caches (Caffeine), always close resources (use .use { }), paginate DB results, monitor heap usage in production (Grafana + Micrometer).
Classes are loaded lazily — only when first referenced. This is why Kotlin's object is lazily initialized.
When you write: val service = TransferService()
JVM checks: is TransferService class loaded?
If NO → class loading starts:
1. LOADING — find and read the .class file
ClassLoader reads bytecode from:
- File system (application classpath)
- JAR files (dependencies)
- Network (in theory, rare)
2. LINKING:
a. Verification — bytecode is valid? No stack overflow in bytecode?
b. Preparation — allocate memory for static fields, set defaults
c. Resolution — resolve symbolic references to actual classes
3. INITIALIZATION — run static initializers
- Kotlin: companion object init, object initialization
- Java: static { } blocks
- Thread-safe: JVM guarantees only one thread initializes a class
ClassLoader hierarchy (delegation model):
Bootstrap ClassLoader (JVM built-in, C++) ↑ delegates to parent first Platform ClassLoader (java.sql, java.xml, ...) ↑ Application ClassLoader (your code, dependencies) ↑ Custom ClassLoaders (plugin systems, hot reload) // When Application CL asked to load "com.bank.TransferService": // 1. Asks Platform CL → "not my class" // 2. Asks Bootstrap CL → "not my class" // 3. Application CL loads it from classpath // This prevents your code from overriding core Java classes
Why this matters for Kotlin:
// Kotlin object (singleton) uses class loading for lazy init:
object Config {
val dbUrl = System.getenv("DB_URL") // initialized on FIRST access
}
// Under the hood, Kotlin generates:
public final class Config {
public static final Config INSTANCE;
static {
INSTANCE = new Config(); // class initializer — runs on first use
}
}
// Thread-safe lazy initialization — guaranteed by JVM class loading spec
Common errors: ClassNotFoundException (class not on classpath at runtime), NoClassDefFoundError (class was available at compile time but missing at runtime — dependency missing from deployment), LinkageError (incompatible class versions — "diamond dependency" problem).
-Xms and -Xmx JVM flags?-Xms: initial heap. -Xmx: max heap. In containers: set both equal to avoid resize overhead-Xms: max stack. -Xmx: max heap-Xms512m -Xmx2g: start with 512MB, grow up to 2GB as needed.
java -Xms512m -Xmx2g -jar app.jar
^^^^^^^^ ^^^^^^
initial maximum
heap heap
Startup: JVM allocates 512MB for heap
During runtime: if 512MB isn't enough, heap grows (up to 2GB)
If 2GB isn't enough: OutOfMemoryError
// Other important flags:
-Xss1m // thread stack size (default ~1MB)
-XX:MaxMetaspaceSize=256m // metaspace limit
-XX:+UseZGC // use ZGC garbage collector
Why set Xms = Xmx in containers (Docker/Kubernetes):
// Problem with Xms < Xmx in containers: Container memory limit: 2GB JVM: -Xms512m -Xmx1536m 1. JVM starts with 512MB heap 2. Load increases → heap grows to 1GB → OS allocates more memory 3. Heap grows to 1.5GB → OS allocates more Each resize: GC pause + OS memory allocation overhead 4. Non-heap memory (Metaspace, thread stacks, native): ~500MB 5. Total: 1.5GB + 500MB = 2GB → hits container limit → OOM Kill! // Solution: set Xms = Xmx -Xms1g -Xmx1g // JVM allocates 1GB immediately. No resize overhead. // Container: 1GB heap + ~500MB non-heap = 1.5GB total < 2GB limit ✓ // Predictable memory usage. No OOM surprises.
Container memory formula:
Container limit = Xmx + non-heap overhead Non-heap ≈ 300-600MB depending on app - Metaspace: 50-150MB - Thread stacks: threads × Xss (200 threads × 1MB = 200MB) - Native memory, code cache, GC overhead: 100-200MB Rule of thumb: container limit = 1.5 × Xmx Example: Xmx=1g → container limit = 1.5GB
Java 17+ container awareness: JVM automatically detects container limits (-XX:+UseContainerSupport, enabled by default). If you don't set Xmx, JVM sets it to ~25% of container memory. Often too conservative — better to set explicitly.
The output is the same .class files. JVM sees bytecode, not source language.
Kotlin source → kotlinc compiler → .class files (JVM bytecode)
Java source → javac compiler → .class files (JVM bytecode)
↓
IDENTICAL FORMAT
JVM doesn't know or care
which language produced it
What Kotlin features become in bytecode:
// data class → regular class + generated methods:
data class Money(val amount: BigDecimal, val currency: String)
// → class Money with: equals(), hashCode(), toString(), copy(),
// component1(), component2(), getAmount(), getCurrency()
// suspend fun → state machine with Continuation parameter:
suspend fun fetchUser(): User
// → Object fetchUser(Continuation<User> cont)
// with switch(cont.state) { case 0: ... case 1: ... }
// Extension function → static method:
fun String.isEmail() = contains("@")
// → public static boolean isEmail(String $this) { return $this.contains("@"); }
// object (singleton) → class with static INSTANCE field:
object Config { val url = "..." }
// → public final class Config {
// public static final Config INSTANCE = new Config();
// }
// null safety → runtime checks injected:
fun greet(name: String) { } // non-nullable parameter
// → public void greet(String name) {
// Intrinsics.checkNotNullParameter(name, "name"); // NPE if null
// }
Performance comparison:
• Runtime: identical. Same bytecode → same JIT optimizations → same machine code. JVM doesn't know it was Kotlin.
• Compilation: Kotlin compiles slower than Java (~1.5-2x). More analysis (null checks, type inference, coroutine state machines). But this is build-time cost, not runtime.
• Overhead: null checks add ~1-3% overhead (Intrinsics.checkNotNull). Inline functions eliminate lambda allocation overhead completely. Overall: negligible difference vs Java.
Serverless cold start: 3 seconds with JVM, 50ms with native image. How?
Traditional JVM: app.jar → JVM loads → class loading → interpretation → JIT compiles hot paths Startup: 3-5 seconds. Peak performance after 30-60s warm-up. GraalVM Native Image: app.jar → native-image tool (build time) → standalone binary (app) ./app starts in ~50ms. No JVM. No warm-up. Instant peak performance.
How it works: at build time, the native-image tool performs static analysis of your entire application. It resolves all classes, runs initializers, and compiles everything to native machine code (x86/ARM). The output is a standalone executable — no JVM, no JRE needed.
Benefits:
• Instant startup: ~50ms instead of 3-5 seconds. Critical for serverless (AWS Lambda) and CLI tools.
• Lower memory: ~50-100MB instead of 300-500MB. No JVM overhead, no JIT compiler in memory.
• Smaller container: ~50MB image instead of 300MB+ (JRE + dependencies).
Trade-offs:
• No JIT: peak throughput may be 10-20% lower than JIT-optimized JVM for long-running services. AOT can't optimize based on runtime behavior.
• Reflection limitations: all reflective access must be declared at build time (reflect-config.json). Spring and Hibernate use reflection heavily — need configuration.
• Build time: 3-10 minutes for native compilation vs 30 seconds for JAR. CI/CD is slower.
• Debugging harder: no JVM debugging tools (VisualVM, JFR). Native debugger (gdb) is less familiar.
Framework support: Spring Boot 3+ (Spring Native), Quarkus (designed for native), Micronaut (designed for native). Kotlin coroutines work with native image but need reflect-config for Continuation classes.
Decision: use native for serverless/CLI/short-lived processes. Use JVM for long-running services where JIT optimization matters and startup time is irrelevant (server restarts are rare).
A list that only grows: list.add() but never list.remove(). GC sees it's referenced → can't collect.
GC can only free UNREACHABLE objects. If you accidentally hold a reference to objects you'll never use again, GC can't help. The reference IS the leak.
// Classic leak patterns:
// 1. GROWING COLLECTION — add without remove:
object EventBus {
val listeners = mutableListOf<Listener>()
fun subscribe(l: Listener) { listeners.add(l) }
// No unsubscribe! List grows forever.
// 1M listeners × 1KB each = 1GB leaked
}
// 2. CACHE WITHOUT EVICTION:
val cache = HashMap<String, ExpensiveObject>()
fun get(key: String): ExpensiveObject {
return cache.getOrPut(key) { compute(key) }
// Cache grows forever. Never evicts old entries.
}
// Fix: use Caffeine cache with maxSize and expireAfterAccess
// 3. THREADLOCAL NOT CLEANED:
val requestContext = ThreadLocal<RequestData>()
fun handleRequest(req: Request) {
requestContext.set(RequestData(req))
processRequest()
// requestContext.remove() ← FORGOTTEN!
}
// Thread pool reuses threads → old RequestData stays → accumulates
// 4. CLOSEABLE RESOURCES NOT CLOSED:
fun readFile() {
val stream = FileInputStream("data.txt")
// ... process ...
// stream.close() ← FORGOTTEN! File descriptor leaked.
}
// Fix: stream.use { ... } — auto-closes, even on exception
// 5. INNER CLASS HOLDS OUTER REFERENCE:
class Outer {
val hugeData = ByteArray(10_000_000)
inner class Inner {
// Inner holds implicit reference to Outer
// Even if Outer is "not needed", Inner keeps it alive
}
}
Detection:
1. Symptoms: heap usage trending upward over hours/days. Old Gen filling up. Full GC frequency increasing.
2. Heap dump: -XX:+HeapDumpOnOutOfMemoryError or jmap -dump:format=b,file=heap.hprof PID
3. Analysis: Eclipse MAT → "Leak Suspects Report" → shows which objects retain the most memory and the reference chain keeping them alive.
Prevention: use .use { } for Closeable, bounded caches (Caffeine), always threadLocal.remove() in finally blocks, prefer immutable data structures, review subscriptions and callbacks for missing unsubscribe.
tailrec in Kotlin?Tail position: the recursive call must be the LAST operation. Compiler reuses the same stack frame.
// NOT tail recursive — multiplication happens AFTER recursive call:
fun factorial(n: Int): Long {
if (n <= 1) return 1
return n * factorial(n - 1) // multiply AFTER recursion returns
// ^^^^^^^^^^^^^^^^^^ not in tail position!
}
// TAIL recursive — recursive call IS the last operation:
tailrec fun factorial(n: Int, acc: Long = 1): Long {
if (n <= 1) return acc
return factorial(n - 1, n * acc) // nothing after this call
// ^^^^^^^^^^^^^^^^^^^^^^^^^ last operation = tail position ✓
}
What the compiler does with tailrec:
// Kotlin source:
tailrec fun factorial(n: Int, acc: Long = 1): Long {
if (n <= 1) return acc
return factorial(n - 1, n * acc)
}
// Compiled to (equivalent bytecode):
fun factorial(n: Int, acc: Long = 1): Long {
var n = n
var acc = acc
while (true) {
if (n <= 1) return acc
acc = n * acc
n = n - 1
// no recursive call! just loop back.
}
}
// Zero stack frames added. Works for n = 10,000,000.
Compiler enforces correct usage:
// If you write tailrec but it's NOT in tail position:
tailrec fun bad(n: Int): Int {
if (n <= 1) return 1
return n * bad(n - 1) // WARNING: recursive call is not a tail call
// ^^^^^ multiplication happens after — not tail position
}
// Kotlin compiler warns you. The optimization can't be applied.
Real-world use: functional-style list processing, tree traversal, state machines. In practice, most Kotlin code uses iterative loops or collection operations (fold, reduce). tailrec is niche but good to know — shows you understand stack mechanics.
synchronized and ReentrantLock?synchronized: simple. ReentrantLock: more features (tryLock, timeout, fairness).
// synchronized — built into every JVM object:
synchronized(lock) {
// critical section
}
// Auto-release on exit (even on exception)
// Simple syntax. No tryLock. No timeout. No fairness.
// ReentrantLock — explicit lock object:
val lock = ReentrantLock()
lock.lock()
try {
// critical section
} finally {
lock.unlock() // MUST manually unlock in finally!
}
What ReentrantLock adds:
// 1. tryLock — non-blocking attempt:
if (lock.tryLock()) {
try { /* critical section */ }
finally { lock.unlock() }
} else {
// lock is held by someone else — do something else instead of blocking
}
// 2. tryLock with timeout:
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try { /* critical section */ }
finally { lock.unlock() }
} else {
// couldn't acquire lock within 5 seconds — give up
}
// 3. Fairness — threads acquire lock in FIFO order:
val fairLock = ReentrantLock(true) // fair = true
// Without fairness: a thread can "steal" the lock even if others waited longer
// With fairness: threads are served in order they requested the lock
// 4. Multiple Conditions (like multiple wait queues):
val notFull = lock.newCondition()
val notEmpty = lock.newCondition()
// More granular than Object.wait()/notify()
"Reentrant" means: the same thread can acquire the lock multiple times without deadlocking:
synchronized(lock) {
synchronized(lock) { // same thread, same lock — OK!
// works fine. Lock counter incremented.
}
}
// Both synchronized and ReentrantLock support this.
In Kotlin coroutines — use NEITHER. Use Mutex:
// synchronized — blocks OS thread ❌
// ReentrantLock — blocks OS thread ❌
// Mutex.withLock — suspends coroutine, frees thread ✅
val mutex = Mutex()
mutex.withLock {
// coroutine suspended if lock is held — thread freed
// no OS thread wasted
}
Summary: synchronized for simple cases, ReentrantLock for advanced features (tryLock, timeout, fairness), Mutex for coroutines.
== in Java and Kotlin?==: reference identity (same object?). Kotlin ==: structural equality (.equals()). Kotlin ===: reference identity. REVERSED!== is reference, Java == is .equals()== for objectsJava's #1 beginner bug: if (name == "John") works with literals (interning) but breaks with runtime strings.
// JAVA:
String a = new String("hello");
String b = new String("hello");
a == b // false — different objects in memory (reference check)
a.equals(b) // true — same content (structural check)
// Beginners write: if (name == "John") — works with literals (interning),
// breaks with name from database/HTTP. Java's most common bug.
// KOTLIN:
val a = String("hello".toCharArray())
val b = String("hello".toCharArray())
a == b // true — calls .equals() (structural check!)
a === b // false — different objects (reference check)
// Kotlin made == do what developers actually want 99% of the time.
Why Kotlin changed this:
In practice, developers almost always want structural comparison ("is the content the same?"). Reference comparison ("is it literally the same object in memory?") is rare and specialized. Kotlin made the common case the default operator.
The full picture across three languages:
Operation Java C# Kotlin
Structural (content) .equals(b) == (overloaded) ==
Reference (identity) == Object.Ref... ===
Null-safe compare Objects.equals() == (null-safe) == (null-safe!)
// Kotlin == is null-safe:
val a: String? = null
a == "hello" // false (not NPE!)
// Compiled to: a?.equals("hello") ?: false
For data classes: Kotlin's data class auto-generates equals() based on constructor properties. So Money(100, "EUR") == Money(100, "EUR") is true. In Java, you must write equals() yourself (or use Lombok/records). In C#, record types generate it automatically (like Kotlin).
Interview tip: this is a classic "gotcha" question. Show you understand the reversal, explain WHY Kotlin chose this (developer intent), mention null-safety of ==, and note that === is rarely needed in practice (mostly for identity checks in caching or singleton verification).