Computer scienceProgramming languagesJavaInterview preparationTech interviewJava fundamentals and OOP

Java OOP interview

18 minutes read

Object-Oriented Programming (OOP) is more than just a paradigm in Java—it's the foundation on which the entire language is built. Whether you're preparing for a technical interview or aiming to design scalable software systems, understanding Java's OOP principles in depth is crucial. This article offers a comprehensive, prose-style explanation of ten essential OOP topics, complete with practical examples and clear insights into how and why they matter in real-world development.

The Four Pillars of OOP in Java

Java fully embraces the four core principles of object-oriented design: Encapsulation, Inheritance, Polymorphism, and Abstraction. These concepts define how objects interact, how data is protected, and how systems scale with clarity and reuse.

Encapsulation restricts direct access to an object’s internal state and ensures that all interaction occurs through well-defined methods. It’s implemented using the private access modifier to achieve encapsulation by restricting access to fields and methods so that they can only be accessed within the same class.


public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    public double getBalance() {
        return balance;
    }
}

Combined with public getters and setters, it allows controlled access to internal data while preserving integrity and flexibility in code design.

Inheritance enables a class to inherit fields and methods from a parent class, promoting code reuse and hierarchical classification.

public class Animal {
    public void makeSound() {
        System.out.println("Generic animal sound");
    }
}

public class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof");
    }
}

Polymorphism allows a single interface to represent different types. It comes in two forms:

  • Compile-time polymorphism (method overloading):

public class Printer {
    public void print(String message) {
        System.out.println(message);
    }

    public void print(int number) {
        System.out.println("Number: " + number);
    }
}
  • Runtime polymorphism (method overriding):

Animal animal = new Dog();
animal.makeSound(); // Outputs: Woof

Abstraction in OOP is the process of hiding complex implementation details while exposing only the essential features and functionality that users need to interact with an object or system:

public interface PaymentProcessor {
    void processPayment(double amount);
}

public class StripeProcessor implements PaymentProcessor {
    public void processPayment(double amount) {
        // logic to process payment via Stripe
    }
}

Abstraction focuses on what an object does rather than how it does it. It provides a simplified interface while hiding the underlying complexity.

Abstract Classes vs Interfaces

Both abstract classes and interfaces are used to define abstract types in Java, but they differ in intent and capabilities.

An abstract class allows partial implementation and state:

public abstract class Vehicle {
    protected String model;

    public Vehicle(String model) {
        this.model = model;
    }

    public abstract void startEngine();

    public void honk() {
        System.out.println("Honking");
    }
}

An interface defines a contract without enforcing inheritance:

public interface Flyable {
    void fly();
}

public class Airplane implements Flyable {
    public void fly() {
        System.out.println("Flying");
    }
}

Abstract classes can contain both abstract and concrete methods, have instance variables, constructors, and support single inheritance, making them ideal when you want to share common code among related classes. Interfaces support multiple inheritance, and cannot have constructors or instance variables - best used when you want unrelated classes to implement the same behavior. Use abstract classes when you have a "is-a" relationship with shared functionality (like Vehicle -> Car, Motorcycle), and use interfaces when you need a "can-do" relationship across different class hierarchies (like Flyable implemented by Bird, Airplane, and Drone).

Method overriding vs method overloading

Polymorphism in Java exists in two major forms: compile-time (static) and runtime (dynamic).

Compile-time polymorphism refers to method overloading. The compiler determines the correct method to call based on method signatures. Overloading occurs within the same class when multiple methods have the same name but different parameter lists.

class MathUtils {
    int add(int a, int b) {
        return a + b;
    }

    double add(double a, double b) {
        return a + b;
    }

    int add(int a, int b, int c) {
        return a + b + c;
    }
}

Runtime polymorphism refers to method overriding, where the JVM resolves the method call at runtime based on the object’s type.

class Animal {
    void speak() {
        System.out.println("Animal speaks");
    }
}

class Cat extends Animal {
    @Override
    void speak() {
        System.out.println("Meow");
    }
}

Overriding allows a subclass to provide a specific implementation of a method already defined in its superclass.

Rules for method overloading

Method overloading in Java allows multiple methods with the same name to coexist in a class, as long as their parameter lists are different. Overloaded methods must differ in the number or types of parameters, but can have different return types and access modifiers:

class Calculator {
    int add(int a, int b) {
        return a + b;
    }

    private double add(double a, int b) {
        return a + b;
    }

    double add(int a, double b) {
        return a + b;
    }

    public int add(int a, int b, int c) {
        return a + b + c;
    }
}

Unlike overriding, method overloading does not rely on inheritance and cannot depend solely on return type differences:

Rules for method overriding

Method overriding allows a subclass to replace the behavior of an inherited method. To do so, the method must have the same name, parameter list, and a compatible return type—either the same or a subtype (covariant return). The access modifier cannot be more restrictive than in the superclass.

class Parent {
    protected int getValue() { return 42; }
}

class Child extends Parent {
    @Override
    public int getValue() { return 100; }
}

You cannot override methods that are final, static, or private, and constructors are never overridden. Additionally, the overriding method may throw fewer or narrower checked exceptions.

Boxing and unboxing

Boxing and unboxing are mechanisms in Java that allow primitive types and their corresponding wrapper classes to interoperate seamlessly. Boxing is the process of converting a primitive type (like int, double, or char) into its corresponding object wrapper (Integer, Double, Character, etc.), while unboxing performs the reverse—extracting the primitive value from its wrapper. Java introduced autoboxing and auto-unboxing in Java 5 to simplify this conversion process, allowing primitives and objects to be used interchangeably in many contexts, such as collections and method calls.

