OOPS Concepts Interview Questions and Answers

Last updated:

Check out 46 of the most common OOPS Concepts interview questions, then take an AI-powered practice interview

JavaC++PythonDesign PatternsSOLID Principles
46+
Questions
18
Basic
18
Intermediate
10
Advanced
Q1

Explain the four pillars of OOP using an order management system rather than definitions.

BasicCore Principles

Answer

Take an e-commerce order. Encapsulation is the Order class owning its items list and its total, and refusing to expose them for direct mutation. There is no public setTotal.

The total is derived, and the only way to change it is addItem or applyCoupon, which keeps the invariant that total always equals the sum of line items minus discount. Hand out an unmodifiable view of the items list, because returning the live ArrayList lets a caller mutate your state behind your back and your total silently goes stale. Abstraction is the PaymentMethod interface with a single charge method.

The checkout code says paymentMethod.charge(amount) and genuinely does not know whether that is UPI, a saved card or a wallet. Inheritance is where most candidates overreach. A reasonable use here is an abstract PaymentMethod holding shared retry and idempotency-key logic, with UpiPayment and CardPayment extending it.

An unreasonable use is making PrepaidOrder extend Order to change a couple of fields, because the subclass then inherits behaviour it must fight. Polymorphism is the payoff: a list of PaymentMethod objects, each charge call dispatching to a different implementation at runtime, and adding NetBankingPayment tomorrow requires zero edits to checkout. Say it in that order and the interviewer hears a designer.

What the panel is probing is whether you can name a consequence for each pillar. If you can finish each one with "and if I did not do this, the thing that breaks is X", you are done.

public final class Order {
    private final List<LineItem> items = new ArrayList<>();
    private BigDecimal discount = BigDecimal.ZERO;

    public void addItem(LineItem item) {
        items.add(item);
    }

    public List<LineItem> getItems() {
        return Collections.unmodifiableList(items); // no leaking the live list
    }

    public BigDecimal total() {   // derived, never stored, never settable
        BigDecimal sum = items.stream()
                .map(LineItem::amount)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        return sum.subtract(discount);
    }
}

interface PaymentMethod { Receipt charge(BigDecimal amount); }

class Checkout {
    Receipt pay(Order order, PaymentMethod method) {
        return method.charge(order.total());  // polymorphic dispatch
    }
}

Key Points

  • Encapsulation protects an invariant, it is not just private fields plus getters
  • Abstraction is the interface the caller codes against
  • Inheritance is for genuinely shared behaviour, not for reusing a few fields
  • Polymorphism is what lets you add a payment type without editing checkout
💡 Pro Tip: Pick one domain, an order or a payment, and use it for the entire OOPS round. Interviewers reward a candidate who keeps deepening one example over one who produces a fresh Animal and Dog for every question.
Q2

What is the difference between encapsulation and abstraction, and why do candidates keep confusing them?

BasicCore Principles

Answer

They get confused because both are described as "hiding", so candidates conclude they are the same thing said twice. The distinction is what you are hiding and from whom. Abstraction hides complexity at the design level.

It answers "what should the caller be allowed to think about?" A JobApplication interface with an apply method is an abstraction: the caller thinks in terms of applying, not in terms of resume parsing, deduplication checks and notification fan-out. Encapsulation hides data at the implementation level.

It answers "who is allowed to change this field, and under what rules?" Making the status field private and only mutating it inside a withdraw method that validates the current state is encapsulation. A sharper way to say it in an interview: abstraction is about the outside view, encapsulation is about protecting the inside.

You can have one without the other, and that is the killer follow-up. A class with every field private and a public getter and setter for each one is fully encapsulated in a syntactic sense and abstracts nothing, because the setters expose the exact internal shape and any caller can drive it into an invalid state. Conversely, a well designed interface abstracts beautifully while the class behind it might have public mutable fields and no encapsulation at all.

Mechanism versus intent is the last piece: encapsulation is enforced by access modifiers, abstraction is achieved through abstract classes and interfaces. Say that and follow up with the getter-and-setter example, because that example is what proves you did not memorise the answer.

Key Points

  • Abstraction hides complexity, encapsulation hides data
  • Abstraction is the outside view, encapsulation protects the inside
  • Access modifiers enforce encapsulation, interfaces and abstract classes deliver abstraction
  • A class with a public setter for every private field encapsulates syntactically but abstracts nothing
Q3

Is a class with private fields and a public getter and setter for each one actually encapsulated?

BasicCore Principles

Answer

No, and this is one of the most useful questions to be asked because it separates people who learned the rule from people who understand the reason. A field that is private but has a public setter is public with extra typing. The setter offers exactly the same power as a public field, and it publishes the internal layout as part of your API, so you can no longer rename or drop that field without breaking callers.

Real encapsulation means the class holds an invariant that no caller can violate. Consider a BankAccount with a balance and a setBalance. Any caller can set a negative balance, and the class has no defence.

Replace setBalance with deposit and withdraw, have withdraw throw InsufficientFundsException when the amount exceeds the balance, and now the invariant balance is greater than or equal to zero is guaranteed by the type itself. That is encapsulation. Two more traps worth naming.

First, a getter that returns a mutable collection or a mutable Date leaks your state, since the caller can mutate the object you handed back. Return an unmodifiable view or a defensive copy. Second, IDE generated getters and setters on a plain data holder are fine when the class genuinely is a data transfer object with no invariant to protect, and pretending otherwise is over-engineering.

The honest answer is: setters are appropriate for DTOs, and a smell on domain objects. Interviewers at product companies specifically listen for the phrase "what invariant does this class protect?"

// Encapsulated only on paper
class BankAccount {
    private double balance;
    public double getBalance() { return balance; }
    public void setBalance(double b) { this.balance = b; }  // negative balance is legal
}

// Actually encapsulated: the invariant is enforced by the type
class BankAccount2 {
    private BigDecimal balance = BigDecimal.ZERO;

    public BigDecimal balance() { return balance; }         // BigDecimal is immutable, safe to hand out

    public void deposit(BigDecimal amount) {
        if (amount.signum() <= 0) throw new IllegalArgumentException("deposit must be positive");
        balance = balance.add(amount);
    }

    public void withdraw(BigDecimal amount) {
        if (amount.compareTo(balance) > 0) throw new InsufficientFundsException();
        balance = balance.subtract(amount);
    }
}

Key Points

  • A public setter gives away the same power as a public field
  • Encapsulation means an invariant the caller cannot break
  • Getters returning mutable collections or dates leak internal state
  • Setters are acceptable on DTOs, a smell on domain objects
Q4

In what exact order do constructors and initializer blocks run in a three level inheritance chain?

BasicObject Lifecycle

Answer

For a chain A, then B extends A, then C extends B, creating a C runs: A static block, B static block, C static block (once ever, at class loading time, in top down order), then A instance initializer blocks and field initializers, then A constructor body, then B instance initializers and field initializers, then B constructor body, then C instance initializers and field initializers, then C constructor body. The rule underneath is that every constructor starts with either this(...) or super(...), and if you write neither, the compiler inserts an implicit no-argument super(). So the constructor calls travel up to Object first, and the bodies then complete on the way back down.

Two consequences interviewers chase. First, if the superclass has no no-argument constructor, the subclass fails to compile unless it explicitly calls super with arguments, which is the classic "constructor X in class A cannot be applied to given types" error. Second, field initializers of a class run after the super constructor body finishes, which is exactly why calling an overridable method from a constructor sees null subclass fields.

Static blocks run at class initialization, which is triggered by the first active use of the class, not necessarily at the first new. Referencing a compile-time constant static final int does not trigger it, and that catches people out. Being able to predict the printed output of a nested initializer puzzle by hand is a standard TCS and Infosys whiteboard exercise.

class A {
    static { System.out.println("A static"); }
    { System.out.println("A instance init"); }
    A() { System.out.println("A ctor"); }
}
class B extends A {
    static { System.out.println("B static"); }
    { System.out.println("B instance init"); }
    B() { System.out.println("B ctor"); }
}
class C extends B {
    static { System.out.println("C static"); }
    { System.out.println("C instance init"); }
    C() { System.out.println("C ctor"); }
}

new C();
// A static
// B static
// C static
// A instance init
// A ctor
// B instance init
// B ctor
// C instance init
// C ctor

Key Points

  • Static blocks run once, top down, at class initialization
  • Every constructor implicitly starts with super() unless you write this() or super(...)
  • Instance initializers and field initializers run before the constructor body of the same class
  • Field initializers run after the super constructor body completes
Q5

What is the difference between this() and super(), and why can only one appear, and only as the first statement?

BasicObject Lifecycle

Answer

this(...) delegates to another constructor in the same class, which is constructor chaining and the standard way to keep default values in one place. super(...) invokes a constructor in the direct superclass. The compiler requires either one to be the first statement of the constructor body, and forbids both in the same constructor. The reason is the object initialization contract: the superclass part of the object must be fully constructed before the subclass touches anything, and there must be exactly one path to that.

If you could write super() twice, or run statements before it, the superclass fields could be observed half initialized. Allowing this(...) works because that delegated constructor will itself eventually reach a super(...), so the chain still terminates at Object exactly once. A useful detail for 2026 candidates: Java 22 finalised flexible constructor bodies, so you can now run statements before super(...) as long as they do not read instance fields or call instance methods, which finally allows argument validation before delegating upward.

Most Indian panels are still on Java 8, 11 or 17, so mention it as an aside rather than as your main answer. Practical guidance for the follow-up: use this(...) to funnel every constructor into one canonical constructor that does all the validation, so you cannot create an object through a side door that skips the checks. And remember that a constructor is not inherited, which is why a subclass must redeclare any constructor signature it wants to offer.

class Job {
    private final String title;
    private final String location;
    private final boolean remote;

    Job(String title) {
        this(title, "Bengaluru", false);       // delegate, do not duplicate defaults
    }

    Job(String title, String location, boolean remote) {  // canonical constructor
        if (title == null || title.isBlank()) throw new IllegalArgumentException("title required");
        this.title = title;
        this.location = location;
        this.remote = remote;
    }
}

class InternshipJob extends Job {
    InternshipJob(String title) {
        super(title);       // must be first; cannot also call this(...)
    }
}

Key Points

  • this() chains within the class, super() goes to the parent
  • Only one, and traditionally only as the first statement
  • Guarantees the superclass part is fully built before the subclass runs
  • Constructors are not inherited, a subclass must declare its own
Q6

Distinguish association, aggregation and composition using a real job portal model, not a UML definition.

BasicInheritance and Composition

Answer

Association is any structural link between two classes with no ownership implied. A Recruiter interviews a Candidate. Neither owns the other, both exist independently, and the relationship may be many to many.

In code it is often just a method parameter or a reference either side can hold. Aggregation is a has-a with shared and independent lifetimes. A Company has a list of Recruiters, but a Recruiter continues to exist if the Company record is deleted, and could in principle be attached to another company.

The container holds references to objects it did not create and does not destroy. Composition is a has-a where the part cannot exist without the whole and the whole controls its lifecycle. An Order has LineItems.

The line items are created by the order, are meaningless outside it, and are deleted with it. In a relational schema that is the difference between a nullable foreign key and a cascade delete. The interview test is the lifecycle question, not the arrow direction.

Ask yourself: if I delete the container, does the part still make sense? If yes it is aggregation, if no it is composition. Two implementation tells worth naming.

Composition usually means the container constructs the parts internally and never exposes a mutable reference to them, since handing out the live list breaks the ownership. Aggregation usually means the parts are passed into the constructor from outside. That is also why aggregation and dependency injection look identical in code.

// Aggregation: recruiters outlive the company object, they are injected
class Company {
    private final List<Recruiter> recruiters;
    Company(List<Recruiter> recruiters) { this.recruiters = recruiters; }
}

// Composition: line items are created by the order and die with it
class Order {
    private final List<LineItem> items = new ArrayList<>();

    void addItem(String sku, int qty, BigDecimal price) {
        items.add(new LineItem(sku, qty, price));  // Order owns construction
    }

    List<LineItem> items() { return List.copyOf(items); }  // no live reference escapes
}

// Association: no ownership either way
class Recruiter {
    void interview(Candidate candidate) { /* ... */ }
}

Key Points

  • Association is a link, no ownership
  • Aggregation is has-a with independent lifetimes, parts are usually injected
  • Composition is has-a with a controlled lifetime, parts are created internally
  • The deciding question is whether the part survives the whole
Q7

What is the difference between compile time and runtime polymorphism, and which one is method overloading?

BasicPolymorphism

Answer

Method overloading is compile time polymorphism, also called static binding or early binding. Several methods share a name in the same class and differ in parameter list. The compiler picks which one to call by looking at the declared types of the arguments at the call site, bakes that choice into the bytecode as a specific method descriptor, and nothing at runtime can change it.

Method overriding is runtime polymorphism, also called dynamic binding or late binding. A subclass supplies a new body for an inherited method signature, and the JVM decides which body to run by looking at the actual class of the object the reference points to, through the virtual method table. The reference type only determines which methods you are allowed to call, the object type determines which implementation runs.

Two things make this a good discriminating question. First, many candidates say overloading is polymorphism at all, and a strict interviewer, especially from a C++ background, may argue it is just name resolution, so acknowledge that debate rather than sounding dogmatic. Second, the interesting follow-up is always what happens when both are involved: the compiler picks the overload using the static type and the JVM then picks the override using the dynamic type, in that order.

Also worth knowing that Java dispatches on exactly one argument, the receiver. Languages with multiple dispatch pick the implementation using all argument types at runtime, which is why the visitor pattern exists in Java at all. In C++ the equivalent knob is the virtual keyword, and a non virtual method is statically bound just like an overload.

class Notifier {
    void send(String message) { System.out.println("string: " + message); }
    void send(Object message) { System.out.println("object: " + message); }
}

Object msg = "interview scheduled";   // static type Object, dynamic type String
new Notifier().send(msg);
// object: interview scheduled      <- overload chosen from the STATIC type

class Base { void run() { System.out.println("base"); } }
class Child extends Base { @Override void run() { System.out.println("child"); } }

Base b = new Child();
b.run();
// child                            <- override chosen from the DYNAMIC type

Key Points

  • Overloading is resolved by the compiler from declared argument types
  • Overriding is resolved by the JVM from the actual object type
  • The reference type gates which methods you may call, the object type decides the body
  • Java dispatches dynamically on the receiver only, not on the arguments
💡 Pro Tip: When asked to define overloading versus overriding, do not stop at the definition. Immediately write the two line example where a reference of type Object holding a String selects the Object overload. That single example usually ends the topic in your favour.
Q8

Why can you not override a static, private or final method, and what is method hiding?

BasicInheritance and Composition

Answer

All three fail for the same underlying reason: overriding requires dynamic dispatch, and none of these participate in it. A private method is not visible to the subclass at all, so a same named method in the child is simply an unrelated new method. Adding @Override on it is a compile error, which is a good reason to always write the annotation.

A final method is explicitly sealed by the author, so the compiler rejects any attempt to redefine it, and that is exactly why String and the wrapper classes are final: security and invariants would collapse if a subclass could redefine them. A static method belongs to the class, not to any instance, so there is no object to dispatch on. Declaring the same static signature in a subclass produces method hiding, not overriding.

