Skip to content

VOL 09 / CH 05 / LESSON 03

5.3 JIT, Deoptimization, and Observability: From Bytecode to Machine Code ​

A Java method may take different execution paths at startup and after warm-up. The JVM can collect profiles, compile hot code, and undo optimizations when assumptions fail. javap shows the compiled class file; later runtime decisions need other evidence. A menu alone cannot tell you how busy the kitchen is tonight.

The Specification Defines Semantics; Implementations Choose Strategies ​

The Java Virtual Machine Specification (JVMS) describes the abstract machine, class file format, and instruction semantics, but does not require the JVM to use an interpreter or JIT compiler. Implementations such as HotSpot and OpenJ9 may employ different compilation levels, garbage collectors, and object layouts, as long as their observable behaviors conform to the specification.

When discussing performance, conclusions should be clearly categorized into two types:

  • Specification-level conclusions: For example, method invocation semantics, exception handling, and memory model behavior;
  • Implementation-level observations: For instance, whether a particular version of HotSpot performs inlining or eliminates object allocations.

The latter must include the runtime version, configuration parameters, workload characteristics, and empirical evidence. It must never be stated as a permanent guarantee in Java.

From Cold Code to Hot Code ​

A typical execution flow looks like this:

text
Load and verify class
      ↓
Interpret or low-level compile
      ↓
Collect profiling data: call counts, branch frequencies, receiver types, etc.
Identify hot spots
      ↓
Apply higher-level optimizations and compile
      ↓
Run native code

The actual implementation may involve tiered compilation, with OSR (On-Stack Replacement) enabling transitions to compiled versions during runtime loops. Thresholds and compilation layers are implementation-specific details and should not rely on a fixed number of calls.

Common Optimizations and Their Conditions ​

Inlining ​

The JIT compiles the body of a called method directly into the invocation site, reducing call overhead and (more importantly) exposing opportunities for cross-method optimizations:

java
record Order(int subtotal) {}

int taxFor(Order order) {
    return tax(order.subtotal());
}

int tax(int subtotal) {
    return Math.toIntExact(subtotal * 13L / 100);
}

These method and record declarations belong inside one class. The example uses integer money units and truncating integer division. Whether inlining occurs depends on method size, call frequency, receiver type profile, and compilation budget. final, private, or small methods may aid in inference, but there is no guarantee of inlining.

Escape Analysis and Scalar Replacement ​

java
record Point(int x, int y) {}

int distance() {
    Point p = new Point(3, 4);
    return p.x() + p.y();
}

If the runtime proves that an object does not escape its scope, it may be decomposed into scalars or entirely eliminated from heap allocation. The source code expression new indicates object creation semantics, but does not guarantee that a fixed-layout object will be allocated on the heap and persist until garbage collection.

Devirtualization ​

If a call site consistently receives one receiver type, the JIT may optimize for it with a type guard. Receiving a different type can take a slow path or trigger deoptimization. Separately, loading a new subclass may invalidate an optimization that depends on the class hierarchy. There is no single mandatory sequence triggered by every new subclass.

Deoptimization is Normal Behavior in Adaptive Systems ​

Speculative optimization relies on runtime facts:

text
Assumption: This call site only invokes FastParser
      ↓
Generate optimized code for FastParser + a guard check
      ↓
Later, SafeParser appears
      ↓
Guard fails, fallback to a more general execution state

This process is known as deoptimization. It does not necessarily indicate a JVM error; it is the adaptive optimizer correcting an invalid assumption. Repeated deoptimization, code-cache pressure, and changing profiles deserve investigation. Even one deoptimization can affect a latency-sensitive request; workload and timing evidence determine whether it matters.

Therefore, microbenchmarks that lack warm-up, whose results are not consumed, or whose branch profiles differ from production environments are likely to produce distorted metrics. Java microbenchmarks should typically use specialized tools like JMH, and users must understand that such tools improve experimental methodology but cannot automatically reflect real-world business behavior.

Choosing Monitoring Tools from Source Code Issues ​

IssuePreferred EvidenceWhat It Answers
What does the compiler generatejavap -c -v -pInstructions, constant pool, attributes, exception tables
Where do classes load fromClass loading logs, JFRLoaders and source origins
Where is CPU time spentJFR or sampling profilerHot stacks, methods, and threads
Where are objects allocated or retainedJFR allocation events; heap-dump analysisAllocation samples from the former; live objects and reference paths from the latter
Is a method compiledJVM compilation logs, JFRCompilation activity and code cache usage
Why are threads waitingThread dumps, JFR lock eventsBlocking points and lock holders

