JVM Performance
JIT compilation, reading bytecode, virtual vs static dispatch, escape analysis, AOT class caching, and profiling a running JVM.
9 questions
JuniorTheoryCommonHow do the C1 and C2 JIT compilers differ, and how does tiered compilation use both?
How do the C1 and C2 JIT compilers differ, and how does tiered compilation use both?
C1 compiles quickly with light optimizations and inserts profiling counters; C2 compiles slowly but aggressively — inlining, loop unrolling, escape analysis — guided by that profile. Tiered compilation, the default, runs both: a method starts interpreted, is promoted to C1 once its counters cross a threshold, and is recompiled by C2 once it proves hot.
Common mistakes
- ✗Thinking
-client/-serverstill selects one compiler for the whole process instead of tiered compilation running both - ✗Assuming C2 optimizes from the bytecode alone and ignores the profile C1 collected
- ✗Believing a method is compiled once and can never be recompiled or deoptimized back to the interpreter
Follow-up questions
- →What makes the JVM deoptimize a C2-compiled method back to the interpreter?
- →Why does a benchmark need a warmup phase before its numbers mean anything?
MiddleDebuggingCommonA CPU profile of a running service puts one JDK method at 72% — diagnose the hotspot
A CPU profile of a running service puts one JDK method at 72% — diagnose the hotspot
Pattern.compile dominates the profile and sits directly under SkuValidator.isValid, so a regex is compiled on every request — the code calls Pattern.compile, or String.matches/split, inside the request path. Compiling parses the pattern into a node tree and costs far more than matching with a ready one. Hoist it into a static final Pattern and only match per request.
Common mistakes
- ✗Reading a hot JDK frame as a JDK problem instead of looking at the application frame directly beneath it
- ✗Confusing the cost of compiling a
Patternwith the cost of matching against one - ✗Assuming
String.matchesandString.splitare cheap — each compiles a freshPatternon every call
Follow-up questions
- →Why is
String.matches(regex)inside a loop just as costly as callingPattern.compilethere? - →What would a wall-clock profile of this service show that a CPU-mode profile hides?
JuniorCodeOccasionalRecreate a method signature from the descriptor javap prints for it
Recreate a method signature from the descriptor javap prints for it
A descriptor lists the parameters in order inside the parentheses and the return type after them, encoding I as int, J as long, a leading [ as an array and Lcom/foo/Bar; as a reference type. So this one is public static List<String> pick(String s, int[] a, long n) — the generic argument is erased. javap -s prints descriptors and -p adds private members.
Common mistakes
- ✗Reading the return type before the parentheses instead of after them
- ✗Expecting generic type arguments to show up in a descriptor — they are erased
- ✗Forgetting that
javaphides private members unless-pis passed
Follow-up questions
- →What does
javap -cshow about a method thatjavap -sdoes not? - →Where does the generic type
List<String>survive inside the class file, if not in the descriptor?
MiddlePerformanceOccasionalHow does Ahead-of-Time Class Loading & Linking (JEP 483) cut JVM startup time?
How does Ahead-of-Time Class Loading & Linking (JEP 483) cut JVM startup time?
A training run records which classes the app loads; -XX:AOTMode=create then writes an AOT cache holding them already parsed, verified and linked. The next start maps that cache in instead of loading and linking every class file, so a class-heavy app starts markedly faster. It shipped in Java 24, extends AppCDS, and compiles no method to machine code.
Common mistakes
- ✗Thinking JEP 483 AOT-compiles methods to machine code instead of caching loaded and linked classes
- ✗Calling Project Leyden itself the shipped feature — Leyden is the umbrella project, JEP 483 is the delivered JEP
- ✗Expecting one cache to stay valid across a JDK upgrade or a changed classpath
Follow-up questions
- →How does an AOT cache differ from an AppCDS archive?
- →What happens at startup when the AOT cache does not match the classpath it was recorded with?
MiddlePerformanceOccasionalWhat does C2's escape analysis let it do with a short-lived object, and what defeats it?
What does C2's escape analysis let it do with a short-lived object, and what defeats it?
Escape analysis proves an allocation never leaves its method or thread. C2 then applies scalar replacement: the object is split into fields held in registers, so no heap allocation happens — HotSpot does not literally put it on the stack — and locks on it are elided. It fires only after inlining, so returning the reference, storing it in a field, or a callee C2 will not inline forces a real allocation.
Common mistakes
- ✗Believing HotSpot literally allocates the object on the stack rather than scalar-replacing it into registers
- ✗Expecting escape analysis to fire on an allocation whose consuming callee was never inlined
- ✗Treating it as a garbage-collector feature instead of a C2 optimization
Follow-up questions
- →How does a deoptimization rebuild an object that scalar replacement had already eliminated?
- →Why can a very large method lose the escape analysis that a smaller one keeps?
MiddleTheoryOccasionalWhich invoke* bytecodes mark static versus virtual dispatch, and when is the target picked?
Which invoke* bytecodes mark static versus virtual dispatch, and when is the target picked?
javac emits invokestatic for static methods and invokespecial for constructors, private methods and super. calls — all bound to one target at compile time. Instance calls emit invokevirtual or invokeinterface: the compiler fixes only the name and descriptor, and the JVM picks the override from the receiver's runtime class per call. C2 devirtualizes a monomorphic site.
Common mistakes
- ✗Confusing overload resolution, which happens at compile time, with override selection, which happens at run time
- ✗Assuming
privateandfinalinstance methods compile toinvokestatic - ✗Believing a virtual call always costs a table lookup even when the JIT has proven the site monomorphic
Follow-up questions
- →Why does a lambda expression compile to
invokedynamicrather than to a plain method call? - →What lets C2 inline through a call site that has seen two different receiver classes?
SeniorPerformanceOccasionalWhy is System.out.println in a hot loop far costlier than a parameterized logger call?
Why is System.out.println in a hot loop far costlier than a parameterized logger call?
System.out is a PrintStream created with autoflush on, and its methods are synchronized: every println takes the stream's monitor, flushes and issues a write syscall, so N iterations cost N syscalls and threads serialize on one lock. A parameterized logger call formats nothing when the level is off and batches through a buffered or async appender.
Common mistakes
- ✗Assuming
System.outis buffered without autoflush, so a loop ofprintlncosts one syscall - ✗Forgetting
PrintStreammethods aresynchronized, so parallel threads serialize on the single stream monitor - ✗Passing a pre-concatenated message to a logger, which throws away the saving the level check would give
Follow-up questions
- →Why does
log.debug("id={}", id)cost almost nothing when the configured level isINFO? - →What does an asynchronous appender trade away to move the write off the calling thread?
SeniorDebuggingOccasionalProduction throws Too many open files — diagnose it from the trace and lsof
Production throws Too many open files — diagnose it from the trace and lsof
This is a file-descriptor leak, not an undersized limit: 3968 of the 4092 handles are report CSVs the process has finished writing. A forced GC drops the count to 231, which proves the streams are closed only by their Cleaner when collected — writeReport never calls close(). Wrap it in try-with-resources; raising ulimit -n only postpones the failure.
Common mistakes
- ✗Raising
ulimit -nand calling it fixed, while the handle count keeps climbing back to the new ceiling - ✗Ignoring the clue that a forced GC releases the handles — the signature of a stream closed only by its cleaner
- ✗Assuming a
FileOutputStreamis closed when its variable goes out of scope
Follow-up questions
- →Why does try-with-resources still close the stream when
writeReportthrows halfway through? - →Which JDK-level metric would have shown the handle count growing before the first failure?
SeniorDesignRareYour recommendation service scores candidates by cosine similarity over float[1024] embeddings. A CPU profile puts the dot-product loop at 38% of service CPU, and p99 latency is the team's loudest complaint. An engineer proposes rewriting that loop with the Vector API — the jdk.incubator.vector module that maps a loop onto the CPU's SIMD instructions — and reports a 3x speedup from a micro-benchmark on their laptop. The service runs on the current LTS JDK, ships weekly, and the platform team upgrades the JDK about once a year. The Vector API is still an incubator module: it needs --add-modules, it warns at run time, and it is expected to keep incubating until the value types from Project Valhalla land, so its API may change source-incompatibly in any release. You own the call at design review. How do you decide whether this workload genuinely benefits, and how would you adopt or reject it?
Your recommendation service scores candidates by cosine similarity over float[1024] embeddings. A CPU profile puts the dot-product loop at 38% of service CPU, and p99 latency is the team's loudest complaint. An engineer proposes rewriting that loop with the Vector API — the jdk.incubator.vector module that maps a loop onto the CPU's SIMD instructions — and reports a 3x speedup from a micro-benchmark on their laptop. The service runs on the current LTS JDK, ships weekly, and the platform team upgrades the JDK about once a year. The Vector API is still an incubator module: it needs --add-modules, it warns at run time, and it is expected to keep incubating until the value types from Project Valhalla land, so its API may change source-incompatibly in any release. You own the call at design review. How do you decide whether this workload genuinely benefits, and how would you adopt or reject it?
First fix the baseline: benchmark the current loop with JMH and check whether C2 already auto-vectorizes it — a 3x laptop number against unoptimized scalar code proves nothing. It pays off only if the loop is CPU-bound, data-parallel over contiguous primitives and branch-free. Then price the incubator status, and adopt only behind an interface with a scalar fallback.
Common mistakes
- ✗Benchmarking against an unoptimized scalar loop instead of against what C2's auto-vectorizer already produces
- ✗Treating an incubator module as a stable API and skipping the re-test on every JDK upgrade
- ✗Expecting a memory-bandwidth-bound loop to speed up merely because the instructions got wider
Follow-up questions
- →How would you prove from a benchmark run that the JIT already emits SIMD instructions for the scalar loop?
- →What does hiding the vector code behind an interface with a scalar fallback cost you at run time?