Hiding is resolved at compile time from the reference type, so Parent p = new Child(); p.staticMethod(); runs the parent's version, which is the opposite of what overriding would do and is a favourite trick question. Note also that hiding applies to fields too: fields are never polymorphic in Java, so a field with the same name in the child hides the parent's, and which one you get depends on the reference type. The rules around hiding are strict in one direction: a static method can only hide a static method, and an instance method can only override an instance method.

Mixing them is a compile error. The practical advice is to never call a static method through an instance reference, which is what makes the trick possible in the first place.

class Parent {
    static String label() { return "parent static"; }
    String name() { return "parent instance"; }
    String id = "parent field";
}

class Child extends Parent {
    static String label() { return "child static"; }   // HIDES, does not override
    @Override String name() { return "child instance"; }
    String id = "child field";                          // HIDES the field
}

Parent p = new Child();
System.out.println(p.label());  // parent static   <- resolved from reference type
System.out.println(p.name());   // child instance  <- resolved from object type
System.out.println(p.id);       // parent field    <- fields are never polymorphic

Key Points

  • Private is invisible to the subclass, so nothing to override
  • Final is sealed by the author at compile time
  • Static belongs to the class, there is no receiver to dispatch on
  • Same signature static in a child is hiding, resolved from the reference type
  • Fields are hidden too, never overridden
Q9

When do you choose an abstract class over an interface in modern Java?

BasicAbstraction and Interfaces

Answer

The textbook answer, that interfaces cannot have implementations, stopped being true in Java 8. Today both can carry concrete methods, so the decision rests on three things that did not change. State: an abstract class can hold instance fields and a constructor, an interface cannot hold instance state, only public static final constants.

If your shared logic needs to remember something per object, such as a retry counter or an injected clock, you need an abstract class. Inheritance budget: a class extends exactly one class but implements any number of interfaces. Committing your users to their single inheritance slot is a real cost, so if the type is a capability that could sensibly be combined with others, Comparable, Serializable, Auditable, make it an interface.

Constructor control: an abstract class can enforce initialization by requiring constructor arguments and validating them, an interface cannot. Practically, model an interface for the contract and add an abstract skeleton class for implementers who want the shared plumbing, which is the pattern the JDK itself uses with List and AbstractList, or Map and AbstractMap. Two more discriminators worth mentioning.

Interface methods are implicitly public, so you cannot express a protected hook in an interface, and protected template methods are frequently what you want. And an abstract class lets you evolve by adding a concrete method without breaking implementers, which used to be the strongest argument for it and is now weaker because default methods do the same job for interfaces.

interface PaymentGateway {                 // the contract
    Receipt charge(BigDecimal amount, String idempotencyKey);
}

abstract class AbstractRetryingGateway implements PaymentGateway {  // the skeleton
    private final int maxAttempts;             // state: interfaces cannot do this

    protected AbstractRetryingGateway(int maxAttempts) {
        if (maxAttempts < 1) throw new IllegalArgumentException();
        this.maxAttempts = maxAttempts;        // constructor validation
    }

    @Override public Receipt charge(BigDecimal amount, String key) {
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try { return doCharge(amount, key); }
            catch (TransientGatewayException e) { if (attempt == maxAttempts) throw e; }
        }
        throw new IllegalStateException();
    }

    protected abstract Receipt doCharge(BigDecimal amount, String key);  // protected hook
}

Key Points

  • Interfaces cannot hold instance state or a constructor, abstract classes can
  • A class has one superclass but many interfaces, so interfaces cost the user less
  • Interface members are implicitly public, so no protected hooks
  • Common answer: interface for the contract, abstract class for a shared skeleton
💡 Pro Tip: If you answer "an interface cannot have method bodies", a panel on Java 8 or later will immediately correct you and the rest of the round gets harder. Lead with state and the single inheritance slot instead.
Q10

Explain Java's four access levels including default, and how C++ protected inheritance differs.

BasicLanguage Differences

Answer

Java has four levels. private is class only, and note that it is class scoped rather than object scoped, so one Order instance can read another Order instance's private fields, which surprises people and is what makes equals implementable without getters. default, also called package-private, applies when you write no modifier at all: visible to any class in the same package, and invisible outside it even to subclasses. protected means the package plus subclasses in other packages, with a restriction candidates rarely know: from a different package a subclass can access a protected member only through a reference of its own type or a subtype, not through a plain parent reference. public is everywhere. The one that gets asked most is default, because it has no keyword and many candidates forget it exists. Since Java 9 there is a fifth practical layer, module level, where a public class in a package that is not exported by module-info.java is unreachable outside the module.

C++ differs in two ways worth naming. First, the levels are private, protected and public with no package concept, and the default for a class is private while the default for a struct is public. Second, and this is the real difference, C++ applies an access specifier to the inheritance itself. class D : public B keeps B's public members public, class D : protected B demotes B's public members to protected in D, and class D : private B makes them private, which expresses "implemented in terms of" rather than "is a". Java has no equivalent, so composition is the only way to say that.

// Java: private is class scoped, not object scoped
class Money {
    private final long paise;
    Money(long paise) { this.paise = paise; }
    boolean sameAs(Money other) { return this.paise == other.paise; }  // legal
}

// Java: protected across packages is restricted to your own subtype
package b;
import a.Parent;
class Child extends Parent {
    void demo(Parent other, Child sibling) {
        // other.protectedField;    // compile error, not through a Parent reference
        sibling.protectedField = 1; // legal, through Child or a subtype
    }
}

// C++: the inheritance itself carries access
class Engine { public: void start(); };
class Car : private Engine {   // "implemented in terms of", start() is now private in Car
    public: void drive() { start(); }
};

Key Points

  • Four levels: private, default (package-private), protected, public
  • private is class scoped, so peer instances can see each other's fields
  • protected from another package works only through your own type or a subtype
  • C++ adds public, protected and private inheritance, which Java has no equivalent for
Q11

What is the difference between static and instance members, and when does a static block actually run?

BasicObject Lifecycle

Answer

A static member belongs to the class and exists once per classloader, no matter how many objects you create. An instance member exists once per object. Consequences: a static method has no this, so it cannot read instance fields or call instance methods directly, and it cannot be overridden because there is no receiver to dispatch on.

An instance method can freely read static members. A static block is a chunk of code that runs during class initialization, which is a distinct phase from class loading. Initialization is triggered by the first active use: creating an instance, calling a static method, reading or writing a non-constant static field, or reflective access.

It is explicitly not triggered by reading a compile-time constant, so a static final int MAX = 100 read from another class is inlined by the compiler and your static block never runs, which is a genuinely confusing production bug. The JVM guarantees class initialization is thread safe and happens exactly once, and that guarantee is the entire basis of the initialization-on-demand holder idiom for lazy singletons. Interview follow-ups usually go to memory: statics live in the metaspace since Java 8, not in the old permgen, and a static Map used as a cache is a classic memory leak because nothing ever collects it and it pins every key and value for the life of the application. In Python the analogue is a class attribute, and the well known trap there is that a mutable class attribute such as a list is shared across all instances, which Java candidates coming from Python get bitten by.

class IdGenerator {
    private static final AtomicLong COUNTER;
    private final long id;

    static {                       // runs once, on first active use of the class
        System.out.println("class initialized");
        COUNTER = new AtomicLong(1000);
    }

    IdGenerator() { this.id = COUNTER.incrementAndGet(); }
}

class Config {
    static final int MAX = 100;    // compile-time constant, gets INLINED at call sites
    static { System.out.println("Config initialized"); }
}

System.out.println(Config.MAX);
// 100
// (the static block never printed: reading a constant does not initialize the class)

Key Points

  • Static is one per class, instance is one per object
  • Static methods have no this, so no instance access and no overriding
  • Static blocks run once at class initialization, guaranteed thread safe by the JVM
  • Reading a compile-time constant does not trigger initialization
Q12

Is Java pass by value or pass by reference, and what actually happens when you pass an object to a method?

BasicCore Principles

Answer

Java is strictly pass by value, always, with no exception. The confusion is that for a non-primitive the value being copied is a reference, not the object. So the method gets its own copy of the reference, pointing at the same object on the heap.

That produces two behaviours that look contradictory until you say it precisely. Mutating the object through the parameter is visible to the caller, because both references point at the same heap object. Reassigning the parameter to a different object is not visible to the caller, because you only overwrote the callee's copy of the reference.

The classic proof is a swap method: write one that takes two objects and assigns them to each other, and nothing changes at the call site. The same reasoning explains why passing a String or an Integer feels like pass by value: those are immutable, so there is no mutation path, only reassignment, which is local. C++ has real pass by reference through the ampersand reference parameter, and that is a genuinely different mechanism, along with pointers, which are closer to Java's model.

Python behaves like Java and is usually described as pass by object reference or call by sharing: appending to a list argument is visible to the caller, rebinding the name is not. Being able to state this crisply, "the reference is passed by value", is a small thing that reliably impresses services company panels because so many candidates say pass by reference and cannot defend it.

class Candidate { String name; Candidate(String n) { name = n; } }

static void mutate(Candidate c) { c.name = "Rohit"; }     // visible to caller
static void reassign(Candidate c) { c = new Candidate("Neha"); }  // NOT visible

Candidate a = new Candidate("Ananya");
mutate(a);
System.out.println(a.name);   // Rohit
reassign(a);
System.out.println(a.name);   // Rohit   (still, the reassignment was local)

// C++ genuinely passes by reference
// void reassign(Candidate& c) { c = Candidate("Neha"); }  // caller DOES see this

Key Points

  • Java copies the value of the reference, never the object, and never the variable itself
  • Mutation through the parameter is visible, reassignment is not
  • A swap method on objects cannot work in Java
  • C++ has true reference parameters, Python behaves like Java
Q13

What is upcasting versus downcasting, and how do you avoid a ClassCastException?

BasicAbstraction and Interfaces

Answer

Upcasting is assigning a subclass reference to a supertype variable. It is always safe, always implicit, and it is the mechanism polymorphism runs on: PaymentMethod pm = new UpiPayment(). The cost is that the compiler now only lets you call methods declared on PaymentMethod, even though the object is still a UpiPayment underneath and any overridden method still dispatches to the UPI implementation.

Downcasting is the reverse, forcing a supertype reference back to a subtype, and it needs an explicit cast because the compiler cannot prove it is valid. It is checked at runtime, and if the object is not actually of that type you get a ClassCastException, one of the most common runtime failures in legacy Java. Guard it with instanceof, and since Java 16 use pattern matching for instanceof, which tests and binds in one expression and removes the redundant cast entirely.

Java 21 extends this to switch patterns, which is by far the cleanest way to handle a small closed set of subtypes, especially with sealed interfaces where the compiler can then check your switch is exhaustive. The deeper interview point is that frequent downcasting is a design smell. If your code repeatedly asks "which subtype is this?", you have pushed behaviour into the caller that belongs on the type.

The fix is usually to add a polymorphic method to the interface, or apply the visitor pattern when you genuinely cannot modify the hierarchy. Also worth naming: generics were introduced precisely to eliminate the casting that raw collections forced, so a codebase full of casts out of a List is usually pre-generics code.

PaymentMethod pm = new UpiPayment();       // upcast, implicit and always safe

// Old style: test, then cast, then use
if (pm instanceof UpiPayment) {
    UpiPayment upi = (UpiPayment) pm;
    System.out.println(upi.vpa());
}

// Java 16+ pattern matching: test and bind together
if (pm instanceof UpiPayment upi) {
    System.out.println(upi.vpa());
}

// Java 21 switch patterns over a sealed hierarchy, exhaustiveness checked
String describe(PaymentMethod p) {
    return switch (p) {
        case UpiPayment u  -> "UPI " + u.vpa();
        case CardPayment c -> "Card ending " + c.last4();
    };
}

Object o = "hello";
Integer n = (Integer) o;   // compiles, throws ClassCastException at runtime

Key Points

  • Upcasting is implicit and safe, downcasting is explicit and runtime checked
  • instanceof pattern matching (Java 16+) removes the redundant cast
  • Sealed types plus switch patterns give compiler checked exhaustiveness
  • Repeated downcasting means behaviour belongs on the type, not the caller
Q14

What does the default toString print, and what should a production toString contain?

BasicCore Principles

Answer

Object.toString returns getClass().getName() + "@" + Integer.toHexString(hashCode()), so you see something like com.goodspace.Order@1b6d3586. Two facts candidates get wrong here. That hex suffix is the hash code, not a memory address, and since it is usually the identity hash code it is derived from but not equal to any address, and it does not change when the garbage collector moves the object.

And if a class overrides hashCode but not toString, the default toString uses the overridden hashCode, so two equal objects print the identical string and look like the same object in a log, which has genuinely confused debugging sessions. A production toString should include the class name and the fields that identify the object, be cheap, never throw, and never include secrets. That last point matters: toString on a user or a payment object is what ends up in logs and in exception messages, so printing a password, an OTP, a card number or an auth token is a real security incident, and the correct pattern is to mask them.

Also avoid walking a lazily loaded collection in toString, because in a JPA entity that triggers a query or a LazyInitializationException from inside a logging call. Practical points for the follow-up: toString is called implicitly by string concatenation and by println, and null is printed as the literal "null" rather than throwing. Java 16 records generate a sensible toString for you, as do Lombok's @ToString (use exclude for sensitive fields) and IDE generators. In Python the parallel is __str__ for humans and __repr__ for developers, with repr expected to be unambiguous.

class Order { }
System.out.println(new Order());
// com.goodspace.Order@1b6d3586    (class name @ hex of hashCode, NOT an address)

class User {
    private final String email;
    private final String passwordHash;

    @Override public String toString() {
        return "User{email=" + email + ", passwordHash=***}";   // never log the secret
    }
}

// Records give you one free
record Money(BigDecimal amount, String currency) { }
System.out.println(new Money(new BigDecimal("499.00"), "INR"));
// Money[amount=499.00, currency=INR]
💡 Pro Tip: If an interviewer asks what the hex value after the @ is, do not say memory address. Saying "it is the hash code rendered in hex, and by default that is the identity hash code" is a small correction that consistently reads as depth.
Q15

What does the default equals do, and why is comparing two objects with == usually a bug?

BasicObject Lifecycle

Answer

Object.equals is reference identity, literally this == obj. So unless a class overrides it, two distinct objects with identical field values are not equal. == on references compares the references themselves, meaning "is this the same object", not "does this represent the same thing". For primitives == compares values, which is the only place it means what people expect.

The classic demonstration is String. String literals are interned into a shared pool, so "abc" == "abc" is true, while new String("abc") == "abc" is false because the second allocates a fresh object. Candidates who learned == on literals then use it on strings read from a request and get intermittent failures, which is why you always use equals for strings and Objects.equals when either side may be null.

The Integer cache is the same trap in a different costume: values from minus 128 to 127 are cached by Integer.valueOf, so Integer a = 127, b = 127 compares true with == while 128 compares false. That exact snippet is a staple of TCS and Infosys written rounds. The correct rule to state: use == for primitives and for genuine identity checks such as enum comparison or null checks, use equals for value comparison.

