Advanced Java: The JVM, Invariants, and Clean Structure

Students arriving from an imperative module often treat objects as magic containers that simply work. This page removes the magic in three layers: what the machine actually does with your objects (the JVM), how to make objects that cannot be broken (defensive programming), and how to keep systems of objects from congealing into a God class (decomposition).

1. The Operational Reality of the JVM

The stack and the heap

MyObject obj = new MyObject();   // what ACTUALLY happens?

Two allocations in one line, in two different places. new MyObject() allocates the object itself on the heap — the shared, garbage-collected pool. The variable obj lives in the current method's stack frame and holds only a reference — effectively an address. This is why Java is neither "pass by reference" nor naively "pass by value": it is pass-by-value where the value is a reference. Method calls copy the reference, so callee and caller see the same object — but reassigning the parameter inside the method changes nothing outside:

void mutate(List<String> list) { list.add("seen by caller"); }     // same object
void reassign(List<String> list) { list = new ArrayList<>(); }     // caller unaffected

Garbage collection as a system layer

The JVM frees an object when it is unreachable — no chain of references from any live thread's stack, static fields, or JNI roots leads to it. Two practical corollaries: "memory leaks" in Java are reachability leaks (a forgotten reference in a static map or listener list pins whole object graphs), and collection is not destruction on a schedule — finalisers may run late or never, which is why resources (files, sockets) are closed with try-with-resources, not left to the collector.

Bytecode and portability

"Write once, run anywhere" is not marketing; it is an architecture. javac does not emit machine code — it targets a virtual instruction set (bytecode in .class files). At runtime the JVM interprets that bytecode, profiles it, and JIT-compiles the hot paths to native machine code for whatever CPU it finds itself on. You can watch the layers: javap -c MyObject disassembles the bytecode your source became (and see CLI First for why running these tools by hand matters).

2. Defensive Programming & Strong Invariants

Encapsulation is invariant-protection, not field-hiding

Making fields private is the mechanism, not the point. The point is that some statements about an object must be always true — invariants — and the object's methods are the only code trusted to preserve them:

public final class BankAccount {
    private long balancePence;                       // invariant: >= 0

    public void withdraw(long pence) {
        if (pence <= 0) throw new IllegalArgumentException("amount must be positive");
        if (pence > balancePence) throw new InsufficientFundsException(balancePence, pence);
        balancePence -= pence;                       // invariant preserved on every path
    }
}

Validate at the boundary, fail loudly, and every other method can assume the invariant instead of re-checking it — that assumption is what makes the rest of the class simple.

Exceptions: a hierarchy with intent

try {{ ... }} catch (Exception e) {{}} is not error handling; it is evidence destruction. The design rule: checked exceptions for recoverable conditions the caller should plan for (file missing, network down); unchecked exceptions for programming errors (violated precondition, impossible state) — those should crash noisily in development, because the fix is a code change, not a retry. Design small hierarchies (PaymentExceptionCardDeclinedException, GatewayTimeoutException) so callers can catch at the level of specificity they can actually handle.

Immutability: deleting whole bug classes

public record Money(long pence, Currency currency) {
    public Money {
        if (pence < 0) throw new IllegalArgumentException("negative money");
    }
    public Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new CurrencyMismatchException();
        return new Money(pence + other.pence, currency);   // new value, no mutation
    }
}

An immutable object's invariants are checked once, in the constructor, and can never be violated afterwards — no defensive copying, no state corruption, and thread-safety for free (the deep reason, shared mutability, is explored in Concurrency). Modern Java's record makes the pattern one line; use it as your default and reach for mutable classes only with a reason.

3. Structural Decomposition: Beyond God Classes

The beginner failure mode is one massive class holding UI, file I/O and business logic — the God class. Two forces keep structure healthy:

  • High cohesion: a class does exactly one thing well; every field is used by most methods. If you can't name the class without "And" or "Manager", it is probably two classes.
  • Low coupling: classes know as little about each other as possible — depend on interfaces, communicate through narrow contracts, never reach through an object into its internals (order.getCustomer().getAddress().getCity() is a coupling chain, and every link is a reason to break later).

Composition over inheritance is the operational rule: reach for inheritance only for a true, permanent is-a relationship with behavioural substitutability (see the Liskov discussions in SOLID), and for everything else compose — a has-a wired at construction time. Deep inheritance trees are rigid because every subclass inherits every ancestor's decisions forever; composed objects can be rewired per use. The full argument, with the classic Square/Rectangle failure, lives in Inheritance & Composition.

4. Generics and Contracts: The Type System as Ally

Generics: polymorphism checked at compile time

List<String> names = new ArrayList<>();   // interface on the left, type argument checked
names.add("Ada");
String first = names.get(0);               // no cast, no ClassCastException at 2am

Raw types (List without a parameter) push type errors from compile time to run time — the compiler is offering to prove a theorem about your program, and raw types decline the offer. The declaration also shows the interface-first habit: List<String> on the left, implementation on the right, so the implementation can change without touching any caller (expanded in Data Structures as Behavioural Contracts).

Abstract classes vs. interfaces

The decision rule: an interface defines a pure behavioural contract — what you promise, nothing about how (and a class can promise many). An abstract class provides a structural skeleton — shared state and partial implementation for a family of closely-related types (template method pattern in Polymorphism). Since default methods, the practical gap narrowed; the remaining question is simply: is there shared state? No → interface. Yes → abstract class — and check the inheritance is truly is-a.

For all of these ideas pushed to an extreme — interfaces, records, promotion, laziness — see the Numerical Tower case study.