Runtime vs Compile Time

Which phase actually catches which mistake — and the foundational distinction the next page, Source/Target Compatibility, builds directly on

← Back to Index

Compile Time vs Runtime — and Why the Line Between Them Matters

Every Java program passes through two genuinely separate phases: compile time, when javac turns .java source into .class bytecode, and runtime, when the JVM actually executes that bytecode. Knowing which phase catches which category of mistake isn't academic — it determines whether a bug surfaces as a build failure your CI pipeline stops on, or as an exception in production at 3 AM. Generics are the clearest example: they feel like a runtime safety net, but they are purely a compile-time construct — the type information is erased before the bytecode is ever generated, which is exactly why certain checks that look like they should be possible at runtime simply aren't.

// BEFORE — assuming generics protect you everywhere, including through reflection
List<String> skus = new ArrayList<>();
Method addMethod = skus.getClass().getMethod("add", Object.class);
addMethod.invoke(skus, 12345);   // compiles and runs fine — reflection bypasses compile-time generics entirely
String firstSku = skus.get(0);    // ClassCastException — an Integer where a String was "guaranteed"

// AFTER — knowing generics are erased at runtime, defend where it actually matters
if (!(rawValue instanceof String)) {
    throw new IllegalArgumentException("Expected a String SKU, got " + rawValue.getClass());
}
// A runtime check, because the compiler's generic type check cannot follow you
// through reflection, raw types, or unchecked casts — it only ever existed at
// compile time in the first place.
The two phases, in one line each
  • Compile time — source (.java) becomes bytecode (.class); syntax, types, and symbols are checked against the full source, then most of that information is discarded
  • Runtime — the JVM loads and executes that bytecode; anything that depends on actual data, actual object identity, or actual timing can only be known here

Compile Time — What javac Actually Checks

// 1. Syntax checking
int x = 5   // Error: ';' expected

// 2. Type checking
String sku = 42;   // Error: incompatible types

// 3. Symbol resolution
System.out.printn("Hi");   // Error: cannot find symbol 'printn'

// 4. Access checking
private int internalStock;
product.internalStock = 20;   // Error: internalStock has private access

// 5. Constant folding (an optimization, not just a check)
int total = 2 + 3;   // compiled directly as: int total = 5;

Compile-time constants — inlined, not just evaluated

public class CatalogConstants {
    static final int MAX_ITEMS_PER_ORDER = 100;         // compile-time constant
    static final int RANDOM_ID = new Random().nextInt();   // NOT one — computed at runtime, every time
    static final int ABSOLUTE_MAX = MAX_ITEMS_PER_ORDER * 2;   // still a compile-time constant expression
}

// Calling code doesn't reference CatalogConstants at runtime for this value —
// the literal is inlined directly into the caller's own bytecode:
int limit = CatalogConstants.MAX_ITEMS_PER_ORDER;   // compiled as: int limit = 100;
Inlining is why a compile-time constant needs a recompile to update, not just a redeploy of the constant's own class

Because the literal value is copied directly into every caller's bytecode at compile time, changing MAX_ITEMS_PER_ORDER and redeploying only CatalogConstants.class does nothing for callers compiled against the old value — they still have 100 baked into their own bytecode until they're recompiled against the new constant. This is a real, recurring production surprise in multi-module builds where one module's JAR gets updated independently of its callers.

Compile-time errors — these stop the build entirely

// Type mismatch on a generic
List<String> skus = new ArrayList<Integer>();   // Error!

// Unhandled checked exception
public void readCatalog() {
    new FileReader("catalog.csv");   // Error: unhandled IOException
}

// Missing return statement
public int getStockLevel() {
    int level = 5;
    // Error: missing return statement
}

Runtime — What the JVM Handles That the Compiler Can't

// 1. Object creation — memory is only actually allocated here
Order order = new Order();

// 2. Method dispatch — which override actually runs depends on the real object
PaymentMethod payment = resolvePaymentMethod(request);   // could be CreditCard, PayPal, ...
payment.charge(amount);   // the ACTUAL method is only known once this line runs

// 3. Array bounds checking
int[] slots = new int[5];
slots[10] = 1;   // ArrayIndexOutOfBoundsException — the compiler has no way to know this in advance

// 4. Null checks
String code = null;
code.length();   // NullPointerException — nullability isn't tracked by the type system

// 5. Class loading
Class<?> clazz = Class.forName("com.shop.PromotionEngine");   // resolved from a string, only possible at runtime