Enums are the one reference type where == is preferred, because there is exactly one instance per constant and == additionally gives you null safety and a compile time type check. If you override equals you must override hashCode, which is the natural next question.

String a = "goodspace";
String b = "goodspace";
String c = new String("goodspace");

System.out.println(a == b);        // true   (both from the string pool)
System.out.println(a == c);        // false  (c is a separate object)
System.out.println(a.equals(c));   // true

Integer x = 127, y = 127;
Integer p = 128, q = 128;
System.out.println(x == y);        // true   (Integer cache, -128..127)
System.out.println(p == q);        // false
System.out.println(p.equals(q));   // true

// Null safe comparison
System.out.println(Objects.equals(null, "x"));  // false, no NPE

Key Points

  • Default equals is reference identity
  • == on references asks "same object", equals asks "same value"
  • String pool and the Integer cache from -128 to 127 make == look correct by accident
  • Enums are the one type where == is the preferred comparison
Q16

What does final mean in its three positions, and how are Java sealed classes different?

BasicLanguage Differences

Answer

final on a variable means the binding cannot be reassigned after initialization. It does not make the object immutable: a final List can still have elements added, only the variable cannot be pointed at a different list. A final field must be assigned exactly once, either at declaration, in an instance initializer, or in every constructor path, and that once-only assignment gives you a real concurrency guarantee under the Java Memory Model, namely that another thread which sees a properly published object is guaranteed to see its final fields fully initialized. final on a method means it cannot be overridden, which you use to protect an invariant or a template algorithm. final on a class means it cannot be extended at all, which is why String, Integer and LocalDate are final: immutability and security depend on nobody subclassing them.

Sealed classes and interfaces, finalised in Java 17, sit between open and final. You write sealed interface PaymentMethod permits UpiPayment, CardPayment, and only those named types may implement it, each of which must itself declare final, sealed or non-sealed. The point is a closed hierarchy the compiler knows about, which enables exhaustive switch patterns with no default branch, so adding a third payment type turns every switch that forgot it into a compile error rather than a runtime surprise.

That is the real selling point: final says nobody may extend, sealed says only these may, and the compiler will help you when the list grows. Mention it if the panel is on Java 17 or later, since most enterprise Indian codebases are moving to 17 and 21 now.

final List<String> skills = new ArrayList<>();
skills.add("Java");        // legal, the LIST is mutable
// skills = new ArrayList<>();  // compile error, the VARIABLE is final

final class Money { }       // nobody can extend
class Base { public final void template() { } }   // nobody can override

// Sealed: a closed set the compiler can reason about
sealed interface PaymentMethod permits UpiPayment, CardPayment { }
final class UpiPayment implements PaymentMethod { }
final class CardPayment implements PaymentMethod { }

static String label(PaymentMethod p) {
    return switch (p) {                 // no default needed, exhaustiveness is checked
        case UpiPayment u  -> "UPI";
        case CardPayment c -> "Card";
    };
}

Key Points

  • final variable: no reassignment, the object may still be mutable
  • final method: no overriding; final class: no extending
  • final fields carry a safe publication guarantee under the memory model
  • sealed (Java 17) permits a named set of subtypes and enables exhaustive switches
Q17

How do you make a class truly immutable, and why does one final field not do it?

BasicCore Principles

Answer

There are five requirements and candidates usually name two. Make the class final, or make all constructors private with a static factory, so nobody can subclass it and add mutable state or override a method to return varying values. Make every field private and final.

Do not provide any method that mutates state, and be careful that this includes methods that look harmless like sort or clear on an exposed collection. Defensively copy every mutable object on the way in, in the constructor, so the caller cannot keep a reference to the array or list you stored and mutate it afterwards. Defensively copy or return an unmodifiable view on the way out, so the caller cannot mutate what you hand back.

Points four and five are what people forget, and they are exactly why one final field does not make a class immutable: a final reference to an ArrayList is a mutable object behind a final handle. The payoff is worth stating because it is what the interviewer is checking you understand: immutable objects are automatically thread safe with no synchronisation, they are safe as HashMap keys because their hash cannot drift, they are safe to cache and share, and they cannot be observed in a half constructed state. The cost is allocation on every change, which is why you pair them with a builder for objects with many fields. Java 16 records give you most of this for free, though a record with a List component still needs a compact constructor doing List.copyOf, because records only make the reference final.

public final class Booking {
    private final String id;
    private final List<String> seats;
    private final Date bookedAt;

    public Booking(String id, List<String> seats, Date bookedAt) {
        this.id = id;
        this.seats = List.copyOf(seats);            // defensive copy IN
        this.bookedAt = new Date(bookedAt.getTime());
    }

    public List<String> seats() { return seats; }   // already unmodifiable
    public Date bookedAt() { return new Date(bookedAt.getTime()); }  // copy OUT
}

// Records make the reference final, not the object
record Booking2(String id, List<String> seats) {
    Booking2 {
        seats = List.copyOf(seats);   // compact constructor, still required
    }
}

Key Points

  • Final class, private final fields, no mutators
  • Defensive copy on the way in and on the way out
  • A final reference to a mutable object is not immutability
  • Immutable objects are thread safe and safe as map keys with no extra work
Q18

Why does an interface method have no body historically, and what changed in Java 8 and Java 9?

BasicAbstraction and Interfaces

Answer

Before Java 8, every interface method was implicitly public and abstract, and fields were implicitly public static final. The reason was that an interface was meant to be a pure contract with no implementation and no state, which is what allows a class to implement many of them without the ambiguity that multiple class inheritance creates. The problem this created was interface evolution.

Adding a method to a published interface broke every existing implementer at compile time, which is why the JDK could not extend Collection for years. Java 8 solved it with default methods, a concrete method in an interface that implementers inherit unless they override it. That is exactly how stream(), forEach() and removeIf() were added to the collections API without breaking the world.

Java 8 also allowed static methods in interfaces, which gave a home to factory helpers like Comparator.comparing. Java 9 then added private and private static interface methods, purely so that two default methods could share helper code without exposing that helper as part of the public contract, which was the obvious gap Java 8 left. What did not change is state: an interface still cannot have instance fields, so a default method can only work with the parameters and the other methods of the interface, never with per object state. That single restriction is still the sharpest line between an interface and an abstract class, and it is the answer you give when a panel asks whether default methods made abstract classes obsolete.

interface JobFeed {
    List<Job> fetch();                       // implicitly public abstract

    default List<Job> fetchRemoteOnly() {    // Java 8 default method
        return filterBy(job -> job.isRemote());
    }

    default List<Job> fetchInCity(String city) {
        return filterBy(job -> city.equals(job.city()));
    }

    private List<Job> filterBy(Predicate<Job> p) {   // Java 9 private interface method
        return fetch().stream().filter(p).toList();
    }

    static JobFeed empty() { return List::of; }      // Java 8 static interface method
}

Key Points

  • Pre Java 8: implicitly public abstract methods, public static final fields, no state
  • Java 8 added default and static methods, enabling interface evolution
  • Java 9 added private interface methods for sharing helper code
  • Interfaces still cannot hold instance state, which is the enduring difference
Q19

Why does calling an overridable method from a constructor produce a null field in the subclass?

IntermediateObject Lifecycle

Answer

Because of initialization order. When you create a Child, the Child constructor first calls super(), which runs the Parent constructor to completion. If the Parent constructor calls an overridable method, dynamic dispatch sends it to the Child override, since the object's actual class is already Child from the very start.

But Child's field initializers and constructor body have not run yet, so every field the override touches is still at its default value, null for references and zero for primitives. The failure is usually a NullPointerException in a method that looks obviously safe, and it is intermittent enough in real systems that it wastes hours. It is worse with final fields, because those are assigned once and can be observed as null inside that window, which looks impossible until you know the rule.

The remedies, in order of preference. Make the method final or private so it cannot be overridden and the dispatch is static. Do not call any instance method from a constructor, only static methods and code that touches only this class's own fields.

Move the polymorphic work out of construction into an explicit init or start method the caller invokes after the object is built, or better, use a factory method that constructs then initialises. If you genuinely need subclass supplied configuration, pass it as a constructor parameter rather than fetching it through an overridden getter. The same trap exists in C++ but resolves the other way: during a base class constructor the object is still of the base type, so a virtual call dispatches to the base version, and a pure virtual call is undefined behaviour. Being able to contrast those two is a strong senior signal.

class Parent {
    Parent() { init(); }                 // dispatches to Child.init()
    void init() { }
}

class Child extends Parent {
    private final List<String> tags = new ArrayList<>();
    private String region = "IN";

    @Override void init() {
        System.out.println(region);      // null, not "IN"
        tags.add("new");                 // NullPointerException
    }
}

new Child();
// null
// Exception in thread "main" java.lang.NullPointerException

// Fix: no polymorphic call during construction
class Parent2 {
    protected Parent2() { }
    public final void start() { init(); }   // caller invokes after construction
    protected void init() { }
}

Key Points

  • The object is already the subclass type, so the override runs first
  • Subclass field initializers have not executed, so fields are null or zero
  • Even final fields can be observed as null in this window
  • Fix by making the method final or private, or by moving work to an explicit init
  • C++ does the opposite: base constructors dispatch to the base version
💡 Pro Tip: This question is the single most common intermediate OOPS trap at product companies. If you can also state the C++ contrast, most interviewers stop probing the topic entirely.
Q20

Given overloads taking String and Object, what happens when you pass null, and how does the compiler decide?

IntermediatePolymorphism

Answer

Passing an untyped null selects the most specific applicable overload. String is a subtype of Object, so String is more specific and the String overload wins. Candidates almost always guess Object, or guess ambiguity.

Now change it: add a third overload taking Integer. String and Integer are unrelated types, neither is more specific than the other, so the call becomes ambiguous and does not compile, with "reference to send is ambiguous". That is the full answer, and the rule underneath is Java's method applicability algorithm.

The compiler runs three phases. Phase one considers only applicable methods without boxing, unboxing or varargs, using subtyping and widening primitive conversion. If any method applies in phase one, phases two and three never run.

Phase two allows boxing and unboxing. Phase three allows varargs. Within a phase, if several methods apply, the compiler picks the most specific one, which means the one whose parameter types can all be passed to the others.

If no single most specific method exists, it is an ambiguity error. So the null case resolves in phase one, purely by subtyping. Two practical follow-ups.

Cast the null to disambiguate, send((Object) null), which is the recommended fix and also the fix for the ambiguous three-overload case. And in real code, overloading on types that are related by inheritance, or overloading where one parameter can be null, is a design smell. Give the methods different names. The JDK itself carries a scar here: List.remove(int) removes by index while List.remove(Object) removes by value, and remove on a List of Integer picks the index overload.

static void send(String s) { System.out.println("String"); }
static void send(Object o) { System.out.println("Object"); }

send(null);              // String   <- most specific applicable type wins
send((Object) null);     // Object   <- cast to disambiguate

// Add an unrelated third overload and it stops compiling
static void send(Integer i) { }
// send(null);           // error: reference to send is ambiguous

// The JDK's own overloading scar
List<Integer> ids = new ArrayList<>(List.of(10, 20, 30));
ids.remove(1);                    // remove(int index)    -> removes 20
ids.remove(Integer.valueOf(10));  // remove(Object)       -> removes the value 10

Key Points

  • null picks the most specific applicable overload
  • Two unrelated candidate types make the call ambiguous at compile time
  • Resolution runs in three phases: no boxing, then boxing, then varargs
  • Cast the null to disambiguate, or avoid overloading on related types
Q21

In what order does Java consider widening, autoboxing and varargs during overload resolution?

IntermediatePolymorphism

Answer

Strictly widening first, then autoboxing, then varargs, and the compiler stops at the first phase in which any candidate applies. So given three overloads accepting long, Integer and int varargs, calling with an int argument selects the long version, because widening int to long is possible in phase one and phase one wins outright. Remove the long overload and the Integer version is selected, since boxing is phase two.

Remove that too and the varargs version is finally chosen in phase three. This ordering exists for backward compatibility: autoboxing and varargs arrived in Java 5, and the rule guarantees that code written before Java 5 keeps resolving exactly as it always did. Two extra rules complete the picture.

Widening and boxing cannot be combined in a single step in the direction you might expect: an int argument will not become a Long, because that requires widening then boxing, and the language does not allow that combination. It will however go the other way in a limited sense, since a boxed value can be unboxed and then widened, for example an Integer argument matching a long parameter. And null never matches a primitive parameter, which is why an Integer that is null passed where an int is expected throws a NullPointerException at the unboxing site, a very common production NPE that appears to come from nowhere.

The practical lesson to voice: this is why you should not create overload sets that mix primitives, their wrappers and varargs. The resolution is technically deterministic and practically unreadable, and future readers of your code will get it wrong.

static void f(long x)       { System.out.println("widening"); }
static void f(Integer x)    { System.out.println("boxing"); }
static void f(int... x)     { System.out.println("varargs"); }

f(5);
// widening        (phase 1 wins outright)
// remove f(long)  -> boxing
// remove both     -> varargs

// int will not widen-then-box
static void g(Long x) { }
// g(5);            // compile error: int cannot convert to Long

// But unbox-then-widen is allowed
static void h(long x) { }
Integer boxed = 5;
h(boxed);           // fine

Map<String, Integer> counts = new HashMap<>();
int c = counts.get("missing");   // NullPointerException at the unboxing site

Key Points

  • Phase order is widening, then autoboxing, then varargs
  • The compiler stops at the first phase with an applicable candidate
  • Widening plus boxing in one step is not allowed (int does not become Long)
  • Unboxing a null wrapper into a primitive throws NPE at the call site
Q22

What are covariant return types, and why were they not allowed before Java 5?

IntermediatePolymorphism

Answer

A covariant return type means an overriding method may declare a return type that is a subtype of the one declared in the overridden method. So if Parent declares Parent copy(), Child may declare Child copy(). This is safe because every caller expecting a Parent still gets something that is a Parent, so the substitution principle holds.

The practical benefit is that callers holding a Child reference no longer have to downcast the result, which removes an entire class of ClassCastException. It shows up constantly in builders, prototypes and clone methods, and Object.clone returning Object is exactly the pain covariance fixed. Before Java 5 the JVM matched overrides by exact signature including return type, so a different return type produced an overload rather than an override, and the compiler rejected it as a clash.

Java 5 changed the compiler to accept it and to generate a synthetic bridge method with the original erased signature, which simply delegates to your real method. That bridge is also how generics preserve overriding under erasure, so the mechanisms are the same one. Three limits are worth stating.

Covariance applies to reference types only, so int cannot narrow to short. Parameter types are not covariant: changing a parameter type in the child creates an overload, not an override, which is the single most common accidental non-override in Java and precisely what the @Override annotation exists to catch. And Java does not support contravariant parameters at all, even though that would be the theoretically safe direction, because the JVM dispatch model is built on exact parameter signatures.

class Document {
    Document copy() { return new Document(); }
}

class Resume extends Document {
    @Override Resume copy() { return new Resume(); }   // covariant return, legal since Java 5
}

