Java Fundamentals & the JVM

Difficulty

Java source (.java) is compiled by javac into bytecode (.class files) — an intermediate, platform-neutral instruction set. At runtime, a Java Virtual Machine (JVM) built for the host OS/CPU interprets or JIT-compiles that bytecode into native instructions.

Because the compiler targets the JVM instead of a specific CPU/OS, the same .class file (or JAR) can run unmodified on Windows, Linux, macOS, or embedded devices — as long as a compatible JVM is installed there. This is "Write Once, Run Anywhere" (WORA).

public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, Java!");
    }
}

Compile once with javac Hello.java, and the resulting Hello.class runs identically via java Hello on any platform with a JVM — no recompilation needed. The trade-off is a layer of indirection (the JVM itself), which historically cost some performance versus native-compiled languages, though modern JIT compilation closes most of that gap.

Related Resources

These three terms nest inside each other, from narrowest to broadest:

  • JVM (Java Virtual Machine): The abstract runtime engine that loads, verifies, and executes bytecode. It's specification + implementation (HotSpot, OpenJ9, etc.) and includes the class loader, execution engine (interpreter + JIT), and garbage collector.
  • JRE (Java Runtime Environment): JVM + the core class libraries (java.lang, java.util, java.io, ...) needed to run compiled Java applications. Historically shipped separately for end users who only needed to run apps.
  • JDK (Java Development Kit): JRE (or, since Java 11, the JVM + libraries directly) + development tooling: javac (compiler), javadoc, jar, jdb (debugger), jshell (REPL), etc. Needed to write and build Java software.

Since Java 11, Oracle stopped shipping a standalone JRE — you install a JDK to both develop and run applications, and can build a minimal custom runtime with jlink if you only need to ship a runtime image.

Rule of thumb: end users running an app need a JRE (or a JDK); developers always need a JDK.

Related Resources

Compilation in Java happens in two stages:

  1. Source → bytecode: javac translates .java files into .class files containing JVM bytecode — a stack-based instruction set (iload, invokevirtual, areturn, ...) that is independent of any real CPU architecture.
  2. Bytecode → native code: At run time, the JVM's class loader loads and verifies the bytecode, then the execution engine either interprets it directly or uses the JIT (Just-In-Time) compiler to translate hot code paths into native machine code for the actual CPU.

Because step 1 produces the same bytecode regardless of the target OS/CPU, and step 2 is the JVM's job (one JVM binary per platform), the exact same .class/.jar artifact is portable. The bytecode verifier also checks the code for illegal operations (stack underflow, type violations) before execution, which is why untrusted bytecode can be run relatively safely.

This differs from languages like C that compile straight to machine code — those binaries are tied to a specific OS/architecture and must be recompiled per target.

Java has exactly 8 primitive types: byte, short, int, long, float, double, char, boolean. A primitive variable stores its value directly (in a stack frame, or inline within an object/array) — there's no separate object, no header, no reference indirection.

Everything else — classes, interfaces, arrays, enums — is a reference type. A reference-type variable stores a pointer to an object allocated on the heap; multiple variables can reference the same object, and the variable itself may be null.

int x = 5;              // value lives directly in x
Integer boxed = 5;      // x is a reference to a heap-allocated Integer object

int[] arr = {1, 2, 3};  // arr is a reference to a heap array object

Key practical differences:

  • Defaults: numeric primitives default to 0/0.0, boolean to false, char to ' '; references default to null.
  • Equality: == compares primitive values directly, but compares references (identity) for objects — use .equals() for value equality on objects.
  • Passing to methods: both are passed by value, but for a reference type that "value" is the reference itself, so mutating the referenced object is visible to the caller, while reassigning the parameter is not.

Related Resources

Autoboxing is the compiler automatically converting a primitive to its wrapper object (intInteger); unboxing is the reverse. This lets primitives be used where an Object/generic type is required (e.g., in a List<Integer>).

List<Integer> nums = new ArrayList<>();
nums.add(5);           // autoboxed: int -> Integer
int first = nums.get(0); // unboxed: Integer -> int

Common pitfalls:

  1. NPE on unboxing null:
Integer count = null;
int x = count; // throws NullPointerException
  1. Identity vs equality with ==: the JVM caches boxed Integer values in [-128, 127] (the "Integer cache"), so == can appear to work for small numbers but breaks outside that range:
Integer a = 100, b = 100;
Integer c = 200, d = 200;
a == b; // true  (cached)
c == d; // false (different objects!)

Always use .equals() (or Objects.equals) to compare wrapper values.

  1. Performance: boxing/unboxing in tight loops (e.g., a Map<Integer, Integer> counter) allocates extra objects and adds overhead versus primitive arrays or specialized collections.
  2. Overload ambiguity: mixing boxed and primitive overloads can pick a surprising method at compile time.

Related Resources