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.
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
| Member | Role |
|---|---|
| Fields | State — instance (per object) or static (per class) |
| Constructors | Establish invariants; not inherited; name matches class |
| Methods | Behavior — instance (receive implicit this) or static |
| Instance initializer blocks | { ... } — run before each constructor body, in source order |
| Static initializer blocks | static { ... } — run once when class is initialized |
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):
- Allocation — reserve object memory on the heap (header + fields)
- Default field init — numeric 0, boolean false, references null
- Superclass chain — repeat allocation/init for each superclass (deepest first)
- Instance initializer blocks — top-down in class hierarchy, source order within class
- Constructor bodies — after super(...) or this(...) completes in each level
Object defaults → Object <clinit> if any
→ Super defaults → Super { blocks } → Super constructor
→ Sub defaults → Sub { blocks } → Sub constructor
→ reference assigned to variable
The this keyword
- Implicit receiver — total inside an instance method means this.total.
- Disambiguation — this.total = total when parameter shadows a field.
- Pass current instance — registry.register(this).
- Constructor chaining — this(otherArgs) must be the first statement in a constructor.
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
| Instance | Static |
|---|---|
Per-object state (order.total) | Shared class state (counter, constants) |
| Polymorphic via overriding | Resolved at compile time by declaring type |
| Needs an object to invoke | Callable as ClassName.method() |
Has this | No 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.
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.
@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).
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()
| Call | Meaning | Rule |
|---|---|---|
this(args) | Another constructor in same class | First statement only |
super(args) | Matching constructor in superclass | First statement only; if omitted, super() inserted |
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
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
}
}
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
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
| Rule | Detail |
|---|---|
| Instance methods only | Static methods are hidden, not overridden |
| Same signature | Name + parameter types + return type (covariant return allowed) |
| Access | Cannot reduce visibility (public → protected is illegal) |
| Exceptions | Cannot throw broader checked exceptions than super |
@Override | Compiler 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
| Modifier | Effect |
|---|---|
final class | No subclasses (String, many records) |
final method | Cannot override — base class relies on fixed algorithm |
final field | Assign 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.
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.
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.
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.
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.
class Animal {
Animal reproduce() { return new Animal(); }
}
class Dog extends Animal {
@Override
Dog reproduce() { return new Dog(); } // covariant return
}
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.
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 fits | When interface fits better |
|---|---|
| Shared fields + protected helpers | Multiple unrelated classes need same capability |
| Template method pattern (fixed skeleton, hooks) | API evolution without breaking implementers |
| Single inheritance line with common base | Testing with mocks/stubs (no forced superclass) |
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
| Version | Feature | Purpose |
|---|---|---|
| Java 7 and earlier | Abstract methods + public static final constants | Pure contract; implementers supply all behavior |
| Java 8 | default methods, static methods on interface | Add API methods without breaking existing implementers |
| Java 9 | private instance methods on interface | Share code between default methods |
| Java 17+ | sealed interfaces with permits | Closed hierarchies (e.g. domain ADTs) |
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:
Storagewith read, write, delete, compress, encrypt — not every caller needs all - Better:
Readable,Writable,Deletable— compose where needed
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.
@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
| Modifier | Same class | Same package | Subclass (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.
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()).
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
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)iffy.equals(x) - Transitive: if
x.equals(y)andy.equals(z), thenx.equals(z) - Consistent: repeated calls agree if objects unchanged
- Non-null:
x.equals(null)is false
hashCode contract
- If
x.equals(y), thenx.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 (==).
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
| Mistake | Symptom |
|---|---|
Override equals only | HashMap / HashSet break—equal objects in different buckets |
Use mutable fields in equals/hashCode | Key changes after insert—object “lost” in map |
Subclass adds fields without super.equals | Symmetric/transitive violations across types |
| Compare unrelated classes | Should return false; use instanceof pattern |
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.
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.
@Override
public String toString() {
return "Order{id=" + id + ", total=" + total + "}";
}
// Records: compiler generates toString listing components
clone() — why it is broken
Cloneableis a marker with noclonemethod—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.
// 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.
try (var in = Files.newInputStream(path)) {
process(in);
} // close called automatically — deterministic cleanup