Resume r = new Resume().copy();   // no cast needed

// Parameters are NOT covariant: this is an overload, not an override
class Base { void handle(Object o) { } }
class Sub extends Base {
    // @Override                    // compile error, nothing to override
    void handle(String s) { }       // silently a NEW method without the annotation
}

Base b = new Sub();
b.handle("text");   // calls Base.handle(Object), which surprises everyone

Key Points

  • Return type may narrow to a subtype in the override, since Java 5
  • Implemented with a compiler generated synthetic bridge method
  • Removes downcasts in builder, clone and prototype style APIs
  • Parameter types are not covariant, changing one creates an overload not an override
💡 Pro Tip: Always write @Override. It costs nothing and turns the silent overload bug in the second half of this example into a compile error, which is a habit interviewers explicitly look for.
Q23

What are the exact rules for a valid override regarding access modifiers and checked exceptions?

IntermediateInheritance and Composition

Answer

The signature must match exactly, same name and same parameter types after erasure. The return type must be identical or a subtype, which is covariance. Access may be widened but never narrowed: a public method can be overridden as public only, a protected method as protected or public, a package-private method as package-private, protected or public.

Narrowing is forbidden because a caller holding a supertype reference must still be able to call the method, and letting a subclass make it private would break that guarantee at runtime. Checked exceptions may be narrowed or removed but never broadened. An override may throw a subclass of a declared exception, fewer exceptions, or none at all, but it may not add a new checked exception the parent did not declare.

The reason is symmetrical: a caller who wrote try-catch based on the parent's declaration must still catch everything that can actually be thrown. Unchecked exceptions, meaning RuntimeException and Error and their subclasses, are completely unconstrained, and an override can throw any of them regardless of the parent's throws clause, which is often how a badly behaved implementation slips through. Two more rules.

A method declared final or static cannot be overridden, and a synchronized or strictfp modifier on the parent carries no obligation, since those are implementation details rather than contract. And the throws clause is a compile time contract only, since the JVM does not enforce checked exceptions at all, which is exactly why Kotlin and Scala can ignore them and still interoperate with Java. Reciting these six rules accurately is a very common services company round question.

class Repository {
    protected Order find(String id) throws IOException { return null; }
}

class CachingRepository extends Repository {
    @Override
    public Order find(String id) throws FileNotFoundException {  // legal
        return null;
    }
    // public   : widened access, allowed
    // FileNotFoundException : subclass of IOException, narrowed, allowed
}

class BadRepository extends Repository {
    // @Override private Order find(String id) { }             // narrowing access: error
    // @Override protected Order find(String id) throws Exception { }  // broader checked: error

    @Override protected Order find(String id) {
        throw new IllegalStateException("unchecked is always allowed");
    }
}

Key Points

  • Same signature, return type identical or a subtype
  • Access may widen, never narrow
  • Checked exceptions may narrow or disappear, never broaden
  • Unchecked exceptions are unconstrained by the throws clause
  • final and static methods cannot be overridden at all
Q24

Show a concrete bug caused by shallow copy, and explain copy constructor versus clone.

IntermediateObject Lifecycle

Answer

A shallow copy duplicates the object's fields, but for reference fields it copies the reference, so the copy and the original share the same nested objects. The classic production bug: an application clones an Order to build a draft revision, edits the draft's line items, and the customer's confirmed order changes too, because both Orders point at the same ArrayList. A deep copy recursively duplicates the nested objects so the two graphs are independent.

Object.clone is the JDK's shallow copy, and it is widely considered broken. It requires implementing the empty marker interface Cloneable or you get CloneNotSupportedException at runtime, it is a protected method so you must override it just to make it callable, it bypasses constructors entirely so your invariants and final field assignments never run, and final fields cannot be reassigned in clone, which makes deep copying a class with final collections effectively impossible. Josh Bloch's advice, which most Indian product panels know, is to prefer a copy constructor or a static copy factory.

Those are ordinary code, they can take an interface type so you can convert between implementations, they work with final fields, and they do not force Cloneable on subclasses. For deep copies of large graphs, options are a hand written recursive copy constructor, serialization round tripping, which is slow and requires everything to be Serializable, or in modern code making the objects immutable so copying stops being a question at all. That last point is the strongest answer: immutability eliminates the entire shallow-versus-deep decision.

class Order implements Cloneable {
    List<String> items = new ArrayList<>();
    @Override protected Order clone() throws CloneNotSupportedException {
        return (Order) super.clone();      // SHALLOW: shares the items list
    }
}

Order original = new Order();
original.items.add("laptop");
Order draft = original.clone();
draft.items.add("mouse");

System.out.println(original.items);   // [laptop, mouse]   <- the bug
System.out.println(original.items == draft.items);  // true

// Copy constructor: explicit, works with final fields, no Cloneable
class Order2 {
    private final List<String> items;
    Order2(List<String> items) { this.items = new ArrayList<>(items); }
    Order2(Order2 other) { this(other.items); }   // deep enough: new list, immutable elements
}

Key Points

  • Shallow copy shares nested mutable objects, deep copy duplicates them
  • clone bypasses constructors and cannot assign final fields
  • Cloneable is a marker interface, clone is protected on Object
  • Prefer a copy constructor or static copy factory
  • Immutable objects make the whole question disappear
Q25

What exactly breaks in a HashMap when you violate the equals and hashCode contract?

IntermediateObject Lifecycle

Answer

The contract has three clauses. Equal objects must have equal hash codes. Unequal objects may share a hash code, that is just a collision.

And hashCode must be consistent for as long as the object is used as a key. HashMap works by taking the key's hashCode, spreading it, and reducing it to a bucket index, then comparing within the bucket using equals. So the failure modes follow directly.

If you override equals but not hashCode, two equal keys almost certainly land in different buckets, and get returns null for a key you just put. This is the most common Java bug of all time and is caught immediately by any panel that asks the question. If you override hashCode but not equals, lookups still fail because the bucket scan uses equals, which is still identity.

The subtler failure is a mutable key. Put an object in a HashMap, then mutate a field that participates in hashCode, and the object is now stored in a bucket that no longer matches its hash. The key is unreachable through get, unreachable through containsKey, will not be removed by remove, yet it still shows up when you iterate the map and it still occupies memory.

That is a genuine memory leak plus a phantom entry, and it is the answer that distinguishes a strong candidate. HashSet is HashMap underneath, so it fails identically. The rules to state at the end: use only immutable fields in hashCode, prefer Objects.hash and Objects.equals or generate the pair together, never use one without the other, and prefer records when the class is a value type since they generate both correctly.

class UserKey {
    String email;
    UserKey(String e) { email = e; }
    @Override public boolean equals(Object o) {
        return o instanceof UserKey u && u.email.equals(email);
    }
    @Override public int hashCode() { return Objects.hash(email); }
}

Map<UserKey, String> map = new HashMap<>();
UserKey key = new UserKey("a@x.com");
map.put(key, "Ananya");

key.email = "b@x.com";           // mutate a field used by hashCode

System.out.println(map.get(key));        // null      (wrong bucket now)
System.out.println(map.containsKey(key)); // false
System.out.println(map.size());          // 1         (still there, unreachable)
map.remove(key);
System.out.println(map.size());          // 1         (remove also failed)

Key Points

  • Equal objects must return equal hash codes; the reverse is not required
  • equals without hashCode means get returns null for a key you just inserted
  • Mutating a key field used by hashCode strands the entry: unreachable but still counted
  • Use only immutable fields in hashCode, or use a record
💡 Pro Tip: Everyone knows "override both together". The mutable key story, unreachable through get but still counted by size, is the part almost nobody volunteers, and it is what turns a pass into a strong pass.
Q26

What is the difference between Comparable and Comparator, and what happens when compareTo disagrees with equals?

IntermediateObject Lifecycle

Answer

Comparable is the natural ordering, implemented by the class itself as compareTo, and there can be only one. Comparator is an external ordering, passed to a sort or a sorted collection, and you can have as many as you need. So a Job class might implement Comparable by posting date, while comparators supply orderings by salary, by relevance and by company name.

Since Java 8 you build comparators declaratively with Comparator.comparing, thenComparing, reversed and nullsFirst, which removes most hand written compareTo bugs. The interesting part is the consistency requirement. The contract says compareTo should be consistent with equals, meaning compareTo returns zero exactly when equals returns true.

It is only a should, not a must, and the JDK explicitly warns about it, because sorted collections use compareTo and ignore equals entirely. So if compareTo compares only the salary field, a TreeSet will treat two different jobs with the same salary as duplicates and silently drop the second one, while a HashSet keeps both. Same objects, two collections, different sizes, and no exception anywhere.

TreeMap does the same thing with put, silently overwriting. That is the answer to give, with the BigDecimal example as the JDK's own instance: new BigDecimal("1.0").equals(new BigDecimal("1.00")) is false because scale differs, while compareTo returns zero, so a HashSet of those holds two elements and a TreeSet holds one. Also worth naming: compareTo must be transitive and antisymmetric, and since Java 7 the TimSort implementation actively detects violations and throws IllegalArgumentException with "Comparison method violates its general contract", which is a real production exception people meet after writing a subtraction based comparator that overflows.

record Job(String title, int salaryLpa) implements Comparable<Job> {
    @Override public int compareTo(Job other) {
        return Integer.compare(salaryLpa, other.salaryLpa);   // only salary
    }
}

Job a = new Job("Backend Engineer", 18);
Job b = new Job("Data Engineer", 18);

System.out.println(a.equals(b));              // false, different titles
System.out.println(new HashSet<>(List.of(a, b)).size());  // 2
System.out.println(new TreeSet<>(List.of(a, b)).size());  // 1   <- b silently dropped

// External orderings, no change to the class
List<Job> jobs = new ArrayList<>(List.of(a, b));
jobs.sort(Comparator.comparingInt(Job::salaryLpa).reversed()
                    .thenComparing(Job::title));

// Never do this: subtraction overflows
// (x, y) -> x.value() - y.value();   use Integer.compare instead

Key Points

  • Comparable is one natural ordering inside the class, Comparator is many, outside it
  • Sorted collections use compareTo and ignore equals entirely
  • Inconsistency makes TreeSet and TreeMap silently drop or overwrite entries
  • Use Integer.compare, never subtraction, to avoid overflow and TimSort contract errors
Q27

If a class implements two interfaces with the same default method, what happens, and what is the resolution rule?

IntermediateAbstraction and Interfaces

Answer

It does not compile. Java refuses to guess, and reports "class X inherits unrelated defaults for m() from types A and B". You must override the method in the implementing class, and inside it you can explicitly delegate using the qualified super syntax A.super.m().

That syntax exists solely for this case and is worth writing on the board because very few candidates know it. The full resolution rule has three steps, applied in order. First, a concrete method inherited from a superclass always beats any interface default, which is the class-wins rule and exists so that adding a default method to an interface can never change the behaviour of existing code.

Second, among interfaces, the most specific one wins: if interface B extends A and both define the default, B's version is selected with no error, because B is more derived. Third, if neither rule settles it, meaning two unrelated interfaces, it is a compile error and you must override. That is the whole of Java's answer to the diamond problem for behaviour.

It works because interfaces still cannot carry instance state, so there is never a question of which copy of a field you inherit, only which method body runs, and the compiler can force you to say. C++ has to solve the harder version because it inherits state as well, which is why it needs virtual inheritance. Mention the class-wins rule specifically, because it is the piece that explains why the JDK could safely add default methods to Collection without breaking any existing implementation.

interface Emailer { default void notifyUser() { System.out.println("email"); } }
interface Smser   { default void notifyUser() { System.out.println("sms"); } }

// class Both implements Emailer, Smser { }
// error: class Both inherits unrelated defaults for notifyUser() from types Emailer and Smser

class Both implements Emailer, Smser {
    @Override public void notifyUser() {
        Emailer.super.notifyUser();     // explicit qualified super
        Smser.super.notifyUser();
    }
}

// Rule 2: most specific interface wins, no error
interface Loud extends Emailer { @Override default void notifyUser() { System.out.println("loud email"); } }
class Ok implements Emailer, Loud { }
new Ok().notifyUser();   // loud email

// Rule 1: a superclass concrete method beats any interface default
class Base { public void notifyUser() { System.out.println("base"); } }
class Derived extends Base implements Emailer { }
new Derived().notifyUser();   // base

Key Points

  • Two unrelated defaults is a compile error, you must override
  • Interface.super.method() explicitly delegates to one of them
  • Class wins over interface default, always
  • A more specific interface wins over a less specific one
Q28

Compare multiple inheritance in C++ virtual base classes, Java interfaces, and Python's C3 linearisation.

IntermediateLanguage Differences

Answer

All three are answers to the diamond problem: D inherits from B and C, both of which inherit from A. C++ inherits both state and behaviour, so plain multiple inheritance gives D two separate A subobjects, two copies of every A field, and any unqualified reference to an A member is ambiguous. The fix is virtual inheritance, class B : virtual public A, which makes the compiler create exactly one shared A subobject.

The cost is real: objects gain a virtual base pointer, layout is no longer a simple concatenation, and the most derived class becomes responsible for calling the virtual base's constructor directly, which surprises people since it skips the intermediate classes. Java sidestepped it by allowing multiple inheritance of type and, since Java 8, of behaviour, but never of state. Interfaces have no instance fields, so there is nothing to duplicate, and conflicting default methods are simply rejected at compile time until you override.

Python allows full multiple inheritance and resolves it with C3 linearisation, computed at class creation time and visible as ClassName.__mro__. C3 produces a single deterministic order that preserves each class's own base order and guarantees a class always appears before its ancestors, so A appears exactly once and each class in the chain is initialised once, provided everyone cooperatively calls super().__init__(). If you write B.__init__(self) explicitly instead of super(), you break the chain and A's initialiser can run twice or not at all.

C3 can also fail outright: an inconsistent base order raises TypeError, cannot create a consistent method resolution order, at class definition time. The one line summary panels want: C++ inherits state so it needs virtual bases, Java forbids state so it needs no mechanism, Python allows everything and resolves it with a deterministic linearisation.

// C++: without virtual, D has TWO A subobjects
class A { public: int x; };
class B : virtual public A { };
class C : virtual public A { };
class D : public B, public C { };   // one shared A thanks to virtual

// Python: C3 linearisation
class A:
    def __init__(self): print("A"); 
class B(A):
    def __init__(self): print("B"); super().__init__()
class C(A):
    def __init__(self): print("C"); super().__init__()
class D(B, C):
    def __init__(self): print("D"); super().__init__()

