Java is often described as both compiled and interpreted. After a class is loaded, what actually executes its methods, and how does that change while the program keeps running?
answer
- javac -> bytecode; JIT -> machine code
- interpreter starts instantly, runs slowly
- invocation + back-edge counters mark hotness
- background compiler threads, no blocking
- JIT knows loaded classes, real profiles, real CPU
basics
~20 sSource is compiled ahead of time into bytecode. At runtime the JVM first interprets that bytecode, counts how often each method runs, and once a method is hot a just-in-time compiler turns it into native machine code used from then on. Both modes run side by side.
solid answer
~50 sThere are two compilations. `javac` compiles source to **bytecode** - a portable instruction set, not machine code. At runtime the JVM executes bytecode in its **interpreter**, which starts instantly but is slow per instruction. While interpreting, the JVM increments per-method invocation counters and loop back-edge counters. When a method crosses a threshold it is queued for a **JIT compiler**, which translates it into native code for the actual CPU and installs it; later calls enter the compiled version instead of the interpreter. This is **mixed-mode execution**: at any instant some methods are interpreted and some compiled, and the same method can exist in both forms. The payoff is that the JVM pays compilation cost only for code that matters, and the compiler can exploit runtime facts an ahead-of-time compiler cannot know - observed receiver types, real branch frequencies, which classes are actually loaded.
code
text · 3 linesjava -Xint MyApp # interpreter only: no JIT at all, dramatically slower steady state
java -Xcomp MyApp # compile every method on first invocation: awful startup, no profile quality
java MyApp # default: mixed mode - interpret, profile, compile the hot partsgo deeper
Recall the two-step story clearly: javac makes bytecode, the JVM interprets it, hot methods get JIT-compiled to native code. Name the counters as 'the JVM counts how often a method runs'.
Add why selective compilation is the right economics (skewed hotness distribution), that compilation is asynchronous on background threads, and that both forms of a method can coexist.
Connect it to observable behavior: warmup curves, short-lived processes never reaching peak, and why first-request latency in a freshly deployed pod is not a code defect.
Frame it as a runtime-versus-startup tradeoff the platform makes on your behalf, and note where you would override it - AOT/archived-profile approaches for short-lived or latency-critical-at-start workloads.
## Two different compilations "Compiled" and "interpreted" are both true of Java because the word covers two separate steps. The **first compilation** is `javac`: it turns source into `.class` files containing **bytecode**, a compact instruction set for an abstract stack machine (`iload`, `iadd`, `invokevirtual`). No real CPU executes bytecode. This step is almost entirely non-optimizing - `javac` does little beyond straightforward translation, deliberately leaving optimization to the runtime. The **second compilation** happens inside the running JVM and produces actual x86-64 or AArch64 instructions. ## The interpreter runs first When a method is first called, its bytecode is executed by the **interpreter**: a loop that fetches a bytecode, dispatches to a small native routine implementing it, and moves on. (HotSpot's is a template interpreter - machine-code stubs generated at startup - not a naive switch loop, but the cost model is the same.) Interpreting is roughly an order of magnitude slower than good native code, because each bytecode costs dispatch overhead and values move through a stack rather than living in registers. Its virtue is that it starts *immediately*: zero compile latency, and it can begin executing a method the instant the class is linked. ## Profiling decides what gets compiled While interpreting, the JVM maintains counters per method: how many times the method was entered, and how many times a loop inside it branched backwards (a **back-edge counter**, which catches a method called once but looping a million times). When the counters cross a threshold the method is judged **hot** and submitted to a compilation queue. Compilation happens on **background compiler threads**. The application thread does not block waiting for machine code; it keeps interpreting until the compiled version is installed, at which point the next call enters native code. This is why the same method can be interpreted and compiled at the same moment on different threads. ## Why not compile everything up front? Most code in a large application executes a handful of times - startup wiring, configuration parsing, error paths. Compiling all of it would spend CPU and memory on code that never runs again and would push startup latency up badly. The distribution is extremely skewed: a small fraction of methods accounts for the overwhelming majority of executed instructions. Selective compilation targets exactly that fraction. There is a second, subtler reason. A just-in-time compiler knows things a static compiler cannot: - **Which classes are actually loaded.** If only one implementation of an interface has ever been loaded, a virtual call can be compiled as a direct call. - **Observed types at each call site.** Profiles record which concrete receiver types actually showed up. - **Real branch frequencies.** A null check that has never fired can be compiled away and replaced by a trap. - **Actual CPU features.** The code is generated for the machine it is running on, not a lowest common denominator. These enable *speculative* optimizations: the compiler bets on what it observed and installs a guard so that, if the bet is later invalidated (a new subclass is loaded, an untaken branch is finally taken), execution can fall back to the interpreter. ## Consequences you can see - **Warmup.** A freshly started JVM is slow, then gets faster over seconds to minutes as hot code is compiled. Steady-state throughput is not observable in the first requests. - **Short-lived processes lose out.** A CLI tool that runs for 300 ms mostly interprets and may never recover the compilation investment. - **Performance is a moving target.** Measurements taken before warmup are not comparable to steady-state numbers. `-Xint` forces interpretation only and `-Xcomp` forces compilation on first call; both are diagnostic tools, not production settings, and both are much slower than the default mixed mode.
- Why does HotSpot let a method keep running in the interpreter while its compilation is queued, instead of waiting for the compiled code?Compilation happens asynchronously on background compiler threads so that application threads never stall on the optimizer. The interpreted version is already correct, just slower, so the safe move is to keep executing it and switch on the next entry after the native code is installed. Blocking would turn every compilation into a latency spike on a request thread.
- How does the JVM handle a method that is entered only once but loops a hundred million times?The invocation counter would never trigger, so HotSpot also counts loop back-edges. Once the back-edge count crosses its threshold the loop body is compiled and execution is transferred into the compiled code mid-loop via on-stack replacement, so the currently running invocation benefits rather than waiting for a next call that never comes.
Like a simultaneous interpreter at a conference who translates sentence by sentence at first, but when a speaker keeps repeating the same passage, someone hands out a printed translation and everyone reads that instead.
saying these in an interview costs you the question
- Saying javac produces machine code, or that bytecode is CPU-specific
- Claiming the JVM interprets everything forever and is therefore inherently slow
- Believing a method is either interpreted or compiled permanently, never both
- Thinking compilation blocks the application thread that triggered it
- Assuming ahead-of-time compilation is strictly better - it cannot use runtime profiles or class-hierarchy facts