Object-Oriented Programming

Frameworks sit on top of the language model: types, substitution, contracts, and identity. Spring wires interfaces to implementations; JPA maps inheritance trees; HashMap keys depend on equals and hashCode. This chapter is the core of Java—how classes become objects and how hierarchies stay correct.

beginner mid

Classes & objects

A class is a named type that describes fields (state) and methods (behavior). An object is a runtime instance: the JVM allocates heap memory, associates it with a Class<?> metadata object, and runs initialization so invariants hold before your code uses it.

Class anatomy

MemberRole
FieldsState — instance (per object) or static (per class)
ConstructorsEstablish invariants; not inherited; name matches class
MethodsBehavior — instance (receive implicit this) or static
Instance initializer blocks{ ... } — run before each constructor body, in source order
Static initializer blocksstatic { ... } — run once when class is initialized
Java
public class Order {
    private static final AtomicLong ID_GEN = new AtomicLong();
    private final long id;
    private BigDecimal total;

    static {
        // Runs once when Order class is first used (loaded + initialized)
        log.info("Order class initialized");
    }

    {
        // Runs before every constructor on this instance
        if (total == null) total = BigDecimal.ZERO;
    }

    public Order(BigDecimal total) {
        this.id = ID_GEN.incrementAndGet();
        this.total = total;
    }

    public long getId() { return id; }

    public static long peekNextId() {
        return ID_GEN.get();  // no "this" in static context
    }
}

Object creation lifecycle

When you write new Order(total), the JVM performs (conceptually):

  1. Allocation — reserve object memory on the heap (header + fields)
  2. Default field init — numeric 0, boolean false, references null
  3. Superclass chain — repeat allocation/init for each superclass (deepest first)
  4. Instance initializer blocks — top-down in class hierarchy, source order within class
  5. Constructor bodies — after super(...) or this(...) completes in each level

The this keyword

  • Implicit receivertotal inside an instance method means this.total.
  • Disambiguationthis.total = total when parameter shadows a field.
  • Pass current instanceregistry.register(this).
  • Constructor chainingthis(otherArgs) must be the first statement in a constructor.
Java
public class LineItem {
    private final String sku;
    private final int qty;

    public LineItem(String sku) {
        this(sku, 1);  // delegate to full constructor
    }

    public LineItem(String sku, int qty) {
        this.sku = sku;
        this.qty = qty;
    }
}

Static vs instance — when and why

InstanceStatic
Per-object state (order.total)Shared class state (counter, constants)
Polymorphic via overridingResolved at compile time by declaring type
Needs an object to invokeCallable as ClassName.method()
Has thisNo this — cannot touch instance fields directly

Use static for utilities (Math.max), constants, and factories that do not need instance state. Use instance for behavior that depends on object fields or should participate in polymorphism.

⚠️ Pitfall

Mutable static fields are process-wide singletons—race conditions in servers, stale state in tests. Spring and modern style favor injected instance beans over static service locators.

📦 Real World

@Entity classes are instance-centric; JPA manages lifecycle per row. Utility static methods on DTO mappers are fine; domain services should remain testable instance collaborators.

Constructors

Constructors are not ordinary methods: they have no return type, are not inherited, and must complete superclass initialization before the subclass body runs. They are the right place to enforce invariants.

Default constructor behavior

If you write no constructors, the compiler synthesizes a public no-arg constructor that calls super().

If you define any constructor, the default is not generated—callers must use one of your overloads.

If a subclass has no constructors, the compiler adds a no-arg that calls super()—which fails at compile time if the superclass has no accessible no-arg constructor.

Constructor overloading

Same class name, different parameter lists—resolved at compile time (like method overloading).

Java
public class User {
    private final String email;
    private final String displayName;

    public User(String email) {
        this(email, email.substring(0, email.indexOf('@')));
    }

    public User(String email, String displayName) {
        this.email = Objects.requireNonNull(email, "email");
        this.displayName = Objects.requireNonNullElse(displayName, email);
    }
}

Chaining: this() and super()

CallMeaningRule
this(args)Another constructor in same classFirst statement only
super(args)Matching constructor in superclassFirst statement only; if omitted, super() inserted
Java
public class AdminUser extends User {
    private final int level;

    public AdminUser(String email, int level) {
        super(email);              // User(String) must exist
        this.level = level;
    }
}

Copy constructors

Java has no language-level copy constructor. Idioms:

  • Constructor taking same type — delegate with this(other.field)
  • Static factory copyOf / builder
  • For mutable fields (arrays, collections) — defensive copy in constructor and getters