D()
# D
# B
# C
# A          <- A runs exactly ONCE, thanks to the MRO
print([c.__name__ for c in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']

Key Points

  • C++ duplicates state, virtual inheritance collapses it to one shared subobject
  • Java allows multiple types and behaviours but never state, so no diamond of state exists
  • Python computes a C3 linearisation, visible as __mro__, and runs each class once
  • Python's guarantee depends on every class calling super(), not the base class by name
💡 Pro Tip: When asked "why does Java not support multiple inheritance", the answer is that it does support multiple inheritance of type and behaviour, and only forbids state. Making that correction politely lands very well.
Q29

How does OOP in Python differ from Java in practice: duck typing, private members, __slots__ and dataclasses?

IntermediateLanguage Differences

Answer

Python is dynamically typed, so polymorphism does not require a common base type at all. Duck typing means any object with a charge method can be passed where a payment is expected, and there is no interface to declare. The idiomatic style is EAFP, try the call and handle the exception, rather than Java's LBYL type checks.

When you do want an enforced contract, you use abc.ABC with @abstractmethod, which prevents instantiation of a subclass that has not implemented every abstract method, and typing.Protocol, which gives you structural typing checked statically by mypy without any runtime inheritance relationship. Privacy is a convention, not a rule. A single underscore prefix means "internal, do not touch" and is purely social.

A double underscore triggers name mangling, so __balance inside class Account becomes _Account__balance, which prevents accidental collisions in subclasses but is trivially bypassed and is explicitly not a security feature. Java's private is enforced by the compiler and the verifier. Python also has properties, so you start with a plain public attribute and later add @property and a setter without changing a single caller, which is why Python code does not carry Java's habit of getters and setters everywhere. __slots__ replaces the per instance __dict__ with a fixed set of descriptors, cutting memory substantially for millions of small objects and blocking the addition of new attributes at runtime, at the cost of losing dynamic attributes and complicating multiple inheritance. dataclasses generate __init__, __repr__ and __eq__ from annotated fields, and frozen=True gives you an immutable value object with a working __hash__, which is Python's closest equivalent to a Java record. Note that eq=True with frozen=False sets __hash__ to None, making the object unhashable, which is the same equals-and-hashCode consistency idea enforced by the language instead of by convention.

from dataclasses import dataclass
from abc import ABC, abstractmethod

# Duck typing: no shared base type required
class Upi:  
    def charge(self, amount): return f"upi {amount}"
class Card:
    def charge(self, amount): return f"card {amount}"

def checkout(method, amount): return method.charge(amount)   # works for both

# Enforced contract when you want one
class PaymentMethod(ABC):
    @abstractmethod
    def charge(self, amount): ...

# "Private" is name mangling, not access control
class Account:
    def __init__(self): self.__balance = 100

a = Account()
# a.__balance          -> AttributeError
print(a._Account__balance)   # 100    <- trivially reachable

@dataclass(frozen=True, slots=True)
class Money:
    amount: int
    currency: str = "INR"
# frozen=True gives immutability plus a working __hash__

Key Points

  • Duck typing removes the need for a declared interface, ABC and Protocol add one back
  • Underscore privacy is convention; double underscore is name mangling, not enforcement
  • @property lets a public attribute become computed without breaking callers
  • __slots__ drops the per instance dict for memory, dataclass(frozen=True) is the record equivalent
Q30

Show a concrete refactor from inheritance to composition and explain what you gained.

IntermediateInheritance and Composition

Answer

The classic trigger is a hierarchy that starts multiplying along two independent axes. Say you model job alerts and start with EmailAlert and SmsAlert extending Alert. Then you need immediate versus digest delivery, so you write ImmediateEmailAlert, DigestEmailAlert, ImmediateSmsAlert, DigestSmsAlert.

Add WhatsApp and you have six, add a third axis such as language and you have eighteen. This is the combinatorial explosion that tells you inheritance is modelling the wrong thing: you have two independent variations, and inheritance can only express one. The composition refactor extracts each axis into its own interface, Channel and Schedule, and makes Alert hold one of each.

Now adding WhatsApp is one new class and adding a third axis is one new field, not a multiplication. This is the bridge pattern, and the strategy pattern is the same move applied to a single axis. What you gain concretely: behaviour becomes swappable at runtime instead of fixed at compile time, each piece is independently testable with a stub, you can inject a mock channel in a unit test without subclassing, and you stop inheriting methods you do not want.

What you give up: a little more wiring code, and one extra indirection when reading the call path. The rule to state is that inheritance couples you to the parent's implementation forever and you get all of it, whereas composition lets you take exactly what you need and swap it later. Use inheritance when the relationship is genuinely is-a and the subtype can be substituted everywhere the supertype is used, and use composition for everything else, which in practice is most things.

// Inheritance: classes multiply along every new axis
class ImmediateEmailAlert extends EmailAlert { }
class DigestEmailAlert extends EmailAlert { }
class ImmediateSmsAlert extends SmsAlert { }
class DigestSmsAlert extends SmsAlert { }     // and it keeps doubling

// Composition: one class, two swappable axes
interface Channel  { void deliver(String to, String body); }
interface Schedule { boolean shouldSendNow(Instant last); }

class JobAlert {
    private final Channel channel;
    private final Schedule schedule;

    JobAlert(Channel channel, Schedule schedule) {
        this.channel = channel;
        this.schedule = schedule;
    }

    void fire(String to, String body, Instant last) {
        if (schedule.shouldSendNow(last)) channel.deliver(to, body);
    }
}

new JobAlert(new WhatsAppChannel(), new DigestSchedule());   // new combos, zero new classes

Key Points

  • Two independent axes of variation is the signal to stop using inheritance
  • Inheritance gives you all of the parent, composition gives you only what you wire in
  • Composition allows runtime swapping and easy test doubles
  • This refactor is the bridge pattern, and strategy for the single axis case
💡 Pro Tip: In a low level design round, if you find yourself naming a class with two adjectives, such as ImmediateEmailAlert, stop and extract those adjectives into fields. Interviewers watch for exactly that correction.
Q31

What is the fragile base class problem, and how does it bite in a real codebase?

IntermediateInheritance and Composition

Answer

The fragile base class problem is that a change to a superclass, entirely valid and internal, can silently break subclasses that were correct before. It happens because inheritance exposes implementation details, not just contract. The canonical example is Bloch's InstrumentedHashSet.

You extend HashSet, override add to increment a counter, and override addAll to increment by the collection size and delegate to super.addAll. You add three elements with addAll and the counter says six, because HashSet.addAll happens to be implemented by calling add in a loop, so your override counted each element twice. Your subclass was correct against the documented contract, and it broke because of a self-call the superclass never promised to keep or to avoid.

The second form is the superclass adding a method later. If a future JDK release adds a method to HashSet whose name collides with one you already added in your subclass but with a different return type, your class stops compiling on upgrade. If the signature matches, you have accidentally overridden something and changed behaviour.

This is exactly why the JDK could not extend Collection until default methods arrived. The mitigations. Document self-use explicitly in the base class, which is what the JDK now does with phrases like "this implementation is equivalent to" in Javadoc.

Make methods final unless they are designed to be overridden, and design for inheritance deliberately or prohibit it with a final class. And prefer composition with forwarding, the decorator approach, which is immune to the whole problem because you only see the public interface. The one line answer: inheritance is only safe within a codebase you control, or across a boundary explicitly designed and documented for it.

class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);          // HashSet.addAll internally calls add()
    }

    int getAddCount() { return addCount; }
}

var s = new InstrumentedHashSet<String>();
s.addAll(List.of("a", "b", "c"));
System.out.println(s.getAddCount());   // 6, expected 3

// Composition with forwarding is immune to superclass self-use
class InstrumentedSet<E> implements Set<E> {
    private final Set<E> delegate;
    private int addCount = 0;
    InstrumentedSet(Set<E> delegate) { this.delegate = delegate; }
    @Override public boolean add(E e) { addCount++; return delegate.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return delegate.addAll(c);       // no self-call leaks back into add()
    }
}

Key Points

  • Superclass self-calls are implementation detail that subclasses accidentally depend on
  • A base class adding a method later can break or silently hijack a subclass method
  • Mitigate by documenting self-use, making methods final, or forbidding subclassing
  • Composition with forwarding removes the problem entirely
Q32

Give a real Single Responsibility Principle violation from a codebase and the refactor that fixes it.

IntermediateSOLID and Design

Answer

The violation almost every team has: a User class that holds the user's data, validates its own email format, saves itself to the database, renders itself to JSON, and sends a welcome email. Five responsibilities. The precise statement of SRP is not "a class should do one thing", which is too vague to apply, it is that a class should have only one reason to change, or in Uncle Bob's later phrasing, it should be answerable to one actor.

Here the actors are different: the database team changes the persistence code, the API team changes the serialisation, the marketing team changes the email copy, and compliance changes the validation rules. Four teams touching one file means merge conflicts, an enormous test surface, and a change to email copy risking the persistence logic. The refactor splits by actor: a User entity holding data and enforcing its own invariants, a UserRepository owning persistence, a UserSerializer or a DTO owning the wire format, an EmailValidator owning the rule, and a WelcomeEmailService owning the notification.

Each has one reason to change. Two cautions to voice, because interviewers respect the nuance. SRP applied without judgement produces a hundred single method classes and a codebase you cannot navigate, so cohesion matters as much as separation.

And SRP is about reasons to change, not about line count. A three hundred line class with one coherent responsibility is fine, and a twenty line class doing persistence and formatting is not. In practice the tell for a violation is the word "and" in the class description, or a class that several different teams keep editing.

// Violation: five actors, one class
class User {
    String email;
    boolean isValidEmail() { /* compliance owns this */ }
    void save() { /* DB team owns this */ }
    String toJson() { /* API team owns this */ }
    void sendWelcomeEmail() { /* marketing owns this */ }
}

// Refactored: one reason to change each
record User(UserId id, Email email, String name) { }        // data plus invariants

interface UserRepository { void save(User user); }           // persistence

class UserResponse {                                          // wire format
    static UserResponse from(User user) { /* ... */ }
}

record Email(String value) {                                  // validation lives in the type
    Email {
        if (!value.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"))
            throw new IllegalArgumentException("invalid email");
    }
}

class WelcomeEmailService { void send(User user) { /* ... */ } }   // notification

Key Points

  • SRP means one reason to change, or one actor, not one method
  • The tell is the word "and" in the class description, or many teams editing one file
  • Split by who requests the change, not by line count
  • Over-applied SRP produces unnavigable class explosion, so watch cohesion
Q33

Show an Open Closed Principle violation and the refactor, and explain what closed for modification really means.

IntermediateSOLID and Design

Answer

The violation is the growing switch or if-else chain on a type code. A DiscountCalculator with a switch over customer type, and every new customer tier means editing the same method, retesting it, and risking the existing branches. The same shape appears as a switch over payment type, over file format, over notification channel.

OCP says a module should be open for extension but closed for modification: you should be able to add new behaviour by adding new code, not by editing code that already works and already has tests. The refactor is polymorphism. Define a DiscountPolicy interface with a single method, one implementation per tier, and have the calculator hold a map or receive the policy.

Adding a Platinum tier is one new class plus one registration line, and nothing already tested changes. In a Spring codebase this is nearly free, since injecting a List of an interface type gives you every implementation automatically, so a new class annotated with @Component is picked up without touching any existing file. Two honest qualifications that separate a thoughtful answer from a memorised one.

OCP is not free: you pay in indirection, and applying it speculatively to code that has never changed is over-engineering. The right time to apply it is the second or third time you edit the same switch. And it is a relative property, not an absolute one.

A design is closed against a specific axis of change, here new customer tiers, and open against others. If the change instead is "add a new method every policy must implement", that abstraction does not protect you at all, which is the tension between OCP and interface stability that the Java 8 default method mechanism was invented to relieve.

// Violation: every new tier edits this method
BigDecimal discount(Customer c, BigDecimal amount) {
    switch (c.type()) {
        case REGULAR: return amount.multiply(new BigDecimal("0.00"));
        case SILVER:  return amount.multiply(new BigDecimal("0.05"));
        case GOLD:    return amount.multiply(new BigDecimal("0.10"));
        default:      return BigDecimal.ZERO;
    }
}

// Closed for modification, open for extension
interface DiscountPolicy {
    CustomerType appliesTo();
    BigDecimal discount(BigDecimal amount);
}

class GoldPolicy implements DiscountPolicy {
    public CustomerType appliesTo() { return CustomerType.GOLD; }
    public BigDecimal discount(BigDecimal a) { return a.multiply(new BigDecimal("0.10")); }
}

class DiscountCalculator {
    private final Map<CustomerType, DiscountPolicy> policies;

    DiscountCalculator(List<DiscountPolicy> all) {   // Spring injects every implementation
        this.policies = all.stream()
            .collect(Collectors.toMap(DiscountPolicy::appliesTo, p -> p));
    }

    BigDecimal discount(Customer c, BigDecimal amount) {
        return policies.getOrDefault(c.type(), a -> BigDecimal.ZERO).discount(amount);
    }
}
// Adding PLATINUM: one new class. Nothing existing is edited.

Key Points

  • The smell is a switch or if-else chain that grows with every new type
  • Replace the branch with polymorphism plus a registry or injected list
  • Closed against a named axis of change, not closed absolutely
  • Apply it on the second or third edit, not speculatively
Q34

Explain the Liskov Substitution Principle with the Square extends Rectangle and Penguin extends Bird examples, done properly.

IntermediateSOLID and Design

Answer

LSP says objects of a subtype must be usable anywhere the supertype is expected without the program becoming incorrect. It is a behavioural condition, not a syntactic one, so the compiler cannot check it. The Rectangle and Square case: mathematically a square is a rectangle, so inheritance looks obvious.

But if Rectangle has independent setWidth and setHeight, Square must override them to keep both sides equal. Now any code written against Rectangle, for instance set width to 5, set height to 4, assert area is 20, fails when handed a Square, which reports 16. The subclass did not break a signature, it broke a caller's assumption.

The correct conclusion is not "squares are not rectangles", it is that a mutable Rectangle with independent setters is not a supertype a Square can satisfy. Make both immutable value types with an area method and there is no violation at all, because there is no setter to invalidate. That framing is what separates a good answer: LSP violations are usually caused by mutability and by the supertype promising more than the subtype can deliver.

The Penguin case: Bird has fly, Penguin overrides it to throw UnsupportedOperationException. Any loop calling fly on a list of Birds now crashes. Strengthening a precondition or weakening a postcondition, and throwing a new exception is the strongest form of the latter, breaks substitutability.

The fix is to model the capability rather than the taxonomy: Bird holds what all birds do, and a separate Flyable interface holds fly, implemented by Sparrow but not Penguin. Once you say that out loud, the interviewer usually recognises you have understood ISP too. The rules to recite: a subtype may weaken preconditions, may strengthen postconditions, must preserve invariants, and must not throw new checked exceptions.

// Violation: the subclass breaks a caller's assumption
class Rectangle {
    protected int w, h;
    void setWidth(int w)  { this.w = w; }
    void setHeight(int h) { this.h = h; }
    int area() { return w * h; }
}
class Square extends Rectangle {
    @Override void setWidth(int w)  { this.w = w; this.h = w; }
    @Override void setHeight(int h) { this.w = h; this.h = h; }
}

void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20;      // fails for Square: area is 16
}

// Fix 1: immutable value types, no setter to invalidate
record Rect(int w, int h) { int area() { return w * h; } }
record Sq(int side)       { int area() { return side * side; } }

// Fix 2 (Penguin): model the capability, not the taxonomy
interface Bird    { void eat(); }
interface Flyable { void fly(); }
class Sparrow implements Bird, Flyable { public void eat() { } public void fly() { } }
class Penguin implements Bird          { public void eat() { } }   // no fly to break

