CMP-220 · Lesson 3 of 12 · 22 min

Constructors, object lifetime and memory

Every object has a lifetime. It is born when memory is set aside and a constructor initializes it, it lives while the program can still reach it, and it dies when its memory is reclaimed. Getting the beginning right means every object starts in a valid state. Getting the end right means memory and other resources, such as files, sockets and database connections, are released promptly and exactly once. Java, Python and C++ handle the end of an object's life in three quite different ways, and a computer engineer who writes all three needs to know the differences. This lesson follows a real Hub incident from start to finish: a memory leak that slowly filled the server's heap over three weeks.

Constructors

A constructor is a special method that runs when an object is created and whose job is to put the new object into a valid state. In Java a constructor has the same name as its class and no return type; it is invoked with new, as in new Detection(17, 2, t, 455, 62). If a class declares no constructor at all, the compiler supplies a default constructor with no parameters that leaves every field at its default value: zero, false or null. As soon as you declare any constructor, the default one disappears, which is usually what you want, because a Detection with lane 0 and a null time would break the invariants from lesson 2.

A class may overload its constructor, offering several versions with different parameter lists. To avoid repeating validation code in each one, one constructor can call another with this(...), which must be its first statement. The usual pattern is that all the convenience constructors delegate to one complete constructor that does all the checking. In Python, a class has a single __init__ method, and default argument values play the role of overloading.

Constructor overloading with delegation through this(...), and a static factory method with a descriptive name.
public final class Node {
    private final int id;
    private final Set<Integer> lanes;
    private Instant lastUpload;            // null until the first upload

    public Node(int id, Set<Integer> lanes) {       // the complete constructor
        if (id < 1 || id > 65535) throw new IllegalArgumentException("id " + id);
        if (lanes.isEmpty()) throw new IllegalArgumentException("no lanes");
        for (int ln : lanes)
            if (ln < 1 || ln > 4) throw new IllegalArgumentException("lane " + ln);
        this.id = id;
        this.lanes = Set.copyOf(lanes);             // defensive copy, unmodifiable
    }

    public Node(int id) { this(id, Set.of(1, 2, 3, 4)); }   // delegates: watch all lanes

    public static Node fromConfigLine(String line) {        // static factory method
        String[] parts = line.split(",");
        int id = Integer.parseInt(parts[0].trim());
        Set<Integer> lanes = new HashSet<>();
        for (int i = 1; i < parts.length; i++) lanes.add(Integer.parseInt(parts[i].trim()));
        return new Node(id, lanes);
    }
}

The last method in the listing is a static factory method: a static method that returns a new object. Factories have advantages over constructors. They have names, so Node.fromConfigLine says what it does; they can return a cached object instead of a new one; and they can return a subclass chosen at run time. Java's own library is full of them, such as Integer.valueOf, List.of and Instant.parse. Lesson 10 builds on this idea with the factory and builder patterns.

A constructor should do only what is needed to establish the object's invariants. Constructors that open network connections, start threads or read large files make objects slow and surprising to create, hard to test, and dangerous if they fail halfway. Another hazard is calling an overridable method from a constructor: if a subclass overrides it, the overriding version runs before the subclass's own fields have been initialized, and sees them as zero or null. Lesson 5 returns to this when it covers inheritance.

Where objects live: the stack and the heap

In Java, every object lives on the heap, a large region of memory managed by the Java virtual machine. A variable of a class type does not contain the object; it contains a reference, which behaves like a pointer that you cannot do arithmetic on. Local variables, including references, live in the current method's stack frame, which disappears when the method returns. Primitive values such as int, long and double are stored directly in variables, without any object. So after Detection d = new Detection(...), the variable d in the stack frame holds a reference to a Detection object on the heap.

A diagram with two regions. On the left, a call stack with two frames, handleUpload and parseBatch, each holding variables. Arrows lead from the variables batch, d and n to objects in the heap region on the right: an ArrayList holding references to three Detection objects, and a Node object. A fourth Detection object in the heap has no arrows pointing to it and is drawn faded with the label unreachable: eligible for collection. A static field box labelled sessionCache at the top of the heap points to a HashMap that holds Session objects.
References in stack frames and static fields point to objects on the heap. An object that no chain of references can reach is garbage.

Because variables hold references, assignment copies the reference, not the object. After Detection e = d, both variables refer to the same object, a situation called aliasing. With immutable objects such as Detection, aliasing is harmless. With mutable objects it is a common source of bugs: two parts of the program share one list, and a change made through one reference surprises the code holding the other. The same is true in Python, where every variable is a reference to an object, and in C++ when you use pointers or references.

Identity and equality

Aliasing raises the question of what equal means. Identity asks whether two references point to the same object; in Java that is the == operator applied to references. Equality asks whether two objects represent the same value; that is the equals method. Two separate Detection objects with the same node, lane, time, length and speed are equal but not identical. A class that represents a value should override equals to compare its fields, and must then also override hashCode so that equal objects have equal hash codes; otherwise hash-based collections such as HashMap and HashSet, which use the hash code to choose a bucket as described in CMP-200, will fail to find objects that are logically present. Records generate both methods correctly. In Python, == calls __eq__ and is tests identity.

