DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

OOP in Java: What Really Happens Behind the Scenes

When I first learned OOP in Java, I could repeat the four pillars like a poem: encapsulation, inheritance, polymorphism, abstraction. But if you had asked me what actually happens in memory when I write new Dog(), I would have gone quiet.

That gap is what confused me most. I knew the words, but not the machine behind them. And without that picture, things like "why did changing one object change another?" or "how does Java know which method to call?" felt like magic.

In this post, we'll learn each OOP idea and then peek behind the curtain to see what the JVM does. If you've read my post on JDK, JRE and JVM, this is a nice follow-up, because the JVM is the star here too.

First, a quick picture of memory

Before any OOP, you need a simple map of where things live. The JVM mostly uses three areas for our code:

  • Stack: small, fast memory. Every method call gets its own box here (called a frame) holding its local variables.
  • Heap: the big shared area where objects live.
  • Metaspace (class area): where the JVM keeps information about each class, like its name, its methods and their bytecode.

Here is how they look together:

        STACK                       HEAP                    METASPACE
   (method call boxes)         (objects live here)        (class info)

  +------------------+      +----------------------+   +------------------+
  | main()           |      |  Dog object          |   | Dog class        |
  |  d1 -----------------+->|   name = "Bruno"     |   |  - bark()        |
  +------------------+   |  |   age  = 3           |   |  - constructor   |
                         |  +----------------------+   +------------------+
                         |
                         +-- d1 only holds the ADDRESS of the object
Enter fullscreen mode Exit fullscreen mode

Keep this picture in your head. One idea matters most: a variable like d1 does not hold the object. It holds a reference, which you can think of as the object's address.

Classes and objects

A class is a blueprint. An object is a real thing built from that blueprint.

public class Dog {
    String name;
    int age;

    Dog(String name, int age) {
        this.name = name;
        this.age = age;
    }

    void bark() {
        System.out.println(name + " says Woof!");
    }

    public static void main(String[] args) {
        Dog d1 = new Dog("Bruno", 3);
        d1.bark();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Bruno says Woof!
Enter fullscreen mode Exit fullscreen mode

What happens when you write new Dog("Bruno", 3)

This one line does quite a lot. In simple steps:

  1. Load the class. If the JVM hasn't seen Dog yet, it loads Dog.class and stores the class info in Metaspace.
  2. Allocate memory. The JVM reserves space on the heap for one Dog object. That space holds name, age, plus a small hidden header (it includes a link back to the class).
  3. Set default values. Before your code runs, every field gets a default: 0 for numbers, false for booleans, null for references.
  4. Run the constructor. Your constructor fills in the real values.
  5. Return the reference. The address of the new object is stored in d1 on the stack.

You can see this in the bytecode. If you run javap -c Dog (a tool that ships with the JDK), you'll see something close to this inside main:

new           Dog          // create the object on the heap
dup                        // keep a copy of the reference
ldc           "Bruno"
iconst_3
invokespecial Dog.<init>   // run the constructor
astore_1                   // store the reference in d1
Enter fullscreen mode Exit fullscreen mode

The exact lines can vary a little by Java version, but the idea is always the same: create, then construct, then store.

Why this matters: references are copied, not objects

Dog d1 = new Dog("Bruno", 3);
Dog d2 = d1;          // copies the address, NOT the object
d2.name = "Tiger";

System.out.println(d1.name);
Enter fullscreen mode Exit fullscreen mode

Output:

Tiger
Enter fullscreen mode Exit fullscreen mode

Surprised? Both d1 and d2 point to the same object on the heap. There is only one Dog.

  d1 ----\
          +--->  [ Dog: name="Tiger", age=3 ]
  d2 ----/
Enter fullscreen mode Exit fullscreen mode

Once this clicked for me, a lot of "weird bugs" stopped being weird.

What is this?

Inside a constructor or method, this means "the object I'm working on right now."

Behind the scenes, every instance method secretly receives the object as a hidden first argument. You can think of d1.bark() as bark(d1). That's how one bark() method, stored once in Metaspace, works correctly for every Dog. It knows which dog to use because the object is passed in.

Static vs instance

  • Instance fields and methods belong to each object. Every Dog has its own name.
  • Static fields and methods belong to the class. There is only one copy, shared by all objects.
class Counter {
    static int total = 0;   // one shared copy
    int id;                 // one per object

    Counter() {
        total++;
        id = total;
    }
}
Enter fullscreen mode Exit fullscreen mode

If you create three Counter objects, Counter.total is 3, and each object has its own id.

Encapsulation: protect your data

Encapsulation means keeping your fields private and letting others touch them only through methods you control.

class BankAccount {
    private double balance;

    void deposit(double amount) {
        if (amount <= 0) {
            System.out.println("Invalid amount");
            return;
        }
        balance += amount;
    }

    double getBalance() {
        return balance;
    }
}
Enter fullscreen mode Exit fullscreen mode
BankAccount acc = new BankAccount();
acc.deposit(500);
acc.deposit(-100);
System.out.println(acc.getBalance());
Enter fullscreen mode Exit fullscreen mode

Output:

Invalid amount
500.0
Enter fullscreen mode Exit fullscreen mode

Why bother? Because if balance were public, anyone could write acc.balance = -99999. With a private field, all changes go through deposit, and that's where you put your rules. If the rules change later, you fix one place.

Behind the scenes, the compiler refuses to compile code that touches a private field from outside the class, and the JVM checks access again when the code runs. (Advanced tools like reflection can bypass this, but normal code can't.)

Inheritance: reuse and extend

Inheritance lets one class build on another using extends. The child gets the parent's fields and methods.

class Animal {
    Animal() {
        System.out.println("Animal constructor");
    }

    void eat() {
        System.out.println("Eating...");
    }
}

class Dog extends Animal {
    Dog() {
        System.out.println("Dog constructor");
    }

    void bark() {
        System.out.println("Woof!");
    }
}
Enter fullscreen mode Exit fullscreen mode
Dog d = new Dog();
d.eat();
d.bark();
Enter fullscreen mode Exit fullscreen mode

Output:

Animal constructor
Dog constructor
Eating...
Woof!
Enter fullscreen mode Exit fullscreen mode

What happens behind the scenes

When you create a Dog, the heap object contains the parent's fields and the child's fields together, in one object. There's no separate Animal object hiding inside.

  Dog object on the heap
  +---------------------------+
  | header                    |
  | Animal fields (inherited) |
  | Dog fields (its own)      |
  +---------------------------+
Enter fullscreen mode Exit fullscreen mode

Also notice the output order: "Animal constructor" printed first. The first line of every constructor secretly calls the parent's constructor with super(), unless you write it yourself. So the chain always runs from the top parent down to the child. At the very top of every chain sits the class Object, which every Java class extends.

Polymorphism: one call, many behaviors

Polymorphism means "many forms." In Java it shows up in two ways, and the difference is all about when Java decides which method to run.

1. Method overloading (decided at compile time)

Same method name, different parameters.

class Printer {
    void print(int x)    { System.out.println("int: " + x); }
    void print(String s) { System.out.println("String: " + s); }
}
Enter fullscreen mode Exit fullscreen mode
Printer p = new Printer();
p.print(10);
p.print("Hi");
Enter fullscreen mode Exit fullscreen mode

Output:

int: 10
String: Hi
Enter fullscreen mode Exit fullscreen mode

The compiler looks at the argument types and picks the right version before the program even runs.

2. Method overriding (decided at run time)

A child class replaces the parent's version of a method.

class Animal {
    void sound() { System.out.println("Some sound"); }
}

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

class Cat extends Animal {
    @Override
    void sound() { System.out.println("Meow"); }
}
Enter fullscreen mode Exit fullscreen mode
Animal a1 = new Dog();
Animal a2 = new Cat();

a1.sound();
a2.sound();
Enter fullscreen mode Exit fullscreen mode

Output:

Woof
Meow
Enter fullscreen mode Exit fullscreen mode

Look closely. Both variables have the type Animal, yet different methods run. How does Java know?

The behind-the-scenes magic: dynamic dispatch

The compiler can't know whether a1 will hold a Dog or a Cat, because that could change while the program runs. So it doesn't decide. Instead, it writes a bytecode instruction (invokevirtual) that means "look at the real object and find its method."

At run time, the JVM follows the object's header to its class, then looks in that class's method table for sound(). A Dog object leads to Dog's table, so you get "Woof".

  a1 (type Animal) ---> [ Dog object ] ---> Dog class ---> sound() => "Woof"
  a2 (type Animal) ---> [ Cat object ] ---> Cat class ---> sound() => "Meow"
Enter fullscreen mode Exit fullscreen mode

Real JVMs use clever tricks to make this lookup very fast, but the idea is just that: the method is chosen by the real object, not by the variable's type. This is called dynamic dispatch, and it's the engine behind polymorphism.

The @Override annotation is worth using every time. If you misspell the method name or get a parameter wrong, the compiler gives you an error instead of silently creating a new method.

Abstraction: show what, hide how

Abstraction means exposing what something does and hiding how it does it. Java gives you two tools: abstract classes and interfaces.

abstract class Shape {
    abstract double area();   // no body: children must provide it

    void describe() {
        System.out.println("Area is " + area());
    }
}

class Circle extends Shape {
    double r;
    Circle(double r) { this.r = r; }

    double area() { return Math.PI * r * r; }
}

class Rectangle extends Shape {
    double w, h;
    Rectangle(double w, double h) { this.w = w; this.h = h; }

    double area() { return w * h; }
}
Enter fullscreen mode Exit fullscreen mode
Shape s1 = new Circle(2);
Shape s2 = new Rectangle(3, 4);

s1.describe();
s2.describe();
Enter fullscreen mode Exit fullscreen mode

Output:

Area is 12.566370614359172
Area is 12.0
Enter fullscreen mode Exit fullscreen mode

You can't write new Shape(), because an abstract class is incomplete on purpose. The compiler stops you. But notice how nicely it works with polymorphism: describe() is written once, and it calls area(), which resolves to the right version through dynamic dispatch.

An interface is a pure contract. It says "any class that signs this must have these methods."

interface Flyable {
    void fly();
}

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

class Plane implements Flyable {
    public void fly() { System.out.println("Plane uses its engines"); }
}
Enter fullscreen mode Exit fullscreen mode

A bird and a plane have nothing in common in real life, yet both can be used as Flyable. A class can extend only one parent class, but it can implement many interfaces, which is one reason interfaces are so popular.

What happens when objects are no longer needed?

You never delete objects in Java. The garbage collector does it for you.

Dog d = new Dog("Bruno", 3);
d = null;   // nothing points to the Dog now
Enter fullscreen mode Exit fullscreen mode

After this, no reference points to the Dog object on the heap. The JVM sees that nobody can reach it anymore, and it may free that memory later. You don't control exactly when, and that's by design. It saves you from a whole class of memory bugs common in languages like C.

Which one should I use?

Here is how I decide between the common choices:

  • Inheritance or composition? Use inheritance only for a true "is-a" relationship (a Dog is an Animal). If it's a "has-a" relationship (a Car has an Engine), put an Engine object inside Car instead. Composition is more flexible and doesn't lock you into a deep family tree.
  • Abstract class or interface? Use an abstract class when related classes share code or fields (like Shape). Use an interface when you only need a contract, especially for unrelated classes (like Flyable).
  • Overloading or overriding? Overload when the same action needs different inputs. Override when a child needs its own version of the parent's behavior.
  • Static or instance? If the data or method belongs to each object, make it instance. If it's shared by all, or it's a pure helper that needs no object (like Math.max), make it static.

Common mistakes

  1. Thinking d2 = d1 copies the object. It only copies the reference. Both variables now point to one object. If you need a real copy, create a new object.
  2. Comparing objects with ==. For objects, == checks whether both sides are the same object in memory, not whether they look equal. Use .equals(), and override equals() and hashCode() in your own classes when equality should depend on values.
  3. Making fields public. It's quick, but it gives up control. Use private fields with methods.
  4. Expecting fields to be polymorphic. Only instance methods are chosen by the real object. Fields and static methods are chosen by the variable's type. So Animal a = new Dog(); reading a.name uses Animal's field, even if Dog has its own name.
  5. Overusing inheritance. Deep family trees become hard to change. Prefer composition when "is-a" doesn't clearly fit.
  6. Forgetting to create the object. Dog d; or Dog d = null; followed by d.bark() throws a NullPointerException, because there is no object at that address.

Quick recap

  • A class is a blueprint, an object is a real thing on the heap, and a variable holds only a reference to it.
  • new loads the class, allocates heap memory, sets default values, runs the constructor and returns a reference.
  • Encapsulation: private fields, public methods, one place for your rules.
  • Inheritance: one object holds parent and child fields, and constructors run from parent to child.
  • Polymorphism: overloading is decided by the compiler, overriding is decided at run time by the real object (dynamic dispatch).
  • Abstraction: abstract classes and interfaces define what, children define how.
  • The garbage collector frees objects that nobody points to anymore.

Try it yourself

Here's a small practice idea: build a tiny zoo. Make an abstract class Animal with a sound() method, add three child classes, put them all in an Animal[] array, and loop through it calling sound(). Then add a static int count that goes up each time an animal is created. You'll use almost everything from this post in about 30 lines.

If you want to go one step deeper, run javap -c on your own class and read the bytecode. Seeing new, invokespecial and invokevirtual with your own eyes makes all of this feel real.

If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.

Top comments (0)