Java
public final class TagSet {
    private final String[] tags;

    public TagSet(String[] tags) {
        this.tags = tags.clone();  // defensive copy on way in
    }

    public TagSet(TagSet other) {
        this(other.tags);          // copy constructor
    }

    public String[] tags() {
        return tags.clone();       // defensive copy on way out
    }
}
💡 Pro Tip

Validate in constructors with Objects.requireNonNull and domain checks—illegal states should be unrepresentable. Records use compact constructors for the same purpose.

Inheritance

extends declares an is-a relationship: a subclass is a specialized superclass. Java allows single class inheritance (one direct superclass) plus multiple interface implementation.

Why not multiple class inheritance?

The diamond problem: if two superclasses define the same method with different behavior, which runs? C++ forces explicit resolution; Java avoids ambiguity by keeping the class hierarchy a tree. Shared behavior belongs in:

  • Composition (has-a) — delegate to a collaborator
  • Default methods on interfaces (with conflict rules)
  • Abstract base classes with one inheritance path
Java
public class Payment {
    protected BigDecimal amount;

    protected void validateAmount() {
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("amount");
        }
    }
}

public class CardPayment extends Payment {
    private final String last4;

    public CardPayment(BigDecimal amount, String last4) {
        this.amount = amount;
        this.last4 = last4;
    }

    @Override
    public String toString() {
        return "Card*" + last4 + " " + amount;
    }

    public void validate() {
        super.validateAmount();
        if (last4 == null || last4.length() != 4) {
            throw new IllegalArgumentException("last4");
        }
    }
}

Method overriding — rules

RuleDetail
Instance methods onlyStatic methods are hidden, not overridden
Same signatureName + parameter types + return type (covariant return allowed)
AccessCannot reduce visibility (public → protected is illegal)
ExceptionsCannot throw broader checked exceptions than super
@OverrideCompiler verifies; catches typos and signature drift

The super keyword

  • super(args) — constructor delegation to superclass
  • super.method() — call superclass implementation from override
  • super.field — access hidden superclass field when subclass shadows it

final — sealing behavior

ModifierEffect
final classNo subclasses (String, many records)
final methodCannot override — base class relies on fixed algorithm
final fieldAssign once (blank final set in constructor)

Sealed classes Java 17+

Restrict which types may extend or implement—documented closed hierarchies for domain modeling and exhaustive switches.

Java
public sealed interface PaymentMethod permits Card, BankTransfer { }

public final class Card implements PaymentMethod {
    private final String last4;
    public Card(String last4) { this.last4 = last4; }
}

public final class BankTransfer implements PaymentMethod {
    private final String iban;
    public BankTransfer(String iban) { this.iban = iban; }
}

// Exhaustive switch (with sealed + final permits)
static String describe(PaymentMethod pm) {
    return switch (pm) {
        case Card c -> "card " + c;
        case BankTransfer b -> "bank " + b;
    };  // no default needed — compiler knows all cases
}

Permitted subclasses must be in the same module (or same package if unnamed module), and must be final, sealed, or non-sealed.

🎯 Interview Tip

Liskov substitution: subtypes must honor the superclass contract—no surprising exceptions or stricter preconditions. Prefer composition when reuse is not a true is-a. Explain interface default method conflicts: class wins over interface; more specific interface wins over general.

Polymorphism

Polymorphism (“many shapes”): one reference type, many runtime types. Call sites depend on abstractions; the JVM selects the correct implementation at runtime for instance methods.

Compile-time: overloading

Same method name, different parameter lists—resolved using compile-time types of arguments. Return type alone does not distinguish overloads.

Java
class Printer {
    void print(int x) { System.out.println("int " + x); }
    void print(long x) { System.out.println("long " + x); }
    void print(String s) { System.out.println("str " + s); }
}

Printer p = new Printer();
p.print(5);      // int — literal int fits int overload
p.print(5L);     // long

Runtime: overriding & dynamic dispatch

The runtime type of the object decides which overridden method runs; the reference type only controls accessible members.

Java
Payment payment = new CardPayment(amount, "4242");
payment.validate();  // CardPayment.validate if declared on Payment

// Static methods — no dynamic dispatch
class Parent { static void greet() { System.out.println("parent"); } }
class Child extends Parent { static void greet() { System.out.println("child"); } }
Parent ref = new Child();
ref.greet();  // "parent" — resolved by reference type Parent

