Java tutorials  /  Object-Oriented Programming in Java (OOP Concepts)
Chapter 9 · Java

Object-Oriented Programming in Java (OOP Concepts)

Object-Oriented Programming (OOP) is a programming paradigm that organizes software design around objects — self-contained units that bundle data (fields) and behavior (methods) together — rather than functions and logic alone. Java is fundamentally built around OOP and rests on four core pillars: Encapsulation, Inheritance, Polymorphism, and Abstraction.

Think of OOP like designing a car manufacturing blueprint. A 'Car' class is the blueprint itself, defining what every car will have (fields like color, speed) and what every car can do (methods like accelerate, brake). Each individual car built from that blueprint (a 'Toyota Camry' or a 'Honda Civic') is an 'object' — a specific instance with its own actual color and speed values, but sharing the same underlying structure and behaviors defined in the blueprint.

Consider a hospital management system. An abstract 'Employee' class defines shared attributes like name and employee ID (encapsulation groups this data with related methods). A 'Doctor' class and a 'Nurse' class both 'inherit' from Employee, reusing common fields while adding their own specific attributes (like 'specialization' for Doctor, or 'shift' for Nurse) — this is inheritance. When the system calls a generic 'performDuties()' method on a list containing both Doctor and Nurse objects, each object executes its own specific version of that behavior (a doctor diagnoses, a nurse administers medication) — this is polymorphism in action, a pattern used constantly in real enterprise systems to handle diverse but related entities uniformly.

As software systems grow larger and more complex, organizing code purely around standalone functions and global data becomes unmanageable, error-prone, and hard to extend. OOP addresses this by modeling real-world entities as objects with clearly defined data and behavior, promoting code reusability (through inheritance), protecting data integrity (through encapsulation), enabling flexible and extensible designs (through polymorphism and abstraction), and making large codebases significantly easier to understand, test, and maintain by mirroring how we naturally think about real-world systems and their relationships.

  • Encapsulation: The bundling of data (fields) and the methods that operate on that data within a single class, while restricting direct external access to internal fields (typically using 'private' access modifiers and public getter/setter methods) to protect data integrity.
  • Inheritance: A mechanism where a new class (subclass/child) acquires the fields and methods of an existing class (superclass/parent) using the 'extends' keyword, promoting code reuse and establishing an 'is-a' relationship between classes.
  • Polymorphism: The ability of an object to take on many forms, primarily achieved through method overriding (runtime polymorphism) and method overloading (compile-time polymorphism), allowing the same method call to produce different behaviors depending on the actual object type.
  • Abstraction: The concept of hiding complex implementation details and exposing only essential features to the user, typically achieved in Java using abstract classes and interfaces, allowing developers to work with high-level concepts without needing to know the underlying complexity.
// Encapsulation: class ClassName { private dataType fieldName; public dataType getFieldName() { return fieldName; } public void setFieldName(dataType value) { this.fieldName = value; } } // Inheritance: class ChildClass extends ParentClass { } // Polymorphism (Overriding): @Override methodName() { }
A beginner wants to model a simple zoo system with a general 'Animal' class defining shared behavior, and specific 'Dog' and 'Cat' subclasses that inherit from Animal but override a 'makeSound()' method to produce their own unique sound — a classic scenario demonstrating inheritance and runtime polymorphism together.
Encapsulation: Protecting Data with Private Fields
This example demonstrates encapsulation by making a bank account's balance field private and only allowing controlled access through public getter and deposit methods, preventing invalid direct modification.
Java
class BankAccount { private double balance; public BankAccount(double initialBalance) { this.balance = initialBalance; } public double getBalance() { return balance; } public void deposit(double amount) { if (amount > 0) { balance += amount; } } } public class EncapsulationDemo { public static void main(String[] args) { BankAccount account = new BankAccount(1000.0); account.deposit(500.0); System.out.println("Current Balance: " + account.getBalance()); } }
Current Balance: 1500.0
The 'balance' field is marked 'private', meaning it cannot be accessed or modified directly from outside the 'BankAccount' class (e.g., 'account.balance = -500;' would cause a compile error). Instead, all interaction must go through the public 'deposit()' method, which includes a validation check ('amount > 0') to prevent invalid negative deposits, and the public 'getBalance()' method, which safely exposes the current value without allowing direct external modification.
Inheritance and Runtime Polymorphism via Method Overriding
This example creates an Animal superclass with a Dog and Cat subclass, each overriding the 'makeSound()' method, demonstrating how the same method call produces different behavior based on the actual object type at runtime.
Java
class Animal { public void makeSound() { System.out.println("Some generic animal sound"); } } class Dog extends Animal { @Override public void makeSound() { System.out.println("Bark!"); } } class Cat extends Animal { @Override public void makeSound() { System.out.println("Meow!"); } } public class PolymorphismDemo { public static void main(String[] args) { Animal myAnimal = new Dog(); myAnimal.makeSound(); myAnimal = new Cat(); myAnimal.makeSound(); } }
Bark! Meow!
Both 'Dog' and 'Cat' use 'extends Animal', inheriting from the common superclass and overriding its 'makeSound()' method with their own specific implementation, marked with the '@Override' annotation for clarity and compile-time safety. Even though the reference variable 'myAnimal' is declared as type 'Animal', Java uses the object's actual runtime type (Dog, then Cat) to determine which overridden version of 'makeSound()' to execute — this dynamic method dispatch is the essence of runtime polymorphism.
  • Making Fields Public Instead of Private (Breaking Encapsulation): Declaring class fields as 'public' allows any external code to directly modify them without any validation, bypassing the entire purpose of encapsulation. For example, a public 'balance' field could be set to a negative value directly ('account.balance = -1000;'), completely circumventing any business logic meant to prevent invalid states.
  • Confusing 'IS-A' and 'HAS-A' Relationships: Beginners sometimes incorrectly use inheritance ('extends') for relationships that are actually 'HAS-A' (composition) rather than 'IS-A'. For example, a 'Car' should NOT extend 'Engine' (a car is not a type of engine), but rather a 'Car' class should HAVE an 'Engine' field as an instance variable, since a car 'has an' engine as a component, which is a composition relationship, not an inheritance one.
  • Forgetting the @Override Annotation: While technically optional, omitting '@Override' when intending to override a parent method means the compiler won't catch simple typos in the method signature (like a misspelled method name or wrong parameter type). Without this annotation, such a typo silently creates an entirely new, separate method (overloading, not overriding) rather than actually overriding the parent's method, leading to confusing runtime behavior where the 'overridden' logic never actually executes.
  • Trying to Call a Subclass-Specific Method Through a Superclass Reference: When an object is referenced using its superclass type (e.g., 'Animal myAnimal = new Dog();'), only methods and fields defined in the 'Animal' class (or overridden from it) are directly accessible through 'myAnimal', even though the actual object is a 'Dog'. Calling a Dog-specific method not present in Animal (like 'myAnimal.fetch();') results in a compile-time error, since the compiler only checks against the reference variable's declared type, not the object's actual runtime type — this requires explicit downcasting ('((Dog) myAnimal).fetch();') to resolve.
  • Always Make Fields Private and Provide Public Getters/Setters: Follow the encapsulation principle strictly by declaring class fields as 'private' and exposing controlled access through public getter and setter methods, allowing validation logic to be added in setters (like preventing negative ages) without breaking any external code that already calls the getter/setter methods.
  • Favor Composition Over Inheritance When Appropriate: Before using inheritance, verify the relationship is genuinely 'IS-A' (a Dog IS AN Animal). If the relationship is actually 'HAS-A' (a Car HAS AN Engine), use composition instead — declaring the other class as a field — since overusing inheritance for convenience can create rigid, fragile class hierarchies that are hard to modify later.
  • Always Use @Override When Overriding Methods: Consistently annotate every intended method override with '@Override'. This allows the compiler to immediately catch and flag any mismatched method signature as a compile-time error, rather than silently creating an unintended overloaded method that never actually gets called as expected.
  • Program to an Interface or Abstract Class, Not a Concrete Implementation: Where possible, declare reference variables using an interface or abstract superclass type (e.g., 'List<String> names = new ArrayList<>();' instead of 'ArrayList<String> names = new ArrayList<>();'), which makes code more flexible and allows the underlying concrete implementation to be swapped later with minimal changes to the rest of the codebase.
What are the four main pillars of Object-Oriented Programming, and briefly describe each?
The four pillars are: 1) Encapsulation — bundling data and related methods together within a class while restricting direct external access to internal fields, typically using private fields with public getter/setter methods. 2) Inheritance — allowing a subclass to reuse and extend the fields and methods of an existing superclass, establishing an 'is-a' relationship and promoting code reuse. 3) Polymorphism — the ability for the same method call to behave differently depending on the actual object type, achieved through method overriding (runtime) and method overloading (compile-time). 4) Abstraction — hiding complex implementation details and exposing only essential, high-level features, typically implemented using abstract classes and interfaces in Java.
What is the difference between compile-time polymorphism and runtime polymorphism in Java?
Compile-time polymorphism (also called static polymorphism) is achieved through method overloading, where the Java compiler determines which specific overloaded method to call based on the number and types of arguments at compile time itself, before the program even runs. Runtime polymorphism (also called dynamic polymorphism) is achieved through method overriding, where the decision of which specific method implementation to execute is deferred until the program is actually running, based on the actual object type stored in memory (not the reference variable's declared type) — this is why calling an overridden method through a superclass reference variable still correctly executes the subclass's specific version, a mechanism known as dynamic method dispatch.
What is the difference between an abstract class and an interface in Java, and when would you choose one over the other?
An abstract class can contain both abstract methods (without implementation) and concrete methods (with implementation), along with instance fields and constructors, but a class can only extend one abstract class (Java doesn't support multiple class inheritance). An interface, prior to Java 8, could only contain abstract method signatures (no implementation), but since Java 8, it can also include 'default' and 'static' methods with implementations; a class can implement multiple interfaces simultaneously. Generally, use an abstract class when subclasses share significant common state or implementation and are closely related in an 'is-a' hierarchy; use an interface when you want to define a contract of capabilities that potentially unrelated classes can implement, especially when a class might need to fulfill multiple such contracts simultaneously.
Create a class 'Person' with private fields 'name' and 'age', along with public getter methods for both and a setter for 'age' that rejects negative values. Demonstrate this in a main method.
class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } public void setAge(int age) { if (age >= 0) { this.age = age; } else { System.out.println("Invalid age, must be non-negative."); } } } public class PersonDemo { public static void main(String[] args) { Person person = new Person("Alice", 25); person.setAge(-5); System.out.println(person.getName() + " is " + person.getAge() + " years old."); } } // Output: // Invalid age, must be non-negative. // Alice is 25 years old.
Create a 'Shape' superclass with a method 'calculateArea()' returning 0, and two subclasses 'Circle' and 'Rectangle' that override this method with their own specific area formulas. Demonstrate polymorphism by calling calculateArea() on both through a Shape reference.
class Shape { public double calculateArea() { return 0; } } class Circle extends Shape { private double radius; public Circle(double radius) { this.radius = radius; } @Override public double calculateArea() { return Math.PI * radius * radius; } } class Rectangle extends Shape { private double length, width; public Rectangle(double length, double width) { this.length = length; this.width = width; } @Override public double calculateArea() { return length * width; } } public class ShapeDemo { public static void main(String[] args) { Shape shape1 = new Circle(5); Shape shape2 = new Rectangle(4, 6); System.out.println("Circle Area: " + shape1.calculateArea()); System.out.println("Rectangle Area: " + shape2.calculateArea()); } } // Output: // Circle Area: 78.53981633974483 // Rectangle Area: 24.0
Explain, using a code example, why attempting to call a Dog-specific method through an Animal reference variable causes a compile-time error, and show how to fix it.
class Animal { public void eat() { System.out.println("Eating"); } } class Dog extends Animal { public void fetch() { System.out.println("Fetching the ball"); } } public class DowncastDemo { public static void main(String[] args) { Animal myAnimal = new Dog(); // myAnimal.fetch(); // Compile Error: cannot find symbol 'fetch' in Animal if (myAnimal instanceof Dog) { Dog myDog = (Dog) myAnimal; myDog.fetch(); } } } // Output: // Fetching the ball // The compiler only allows calling methods that exist on the reference variable's DECLARED type (Animal), regardless of the object's actual runtime type. Since 'fetch()' is not defined in Animal, calling it directly on 'myAnimal' fails to compile. The fix requires an explicit downcast '(Dog) myAnimal' to temporarily treat the reference as a Dog, after safely verifying the object's actual type using 'instanceof' to avoid a ClassCastException.

Object-Oriented Programming in Java is built on four foundational pillars — Encapsulation (protecting data via private fields and public accessors), Inheritance (reusing code through an 'is-a' class hierarchy), Polymorphism (allowing the same method call to behave differently based on runtime object type), and Abstraction (hiding implementation complexity behind clean, high-level interfaces). Mastering these concepts, along with related nuances like the IS-A vs HAS-A distinction and proper use of @Override, is essential for designing maintainable, extensible, real-world Java applications.

© 2026 CompileX. Maintained by Aditya Kumar Sharma.