oop Lesson 8 20 min read

Objects in Memory

References, aliasing, null, and garbage collection

Reading: Reges & Stepp: Ch. 8–9

After this lesson, you will be able to:

  • Explain where primitives and objects are stored in memory (stack vs. heap)
  • Draw accurate memory diagrams showing references from stack to heap
  • Predict the behavior of aliased object references
  • Identify when NullPointerException will occur and write defensive null checks
  • Trace what happens when objects are passed to methods
  • Describe how garbage collection reclaims unreachable objects

The Question That Breaks Beginners

Look at this code and predict the output before reading further:

Student alice = new Student("Alice", 3.8, 1001);
Student bob = alice;
bob.setGpa(4.0);

System.out.println(alice.getGpa());

If you guessed 3.8, you are wrong. The answer is 4.0. If that surprises you, this lesson will explain exactly why. The key is understanding what variables actually store when they hold objects.

From CSCD 110: In Python, the same thing happens: a = [1, 2, 3]; b = a; b[0] = 99 changes a[0] too, because lists are mutable objects and b = a copies the reference, not the list. Java works the same way for all objects, but makes it more visible because you must think about types.


Stack vs. Heap: Two Regions of Memory

Java uses two distinct regions of memory. Understanding the difference is essential.

The stack is where local variables and method call frames live. It is fast, small, and automatically cleaned up when a method returns. Each method call creates a new frame on the stack; when the method finishes, that frame is destroyed.

The heap is where objects live. It is large, slower, and managed by the garbage collector. Objects persist on the heap as long as at least one reference points to them.

Here is the rule:

Primitives (int, double, boolean, char) → value stored directly on the stack
Objects (Student, String, arrays)       → object on the heap, reference on the stack

Memory Diagram: Primitives vs. Objects

After this code runs:

int age = 25;
double gpa = 3.8;
Student alice = new Student("Alice", 3.8, 1001);

Memory looks like this:

         STACK                          HEAP
    ┌──────────────┐            ┌──────────────────────┐
    │ age:  25     │            │                      │
    ├──────────────┤            │  @5024: Student      │
    │ gpa:  3.8    │            │    name ──► "Alice"  │
    ├──────────────┤            │    gpa = 3.8         │
    │ alice: @5024 ─┼──────────►│    id  = 1001        │
    └──────────────┘            │                      │
                                └──────────────────────┘

age and gpa hold their actual values directly on the stack. alice holds a reference — a memory address (@5024) — that points to the Student object on the heap. The variable alice is not the student. It is an arrow pointing to the student.

Key Insight: Primitives store values. Object variables store references (arrows). This single distinction explains aliasing, == vs. .equals(), null pointer exceptions, and how methods interact with objects.


References and Aliasing

A reference is a memory address — an arrow pointing to an object on the heap. When you assign one object variable to another, you copy the reference, not the object:

Student alice = new Student("Alice", 3.8, 1001);
Student bob = alice;  // bob now points to the SAME object
         STACK                          HEAP
    ┌──────────────┐            ┌──────────────────────┐
    │ alice: @5024 ─┼─────┐     │                      │
    ├──────────────┤      ├────►│  @5024: Student      │
    │ bob:   @5024 ─┼─────┘     │    name ──► "Alice"  │
    └──────────────┘            │    gpa = 3.8         │
                                │    id  = 1001        │
                                └──────────────────────┘

Both alice and bob hold the same address. They are two names for the same object. This is called aliasing.

Now the opening puzzle makes sense:

Student alice = new Student("Alice", 3.8, 1001);
Student bob = alice;     // alias — same object
bob.setGpa(4.0);         // modifies the shared object

System.out.println(alice.getGpa());  // 4.0

There is only one Student object. Both variables point to it. Changing it through bob changes what alice sees, because they are the same object.

Common Pitfall: Student bob = alice; does NOT create a copy of Alice. It creates a second reference to the same object. To make an actual independent copy, you must create a new object:

Student bob = new Student(alice.getName(), alice.getGpa(), alice.getId());
Check Your Understanding

After this code runs, how many Student objects exist on the heap?
Student a = new Student("A", 3.0, 1);
Student b = new Student("B", 3.5, 2);
Student c = a;


== vs. .equals(): References vs. Content

Now you can understand a critical distinction. The == operator compares references (memory addresses), not content:

Student alice1 = new Student("Alice", 3.8, 1001);
Student alice2 = new Student("Alice", 3.8, 1001);

System.out.println(alice1 == alice2);      // false — different objects
System.out.println(alice1.equals(alice2)); // true  — same content (if equals is overridden)
         STACK                          HEAP
    ┌───────────────┐           ┌──────────────────────┐
    │ alice1: @5024 ─┼─────────►│  @5024: Student      │
    ├───────────────┤           │    name ──► "Alice"  │
    │ alice2: @5048 ─┼───┐      │    gpa = 3.8         │
    └───────────────┘    │      │    id  = 1001        │
                         │      ├──────────────────────┤
                         └─────►│  @5048: Student      │
                                │    name ──► "Alice"  │
                                │    gpa = 3.8         │
                                │    id  = 1001        │
                                └──────────────────────┘

Two separate objects with identical data. == returns false because the arrows point to different locations. .equals() returns true because the content matches (assuming you overrode equals to compare by ID or fields).

The same applies to String:

String s1 = new String("Hello");
String s2 = new String("Hello");

System.out.println(s1 == s2);      // false — different objects
System.out.println(s1.equals(s2)); // true  — same text

The Trick: For objects, always use .equals() to compare content. The == operator only tells you whether two variables point to the same object in memory, not whether they represent the same data.


Null: The Absence of a Reference

What if an object variable does not point to anything? Java uses the special value null:

Student alice = null;  // alice points to nothing
         STACK                          HEAP
    ┌──────────────┐            ┌──────────────────────┐
    │ alice: null   │            │   (nothing here)     │
    └──────────────┘            └──────────────────────┘

null means “no object.” The variable exists on the stack but its arrow points nowhere.

If you try to follow a null reference — calling a method or accessing a field — Java throws a NullPointerException:

Student alice = null;
System.out.println(alice.getGpa());  // NullPointerException!

This is one of the most common runtime errors in Java. The fix is a null check:

if (alice != null) {
    System.out.println(alice.getGpa());
} else {
    System.out.println("No student assigned.");
}

A common pattern with arrays of objects:

Student[] roster = new Student[5];
// All 5 slots are null by default!

roster[0] = new Student("Alice", 3.8, 1001);
// roster[1] through roster[4] are still null

for (int i = 0; i < roster.length; i++) {
    if (roster[i] != null) {
        System.out.println(roster[i]);
    }
}

When you create an object array with new Student[5], Java allocates 5 reference slots, all initialized to null. No Student objects exist yet. You must create them individually with new.

Check Your Understanding

What happens when this code runs?
String[] words = new String[3];
System.out.println(words[0].length());


Passing Objects to Methods

When you pass a primitive to a method, the method receives a copy of the value. The original variable cannot change:

public static void tryToChange(int x) {
    x = 99;  // changes the local copy only
}

int num = 5;
tryToChange(num);
System.out.println(num);  // still 5

When you pass an object to a method, the method receives a copy of the reference. The reference is copied, but it still points to the same object on the heap:

public static void giveRaise(final Student s) {
    s.setGpa(4.0);  // modifies the SHARED object
}

Student alice = new Student("Alice", 3.8, 1001);
giveRaise(alice);
System.out.println(alice.getGpa());  // 4.0 — the object changed!
  main's stack frame         giveRaise's frame           HEAP
  ┌──────────────┐          ┌──────────────┐    ┌──────────────────┐
  │ alice: @5024 ─┼────┐    │ s:     @5024 ─┼──►│ @5024: Student   │
  └──────────────┘     └───►│              │    │   gpa: 3.8→4.0  │
                             └──────────────┘    └──────────────────┘