The end of life: garbage collection

Java reclaims memory automatically with a garbage collector. An object is reachable if a chain of references leads to it from a root: a local variable in some active stack frame, a static field, or a few other places the virtual machine knows about. Anything not reachable is garbage, and the collector reclaims it at a time of its choosing. Modern collectors are generational: most objects die young, so new objects are allocated in a small young region that is collected often and cheaply, and the few long-lived survivors are promoted to an old region collected less often. Collectors such as G1, the default in current Java versions, and ZGC aim to keep the pauses they cause to a few milliseconds or less.

Garbage collection removes two whole classes of C bugs: use after free and double free. It does not prevent memory leaks. In a garbage-collected language, a leak is a set of objects that the program no longer needs but that remain reachable, so the collector must keep them. The usual culprits are long-lived collections that only grow: caches with no size limit or expiry, listener lists that never remove listeners, and static maps keyed by things that come and go.

Worked problem

The session-cache leak

After a release, the Hub's heap use climbs from 2 GB to the 12 GB limit over about three weeks, and then the server spends most of its time collecting garbage and stops answering uploads. A heap dump shows 3.1 million Session objects held by a static HashMap<String, Session> called sessionCache. Each NS-4 opens a new upload session, with a new random session key, every time it reconnects. About 4,800 nodes each reconnect about 30 times a day. Explain the leak, estimate its rate, and propose a fix.

Show solution
  1. Each reconnect puts a new Session into the static map under a new key, and nothing ever removes old entries. A static field is a root, so every session stays reachable forever.
  2. \[4800 \times 30 \approx 144000 \text{ new sessions per day}\]
  3. Over three weeks:
  4. \[144000 \times 21 \approx 3.0 \times 10^{6} \text{ sessions}\]
  5. which matches the 3.1 million in the heap dump. Each session holds buffers of about 3 KB, so
  6. \[3.1 \times 10^{6} \times 3\ \text{KB} \approx 9.3\ \text{GB}\]
  7. Fix: remove a session when its connection closes, in a finally block or with try-with-resources, and replace the plain map with a bounded cache whose entries expire after 30 minutes without use.

Answer: The leak adds about 144,000 sessions, roughly 430 MB, per day and explains the 9 to 10 GB of growth. The cause is not the garbage collector but a reference the program forgot to drop.

Releasing other resources

Memory is not the only resource. Files, sockets and database connections are limited by the operating system and must be closed promptly; waiting for the garbage collector to notice an unreachable file object could take minutes or never happen. Java's answer is the AutoCloseable interface and the try-with-resources statement: any object declared in the parentheses after try is closed automatically when the block ends, whether normally or by an exception. Older Java code used finalize methods, which ran at the collector's whim, if at all; they were unreliable and have been removed from recommended practice. Python's equivalent of try-with-resources is the with statement and context managers.

try-with-resources guarantees that close runs exactly once, however the block ends.
// The fixed upload handler: the session is always removed and closed
void handleUpload(Socket socket) throws IOException {
    try (Session s = sessions.open(socket)) {       // Session implements AutoCloseable
        s.receiveBatch();
    }                                               // s.close() runs here, even after an exception
}

C++: deterministic destruction and RAII

C++ takes a different approach. An object can live in automatic storage, on the stack, in which case it is destroyed exactly when it goes out of scope, or on the heap, created with new. Every class can declare a destructor, written ~ClassName(), that runs at the moment of destruction. C++ programmers use this to tie every resource to an object's lifetime, an idiom called Resource Acquisition Is Initialization, RAII: the constructor acquires the resource and the destructor releases it, so leaving a scope, by any path, releases everything in reverse order. Heap objects are managed with smart pointers: std::unique_ptr owns an object alone and deletes it when the pointer is destroyed, and std::shared_ptr counts its owners and deletes the object when the last one goes away. Raw new and delete are rarely needed in modern C++.

In C++, destructors run deterministically at the end of scope, so resources are released without explicit close calls.
// C++ (the NS-5 gateway firmware): RAII closes the serial port on every exit path
class SerialPort {
public:
    explicit SerialPort(const char* dev) : fd_(::open(dev, O_RDWR)) {
        if (fd_ < 0) throw std::runtime_error("cannot open port");
    }
    ~SerialPort() { if (fd_ >= 0) ::close(fd_); }     // destructor releases the resource
    SerialPort(const SerialPort&) = delete;            // no copies: one owner per port
    SerialPort& operator=(const SerialPort&) = delete;
    int fd() const { return fd_; }
private:
    int fd_;
};

void poll_sensor() {
    SerialPort port("/dev/ttyS1");                     // acquired here
    auto buf = std::make_unique<std::array<uint8_t, 512>>();
    read_frame(port.fd(), *buf);                       // may throw
}                                                      // buf, then port, destroyed here in reverse order