Key Points

  • Substitutability is behavioural, the compiler cannot verify it
  • Square breaks Rectangle only because Rectangle is mutable with independent setters
  • Throwing UnsupportedOperationException in an override is a textbook violation
  • Subtypes may weaken preconditions and strengthen postconditions, never the reverse
  • Fix by modelling capabilities as interfaces instead of forcing a taxonomy
💡 Pro Tip: Do not just recite the Square example. Say why it breaks, which is mutability, and give the immutable fix. Panels hear the Square answer twenty times a week and only the explanation of the cause stands out.
Q35

What is an Interface Segregation Principle violation, and how do you spot one in code review?

IntermediateSOLID and Design

Answer

ISP says no client should be forced to depend on methods it does not use. The violation is the fat interface: a Worker interface with work, eat, takeBreak and receiveSalary, implemented by both HumanWorker and RobotWorker, where the robot has to implement eat with an empty body or an UnsupportedOperationException. Or, far more commonly in real Indian enterprise codebases, a JobService interface with forty methods that every consumer must mock in full just to test one call path.

The review tells are concrete and easy to name, which is what the interviewer wants. First, implementations with empty method bodies or methods that throw UnsupportedOperationException, which is also an LSP violation, since the two principles fail together. Second, a test that has to stub eight methods to exercise one.

Third, an interface where different consumers use disjoint subsets of the methods, with no consumer using more than a third of them. Fourth, an interface that keeps growing because every new feature adds a method rather than a new type. The fix is to split by client need rather than by implementation convenience: Workable, Feedable, Payable, and let classes implement the combination that is true for them.

In Java the JDK itself provides both the good and the bad example. Comparable, Runnable and Iterable are minimal and composable. The legacy Collection interface forced optional operations, which is why Arrays.asList returns a list whose add throws UnsupportedOperationException, exactly the smell ISP warns about, kept only for backward compatibility. Small interfaces also make mocking and dependency injection dramatically easier, which is the practical argument that usually lands hardest with a working engineer on the panel.

// Violation: the robot is forced to implement eating
interface Worker {
    void work();
    void eat();
    void receiveSalary();
}

class RobotWorker implements Worker {
    public void work() { }
    public void eat() { throw new UnsupportedOperationException(); }   // the smell
    public void receiveSalary() { }
}

// Segregated by what clients actually need
interface Workable { void work(); }
interface Feedable { void eat(); }
interface Payable  { void receiveSalary(); }

class HumanWorker implements Workable, Feedable, Payable { /* ... */ }
class RobotWorker2 implements Workable { /* ... */ }

// The JDK's own example of the smell, kept for compatibility
List<String> fixed = Arrays.asList("a", "b");
fixed.add("c");   // UnsupportedOperationException at runtime

Key Points

  • Empty method bodies and UnsupportedOperationException are the primary tells
  • A test that must stub eight methods to exercise one is a fat interface
  • Split interfaces by client need, not by implementation convenience
  • ISP violations usually come with LSP violations attached
Q36

How is dependency injection an application of the Dependency Inversion Principle, and are they the same thing?

IntermediateSOLID and Design

Answer

They are not the same thing, and being able to separate them is a strong signal. DIP is a design principle: high level modules should not depend on low level modules, both should depend on abstractions, and abstractions should not depend on details. Dependency injection is one implementation technique that helps you satisfy it.

You can inject a concrete class, which is DI without DIP, and you can satisfy DIP with a service locator or a factory rather than injection. The classic violation: an OrderService that news up a MySqlOrderRepository and a RazorpayGateway directly. The business logic, which is the high level policy, now depends on two low level details.

You cannot unit test it without a real database, you cannot swap gateways without editing the service, and a schema library upgrade forces a recompile of your domain code. The DIP fix is to define OrderRepository and PaymentGateway interfaces, and here is the part candidates miss: those interfaces belong in the high level module, owned by the domain, not in the persistence package. That is what inverts the dependency, the arrow now points from the database code inward to the domain instead of outward.

Then DI supplies the concrete implementation, ideally through the constructor, which makes the dependency explicit, allows the field to be final, and makes the object impossible to construct in an incomplete state. Prefer constructor injection over field injection for exactly those reasons, and because field injection with reflection makes the class untestable without a container. This is also the entire architectural basis of hexagonal architecture and Spring, so a follow-up about ports and adapters is very likely at a product company.

// Violation: high level policy depends on low level details
class OrderService {
    private final MySqlOrderRepository repo = new MySqlOrderRepository();
    private final RazorpayGateway gateway = new RazorpayGateway();
}

// DIP: the abstraction is OWNED by the domain package
package com.goodspace.domain;
public interface OrderRepository { void save(Order order); }
public interface PaymentGateway  { Receipt charge(BigDecimal amount); }

public class OrderService {
    private final OrderRepository repo;
    private final PaymentGateway gateway;

    public OrderService(OrderRepository repo, PaymentGateway gateway) {  // constructor injection
        this.repo = repo;
        this.gateway = gateway;
    }
}

// The detail depends on the abstraction, not the reverse
package com.goodspace.infrastructure;
class MySqlOrderRepository implements com.goodspace.domain.OrderRepository { }

// Test with no database and no network
new OrderService(order -> { }, amount -> Receipt.success());

Key Points

  • DIP is the principle, DI is one technique that helps satisfy it
  • The interface must be owned by the high level module, that is what inverts the arrow
  • Prefer constructor injection: explicit, allows final fields, testable without a container
  • This is the basis of hexagonal architecture and of Spring's design
💡 Pro Tip: Most candidates treat DIP and dependency injection as synonyms. Saying "DI is a technique, DIP is the principle, and you can do either without the other" is a one line answer that reliably raises the level of the conversation.
Q37

Walk through every thread safe Singleton implementation in Java and say which one you would actually ship.

AdvancedDesign Patterns

Answer

Start with what is broken. Lazy initialisation with a plain null check is not thread safe: two threads can both see null and both construct. Making getInstance synchronized fixes correctness but serialises every read forever, which matters when the instance is fetched in a hot path.

Double checked locking is the classic fix, and it is only correct if the field is volatile. Without volatile, the JVM is permitted to reorder the constructor's field writes after the reference assignment, so a second thread can see a non null reference pointing at a partially constructed object. Volatile was not enough before Java 5 because the older memory model did not give the required happens-before guarantee, which is why so many older textbooks call DCL broken outright.

Eager initialisation with a static final field is thread safe for free through the class initialisation guarantee, and it is the right answer whenever construction is cheap. The initialisation-on-demand holder idiom gives you lazy plus thread safe with zero synchronisation, because the JVM only initialises the holder class on first access and guarantees that initialisation is atomic. What I would actually ship is an enum singleton, which Bloch calls the best approach.

It is concise, gives serialisation safety for free since the JVM guarantees enum deserialisation returns the same constant, and it is the only version immune to reflection attacks, because Constructor.newInstance explicitly refuses to instantiate an enum. Every other form can be broken by setAccessible(true) on the private constructor, or by deserialising to a second instance unless you implement readResolve. The senior follow-up is that a singleton is usually a code smell: it is global mutable state, it makes unit tests order dependent, and in a Spring application the container already gives you one instance per context through scope, so you rarely need the pattern at all.

// Broken without volatile: reordering can publish a half built object
class Config {
    private static volatile Config instance;
    private Config() { }
    static Config getInstance() {
        if (instance == null) {                 // no lock on the common path
            synchronized (Config.class) {
                if (instance == null) instance = new Config();
            }
        }
        return instance;
    }
}

// Holder idiom: lazy, thread safe, no synchronisation at all
class Config2 {
    private Config2() { }
    private static class Holder { static final Config2 INSTANCE = new Config2(); }
    static Config2 getInstance() { return Holder.INSTANCE; }
}

// Enum: serialisation safe and reflection proof
enum Config3 {
    INSTANCE;
    private final Properties props = load();
    Properties props() { return props; }
}

// Why the enum wins:
// Constructor<Config3> c = Config3.class.getDeclaredConstructor();
// c.setAccessible(true); c.newInstance();
//   -> IllegalArgumentException: Cannot reflectively create enum objects

Key Points

  • Double checked locking requires volatile, and only works correctly from Java 5 onward
  • Holder idiom gives lazy plus thread safe with no locking
  • Enum singleton is serialisation safe and cannot be broken by reflection
  • Other forms need readResolve to survive deserialisation
  • Singletons are global mutable state, prefer container managed scope
💡 Pro Tip: When asked for a singleton, write the enum version first and then say you know the double checked locking version and why it needs volatile. Leading with the correct answer and then demonstrating the historical one reads far better than the reverse.
Q38

Distinguish Factory Method, Abstract Factory and Builder, and say when you should NOT use each.

AdvancedDesign Patterns

Answer

Factory Method defines an interface for creating one object and lets subclasses or implementations decide the concrete class. You use it when the caller should not know or name the concrete type, for example a PaymentGatewayFactory returning a UpiGateway or a CardGateway based on a request. The related simple factory, a static method with a switch, is not a Gang of Four pattern at all but is what most codebases actually have and is perfectly fine for small closed sets.

Abstract Factory creates families of related objects that must be used together, and the discriminator is families. The real use case is a UI toolkit producing a matching Button, Checkbox and Menu per platform, or in backend work a persistence factory producing a matching Connection, Transaction and Dialect per database. If you only ever create one kind of object, you want a Factory Method and Abstract Factory is over-engineering.

Builder solves a different problem entirely: constructing one complex object step by step, especially when there are many optional parameters. It replaces the telescoping constructor anti-pattern, gives readable named arguments at the call site, and lets you validate the whole object in build() before any invariant is exposed. It is also the standard way to construct an immutable object with fifteen fields.

When not to use them. Do not use a Builder for a class with three fields, since a constructor or a record is clearer. Do not use Abstract Factory unless the objects genuinely vary as a family, otherwise you have doubled your class count for nothing.

Do not use a factory at all when the constructor is sufficient and there is exactly one implementation, which is the most common over-engineering in Indian enterprise codebases: a FooFactory, a FooService, a FooServiceImpl and a FooManager where one class would do. A static factory method with a meaningful name, such as Money.ofRupees, is often the entire improvement you needed.

// Factory Method: one product, caller does not name the concrete type
interface GatewayFactory { PaymentGateway create(PaymentRequest req); }

// Abstract Factory: a FAMILY that must match
interface PersistenceFactory {
    Connection  connection();
    Dialect     dialect();
    Transaction transaction();
}
class PostgresFactory implements PersistenceFactory { /* all three are Postgres flavoured */ }

// Builder: many optional fields, validate once in build()
public final class JobPosting {
    private final String title, city;
    private final Integer minLpa, maxLpa;
    private JobPosting(Builder b) { this.title = b.title; this.city = b.city;
                                    this.minLpa = b.minLpa; this.maxLpa = b.maxLpa; }

    public static class Builder {
        private String title, city = "Bengaluru";
        private Integer minLpa, maxLpa;
        public Builder title(String t) { this.title = t; return this; }
        public Builder city(String c)  { this.city = c;  return this; }
        public Builder salary(int min, int max) { this.minLpa = min; this.maxLpa = max; return this; }
        public JobPosting build() {
            if (title == null) throw new IllegalStateException("title required");
            if (minLpa != null && maxLpa != null && minLpa > maxLpa)
                throw new IllegalStateException("salary range inverted");
            return new JobPosting(this);
        }
    }
}

new JobPosting.Builder().title("Backend Engineer").salary(12, 20).build();

Key Points

  • Factory Method: one product type, concrete class hidden from the caller
  • Abstract Factory: families of products that must be consistent with each other
  • Builder: one complex object with many optional fields, validated in build()
  • Do not use Builder for three fields, or Abstract Factory for a single product
  • A named static factory method is often the whole solution
Q39

Compare Strategy and Observer, and explain the failure modes each introduces in production.

AdvancedDesign Patterns

Answer

Strategy encapsulates interchangeable algorithms behind a common interface so the caller can swap behaviour at runtime. Sorting rules, pricing rules, retry policies, resume ranking algorithms. It is the direct implementation of Open Closed for a single axis of variation, and it is what a growing switch statement should become.

Observer defines a one to many dependency so that when a subject changes state, all its registered dependents are notified. Event buses, UI listeners, domain events like OrderPlaced triggering an invoice, an email and an analytics record. Structurally they look similar, both hold references to interface implementations, but the intent is opposite: Strategy has exactly one collaborator that answers a question, Observer has many collaborators that are told about a fact and whose answers are ignored.

Failure modes are where a senior answer lives. Strategy proliferates classes, and if strategies need different data you end up widening the interface until every strategy ignores half its parameters, which is an ISP violation creeping in. Strategy also cannot easily be composed, so people bolt on a chain of responsibility and the flow becomes hard to trace.

Observer has worse failure modes. The lapsed listener leak: an observer that registers and never unregisters keeps the subject holding a strong reference to it, and in a long lived application that is a genuine memory leak, which is why Swing and Android guidance is full of weak references and explicit removal in lifecycle callbacks. Notification order is unspecified, so observers that depend on each other create heisenbugs.

A synchronous observer that throws can abort the notification loop and leave the remaining observers unnotified, so you wrap each callback. And in a synchronous in-process bus, a slow observer directly slows down the business transaction, which is the usual reason teams move to an asynchronous queue. When not to use Observer: when there is exactly one listener and the flow is a simple call, an event bus just makes the code untraceable.

// Strategy: one collaborator, answers a question
interface RankingStrategy { List<Candidate> rank(List<Candidate> pool, Job job); }

class Matcher {
    private RankingStrategy strategy;
    void setStrategy(RankingStrategy s) { this.strategy = s; }   // swap at runtime
    List<Candidate> shortlist(List<Candidate> pool, Job job) { return strategy.rank(pool, job); }
}

// Observer: many collaborators, told about a fact
interface OrderListener { void onOrderPlaced(Order order); }

class OrderPublisher {
    private final List<OrderListener> listeners = new CopyOnWriteArrayList<>();

    void register(OrderListener l)   { listeners.add(l); }
    void unregister(OrderListener l) { listeners.remove(l); }   // forgetting this leaks

    void publish(Order order) {
        for (OrderListener l : listeners) {
            try { l.onOrderPlaced(order); }
            catch (RuntimeException e) { log.error("listener failed", e); }  // one bad listener must not abort the rest
        }
    }
}

Key Points

  • Strategy swaps one algorithm, Observer notifies many dependents
  • Strategy's risk is interface widening and class proliferation
  • Observer's classic bug is the lapsed listener memory leak
  • Unspecified notification order and a throwing listener both need explicit handling
  • CopyOnWriteArrayList avoids ConcurrentModificationException when a listener unregisters itself
Q40

Adapter, Decorator and Proxy all wrap another object. How do you tell them apart, and when should you not wrap at all?

AdvancedDesign Patterns

Answer

All three hold a reference to a wrapped object and forward calls, so structurally they are near identical and the difference is intent, which is exactly what the interviewer is testing. Adapter changes the interface. You have a class with the wrong shape, usually a third party SDK or a legacy component, and you need it to satisfy an interface your code already uses.