Both alice (in main) and s (in giveRaise) point to the same heap object. When giveRaise modifies the object through s, the change is visible through alice because there is only one object.

However, the method cannot reassign the caller’s variable:

public static void tryToReplace(Student s) {
    s = new Student("Bob", 3.0, 1002);  // reassigns LOCAL copy of reference
}

Student alice = new Student("Alice", 3.8, 1001);
tryToReplace(alice);
System.out.println(alice.getName());  // still "Alice"
  main's stack frame         tryToReplace's frame          HEAP
  ┌──────────────┐          ┌──────────────┐    ┌──────────────────┐
  │ alice: @5024 ─┼────────►│              │    │ @5024: Student   │
  └──────────────┘          │ s:     @5048 ─┼──►│   name: "Alice"  │
                             └──────────────┘    ├──────────────────┤
                                                 │ @5048: Student   │
                                                 │   name: "Bob"    │
                                                 └──────────────────┘

Inside tryToReplace, s is a local copy of the reference. Reassigning s to point to a new object does not affect alice in main. The original reference is unchanged.

Key Insight: Java is always pass-by-value. For primitives, the value is the data. For objects, the value is the reference (the arrow). The method gets its own copy of the arrow, which still points to the same object. The method can follow the arrow and modify the object, but it cannot change where the caller’s arrow points.

Check Your Understanding

What does this code print?
public static void reset(Student s) { s = null; }
Student alice = new Student("Alice", 3.8, 1001);
reset(alice);
System.out.println(alice.getName());


Garbage Collection

When no variable in the program points to an object, that object is unreachable. Java’s garbage collector automatically reclaims the memory:

Student alice = new Student("Alice", 3.8, 1001);
alice = null;  // the Student object is now unreachable
// Garbage collector will eventually free that memory
         STACK                          HEAP
    ┌──────────────┐            ┌──────────────────────┐
    │ alice: null   │            │  @5024: Student      │ ← no references!
    └──────────────┘            │    (garbage)         │
                                └──────────────────────┘

Another example:

Student alice = new Student("Alice", 3.8, 1001);
alice = new Student("Bob", 3.5, 1002);
// The original "Alice" Student object is now garbage

After the second assignment, alice points to the new “Bob” object. Nobody points to the “Alice” object anymore, so it is eligible for garbage collection.

You do not need to manually free memory in Java. The garbage collector handles it. This is one of Java’s advantages over languages like C and C++, where you must call free() or delete yourself. In CSCD 240, you will manage memory manually and appreciate what the garbage collector does for you.


Complete Example: Tracing Exercise

Trace through this code and draw the memory diagram at each step. Then check your answer:

public class MemoryTrace {
    public static void main(final String[] args) {
        Student alice = new Student("Alice", 3.8, 1001);  // Step 1
        Student bob = new Student("Bob", 3.2, 1002);      // Step 2
        Student charlie = alice;                            // Step 3
        alice = bob;                                        // Step 4

        System.out.println("alice: " + alice);
        System.out.println("bob: " + bob);
        System.out.println("charlie: " + charlie);
    }
}

Step 1: Create Alice

    STACK                   HEAP
    ┌────────────────┐      ┌────────────────────┐
    │ alice:  @1000  ─┼─────►│ @1000: Student     │
    └────────────────┘      │   name: "Alice"    │
                            │   gpa:  3.8        │
                            └────────────────────┘

Step 2: Create Bob

    STACK                   HEAP
    ┌────────────────┐      ┌────────────────────┐
    │ alice:  @1000  ─┼─────►│ @1000: Student     │
    ├────────────────┤      │   name: "Alice"    │
    │ bob:    @2000  ─┼──┐   │   gpa:  3.8        │
    └────────────────┘   │  ├────────────────────┤
                         └─►│ @2000: Student     │
                            │   name: "Bob"      │
                            │   gpa:  3.2        │
                            └────────────────────┘