Dynamic dispatch under the hood

HotSpot lays out instance methods in a vtable-like structure per class. Each object header points at class metadata; invokevirtual uses the receiver’s class to index the correct method entry. JIT can devirtualize monomorphic hot calls (inline the only implementation).

Bridge methods: generics erase type parameters; the compiler may synthesize bridge methods so overriding remains JVM-compatible after erasure.

Covariant return types

An overriding method may return a subtype of the superclass return type (Java 5+). Enables fluent subclasses and typed factories.

Java
class Animal {
    Animal reproduce() { return new Animal(); }
}
class Dog extends Animal {
    @Override
    Dog reproduce() { return new Dog(); }  // covariant return
}
🔬 Under the Hood

invokeinterface walks the interface method table (receiver may implement multiple interfaces). invokedynamic backs lambdas (SAM factory + call site linkage). final methods may be called with invokevirtual but are candidates for inlining—no override exists.

📦 Real World

Spring @Autowired UserRepository injects a runtime proxy or impl—callers use the interface type. JPA @Inheritance strategies map polymorphic entities to one or many tables—design cost beyond language polymorphism.

Abstraction — overview

Abstraction hides implementation behind a stable surface. Java offers abstract classes (partial implementation + shared state) and interfaces (capabilities, multiple inheritance of type, evolution via default methods).

Choose abstract classes when subclasses share code and protected state; choose interfaces when you define a role or plug-in boundary implementers may already extend something else.

Abstract classes

When abstract class fitsWhen interface fits better
Shared fields + protected helpersMultiple unrelated classes need same capability
Template method pattern (fixed skeleton, hooks)API evolution without breaking implementers
Single inheritance line with common baseTesting with mocks/stubs (no forced superclass)
Java
public abstract class HttpClientBase {
    protected final Duration timeout;

    protected HttpClientBase(Duration timeout) {
        this.timeout = timeout;
    }

    public final String get(String url) {  // template method — not overridable
        validateUrl(url);
        return doGet(url);
    }

    protected abstract String doGet(String url);

    protected void validateUrl(String url) {
        Objects.requireNonNull(url);
    }
}

Cannot instantiate abstract types directly—subclasses must implement abstract methods (unless they are abstract too).

Interfaces — evolution & design

Timeline of interface features

VersionFeaturePurpose
Java 7 and earlierAbstract methods + public static final constantsPure contract; implementers supply all behavior
Java 8default methods, static methods on interfaceAdd API methods without breaking existing implementers
Java 9private instance methods on interfaceShare code between default methods
Java 17+sealed interfaces with permitsClosed hierarchies (e.g. domain ADTs)
Java
public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    void save(T entity);

    default T findByIdOrThrow(ID id) {
        return findById(id).orElseThrow(() -> notFound(id));
    }

    static <ID> RuntimeException notFound(ID id) {
        return new NoSuchElementException("id=" + id);
    }

    private void audit(String op) {  // Java 9+
        System.out.println(op);
    }
}

Interface segregation in practice

Split large interfaces so clients depend only on methods they use—easier testing and fewer empty stub implementations.

  • Bad: Storage with read, write, delete, compress, encrypt — not every caller needs all
  • Better: Readable, Writable, Deletable — compose where needed
💡 Pro Tip

Adding a method to an interface without default breaks every implementer at compile time. That is why JDK collection helpers moved to default methods on Iterable / Collection in Java 8.

Functional interfaces & SAM types

A Single Abstract Method (SAM) interface can be implemented with a lambda or method reference. @FunctionalInterface is optional but documents intent and triggers compiler checks.

Java
@FunctionalInterface
interface EventHandler {
    void onEvent(String payload);
    // default/static methods OK — still one abstract method
}

EventHandler log = msg -> System.out.println(msg);
list.forEach(log::onEvent);

// Built-in SAMs in java.util.function
Predicate<String> nonBlank = s -> !s.isBlank();
Function<String, Integer> len = String::length;

Lambdas capture effectively final locals; they compile to invokedynamic + synthetic methods, not anonymous inner classes (unless you write those explicitly).

Encapsulation

Hide representation details; expose behavior through a small API. Access modifiers enforce boundaries at compile time; good design minimizes what is public so invariants stay inside the class.

Access modifier scope matrix

ModifierSame classSame packageSubclass (other pkg)Anywhere
private
package-private (default)
protected
public

JavaBeans convention — when getters/setters help vs hurt