The wrapper's interface is deliberately different from the wrappee's. Decorator keeps the interface identical and adds behaviour, and crucially it can be stacked. BufferedInputStream wrapping a FileInputStream wrapping nothing is the JDK's canonical example, and the compression and encryption wrappers around a payload are the everyday backend one.

Proxy also keeps the interface identical, but controls access rather than adding behaviour: lazy loading, access control, caching, remote invocation, or the transaction and security proxies Spring generates for you at runtime. The one line discriminator to say out loud: Adapter changes the interface, Decorator adds responsibility, Proxy controls access. When not to wrap.

Deep decorator chains are painful to debug, because a stack trace shows six layers of forwarding and you cannot tell which layer changed the value, so keep them shallow and name them clearly. A decorator that has to implement forty methods to add one behaviour will fall out of sync when the interface changes, which is why the JDK gives you abstract forwarding classes. Do not use a proxy when a simple direct call plus a cache lookup is honest and readable.

And beware the Java specific trap: Spring's default proxies mean a self-invocation, one method of a bean calling another annotated method on this, bypasses the proxy entirely, so @Transactional and @Cacheable silently do nothing on that path. That is a real production bug and naming it is a strong senior signal.

// Adapter: the interfaces differ, this one translates
interface OurSmsSender { void send(String phone, String body); }

class TwilioAdapter implements OurSmsSender {
    private final TwilioClient twilio;                      // third party shape
    public void send(String phone, String body) {
        twilio.messages().create(new MessageOptions("+91" + phone, body));
    }
}

// Decorator: same interface, stackable, adds behaviour
class CompressingStore implements PayloadStore {
    private final PayloadStore inner;
    public void put(byte[] data) { inner.put(gzip(data)); }
}
PayloadStore store = new EncryptingStore(new CompressingStore(new S3Store()));

// Proxy: same interface, controls access
class CachingJobRepository implements JobRepository {
    private final JobRepository inner;
    private final Map<String, Job> cache = new ConcurrentHashMap<>();
    public Job find(String id) { return cache.computeIfAbsent(id, inner::find); }
}

// Spring proxy trap: self invocation skips the proxy
class BillingService {
    public void run() { this.charge(); }       // @Transactional on charge() does NOT apply
    @Transactional public void charge() { }
}

Key Points

  • Adapter changes the interface, Decorator adds behaviour, Proxy controls access
  • Decorators stack, adapters and proxies usually do not
  • Deep decorator chains make stack traces unreadable, keep them shallow
  • Spring self-invocation bypasses the proxy, silently disabling @Transactional
Q41

Why is List<String> not a List<Object>, and how do wildcards and PECS fix it?

AdvancedAbstraction and Interfaces

Answer

Because generics are invariant, and they have to be for type safety. If List<String> were assignable to List<Object>, you could add an Integer through the Object typed reference, then read a String out of the original list and get a ClassCastException from code containing no cast. Java arrays actually do allow this, they are covariant, which is a known design mistake: Object[] arr = new String[1]; arr[0] = 1; compiles fine and throws ArrayStoreException at runtime.

Generics chose compile time safety instead, at the cost of flexibility, and wildcards are the mechanism that gives the flexibility back. An upper bounded wildcard, List<? extends Number>, means a list of some unknown subtype of Number. You can read Number from it safely, but you cannot add anything except null, because the compiler does not know whether the concrete list is a List<Integer> or a List<Double>.

A lower bounded wildcard, List<? super Integer>, means a list of Integer or some supertype. You can add Integer safely, but reading gives you only Object. That is PECS: Producer Extends, Consumer Super.

If the parameter produces values you read, use extends; if it consumes values you write, use super; if it does both, use the exact type with no wildcard. Collections.copy(List<? super T> dest, List<? extends T> src) is the canonical signature and worth reciting. The unbounded wildcard List<?> means a list of unknown type, is read only apart from null, and is different from the raw type List, which disables generic checking entirely and lets anything through with an unchecked warning. Underneath all of this is erasure: generic type arguments are removed at compile time, so there is no List<String>.class, you cannot do new T[], you cannot catch a generic exception type, and two overloads differing only in type argument clash because their erasures are identical.

// Arrays are covariant and unsafe
Object[] arr = new String[1];
arr[0] = 42;              // compiles, throws ArrayStoreException at runtime

// Generics are invariant and safe
List<String> names = new ArrayList<>();
// List<Object> objs = names;   // compile error, and that is the point

// PECS: producer extends
double sum(List<? extends Number> producer) {
    double total = 0;
    for (Number n : producer) total += n.doubleValue();   // reading is fine
    // producer.add(1);                                    // compile error
    return total;
}

// PECS: consumer super
void fill(List<? super Integer> consumer) {
    consumer.add(1);                     // writing is fine
    Object o = consumer.get(0);          // reading gives only Object
}

sum(List.of(1, 2, 3));                   // List<Integer> accepted
fill(new ArrayList<Number>());           // List<Number> accepted

// Erasure consequences
// if (list instanceof List<String>) { }   // illegal
// class Box<T> { T[] items = new T[10]; } // illegal

Key Points

  • Invariance exists so a write through a widened reference cannot corrupt the list
  • Arrays are covariant, which is why ArrayStoreException exists
  • PECS: extends when the parameter produces, super when it consumes
  • List<?> is read only and safe, the raw type List disables checking entirely
  • Erasure blocks new T[], generic instanceof, and overloads differing only in type argument
💡 Pro Tip: Recite the Collections.copy signature from memory, copy(List<? super T> dest, List<? extends T> src). It demonstrates PECS in one line and interviewers almost always stop probing generics afterwards.
Q42

What is object slicing in C++, why do you need a virtual destructor, and what do the Java equivalents look like?

AdvancedLanguage Differences

Answer

Object slicing happens when you assign or pass a derived object by value to a base type. C++ objects have value semantics, so the compiler copies only the base portion and literally discards the derived fields, and the resulting object has the base's vtable pointer, so virtual calls dispatch to the base version. The bug is silent, there is no warning by default, and it is one of the most damaging differences from Java, where you always hold a reference and slicing simply cannot occur.

The fix is to pass by reference or by pointer, ideally a smart pointer, or to disable copying with a deleted copy constructor. The virtual destructor rule follows the same mechanism. If you delete a derived object through a base pointer and the base destructor is not virtual, the behaviour is undefined and in practice only the base destructor runs, so the derived class's members are never destroyed and anything it owned leaks.

The rule to state: any class intended to be used polymorphically, that is any class with a virtual method, must have a virtual destructor, or must be protected and non virtual if you intend to forbid deletion through the base. Underneath, virtual dispatch works through a vtable, one per class, with each object holding a vptr to it, which costs one pointer per object and one indirection per virtual call, and this is why C++ makes you opt in with the virtual keyword while Java makes every method virtual by default and relies on the JIT to devirtualise monomorphic call sites. Java's equivalent concerns are different in shape: there is no slicing, destructors do not exist, and cleanup is either garbage collection or AutoCloseable with try-with-resources. Being able to map the two models onto each other is exactly what a systems-oriented panel at Microsoft or Adobe is probing.

// C++ slicing
class Base { public: virtual void speak() { std::cout << "base\n"; } };
class Derived : public Base {
    int extra = 42;
 public: void speak() override { std::cout << "derived\n"; }
};

void byValue(Base b) { b.speak(); }      // SLICED
void byRef(Base& b)  { b.speak(); }

Derived d;
byValue(d);   // base      <- extra field discarded, vtable is Base's
byRef(d);     // derived

// Missing virtual destructor leaks the derived part
class Resource { public: ~Resource() { } };          // NOT virtual
class FileResource : public Resource { std::vector<char> buf; };
Resource* r = new FileResource();
delete r;     // undefined behaviour, buf is never destroyed

// Fix
class Resource2 { public: virtual ~Resource2() = default; };

// Java: no slicing possible, cleanup is try-with-resources
try (var stream = Files.newInputStream(path)) {
    // close() is guaranteed, in reverse order, even on exception
}

Key Points

  • Slicing copies only the base subobject and drops derived state and the derived vtable
  • Pass by reference or pointer, or delete the copy constructor
  • Any polymorphic base class needs a virtual destructor or deletion leaks
  • Java has references only, so slicing cannot happen; cleanup uses AutoCloseable
Q43

How should an OOP developer reason about object lifecycle and garbage collection, and why was finalize deprecated?

AdvancedObject Lifecycle

Answer

A Java object's lifetime is: allocated in the young generation eden space, promoted through survivor spaces if it survives collections, eventually tenured into the old generation, and collected when it becomes unreachable from any GC root, which means stack frames, static fields, JNI references and active threads. Note the criterion is reachability, not reference counting, so cycles are collected correctly, unlike CPython's primary mechanism. The design consequence for an OOP developer is that you cannot know when an object dies, so you must never put resource release in a destructor style hook. finalize was that hook, and it was deprecated in Java 9 and removed for use in Java 18 because it is unfixable: it may never run at all if the JVM exits, it runs on an unspecified finalizer thread so an exception there is swallowed, it adds at least two GC cycles to the lifetime of every finalizable object which materially hurts throughput, and worst of all it can resurrect an object by storing this somewhere, breaking every invariant the class had.

The replacements are AutoCloseable with try-with-resources for deterministic release, which is what you should use for connections, streams, locks and file handles, and java.lang.ref.Cleaner as a safety net for native memory, registered with a lambda that must not capture the object itself or it will never become unreachable. The practical OOP lessons to state. Scope your objects narrowly so they become unreachable naturally.

Beware static collections and long lived caches, which pin objects forever and are the most common Java memory leak. Beware unregistered listeners, the lapsed listener problem, and beware non static inner classes and lambdas capturing this, which keep the whole outer object alive. And null out references only in genuinely long lived containers such as a hand written stack, since doing it everywhere is noise.

// Deterministic release: this is the answer 95% of the time
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(SQL)) {
    ps.execute();
}   // closed in reverse order, even if execute() throws

// Cleaner as a safety net for native memory
class NativeBuffer implements AutoCloseable {
    private static final Cleaner CLEANER = Cleaner.create();
    private final Cleaner.Cleanable cleanable;

    private static class State implements Runnable {   // static: must NOT capture the outer object
        private final long ptr;
        State(long ptr) { this.ptr = ptr; }
        @Override public void run() { freeNative(ptr); }
    }

    NativeBuffer(long ptr) { this.cleanable = CLEANER.register(this, new State(ptr)); }
    @Override public void close() { cleanable.clean(); }
}

// The most common Java leak: a static cache nothing ever evicts
private static final Map<String, Session> SESSIONS = new HashMap<>();   // pins everything forever

Key Points

  • Collection is by reachability from GC roots, so reference cycles are handled
  • finalize is non deterministic, may never run, and can resurrect the object
  • Use AutoCloseable with try-with-resources for deterministic cleanup
  • Cleaner is a safety net only, and its action must not capture the object
  • Static caches, unregistered listeners and captured outer references are the usual leaks
Q44

Design a parking lot in an OOP low level design round: what classes, and where do most candidates go wrong?

AdvancedSOLID and Design

Answer

Start by clarifying scope out loud, because that is half the score: multiple floors, vehicle types with different spot sizes, hourly pricing that may vary by type, entry and exit gates, payment, and whether you need real time availability. Then name the entities. ParkingLot holds Floors, a Floor holds Spots, a Spot has a SpotType and an optional occupying Ticket.

Vehicle is an abstraction with a size or type, not a deep inheritance tree. Ticket records the spot, the vehicle, and the entry time. A ParkingStrategy decides which spot to allocate, so nearest-to-entry and first-available become swappable rather than an if-chain.

A PricingStrategy computes the fee, so hourly, day-rate and weekend pricing are new classes rather than edits. Payment is an interface with UPI, card and cash implementations. Where candidates go wrong, in order of frequency.

First, an inheritance tree of vehicles, CarVehicle, BikeVehicle, TruckVehicle, each with its own fee method, which hard-couples pricing to the vehicle taxonomy so adding an EV charging spot forces edits everywhere; use an enum plus a strategy instead. Second, putting pricing logic inside Ticket or ParkingLot, so ParkingLot becomes a god class that allocates, prices, takes payment and prints receipts, which is an SRP violation the interviewer will name. Third, ignoring concurrency: two entry gates can allocate the same spot, so you need either synchronised allocation, a compare and swap on spot state, or a per floor lock, and you should say which and why.

Fourth, no interfaces at all, which means nothing is testable. Fifth, spending twenty minutes on the class diagram and never writing the two important methods. Sketch the classes in five minutes, then write park() and unpark() properly, since that is where the interviewer sees whether your model actually works.

enum SpotType { MOTORCYCLE, COMPACT, LARGE, ELECTRIC }

class Spot {
    private final String id;
    private final SpotType type;
    private final AtomicReference<Ticket> occupant = new AtomicReference<>();

    boolean tryOccupy(Ticket t) { return occupant.compareAndSet(null, t); }  // no double allocation
    void release() { occupant.set(null); }
    boolean isFree() { return occupant.get() == null; }
}

interface AllocationStrategy { Optional<Spot> allocate(List<Floor> floors, SpotType type); }
interface PricingStrategy    { BigDecimal fee(Ticket ticket, Instant exitAt); }
interface PaymentMethod      { Receipt charge(BigDecimal amount); }

class ParkingLot {
    private final List<Floor> floors;
    private final AllocationStrategy allocation;
    private final PricingStrategy pricing;

    Ticket park(Vehicle vehicle) {
        SpotType type = SpotType.forVehicle(vehicle);
        Spot spot = allocation.allocate(floors, type)
                .orElseThrow(() -> new LotFullException(type));
        Ticket ticket = new Ticket(spot, vehicle, Instant.now());
        if (!spot.tryOccupy(ticket)) return park(vehicle);   // lost the race, retry
        return ticket;
    }

    Receipt unpark(Ticket ticket, PaymentMethod method) {
        BigDecimal amount = pricing.fee(ticket, Instant.now());
        Receipt receipt = method.charge(amount);
        ticket.spot().release();
        return receipt;
    }
}

Key Points

  • Clarify scope before drawing anything, that is scored
  • Allocation and pricing are strategies, not if-chains or vehicle subclasses
  • Spot occupancy needs compare and swap or a lock, two gates race
  • Do not let ParkingLot become a god class doing allocation, pricing and payment
  • Sketch classes fast, then write park() and unpark() properly
💡 Pro Tip: In a sixty minute LLD round, spend the first five minutes on requirements and assumptions, ten on the class model, and the rest on code. Candidates who draw diagrams for forty minutes and write nothing almost always get rejected.
Q45

Design a BookMyShow style seat booking system: how do you model it and how do you stop two users booking the same seat?

AdvancedSOLID and Design

Answer

The model. City has Theatres, a Theatre has Screens, a Screen has a SeatLayout of Seats with a SeatType such as recliner, premium or regular. A Show binds a Movie to a Screen at a start time.