Because C++ objects can be copied, a class that owns a resource must decide what copying means. The rule of three says that a class that needs a custom destructor almost certainly needs a custom copy constructor and copy assignment operator too; C++11 extends this to the rule of five by adding the move constructor and move assignment. The modern advice, the rule of zero, is to let members such as std::unique_ptr, std::vector and std::string manage resources so that your own class needs none of the five. SerialPort above simply deletes copying, so each port has exactly one owner.

Python: reference counting plus a collector

CPython, the standard Python interpreter, keeps a reference count in every object and frees the object the instant the count reaches zero, which makes most deallocation prompt and predictable. Reference counting cannot free cycles, such as two objects that refer to each other, so a separate cycle collector runs periodically. A __del__ method exists but, like Java's finalize, should not be relied on to release resources; use with statements instead. The example below makes object lifetimes visible with weak references, which refer to an object without keeping it alive.

import gc, weakref

class Session:
    def __init__(self, key):
        self.key = key
        self.buffer = bytearray(3072)

def report(ref, label):
    print(f"{label:<34} alive: {ref() is not None}")

# 1. A normal object dies as soon as the last reference goes away
s = Session("a1")
r = weakref.ref(s)
report(r, "after creation")
alias = s                    # aliasing: two references, one object
del s
report(r, "after del s (alias still held)")
del alias
report(r, "after del alias")

# 2. A forgotten entry in a long-lived dictionary keeps the object reachable: a leak
session_cache = {}
s = Session("b2"); r = weakref.ref(s)
session_cache[s.key] = s
del s
report(r, "cached, local name deleted")
session_cache.pop("b2")      # the fix: remove the entry when the session ends
report(r, "after removing from cache")

# 3. A reference cycle needs the cycle collector
a, b = Session("c3"), Session("d4")
a.peer, b.peer = b, a
ra = weakref.ref(a)
del a, b
report(ra, "cycle, names deleted")
gc.collect()
report(ra, "after gc.collect()")
Output
after creation                     alive: True
after del s (alias still held)     alive: True
after del alias                    alive: False
cached, local name deleted         alive: True
after removing from cache          alive: False
cycle, names deleted               alive: True
after gc.collect()                 alive: False
Object lifetime in the three languages of this course.
QuestionJavaPython (CPython)C++
Where do objects live?Always on the heapAlways on the heapStack (automatic) or heap
When is memory freed?When the collector finds it unreachableWhen the reference count reaches zero; cycles laterAt end of scope, or when the owning smart pointer is destroyed
Resource releasetry-with-resources, AutoCloseablewith statement, context managersDestructors (RAII)
Typical lifetime bugLeak through a forgotten referenceLeak through a forgotten reference or cycle with __del__Dangling pointer, double free, leak

Common mistakes

  • MistakeDoing heavy work such as opening connections inside a constructor.

    InsteadKeep constructors to establishing invariants; open resources in explicit methods or factories.

  • MistakeCalling an overridable method from a constructor.

    InsteadSubclass overrides would run before the subclass is initialized; call only private or final methods.

  • MistakeBelieving garbage collection prevents all memory leaks.

    InsteadObjects that stay reachable are never collected; remove entries from long-lived collections and bound caches.

  • MistakeRelying on finalize or __del__ to close files and sockets.

    InsteadUse try-with-resources in Java, with in Python and destructors in C++ so release is prompt and certain.

  • MistakeOverriding equals without hashCode.

    InsteadEqual objects must have equal hash codes, or hash-based collections will lose them.

Summary and key terms

  • Constructors establish a new object's invariants; overloads should delegate to one complete constructor, and static factories add names and flexibility.
  • Java variables of class type hold references to heap objects; assignment copies references and creates aliases.
  • Identity (==, is) differs from equality (equals, __eq__); value classes override equals and hashCode together.
  • Garbage collectors reclaim unreachable objects; leaks in managed languages are objects kept reachable by mistake.
  • Release non-memory resources deterministically: try-with-resources in Java, with in Python, RAII destructors and smart pointers in C++.
Constructor
Code that runs when an object is created to put it into a valid initial state.
Static factory method
A static method that creates and returns an object, often with a descriptive name.
Reference
A value that refers to an object stored elsewhere, usually on the heap.
Aliasing
Two or more references referring to the same object.
Garbage collector
A runtime component that reclaims memory from objects no longer reachable.
RAII
Resource Acquisition Is Initialization: tying a resource to an object's lifetime so the destructor releases it.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. A class declares Node(int id, Set<Integer> lanes). Can you still write new Node()?

    Show answer

    No. Declaring any constructor removes the default no-argument constructor.

  2. Detection d = new Detection(...); Detection e = d; How many Detection objects exist?

    Show answer

    One; d and e are two references to the same object.

  3. Why must a class that overrides equals also override hashCode?

    Show answer

    Hash collections look in the bucket chosen by the hash code; equal objects with different hash codes would be looked for in the wrong bucket.

  4. Give two common causes of memory leaks in Java.

    Show answer

    Unbounded caches or static maps that only grow, and listeners registered but never removed.

  5. In C++, in what order are buf and port destroyed at the end of poll_sensor?

    Show answer

    In reverse order of construction: buf first, then port.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz