Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Object-Oriented Programming (OOP)

Java is an OOP language at its core. Everything is an object (except primitives). OOP helps organize complex software by modeling it after real-world entities — but the real value is not “modeling the real world” (a common oversimplification). The real value is managing complexity. OOP gives you tools to hide implementation details (encapsulation), share behavior (inheritance), and write flexible code that works with families of related types (polymorphism). Think of it as building with LEGO: each brick (object) has a well-defined shape (interface), you can snap them together without knowing what is inside, and you can swap one brick for another of the same shape.

1. Classes & Objects

A Class is a blueprint or template. An Object is a specific instance created from that blueprint.
  • Class: Car (Has wheels, engine, color)
  • Object: Tesla Model 3 (Red, Electric)
public class Car {
    // Fields (State): Data stored in the object
    private String brand;
    private int speed;

    // Constructor: Called when creating the object
    public Car(String brand) {
        this.brand = brand;
        this.speed = 0;
    }

    // Methods (Behavior): Actions the object can perform
    public void accelerate(int amount) {
        this.speed += amount;
    }
}

// Usage
Car tesla = new Car("Tesla"); // Create new object on Heap
tesla.accelerate(100);        // Call method

Access Modifiers

Control who can see and use your code. This is Encapsulation.
ModifierClassPackageSubclassWorld
publicYesYesYesYes
protectedYesYesYesNo
defaultYesYesNoNo
privateYesNoNoNo
Best Practice: Always make fields private. Expose them via public methods (getters/setters) only if necessary. This protects your data from invalid states.

2. Inheritance

Inheritance allows a class to acquire properties of another. It promotes code reuse. Java supports single inheritance (a class can extend only one parent).
// Base Class (Superclass)
public class Animal {
    protected String name;
    
    public Animal(String name) {
        this.name = name;
    }
    
    public void makeSound() {
        System.out.println("Generic sound");
    }
}

// Derived Class (Subclass)
public class Dog extends Animal {
    public Dog(String name) {
        super(name); // Call super constructor
    }
    
    @Override // Annotation ensures we are correctly overriding
    public void makeSound() {
        System.out.println("Woof!");
    }
}

3. Polymorphism

Polymorphism (“many forms”) allows you to treat objects of different subclasses as objects of their superclass. This makes your code flexible.
Animal myDog = new Dog("Buddy");
myDog.makeSound(); // Prints "Woof!" 
Even though the variable type is Animal, Java knows at runtime that the actual object is a Dog, so it calls the Dog version of makeSound. This is called Dynamic Dispatch.

Abstract Classes

An abstract class cannot be instantiated. It’s a partial blueprint that forces subclasses to complete the implementation.
public abstract class Shape {
    abstract double area(); // Subclasses MUST implement this
}

4. Interfaces

Interfaces define a contract. They say “I promise to do X”, but they don’t say how. A class can implement multiple interfaces.
interface Flyable {
    void fly();
    
    // Default method (Java 8+): Provides a default implementation
    default void land() {
        System.out.println("Landing...");
    }
}

public class Bird implements Flyable {
    public void fly() {
        System.out.println("Flapping wings");
    }
}

5. Records (Java 14+)

For years, Java developers wrote boilerplate code for classes that just held data (getters, equals, hashCode, toString). Records solve this. They are immutable data carriers. Before (Boilerplate):
class Point {
    private final int x;
    private final int y;
    public Point(int x, int y) { this.x = x; this.y = y; }
    public int x() { return x; }
    public int y() { return y; }
    // ... plus 50 lines of equals, hashCode, toString ...
}
After (Record):
public record Point(int x, int y) {}
That’s it. The compiler generates everything for you. Usage:
Point p = new Point(10, 20);
System.out.println(p.x()); // 10
System.out.println(p);     // Point[x=10, y=20]

6. Sealed Classes (Java 17+)

Sealed classes give you control over your hierarchy. You can specify exactly which classes are allowed to extend your class.
public sealed interface Shape
    permits Circle, Rectangle, Square {
    // Only these classes can implement Shape
}
Why? It allows the compiler to know every possible subtype. This is useful for pattern matching, ensuring you handle every case.

Summary

  • Classes: Encapsulate state and behavior.
  • Inheritance: extends for code reuse.
  • Polymorphism: Treat subclasses as superclasses.
  • Interfaces: Define contracts (implements).
  • Records: Concise, immutable data classes. Use them for DTOs.
  • Sealed Classes: Restricted inheritance hierarchies.
Next, we’ll explore the Collections Framework, where we store and manipulate groups of objects.