javaCopyEditint num = 10;
Integer boxed = num;          // autoboxing
int unboxed = boxed + 5;      // auto-unboxing

Although convenient, excessive boxing can lead to performance overhead and unintended NullPointerExceptions when unboxing null values. Understanding boxing is essential for writing efficient and error-free code in Java’s object-oriented ecosystem.

String vs StringBuilder vs StringBuffer

Java provides three classes for handling character sequences: String, StringBuilder, and StringBuffer. While they serve a similar purpose, they differ significantly in terms of mutability, performance, and thread safety.

  • String objects are immutable—any modification creates a new object. To optimize memory usage, Java maintains a string pool and applies object interning: identical string literals are stored only once and reused. This makes strings efficient and thread-safe, but expensive for frequent modifications.

  • StringBuilder is mutable and not thread-safe. It’s designed for scenarios where strings are built or modified often, offering better performance in single-threaded environments.

  • StringBuffer is similar to StringBuilder but is thread-safe due to internal synchronization, making it suitable for multi-threaded context, at the cost of some performance.

String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2); // true, same reference from string pool

StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // Efficient for single-threaded use

StringBuffer sbf = new StringBuffer("Hello");
sbf.append(" World"); // Thread-safe for concurrent use

Use StringBuilder for dynamic string operations when thread safety isn't a concern. Choose StringBuffer only when multiple threads modify the same buffer, and rely on String for fixed or pooled text content.

Object interning

In Java, object interning is a memory optimization technique where identical immutable objects are stored in a shared pool, allowing the JVM to reuse them instead of creating duplicates. This is most commonly associated with String literals, which are automatically interned by the JVM. For example, multiple variables assigned the literal "Java" will point to the same object in the String Pool.

Using new String("Java") explicitly creates a new object on the heap, bypassing interning unless .intern() is called manually.

Additionally, Java interns certain wrapper objects—specifically Boolean, Byte, Character (from \u0000 to \u007F), and Short and Integer values from -128 to 127. This means that autoboxed values within those ranges will reference the same object:

Integer b = 100;
System.out.println(a == b); // true

Integer x = 200;
Integer y = 200;
System.out.println(x == y); // false

This behavior helps reduce memory footprint and improve performance when working with frequently repeated values. However, developers must be cautious when comparing objects using == outside of interned ranges, as it compares references, not values. For values outside the interned range or for explicitly constructed objects, always use .equals() to ensure correct comparison semantics.

== vs equals() and hashing

In Java, it's essential to understand the difference between the == operator and the .equals() method, especially when working with objects.

The == operator checks whether two references point to the exact same object in memory. It does not compare the content of the objects themselves. This means that even if two objects have identical values internally, == will return false unless both references point to the same memory location.

In contrast, the .equals() method is intended to compare the contents of two objects for logical equality. For example:

String a = new String("Java");
String b = new String("Java");

System.out.println(a == b);       // false – different objects
System.out.println(a.equals(b));  // true – same content

Even though both a and b contain the same characters, they are two separate String instances, so a == b is false. However, a.equals(b) correctly identifies that the values are equal.

When creating your own classes—especially if you intend to use them in hash-based collections like HashSet, HashMap, or Hashtable—you must override both equals() and hashCode() in a way that ensures consistency.

Why? These collections use the hashCode() method to group objects into buckets for efficient retrieval. If two objects are considered equal according to .equals(), they must also have the same hash code. Otherwise, you’ll encounter unexpected behavior when storing or retrieving instances.

Here’s a proper implementation example:

public class Person {
    private String name;
    private int age;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;

        Person other = (Person) obj;
        return age == other.age && Objects.equals(name, other.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

This ensures that two Person objects with the same name and age are treated as equal and generate the same hash code, making them behave correctly when used in collections.

Java Memory Model and Object Lifecycle

Java utilizes automatic memory management through garbage collection (GC) to reclaim memory used by unreachable objects. Most collectors, including the default G1GC (Garbage-First Garbage Collector) in OpenJDK, are based on the generational hypothesis—the idea that most objects die young. To optimize this, the heap is split into generations:

  • The Young Generation holds newly created, short-lived objects. It's further divided into Eden (where objects are first allocated) and Survivor spaces (where surviving objects are copied during minor collections).

  • The Old Generation (or Tenured space) stores long-lived objects that survived multiple garbage collection cycles.

The G1GC collector improves upon traditional collectors by dividing the heap not into contiguous Young/Old spaces but into uniform regions. G1GC tracks garbage accumulation across regions and collects the ones with the most waste first, making it suitable for applications needing low-pause-time performance.

List<String> list = new ArrayList<>(); // Allocated in an Eden region of the heap
int x = 42; // Stored on the Stack

The JVM promotes short-lived objects in the Young Generation and long-lived ones to the Old Generation. Garbage collection removes unreachable objects automatically.

Conclusion

Java’s object-oriented foundation is rich and nuanced. Understanding its key principles and features—from polymorphism to memory management—helps you build clean, maintainable, and scalable software. These concepts are central not just for interviews but for professional-level development.

By writing real-world code and continuously reflecting on OOP principles, you not only deepen your understanding but also unlock the full expressive power of Java.

How did you like the theory?
Report a typo