Step 3: charlie = alice (aliasing)

    STACK                   HEAP
    ┌────────────────┐      ┌────────────────────┐
    │ alice:   @1000 ─┼──┐   │ @1000: Student     │
    ├────────────────┤   ├──►│   name: "Alice"    │
    │ bob:     @2000 ─┼──┤   │   gpa:  3.8        │
    ├────────────────┤   │  ├────────────────────┤
    │ charlie: @1000 ─┼──┘   │ @2000: Student     │
    └────────────────┘  └───►│   name: "Bob"      │
                            │   gpa:  3.2        │
                            └────────────────────┘

Wait — bob’s arrow is wrong in the diagram above. Let me fix that. alice and charlie both point to @1000. bob points to @2000:

    STACK                   HEAP
    ┌────────────────┐      ┌────────────────────┐
    │ alice:   @1000 ─┼─┐    │ @1000: Student     │
    ├────────────────┤  ├───►│   name: "Alice"    │
    │ bob:     @2000 ─┼──┐   │   gpa:  3.8        │
    ├────────────────┤   │  ├────────────────────┤
    │ charlie: @1000 ─┼─┘   │ @2000: Student     │
    └────────────────┘  └──►│   name: "Bob"      │
                            │   gpa:  3.2        │
                            └────────────────────┘

Step 4: alice = bob (reassign alice)

    STACK                   HEAP
    ┌────────────────┐      ┌────────────────────┐
    │ alice:   @2000 ─┼──┐   │ @1000: Student     │
    ├────────────────┤   ├──►│   name: "Alice"    │ ◄── charlie points here
    │ bob:     @2000 ─┼──┘   │   gpa:  3.8        │
    ├────────────────┤      ├────────────────────┤
    │ charlie: @1000 ─┼─────►│ @2000: Student     │ ◄── alice AND bob point here
    └────────────────┘      │   name: "Bob"      │
                            │   gpa:  3.2        │
                            └────────────────────┘

Wait — the arrows crossed. Let me be precise:

  • alice now points to @2000 (Bob’s object)
  • bob still points to @2000 (Bob’s object)
  • charlie still points to @1000 (Alice’s object)

Output:

alice: Bob (ID: 1002, GPA: 3.2)
bob: Bob (ID: 1002, GPA: 3.2)
charlie: Alice (ID: 1001, GPA: 3.8)

Both objects are still reachable — one through charlie, the other through alice and bob. Nothing is garbage collected.

Check Your Understanding

In the tracing example above, if we add the line charlie = null; after Step 4, is the "Alice" Student object eligible for garbage collection?


Quick Reference

Concept Rule
Primitives Stored directly on the stack
Objects Stored on the heap, referenced from the stack
= with objects Copies the reference, not the object
Aliasing Two variables pointing to the same object
== on objects Compares references (memory addresses)
.equals() Compares content (if overridden)
null A reference that points to nothing
NullPointerException Calling a method on a null reference
Pass-by-value Primitives: copy of value. Objects: copy of reference
Garbage collection Automatic cleanup of unreachable objects

Summary

Java organizes memory into two regions: the stack (for primitives and references) and the heap (for objects). Object variables do not store objects — they store references that point to objects on the heap.

Assigning one object variable to another copies the reference, creating an alias. Both variables point to the same object, so changes through one are visible through the other. The == operator compares references; .equals() compares content.

null means a reference points to nothing. Following a null reference throws NullPointerException — check for null before using any object that might not be initialized. When no references point to an object, the garbage collector reclaims it automatically.

When you pass an object to a method, the method receives a copy of the reference. It can modify the object (both caller and method share the same heap object), but it cannot reassign the caller’s variable.

These concepts are not just theory. Every bug involving unexpected state changes, null pointer crashes, or mysterious side effects traces back to how references work. In CSCD 211 and CSCD 240, you will build on this foundation extensively.