Interview Deep-Dive

Strong Answer:
  • When a class defines or inherits virtual methods (in Java, all non-static, non-final, non-private instance methods are virtual by default), the JVM creates a vtable (virtual method table) for that class. The vtable is an array of method pointers, one entry per virtual method. Each object instance contains a hidden pointer (sometimes called the klass pointer) in its object header that points to the class metadata, which includes the vtable.
  • When you write animal.makeSound() where animal is typed as Animal but the actual object is a Dog, the JVM performs these steps at runtime: (1) load the klass pointer from the object header, (2) look up the vtable for the class, (3) index into the vtable at the slot assigned to makeSound(), (4) call the function pointer found there. Because Dog overrides makeSound(), the vtable entry for Dog points to Dog.makeSound() rather than Animal.makeSound(). This is invokevirtual at the bytecode level.
  • The JIT compiler aggressively optimizes this. If profiling data shows that a particular call site always sees Dog objects (monomorphic), the JIT replaces the vtable lookup with a direct call to Dog.makeSound() and inlines the method body. It inserts a type guard check, and if a Cat suddenly appears at that call site, it deoptimizes and falls back to the full vtable dispatch. For bimorphic sites (exactly two types), the JIT can generate an if-else chain. For megamorphic sites (many types), it falls back to the full vtable lookup.
  • The practical implication: polymorphism in Java is not free, but the JIT makes it close to free for the common case. The cost only becomes measurable in tight loops processing millions of objects with many different concrete types at the same call site, which causes megamorphic dispatch and defeats inlining.
Follow-up: What is the difference between invokevirtual, invokeinterface, invokespecial, and invokestatic at the bytecode level?
  • invokestatic calls a static method. No receiver object, no dispatch — the target method is fully resolved at compile time. This is the cheapest call.
  • invokespecial calls constructors (init), private methods, and super.method() calls. The target is known at compile time because these are not subject to overriding. It uses the receiver’s compile-time type, not its runtime type.
  • invokevirtual calls instance methods on classes. It performs vtable dispatch as described above. The vtable index for a given method name is fixed within a class hierarchy, so dispatch is a single array lookup — O(1).
  • invokeinterface calls methods declared in an interface. This is slightly more expensive than invokevirtual because a class can implement multiple interfaces, and the vtable offsets for interface methods are not guaranteed to be at fixed positions across unrelated classes. The JVM uses an itable (interface method table) or a search mechanism. In practice, the JIT optimizes monomorphic and bimorphic invokeinterface sites just as aggressively as invokevirtual, so the difference is only measurable in cold code.
  • There is also invokedynamic (added in Java 7 for JVM-based languages, used heavily by lambdas since Java 8), which defers method linking to runtime via a bootstrap method. The first invocation calls a bootstrap method that returns a CallSite bound to a MethodHandle, and subsequent invocations go through the bound handle. This is how lambdas avoid generating a new class for every lambda expression.
Strong Answer:
  • The rule of thumb is: use inheritance for “is-a” relationships where substitutability makes semantic sense, and use composition for “has-a” relationships or when you want to reuse behavior without coupling to a type hierarchy. In practice, I reach for composition first because inheritance creates tight coupling — the subclass depends on the implementation details of the superclass, not just its interface.
  • The classic real-world problem is the fragile base class. Joshua Bloch describes this in Effective Java with the InstrumentedHashSet example: you extend HashSet and override add() and addAll() to count insertions. But HashSet.addAll() internally calls add() for each element. Your overridden addAll() increments the count, then calls super.addAll(), which calls your overridden add() for each element, double-counting. Your code is correct against the public API contract, but broken because of an implementation detail of the superclass. If the superclass changes its internal delegation pattern in a future release, your subclass silently breaks. This is not hypothetical — I have seen this pattern cause counting and auditing bugs in production systems.
  • The composition solution is a wrapper (decorator) pattern: create a class that holds a Set as a field, delegates all Set methods to it, and adds the counting logic in the wrapper. The wrapper depends only on the Set interface, not on HashSet’s internal implementation. If HashSet changes its internals, the wrapper is unaffected.
  • A broader example from enterprise Java: many legacy codebases built deep inheritance hierarchies for domain entities — BaseEntity to AuditableEntity to TenantEntity to Customer. Any change to BaseEntity risks breaking all descendants. Modern frameworks like Spring prefer composition (inject behaviors via services and interceptors) and flat class hierarchies. The rise of interfaces with default methods in Java 8 further reduced the need for abstract base classes.