javap is a static tool and cannot reveal whether inlining occurred or what the final machine code looks like. A thread dump provides only a snapshot at a single moment and cannot prove long-term CPU hotspots on its own. Tools must align with the specific problem being investigated.

Java Flight Recorder (JFR) supports continuous or bounded event recording. Its overhead depends on settings, event rates, and stack collection; measure it under the target workload. Before starting diagnostics, record the following context:

  • JDK edition and full version;
  • JVM arguments, container CPU and memory limits;
  • Workload characteristics and sampling time window;
  • Whether the system is in startup, warm-up, or steady state;
  • Concurrent garbage collection, deployment, or traffic changes.

Without this context, claims like "JIT didn't optimize" are often just unrepeatable guesses.

Make a First JFR Recording ​

Use a HotSpot JDK 17 or later that includes JFR and the jfr command. Save this as JitDemo.java:

java
public final class JitDemo {
    static long mix(int value) {
        return (value * 31L) ^ (value >>> 3);
    }

    public static void main(String[] args) {
        long checksum = 0;
        for (int i = 0; i < 100_000_000; i++) {
            checksum += mix(i);
        }
        System.out.println(checksum);
    }
}
bash
javac --release 17 JitDemo.java
java -version
java -XX:StartFlightRecording=filename=jit-demo.jfr,settings=profile,dumponexit=true JitDemo
jfr summary jit-demo.jfr
jfr print --events jdk.Compilation,jdk.Deoptimization jit-demo.jfr

The program prints a checksum; the JVM also prints recording messages. First confirm that jfr summary can read the recording, then inspect the compilation and deoptimization events captured by the active settings. Counts, compilation tiers, and whether mix appears separately can vary. An inlined method may lack a separate entry, and zero recorded deoptimizations do not prove that deoptimization can never occur.

This loop exercises the recording workflow. It measures no elapsed time and is not a performance benchmark. Comparing implementations still requires JMH warm-up, independent forks, result consumption, and representative inputs.

Explaining Common Source Code Phenomena with Bytecode ​

Boxing ​

java
Integer sum = 1;
sum += 2;

Bytecode can expose Integer.valueOf, unboxing, and re-boxing operations. JIT compilation may eliminate some of this overhead, or may retain it due to escape analysis and other reasons; static bytecode only proves that the compiler generated certain semantic steps.

Bridge Methods ​

After type erasure, generic overrides may require the compiler to generate ACC_BRIDGE and ACC_SYNTHETIC bridge methods to preserve polymorphic dispatch:

java
interface Box<T> {
    T get();
}

class StringBox implements Box<String> {
    public String get() { return "ok"; }
}

Using javap -v -p reveals bridge methods that are not directly declared in the source code. These are not redundant business logic, but rather mechanisms by which the compiler maintains method signatures at the binary level.

switch and String Concatenation ​

switch may generate either tableswitch or lookupswitch, with the choice depending on case distribution and compiler strategy. Non-constant string concatenation may go through invokedynamic, while constant expressions may be folded. Any conclusion that "Java always generates a specific instruction" must first be validated in the target toolchain.

A Reproducible Diagnostic Process ​

  1. First, describe the observable symptoms: reduced throughput, rising tail latency, or increased allocation;
  2. Lock down the runtime environment and reproduce the workload;
  3. Use JFR or sampling data to identify performance hotspots, don’t assume JIT compilation first;
  4. When necessary, inspect bytecode, compilation events, and class loading origins;
  5. Formulate a falsifiable hypothesis and retest by changing only one variable;
  6. Verify that any optimization hasn’t compromised correctness or degraded performance under other workloads.

Completion Checklist ​

Choose a class that implements a generic interface, and add lambdas and boxing to the calling code. Using generics alone does not necessarily generate a bridge method:

  1. Use javap to locate bridge methods, invokedynamic, and boxing calls;
  2. Predict what optimizations the JIT might apply;
  3. Clearly identify which predictions are not guaranteed by the specification;
  4. Design a JFR or benchmarking experiment to validate those predictions;
  5. Record the JDK version and full command-line arguments to enable reproduction by others.

References ​

Built with VitePress | Software Systems Atlas