Lesson 04 / 11
Just-in-Time Compilation
The class file holds no optimization decision at all: even if the same method is called three hundred thousand times, the instruction list read from the class file stays byte-for-byte identical. The party that makes the decision is not the program itself; the receiver type count the decision rests on, though, is produced by the calling thread while it runs. When a call site moves from monomorphic to polymorphic, the assumption becomes invalid — this lesson does not measure speed, it only counts instructions and receiver types.
Published
In this lesson
- Shows, by reading the class file again, that a method's instruction list does not change even after thousands of calls
- Counts the receiver types passing through a call site with its own counter, and measures the shift from monomorphic to polymorphic
- Explains that just-in-time compilation is not a claim about speed but a region defined by call density, and that this lesson never measures speed
Contents
The previous lesson saw a decision not found in the class file: which object gets collected and when. This lesson measures another decision not found in the class file — this time a decision about speed, not cleanup. While a method’s body sits unchanged in the class file, the same method may not run the same way on its first call as on its thousandth. The How Computers Work course established just-in-time compilation as a concept, and this lesson does not repeat it; this lesson only looks at the observed behavior — does the decision sit in the class file, or somewhere else?
The Class File Holds No Optimization Decision
A class file is fixed from the moment it is compiled and written to disk; no runtime event changes its content. Whether a method is never called or called millions of times, that method’s instruction list in the class file stays the same — because the decision “which method is called often, when should it be sped up” is not information that belongs to the class file itself. This is not left as a claim; it is measured below.
This is the fourth repetition of the pattern the previous three lessons built. A loader does not write which class it defines into the class file; which thread runs class initialization is invisible in the class file; who collects an unreachable object is not the class file’s business either. The fourth is the same shape: a class file states exactly what a method does, but how that method will run at runtime — interpreted, or in a sped-up form — is never part of the class file. All four measurements arrive at the same conclusion: a class file is a translation of the source, but it is not the runtime itself.
The measurement uses the Gauge tool the Java Fundamentals course built — not as a
measurement unit here, only as a tool that reads a method’s instruction list from the
class file.
- JV16 —
Gaugeis only a tool; it reads a method’s instruction list from the class file, and does nothing like classifying an “added step.” - JV17 — The same method, after really being loaded and called thousands of times, has its instruction list read again from the very same class file; no recompilation is done.
- JV18 — Only the call count is counted (how many times it was called); no duration is ever measured, no timestamp is ever read.
// Gauge.java — the tool that reads a method's instruction list from the class file (from Java Fundamentals, as a tool only)
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;
class Gauge {
static Path compile(String source, String className) throws Exception {
Path d = Files.createTempDirectory("gauge");
Path f = d.resolve(className + ".java");
Files.writeString(f, source);
PrintWriter sink = new PrintWriter(Writer.nullWriter());
ToolProvider.findFirst("javac").orElseThrow().run(sink, sink, "-d", d.toString(), f.toString());
return d;
}
static List<String> instructions(Path directory, String className, String method) throws Exception {
ClassModel cm = ClassFile.of().parse(directory.resolve(className + ".class"));
List<String> a = new ArrayList<>();
for (MethodModel m : cm.methods())
if (m.methodName().equalsString(method) && m.code().isPresent())
for (CodeElement e : m.code().get())
if (e instanceof Instruction i) a.add(i.opcode().name().toLowerCase(Locale.ROOT));
return a;
}
}
// HotPath.java — even if a method is called many times, the instruction list in the class file does not change
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.List;
public class HotPath {
public static void main(String[] args) throws Exception {
String source = """
public class Calculation {
public static int apply(int x) { return x * 2 + 1; }
}
""";
Path directory = Gauge.compile(source, "Calculation");
List<String> before = Gauge.instructions(directory, "Calculation", "apply");
URLClassLoader loader = new URLClassLoader(new URL[] { directory.toUri().toURL() });
Class<?> calculation = Class.forName("Calculation", true, loader);
Method method = calculation.getMethod("apply", int.class);
int callCount = 300_000;
int total = 0;
for (int i = 0; i < callCount; i++) total += (int) method.invoke(null, i);
List<String> after = Gauge.instructions(directory, "Calculation", "apply");
System.out.println("call count : " + callCount);
System.out.println("instruction list before the calls : " + before);
System.out.println("instruction list after the calls : " + after);
System.out.println("are the two lists byte-for-byte identical : " + before.equals(after));
System.out.println("is the total nonzero (it ran) : " + (total != 0));
}
}
call count : 300000 instruction list before the calls : [iload_0, iconst_2, imul, iconst_1, iadd, ireturn] instruction list after the calls : [iload_0, iconst_2, imul, iconst_1, iadd, ireturn] are the two lists byte-for-byte identical : true is the total nonzero (it ran) : true
The apply method was called three hundred thousand times, through a genuinely loaded
class — total did not come out zero, so the calls really ran. Yet the same class file
was read separately before and after the calls, and the two instruction lists came out
byte-for-byte identical. This is not “unchanged because it was not measured” — same file,
same method, two separate reads, the same result. Noticing that the method is called
often, deciding to speed it up, actually applying that decision — none of this is written
into the class file. The decision does not exist in the program’s own source text, nor in
the class file it compiles to; it is born at runtime, in a place the program itself never
created.
As the How Computers Work course established, the virtual machine can either interpret bytecode directly or translate part of it into instructions specific to the processor it is running on; which path is chosen depends on how often that part runs. But this translation stays where it was produced — in the running program’s own memory; it is never written back to the source or to the class file. The measurement below tests this distinction directly: the same class file, even after being brought into the state most primed for speeding up, is compared against its own form on disk.
Making the measurement through Method.invoke, an indirect call, is a deliberate choice:
the Calculation class was not compiled into the same directory as this lesson’s other
classes; it was loaded with a separate loader, and its name was carried only as a piece of
text. The reason is being able to both really run the same class file and read it again
after the calls; if the class had been written as an ordinary neighboring file, reading the
running copy’s bytecode separately would still require going through the same path. What
gets read is not merely a copy recompiled from the same source, but the very class that was
really called three hundred thousand times.
The Receiver Type Passing Through a Call Site: From Monomorphic to Polymorphic
A decision needs data to rest on, and that data has to come from somewhere. Every call
made through an interface — a call site — can accumulate a record, over time, of which
concrete types it has encountered. This lesson keeps that record with its own counter: it
counts, through getClass() identities, how many distinct types the receivers passing
through a call site come from. A call site counts as monomorphic if it has only ever
encountered a single type, and polymorphic if it has encountered more than one.
The importance of this distinction comes from here: the class-file counterpart of a
Processable i reference is the call i.value(), and this call does not say at compile
time which concrete class it carries — it could be TypeA, it could be TypeB. If a call
site, in practice, always meets the same single type, that fact is an opportunity to speed
things up: instead of re-asking “which class is this” on every call, it can be assumed
that the same answer has always come back in the past. But this assumption is not part of
the class file — because at the moment the class file is compiled, how many distinct types
the call site will meet at runtime is not yet known. How many implementations the
Processable interface has does not give this information either: an interface can have
ten implementations, but a single call site may see only one of them over the course of a
run; the receiver count is not a property of the interface itself, but a summary of what
that particular call site has seen in the past.
- JV19 — The number of receiver types passing through a call site is counted with a
set we build ourselves (through
getClass()identities); this is the result of our own counter modeling that mechanism, not of the runtime’s own profiling machinery.
// Processable.java — the common interface for receivers passing through a call site
interface Processable { int value(); }
// TypeA.java — the first receiver type at the call site
class TypeA implements Processable { public int value() { return 1; } }
// TypeB.java — the second receiver type, added to the call site later
class TypeB implements Processable { public int value() { return 2; } }
// CallSite.java — a single call site through the interface
class CallSite { static int call(Processable i) { return i.value(); } }
// ReceiverType.java — the number of distinct receiver types passing through a call site, counted with our own set
import java.nio.file.Path;
import java.util.*;
public class ReceiverType {
public static void main(String[] args) throws Exception {
Set<Class<?>> seenTypes = new HashSet<>();
TypeA a = new TypeA();
int callCount = 100_000;
for (int i = 0; i < callCount; i++) {
seenTypes.add(a.getClass());
CallSite.call(a);
}
System.out.println("call count (TypeA only) : " + callCount);
System.out.println("monomorphic call site : " + (seenTypes.size() == 1));
TypeB b = new TypeB();
seenTypes.add(b.getClass());
CallSite.call(b);
System.out.println("second implementation loaded : " + (seenTypes.size() == 2));
System.out.println("polymorphic call site (assumption invalid): " + (seenTypes.size() > 1));
String source = """
interface Processable { int value(); }
class CallSite { static int call(Processable i) { return i.value(); } }
""";
Path directory = Gauge.compile(source, "CallSite");
System.out.println("instruction list for the call method: " + Gauge.instructions(directory, "CallSite", "call"));
}
}
call count (TypeA only) : 100000 monomorphic call site : true second implementation loaded : true polymorphic call site (assumption invalid): true instruction list for the call method: [aload_0, invokeinterface, ireturn]
Across a hundred thousand calls, CallSite.call met only TypeA instances; the counter
stayed at one. The moment TypeB’s first instance passed through the call site, the
counter rose to two and the call site became polymorphic. A real runtime can speed up a
monomorphic call site based on exactly this information — by assuming the receiver will
always be the same type; once a second type appears, this assumption becomes invalid and
the runtime deoptimizes. This lesson does not capture that reversal as a real event (doing
so would require an implementation-specific tool); it only models, with its own counter,
the condition that invalidates the assumption — the second type joining the call site. The
instruction list of the underlying call method, though, is identical in both cases: the
invokeinterface instruction does not say which concrete type is coming, it only says
“call a Processable”; whether the receiver count is one or two leaves no trace in the
class file.
Who produced the data this decision rests on? The code made its own addition to the
seenTypes set before every call — this addition is part of the calling thread’s own
code, not something done by a separate party. The real runtime’s profile data is produced
on the same principle: every call passing through a call site leaves data for the party
that will make the decision (the runtime itself), and the step that accumulates this data
is itself part of the code the calling thread executes. The party that makes the decision
is not the caller, but the caller is what produces the data the decision rests on.
This distinction resembles the “who runs it” versus “who sees it” distinction from an earlier lesson, but it is not the same. There, the one who wrote a value and the one who saw it were two separate threads; here, the one who produces the data (the caller) and the one who decides based on that data (the runtime) are two separate parties, and neither can be named with the same concept of a thread — because the party that decides is never observed in this lesson through a thread identity, it is recognized only by its absence from the class file.
A Bounding Measurement: A Hot Path Is Not a Claim About Speed
No duration was measured anywhere in this lesson, and none can be: how long three hundred thousand calls took, which of a monomorphic and a polymorphic call site runs faster — none of this is written in this lesson, because both depend on the machine it runs on, on the current load, and on the implementation. The only things counted are the instruction count and the receiver type count; both are data the program produces itself, staying fixed from run to run.
The term hot path is therefore not a claim about speed, but a region defined by call
density: a method is hot because it is called often, not because it runs fast. The
measurement above put the apply method into a dense call region by calling it three
hundred thousand times, but this lesson never compared any duration before or after the
calls — only the call count and the fact that the class file did not change were counted.
Whether this heat turns into a speed gain is not this lesson’s question.
This boundary works in both directions. On one hand, no claim was written about whether the three hundred thousand calls were “slow” or “fast” — the only claim was that the class file did not change. On the other hand, no claim was written about a monomorphic call site running “faster” than a polymorphic one either — the only thing measured was whether the receiver count was one or two. Neither measurement came anywhere near the question of “how fast,” answering only the questions of “how many” and “is it the same”; this course’s measurement discipline applies here in exactly the same way.
Summary
- A class file is fixed once compiled: even if a method is called three hundred thousand times, its instruction list stays byte-for-byte identical before and after the calls.
- The decision of when and how to speed up a method is never in the class file; the decision is born at runtime, in a place the program itself never created.
- A call site counts as monomorphic or polymorphic based on how many distinct types the receivers passing through it come from; once a second implementation joins the call site, the monomorphic assumption becomes invalid — this lesson models that invalidation (deoptimization) with its own counter, it does not capture it as a real event.
- The party that produces the data (the receiver type count) a decision rests on is the calling thread; the party that makes the decision is not the caller.
- Whether a call site’s receiver count is one or two, the instruction list of the method carrying that call does not change in the class file.
- A hot path is defined by call density, not by duration; this lesson never measures duration, it only counts instructions and receiver types.
Next Step
Across this topic, three decisions not found in the class file were observed: which loader
defines a class and which thread initializes it, who collects an unreachable object, and
when a method gets sped up. In all three, the party that makes the decision was not the
program itself — it could be found nowhere, neither in the source text nor in the class
file compiled from it. But the program itself is still what creates the threads that run
its code: the program itself writes the new Thread(...) line, the program itself makes
the start() call. The next topic starts from there — how is a thread brought into
existence from inside the program, and which party runs its body?
To keep your progress and take notes, Log in
My notes
Log in to take notes.