Follow-up: Sealed classes (Java 17) let you restrict inheritance. How do they change the composition vs. inheritance calculus?
  • Sealed classes address one of the main objections to inheritance: uncontrolled extension. With sealed, you declare the complete set of permitted subtypes. This gives you the exhaustiveness guarantees of an algebraic data type (the compiler knows all possible subtypes for pattern matching) while keeping the inheritance hierarchy intentionally tight.
  • The key shift is that sealed classes make inheritance appropriate for closed type hierarchies — scenarios like an AST (Abstract Syntax Tree) with sealed interface Expr permits Literal, BinaryOp, UnaryOp, or a state machine with sealed class State permits Running, Paused, Stopped. In these cases, the set of subtypes is part of the design and should not be extended by arbitrary external code.
  • For open extension points (where you want arbitrary third-party implementations), interfaces without sealed remain the right choice. For behavior reuse without type coupling, composition is still better. Sealed classes add a middle ground that did not exist before: controlled inheritance with compiler-verified exhaustiveness.
  • The practical benefit combines with switch pattern matching. When you switch on a sealed type and handle all permitted subtypes, the compiler verifies exhaustiveness — no default case needed, and adding a new subtype forces you to update every switch. This eliminates the “forgot to handle the new case” class of bugs that plagues open inheritance.
Strong Answer:
  • The contract states: if two objects are equal according to equals(), they must return the same hashCode(). The reverse is not required — different objects can have the same hash code (collisions are expected). Additionally, equals() must be reflexive (a.equals(a) is true), symmetric (a.equals(b) implies b.equals(a)), transitive, and consistent across multiple invocations.
  • If you override equals() without overriding hashCode(), objects that are logically equal will have different hash codes (inherited from Object, which returns the memory address or an identity-based hash). This means: you insert new Customer("Alice") into a HashSet, then check set.contains(new Customer("Alice")) and it returns false — even though the customer is logically in the set. The HashSet used the hash code to determine which bucket to check, found a different bucket, and never compared via equals().
  • I have seen this cause a production data loss incident. A caching layer keyed on a custom OrderKey class that overrode equals() but not hashCode() resulted in cache misses on every lookup. The cache was functionally useless — it consumed memory but never returned hits. The system fell through to the database on every request, causing a cascading overload during peak traffic. The fix was a one-line hashCode() override, but the incident cost hours of downtime.
  • Best practice: use your IDE or Lombok’s @EqualsAndHashCode to generate both together, or use Records (which auto-generate correct implementations). If you must implement manually, use Objects.hash(field1, field2) for hashCode() and compare the same fields in equals(). Never include mutable fields in hashCode() — if the hash changes after the object is stored in a HashMap, the object becomes unretrievable (it is in the bucket for the old hash, but lookups use the new hash).
Follow-up: You said never include mutable fields in hashCode. What happens internally in a HashMap when an object’s hash code changes after insertion?
  • A HashMap computes the hash of the key at insertion time and uses it to determine the bucket (array index). The key-value entry is stored in that bucket. When you call get(key), the HashMap computes the hash of the lookup key, goes to the corresponding bucket, and then walks the chain (or tree, in Java 8+) in that bucket, comparing each entry’s key via equals().
  • If you mutate the key object after insertion such that its hashCode() now returns a different value, the entry is still physically in the old bucket, but any get() or contains() call with the mutated key computes the new hash, goes to the new bucket, and finds nothing. The entry is effectively orphaned — it exists in the map, contributes to the size, and is visible during iteration, but is invisible to hash-based lookups.
  • Even remove() will not find it, because remove() also uses the hash to locate the bucket. The orphaned entry will remain until the HashMap is garbage collected. In a long-running server with a HashMap used as a cache, this manifests as a slow memory leak — the map grows but entries are never retrieved or removed. I have diagnosed this with heap dumps showing HashMap$Node entries with keys that do not match any current lookup patterns.
