Topic 4.12
Object Lifetime, References and null
In one line
An object lives as long as some chain of references from the running program can reach it; after that the garbage collector may reclaim it, at a time you don't control. null means "no object", and using it as if it were one throws NullPointerException.
Think of it like this
Helium balloons at a fair. Each balloon is held by a string, and a child holds the string. As long as someone holds a string, the balloon stays. Let go of every string, and the balloon floats away and a cleaner eventually collects it. In Java, objects are the balloons, references are the strings, and the garbage collector is the cleaner. An empty hand holding no string is null.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Lifetime
- The time from when an object is created until it can no longer be used and its memory can be reclaimed.
- Garbage collector (GC)
- The part of the JVM that finds objects nobody can reach any more and reuses their memory automatically.
- GC root
- A starting point the garbage collector trusts is alive: local variables in running methods, static fields, live threads.
- Reachable
- An object is reachable if you can get to it by following references from a GC root. Reachable objects are kept.
- Eligible for GC
- No longer reachable, so the garbage collector is allowed to reclaim it whenever it chooses.
- `null`
- A reference value that points to no object at all.
- `NullPointerException` (NPE)
- The exception thrown when code tries to use
nullas if it were an object. - Memory leak (in Java)
- Objects that stay reachable, so they can't be collected, even though the program will never use them again.
Step by step
01Birth: new puts an object on the heap
Every object starts with new (or something that calls it, like a factory method or a literal such as "hi"). The reference you get back is the only way to reach it.
Objects can outlive the method that created them. A factory method creates an object in its own frame and returns the reference; the frame is popped, but the object lives on because the caller now holds a reference.
static Dog adopt(String name) {
Dog d = new Dog(name); // d is a local: dies when adopt returns
return d; // but the Dog object survives: the caller holds it
}02Life: reachable from a root
The GC starts from the roots and follows every reference, marking what it can reach. Everything marked stays. Everything else is garbage.
In the picture, main's local team reaches the Team, which reaches two players. The old Player that nothing points to any more is unreachable, and so is the cycle of two nodes pointing only at each other.
03Death: unreachable, then collected later
An object becomes unreachable when the last reference to it goes away: a variable is reassigned (d = new Dog("Max")), set to null, or goes out of scope when its method returns, or the object holding the reference becomes unreachable itself.
Being unreachable doesn't mean it's freed now. The GC runs when it decides to, usually when an allocation area fills up. System.gc() is only a hint and can be disabled (-XX:+DisableExplicitGC). Programs must never depend on when collection happens.
That's why Java has no destructors. To release non-memory resources (files, sockets, database connections) you close them explicitly with try-with-resources (Phase 8), not when the object dies. finalize() was deprecated in Java 9 and deprecated for removal in Java 18 (JEP 421).
04null: a reference to nothing
Dog d = null; means d exists but refers to no object. Fields and array elements of reference type start as null. Local variables don't start as anything: reading one before assigning is a compile error (variable d might not have been initialized).
Using null as an object fails at run time. These all throw NullPointerException: calling a method on it, reading or writing a field, getting an array's length or element, unboxing a null Integer into an int, throw null, and synchronized (null).
Some things are fine with null: printing it or concatenating it ("x" + null is "xnull"), comparing with ==, instanceof (always false), and passing it as an argument.
05Read a helpful NullPointerException
Since Java 14 (enabled by default in 15), the JVM analyses the bytecode that failed and explains which part was null: Cannot invoke "String.trim()" because the return value of "Main.find(String)" is null.
For local variables, the message shows their names only if the class was compiled with -g (debug info). Without it you see "<local1>". Field names, static names and method names always appear.
06Defend against null on purpose
Fail fast at the boundary: this.owner = Objects.requireNonNull(owner, "owner"); throws immediately, with a clear message, when the bad value arrives, instead of an NPE far away later.
Compare safely: "admin".equals(role) and Objects.equals(a, b) never throw. Use defaults: Objects.requireNonNullElse(name, "guest") (Java 9).
Don't create nulls: return List.of() instead of null for "no results", and Optional<T> for a single value that may be absent (Phase 11). Many teams also use @Nullable/@NonNull annotations with static analysis tools.
07Leaks: reachable but useless
A static map used as a cache is a GC root forever. If entries are added per request and never removed, memory grows until java.lang.OutOfMemoryError: Java heap space.
Other classics: event listeners registered and never unregistered, collections that hold sessions after logout, and ThreadLocal values in pooled threads. The fix is always to break the reference: bound the cache (with eviction), unregister listeners, call ThreadLocal.remove(). Heap dumps (jcmd <pid> GC.heap_dump) and tools like Eclipse MAT show which root keeps the objects alive (Phase 13).
Try it yourself
- 1
Predict each NPE message
In "Helpful NullPointerException messages", add a case
int[] scores = null; scores[0] = 1;inside a try/catch. Predict the message before running (hint: it mentions storing to an int array and"<local1>"or similar). - 2
Turn a late failure into an early one
Write a class
Accountwhose constructor stores anownerstring, and a methodownerInitial()returningowner.charAt(0). Createnew Account(null)and call the method: where does it fail? Then addObjects.requireNonNull(owner, "owner")to the constructor and compare. - 3
See an OutOfMemoryError locally
On your machine (not the browser), run a loop that adds 1 MB arrays to a static list forever, with a small heap. Watch it fail, then explain which root kept the arrays alive.
terminal$ java -Xmx64m Leak.java── expected output ──Exception in thread "main" java.lang.OutOfMemoryError: Java heap spaceat Leak.main(Leak.java:7)
Code & diagrams
Static fields and methods are named in the message. Local variables need javac -g to appear by name.
Expected output
1: Cannot invoke "String.length()" because "Main.name" is null
2: Cannot invoke "String.trim()" because the return value of "Main.find(String)" is null
3: Cannot read field "name" because "Main.owner.pet" is null
4: name must not be nullExpected output
concat: role=null
== null: true
instanceof: false
literal first equals: false
Objects.equals: true
default: guest
empty list instead of null: 0
unboxing null throws NullPointerExceptionThis is also the shape of the classic leak: a static collection that is only ever added to.
Expected output
registry: [Rex, Bella]
first dog still reachable: Rex
registry after clear: []Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Call a method on a value that might be null
Write static String find(String key) { return null; } and then find("x").trim(); without a check.
Break #2
Use a local variable before giving it a value
Write Dog d; d.bark();.
Myth vs fact
Myth
Setting a variable to null frees the object immediately.
Fact
It only removes one reference. The object becomes eligible for collection if no other references remain, and the GC frees it whenever it decides.
Myth
System.gc() forces garbage collection.
Fact
It's a request the JVM may ignore (and -XX:+DisableExplicitGC turns it off). Correct programs never depend on it.
Myth
Java can't have memory leaks because it has a garbage collector.
Fact
The GC frees only unreachable objects. Anything still referenced, like entries in a static map nobody cleans, stays forever.
Myth
Objects that reference each other in a cycle are never collected.
Fact
That's a problem for reference counting. Java's tracing collectors start from roots, so an unreachable cycle is collected like any other garbage.
When it breaks
A static HashMap used as a cache grows on every request
What you see
Heap usage climbs steadily over days, GC runs more and more often, latency rises, and finally the service dies with java.lang.OutOfMemoryError: Java heap space. Restarting "fixes" it until it happens again.
Fix & prevent
Take a heap dump (jcmd <pid> GC.heap_dump file.hprof) and find the dominant map and its GC root. Replace it with a bounded cache with eviction and expiry (Caffeine, or LinkedHashMap with removeEldestEntry). Alert on old-generation usage after GC.
NullPointerException deep inside a service from a missing config value
What you see
Requests fail with a 500 and an NPE stack trace far from where the null entered (for example a missing environment variable read at startup and used much later).
Fix & prevent
Validate configuration at startup with Objects.requireNonNull and clear messages, so the process fails fast before taking traffic. Use helpful NPE messages (on by default since Java 15) to find the exact null expression.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Reachability has strengths: strong (normal references), soft (
SoftReference, cleared under memory pressure), weak (WeakReference, cleared at the next GC once only weakly reachable; used byWeakHashMap), and phantom (PhantomReference, for post-mortem cleanup; whatjava.lang.ref.Cleaner, Java 9, uses as the replacement forfinalize). - ▸
The JIT can treat a local variable as dead after its last use, even if it's still in scope, so an object can become unreachable while its method is still running. That's why
Reference.reachabilityFence(obj)(Java 9) exists for code that relies on an object staying alive (for example around native resources). - ▸
Most objects die young (the generational hypothesis), so collectors like G1 (default since Java 9) and generational ZGC (Java 21) collect the young generation often and cheaply. Allocation-heavy code is usually fine; long-lived caches and leaks are what hurt. Phase 13 covers GC tuning.
- ▸
Helpful NPE messages are computed lazily (only when
getMessage()is called) from the bytecode, so they cost nothing on the normal path. Turn them off with-XX:-ShowCodeDetailsInExceptionMessagesif messages could reveal sensitive code details in logs.
Remember this
- 1
An object is born with
newand lives on the heap. It stays alive as long as it's reachable: there is a path of references to it starting from a GC root. Roots include local variables and parameters in active stack frames,staticfields of loaded classes, live threads, and references held by native code. - 2
When no path from any root reaches an object, it's unreachable and becomes eligible for garbage collection. The JVM decides when to collect; it might be milliseconds later, much later, or never if the program ends first. You can't free an object yourself, and there are no destructors in Java.
- 3
Java uses tracing collection, not reference counting. Objects that point to each other in a cycle (A points to B, B points to A) are still collected once nothing outside the cycle reaches them. Setting fields to
nullto "help the GC" is almost never needed; letting variables go out of scope does the job. - 4
nullis a special reference value meaning "refers to no object". It's the default for reference fields and array elements. Calling a method, reading a field, indexing an array, or unboxing throughnullthrowsNullPointerException. Since Java 14 (default on since 15), the message says exactly which expression was null (JEP 358, helpful NullPointerExceptions). - 5
Handle null deliberately: validate inputs with
Objects.requireNonNull(x, "x")so the failure happens early and with a clear message; useObjects.equals(a, b)and"literal".equals(x)for null-safe comparisons; return empty collections instead ofnull; and for "maybe a value" return types, useOptional(Phase 11). - 6
Java can still leak memory: a leak here is an object that's reachable but never used again, such as entries piling up in a
staticmap used as a cache, listeners that are registered but never removed, or aThreadLocalin a thread pool. The GC can't free what you still reference.
Explain it without notes
When does an object become eligible for garbage collection? What are GC roots?
Why doesn't Java have destructors, and how do you release resources like files?
List the operations that throw NullPointerException and three that are safe with null.
How can a garbage-collected Java program still leak memory? Give two examples and fixes.
Practice
Write a class Profile whose constructor requires a non-null email (with Objects.requireNonNull and a message) and allows a null nickname, with a method displayName() that returns the nickname or, if it's null, the part of the email before @. Show both cases and a rejected null email.
Write a method static int safeLength(String s) that returns 0 for null, and print it for null, "" and "java".
Create a tiny cycle: two Node objects whose next fields point at each other, print a.next.next == a, then set both local variables to null. Write in a comment whether the cycle can be collected and why.
Trade-offs
- ↔
Automatic garbage collection removes whole classes of bugs (double free, dangling pointers) at the cost of some CPU, memory headroom and, with some collectors, pauses. Modern collectors (G1, ZGC, Shenandoah) keep pauses short; you trade a little throughput for that.
- ↔
Returning
nullfor "absent" is cheap and familiar but invites NPEs far from the source.Optional, empty collections and fail-fast checks cost a little ceremony and make absence explicit. - ↔
Caching objects in long-lived structures speeds repeated work but extends their lifetime, possibly forever. Every cache needs a size limit or expiry, or it's a leak waiting to happen.
Done when you can
Done when you can explain reachability, GC roots and "eligible for collection".
Done when you know why Java has no destructors and how resources are released instead.
Done when you can read a helpful NPE message and find the null expression.
Done when you use
requireNonNull, null-safe comparisons and empty results instead of returning null.Done when you can describe two ways Java programs leak memory and how to fix them.