Runtime exceptions — the compiler had no way to catch these

// NullPointerException
String code = null;
code.toUpperCase();

// ClassCastException
Object obj = "discount-code";
Integer num = (Integer) obj;

// ArithmeticException
int result = 10 / 0;

// OutOfMemoryError — a resource limit, not a logic error the compiler could flag
List<byte[]> buffers = new ArrayList<>();
while (true) { buffers.add(new byte[1_000_000]); }

Type Erasure — Where "Compile-Time-Only" Has Real Consequences

Generics exist purely at compile time. By the time bytecode is generated, the type parameter is erased — this is precisely why the Section 0 example above compiles and runs without a single warning until the moment it actually reads a mistyped value back out.

// At compile time — fully type-checked
List<String> skus = new ArrayList<>();
List<Integer> quantities = new ArrayList<>();

// At runtime — both are just "List". The type parameter is gone.
System.out.println(skus.getClass() == quantities.getClass());   // true!

// This is exactly why you can't do this:
// if (list instanceof List<String>) { }   // compile error — no type info left to check
if (list instanceof List<?>) { }   // OK — the unbounded wildcard makes no claim about the erased type
Practical implications

Generic type parameters aren't available at runtime: you can't create a generic array (new T[10]), you can't instanceof against a parameterized type, and reflection can't recover the original type argument without extra machinery (like inspecting a subclass's own generic superclass signature — the "super type token" trick). Any framework that appears to "know" your generic type at runtime (Jackson deserializing into List<Product>, for example) is doing exactly this kind of extra work, not defying erasure.

Annotations — Retained for a Specific Phase, by Design

// SOURCE — discarded after compilation, never reaches the .class file
@Retention(RetentionPolicy.SOURCE)
public @interface Todo { }

// CLASS — present in the .class file, but not accessible via reflection (this is the default)
@Retention(RetentionPolicy.CLASS)
public @interface InBytecode { }

// RUNTIME — readable via reflection while the program runs
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditLogged { }

// Familiar examples, by actual retention:
@Override       // SOURCE — the compiler checks it, then it's gone
@Deprecated    // RUNTIME — tools and reflection can query it
@Entity        // RUNTIME — Hibernate/JPA reads this while the application is running

Static vs Dynamic Binding

Static binding — resolved at compile time

public class Formatter {
    // Overloading — the compiler picks the matching signature
    public void print(String s) { System.out.println("String: " + s); }
    public void print(Integer i) { System.out.println("Integer: " + i); }

    public static void staticMethod() { }    // bound at compile time
    private void privateMethod() { }    // bound at compile time — can't be overridden at all
    public final void finalMethod() { }   // bound at compile time — the compiler knows no override can exist
}

Dynamic binding — resolved at runtime

class Animal {
    public void speak() { System.out.println("Animal speaks"); }
}

class Dog extends Animal {
    @Override
    public void speak() { System.out.println("Dog barks"); }
}

Animal animal = new Dog();
animal.speak();   // prints "Dog barks" — the ACTUAL runtime type decides which override runs,
                  // regardless of the compile-time declared type (Animal)
Sealed classes narrow this gap — partially back into compile time

A switch over a sealed hierarchy (Java 17+) lets the compiler verify exhaustiveness — every possible subtype is known at compile time, so a missing case is now a compile error rather than a silent runtime gap. Dynamic dispatch still happens at runtime exactly as shown above; what sealed classes add is compile-time certainty about the complete set of types that dispatch could ever resolve to.

Reflection — Runtime Introspection of Compile-Time Structure

Class<?> clazz = PromotionEngine.class;

// Discover methods at runtime that were fixed at compile time
Method[] methods = clazz.getDeclaredMethods();

// Create an instance without a compile-time `new` expression
Object instance = clazz.getDeclaredConstructor().newInstance();

// Invoke a method resolved entirely by name, at runtime
Method apply = clazz.getMethod("applyDiscount", Order.class);
apply.invoke(instance, order);

// Read a RUNTIME-retention annotation — SOURCE/CLASS-retention ones are invisible here
if (clazz.isAnnotationPresent(AuditLogged.class)) {
    AuditLogged ann = clazz.getAnnotation(AuditLogged.class);
}

Reflection is exactly why the Section 0 example bypasses generic type safety: Method.invoke() accepts an Object, so the compile-time type check that would have stopped skus.add(12345) directly never gets a chance to run at all.

Practical Comparison

AspectCompile timeRuntime
Type checkingGenerics, method signaturesinstanceof, explicit casts
Method resolutionOverloading, static/private/final methodsOverriding (dynamic dispatch)
Typical errorsSyntax errors, type mismatchesNullPointerException, ArrayIndexOutOfBoundsException
OptimizationConstant folding, dead code eliminationJIT compilation, method inlining
Information availableFull source, full generic type parametersBytecode only — generics erased

Best Practices and Common Pitfalls

✅ Do

  • Add an explicit runtime type check at any boundary where reflection, raw types, or an unchecked cast can bypass the compiler's generic type safety
  • Recompile every caller after changing a compile-time constant's value — updating only the constant's own class silently doesn't propagate
  • Use @Retention(RUNTIME) only for annotations something genuinely needs to read via reflection — every other case can use SOURCE or the default CLASS
  • Prefer sealed hierarchies with exhaustive switch when you want a missing case to be a compile error instead of a runtime gap

❌ Don't

  • Don't assume a generic type parameter is available at runtime for anything — reflection, logging, or serialization all only see the erased raw type
  • Don't treat @Deprecated(forRemoval = true) or similar compile-time signals as optional style hints — some are enforced at compile time specifically so they can't be silently ignored
  • Don't assume overloaded methods resolve the way you'd expect with autoboxing or null arguments — overload resolution is a compile-time decision based on the declared, not actual, argument types
  • Don't forget that a checked exception is a compile-time contract — it must be declared or handled, unlike an unchecked one that can silently propagate at runtime

Interview Questions

🎓 Junior level

Q: What's the difference between a compile-time error and a runtime exception?
A compile-time error stops the build entirely — the program never produces a runnable artifact until it's fixed. A runtime exception occurs while the compiled program is already executing, and the compiler had no way to detect it in advance — a null reference, an out-of-bounds array index, a division by zero.

Q: Why can't you check if (list instanceof List<String>)?
Generics are erased at runtime — by the time this check would execute, the JVM only sees a raw List, with no record of what type parameter it was declared with at compile time. There's nothing left to check against, which is why this specific form is a compile error rather than something that would simply always return false.

Q: What determines which overridden method actually runs when you call a method through a superclass reference?
The object's actual runtime type, not its compile-time declared type — this is dynamic binding. Animal a = new Dog(); a.speak(); calls Dog's override, because the JVM dispatches based on what the object actually is at the moment the call executes.

🔥 Senior level

Q: A team changes a public static final int constant in a shared library JAR and redeploys only that JAR. Callers still behave as if the old value is in effect. Explain why, precisely.
A compile-time constant expression is inlined directly into every caller's own bytecode at the moment those callers were compiled — the literal value, not a reference to the constant's class, is what actually ends up in the caller's .class file. Redeploying the updated library changes what a fresh compile would produce, but every already-compiled caller still contains the old literal baked in from its last compilation. The fix requires recompiling and redeploying every affected caller, not just the constant's own module — or, if this coupling is unacceptable, deliberately not declaring the value as a compile-time constant expression (e.g., initializing it via a method call instead of a literal) so callers read it at runtime instead.

Q: Why does invoking a method via reflection bypass the type safety that generics would normally enforce at a direct call site?
Method.invoke(Object target, Object... args) is itself declared using raw Object types — the reflective API has no way to express "this parameter must be a String because that's this specific list's erased-away type argument," since that information genuinely no longer exists in the bytecode by the time reflection operates on it. The compiler's generic type check only ever ran once, at the original compile time, against source code that explicitly called list.add(someString). A reflective call is a completely different code path that was never subject to that specific check, which is exactly why it can insert a value of the wrong type without any compiler or runtime generics error — the resulting ClassCastException only surfaces later, at the point something reads the value back out and implicitly casts it.

Q: Overload resolution is a compile-time decision. What's a concrete case where this produces a surprising result with autoboxing or null?
Given overloads print(Object o) and print(Integer i), calling print(null) resolves to print(Integer) at compile time, because the compiler picks the most specific applicable overload based on the declared parameter types — not based on any runtime value, since there is no runtime value yet at the moment this decision is made. Similarly, given process(int i) and process(Integer i), calling process(someInt) with a primitive always prefers the primitive overload over autoboxing to the wrapper type, because the compiler only autoboxes when no exact or widening match exists among the primitive overloads. Both outcomes are entirely determined before the program ever runs — understanding overload resolution as a purely compile-time algorithm, evaluated once against static, declared types, is what makes these results predictable rather than surprising.