Strong Answer:
  • Before Java 8, the distinction was clear: abstract classes could have state (fields) and implemented methods, while interfaces could only declare method signatures. Java 8 blurred this by adding default methods and static methods to interfaces. Java 9 added private methods to interfaces. Now the practical differences are narrower: abstract classes can have constructors, mutable instance state (fields), and non-public methods, while interfaces can only have public abstract methods, default methods, static methods, private methods, and constants (public static final fields).
  • The decisive factor is single vs. multiple inheritance. A class can extend only one abstract class but implement many interfaces. This means interfaces are the only way to mix in multiple capabilities. If you need a type to be both Serializable and Comparable and Closeable, interfaces are the only option.
  • I choose abstract classes when: there is genuine shared state (fields that subclasses need), the hierarchy represents a true “is-a” relationship with shared implementation that goes beyond default method logic (e.g., a template method pattern with multiple interdependent protected methods), or I need to enforce invariants via a constructor.
  • I choose interfaces when: defining a capability or contract (Flyable, Printable, Repository), building APIs that external code will implement, or when multiple inheritance of type is needed. In modern Java, interfaces with default methods cover 90% of cases that used to require abstract classes.
  • A senior-level nuance: interfaces evolved so much that the remaining reason for abstract classes is essentially mutable state and constructors. If your shared code is stateless, put it in an interface with default methods. If it manages state, you need an abstract class. But be cautious with default methods in interfaces — if two interfaces declare the same default method and a class implements both, you get a compile error that requires manual resolution. This “diamond problem” is Java’s version of the C++ multiple inheritance headache, though Java forces you to deal with it explicitly rather than silently choosing one.
Follow-up: Default methods in interfaces can introduce backward compatibility issues. Can you describe a scenario?
  • Suppose you have an interface Printer in a library (v1) with one abstract method print(). A consumer class MyPrinter implements Printer compiles fine. In v2 of the library, you add a default method printBatch(List items) with a default implementation that calls print() in a loop. This compiles and runs without the consumer changing anything — this is the whole point of default methods, to evolve interfaces without breaking existing implementations.
  • The problem arises if the consumer class already had its own printBatch(List items) method. Now the class has an implicit override of the interface’s default method. This might be fine, or the semantics might diverge — the consumer’s printBatch might do something completely different. The consumer’s code still compiles because their method matches the interface signature, but the behavior changes silently when the interface adds the default method.
  • A worse scenario: if MyPrinter implements Printer, BatchProcessor and both Printer and BatchProcessor add a printBatch() default method in their respective v2 releases, MyPrinter fails to compile with a “class inherits unrelated defaults” error. The consumer did not change any code but now has a compilation failure caused by two upstream libraries independently evolving their interfaces. This is rare but real — it is why library authors should add default methods conservatively.
Strong Answer:
  • Records are final (cannot be extended), their components are implicitly final (immutable), they cannot declare instance fields beyond the record components, and they cannot extend other classes (though they can implement interfaces). The compiler generates equals(), hashCode(), toString(), canonical constructor, and component accessor methods.
  • You should not use records when you need mutability (e.g., a builder pattern or an entity that changes state over its lifecycle), when you need inheritance (records cannot participate in a class hierarchy beyond implementing interfaces), or when you need JPA/Hibernate entities (JPA requires a no-arg constructor and mutable fields for proxying and lazy loading, which records cannot provide — though some JPA providers are adding experimental record support).
  • A common mistake: records give you shallow immutability, not deep immutability. If a record component is a List<String>, the record does not copy or wrap the list. A caller can pass a mutable list, and then mutate it externally, modifying the record’s “immutable” state. Defensive copying in the canonical constructor is required for true immutability: public MyRecord(List<String> items) { this.items = List.copyOf(items); }.
  • Where records excel: DTOs (Data Transfer Objects), API response/request objects, value objects in Domain-Driven Design, map keys (they generate correct equals/hashCode automatically), and pattern matching (records can be deconstructed in pattern matching expressions in Java 21). They eliminate the most tedious boilerplate in Java and make the code’s intent clearer — when you see record, you immediately know this is a transparent, immutable data carrier.
Follow-up: How do records interact with serialization, and are there security considerations?
  • Records have a fundamentally different serialization mechanism than regular classes. When a record is serialized, only the component values are written. When it is deserialized, the canonical constructor is invoked with those values. This is a major security improvement over regular Java serialization, where deserialization bypasses constructors entirely (using sun.misc.Unsafe or ReflectionFactory to create objects without calling any constructor). With regular classes, a malicious serialized payload can create objects in invalid states because validation in the constructor is never executed.
  • Because record deserialization goes through the canonical constructor, any validation logic you put there (range checks, null checks, invariant enforcement) is guaranteed to run on deserialization. This closes a class of deserialization vulnerabilities that have plagued Java for decades.
  • The practical caveat: records are not automatically Serializable. You must explicitly implements Serializable. And the general guidance still applies — Java’s built-in serialization mechanism has enough security concerns that most modern systems use JSON (Jackson, Gson) or Protocol Buffers instead. Records work exceptionally well with Jackson — @JsonProperty annotations on components work as expected, and Jackson can construct records via the canonical constructor.