A ShowSeat is the crucial entity that most candidates miss: a Seat is physical and permanent, a ShowSeat is a Seat for one specific Show with a status of AVAILABLE, HELD or BOOKED. Without ShowSeat you cannot represent that seat A5 is free at 6pm and taken at 9pm. Booking references a user, a show, a set of ShowSeats, a status and a payment.

Pricing is a strategy so that a weekend surcharge or a recliner premium is a new class. The concurrency answer is what the round is actually about, and the right shape is a two phase hold. On seat selection you atomically transition the chosen ShowSeats from AVAILABLE to HELD with an expiry, typically five to ten minutes, and only the winner of that transition proceeds to payment.

In SQL that is an UPDATE ... WHERE status = 'AVAILABLE' checking the affected row count, or a SELECT FOR UPDATE inside a transaction, and it must cover all selected seats atomically or a user can hold two of three seats. On successful payment you move HELD to BOOKED.

On expiry or failure, a background sweeper or a Redis key TTL releases them back. Say why you would not just use an application level lock: it does not survive multiple instances. Say why optimistic locking with a version column works well here: contention is high only for the first minutes of a popular release, and a failed compare and swap is cheap to retry.

Mention idempotency on the payment callback, since a duplicate webhook must not create a second booking. Mention that holding a database transaction open across a payment gateway call is the classic mistake, which is exactly why the hold state exists.

enum ShowSeatStatus { AVAILABLE, HELD, BOOKED }

class ShowSeat {                     // Seat is physical, ShowSeat is per show
    private final Seat seat;
    private final Show show;
    private ShowSeatStatus status = ShowSeatStatus.AVAILABLE;
    private Instant holdExpiresAt;
    private long version;            // optimistic locking
}

interface ShowSeatRepository {
    // atomic across ALL requested seats, returns rows updated
    int holdAll(List<Long> showSeatIds, String holdRef, Instant expiresAt);
    int confirmAll(String holdRef);
    int releaseExpired(Instant now);
}

class BookingService {
    Booking hold(User user, Show show, List<Long> seatIds) {
        String ref = UUID.randomUUID().toString();
        int updated = repo.holdAll(seatIds, ref, Instant.now().plus(Duration.ofMinutes(8)));
        if (updated != seatIds.size())
            throw new SeatsUnavailableException();     // all or nothing, no partial hold
        return Booking.pending(user, show, ref);
    }

    Booking confirm(String ref, String idempotencyKey) {
        return bookings.findByIdempotencyKey(idempotencyKey)   // duplicate webhook is a no-op
                .orElseGet(() -> {
                    repo.confirmAll(ref);
                    return bookings.save(Booking.confirmed(ref, idempotencyKey));
                });
    }
}

// UPDATE show_seat SET status='HELD', hold_ref=?, expires_at=?
//  WHERE id IN (?) AND status='AVAILABLE'      <- the atomic guard

Key Points

  • ShowSeat, not Seat, is the entity that carries per show availability
  • Two phase hold then confirm, with a TTL, is the standard answer
  • Guard the transition with a conditional UPDATE and check the affected row count
  • Hold all requested seats atomically or none, never partially
  • Never hold a database transaction open across a payment gateway call
  • Payment callbacks need an idempotency key so a duplicate webhook is a no-op
Q46

How do you write equals correctly across an inheritance hierarchy, and why can it not be done with instanceof?

AdvancedObject Lifecycle

Answer

This is the deepest equals question and it has a genuinely uncomfortable answer: there is no way to extend an instantiable class with a new value-carrying field and preserve the equals contract. Suppose Point has x and y, and ColorPoint extends it adding a colour. If ColorPoint.equals uses instanceof and ignores colour when compared against a plain Point, you get asymmetry: point.equals(colorPoint) is true while colorPoint.equals(point) is false, and equals must be symmetric.

Try to fix it by making ColorPoint.equals return true when either side is a plain Point, and you break transitivity: a red point equals the plain point, the plain point equals a blue point, but the red and blue points are not equal. Use getClass() instead of instanceof and you get symmetry and transitivity back, but you violate the Liskov Substitution Principle, because a trivial subclass that adds no state, or a Hibernate generated proxy, will never equal its own base instance. That last one is a real production problem: JPA proxies are subclasses, so getClass() based equals silently breaks entity comparison in a lazily loaded association.

The professional answer is to avoid the situation. Prefer composition: give ColorPoint a Point field and a viewAsPoint accessor, and the problem disappears. Prefer immutable value types, and in modern Java prefer records, which are implicitly final and generate a correct equals over their components.

If you are writing an entity framework aware equals, base it on a business key rather than the identity field, since the generated id is null before persistence, and use instanceof so proxies still compare correctly. Being able to lay out the symmetry failure, the transitivity failure, the LSP tension, and the composition escape hatch is close to the ceiling of what an OOPS round can ask.

class Point {
    final int x, y;
    @Override public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return p.x == x && p.y == y;
    }
}

class ColorPoint extends Point {
    final String color;
    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(o) && cp.color.equals(color);
    }
}

Point p = new Point(1, 1);
ColorPoint cp = new ColorPoint(1, 1, "red");
System.out.println(p.equals(cp));   // true
System.out.println(cp.equals(p));   // false   <- symmetry broken

// getClass() restores symmetry but breaks LSP and JPA proxies
// if (o == null || getClass() != o.getClass()) return false;

// The escape hatch: composition instead of inheritance
final class ColorPoint2 {
    private final Point point;
    private final String color;
    Point asPoint() { return point; }
    @Override public boolean equals(Object o) {
        return o instanceof ColorPoint2 c
            && c.point.equals(point) && c.color.equals(color);
    }
}

// Or just use a record: implicitly final, correct equals generated
record ColorPoint3(int x, int y, String color) { }

Key Points

  • Adding a value field in a subclass cannot preserve the equals contract
  • instanceof breaks symmetry, and the naive fix then breaks transitivity
  • getClass() restores both but violates LSP and breaks JPA and Hibernate proxies
  • Use composition, or a record, which is implicitly final
  • For entities, compare on a business key, not on a database generated id
💡 Pro Tip: If a panel asks you to write equals on a whiteboard, always write hashCode immediately after without being asked. Forgetting it is the single most reliable way to lose the question.

Companies Hiring OOPS Concepts

TCS
Infosys
Wipro
Accenture
Cognizant
Microsoft
Adobe
Flipkart

Salary Insights

Average in India
₹4-18 LPA

Frequently Asked Questions

What salary can I expect for roles where an OOPS round gates entry?

The OOPS round is the gate at almost every tier, so the bands track employer type rather than the topic. Services majors, TCS, Infosys, Wipro, Cognizant, Accenture and Capgemini, hire freshers at roughly ₹3.5 to 4.5 LPA through standard campus offers, with digital or specialist tracks such as TCS Digital and Infosys Power Programmer landing ₹7 to 9 LPA for candidates who clear the harder coding and OOPS sections. Two to five years of experience in those firms typically means ₹6 to 12 LPA. Product companies and funded startups, Flipkart, Razorpay, Swiggy, Zerodha, CRED, Zoho, Freshworks, PhonePe and Meesho, start freshers around ₹12 to 25 LPA, and the low level design round is where most of that differentiation is decided. Two to five years there runs ₹18 to 40 LPA. Global captives, Microsoft, Walmart Global Tech, Adobe, Atlassian and Salesforce, sit at the top for freshers at ₹20 to 45 LPA including stock, and ₹30 to 60 LPA at the three to six year mark. The pattern worth noting is that OOPS knowledge alone gets you through the services tier, while applying it under time pressure in a design round is what unlocks the product tier.

How long does it take to prepare for OOPS interview rounds?

For a services company round, two focused weeks is enough if you already write code in Java, C++ or Python. Week one covers the four pillars with a real example, constructors and initialisation order, overloading versus overriding with the resolution rules, abstract class versus interface, static versus instance, and access modifiers. Week two is practice: write the class hierarchies by hand on paper, predict the output of initialisation-order and overload-resolution puzzles, and rehearse explaining each concept in ninety seconds using one running example. For a product company low level design round, plan six to eight weeks. You need SOLID applied to real violations rather than recited, the common design patterns with a genuine opinion on when not to use them, and roughly eight to ten practice designs done end to end with a timer: parking lot, BookMyShow, elevator, splitwise, rate limiter, cache, food delivery, chess. The bottleneck is almost never knowledge, it is producing a defensible class model in sixty minutes while narrating your reasoning. If you are working full time, ninety minutes a day for six weeks is a realistic plan.

Which programming language should I answer OOPS questions in?

Answer in the language on your resume, and say which one you are choosing before you start. Java is the safest default for Indian panels: services companies train and interview on it, most interview material assumes it, and the concepts map cleanly onto standard questions such as why multiple inheritance of classes is not allowed. C++ is a good choice if it is genuinely your strength, and it lets you demonstrate depth on virtual destructors, object slicing and virtual inheritance, but be ready for follow-ups on memory management. Python is increasingly accepted, particularly for data and machine learning roles, though you must be able to explain duck typing, the method resolution order, and why the language has no enforced private, since a panel used to Java will probe exactly those gaps. Do not switch languages mid-interview, and do not claim a language you cannot write on a whiteboard. If the interviewer asks for a language you are weak in, say so honestly, offer your stronger one, and explain the concept in the language they asked about at a conceptual level. Being upfront costs far less than a syntax collapse halfway through.

Is OOPS asked only to freshers, or to experienced candidates too?

Both, but the shape changes completely. Freshers get definition and mechanism questions: the four pillars, overloading versus overriding, abstract class versus interface, output prediction on constructors and static blocks. The bar is accuracy and the ability to write compiling code by hand. Candidates with two to six years of experience get applied questions: why did you model it this way, what breaks if a new payment type is added, show me a class in your current project that violates single responsibility. The interviewer is checking whether you have internalised the principles or memorised them, and answers grounded in your actual codebase score far higher than textbook examples. Beyond six years, the OOPS content moves entirely into design rounds, low level design, sometimes high level design, and you are assessed on trade-offs, extensibility and the ability to justify a model under challenge. What never disappears is equals and hashCode, immutability, composition over inheritance, and SOLID. Those come up at every level, and a senior candidate who fumbles the equals and hashCode contract in a HashMap loses credibility instantly.

How do OOPS rounds differ between TCS or Infosys and Flipkart or Microsoft?

At TCS, Infosys, Wipro, Accenture and Cognizant, OOPS is a fifteen to twenty five minute segment inside a broader technical round, usually run by a project lead rather than an interview specialist. It follows a predictable script of definitions, differences and small code snippets, and preparation is largely about accuracy and confident delivery. TCS NQT and the equivalent Infosys and Wipro campus tests include multiple choice OOPS questions before you ever reach a human. Zoho is the well known exception among Indian product firms: its rounds run all day in person, and the OOPS portion turns into implementing a small system on paper with real constraints, which is much closer to design than to definitions. At Flipkart, Microsoft, Adobe, Walmart Global Tech, Razorpay and Atlassian, nobody asks you to define polymorphism. Instead you get a sixty to ninety minute low level design or machine coding round: design a parking lot, a seat booking flow, a rate limiter, sometimes with running code and tests expected. The same concepts are being assessed, but through whether your class boundaries hold up when the interviewer adds a requirement halfway through.

Do I need to know design patterns as a fresher?

For services company rounds, know four well rather than twenty badly: Singleton, Factory, Strategy and Observer. Those four cover the overwhelming majority of fresher questions, and being able to write a correct thread safe Singleton and explain why you would prefer the enum version already puts you ahead of most candidates. For product company roles, you need a working set of about eight, adding Builder, Adapter, Decorator and Template Method, and more importantly you need an opinion on when not to use each one. Interviewers at Flipkart and Microsoft actively penalise pattern-dropping, where a candidate announces they will use an Abstract Factory for a problem that needs one class. What matters much more than pattern names is the underlying reasoning: composition over inheritance, programming to an interface, and Open Closed. If your design is clean, an interviewer will often name the pattern for you. So learn the patterns as solutions to specific problems you can describe, not as a list to recite, and always be ready for the follow-up asking what the pattern costs you.

How does OOPS connect to low level design rounds?

A low level design round is an OOPS round with the questions removed. You are handed a domain, a parking lot, a movie booking flow, an elevator bank, a splitwise clone, and asked to produce classes, interfaces and often working code in sixty to ninety minutes. Everything being assessed is OOPS: whether your class boundaries respect single responsibility, whether adding a new vehicle type or payment method requires editing existing code, whether you reached for inheritance where composition was correct, whether your interfaces are small enough to implement honestly, and whether your objects protect their invariants instead of exposing setters. The extra skills the round demands beyond concepts are scoping the problem out loud before you draw anything, managing time so you actually write the two or three important methods, and defending your model when the interviewer changes a requirement mid-round. That last move is deliberate: they are testing whether your design absorbs change or shatters. Preparing for low level design is therefore the highest leverage way to prepare for OOPS at the product tier, because it forces you to apply every principle instead of reciting it.

Introduction

OOPS is the single most reliably asked topic in Indian technical hiring. Every campus placement season, TCS NQT, the Infosys and Wipro technical rounds, Accenture, Cognizant and Capgemini all run a round where a panel member asks you to explain the four pillars, then immediately tries to catch you out with method overloading versus overriding, or with the difference between an abstract class and an interface. The volume is enormous because it is cheap to ask, language agnostic on paper, and separates candidates who memorised a definition from candidates who have actually written classes. What has changed by 2026 is that the panels have read the same blog posts you have. Reciting "encapsulation is data hiding" now signals preparation without understanding. The answers that land are the ones anchored in a real object model, an order, a payment, a seat booking, where you can say what the class owns, what it exposes, and what would break if you moved a method somewhere else.

In services company rounds the OOPS section is usually fifteen to twenty five minutes, delivered by a project lead rather than a specialist interviewer, and follows a fairly stable script. Expect the four pillars, constructor versus method, this versus super, static versus instance, why Java does not support multiple inheritance of classes, abstract class versus interface, and a whiteboard question where you write a small class hierarchy such as Vehicle, Car, Bike or Shape, Circle, Rectangle. Zoho is the well known outlier: its rounds run all day in person, and the OOPS portion turns into you implementing a small system on paper with real constraints, which is much closer to a design round than a definitions quiz. The practical advice for services rounds is to answer in the language on your resume, keep a single running example across the whole conversation, and be ready to write compiling code by hand, because syntax errors get noticed.

At product companies and global captives, Flipkart, Microsoft, Adobe, Walmart Global Tech, Razorpay, Swiggy, PhonePe, Atlassian, the OOPS round exists under a different name. It is called low level design or machine coding, and it lasts sixty to ninety minutes. You are asked to design a parking lot, a BookMyShow style seat booking flow, a ride matching service, a rate limiter or an elevator system, and to produce classes, interfaces, and sometimes running code. Nobody will ask you to define polymorphism, but you will be judged on whether your class boundaries make sense, whether adding a new vehicle type or a new payment method forces you to edit existing code, and whether you understood that composition would have been cleaner than the inheritance tree you drew. The concepts are identical to the ones in a TCS round. The bar is that you apply them under time pressure to a domain you have never seen.

Ready to practice OOPS Concepts interviews?

Don't just read, practice these OOPS Concepts questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview