Lesson 1 of 5
From Source to Bytecode
"A translation issues from the original — not so much from its life as from its afterlife." — Walter Benjamin, The Task of the Translator (1923)
Every Java programmer knows that javac turns .java into .class, and almost none of them have looked inside the result. That is a strange gap, because the class file is the thing that actually ships. It is what the JVM loads, what the profiler samples, what the decompiler reads, what your dependency scanner inspects, and what an interviewer is really asking about when they ask why two strings compared with == behaved differently than you expected.
This chapter opens the file. Not metaphorically — a block on this page reads its own compiled bytes while it is running and prints them back to you. By the end you will have watched the compiler add things you did not write, rewrite things you did, and delete things you did, and you will be able to say which is which from memory rather than from folklore.
The reason to start here, rather than with syntax, is that most of the Java questions that separate a competent mid-level engineer from a senior one are not language questions at all. They are runtime questions wearing a language costume. Why is this string comparison true here and false there? Why did adding a lambda not create a new class file? Why does changing a constant in one module require recompiling another? Each has a one-sentence answer once you can see the class file, and no satisfying answer at all if you cannot.
If the compiler and the JVM both check your types, why do both exist — and which of them is the one you are actually programming against?
Your source is already gone
Start with the simplest possible observation: after compilation, your source code is not what runs. Something else runs, and that something else is a file with a defined shape you can read.
The playground compiles each block to /files/out/ and then loads it, which means a running program can open the very bytes it was compiled into. That is not a trick available in most environments, and it makes the abstraction unusually easy to puncture.
Click Run to compile and execute the code.
The first four bytes are CAFEBABE. James Gosling's team chose it in 1991 as a magic number that was memorable and unlikely to occur by accident, and it has never changed; a file that does not begin with those bytes is not a class file, and the JVM will refuse it before parsing anything else.
The two numbers after it are the version this file was compiled for, not the version that compiled it. Major 61 means Java 17. This single field is the entire mechanism behind the error every Java developer eventually meets:
UnsupportedClassVersionError: Main has been compiled by a more recent
version of the Java Runtime (class file version 65.0), this version of
the Java Runtime only recognizes class file versions up to 61.0
There is no cleverness in that check. The JVM reads bytes 6 and 7, compares them to what it supports, and gives up if the number is too high. Class files are forward-compatible in one direction only: a newer JVM runs older class files happily, an older JVM refuses newer ones flatly. That asymmetry is why "which Java are you targeting?" is a different question from "which Java are you running?", and why --release 17 on a JDK 21 is a normal and correct thing to do.
A class file is a shipping manifest, not a photograph of the warehouse. It does not contain your code as you wrote it; it contains a precise, checkable description of what must be loaded, what must be linked, and what must run — written in a format designed to be validated by a suspicious reader who assumes the sender may be lying. That suspicion is the point: the JVM verifies bytecode it did not compile, because on the web of 1995 it fully expected to be handed hostile class files by strangers.
The table your code points into
The number the last block printed — somewhere near a hundred constant-pool entries for a program of a dozen lines, and a class file roughly twice the size of its own source — deserves explanation, because that table is where most of the file's weight lives.
Bytecode instructions almost never contain data. They contain indexes. When your code calls System.out.println("live"), the compiled instruction does not carry the string, or the class name, or the method signature. It carries a number, and that number indexes a table at the top of the file where all the names and literals live exactly once. The table is the constant pool, and reading it is the fastest way to understand what a class file actually is.
Click Run to compile and execute the code.
Look at the shape of it. Entry 1 is a Class that points at entry 2, which is the text Main. Entry 3 is a Class pointing at java/lang/Object. A Methodref points at a Class and a NameAndType, and the NameAndType points at two more strings. Everything is indirection into text, and roughly half of all entries are raw text.
This explains a surprising amount. It is why class files are so compressible, and why a fat jar shrinks dramatically under pack200 or a modern equivalent: the same type names appear thousands of times across thousands of class files, and within each file exactly once. It is why obfuscators work by rewriting the pool rather than the instructions. It is why a NoSuchMethodError at runtime is possible at all — the caller holds a name and descriptor, resolved late against whatever class is actually on the classpath, not a hard pointer fixed at compile time.
And it is the mechanical reason for one of Java's more infamous rules: the class file format stores those strings with a two-byte length, so no single constant may exceed 65,535 bytes of modified UTF-8. That is why a sufficiently enormous string literal is a compile error rather than a large string.
The constant pool is older than Java's popularity and was shaped by a constraint that has since evaporated: class files were meant to travel over a 1995 modem, one at a time, into an applet sandbox. Deduplicating every name into a shared table mattered when the file was going through a 28.8k line. What survived is not the size saving but the late binding it forced — because references are symbolic text resolved at load time rather than addresses fixed at compile time, Java got dynamic class loading, hot deployment, and the entire plugin-and-container culture that Spring was later built on. A bandwidth optimisation turned into an architecture.
What the compiler adds
So far everything in the file corresponds to something you wrote. Now the interesting part: the compiler emits things you did not write, and declines to emit things you did.
The cleanest demonstration is one of the most commonly misunderstood pairs in the language. An anonymous inner class and a lambda look like two spellings of the same idea. They are not the same thing at all, and you can prove it by listing a directory.
Click Run to compile and execute the code.
The anonymous Runnable became a real file on disk: Main$1.class. The nested interface became Main$Greeter.class. The two lambdas became no file at all — and yet at runtime they have class names like Main$$Lambda$144, which exist nowhere on disk.
That is invokedynamic at work. Instead of generating a class at compile time, the compiler emits a single instruction that says, in effect: the first time this line executes, call this bootstrap method and ask it to produce me an implementation of this interface. The bootstrap is LambdaMetafactory, and the class it spins is created in memory, on first use, by the JVM.
The practical consequences are worth memorising, because interviewers ask about all three:
- Startup and size. A thousand anonymous classes are a thousand class files to load and verify. A thousand lambdas are one instruction each and a handful of generated classes, produced only for the ones actually reached.
- Identity. Two anonymous class instances are always distinct objects of distinct classes. A non-capturing lambda may be cached and handed back as the same instance every time, which is why comparing lambdas with
==is meaningless and why you must never use one as a map key you intend to remove later. - Debugging. Stack frames say
Main$$Lambda$144, which tells you nothing about which lambda. This is the single most common complaint about lambdas in production, and it is a direct consequence of the class not existing until runtime.
Writing this chapter I twice proved something false and believed it for several minutes each time. I wanted to show that dead code never reaches the class file, so I searched the compiled bytes for a marker string that appeared only inside an if (false) block — and found it, apparently disproving the claim. The marker was present because my own diagnostic line printed its name, putting the literal in the constant pool. I then "fixed" it by assembling the needle as "GHOST" + "_" + "ONE", which the compiler folded straight back into a single literal, and found it again. Only a needle built from raw characters, with no string literal anywhere, gave the true answer. Both mistakes are the same mistake: a test that cannot fail is not a test, and searching a file for a string you have just put into it will always succeed.
What the compiler rewrites
Between the source you type and the pool you just printed, the compiler performs a small set of transformations. It is important to know exactly how small that set is, because Java folklore consistently credits javac with optimisations it does not perform — those happen much later, in the JIT, and the difference matters when you reason about performance.
Two rewrites are worth seeing directly.
Click Run to compile and execute the code.
The first result is the one that ends up in interviews. folded == "abcd" is true and joined == "abcd" is false, even though the two strings are equals. Nothing mystical is happening. "ab" + "cd" is a constant expression: the compiler evaluates it, produces the single literal "abcd", and places it in the constant pool, where all identical literals are the same interned object. prefix + "cd" cannot be evaluated at compile time, so it becomes a runtime concatenation producing a fresh object on the heap.
This is the honest answer to "why should I never compare strings with ==". Not because it is bad style, but because whether it works depends on whether the compiler could fold the expression — which is a property of how you wrote it, not of what it means.
The loop is the other half. Modern javac compiles a + b to a single invokedynamic makeConcatWithConstants instruction rather than the StringBuilder chain older Java used. That is a genuine improvement, and it is where the folklore goes wrong: the optimisation applies within one expression, and the compiler does not hoist anything out of a loop. Each iteration of acc += i still allocates a complete new string and copies everything accumulated so far, so the loop remains quadratic in the total length. The old advice to use a StringBuilder for loop accumulation is still correct; only the reason has changed.
I verified this on both compilers rather than trusting one, because the playground compiles with the Eclipse compiler while most readers use javac. They agree: both emit invokedynamic makeConcatWithConstants, both place it inside the loop body, and neither hoists a builder out of it.
What the compiler deletes
The last transformation is subtraction, and it is the one with the most surprising consequence.
Click Run to compile and execute the code.
Both ghosts are gone. Not merely unreachable at runtime — absent from the file. The string literals never made it into the constant pool, and the println calls never became instructions.
This is conditional compilation, and it is the one place the Java language explicitly licenses the compiler to remove code you wrote. The rule is narrow: the condition must be a constant expression, which false is, and which a final variable initialised to a constant also is. Change final boolean DEBUG = false to a plain boolean DEBUG = false and the branch comes back, dead code and all, because the compiler is no longer permitted to assume the value.
The consequence people trip over is not this feature but its sibling. Because static final fields with constant initialisers are inlined into the call site, a class that reads Config.MAX_RETRIES does not read a field at runtime — it contains the literal value, baked in at compile time. Change the constant in the library, ship the new jar, do not recompile the caller, and the caller keeps using the old number with no error and no warning. This is the single most common cause of "I changed the config and nothing happened" in multi-module Java builds, and it has confused people for thirty years.
Every tool you already use is reading the structures in this chapter. A profiler maps samples back to methods via the class file's line-number table. Proguard and R8 shrink apps by rewriting the constant pool and deleting unreachable methods, which is why reflection breaks under obfuscation — a name looked up as a string at runtime cannot be renamed safely. Jacoco measures coverage by instrumenting bytecode after compilation, not source, which is why a fully covered line can still hide an untested branch. Dependency scanners identify a vulnerable library by hashing class files, not by trusting a version string. And every "class not found" that appears only in production is a symbolic reference that resolved locally and did not resolve there.
Two compilers, one contract
A detail worth stating plainly, because it explains why any of this is stable enough to teach: javac is not special. It is one implementation of a specification that describes the class file format, and the Eclipse compiler is another. They disagree in places — error recovery, warning text, the precise bytecode for some edge cases — but they produce files the same JVM accepts, because the contract is the file format, not the compiler.
This is the reason Java has real alternative compilers and real alternative languages on the same runtime. Kotlin, Scala, Groovy and Clojure are not translated to Java; they emit class files directly. Anything in this chapter that is a property of the format — the magic number, the version field, the constant pool, symbolic resolution — holds for all of them. Anything that is a property of javac's choices — how a lambda is bootstrapped, how string concatenation is compiled — may differ, and is worth checking with javap -c rather than assuming.
That distinction is the practical skill this chapter is really teaching. When you next want to know what Java does with some construct, the answer is not on a forum. It is four keystrokes away:
javac Example.java && javap -c -p Example
Common Mistakes
Believing
==on strings is unpredictable. It is completely predictable once you know whether the expression is a constant expression. Two folded literals share one interned object and compare equal; anything built at runtime does not. The rule to teach juniors is still "always useequals", but the reason is not superstition.Thinking
javacoptimises your code. It performs constant folding, dead-code elimination for constant conditions, and a handful of mandated desugarings. It does not inline methods, unroll loops, eliminate bounds checks or hoist invariants. Those are the JIT's job, at runtime, with profiling data the compiler never had.Assuming a lambda is an anonymous class with nicer syntax. They compile to entirely different things: one is a class file on disk, the other an
invokedynamicresolved at first execution. This determines startup cost, object identity, and what your stack traces look like.Comparing lambdas with
==, or using one as a removable listener key. A non-capturing lambda may be cached and returned as the same instance; a capturing one is generally fresh. Neither behaviour is specified for you to rely on, soremoveListener(x -> ...)silently removes nothing.Expecting a constant change to propagate without recompilation.
static finalprimitives and strings with constant initialisers are inlined into every call site. Downstream modules keep the old value until rebuilt, with no error to tell you.Reading a class file's version as "the JDK that compiled it". It is the version targeted. A JDK 21 compiling with
--release 17produces major 61, and that is the normal state of affairs in any project that supports more than one runtime.Treating
UnsupportedClassVersionErroras a dependency problem. It is a two-byte comparison. The message states both numbers; the fix is always either a newer runtime or a lower target, and never a different library version.Assuming dead code costs nothing because it never runs. Code under a constant-false condition costs nothing because it is deleted. Code under a runtime-false condition is fully present, loaded, verified and JIT-eligible — it only costs nothing to execute.
Searching compiled output for a string you also print. The literal enters the constant pool via your diagnostic, and the search finds it there. This is the same defect as a unit test that asserts only what a do-nothing stub already satisfies, and it is just as easy to write by accident.
Believing the compiler resolves your method calls. It resolves them symbolically — a name, a descriptor, an owning class — and the real resolution happens at class load and link time against whatever is actually on the classpath. Every
NoSuchMethodErroris that late resolution failing on code that compiled perfectly.
Interview Corner
javac produce, and what does the JVM actually load?javac produces a .class file per class: a binary format starting with the magic number CAFEBABE, a version field saying which Java release it targets, and a constant pool holding every name and literal the class refers to. The JVM loads that file, verifies it, and resolves its symbolic references to real classes and methods at link time. The source file is not involved at runtime at all — which is why you can run a program whose source you have deleted, and why a stack trace needs a separate line-number table to mention line numbers.("ab" + "cd") == "abcd" true while (prefix + "cd") == "abcd" is false?"abcd". Identical literals in the constant pool are the same interned object, so == compares one object with itself. The second involves a variable, so it cannot be folded; it compiles to a runtime concatenation that allocates a new String, and == compares two distinct objects. Both are equals. The lesson is not "== is unreliable" but "whether it works depends on how the expression was written, which is exactly why you should not depend on it".Outer$1.class — loaded and verified like any other. The lambda becomes one invokedynamic instruction whose bootstrap, LambdaMetafactory, spins an implementation class in memory the first time that line executes. That changes three things in production. Startup and footprint: thousands of anonymous classes are thousands of files to load, where lambdas cost one instruction each and materialise only when reached. Identity: a non-capturing lambda may be cached and returned as the same instance, so identity comparisons and removal-by-reference are unreliable. Diagnostics: frames read Outer$$Lambda$14, so heavy lambda use in a hot path is genuinely harder to profile and read in a crash dump. I would reach for a named class when the thing needs identity, a useful name in a stack trace, or state with a lifecycle — and a lambda everywhere else..class file is, why == on strings misleads, and whether you can read a stack trace that mentions a synthetic lambda class.UnsupportedClassVersionError or NoSuchMethodError only in production; walk through diagnosing it without guessing.Hands-on
Model what you have just measured. Implement majorFor, mapping a Java release to its class-file major version; runsOn, deciding whether a class file will load on a given JVM; emitsClassFile, saying which constructs produce a file on disk; isConstantExpression, distinguishing what the compiler can fold from what it cannot; and survivesCompilation, saying which statements reach the class file at all. A stub scores zero, so a passing run means the model is real rather than lucky.
Click Run to compile and execute the code.
Summary
- Your source does not run; a class file does. It begins with
CAFEBABE, declares the Java version it targets, and carries a constant pool of every name and literal the class needs. - The version field is a two-byte number, and
UnsupportedClassVersionErroris a two-byte comparison. Newer JVMs run older class files; older JVMs refuse newer ones, always and only for this reason. - Bytecode holds indexes, not data. Instructions point into the constant pool, which is why roughly half of a small class file is raw text and why deduplication made class files cheap to ship.
- References are symbolic and resolved late, against whatever is on the classpath at load time. That late binding bought Java dynamic loading and plugins, and it is why
NoSuchMethodErrorcan happen to code that compiled cleanly. - An anonymous class becomes a file on disk; a lambda becomes an
invokedynamicwhose implementation class is spun in memory on first execution, which changes startup cost, identity semantics and every stack trace. - Constant expressions are evaluated by the compiler, so
"ab" + "cd"becomes one interned literal and==against"abcd"is true — while the same value built from a variable is a different object. - String concatenation compiles to
invokedynamic, once per expression. Nothing is hoisted out of a loop, so accumulating with+=is still quadratic andStringBuilderis still the right tool. - Code under a constant-false condition is deleted outright, literals and all, while code under a runtime-false condition is fully present in the file and merely unexecuted.
static finalconstants are inlined into every call site, so changing one in a library and not recompiling its callers silently leaves them using the old value.javacis one implementation of a published format, not the definition of Java. Anything true of the file holds for Kotlin and Scala too; anything true ofjavac's choices should be checked withjavap -crather than assumed.
The file is now readable, but nothing has run yet. A class file on disk is inert: before a single instruction executes, something must find it, parse it, check that it is not lying, and decide when its static initialisers fire — and the order of those steps explains a family of bugs that look like magic. That is the next chapter.
The thing I keep returning to about this chapter is how much of Java's reputation for mystery dissolves once the class file stops being a black box. Why is this string comparison true? is unanswerable as a language question and trivial as a format question. So is why didn't my constant change take effect?, and why does the stack trace say $$Lambda$14, and why does this work on my machine. None of those require deep knowledge; they require having looked once. And yet most working Java programmers never do look, because the tooling is so good at hiding the step that you can ship for a decade without needing to. That is a real achievement and a real cost at the same time — the abstraction earns its keep right up until the day it leaks, and on that day the people who have opened the file are debugging while everyone else is guessing.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.