JavaBeans: private fields, public getX / setX for tooling (serialization, EL, early frameworks). Still common in DTOs and config properties.

Anti-pattern when:

  • Every field is exposed with no behavior—anemic domain model
  • Setters break invariants (negative amounts, invalid states)
  • Mutable objects leak internal collections (getItems().add(...) bypasses encapsulation)

Prefer constructors, factories, and methods that express intent (activate(), debit(Money)) over blind setters.

Java
public class Account {
    private final String id;
    private BigDecimal balance;

    public Account(String id, BigDecimal opening) {
        this.id = id;
        this.balance = opening;
    }

    public void debit(BigDecimal amount) {
        if (amount.signum() <= 0) throw new IllegalArgumentException();
        balance = balance.subtract(amount);
    }

    public BigDecimal balance() { return balance; }  // accessor, no setter
}

Records Java 16+

Immutable data carriers: compiler generates constructor, accessors, equals, hashCode, toString. Components are private final fields with accessor names matching the component (amount() not getAmount()).

Java
public record Money(BigDecimal amount, Currency currency) {
    public Money {  // compact constructor — validate before fields assigned
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.signum() < 0) throw new IllegalArgumentException("negative");
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException();
        return new Money(amount.add(other.amount), currency);
    }
}
  • Cannot extend other classes (implicitly extend Record)—can implement interfaces
  • Not ideal as JPA entities (mutability, enhancement, lazy proxies)
  • Excellent for DTOs, commands, events, API responses
💡 Pro Tip

For nested mutable components (e.g. byte[]), defensive-copy in compact constructor and accessors—or use immutable types inside the record.

Object class methods

Every class inherits from java.lang.Object (directly or via a superclass). Four methods shape collections, logging, copying, and legacy resource management.

equals() & hashCode() contract

equals contract (must satisfy all)

  • Reflexive: x.equals(x) is true
  • Symmetric: x.equals(y) iff y.equals(x)
  • Transitive: if x.equals(y) and y.equals(z), then x.equals(z)
  • Consistent: repeated calls agree if objects unchanged
  • Non-null: x.equals(null) is false

hashCode contract

  • If x.equals(y), then x.hashCode() == y.hashCode()
  • Need not differ for unequal objects (collisions allowed—but hurt hash table performance)

Always override both together with the same fields. Default Object.equals uses reference identity (==).

Java
public final class UserId {
    private final UUID value;

    public UserId(UUID value) {
        this.value = Objects.requireNonNull(value);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserId that)) return false;
        return value.equals(that.value);
    }

    @Override
    public int hashCode() {
        return value.hashCode();  // or Objects.hash(value)
    }
}

Broken contract consequences

MistakeSymptom
Override equals onlyHashMap / HashSet break—equal objects in different buckets
Use mutable fields in equals/hashCodeKey changes after insert—object “lost” in map
Subclass adds fields without super.equalsSymmetric/transitive violations across types
Compare unrelated classesShould return false; use instanceof pattern
⚠️ Pitfall

Hibernate/JPA entities: persistence identity vs business equality differ—often override equals on business key only when detached, or avoid putting managed entities in HashSet. Lombok @EqualsAndHashCode on entities can surprise you—configure fields explicitly.

🎯 Interview Tip

Explain why new String("a") and new String("a") are equals but may have different identity—and why that matters for keys vs values in maps.

toString(), clone(), finalize()

toString() best practices

Default: className@hexHash — useless in logs. Override for stable, readable diagnostics; include business identifiers, not secrets.

Java
@Override
public String toString() {
    return "Order{id=" + id + ", total=" + total + "}";
}
// Records: compiler generates toString listing components

clone() — why it is broken

  • Cloneable is a marker with no clone method—contract is implicit
  • Default Object.clone() is shallow—shared mutable innards
  • Does not invoke constructors—invariants can be bypassed
  • Declares checked CloneNotSupportedException

Alternatives: copy constructors, static factories, serialization libraries for deep graphs, or immutable structures so copy is cheap.

Java
// Prefer explicit copy
List<String> copy = new ArrayList<>(original);
// Or List.copyOf(original) for unmodifiable snapshot

finalize() — deprecated

Runs (maybe) after object becomes unreachable—unpredictable delay, resurrection possible, no guarantee on crash. Removed for new code; use try-with-resources and AutoCloseable.

Java
try (var in = Files.newInputStream(path)) {
    process(in);
}  // close called automatically — deterministic cleanup