Java OOP Interview Questions: Polymorphism, Abstraction & More
If you're interviewing for a Java role — junior, mid, or senior — OOP questions are guaranteed to show up. Not because interviewers love theory, but because the way you answer reveals how you actually design software. A candidate who can recite four pillars from memory is forgettable. A candidate who explains WHY encapsulation prevents bugs in a multi-team codebase gets the offer.
The problem is that most resources teach OOP as a list of definitions. That leaves you able to parrot answers but unable to handle the natural follow-up: 'Can you give me a real-world example?' or 'How does that differ from an abstract class?' Those follow-ups are where interviews are actually won or lost.
After working through this article, you'll be able to explain every major OOP concept with a concrete analogy, write runnable code that demonstrates each idea, spot the three classic mistakes candidates make, and handle the tricky follow-up questions interviewers use to separate the memorisers from the thinkers.
The Four Pillars — What They Are and Why Each One Exists
Every Java OOP interview starts here. The four pillars are Encapsulation, Abstraction, Inheritance, and Polymorphism. But interviewers don't want a dictionary. They want to know you understand the problem each pillar solves.
Encapsulation solves the 'who changed my data?' problem. By bundling data with the methods that operate on it and hiding the internals, you prevent other parts of the system from putting an object into an invalid state. Think of a bank account — you never want external code to set the balance directly to a negative number.
Abstraction solves the 'I don't need to know how' problem. You expose only what's necessary and hide everything else. This is why you can call list.sort() without understanding TimSort.
Inheritance solves the 'don't repeat yourself' problem. Common behaviour lives in a parent class; child classes inherit it and specialise where needed.
Polymorphism solves the 'treat different things uniformly' problem. One interface, many implementations. This is what makes your code extensible without modification — the Open/Closed Principle in action.
public class BankAccountEncapsulation { // --- Encapsulation Demo --- // The balance field is private — no outside code can set it directly. // This means we can enforce rules (e.g. no negative balances) in ONE place. static class BankAccount { private double balance; // hidden internal state private String accountHolder; // also hidden public BankAccount(String accountHolder, double openingBalance) { this.accountHolder = accountHolder; // Using our own setter so the validation rule applies at construction too setBalance(openingBalance); } // Public getter — read-only access from outside public double getBalance() { return balance; } // Public method to deposit — enforces business logic public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Deposit amount must be positive."); } balance += amount; // safe: we control every modification } // Public method to withdraw — enforces business logic public void withdraw(double amount) { if (amount > balance) { throw new IllegalStateException("Insufficient funds."); } balance -= amount; } // Private setter — only used internally so we keep validation centralised private void setBalance(double balance) { if (balance < 0) { throw new IllegalArgumentException("Opening balance cannot be negative."); } this.balance = balance; } @Override public String toString() { return accountHolder + "'s balance: $" + balance; } } public static void main(String[] args) { BankAccount account = new BankAccount("Alice", 500.00); System.out.println(account); // initial state account.deposit(200.00); System.out.println(account); // after deposit account.withdraw(100.00); System.out.println(account); // after withdrawal // This next line would throw IllegalStateException — try it! // account.withdraw(1000.00); // Without encapsulation, any code could do: account.balance = -999; // With encapsulation, that line won't even compile. } }
Alice's balance: $700.0
Alice's balance: $600.0
Polymorphism vs Abstraction — The Question That Trips Everyone Up
These two are the most commonly confused pillars, and interviewers exploit that confusion heavily. Here's the clean separation: Abstraction is about DESIGN — hiding complexity behind a simple interface. Polymorphism is about BEHAVIOUR — the same call producing different results depending on the actual object at runtime.
Abstraction is implemented in Java via abstract classes and interfaces. You define WHAT something must do without specifying HOW. Polymorphism is what happens at runtime when Java resolves which overridden method to actually call.
A classic follow-up: 'What's the difference between method overloading and method overriding?' Overloading is compile-time polymorphism — same method name, different parameters, resolved by the compiler. Overriding is runtime polymorphism — same signature in parent and child, resolved by the JVM based on the actual object type.
The real-world consequence: if you write code against an interface (abstraction), you can swap out implementations without touching the calling code (polymorphism). This is the core of dependency injection frameworks like Spring.
import java.util.List; import java.util.ArrayList; public class NotificationPolymorphism { // ABSTRACTION: This interface declares WHAT a notifier must do. // It says nothing about HOW — that's left to implementations. interface Notifier { void sendAlert(String message); // Default method: shared behaviour without forcing subclasses to override default String formatMessage(String raw) { return "[ALERT] " + raw.toUpperCase(); } } // POLYMORPHISM: Each class defines its own version of sendAlert. // The caller never needs to know which one it's talking to. static class EmailNotifier implements Notifier { private String recipientEmail; EmailNotifier(String recipientEmail) { this.recipientEmail = recipientEmail; } @Override public void sendAlert(String message) { // In production this would call an email API System.out.println("EMAIL to " + recipientEmail + ": " + formatMessage(message)); } } static class SmsNotifier implements Notifier { private String phoneNumber; SmsNotifier(String phoneNumber) { this.phoneNumber = phoneNumber; } @Override public void sendAlert(String message) { // In production this would call an SMS gateway System.out.println("SMS to " + phoneNumber + ": " + formatMessage(message)); } } static class SlackNotifier implements Notifier { private String channel; SlackNotifier(String channel) { this.channel = channel; } @Override public void sendAlert(String message) { System.out.println("SLACK #" + channel + ": " + formatMessage(message)); } } // KEY POINT: This method takes a List<Notifier> — it doesn't care about // the concrete type. Add a new PushNotifier tomorrow and this code needs // zero changes. That's polymorphism making your code Open/Closed. static void broadcastAlert(List<Notifier> notifiers, String message) { for (Notifier notifier : notifiers) { notifier.sendAlert(message); // JVM picks the right implementation at runtime } } public static void main(String[] args) { List<Notifier> alertChannels = new ArrayList<>(); alertChannels.add(new EmailNotifier("ops@company.com")); alertChannels.add(new SmsNotifier("+1-555-0199")); alertChannels.add(new SlackNotifier("incidents")); broadcastAlert(alertChannels, "server CPU at 98%"); } }
SMS to +1-555-0199: [ALERT] SERVER CPU AT 98%
SLAK #incidents: [ALERT] SERVER CPU AT 98%
Abstract Classes vs Interfaces — When to Use Which
This is arguably the most asked Java OOP question at mid-level interviews. Both enforce a contract. Both support polymorphism. But they're not interchangeable, and using the wrong one reveals a gap in design thinking.
Use an abstract class when you have a true 'is-a' relationship AND shared state or behaviour to inherit. Example: a Vehicle abstract class that stores fuelLevel and has a concrete refuel() method that all vehicles share. Child classes extend this and implement their own accelerate() method.
Use an interface when you're defining a capability that could apply to completely unrelated classes. Serializable, Comparable, and Runnable are capabilities, not identities. A Dog and a BankTransaction can both be Serializable — that doesn't mean they share a parent.
Since Java 8, interfaces can have default and static methods, which blurs the line slightly. The practical rule: if you need instance state (fields) in the shared contract, you need an abstract class. Interfaces can't hold instance state.
A common interview follow-up: 'Can a class extend multiple abstract classes?' No — Java uses single inheritance for classes to avoid the diamond problem. But a class can implement multiple interfaces.
public class VehicleHierarchy { // ABSTRACT CLASS: appropriate here because: // 1. All vehicles genuinely 'are' vehicles (is-a relationship) // 2. They share real instance state (fuelLevel, make) // 3. They share some concrete behaviour (refuel) static abstract class Vehicle { protected String make; // shared state — inherited by all subclasses protected double fuelLevel; // shared state Vehicle(String make, double initialFuel) { this.make = make; this.fuelLevel = initialFuel; } // Concrete method — defined once, works for every vehicle public void refuel(double litres) { fuelLevel += litres; System.out.println(make + " refuelled. Fuel level: " + fuelLevel + "L"); } // Abstract method — subclasses MUST provide their own implementation public abstract void accelerate(); // Abstract method — different vehicles report differently public abstract String getType(); } // INTERFACE: appropriate for capabilities that cross class hierarchies // A Car and a Drone can both be GPS-tracked — they don't share a parent interface GpsTrackable { String getCurrentLocation(); // every implementor must define this // Default method — shared utility behaviour added in Java 8 default void printLocation() { System.out.println("Current position: " + getCurrentLocation()); } } // Car extends Vehicle (is-a) AND implements GpsTrackable (has-a capability) // Java allows one parent class + multiple interfaces — best of both worlds static class Car extends Vehicle implements GpsTrackable { private int horsepower; private double latitude; private double longitude; Car(String make, double fuel, int horsepower) { super(make, fuel); // call parent constructor to set shared state this.horsepower = horsepower; this.latitude = 51.5074; // London by default for demo this.longitude = -0.1278; } @Override public void accelerate() { // Car-specific implementation — burns fuel faster with more HP fuelLevel -= (horsepower * 0.01); System.out.println(make + " car accelerates. Fuel remaining: " + fuelLevel + "L"); } @Override public String getType() { return "Petrol Car"; } @Override public String getCurrentLocation() { return latitude + ", " + longitude; // would call GPS API in production } } static class ElectricBike extends Vehicle { private int batteryPercent; ElectricBike(String make, double fuel, int battery) { super(make, fuel); this.batteryPercent = battery; } @Override public void accelerate() { batteryPercent -= 2; // electric: drains battery, not fuel System.out.println(make + " e-bike accelerates. Battery: " + batteryPercent + "%"); } @Override public String getType() { return "Electric Bike"; } } public static void main(String[] args) { Car tesla = new Car("Tesla", 100.0, 300); ElectricBike specialised = new ElectricBike("Specialised", 0, 100); // Polymorphism: same method call, different behaviour Vehicle[] garage = { tesla, specialised }; for (Vehicle v : garage) { System.out.println("--- " + v.getType() + " ---"); v.accelerate(); } System.out.println(); // GpsTrackable: only Tesla implements it — checked at compile time tesla.printLocation(); // uses default interface method // Refuel is concrete in parent — works without override tesla.refuel(20.0); } }
Tesla car accelerates. Fuel remaining: 97.0L
--- Electric Bike ---
Specialised e-bike accelerates. Battery: 98%
Current position: 51.5074, -0.1278
Tesla refuelled. Fuel level: 117.0L
Inheritance Pitfalls and the Liskov Substitution Principle
Inheritance looks clean on paper but is the most misused OOP feature in real codebases. The classic mistake: using inheritance for code reuse when there's no genuine 'is-a' relationship. Stack extending Vector in Java's own standard library is the canonical example of this done badly — a Stack is NOT a Vector, but Java's designers used inheritance for convenience, which meant Stack accidentally exposed methods like add(int index, Object element) that make no logical sense for a stack.
The Liskov Substitution Principle (LSP) is the interview gold standard here. It says: if you replace a parent with any of its subtypes, the program should still behave correctly. A Square extending Rectangle violates LSP — if you set width on a Square it must also change the height, which breaks any code that expects to set width and height independently.
When LSP is in danger, favour composition over inheritance. Instead of Square extending Rectangle, give Square a Dimensions object internally.
In interviews, mention LSP and give the Square/Rectangle example. Fewer than 10% of candidates do. It immediately signals that you think about design consequences, not just syntax.
public class LiskovSubstitution { // GOOD INHERITANCE: Penguin violating LSP — let's see what goes wrong first, // then we'll fix it with the correct design. // Scenario: A payroll system processes different employee types. // Base class represents any person on payroll. static abstract class PayrollEmployee { protected String name; protected double baseSalary; PayrollEmployee(String name, double baseSalary) { this.name = name; this.baseSalary = baseSalary; } // Every employee gets paid — the amount may vary by type public abstract double calculateMonthlyPay(); public String getName() { return name; } } // FullTimeEmployee: gets exactly their monthly salary static class FullTimeEmployee extends PayrollEmployee { FullTimeEmployee(String name, double annualSalary) { super(name, annualSalary / 12); // store as monthly } @Override public double calculateMonthlyPay() { return baseSalary; // straightforward } } // Contractor: paid by hours worked each month static class Contractor extends PayrollEmployee { private double hourlyRate; private int hoursWorkedThisMonth; Contractor(String name, double hourlyRate, int hoursWorked) { super(name, 0); // contractors don't have a base salary this.hourlyRate = hourlyRate; this.hoursWorkedThisMonth = hoursWorked; } @Override public double calculateMonthlyPay() { return hourlyRate * hoursWorkedThisMonth; } } // BonusEligibleEmployee: full-timer who also gets a performance bonus static class BonusEligibleEmployee extends FullTimeEmployee { private double bonusMultiplier; // e.g. 1.2 = 20% bonus on top of salary BonusEligibleEmployee(String name, double annualSalary, double bonusMultiplier) { super(name, annualSalary); this.bonusMultiplier = bonusMultiplier; } @Override public double calculateMonthlyPay() { // LSP is respected: substituting this for FullTimeEmployee still makes sense. // We return MORE than base pay, never LESS or something nonsensical. return super.calculateMonthlyPay() * bonusMultiplier; } } // This method works on ANY PayrollEmployee — it doesn't care about the subtype. // LSP guarantees this is safe: every subtype correctly fulfils the contract. static void processPayroll(PayrollEmployee[] employees) { double totalPayroll = 0; for (PayrollEmployee employee : employees) { double pay = employee.calculateMonthlyPay(); // runtime polymorphism totalPayroll += pay; System.out.printf("%-25s $%.2f%n", employee.getName(), pay); } System.out.printf("%-25s $%.2f%n", "TOTAL PAYROLL:", totalPayroll); } public static void main(String[] args) { PayrollEmployee[] team = { new FullTimeEmployee("Sarah Chen", 84000), // $7,000/month new Contractor("Marcus Webb", 95.00, 160), // $15,200/month new BonusEligibleEmployee("Priya Patel", 96000, 1.25) // $10,000/month }; System.out.println("=== Monthly Payroll Run ==="); processPayroll(team); } }
Sarah Chen $7000.00
Marcus Webb $15200.00
Priya Patel $10000.00
TOTAL PAYROLL: $32200.00
| Aspect | Abstract Class | Interface |
|---|---|---|
| Can hold instance state (fields) | Yes — instance fields allowed | No — only static final constants |
| Constructor | Yes — can define constructors | No — interfaces have no constructors |
| Inheritance limit | Single parent class only | A class can implement unlimited interfaces |
| Method types allowed | Abstract + concrete + static | Abstract + default + static (Java 8+) |
| Access modifiers on methods | Any modifier (private, protected, public) | Public by default (private since Java 9) |
| Best used when | Shared state + is-a relationship exists | Shared capability across unrelated classes |
| Real Java example | AbstractList in java.util | Comparable, Runnable, Serializable |
🎯 Key Takeaways
- Encapsulation isn't about getters and setters — it's about protecting invariants so no external code can put your object into an illegal state.
- Abstraction defines the contract (what must be done); polymorphism executes it (which version runs at runtime). They're partners, not synonyms.
- Choose abstract class when subtypes share instance state or a genuine is-a relationship. Choose interface for capabilities that apply across unrelated class hierarchies.
- Liskov Substitution Principle is your inheritance sanity check: if you can't swap a subtype in wherever the parent is expected without breaking anything, you've got bad inheritance — fix it with composition.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Confusing overloading with overriding — Candidates say 'method overriding' when they mean 'method overloading', or claim both are runtime polymorphism. Overloading is resolved at COMPILE time by the compiler based on parameter types. Overriding is resolved at RUNTIME by the JVM based on the actual object type. To fix: remember the rule — overLOADing = LOts of signatures, resolved early. overRIDing = the child RIDes over the parent, resolved late.
- ✕Mistake 2: Thinking private fields are 'inherited' — Candidates say child classes inherit everything from the parent, including private fields. Private fields exist in the child object's memory but are NOT accessible by name in the child class. The child can only reach them via public or protected getter/setter methods defined in the parent. To fix: declare fields as protected if child classes genuinely need direct access, but prefer private + accessor methods to preserve encapsulation.
- ✕Mistake 3: Using an interface purely because 'it allows multiple inheritance' — Choosing an interface over an abstract class solely to avoid Java's single-class inheritance limit. If your types genuinely share state and behaviour via is-a, forcing an interface means copying default implementations or creating awkward workarounds. To fix: ask 'do these types share state?' first. If yes, abstract class. Then ask 'do they need additional capabilities?' — add interfaces on top for those capabilities.
Interview Questions on This Topic
- QWhat's the difference between an abstract class and an interface in Java, and how do you decide which to use in a real design?
- QCan you explain the Liskov Substitution Principle with an example — and tell me about a time Java's own standard library violates it?
- QIf method overriding is runtime polymorphism, what happens when you call an overridden method from a parent class constructor — which version runs?
Frequently Asked Questions
What are the four pillars of OOP in Java?
Encapsulation (protecting internal state), Abstraction (hiding complexity behind simple interfaces), Inheritance (reusing and extending behaviour from a parent class), and Polymorphism (one method call behaving differently depending on the actual object type at runtime). Each pillar solves a distinct design problem — they work together rather than independently.
Can a Java interface have a constructor?
No. Interfaces cannot be instantiated directly, so they have no constructors. Since Java 8, interfaces can have default and static methods, and since Java 9 they can have private methods — but none of these are constructors. If you need initialisation logic, you need an abstract class.
What is the difference between method overloading and method overriding in Java?
Overloading is compile-time polymorphism: multiple methods in the same class share a name but differ in parameter type or count, and the compiler decides which to call. Overriding is runtime polymorphism: a subclass provides its own implementation of a parent method with the identical signature, and the JVM decides which to call based on the actual object type — not the reference type.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.