What you'll learn
Quick Answer
javac compiles source to platform-independent bytecode; the JVM interprets it and compiles hot paths to native code at runtime. Two of its caches — the string pool and the Integer cache — make == behave inconsistently, which is why you use equals().
Source, bytecode, machine code
Java is compiled twice, and understanding that explains most of its behaviour.
javac compiles .java to .class files containing bytecode — instructions for an imaginary machine rather than for any real processor. That is what makes the same jar run unchanged on Windows, Linux and macOS: the bytecode is identical everywhere, and only the JVM differs.
At runtime the JVM starts by interpreting that bytecode, which is slow but starts instantly. Meanwhile it counts how often each method runs, and when something is called enough it invokes the JIT compiler, which compiles that method to native machine code.
This is why a Java program is often slow for its first seconds and then noticeably faster — it is warming up. It is also why benchmarking Java badly is easy: measuring the first thousand iterations measures the interpreter, not the compiled code.
Because the JIT compiles at runtime, it knows things a static compiler cannot — which branches are actually taken, which types actually appear — so long-running Java can approach C-like performance despite the extra layer.
Where objects live
The JVM divides memory into regions, and two matter for everyday work.
The stack holds one frame per method call, containing local variables and primitive values. It is per-thread, freed automatically on return, and limited in size — deep recursion produces StackOverflowError.
The heap holds every object, shared across threads and managed by the garbage collector. A local variable holding an object contains a reference on the stack pointing to the heap, which is why assigning one variable to another copies the reference rather than the object.
Garbage collection frees objects nothing references any more. You do not free memory manually, and System.gc() is only a suggestion the JVM may ignore.
Java can still leak memory, which surprises people. A collection that grows forever, a cache with no eviction, or listeners never unregistered all keep objects referenced — so the collector correctly refuses to free them. "Reachable but useless" is the shape of a Java memory leak.
The string pool, and why == lies
String literals are interned — identical literals share one object in a pool, saving memory since strings are everywhere and immutable.
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1.equals(s3)); // true
System.out.println(s1 == s3.intern()); // true
s1 == s2 is true because both refer to the same pooled object. new String("hello") explicitly forces a separate object, so == is false while the contents are identical.
This is precisely why == compares references and equals() compares contents. The dangerous part is that == appears to work in simple tests with literals, then fails on strings built at runtime or read from input — because those are not pooled.
Rule: never use == on strings. intern() exists to place a runtime string into the pool, and is rarely needed.
The Integer cache, which is stranger still
Integer a1 = 127, b1 = 127;
Integer a2 = 128, b2 = 128;
System.out.println(a1 == b1); // true
System.out.println(a2 == b2); // false
The same comparison, different answers, decided by whether the value is above 127.
The JVM caches Integer objects for −128 to 127, on the reasoning that small numbers are extremely common. Autoboxing 127 returns the cached instance, so both variables reference the same object. 128 is outside the cache, so two separate objects are created.
This is a favourite interview question because it looks like a language inconsistency and is actually an optimisation leaking through. The practical lesson is the same as for strings: use equals() for wrapper types, and prefer primitive int where you can, which avoids the question entirely.
It also illustrates why autoboxing deserves attention — Integer in a tight loop allocates objects, and unboxing a null Integer produces a NullPointerException from a line that appears to contain only arithmetic.
What this means in practice
- JDK, JRE, JVM. The JVM runs bytecode. The JRE is the JVM plus the standard libraries. The JDK is the JRE plus development tools such as
javac. You need the JDK to compile. - Bytecode is not obfuscation. A
.classfile decompiles to readable source easily, so never treat a compiled jar as a place to hide secrets. - Heap size is configurable with
-Xmx. AnOutOfMemoryErrormeans either a genuine leak or a heap too small for the workload, and telling those apart requires looking rather than guessing. - Other languages target the JVM — Kotlin, Scala, Groovy all compile to the same bytecode and interoperate with Java libraries. That is why Kotlin could be adopted for Android without replacing the ecosystem.
For interviews, being able to explain the two-stage compilation and why == misbehaves covers most of what is asked. See Java exception handling and process vs thread for the neighbouring topics.
